feat(server): add an embedded ACME directory server and certificate authority with challenge, order, and certificate endpoints
This commit is contained in:
116
ts_server/server.classes.router.ts
Normal file
116
ts_server/server.classes.router.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type * as http from 'node:http';
|
||||
import type { TRouteHandler } from './server.interfaces.js';
|
||||
import type { NonceManager } from './server.classes.nonce.js';
|
||||
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
||||
|
||||
interface IRoute {
|
||||
method: string;
|
||||
pattern: string;
|
||||
segments: string[];
|
||||
handler: TRouteHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal HTTP router for the ACME server.
|
||||
* Supports parameterized paths like /order/:id.
|
||||
*/
|
||||
export class AcmeRouter {
|
||||
private routes: IRoute[] = [];
|
||||
private nonceManager: NonceManager;
|
||||
|
||||
constructor(nonceManager: NonceManager) {
|
||||
this.nonceManager = nonceManager;
|
||||
}
|
||||
|
||||
addRoute(method: string, pattern: string, handler: TRouteHandler): void {
|
||||
this.routes.push({
|
||||
method: method.toUpperCase(),
|
||||
pattern,
|
||||
segments: pattern.split('/').filter(Boolean),
|
||||
handler,
|
||||
});
|
||||
}
|
||||
|
||||
async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
const method = (req.method || 'GET').toUpperCase();
|
||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||
|
||||
// Always add a fresh nonce to every response
|
||||
res.setHeader('Replay-Nonce', this.nonceManager.generate());
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
// Find matching route
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== method) continue;
|
||||
const params = this.matchPath(route.segments, pathSegments);
|
||||
if (params === null) continue;
|
||||
|
||||
try {
|
||||
const body = method === 'POST' ? await this.parseBody(req) : undefined;
|
||||
await route.handler(req, res, params, body);
|
||||
} catch (err) {
|
||||
this.sendError(res, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No route found
|
||||
this.sendError(res, new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Not found'));
|
||||
}
|
||||
|
||||
private matchPath(
|
||||
routeSegments: string[],
|
||||
pathSegments: string[],
|
||||
): Record<string, string> | null {
|
||||
if (routeSegments.length !== pathSegments.length) return null;
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < routeSegments.length; i++) {
|
||||
if (routeSegments[i].startsWith(':')) {
|
||||
params[routeSegments[i].slice(1)] = pathSegments[i];
|
||||
} else if (routeSegments[i] !== pathSegments[i]) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
private parseBody(req: http.IncomingMessage): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
if (!raw) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
reject(new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JSON body'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private sendError(res: http.ServerResponse, err: unknown): void {
|
||||
if (err instanceof AcmeServerError) {
|
||||
res.writeHead(err.status, { 'Content-Type': 'application/problem+json' });
|
||||
res.end(JSON.stringify({
|
||||
type: err.type,
|
||||
detail: err.detail,
|
||||
status: err.status,
|
||||
}));
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : 'Internal server error';
|
||||
res.writeHead(500, { 'Content-Type': 'application/problem+json' });
|
||||
res.end(JSON.stringify({
|
||||
type: 'urn:ietf:params:acme:error:serverInternal',
|
||||
detail: message,
|
||||
status: 500,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user