feat: add initial API
This commit is contained in:
@@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
45
src/api/endpoints/endpoints.manage.ts
Normal file
45
src/api/endpoints/endpoints.manage.ts
Normal 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 };
|
||||
62
src/api/endpoints/endpoints.message.ts
Normal file
62
src/api/endpoints/endpoints.message.ts
Normal 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
14
src/api/extensions.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
27
src/api/plugins/plugins.auth.ts
Normal file
27
src/api/plugins/plugins.auth.ts
Normal 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 };
|
||||
@@ -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
25
src/auth/auth.consts.ts
Normal 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 };
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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
9
src/dev.ts
Normal 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');
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user