feat: Phase 4 – Modul-Gateway mit dynamischem Routing und Startup-Recovery
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
Zentrale, webbasierte Management-Plattform, über die eigenständige Web-Applikationen als **Module** integriert, verwaltet und Benutzern zugewiesen werden können.
|
||||
|
||||
**Status: Phase 3 – Modul-System (abgeschlossen)**
|
||||
**Status: Phase 4 – Gateway & dynamisches Routing (abgeschlossen)**
|
||||
|
||||
## Architektur-Überblick
|
||||
|
||||
@@ -100,6 +100,9 @@ Vollständige Benutzer-CRUD-API (nur Admin) mit Duplikat-Schutz, Schutz des letz
|
||||
### Phase 3 – Modul-System
|
||||
Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installation mit Zip-Slip-Schutz, Prozess-Manager (Kindprozesse mit minimaler ENV, eigene Logs), Lifecycle (INSTALLED/STARTING/RUNNING/STOPPED/ERROR/DISABLED), Healthchecks mit Startup-Grace, Modulverwaltungs-UI und persistente Volumes für Modul-Dateien.
|
||||
|
||||
### Phase 4 – Gateway & Routing
|
||||
Dynamisches Routing `/slug` über Nginx → Modul-Gateway (Middleware): Session-Check, Modul-Status-Check, Permission-Check (fail-closed), Proxy zu internen Ports. Sichere Identitätsübergabe über Header, Startup-Recovery mit Autostart nach Container-Neustarts.
|
||||
|
||||
## Annahme
|
||||
|
||||
„ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`).
|
||||
58
apps/platform-backend/package-lock.json
generated
58
apps/platform-backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
128
apps/platform-backend/src/modules/module-gateway.middleware.ts
Normal file
128
apps/platform-backend/src/modules/module-gateway.middleware.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
55
apps/platform-backend/src/modules/module-startup-recovery.ts
Normal file
55
apps/platform-backend/src/modules/module-startup-recovery.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,22 @@ http {
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Modul-Routing (Phase 4): /slug/* wird intern an den
|
||||
# Modul-Gateway des Backends übergeben (/api/v1/gateway/slug/*).
|
||||
# Der Gateway prüft Session, Modul-Status und Berechtigung,
|
||||
# bevor der Request an den Modul-Prozess proxied wird.
|
||||
# WICHTIG: Plattform-Pfade (api, assets, login, …) sind ausgeschlossen,
|
||||
# damit nur echte Modul-Slugs (3–100 Zeichen) weitergeleitet werden.
|
||||
location ~ "^/(?!api/|assets/|login|profile|admin|403|404)(?<module_slug>[a-z0-9][a-z0-9-]{2,100})(?<module_path>/.*)?$" {
|
||||
proxy_pass http://platform_backend/api/v1/gateway/$module_slug$module_path;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# SPA-Fallback für React Router
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -7,7 +7,7 @@ Der Arbeitsplan sieht zehn inkrementelle Phasen vor. Nach jeder Phase muss das S
|
||||
| 1 | Grundgerüst (Docker, DB, Login, Rollen) | ✅ Abgeschlossen |
|
||||
| 2 | Benutzerverwaltung | ✅ Abgeschlossen |
|
||||
| 3 | Modul-System (Manifest, Installation, Lifecycle) | ✅ Abgeschlossen |
|
||||
| 4 | Gateway & dynamisches Routing (`/slug`) | ⏳ Geplant |
|
||||
| 4 | Gateway & dynamisches Routing (`/slug`) | ✅ Abgeschlossen |
|
||||
| 5 | Berechtigungssystem (User ↔ Module) | ⏳ Geplant |
|
||||
| 6 | Modul-API (`/health`, `/api/manifest`, `/api/me`) | ⏳ Geplant |
|
||||
| 7 | Referenzmodul Kalender | ⏳ Geplant |
|
||||
@@ -69,8 +69,21 @@ Definition of Done: Modul-Datenmodell, Manifest, Installation, Registrierung, St
|
||||
- [x] Backend-Tests: 72 bestanden (inkl. ModulesService-Lifecycle, Manifest-Validierung, Installer)
|
||||
- [x] E2E verifiziert: Installation (201), Start → RUNNING, Healthcheck (healthy, 2 ms), Stop, Restart, Disable (stoppt Prozess), Start-Sperre bei Disable (400), Remove, RBAC (USER → 403)
|
||||
|
||||
## Nächste Schritte (Phase 4 – Gateway & Routing)
|
||||
## Phase 4 – Gateway & dynamisches Routing (abgeschlossen)
|
||||
|
||||
- Dynamisches Routing: `/slug` → Modul-Prozess (Nginx-Konfiguration zur Laufzeit)
|
||||
- Zugriffskontrolle beim Routing (Session → Permission Check → 403/Proxy)
|
||||
- Modul-Gateway mit sicherer Identitätsübergabe an das Modul
|
||||
Definition of Done: Module sind über `/slug` erreichbar, Zugriff nur mit gültiger Session und Berechtigung.
|
||||
|
||||
- [x] Nginx-Routing: `/slug/*` → Backend-Gateway (`/api/v1/gateway/slug/*`) mit Ausschluss-Regex für Plattform-Pfade (api, assets, login, …)
|
||||
- [x] Modul-Gateway als Middleware: Session-Check (401), Slug-Suche (404), Status-Check (503), Permission-Check (403), Proxy zum internen Port
|
||||
- [x] Sichere Identitätsübergabe über interne Header (`x-user-id`, `x-user-username`, `x-user-display-name`, `x-user-role`); Session-Cookie wird nie an Module weitergereicht
|
||||
- [x] Fail-closed: USERs erhalten bis Phase 5 grundsätzlich 403
|
||||
- [x] Startup-Recovery: Nach Container-Neustarts werden veraltete Status zurückgesetzt und aktivierte Module automatisch neu gestartet
|
||||
- [x] Backend-Tests: 81 bestanden (inkl. 9 Gateway-Tests)
|
||||
- [x] E2E verifiziert: `/demo` → 200 (Modul-Inhalt), `/demo/health` → 200, unbekanntes Modul → 404, ohne Login → 401, USER → 403, gestopptes Modul → 503
|
||||
|
||||
## Nächste Schritte (Phase 5 – Berechtigungssystem)
|
||||
|
||||
- `user_module_permissions`-Tabelle (GRANTED/DENIED)
|
||||
- Admin-API: Benutzer ↔ Module zuweisen (`POST /api/v1/users/:id/modules/:moduleId`)
|
||||
- Gateway-Permission-Check an die Tabelle anbinden (USER mit GRANTED → Zugriff)
|
||||
- Dashboard: Modul-Kacheln anhand der tatsächlichen Berechtigungen
|
||||
Reference in New Issue
Block a user