feat: add initial API

This commit is contained in:
Morten Olsen
2025-10-16 20:54:31 +02:00
parent 5cf0a3612a
commit 11828da073
17 changed files with 647 additions and 89 deletions

View File

@@ -1,8 +1,34 @@
import { type FastifyPluginAsync } from 'fastify';
import { manageEndpoints } from './endpoints/endpoints.manage.ts';
import { authPlugin } from './plugins/plugins.auth.ts';
import { messageEndpoints } from './endpoints/endpoints.message.ts';
import { z } from 'zod';
const api: FastifyPluginAsync = async (fastify) => {
fastify.get('/healthz', () => {
return { status: 'ok' };
fastify.route({
method: 'get',
url: '/health',
schema: {
operationId: 'health.get',
summary: 'Get health status',
tags: ['system'],
response: {
200: z.object({
status: z.literal('ok'),
}),
},
},
handler: () => {
return { status: 'ok' };
},
});
await authPlugin(fastify, {});
await fastify.register(manageEndpoints, {
prefix: '/manage',
});
await fastify.register(messageEndpoints, {
prefix: '/message',
});
};

View File

@@ -0,0 +1,45 @@
import { JwtAuth } from '#root/auth/auth.jwt.ts';
import { statementSchema } from '#root/auth/auth.schemas.ts';
import { Config } from '#root/config/config.ts';
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod';
const manageEndpoints: FastifyPluginAsyncZod = async (fastify) => {
const config = fastify.services.get(Config);
if (config.jwtSecret) {
fastify.route({
method: 'post',
url: '/jwt',
schema: {
operationId: 'manage.jwt.post',
summary: 'Generate a JWT',
tags: ['manage'],
body: z.object({
exp: z.number().optional(),
statements: z.array(statementSchema),
}),
response: {
200: z.object({
jwt: z.string(),
}),
},
},
handler: async (req, reply) => {
if (
!req.session.validate({
action: 'mgmt:generate-jwt',
resource: 'mgmt/',
})
) {
throw reply.unauthorized('not allowed');
}
const jwtAuth = fastify.services.get(JwtAuth);
const jwt = jwtAuth.generate(req.body);
reply.send({ jwt });
},
});
}
};
export { manageEndpoints };

View File

@@ -0,0 +1,62 @@
import { Config } from '#root/config/config.ts';
import { MqttServer } from '#root/server/server.ts';
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod';
const messageEndpoints: FastifyPluginAsyncZod = async (fastify) => {
const config = fastify.services.get(Config);
if (config.jwtSecret) {
fastify.route({
method: 'post',
url: '',
schema: {
summary: 'Post a message to the bus',
operationId: 'message.post',
tags: ['message'],
body: z.object({
topic: z.string(),
dup: z.boolean(),
qos: z.union([z.literal(0), z.literal(1), z.literal(2)]),
retain: z.boolean(),
payload: z.string(),
}),
response: {
200: z.object({
success: z.literal(true),
}),
},
},
handler: async (req, reply) => {
if (
!req.session.validate({
action: 'mqtt:publish',
resource: 'mgmt:',
})
) {
throw reply.unauthorized('not allowed');
}
const server = fastify.services.get(MqttServer);
await new Promise<void>((resolve, reject) => {
server.bus.publish(
{
...req.body,
cmd: 'publish',
payload: Buffer.from(req.body.payload, 'base64'),
},
(err) => {
if (err) {
return reject(err);
}
resolve();
},
);
});
reply.send({ success: true });
},
});
}
};
export { messageEndpoints };

14
src/api/extensions.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
import type { Session } from '#root/services/sessions/sessions.session.ts';
import type { Services } from '#root/utils/services.ts';
import 'fastify';
declare module 'fastify' {
// eslint-disable-next-line
export interface FastifyInstance {
services: Services;
}
// eslint-disable-next-line
export interface FastifyRequest {
session: Session;
}
}

View File

@@ -0,0 +1,27 @@
import { SessionProvider } from '#root/services/sessions/sessions.provider.ts';
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
const authPlugin: FastifyPluginAsyncZod = async (fastify) => {
fastify.addHook('onRequest', async (req, reply) => {
const authProvider = req.headers['x-auth-provider'];
if (!authProvider || Array.isArray(authProvider)) {
throw reply.unauthorized('missing x-auth-provider header');
}
const authorization = req.headers.authorization;
if (!authorization) {
throw reply.unauthorized('missing authorization header');
}
const [type, token] = authorization.split(' ');
if (type.toLowerCase() !== 'bearer') {
throw reply.unauthorized('only bearer tokens are allowed');
}
if (!token) {
throw reply.unauthorized('missing token');
}
const sessionProvider = fastify.services.get(SessionProvider);
const session = await sessionProvider.get(authProvider, token);
req.session = session;
});
};
export { authPlugin };

View File

@@ -1,15 +1,7 @@
import type { Services } from '#root/utils/services.ts';
import { Config } from '#root/config/config.ts';
import type { Statement } from './auth.schemas.ts';
import type { AuthProvider } from './auth.provider.ts';
const adminStatements: Statement[] = [
{
effect: 'allow',
resources: ['**'],
actions: ['**'],
},
];
import { ADMIN_STATEMENTS } from './auth.consts.ts';
class AdminAuth implements AuthProvider {
#services: Services;
@@ -24,7 +16,7 @@ class AdminAuth implements AuthProvider {
throw new Error('Invalid admin token');
}
return {
statements: adminStatements,
statements: ADMIN_STATEMENTS,
};
};
}

25
src/auth/auth.consts.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { Statement } from './auth.schemas.ts';
const ADMIN_STATEMENTS: Statement[] = [
{
effect: 'allow',
resources: ['**'],
actions: ['**'],
},
];
const WRITER_STATEMENTS: Statement[] = [
{
effect: 'allow',
resources: ['**'],
actions: ['mqtt:**'],
},
];
const READER_STATEMENTS: Statement[] = [
{
effect: 'allow',
resources: ['**'],
actions: ['mqtt:read', 'mqtt:subscribe'],
},
];
export { ADMIN_STATEMENTS, WRITER_STATEMENTS, READER_STATEMENTS };

View File

@@ -8,6 +8,7 @@ import type { Services } from '#root/utils/services.ts';
import { Config } from '#root/config/config.ts';
const tokenBodySchema = z.object({
exp: z.number().optional(),
statements: z.array(statementSchema),
});
@@ -32,11 +33,11 @@ class JwtAuth implements AuthProvider {
public getAccess = async (token: string) => {
const config = this.#services.get(Config);
const { jwtSecret: tokenSecret } = config;
if (!tokenSecret) {
const { jwtSecret } = config;
if (!jwtSecret) {
throw new Error('Token secret does not exist');
}
const data = jwt.verify(token, tokenSecret);
const data = jwt.verify(token, jwtSecret);
const parsed = tokenBodySchema.parse(data);
return parsed;
};

View File

@@ -5,28 +5,7 @@ import type { AuthProvider } from './auth.provider.ts';
import type { Services } from '#root/utils/services.ts';
import { Config } from '#root/config/config.ts';
const adminStatements: Statement[] = [
{
effect: 'allow',
resources: ['**'],
actions: ['**'],
},
];
const writerStatements: Statement[] = [
{
effect: 'allow',
resources: ['**'],
actions: ['mqtt:**'],
},
];
const readerStatements: Statement[] = [
{
effect: 'allow',
resources: ['**'],
actions: ['mqtt:read', 'mqtt:subscribe'],
},
];
import { ADMIN_STATEMENTS, READER_STATEMENTS, WRITER_STATEMENTS } from './auth.consts.ts';
class OidcAuth implements AuthProvider {
#services: Services;
@@ -49,13 +28,13 @@ class OidcAuth implements AuthProvider {
const groups = data[config.oidc.groupField];
if (Array.isArray(groups)) {
if (config.oidc.groups.admin && groups.includes(config.oidc.groups.admin)) {
statements = adminStatements;
statements = ADMIN_STATEMENTS;
}
if (config.oidc.groups.writer && groups.includes(config.oidc.groups.writer)) {
statements = writerStatements;
statements = WRITER_STATEMENTS;
}
if (config.oidc.groups.reader && groups.includes(config.oidc.groups.reader)) {
statements = readerStatements;
statements = READER_STATEMENTS;
}
}
return {

View File

@@ -45,8 +45,7 @@ class Backbone {
await this.k8s.setup();
this.sessionProvider.register('k8s', this.#services.get(K8sAuth));
}
if (this.config.http.enabled) {
console.log('starting http');
if (this.config.ws.enabled || this.config.api.enabled) {
const http = await this.server.getHttpServer();
http.listen({ port: this.config.http.port, host: '0.0.0.0' });
}

View File

@@ -1,6 +1,6 @@
class Config {
public get jwtSecret() {
return process.env.TOKEN_SECRET;
return process.env.JWT_SECRET;
}
public get adminToken() {
@@ -38,14 +38,26 @@ class Config {
}
public get http() {
const enabled = (process.env.HTTP_ENABLED = 'true');
const port = process.env.HTTP_PORT ? parseInt(process.env.HTTP_PORT) : 8883;
return {
enabled,
port,
};
}
public get api() {
const enabled = process.env.API_ENABLED === 'true';
return {
enabled,
};
}
public get ws() {
const enabled = process.env.WS_ENABLED === 'true';
return {
enabled,
};
}
public get tcp() {
const enabled = (process.env.TCP_ENABLED = 'true');
const port = process.env.TCP_PORT ? parseInt(process.env.TCP_PORT) : 1883;

9
src/dev.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Backbone } from './backbone.ts';
process.env.JWT_SECRET = 'test';
process.env.ADMIN_TOKEN = 'admin';
process.env.API_ENABLED = 'true';
const backbone = new Backbone();
await backbone.start();
console.log('started');

View File

@@ -1,6 +1,15 @@
import tcp from 'node:net';
import type { IncomingMessage } from 'node:http';
import swagger from '@fastify/swagger';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import {
jsonSchemaTransform,
createJsonSchemaTransform,
serializerCompiler,
validatorCompiler,
} from 'fastify-type-provider-zod';
import scalar from '@scalar/fastify-api-reference';
import {
type AuthenticateHandler,
type AuthorizeForwardHandler,
@@ -19,6 +28,8 @@ import { TopicsHandler } from '#root/topics/topics.handler.ts';
import type { Services } from '#root/utils/services.ts';
import { Session } from '#root/services/sessions/sessions.session.ts';
import { SessionProvider } from '#root/services/sessions/sessions.provider.ts';
import fastifySensible from '@fastify/sensible';
import { Config } from '#root/config/config.ts';
type Aedes = ReturnType<typeof aedes.createBroker>;
@@ -52,6 +63,10 @@ class MqttServer {
});
}
public get bus() {
return this.#server;
}
#authenticate: AuthenticateHandler = async (client, username, password, callback) => {
try {
if (!username || !password) {
@@ -112,14 +127,51 @@ class MqttServer {
#setupHttpServer = async () => {
const http = fastify({});
await http.register(fastifyWebSocket);
http.get('/ws', { websocket: true }, (socket, req) => {
const stream = createWebSocketStream(socket);
this.#server.handle(stream, req as unknown as IncomingMessage);
});
await http.register(api, {
prefix: '/api',
});
const config = this.#services.get(Config);
if (config.api.enabled) {
http.decorate('services', this.#services);
http.setValidatorCompiler(validatorCompiler);
http.setSerializerCompiler(serializerCompiler);
await http.register(fastifyWebSocket);
await http.register(fastifySensible);
await http.register(swagger, {
openapi: {
info: {
title: 'Backbone',
version: '1.0.0',
},
components: {
securitySchemes: {
authProviderHeader: {
type: 'apiKey',
name: 'X-Auth-Provider',
in: 'header',
},
bearerAuth: {
type: 'http',
scheme: 'bearer',
},
},
},
security: [{ bearerAuth: [], authProviderHeader: [] }],
},
transform: jsonSchemaTransform,
});
await http.register(scalar, {
routePrefix: '/docs',
});
await http.register(api, {
prefix: '/api',
});
}
if (config.ws.enabled) {
http.get('/ws', { websocket: true }, (socket, req) => {
const stream = createWebSocketStream(socket);
this.#server.handle(stream, req as unknown as IncomingMessage);
});
}
await http.ready();
http.swagger();
return http;
};

View File

@@ -1,4 +1,5 @@
import type { AuthProvider } from '#root/auth/auth.provider.ts';
import { Session } from './sessions.session.ts';
class SessionProvider {
#handlers: Map<string, AuthProvider>;
@@ -7,6 +8,10 @@ class SessionProvider {
this.#handlers = new Map();
}
public get providers() {
return Array.from(this.#handlers.keys());
}
public register = (name: string, provider: AuthProvider) => {
this.#handlers.set(name, provider);
};
@@ -18,6 +23,15 @@ class SessionProvider {
}
return handler.getAccess(token);
};
public get = async (provider: string, token: string) => {
const handler = this.#handlers.get(provider);
if (!handler) {
throw new Error('Provider not available');
}
const access = await handler.getAccess(token);
return new Session(access);
};
}
export { SessionProvider };