feat: Phase 4 – Modul-Gateway mit dynamischem Routing und Startup-Recovery

This commit is contained in:
MPM Dev
2026-10-07 15:59:56 +02:00
parent 4bbca21fd6
commit 5d775c317a
9 changed files with 537 additions and 10 deletions

View File

@@ -18,6 +18,7 @@
"cookie-parser": "^1.4.7",
"express-rate-limit": "^7.5.0",
"helmet": "^8.0.0",
"http-proxy": "^1.18.1",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@@ -29,6 +30,7 @@
"@types/adm-zip": "^0.5.8",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0",
"@types/http-proxy": "^1.17.17",
"@types/jest": "^29.5.14",
"@types/multer": "^2.3.0",
"@types/node": "^24.0.0",
@@ -2612,6 +2614,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-proxy": {
"version": "1.17.17",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -4797,6 +4809,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -5106,6 +5124,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/fork-ts-checker-webpack-plugin": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz",
@@ -5573,6 +5611,20 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -7906,6 +7958,12 @@
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",

View File

@@ -24,6 +24,7 @@
"cookie-parser": "^1.4.7",
"express-rate-limit": "^7.5.0",
"helmet": "^8.0.0",
"http-proxy": "^1.18.1",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@@ -35,6 +36,7 @@
"@types/adm-zip": "^0.5.8",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0",
"@types/http-proxy": "^1.17.17",
"@types/jest": "^29.5.14",
"@types/multer": "^2.3.0",
"@types/node": "^24.0.0",

View File

@@ -0,0 +1,224 @@
import type { Request, Response, NextFunction } from 'express';
import { SessionService } from '../auth/session.service';
import type { SessionData } from '../auth/session.service';
import { UserRepository } from '../users/user.repository';
import type { UserRecord } from '../users/user.types';
import { ModuleRepository } from './module.repository';
import type { ModuleRecord } from './manifest.types';
import { ModuleGatewayMiddleware } from './module-gateway.middleware';
/** Erzeugt einen Modul-Datensatz für Tests. */
function createModuleRecord(overrides: Partial<ModuleRecord> = {}): ModuleRecord {
return {
id: 'module-1',
moduleId: 'demo',
name: 'Demo-Modul',
slug: 'demo',
version: '1.0.0',
description: '',
author: '',
path: '/data/modules/demo',
status: 'RUNNING',
internalPort: 41001,
healthcheckUrl: '/health',
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
/** Erzeugt einen Benutzer-Datensatz für Tests. */
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
return {
id: 'user-1',
username: 'max',
email: 'max@example.com',
passwordHash: 'not-a-real-hash',
displayName: 'Max Mustermann',
role: 'USER',
isActive: true,
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
/** Mock-Request für Gateway-Tests. */
function createRequest(url: string, cookie?: string): Request {
return {
url,
headers: cookie ? { cookie } : {},
} as unknown as Request;
}
/** Mock-Response, der Status und JSON-Antworten aufzeichnet. */
function createResponse(): Response & { sentStatus: number; sentBody: unknown } {
const response = {
sentStatus: 0,
sentBody: undefined as unknown,
status(code: number) {
this.sentStatus = code;
return this;
},
json(body: unknown) {
this.sentBody = body;
return this;
},
};
return response as Response & { sentStatus: number; sentBody: unknown };
}
/** Mock des SessionService. */
class MockSessionService {
public session: SessionData | null = null;
async findValid(): Promise<SessionData | null> {
return this.session;
}
}
/** Mock des UserRepository. */
class MockUserRepository {
public user: UserRecord | null = createUserRecord();
async findById(): Promise<UserRecord | null> {
return this.user;
}
}
/** Mock des ModuleRepository. */
class MockModuleRepository {
public module: ModuleRecord | null = createModuleRecord();
async findBySlug(): Promise<ModuleRecord | null> {
return this.module;
}
}
describe('ModuleGatewayMiddleware', () => {
let sessionService: MockSessionService;
let userRepository: MockUserRepository;
let moduleRepository: MockModuleRepository;
let middleware: ModuleGatewayMiddleware;
const nextCalls: NextFunction[] = [];
function makeNext(): NextFunction {
const next = jest.fn() as unknown as NextFunction;
nextCalls.push(next);
return next;
}
beforeEach(() => {
sessionService = new MockSessionService();
userRepository = new MockUserRepository();
moduleRepository = new MockModuleRepository();
middleware = new ModuleGatewayMiddleware(
moduleRepository as unknown as ModuleRepository,
sessionService as unknown as SessionService,
userRepository as unknown as UserRepository,
);
sessionService.session = {
id: 'session-1',
userId: 'user-1',
csrfToken: 'csrf-token',
expiresAt: new Date(Date.now() + 60_000),
};
});
it('reicht Nicht-Gateway-Pfade an next() weiter', async () => {
const request = createRequest('/api/v1/users');
const response = createResponse();
const next = makeNext();
await middleware.use(request, response, next);
expect(next).toHaveBeenCalled();
});
it('antwortet 401 ohne Session-Cookie', async () => {
const request = createRequest('/api/v1/gateway/demo/');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(401);
});
it('antwortet 401 bei ungültiger Session', async () => {
sessionService.session = null;
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=invalid');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(401);
});
it('antwortet 401 bei deaktiviertem Benutzer', async () => {
userRepository.user = createUserRecord({ isActive: false });
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(401);
});
it('antwortet 404 bei unbekanntem Modul-Slug', async () => {
moduleRepository.module = null;
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(404);
});
it('antwortet 503 bei gestopptem Modul', async () => {
moduleRepository.module = createModuleRecord({ status: 'STOPPED' });
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(503);
});
it('antwortet 503 bei deaktiviertem Modul', async () => {
moduleRepository.module = createModuleRecord({ enabled: false });
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(503);
});
it('antwortet 403 für USER ohne Berechtigung (fail-closed)', async () => {
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(403);
});
it('leitet ADMIN-Requests an den Modul-Proxy weiter', async () => {
userRepository.user = createUserRecord({ role: 'ADMIN' });
const request = createRequest('/api/v1/gateway/demo/health', 'mpm_session=valid');
const response = createResponse();
// proxy.web würde einen echten Request starten – hier nur prüfen,
// dass die Identitäts-Header gesetzt und das Cookie entfernt wurde.
const proxySpy = jest
.spyOn(middleware['proxy'], 'web')
.mockImplementation(() => undefined);
await middleware.use(request, response, makeNext());
expect(proxySpy).toHaveBeenCalled();
expect(request.headers['x-user-id']).toBe('user-1');
expect(request.headers['x-user-username']).toBe('max');
expect(request.headers['x-user-role']).toBe('ADMIN');
expect(request.headers.cookie).toBeUndefined();
expect(request.url).toBe('/health');
proxySpy.mockRestore();
});
});

View File

@@ -0,0 +1,128 @@
import { Injectable, type NestMiddleware } from '@nestjs/common';
import type { Request, Response, NextFunction } from 'express';
import httpProxy from 'http-proxy';
import { SessionService } from '../auth/session.service';
import { extractSessionToken } from '../auth/guards/session.guard';
import { UserRepository } from '../users/user.repository';
import { ModuleRepository } from './module.repository';
/** Gateway-Pfad-Präfix für interne Nginx-Weiterleitung. */
const GATEWAY_PREFIX = '/api/v1/gateway/';
/**
* Modul-Gateway (Phase 4): Dynamisches Routing /slug → Modul-Prozess.
*
* Sicherheitskritischer Request-Flow (Nginx leitet /slug intern auf
* /api/v1/gateway/slug/* um):
* 1. Session prüfen (401 ohne Login)
* 2. Modul anhand Slug suchen (404)
* 3. Modul muss RUNNING und enabled sein (503)
* 4. Permission-Check: ADMIN darf alles, USER nur freigegebene
* Module (403) – bis Phase 5 fail-closed für USERs
* 5. Proxy zum internen Port (nie öffentlich erreichbar)
*
* Das Modul selbst vertraut nie allein auf die URL – die Plattform
* identifiziert den Benutzer zentral und übergibt die Identität
* über interne Header (Session-Cookie wird nicht weitergereicht).
*/
@Injectable()
export class ModuleGatewayMiddleware implements NestMiddleware {
private readonly proxy: httpProxy;
constructor(
private readonly moduleRepository: ModuleRepository,
private readonly sessionService: SessionService,
private readonly userRepository: UserRepository,
) {
this.proxy = httpProxy.createProxyServer({
proxyTimeout: 30_000,
timeout: 30_000,
});
this.proxy.on('error', (error, _req, res) => {
console.error(`[ModuleGateway] Proxy-Fehler: ${error.message}`);
const response = res as Response;
if (!response.headersSent) {
response.status(502).json({
statusCode: 502,
message: 'Modul ist nicht erreichbar',
});
} else {
response.end();
}
});
}
async use(request: Request, response: Response, next: NextFunction): Promise<void> {
if (!request.url.startsWith(GATEWAY_PREFIX)) {
next();
return;
}
// Slug aus dem Gateway-Pfad extrahieren: /api/v1/gateway/<slug>/<rest>
const pathAfterPrefix = request.url.slice(GATEWAY_PREFIX.length);
const slashIndex = pathAfterPrefix.indexOf('/');
const slug = slashIndex === -1 ? pathAfterPrefix : pathAfterPrefix.slice(0, slashIndex);
const modulePath = slashIndex === -1 ? '/' : pathAfterPrefix.slice(slashIndex);
// 1. Authentifizierung: Session aus Cookie laden
const token = extractSessionToken(request);
if (!token) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return;
}
const session = await this.sessionService.findValid(token);
if (!session) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return;
}
const user = await this.userRepository.findById(session.userId);
if (!user || !user.isActive) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return;
}
// 2. Modul anhand Slug suchen
const module = await this.moduleRepository.findBySlug(slug);
if (!module) {
response.status(404).json({ statusCode: 404, message: 'Modul nicht gefunden' });
return;
}
// 3. Modul-Zustand prüfen
if (!module.enabled || module.status !== 'RUNNING') {
response.status(503).json({
statusCode: 503,
message: 'Modul ist nicht verfügbar',
});
return;
}
// 4. Permission-Check (Ebene 2 – Modul-Rechte)
// ADMIN: voller Zugriff. USER: nur mit GRANTED-Berechtigung
// (user_module_permissions folgt in Phase 5; bis dahin
// fail-closed – USERs erhalten keinen Zugriff).
if (user.role !== 'ADMIN') {
response.status(403).json({
statusCode: 403,
message: 'Keine Berechtigung für dieses Modul',
});
return;
}
// 5. Identität sicher an das Modul übergeben (Header, nicht URL)
request.headers['x-user-id'] = user.id;
request.headers['x-user-username'] = user.username;
request.headers['x-user-display-name'] = user.displayName;
request.headers['x-user-role'] = user.role;
// Session-Cookie niemals an das Modul weiterleiten
delete request.headers.cookie;
request.url = modulePath;
this.proxy.web(request, response, {
target: `http://127.0.0.1:${module.internalPort}`,
});
}
}

View File

@@ -0,0 +1,55 @@
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
import { ModuleProcessManager } from './module-process-manager';
import { ModuleRepository } from './module.repository';
/**
* Start-Wiederherstellung (Phase 4):
* Nach einem Container-Neustart sind alle Modul-Prozesse weg, während
* die Datenbank noch alte Status (z. B. RUNNING) enthält.
*
* Diese Komponente synchronisiert beim Backend-Start:
* - Module mit Status RUNNING/STARTING/STOPPING werden auf STOPPED
* zurückgesetzt (Prozesse existieren nach Neustart nicht mehr).
* - Aktivierte Module, die vorher liefen, werden automatisch neu
* gestartet (Autostart).
*/
@Injectable()
export class ModuleStartupRecovery implements OnApplicationBootstrap {
private readonly logger = new Logger('ModuleRecovery');
constructor(
private readonly moduleRepository: ModuleRepository,
private readonly processManager: ModuleProcessManager,
) {}
async onApplicationBootstrap(): Promise<void> {
await this.recover();
}
/** Setzt veraltete Status zurück und startet Module ggf. neu. */
async recover(): Promise<void> {
const staleStatuses = ['RUNNING', 'STARTING', 'STOPPING'] as const;
const staleModules = await this.moduleRepository.findByStatus([...staleStatuses]);
for (const module of staleModules) {
await this.moduleRepository.updateStatus(module.id, 'STOPPED');
this.logger.log(`Status von "${module.moduleId}" nach Neustart auf STOPPED zurückgesetzt`);
}
// Autostart: Aktivierte Module, die vor dem Neustart liefen, neu starten.
const restartable = staleModules.filter((module) => module.enabled);
for (const module of restartable) {
try {
await this.processManager.start(module);
await this.moduleRepository.updateStatus(module.id, 'RUNNING');
this.logger.log(`Modul "${module.moduleId}" automatisch neu gestartet`);
} catch (error) {
await this.moduleRepository.updateStatus(module.id, 'ERROR');
this.logger.error(
`Autostart von "${module.moduleId}" fehlgeschlagen`,
error instanceof Error ? error.stack : String(error),
);
}
}
}
}

View File

@@ -1,19 +1,47 @@
import { Module } from '@nestjs/common';
import {
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common';
import { ConfigModule } from '../config/config.module';
import { DatabaseModule } from '../database/database.module';
import { AuditModule } from '../audit/audit.module';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from '../users/password-hasher';
import { UserRepository } from '../users/user.repository';
import { ModuleHealthChecker } from './module-health-checker';
import { ModuleInstaller } from './module-installer';
import { ModuleProcessManager } from './module-process-manager';
import { ModuleGatewayMiddleware } from './module-gateway.middleware';
import { ModuleRepository } from './module.repository';
import { ModuleStartupRecovery } from './module-startup-recovery';
import { ModulesController } from './modules.controller';
import { ModulesService } from './modules.service';
/** Modul-System: Installation, Lifecycle, Prozessverwaltung, Healthchecks. */
/** Modul-System: Installation, Lifecycle, Prozessverwaltung, Gateway. */
@Module({
imports: [ConfigModule, DatabaseModule, AuditModule],
controllers: [ModulesController],
providers: [ModuleRepository, ModuleInstaller, ModuleProcessManager, ModuleHealthChecker, ModulesService],
providers: [
ModuleRepository,
ModuleInstaller,
ModuleProcessManager,
ModuleHealthChecker,
ModulesService,
SessionService,
UserRepository,
PasswordHasher,
ModuleGatewayMiddleware,
ModuleStartupRecovery,
],
exports: [ModuleRepository, ModulesService],
})
export class ModulesModule {}
export class ModulesModule implements NestModule {
/** Gateway-Middleware für alle /api/v1/gateway/* Pfade. */
configure(consumer: MiddlewareConsumer): void {
consumer
.apply(ModuleGatewayMiddleware)
.forRoutes({ path: '/api/v1/gateway/(.*)', method: RequestMethod.ALL });
}
}