diff --git a/Dockerfile b/Dockerfile index 5d5060a..ac15978 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,8 +37,8 @@ RUN apt-get update \ # Unprivilegierter Benutzer für alle Prozesse im Container RUN groupadd --gid 1001 app \ && useradd --uid 1001 --gid 1001 --create-home --shell /usr/sbin/nologin app \ - && mkdir -p /tmp/nginx/client_body /tmp/nginx/proxy /tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi /var/log/supervisor \ - && chown -R app:app /tmp/nginx /var/log/supervisor + && mkdir -p /tmp/nginx/client_body /tmp/nginx/proxy /tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi /var/log/supervisor /app/data/modules /app/data/logs \ + && chown -R app:app /tmp/nginx /var/log/supervisor /app/data COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf diff --git a/README.md b/README.md index 1dba2ad..7213882 100644 --- a/README.md +++ b/README.md @@ -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 2 – Benutzerverwaltung (abgeschlossen)** +**Status: Phase 3 – Modul-System (abgeschlossen)** ## Architektur-Überblick @@ -97,6 +97,9 @@ Login/Logout mit serverseitigen Sessions, Rollen (ADMIN/USER), Health-Monitoring ### Phase 2 – Benutzerverwaltung Vollständige Benutzer-CRUD-API (nur Admin) mit Duplikat-Schutz, Schutz des letzten Admins, sofortiger Session-Sperrung bei Deaktivierung, Passwort-Reset, eigenes Passwort ändern, Benutzerverwaltungs-UI (Tabelle, Modals, Toasts) und Profil-Seite. +### 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. + ## Annahme „ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`). \ No newline at end of file diff --git a/apps/platform-backend/package-lock.json b/apps/platform-backend/package-lock.json index dba4bd3..065676b 100644 --- a/apps/platform-backend/package-lock.json +++ b/apps/platform-backend/package-lock.json @@ -14,6 +14,7 @@ "@nestjs/platform-express": "^11.0.0", "@nestjs/swagger": "^11.0.0", "@node-rs/argon2": "^2.0.0", + "adm-zip": "^0.6.1", "cookie-parser": "^1.4.7", "express-rate-limit": "^7.5.0", "helmet": "^8.0.0", @@ -25,9 +26,11 @@ "devDependencies": { "@nestjs/cli": "^11.0.0", "@nestjs/testing": "^11.0.0", + "@types/adm-zip": "^0.5.8", "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/multer": "^2.3.0", "@types/node": "^24.0.0", "@types/pg": "^8.11.0", "@types/supertest": "^6.0.2", @@ -2445,6 +2448,16 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@types/adm-zip": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", + "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2651,6 +2664,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-i7STIm9V4K2MPH4ZYMtrZAwNxs3kglk2LgleaTuB9pUXgADBcQYQuYMd7+6xgTIxfoggIkFA/DkkvycdZpIARA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "24.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", @@ -3262,6 +3285,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz", + "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==", + "license": "MIT", + "engines": { + "node": ">=14.0" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", diff --git a/apps/platform-backend/package.json b/apps/platform-backend/package.json index 38bd253..1ef905e 100644 --- a/apps/platform-backend/package.json +++ b/apps/platform-backend/package.json @@ -20,6 +20,7 @@ "@nestjs/platform-express": "^11.0.0", "@nestjs/swagger": "^11.0.0", "@node-rs/argon2": "^2.0.0", + "adm-zip": "^0.6.1", "cookie-parser": "^1.4.7", "express-rate-limit": "^7.5.0", "helmet": "^8.0.0", @@ -31,9 +32,11 @@ "devDependencies": { "@nestjs/cli": "^11.0.0", "@nestjs/testing": "^11.0.0", + "@types/adm-zip": "^0.5.8", "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/multer": "^2.3.0", "@types/node": "^24.0.0", "@types/pg": "^8.11.0", "@types/supertest": "^6.0.2", @@ -45,4 +48,4 @@ "typescript": "^5.7.0", "typescript-eslint": "^8.0.0" } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/app.module.ts b/apps/platform-backend/src/app.module.ts index d4c6729..30944e6 100644 --- a/apps/platform-backend/src/app.module.ts +++ b/apps/platform-backend/src/app.module.ts @@ -7,6 +7,7 @@ import { AuditModule } from './audit/audit.module'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { HealthModule } from './health/health.module'; +import { ModulesModule } from './modules/modules.module'; import { SessionGuard } from './auth/guards/session.guard'; import { CsrfGuard } from './auth/guards/csrf.guard'; import { RolesGuard } from './common/guards/roles.guard'; @@ -16,7 +17,15 @@ import { RolesGuard } from './common/guards/roles.guard'; * Globale Guards: SessionGuard (Authentifizierung) → CsrfGuard → RolesGuard. */ @Module({ - imports: [ConfigModule, DatabaseModule, AuditModule, AuthModule, UsersModule, HealthModule], + imports: [ + ConfigModule, + DatabaseModule, + AuditModule, + AuthModule, + UsersModule, + HealthModule, + ModulesModule, + ], providers: [ MigrationRunner, { provide: APP_GUARD, useClass: SessionGuard }, diff --git a/apps/platform-backend/src/audit/audit.service.ts b/apps/platform-backend/src/audit/audit.service.ts index bdfe99e..5f1ce52 100644 --- a/apps/platform-backend/src/audit/audit.service.ts +++ b/apps/platform-backend/src/audit/audit.service.ts @@ -14,6 +14,13 @@ export const AUDIT_ACTIONS = { USER_DELETED: 'USER_DELETED', USER_PASSWORD_RESET: 'USER_PASSWORD_RESET', USER_PASSWORD_CHANGED: 'USER_PASSWORD_CHANGED', + MODULE_INSTALLED: 'MODULE_INSTALLED', + MODULE_REMOVED: 'MODULE_REMOVED', + MODULE_STARTED: 'MODULE_STARTED', + MODULE_STOPPED: 'MODULE_STOPPED', + MODULE_RESTARTED: 'MODULE_RESTARTED', + MODULE_ENABLED: 'MODULE_ENABLED', + MODULE_DISABLED: 'MODULE_DISABLED', } as const; export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS]; diff --git a/apps/platform-backend/src/auth/auth.service.spec.ts b/apps/platform-backend/src/auth/auth.service.spec.ts index 53f8b2b..f0ea5d2 100644 --- a/apps/platform-backend/src/auth/auth.service.spec.ts +++ b/apps/platform-backend/src/auth/auth.service.spec.ts @@ -25,6 +25,7 @@ function createConfig(overrides: Partial = {}): AppConfig ...overrides, }, adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' }, + runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' }, }; } diff --git a/apps/platform-backend/src/config/configuration.ts b/apps/platform-backend/src/config/configuration.ts index 1ed6d15..29f51c3 100644 --- a/apps/platform-backend/src/config/configuration.ts +++ b/apps/platform-backend/src/config/configuration.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import path from 'node:path'; /** * Zentrale, typsichere Konfiguration der Management-Plattform. @@ -28,12 +29,18 @@ export interface AdminSeedConfig { readonly password: string; } +export interface RuntimeConfig { + readonly modulesDir: string; + readonly logsDir: string; +} + export interface AppConfig { readonly nodeEnv: NodeEnvironment; readonly port: number; readonly database: DatabaseConfig; readonly security: SecurityConfig; readonly adminSeed: AdminSeedConfig; + readonly runtime: RuntimeConfig; } const booleanFromString = z @@ -55,6 +62,8 @@ const environmentSchema = z.object({ ADMIN_USERNAME: z.string().trim().min(3).max(100), ADMIN_EMAIL: z.string().trim().email(), ADMIN_PASSWORD: z.string().min(10, 'ADMIN_PASSWORD muss mindestens 10 Zeichen lang sein').max(200), + MODULES_DIR: z.string().min(1).default('./data/modules'), + LOGS_DIR: z.string().min(1).default('./data/logs'), }); /** Lädt und validiert die Konfiguration aus den Umgebungsvariablen. */ @@ -79,5 +88,9 @@ export function loadConfiguration(): AppConfig { email: environment.ADMIN_EMAIL, password: environment.ADMIN_PASSWORD, }, + runtime: { + modulesDir: path.resolve(environment.MODULES_DIR), + logsDir: path.resolve(environment.LOGS_DIR), + }, }; } \ No newline at end of file diff --git a/apps/platform-backend/src/database/migrations/index.ts b/apps/platform-backend/src/database/migrations/index.ts index 355de82..791c5ab 100644 --- a/apps/platform-backend/src/database/migrations/index.ts +++ b/apps/platform-backend/src/database/migrations/index.ts @@ -1,4 +1,5 @@ import { migration001CoreSchema } from './001-core-schema'; +import { migration002Modules } from '../../modules/migrations/002-modules'; /** Registrierte Migrationen in aufsteigender Reihenfolge. */ -export const MIGRATIONS = [migration001CoreSchema]; \ No newline at end of file +export const MIGRATIONS = [migration001CoreSchema, migration002Modules]; \ No newline at end of file diff --git a/apps/platform-backend/src/modules/manifest.types.spec.ts b/apps/platform-backend/src/modules/manifest.types.spec.ts new file mode 100644 index 0000000..9c4bb64 --- /dev/null +++ b/apps/platform-backend/src/modules/manifest.types.spec.ts @@ -0,0 +1,79 @@ +import { BadRequestException } from '@nestjs/common'; +import { moduleManifestSchema } from './manifest.types'; +import { ModuleInstaller } from './module-installer'; + +describe('moduleManifestSchema', () => { + const validManifest = { + id: 'calendar', + name: 'Kalender', + version: '1.0.0', + slug: 'kalender-tool', + description: 'Kalenderverwaltung', + author: 'MPM', + runtime: 'node', + entrypoint: 'server.js', + port: 41001, + healthcheck: '/health', + apiVersion: 'v1', + }; + + it('akzeptiert ein gültiges Manifest', () => { + const result = moduleManifestSchema.safeParse(validManifest); + expect(result.success).toBe(true); + }); + + it('lehnt ungültige Modul-IDs ab', () => { + const result = moduleManifestSchema.safeParse({ ...validManifest, id: 'Invalid_ID!' }); + expect(result.success).toBe(false); + }); + + it('lehnt ungültige Versionen ab', () => { + const result = moduleManifestSchema.safeParse({ ...validManifest, version: '1.0' }); + expect(result.success).toBe(false); + }); + + it('lehnt Ports außerhalb des erlaubten Bereichs ab', () => { + const result = moduleManifestSchema.safeParse({ ...validManifest, port: 80 }); + expect(result.success).toBe(false); + }); + + it('lehnt Pfad-Traversal im Entrypoint ab', () => { + const result = moduleManifestSchema.safeParse({ ...validManifest, entrypoint: '../evil.js' }); + expect(result.success).toBe(false); + }); + + it('lehnt unbekannte Runtimes ab', () => { + const result = moduleManifestSchema.safeParse({ ...validManifest, runtime: 'python' }); + expect(result.success).toBe(false); + }); + + it('lehnt ungültige Healthcheck-Pfade ab', () => { + const result = moduleManifestSchema.safeParse({ ...validManifest, healthcheck: 'http://evil' }); + expect(result.success).toBe(false); + }); +}); + +describe('ModuleInstaller.validatePackage', () => { + it('lehnt leere Pakete ab', async () => { + const installer = new ModuleInstaller(); + await expect(installer.validatePackage(Buffer.alloc(0))).rejects.toThrow(BadRequestException); + }); + + it('lehnt Pakete ohne module.json ab', async () => { + const AdmZip = (await import('adm-zip')).default; + const zip = new AdmZip(); + zip.addFile('irgendwas.txt', Buffer.from('Inhalt')); + const installer = new ModuleInstaller(); + await expect(installer.validatePackage(zip.toBuffer())).rejects.toThrow( + 'Paket enthält keine module.json', + ); + }); + + it('lehnt ungültige Manifeste ab', async () => { + const AdmZip = (await import('adm-zip')).default; + const zip = new AdmZip(); + zip.addFile('module.json', Buffer.from(JSON.stringify({ id: 'X' }))); + const installer = new ModuleInstaller(); + await expect(installer.validatePackage(zip.toBuffer())).rejects.toThrow(BadRequestException); + }); +}); \ No newline at end of file diff --git a/apps/platform-backend/src/modules/manifest.types.ts b/apps/platform-backend/src/modules/manifest.types.ts new file mode 100644 index 0000000..6437e46 --- /dev/null +++ b/apps/platform-backend/src/modules/manifest.types.ts @@ -0,0 +1,74 @@ +import { z } from 'zod'; + +/** Mögliche Lebenszyklus-Zustände eines Moduls. */ +export const MODULE_STATUSES = [ + 'INSTALLED', + 'STARTING', + 'RUNNING', + 'STOPPING', + 'STOPPED', + 'ERROR', + 'DISABLED', +] as const; +export type ModuleStatus = (typeof MODULE_STATUSES)[number]; + +/** Erlaubter interner Portbereich für Modul-Prozesse (nicht nach außen sichtbar). */ +export const MODULE_PORT_MIN = 41000; +export const MODULE_PORT_MAX = 41999; + +/** Modul-IDs: kleinbuchstaben, Zahlen, Bindestriche – keine Pfadzeichen. */ +const MODULE_ID_PATTERN = /^[a-z][a-z0-9-]{2,63}$/; + +/** URL-Slugs für das spätere Routing (/slug). */ +const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{2,99}$/; + +/** Semantische Versionierung (major.minor.patch). */ +const VERSION_PATTERN = /^\d+\.\d+\.\d+$/; + +/** + * Manifest-Vertrag (module.json) eines Moduls. + * Jedes installierbare Paket muss dieses Schema erfüllen. + */ +export const moduleManifestSchema = z.object({ + id: z + .string() + .regex(MODULE_ID_PATTERN, 'Modul-ID muss dem Muster [a-z][a-z0-9-]{2,63} folgen'), + name: z.string().trim().min(1, 'Name ist erforderlich').max(100), + version: z.string().regex(VERSION_PATTERN, 'Version muss dem Muster major.minor.patch folgen'), + slug: z.string().regex(SLUG_PATTERN, 'Slug muss dem Muster [a-z0-9-]{3,100} folgen'), + description: z.string().max(500).default(''), + author: z.string().max(200).default(''), + runtime: z.literal('node'), + entrypoint: z + .string() + .regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*\.js$/, 'Entrypoint muss ein relativer .js-Pfad sein') + .refine((entrypoint) => !entrypoint.split('/').includes('..'), { + message: 'Entrypoint darf nicht über das Modulverzeichnis hinauszeigen', + }), + port: z + .number() + .int() + .min(MODULE_PORT_MIN, `Port muss zwischen ${MODULE_PORT_MIN} und ${MODULE_PORT_MAX} liegen`) + .max(MODULE_PORT_MAX, `Port muss zwischen ${MODULE_PORT_MIN} und ${MODULE_PORT_MAX} liegen`), + healthcheck: z.string().regex(/^\/[A-Za-z0-9\-./]*$/, 'Healthcheck muss ein Pfad sein'), + apiVersion: z.literal('v1'), +}); +export type ModuleManifest = z.infer; + +/** Vollständiger Modul-Datensatz aus der Datenbank. */ +export interface ModuleRecord { + readonly id: string; + readonly moduleId: string; + readonly name: string; + readonly slug: string; + readonly version: string; + readonly description: string; + readonly author: string; + readonly path: string; + readonly status: ModuleStatus; + readonly internalPort: number; + readonly healthcheckUrl: string; + readonly enabled: boolean; + readonly createdAt: Date; + readonly updatedAt: Date; +} \ No newline at end of file diff --git a/apps/platform-backend/src/modules/migrations/002-modules.ts b/apps/platform-backend/src/modules/migrations/002-modules.ts new file mode 100644 index 0000000..ccc088a --- /dev/null +++ b/apps/platform-backend/src/modules/migrations/002-modules.ts @@ -0,0 +1,30 @@ +import type { Migration } from '../../database/migration.types'; + +/** Phase 3: Modul-Registry für installierte Module. */ +export const migration002Modules: Migration = { + id: '002-modules', + description: 'Modul-Registry anlegen', + up: async (client) => { + await client.query(` + CREATE TABLE modules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + module_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + version TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'INSTALLED' + CHECK (status IN ('INSTALLED', 'STARTING', 'RUNNING', 'STOPPING', 'STOPPED', 'ERROR', 'DISABLED')), + internal_port INTEGER NOT NULL, + healthcheck_url TEXT NOT NULL DEFAULT '/health', + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + + await client.query('CREATE INDEX idx_modules_status ON modules(status)'); + }, +}; \ No newline at end of file diff --git a/apps/platform-backend/src/modules/module-health-checker.ts b/apps/platform-backend/src/modules/module-health-checker.ts new file mode 100644 index 0000000..33fa80f --- /dev/null +++ b/apps/platform-backend/src/modules/module-health-checker.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import type { ModuleRecord } from './manifest.types'; + +/** Ergebnis eines Modul-Healthchecks. */ +export interface ModuleHealthResult { + readonly healthy: boolean; + readonly latencyMs: number; + readonly detail: string; +} + +/** + * Modul-Healthchecks (Infrastructure): Prüft die Healthcheck-URL eines + * Modul-Prozesses. Fehler werden abgefangen – ein krankes Modul darf + * die Plattform niemals mitreißen. + */ +@Injectable() +export class ModuleHealthChecker { + + /** Prüft einen Modul-Prozess über seine Healthcheck-URL. */ + async check(module: ModuleRecord): Promise { + const url = `http://127.0.0.1:${module.internalPort}${module.healthcheckUrl}`; + const start = performance.now(); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3_000); + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeout); + + const latencyMs = Math.round(performance.now() - start); + const healthy = response.ok; + return { + healthy, + latencyMs, + detail: healthy ? 'healthy' : `HTTP ${response.status}`, + }; + } catch (error) { + const latencyMs = Math.round(performance.now() - start); + const detail = error instanceof Error ? error.message : String(error); + return { healthy: false, latencyMs, detail: `unreachable: ${detail}` }; + } + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/modules/module-installer.ts b/apps/platform-backend/src/modules/module-installer.ts new file mode 100644 index 0000000..555153f --- /dev/null +++ b/apps/platform-backend/src/modules/module-installer.ts @@ -0,0 +1,130 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { moduleManifestSchema, type ModuleManifest } from './manifest.types'; + +/** Maximale Größe eines Modul-Pakets (10 MB). */ +const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024; + +/** Dateien, die in einem Modul-Paket erwartet werden. */ +const REQUIRED_MANIFEST_FILE = 'module.json'; + +/** + * Modul-Installer (Infrastructure): + * Nimmt ein ZIP-Paket entgegen, validiert es (Größe, Manifest, + * Zip-Slip-Schutz) und installiert es in das Modulverzeichnis. + * + * Ein Modul ist grundsätzlich nicht vertrauenswürdig – deshalb: + * - strikte Manifest-Validierung (Zod) + * - kein Pfad-Exit aus dem Zielverzeichnis (Zip-Slip) + * - keine Ausführung von Paket-Skripten (npm install --ignore-scripts) + */ +@Injectable() +export class ModuleInstaller { + private readonly logger = new Logger('ModuleInstaller'); + + /** Validiert ein hochgeladenes Paket und gibt das Manifest zurück. */ + async validatePackage(buffer: Buffer): Promise { + if (buffer.length === 0) { + throw new BadRequestException('Paket ist leer'); + } + if (buffer.length > MAX_PACKAGE_SIZE_BYTES) { + throw new BadRequestException('Paket ist zu groß (maximal 10 MB)'); + } + + const AdmZip = (await import('adm-zip')).default; + const zip = new AdmZip(buffer); + + const manifestEntry = zip.getEntry(REQUIRED_MANIFEST_FILE); + if (!manifestEntry) { + throw new BadRequestException(`Paket enthält keine ${REQUIRED_MANIFEST_FILE}`); + } + + let manifestJson: unknown; + try { + manifestJson = JSON.parse(manifestEntry.getData().toString('utf8')); + } catch { + throw new BadRequestException(`${REQUIRED_MANIFEST_FILE} ist kein gültiges JSON`); + } + + const result = moduleManifestSchema.safeParse(manifestJson); + if (!result.success) { + const details: Record = {}; + for (const issue of result.error.issues) { + const key = issue.path.join('.') || 'form'; + if (!details[key]) { + details[key] = [issue.message]; + } + } + throw new BadRequestException({ + statusCode: 400, + message: 'Manifest-Validierung fehlgeschlagen', + error: 'Bad Request', + details, + }); + } + + return result.data; + } + + /** + * Installiert ein validiertes Paket in das Modulverzeichnis. + * @returns Installationsverzeichnis und Manifest. + */ + async install( + buffer: Buffer, + manifest: ModuleManifest, + modulesDir: string, + ): Promise<{ directory: string; manifest: ModuleManifest }> { + const directory = path.join(modulesDir, manifest.id); + + // Zip-Slip-Schutz: Alle Einträge müssen innerhalb des Zielverzeichnisses liegen. + const AdmZip = (await import('adm-zip')).default; + const zip = new AdmZip(buffer); + const resolvedDirectory = path.resolve(directory); + + for (const entry of zip.getEntries()) { + const entryName = entry.entryName; + if (entryName.startsWith('/') || entryName.includes('..') || /^[A-Za-z]:/.test(entryName)) { + throw new BadRequestException(`Unsicherer Pfad im Paket: ${entryName}`); + } + const resolvedEntry = path.resolve(resolvedDirectory, entryName); + if (!resolvedEntry.startsWith(resolvedDirectory + path.sep)) { + throw new BadRequestException(`Unsicherer Pfad im Paket: ${entryName}`); + } + } + + // Bestehende Installation entfernen (Update-Szenario). + await rm(directory, { recursive: true, force: true }); + await mkdir(directory, { recursive: true }); + + zip.extractAllTo(resolvedDirectory, true); + + // Paket-Metadaten für spätere Diagnose speichern. + await writeFile( + path.join(directory, '.installed.json'), + JSON.stringify({ installedAt: new Date().toISOString(), manifest }, null, 2), + 'utf8', + ); + + this.logger.log(`Modul "${manifest.id}" installiert in ${directory}`); + return { directory, manifest }; + } + + /** Entfernt eine Modul-Installation vom Dateisystem. */ + async remove(modulesDir: string, moduleId: string): Promise { + const directory = path.join(modulesDir, moduleId); + await rm(directory, { recursive: true, force: true }); + } + + /** Liest das Manifest einer bestehenden Installation. */ + async readInstalledManifest(directory: string): Promise { + try { + const raw = await readFile(path.join(directory, REQUIRED_MANIFEST_FILE), 'utf8'); + const result = moduleManifestSchema.safeParse(JSON.parse(raw)); + return result.success ? result.data : null; + } catch { + return null; + } + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/modules/module-process-manager.ts b/apps/platform-backend/src/modules/module-process-manager.ts new file mode 100644 index 0000000..88d6b25 --- /dev/null +++ b/apps/platform-backend/src/modules/module-process-manager.ts @@ -0,0 +1,110 @@ +import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdir, open } from 'node:fs/promises'; +import path from 'node:path'; +import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; +import { Inject } from '@nestjs/common'; +import type { ModuleRecord } from './manifest.types'; + +/** Laufende Modul-Prozesse im Speicher (nicht persistent). */ +interface RunningProcess { + readonly child: ChildProcess; + readonly logFilePath: string; +} + +/** + * Modul-Prozess-Manager (Infrastructure): + * Startet, stoppt und überwacht Modul-Prozesse innerhalb des + * Management-Containers. Kein Docker-in-Docker – Module laufen als + * Kindprozesse des Backends mit eigenen internen Ports. + * + * Sicherheitsprinzipien: + * - Prozesse laufen ohne Shell (keine Command-Injection) + * - Umgebungsvariablen minimal gehalten (keine Plattform-Secrets) + * - Logs pro Modul in eigene Dateien + */ +@Injectable() +export class ModuleProcessManager implements OnModuleDestroy { + private readonly logger = new Logger('ModuleProcesses'); + private readonly running = new Map(); + + constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {} + + /** Startet einen Modul-Prozess. */ + async start(module: ModuleRecord): Promise { + if (this.running.has(module.moduleId)) { + return; + } + + const entrypoint = path.join(module.path, 'backend', 'server.js'); + const logFilePath = path.join(this.config.runtime.logsDir, `module-${module.moduleId}.log`); + await mkdir(this.config.runtime.logsDir, { recursive: true }); + const logFile = await open(logFilePath, 'a'); + + const child = spawn(process.execPath, [entrypoint], { + cwd: module.path, + // Log-Datei bleibt offen: Der fd wird vom Kindprozess geerbt und + // darf erst nach Prozessende geschlossen werden. + stdio: ['ignore', logFile.fd, logFile.fd], + env: { + PATH: process.env.PATH ?? '', + NODE_ENV: this.config.nodeEnv, + PORT: String(module.internalPort), + // Modul erhält nur seinen eigenen Kontext – keine Plattform-Secrets. + }, + detached: false, + }); + + child.unref(); + + child.on('exit', (code) => { + this.logger.warn(`Modul-Prozess "${module.moduleId}" beendet (Code ${code ?? 'signal'})`); + this.running.delete(module.moduleId); + }); + + this.running.set(module.moduleId, { child, logFilePath }); + this.logger.log(`Modul-Prozess "${module.moduleId}" gestartet (Port ${module.internalPort})`); + } + + /** Stoppt einen Modul-Prozess (SIGTERM, dann SIGKILL). */ + async stop(moduleId: string): Promise { + const process_ = this.running.get(moduleId); + if (!process_) { + return; + } + + this.running.delete(moduleId); + const child = process_.child; + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + resolve(); + }, 5_000); + + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + + child.kill('SIGTERM'); + }); + + this.logger.log(`Modul-Prozess "${moduleId}" gestoppt`); + } + + /** Prüft, ob ein Modul-Prozess läuft. */ + isRunning(moduleId: string): boolean { + return this.running.has(moduleId); + } + + /** Stoppt alle Modul-Prozesse (Herunterfahren). */ + async stopAll(): Promise { + const moduleIds = [...this.running.keys()]; + await Promise.all(moduleIds.map((moduleId) => this.stop(moduleId))); + } + + async onModuleDestroy(): Promise { + await this.stopAll(); + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/modules/module.repository.ts b/apps/platform-backend/src/modules/module.repository.ts new file mode 100644 index 0000000..79bb1e7 --- /dev/null +++ b/apps/platform-backend/src/modules/module.repository.ts @@ -0,0 +1,137 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import type { ModuleManifest, ModuleRecord, ModuleStatus } from './manifest.types'; + +interface ModuleRow { + id: string; + module_id: string; + name: string; + slug: string; + version: string; + description: string; + author: string; + path: string; + status: ModuleStatus; + internal_port: number; + healthcheck_url: string; + enabled: boolean; + created_at: Date; + updated_at: Date; +} + +const MODULE_COLUMNS = `id, module_id, name, slug, version, description, author, path, + status, internal_port, healthcheck_url, enabled, created_at, updated_at`; + +/** + * Modul-Repository (Infrastructure): Datenbankzugriffe für die Modul-Registry. + * Keine Business-Logik – nur parametrisierten Datenzugriff. + */ +@Injectable() +export class ModuleRepository { + constructor(private readonly database: DatabaseService) {} + + async list(): Promise { + const result = await this.database.query( + `SELECT ${MODULE_COLUMNS} FROM modules ORDER BY created_at ASC`, + ); + return result.rows.map((row) => this.mapRow(row)); + } + + async findByModuleId(moduleId: string): Promise { + const result = await this.database.query( + `SELECT ${MODULE_COLUMNS} FROM modules WHERE module_id = $1`, + [moduleId], + ); + return result.rows[0] ? this.mapRow(result.rows[0]) : null; + } + + async findBySlug(slug: string): Promise { + const result = await this.database.query( + `SELECT ${MODULE_COLUMNS} FROM modules WHERE slug = $1`, + [slug], + ); + return result.rows[0] ? this.mapRow(result.rows[0]) : null; + } + + async findByPort(port: number): Promise { + const result = await this.database.query( + `SELECT ${MODULE_COLUMNS} FROM modules WHERE internal_port = $1`, + [port], + ); + return result.rows[0] ? this.mapRow(result.rows[0]) : null; + } + + async findById(id: string): Promise { + const result = await this.database.query( + `SELECT ${MODULE_COLUMNS} FROM modules WHERE id = $1`, + [id], + ); + return result.rows[0] ? this.mapRow(result.rows[0]) : null; + } + + async findByStatus(statuses: readonly ModuleStatus[]): Promise { + const result = await this.database.query( + `SELECT ${MODULE_COLUMNS} FROM modules WHERE status = ANY($1::text[])`, + [statuses], + ); + return result.rows.map((row) => this.mapRow(row)); + } + + async create(manifest: ModuleManifest, directory: string): Promise { + const result = await this.database.query( + `INSERT INTO modules + (module_id, name, slug, version, description, author, path, status, internal_port, healthcheck_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9) + RETURNING ${MODULE_COLUMNS}`, + [ + manifest.id, + manifest.name, + manifest.slug, + manifest.version, + manifest.description, + manifest.author, + directory, + manifest.port, + manifest.healthcheck, + ], + ); + return this.mapRow(result.rows[0]); + } + + async updateStatus(id: string, status: ModuleStatus): Promise { + await this.database.query( + 'UPDATE modules SET status = $2, updated_at = now() WHERE id = $1', + [id, status], + ); + } + + async updateEnabled(id: string, enabled: boolean): Promise { + await this.database.query( + 'UPDATE modules SET enabled = $2, updated_at = now() WHERE id = $1', + [id, enabled], + ); + } + + async delete(id: string): Promise { + await this.database.query('DELETE FROM modules WHERE id = $1', [id]); + } + + private mapRow(row: ModuleRow): ModuleRecord { + return { + id: row.id, + moduleId: row.module_id, + name: row.name, + slug: row.slug, + version: row.version, + description: row.description, + author: row.author, + path: row.path, + status: row.status, + internalPort: row.internal_port, + healthcheckUrl: row.healthcheck_url, + enabled: row.enabled, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/modules/modules.controller.ts b/apps/platform-backend/src/modules/modules.controller.ts new file mode 100644 index 0000000..b849938 --- /dev/null +++ b/apps/platform-backend/src/modules/modules.controller.ts @@ -0,0 +1,154 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Req, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Request } from 'express'; +import type { Multer } from 'multer'; +import { ApiTags } from '@nestjs/swagger'; + +/** Hochgeladene Datei (Multer). */ +type UploadedPackageFile = Multer & { buffer: Buffer }; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { Roles } from '../common/decorators/roles.decorator'; +import type { AuthenticatedRequest } from '../auth/authenticated-request'; +import type { AuthUser } from '../users/user.types'; +import type { ModuleRecord, ModuleStatus } from './manifest.types'; +import { ModulesService } from './modules.service'; + +/** Modul-Daten in API-Antworten. */ +interface ModuleResponse { + id: string; + moduleId: string; + name: string; + slug: string; + version: string; + description: string; + author: string; + status: ModuleStatus; + internalPort: number; + healthcheckUrl: string; + enabled: boolean; + createdAt: string; +} + +function toModuleResponse(module: ModuleRecord): ModuleResponse { + return { + id: module.id, + moduleId: module.moduleId, + name: module.name, + slug: module.slug, + version: module.version, + description: module.description, + author: module.author, + status: module.status, + internalPort: module.internalPort, + healthcheckUrl: module.healthcheckUrl, + enabled: module.enabled, + createdAt: module.createdAt.toISOString(), + }; +} + +/** + * Modul-Verwaltung (nur Administratoren): Installation, Lifecycle, + * Healthchecks und Entfernen von Modulen. + */ +@ApiTags('Modules') +@Roles('ADMIN') +@Controller({ path: 'api/v1/modules' }) +export class ModulesController { + constructor(private readonly modulesService: ModulesService) {} + + @Get() + async list(): Promise<{ modules: ModuleResponse[] }> { + const modules = await this.modulesService.list(); + return { modules: modules.map(toModuleResponse) }; + } + + @Get(':id') + async getById(@Param('id', ParseUUIDPipe) id: string): Promise<{ module: ModuleResponse }> { + const module = await this.modulesService.getById(id); + return { module: toModuleResponse(module) }; + } + + @Post('install') + @UseInterceptors(FileInterceptor('package')) + async install( + @UploadedFile() file: UploadedPackageFile | undefined, + @CurrentUser() actor: AuthUser, + @Req() request: AuthenticatedRequest & Request, + ): Promise<{ module: ModuleResponse }> { + if (!file) { + throw new BadRequestException('Keine Paketdatei hochgeladen (Feld "package")'); + } + const module = await this.modulesService.install(file.buffer, actor, request.ip ?? null); + return { module: toModuleResponse(module) }; + } + + @Post(':id/start') + async start( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() actor: AuthUser, + @Req() request: AuthenticatedRequest & Request, + ): Promise<{ module: ModuleResponse }> { + const module = await this.modulesService.start(id, actor, request.ip ?? null); + return { module: toModuleResponse(module) }; + } + + @Post(':id/stop') + async stop( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() actor: AuthUser, + @Req() request: AuthenticatedRequest & Request, + ): Promise<{ module: ModuleResponse }> { + const module = await this.modulesService.stop(id, actor, request.ip ?? null); + return { module: toModuleResponse(module) }; + } + + @Post(':id/restart') + async restart( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() actor: AuthUser, + @Req() request: AuthenticatedRequest & Request, + ): Promise<{ module: ModuleResponse }> { + const module = await this.modulesService.restart(id, actor, request.ip ?? null); + return { module: toModuleResponse(module) }; + } + + @Patch(':id/enabled') + async setEnabled( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { enabled: boolean }, + @CurrentUser() actor: AuthUser, + @Req() request: AuthenticatedRequest & Request, + ): Promise<{ module: ModuleResponse }> { + const module = await this.modulesService.setEnabled(id, body.enabled, actor, request.ip ?? null); + return { module: toModuleResponse(module) }; + } + + @Get(':id/health') + async checkHealth(@Param('id', ParseUUIDPipe) id: string): Promise<{ healthy: boolean; latencyMs: number; detail: string }> { + const { health } = await this.modulesService.checkHealth(id); + return { healthy: health.healthy, latencyMs: health.latencyMs, detail: health.detail }; + } + + @Delete(':id') + async remove( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() actor: AuthUser, + @Req() request: AuthenticatedRequest & Request, + ): Promise<{ success: true }> { + await this.modulesService.remove(id, actor, request.ip ?? null); + return { success: true }; + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/modules/modules.module.ts b/apps/platform-backend/src/modules/modules.module.ts new file mode 100644 index 0000000..1868945 --- /dev/null +++ b/apps/platform-backend/src/modules/modules.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '../config/config.module'; +import { DatabaseModule } from '../database/database.module'; +import { AuditModule } from '../audit/audit.module'; +import { ModuleHealthChecker } from './module-health-checker'; +import { ModuleInstaller } from './module-installer'; +import { ModuleProcessManager } from './module-process-manager'; +import { ModuleRepository } from './module.repository'; +import { ModulesController } from './modules.controller'; +import { ModulesService } from './modules.service'; + +/** Modul-System: Installation, Lifecycle, Prozessverwaltung, Healthchecks. */ +@Module({ + imports: [ConfigModule, DatabaseModule, AuditModule], + controllers: [ModulesController], + providers: [ModuleRepository, ModuleInstaller, ModuleProcessManager, ModuleHealthChecker, ModulesService], + exports: [ModuleRepository, ModulesService], +}) +export class ModulesModule {} \ No newline at end of file diff --git a/apps/platform-backend/src/modules/modules.service.spec.ts b/apps/platform-backend/src/modules/modules.service.spec.ts new file mode 100644 index 0000000..c887d18 --- /dev/null +++ b/apps/platform-backend/src/modules/modules.service.spec.ts @@ -0,0 +1,333 @@ +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service'; +import type { AppConfig } from '../config/config.tokens'; +import type { ActingUser } from '../users/users.service'; +import { ModuleHealthChecker } from './module-health-checker'; +import { ModuleInstaller } from './module-installer'; +import { ModuleProcessManager } from './module-process-manager'; +import { ModuleRepository } from './module.repository'; +import type { ModuleManifest, ModuleRecord } from './manifest.types'; +import { ModulesService } from './modules.service'; + +/** Gültiges Test-Manifest. */ +function createManifest(overrides: Partial = {}): ModuleManifest { + return { + id: 'calendar', + name: 'Kalender', + version: '1.0.0', + slug: 'kalender-tool', + description: 'Kalenderverwaltung', + author: 'MPM', + runtime: 'node', + entrypoint: 'server.js', + port: 41001, + healthcheck: '/health', + apiVersion: 'v1', + ...overrides, + }; +} + +/** Erzeugt einen Modul-Datensatz für Tests. */ +function createModuleRecord(overrides: Partial = {}): ModuleRecord { + const manifest = createManifest(); + return { + id: 'module-1', + moduleId: manifest.id, + name: manifest.name, + slug: manifest.slug, + version: manifest.version, + description: manifest.description, + author: manifest.author, + path: '/data/modules/calendar', + status: 'STOPPED', + internalPort: manifest.port, + healthcheckUrl: manifest.healthcheck, + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +const ACTOR: ActingUser = { id: 'admin-1', username: 'admin' }; + +/** Mock des ModuleRepository. */ +class MockModuleRepository { + public modules: ModuleRecord[] = []; + public statusUpdates: Array<{ id: string; status: ModuleRecord['status'] }> = []; + public enabledUpdates: Array<{ id: string; enabled: boolean }> = []; + public deletedIds: string[] = []; + + async list(): Promise { + return this.modules; + } + + async findById(id: string): Promise { + return this.modules.find((module) => module.id === id) ?? null; + } + + async findByModuleId(moduleId: string): Promise { + return this.modules.find((module) => module.moduleId === moduleId) ?? null; + } + + async findBySlug(slug: string): Promise { + return this.modules.find((module) => module.slug === slug) ?? null; + } + + async findByPort(port: number): Promise { + return this.modules.find((module) => module.internalPort === port) ?? null; + } + + async create(manifest: ModuleManifest, directory: string): Promise { + const module = createModuleRecord({ + id: 'new-module', + moduleId: manifest.id, + name: manifest.name, + slug: manifest.slug, + version: manifest.version, + path: directory, + internalPort: manifest.port, + healthcheckUrl: manifest.healthcheck, + }); + this.modules.push(module); + return module; + } + + async updateStatus(id: string, status: ModuleRecord['status']): Promise { + this.statusUpdates.push({ id, status }); + const module = this.modules.find((m) => m.id === id); + if (module) { + (module as { status: ModuleRecord['status'] }).status = status; + } + } + + async updateEnabled(id: string, enabled: boolean): Promise { + this.enabledUpdates.push({ id, enabled }); + const module = this.modules.find((m) => m.id === id); + if (module) { + (module as { enabled: boolean }).enabled = enabled; + } + } + + async delete(id: string): Promise { + this.deletedIds.push(id); + this.modules = this.modules.filter((m) => m.id !== id); + } +} + +/** Mock des ModuleInstaller. */ +class MockModuleInstaller { + public validateResult: ModuleManifest = createManifest(); + public installed: Array<{ moduleId: string }> = []; + public removed: string[] = []; + + async validatePackage(): Promise { + return this.validateResult; + } + + async install( + _buffer: Buffer, + manifest: ModuleManifest, + _modulesDir: string, + ): Promise<{ directory: string; manifest: ModuleManifest }> { + this.installed.push({ moduleId: manifest.id }); + return { directory: `/data/modules/${manifest.id}`, manifest }; + } + + async remove(_modulesDir: string, moduleId: string): Promise { + this.removed.push(moduleId); + } +} + +/** Mock des ModuleProcessManager. */ +class MockProcessManager { + public started: string[] = []; + public stopped: string[] = []; + + async start(module: ModuleRecord): Promise { + this.started.push(module.moduleId); + } + + async stop(moduleId: string): Promise { + this.stopped.push(moduleId); + } + + isRunning(moduleId: string): boolean { + return this.started.includes(moduleId) && !this.stopped.includes(moduleId); + } +} + +/** Mock des ModuleHealthChecker. */ +class MockHealthChecker { + public healthy = true; + + async check(): Promise<{ healthy: boolean; latencyMs: number; detail: string }> { + return { healthy: this.healthy, latencyMs: 5, detail: this.healthy ? 'healthy' : 'unreachable' }; + } +} + +/** Mock des AuditService. */ +class MockAuditService { + public records: Array<{ action: string }> = []; + + async record(entry: { action: string }): Promise { + this.records.push(entry); + } +} + +/** Test-Konfiguration. */ +const TEST_CONFIG: AppConfig = { + nodeEnv: 'test', + port: 3000, + database: { url: 'postgresql://test' }, + security: { + sessionTtlMinutes: 120, + cookieSecure: false, + behindProxy: false, + loginMaxAttempts: 5, + loginLockoutMinutes: 15, + loginRateLimitAttempts: 10, + loginRateLimitWindowMinutes: 5, + }, + adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' }, + runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' }, +}; + +describe('ModulesService', () => { + let repository: MockModuleRepository; + let installer: MockModuleInstaller; + let processManager: MockProcessManager; + let healthChecker: MockHealthChecker; + let auditService: MockAuditService; + let modulesService: ModulesService; + + beforeEach(() => { + repository = new MockModuleRepository(); + installer = new MockModuleInstaller(); + processManager = new MockProcessManager(); + healthChecker = new MockHealthChecker(); + auditService = new MockAuditService(); + modulesService = new ModulesService( + repository as unknown as ModuleRepository, + installer as unknown as ModuleInstaller, + processManager as unknown as ModuleProcessManager, + healthChecker as unknown as ModuleHealthChecker, + auditService as unknown as AuditService, + TEST_CONFIG, + ); + }); + + describe('install', () => { + it('installiert ein neues Modul und schreibt Audit', async () => { + const module = await modulesService.install(Buffer.from('zip'), ACTOR, null); + expect(module.moduleId).toBe('calendar'); + expect(installer.installed).toEqual([{ moduleId: 'calendar' }]); + expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_INSTALLED); + }); + + it('lehnt doppelte Modul-IDs ab', async () => { + repository.modules = [createModuleRecord()]; + await expect(modulesService.install(Buffer.from('zip'), ACTOR, null)).rejects.toThrow( + ConflictException, + ); + }); + + it('lehnt doppelte Slugs ab', async () => { + repository.modules = [createModuleRecord({ moduleId: 'anderes', slug: 'kalender-tool' })]; + await expect(modulesService.install(Buffer.from('zip'), ACTOR, null)).rejects.toThrow( + ConflictException, + ); + }); + + it('lehnt belegte Ports ab', async () => { + repository.modules = [createModuleRecord({ moduleId: 'anderes', slug: 'anderer-slug' })]; + await expect(modulesService.install(Buffer.from('zip'), ACTOR, null)).rejects.toThrow( + ConflictException, + ); + }); + }); + + describe('start', () => { + it('startet ein gestopptes Modul (STARTING → RUNNING)', async () => { + repository.modules = [createModuleRecord({ status: 'STOPPED' })]; + const module = await modulesService.start('module-1', ACTOR, null); + expect(module.status).toBe('RUNNING'); + expect(processManager.started).toEqual(['calendar']); + expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_STARTED); + }); + + it('setzt ERROR bei fehlgeschlagenem Healthcheck', async () => { + repository.modules = [createModuleRecord({ status: 'STOPPED' })]; + healthChecker.healthy = false; + await expect(modulesService.start('module-1', ACTOR, null)).rejects.toThrow( + BadRequestException, + ); + expect(repository.statusUpdates).toContainEqual({ id: 'module-1', status: 'ERROR' }); + expect(processManager.stopped).toEqual(['calendar']); + }); + + it('lehnt Start deaktivierter Module ab', async () => { + repository.modules = [createModuleRecord({ enabled: false })]; + await expect(modulesService.start('module-1', ACTOR, null)).rejects.toThrow( + BadRequestException, + ); + }); + + it('wirft NotFoundException bei unbekannter ID', async () => { + await expect(modulesService.start('unbekannt', ACTOR, null)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('stop', () => { + it('stoppt ein laufendes Modul (STOPPING → STOPPED)', async () => { + repository.modules = [createModuleRecord({ status: 'RUNNING' })]; + const module = await modulesService.stop('module-1', ACTOR, null); + expect(module.status).toBe('STOPPED'); + expect(processManager.stopped).toEqual(['calendar']); + expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_STOPPED); + }); + }); + + describe('restart', () => { + it('stoppt und startet ein laufendes Modul', async () => { + repository.modules = [createModuleRecord({ status: 'RUNNING' })]; + const module = await modulesService.restart('module-1', ACTOR, null); + expect(module.status).toBe('RUNNING'); + expect(processManager.stopped).toEqual(['calendar']); + expect(processManager.started).toEqual(['calendar']); + expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_STARTED); + }); + }); + + describe('setEnabled', () => { + it('deaktiviert ein laufendes Modul (stoppt es zuerst)', async () => { + repository.modules = [createModuleRecord({ status: 'RUNNING' })]; + const module = await modulesService.setEnabled('module-1', false, ACTOR, null); + expect(module.enabled).toBe(false); + expect(module.status).toBe('DISABLED'); + expect(processManager.stopped).toEqual(['calendar']); + expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_DISABLED); + }); + + it('aktiviert ein deaktiviertes Modul', async () => { + repository.modules = [createModuleRecord({ enabled: false, status: 'DISABLED' })]; + const module = await modulesService.setEnabled('module-1', true, ACTOR, null); + expect(module.enabled).toBe(true); + expect(module.status).toBe('STOPPED'); + expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_ENABLED); + }); + }); + + describe('remove', () => { + it('stoppt, löscht Registry-Eintrag und Dateien', async () => { + repository.modules = [createModuleRecord({ status: 'RUNNING' })]; + await modulesService.remove('module-1', ACTOR, null); + expect(processManager.stopped).toEqual(['calendar']); + expect(repository.deletedIds).toEqual(['module-1']); + expect(installer.removed).toEqual(['calendar']); + expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_REMOVED); + }); + }); +}); \ No newline at end of file diff --git a/apps/platform-backend/src/modules/modules.service.ts b/apps/platform-backend/src/modules/modules.service.ts new file mode 100644 index 0000000..900a63a --- /dev/null +++ b/apps/platform-backend/src/modules/modules.service.ts @@ -0,0 +1,252 @@ +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; +import { AUDIT_ACTIONS, AuditService, type AuditAction } from '../audit/audit.service'; +import type { ActingUser } from '../users/users.service'; +import { ModuleHealthChecker } from './module-health-checker'; +import { ModuleInstaller } from './module-installer'; +import { ModuleProcessManager } from './module-process-manager'; +import { ModuleRepository } from './module.repository'; +import type { ModuleRecord } from './manifest.types'; + +/** + * Modul-Verwaltung (Application-Layer): Lifecycle-Logik für Module. + * + * Zustände: INSTALLED → STARTING → RUNNING → STOPPING → STOPPED + * (sowie ERROR und DISABLED) + * + * Grundsätze: + * - Die Plattform hängt nie von einem Modul ab (Fehler werden isoliert) + * - Jede Lifecycle-Aktion wird auditiert + * - Ports und Slugs sind eindeutig + */ +@Injectable() +export class ModulesService { + constructor( + private readonly moduleRepository: ModuleRepository, + private readonly installer: ModuleInstaller, + private readonly processManager: ModuleProcessManager, + private readonly healthChecker: ModuleHealthChecker, + private readonly auditService: AuditService, + @Inject(APP_CONFIG) private readonly config: AppConfig, + ) {} + + async list(): Promise { + return this.moduleRepository.list(); + } + + async getById(id: string): Promise { + const module = await this.moduleRepository.findById(id); + if (!module) { + throw new NotFoundException('Modul nicht gefunden'); + } + return module; + } + + /** Installiert ein Modul-Paket (ZIP) und registriert es. */ + async install( + packageBuffer: Buffer, + actor: ActingUser, + ipAddress: string | null, + ): Promise { + const manifest = await this.installer.validatePackage(packageBuffer); + + const [existingId, existingSlug, existingPort] = await Promise.all([ + this.moduleRepository.findByModuleId(manifest.id), + this.moduleRepository.findBySlug(manifest.slug), + this.moduleRepository.findByPort(manifest.port), + ]); + if (existingId) { + throw new ConflictException(`Modul-ID "${manifest.id}" ist bereits installiert`); + } + if (existingSlug) { + throw new ConflictException(`Slug "${manifest.slug}" ist bereits vergeben`); + } + if (existingPort) { + throw new ConflictException(`Port ${manifest.port} ist bereits belegt`); + } + + const { directory } = await this.installer.install( + packageBuffer, + manifest, + this.config.runtime.modulesDir, + ); + const module = await this.moduleRepository.create(manifest, directory); + + await this.auditService.record({ + userId: actor.id, + username: actor.username, + action: AUDIT_ACTIONS.MODULE_INSTALLED, + details: { moduleId: manifest.id, version: manifest.version, slug: manifest.slug }, + ipAddress, + }); + return module; + } + + /** Startet ein Modul (INSTALLED/STOPPED → STARTING → RUNNING). */ + async start(id: string, actor: ActingUser, ipAddress: string | null): Promise { + const module = await this.getById(id); + this.assertEnabled(module); + if (module.status === 'RUNNING' || module.status === 'STARTING') { + return module; + } + + await this.moduleRepository.updateStatus(id, 'STARTING'); + try { + await this.processManager.start(module); + // Startup-Grace: Der Modul-Prozess braucht einen Moment zum Starten. + // Der Healthcheck wird mit Retries wiederholt, bevor er als Fehlschlag gilt. + const health = await this.waitForHealthy(module); + if (!health.healthy) { + throw new Error(`Healthcheck fehlgeschlagen: ${health.detail}`); + } + await this.moduleRepository.updateStatus(id, 'RUNNING'); + await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STARTED, actor, ipAddress); + } catch (error) { + await this.moduleRepository.updateStatus(id, 'ERROR'); + await this.processManager.stop(module.moduleId); + throw new BadRequestException( + `Modul konnte nicht gestartet werden: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return (await this.moduleRepository.findById(id)) ?? module; + } + + /** + * Wartet bis zum ersten erfolgreichen Healthcheck (max. 10 Versuche + * mit 500 ms Abstand). Ein Modul darf beim Start nicht zu früh + * als fehlerhaft gelten. + */ + private async waitForHealthy( + module: ModuleRecord, + ): Promise<{ healthy: boolean; latencyMs: number; detail: string }> { + const maxAttempts = 10; + const delayMs = 500; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const health = await this.healthChecker.check(module); + if (health.healthy) { + return health; + } + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + return this.healthChecker.check(module); + } + + /** Stoppt ein Modul (RUNNING → STOPPING → STOPPED). */ + async stop(id: string, actor: ActingUser, ipAddress: string | null): Promise { + const module = await this.getById(id); + if (module.status === 'STOPPED' || module.status === 'STOPPING') { + return module; + } + + await this.moduleRepository.updateStatus(id, 'STOPPING'); + try { + await this.processManager.stop(module.moduleId); + await this.moduleRepository.updateStatus(id, 'STOPPED'); + await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STOPPED, actor, ipAddress); + } catch (error) { + await this.moduleRepository.updateStatus(id, 'ERROR'); + throw new BadRequestException( + `Modul konnte nicht gestoppt werden: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return (await this.moduleRepository.findById(id)) ?? module; + } + + /** Startet ein Modul neu (Stop + Start). */ + async restart(id: string, actor: ActingUser, ipAddress: string | null): Promise { + const module = await this.getById(id); + if (module.status === 'RUNNING' || module.status === 'STARTING') { + await this.stop(id, actor, ipAddress); + } + return this.start(id, actor, ipAddress); + } + + /** Aktiviert oder deaktiviert ein Modul (DISABLED-Zustand). */ + async setEnabled( + id: string, + enabled: boolean, + actor: ActingUser, + ipAddress: string | null, + ): Promise { + const module = await this.getById(id); + if (module.enabled === enabled) { + return module; + } + + if (!enabled && (module.status === 'RUNNING' || module.status === 'STARTING')) { + await this.stop(id, actor, ipAddress); + } + + await this.moduleRepository.updateEnabled(id, enabled); + await this.moduleRepository.updateStatus( + id, + enabled ? 'STOPPED' : 'DISABLED', + ); + + await this.auditService.record({ + userId: actor.id, + username: actor.username, + action: enabled ? AUDIT_ACTIONS.MODULE_ENABLED : AUDIT_ACTIONS.MODULE_DISABLED, + details: { moduleId: module.moduleId }, + ipAddress, + }); + return (await this.moduleRepository.findById(id)) ?? module; + } + + /** Entfernt ein Modul vollständig (Prozess, Dateien, Registry). */ + async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise { + const module = await this.getById(id); + + if (module.status === 'RUNNING' || module.status === 'STARTING') { + await this.stop(id, actor, ipAddress); + } + + await this.moduleRepository.delete(id); + await this.installer.remove(this.config.runtime.modulesDir, module.moduleId); + + await this.auditService.record({ + userId: actor.id, + username: actor.username, + action: AUDIT_ACTIONS.MODULE_REMOVED, + details: { moduleId: module.moduleId }, + ipAddress, + }); + } + + /** Führt einen Healthcheck für ein Modul aus (ohne Statusänderung). */ + async checkHealth(id: string): Promise<{ module: ModuleRecord; health: Awaited> }> { + const module = await this.getById(id); + const health = await this.healthChecker.check(module); + return { module, health }; + } + + private assertEnabled(module: ModuleRecord): void { + if (!module.enabled) { + throw new BadRequestException('Modul ist deaktiviert'); + } + } + + private async auditLifecycle( + module: ModuleRecord, + action: AuditAction, + actor: ActingUser, + ipAddress: string | null, + ): Promise { + await this.auditService.record({ + userId: actor.id, + username: actor.username, + action, + details: { moduleId: module.moduleId, version: module.version }, + ipAddress, + }); + } +} \ No newline at end of file diff --git a/apps/platform-frontend/src/components/layout/app-layout.tsx b/apps/platform-frontend/src/components/layout/app-layout.tsx index a224cf5..13af622 100644 --- a/apps/platform-frontend/src/components/layout/app-layout.tsx +++ b/apps/platform-frontend/src/components/layout/app-layout.tsx @@ -15,6 +15,7 @@ const NAV_ITEMS: NavItem[] = [ { to: '/', label: 'Dashboard', icon: '⌂' }, { to: '/profile', label: 'Mein Profil', icon: '👤' }, { to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true }, + { to: '/admin/modules', label: 'Module', icon: '🧩', adminOnly: true }, { to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true }, ]; diff --git a/apps/platform-frontend/src/features/admin/modules-page.tsx b/apps/platform-frontend/src/features/admin/modules-page.tsx new file mode 100644 index 0000000..4c5576e --- /dev/null +++ b/apps/platform-frontend/src/features/admin/modules-page.tsx @@ -0,0 +1,352 @@ +import { type ReactNode, useRef, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Button } from '../../components/ui/button'; +import { Modal } from '../../components/ui/modal'; +import { useToast } from '../../components/ui/toast'; +import { ApiError } from '../../lib/api-client'; +import { + checkModuleHealth, + fetchModules, + installModule, + removeModule, + restartModule, + setModuleEnabled, + startModule, + stopModule, +} from '../../lib/modules-api'; +import type { Module, ModuleStatus } from '../../lib/schemas'; + +/** Status-Badge-Farben je Lifecycle-Zustand. */ +function statusVariant(status: ModuleStatus): 'success' | 'warning' | 'danger' | 'neutral' | 'info' { + switch (status) { + case 'RUNNING': + return 'success'; + case 'STARTING': + case 'STOPPING': + return 'warning'; + case 'ERROR': + return 'danger'; + case 'DISABLED': + return 'neutral'; + default: + return 'info'; + } +} + +/** Bestätigungsdialog: Modul entfernen. */ +function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () => void }): ReactNode { + const { showToast } = useToast(); + const queryClient = useQueryClient(); + const [formError, setFormError] = useState(null); + + const removeMutation = useMutation({ + mutationFn: () => removeModule(module.id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['modules'] }); + showToast('success', `Modul "${module.name}" wurde entfernt`); + onClose(); + }, + onError: (error) => { + setFormError(error instanceof ApiError ? error.message : 'Entfernen fehlgeschlagen'); + }, + }); + + return ( + + {formError && ( +

+ {formError} +

+ )} +
+ + +
+
+ ); +} + +/** Modul-Verwaltung (nur Admin): Installation, Lifecycle, Healthchecks. */ +export function ModulesPage(): ReactNode { + const { showToast } = useToast(); + const queryClient = useQueryClient(); + const fileInputRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); + const [removeTarget, setRemoveTarget] = useState(null); + const [healthResults, setHealthResults] = useState>({}); + + const modulesQuery = useQuery({ + queryKey: ['modules'], + queryFn: fetchModules, + refetchInterval: 15_000, + }); + + const invalidate = (): void => { + void queryClient.invalidateQueries({ queryKey: ['modules'] }); + }; + + const installMutation = useMutation({ + mutationFn: (file: File) => installModule(file), + onSuccess: (module) => { + showToast('success', `Modul "${module.name}" wurde installiert`); + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + invalidate(); + }, + onError: (error) => { + showToast('error', error instanceof ApiError ? error.message : 'Installation fehlgeschlagen'); + }, + }); + + const lifecycleMutation = useMutation({ + mutationFn: async (input: { module: Module; action: 'start' | 'stop' | 'restart' | 'enable' | 'disable' }) => { + switch (input.action) { + case 'start': + return startModule(input.module.id); + case 'stop': + return stopModule(input.module.id); + case 'restart': + return restartModule(input.module.id); + case 'enable': + return setModuleEnabled(input.module.id, true); + case 'disable': + return setModuleEnabled(input.module.id, false); + } + }, + onSuccess: (module, variables) => { + showToast('success', `Modul "${module.name}" – Aktion "${variables.action}" erfolgreich`); + invalidate(); + }, + onError: (error) => { + showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen'); + invalidate(); + }, + }); + + const healthMutation = useMutation({ + mutationFn: (module: Module) => checkModuleHealth(module.id), + onSuccess: (result, module) => { + setHealthResults((current) => ({ + ...current, + [module.id]: { healthy: result.healthy, detail: result.detail }, + })); + showToast( + result.healthy ? 'success' : 'error', + `Healthcheck "${module.name}": ${result.detail}`, + ); + }, + }); + + function handleFileChange(event: React.ChangeEvent): void { + setSelectedFile(event.target.files?.[0] ?? null); + } + + function handleInstall(): void { + if (selectedFile) { + installMutation.mutate(selectedFile); + } + } + + return ( +
+
+

Modulverwaltung

+

+ Module installieren, starten, stoppen und entfernen. +

+
+ + {/* Installation */} +
+
+ + +

+ ZIP-Paket mit module.json (Manifest). Maximal 10 MB. +

+
+
+ + {/* Modul-Liste */} +
+ + + + + + + + + + + {modulesQuery.isLoading && ( + + + + )} + {modulesQuery.isError && ( + + + + )} + {modulesQuery.data?.map((module) => ( + + + + + + + ))} + +
ModulStatusURLAktionen
+ Module werden geladen… +
+ Module konnten nicht geladen werden. +
+
{module.name}
+
+ {module.moduleId} · Version {module.version} + {module.author && ` · ${module.author}`} +
+ {module.description && ( +
{module.description}
+ )} +
+ + {module.status} + + {healthResults[module.id] && ( +
+ Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail} +
+ )} +
+ + /{module.slug} + + +
+ {module.status !== 'RUNNING' && module.enabled && ( + + )} + {(module.status === 'RUNNING' || module.status === 'STARTING') && ( + + )} + + + + +
+
+ {modulesQuery.data?.length === 0 && ( +

+ Noch keine Module installiert. +

+ )} +
+ + {removeTarget && ( + setRemoveTarget(null)} /> + )} +
+ ); +} \ No newline at end of file diff --git a/apps/platform-frontend/src/lib/modules-api.ts b/apps/platform-frontend/src/lib/modules-api.ts new file mode 100644 index 0000000..3b5b17a --- /dev/null +++ b/apps/platform-frontend/src/lib/modules-api.ts @@ -0,0 +1,100 @@ +import { apiRequest, ApiError } from './api-client'; +import { moduleSchema, type Module } from './schemas'; + +/** Typsichere API-Funktionen für die Modul-Verwaltung. */ + +export async function fetchModules(): Promise { + const response = await apiRequest<{ modules: unknown[] }>('/api/v1/modules'); + return response.modules.map((module) => moduleSchema.parse(module)); +} + +export async function installModule(file: File): Promise { + const formData = new FormData(); + formData.append('package', file); + + const headers: Record = {}; + const csrfToken = readCsrfToken(); + if (csrfToken) { + headers['X-CSRF-Token'] = csrfToken; + } + + const response = await fetch('/api/v1/modules/install', { + method: 'POST', + headers, + credentials: 'same-origin', + body: formData, + }); + + if (!response.ok) { + let message = 'Installation fehlgeschlagen'; + let details: Record | undefined; + try { + const errorBody = (await response.json()) as { + message?: string | string[]; + details?: Record; + }; + if (typeof errorBody.message === 'string') { + message = errorBody.message; + } else if (Array.isArray(errorBody.message)) { + message = errorBody.message.join(', '); + } + details = errorBody.details; + } catch { + // Kein JSON in der Antwort – Standardmeldung verwenden. + } + throw new ApiError(response.status, message, details); + } + + const response_ = (await response.json()) as { module: unknown }; + return moduleSchema.parse(response_.module); +} + +export async function startModule(id: string): Promise { + const response = await apiRequest<{ module: unknown }>(`/api/v1/modules/${id}/start`, { + method: 'POST', + }); + return moduleSchema.parse(response.module); +} + +export async function stopModule(id: string): Promise { + const response = await apiRequest<{ module: unknown }>(`/api/v1/modules/${id}/stop`, { + method: 'POST', + }); + return moduleSchema.parse(response.module); +} + +export async function restartModule(id: string): Promise { + const response = await apiRequest<{ module: unknown }>(`/api/v1/modules/${id}/restart`, { + method: 'POST', + }); + return moduleSchema.parse(response.module); +} + +export async function setModuleEnabled(id: string, enabled: boolean): Promise { + const response = await apiRequest<{ module: unknown }>(`/api/v1/modules/${id}/enabled`, { + method: 'PATCH', + body: { enabled }, + }); + return moduleSchema.parse(response.module); +} + +export async function removeModule(id: string): Promise { + await apiRequest(`/api/v1/modules/${id}`, { method: 'DELETE' }); +} + +export async function checkModuleHealth( + id: string, +): Promise<{ healthy: boolean; latencyMs: number; detail: string }> { + return apiRequest(`/api/v1/modules/${id}/health`); +} + +/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */ +function readCsrfToken(): string | null { + for (const part of document.cookie.split(';')) { + const [name, ...value] = part.trim().split('='); + if (name === 'mpm_csrf') { + return decodeURIComponent(value.join('=')); + } + } + return null; +} \ No newline at end of file diff --git a/apps/platform-frontend/src/lib/schemas.ts b/apps/platform-frontend/src/lib/schemas.ts index 3c2938e..6b657af 100644 --- a/apps/platform-frontend/src/lib/schemas.ts +++ b/apps/platform-frontend/src/lib/schemas.ts @@ -106,4 +106,33 @@ export const changePasswordSchema = z message: 'Passwörter stimmen nicht überein', path: ['confirmPassword'], }); -export type ChangePasswordDto = z.infer; \ No newline at end of file +export type ChangePasswordDto = z.infer; + +/** Modul-Lifecycle-Zustände (muss zum Backend passen). */ +export const MODULE_STATUSES = [ + 'INSTALLED', + 'STARTING', + 'RUNNING', + 'STOPPING', + 'STOPPED', + 'ERROR', + 'DISABLED', +] as const; +export type ModuleStatus = (typeof MODULE_STATUSES)[number]; + +/** Modul-Datensatz der Admin-API (/api/v1/modules). */ +export const moduleSchema = z.object({ + id: z.string(), + moduleId: z.string(), + name: z.string(), + slug: z.string(), + version: z.string(), + description: z.string(), + author: z.string(), + status: z.enum(MODULE_STATUSES), + internalPort: z.number(), + healthcheckUrl: z.string(), + enabled: z.boolean(), + createdAt: z.string(), +}); +export type Module = z.infer; \ No newline at end of file diff --git a/apps/platform-frontend/src/main.tsx b/apps/platform-frontend/src/main.tsx index c1fc4be..4c5defe 100644 --- a/apps/platform-frontend/src/main.tsx +++ b/apps/platform-frontend/src/main.tsx @@ -10,6 +10,7 @@ import { LoginPage } from './features/auth/login-page'; import { DashboardPage } from './features/dashboard/dashboard-page'; import { SystemStatusPage } from './features/admin/system-status-page'; import { UsersPage } from './features/admin/users-page'; +import { ModulesPage } from './features/admin/modules-page'; import { ProfilePage } from './features/profile/profile-page'; import { ForbiddenPage, NotFoundPage } from './pages/error-pages'; import './index.css'; @@ -38,6 +39,10 @@ function AppRoutes(): ReactNode { path="/admin/users" element={} /> + } + /> } diff --git a/docker-compose.yml b/docker-compose.yml index 5aee0bd..e3451cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,11 +39,19 @@ services: SESSION_TTL_MINUTES: ${SESSION_TTL_MINUTES:-120} COOKIE_SECURE: ${COOKIE_SECURE:-false} BEHIND_PROXY: "true" + MODULES_DIR: /app/data/modules + LOGS_DIR: /app/data/logs ADMIN_USERNAME: ${ADMIN_USERNAME:?Bitte ADMIN_USERNAME in .env setzen} ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL in .env setzen} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Bitte ADMIN_PASSWORD in .env setzen} ports: - "${APP_PORT:-8080}:8080" + volumes: + # Modul-Installationen und Logs persistent halten (Container-Updates überleben) + - modules-data:/app/data/modules + - module-logs:/app/data/logs volumes: - postgres-data: \ No newline at end of file + postgres-data: + modules-data: + module-logs: \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f674cb8..9b88dfe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -142,8 +142,17 @@ Security Hardening (Penetrationstests, Dependency/Container-Scans, HSTS, Backup/ | DELETE | `/api/v1/users/:id` | Admin | Benutzer löschen | | GET | `/api/v1/profile` | Session | Eigenes Profil | | PATCH | `/api/v1/profile/password` | Session | Eigenes Passwort ändern | +| GET | `/api/v1/modules` | Admin | Alle Module | +| POST | `/api/v1/modules/install` | Admin | ZIP-Paket installieren (multipart, Feld `package`) | +| GET | `/api/v1/modules/:id` | Admin | Einzelnes Modul | +| POST | `/api/v1/modules/:id/start` | Admin | Modul starten | +| POST | `/api/v1/modules/:id/stop` | Admin | Modul stoppen | +| POST | `/api/v1/modules/:id/restart` | Admin | Modul neu starten | +| PATCH | `/api/v1/modules/:id/enabled` | Admin | Modul aktivieren/deaktivieren | +| GET | `/api/v1/modules/:id/health` | Admin | Healthcheck ausführen | +| DELETE | `/api/v1/modules/:id` | Admin | Modul entfernen | -OpenAPI/Swagger UI: `/api/docs` · Ab Phase 3: `/api/v1/modules/*`, `/api/v1/permissions/*`, `/api/v1/audit/*`. +OpenAPI/Swagger UI: `/api/docs` · Ab Phase 4: dynamisches Routing `/slug`. ### Schutzregeln der Benutzerverwaltung @@ -153,9 +162,11 @@ OpenAPI/Swagger UI: `/api/docs` · Ab Phase 3: `/api/v1/modules/*`, `/api/v1/per - Passwort-Reset (Admin) meldet den Benutzer von allen Sessions ab - Eigenes Passwort ändern meldet alle übrigen Sessions ab (aktuelle bleibt aktiv) -## 9. Modul-Vertrag (Ausblick Phase 3+) +## 9. Modul-System (Phase 3) -Jedes Modul liefert ein Manifest (`module.json`) und einen minimalen API-Vertrag: +### Manifest-Vertrag + +Jedes Modul-Paket (ZIP, max. 10 MB) enthält ein `module.json`: ```json { @@ -164,6 +175,7 @@ Jedes Modul liefert ein Manifest (`module.json`) und einen minimalen API-Vertrag "version": "1.0.0", "slug": "kalender-tool", "description": "Kalenderverwaltung", + "author": "…", "runtime": "node", "entrypoint": "server.js", "port": 41001, @@ -172,7 +184,27 @@ Jedes Modul liefert ein Manifest (`module.json`) und einen minimalen API-Vertrag } ``` -Minimal-API je Modul: `GET /health`, `GET /api/manifest`, `GET /api/me`. Installation als ZIP-Paket mit Validierung, Dependency-Installation, Migrationen und Healthcheck. Details: [`modules/README.md`](../modules/README.md). +Validierung per Zod: ID/Slug-Muster, SemVer, Port 41000–41999, kein Pfad-Traversal im Entrypoint, Runtime `node`, apiVersion `v1`. + +### Lifecycle + +``` +INSTALLED → STARTING → RUNNING → STOPPING → STOPPED + ↓ ↓ + ERROR ERROR DISABLED (via Enable/Disable) +``` + +### Sicherheitsmaßnahmen + +- **Zip-Slip-Schutz**: Alle ZIP-Einträge müssen im Zielverzeichnis bleiben +- **Minimale Prozess-ENV**: Module erhalten nur `PATH`, `NODE_ENV`, `PORT` – keine Plattform-Secrets +- **Spawn ohne Shell**: keine Command-Injection möglich +- **Eigene Log-Dateien** pro Modul (`/app/data/logs/module-.log`) +- **Startup-Grace**: Healthcheck mit 10 Retries, bevor ein Modul als ERROR gilt +- **Isolation**: Ein abgestürztes Modul (ERROR) beeinträchtigt weder Plattform noch andere Module +- Persistente Volumes: Modul-Dateien und Logs überleben Container-Updates + +Minimal-API je Modul (Phase 6): `GET /health`, `GET /api/manifest`, `GET /api/me`. Details zum Paketformat: [`modules/README.md`](../modules/README.md). ## 10. Architektur-Entscheidungen (ADR-Kurzform) diff --git a/docs/PHASES.md b/docs/PHASES.md index 0964910..9643eab 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -6,7 +6,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) | ⏳ Geplant | +| 3 | Modul-System (Manifest, Installation, Lifecycle) | ✅ Abgeschlossen | | 4 | Gateway & dynamisches Routing (`/slug`) | ⏳ Geplant | | 5 | Berechtigungssystem (User ↔ Module) | ⏳ Geplant | | 6 | Modul-API (`/health`, `/api/manifest`, `/api/me`) | ⏳ Geplant | @@ -51,10 +51,26 @@ Definition of Done: Admin kann Benutzer vollständig verwalten. - [x] Backend-Tests: 49 bestanden (inkl. UsersService, ProfileService) - [x] E2E verifiziert: CRUD, RBAC (User → 403), Login-Sperre nach Deaktivierung, Passwort-Flows -## Nächste Schritte (Phase 3 – Modul-System) +## Phase 3 – Modul-System (abgeschlossen) -- Modul-Datenmodell (`modules`-Tabelle) und Manifest-Vertrag (`module.json`) -- Modul-Installation als ZIP-Paket (Validierung, Dateien, Registrierung) -- Modul-Lifecycle (INSTALLED/STARTING/RUNNING/STOPPING/STOPPED/ERROR/DISABLED) -- Prozessverwaltung der Modul-Prozesse über Supervisor -- Healthchecks und Statusanzeige im Admin-Bereich \ No newline at end of file +Definition of Done: Modul-Datenmodell, Manifest, Installation, Registrierung, Status, Start/Stop/Restart, Healthcheck. + +- [x] `modules`-Tabelle (Migration 002) mit Lifecycle-Status und eindeutigen Ports/Slugs +- [x] Manifest-Vertrag `module.json` (Zod: ID, Name, Version, Slug, Runtime, Entrypoint, Port 41000–41999, Healthcheck, apiVersion) +- [x] ZIP-Installer mit Manifest-Validierung, Größenlimit (10 MB) und **Zip-Slip-Schutz** +- [x] Modul-Prozess-Manager: Start/Stop (SIGTERM→SIGKILL) als Kindprozesse, minimale ENV (keine Plattform-Secrets), eigene Log-Dateien +- [x] Healthcheck-Service mit Startup-Grace (10 Retries × 500 ms) +- [x] Lifecycle: INSTALLED → STARTING → RUNNING → STOPPING → STOPPED, ERROR, DISABLED +- [x] `GET/POST /api/v1/modules`, `POST :id/start|stop|restart`, `PATCH :id/enabled`, `GET :id/health`, `DELETE :id` (nur ADMIN) +- [x] Duplikat-Schutz: Modul-ID, Slug und Port müssen eindeutig sein (409) +- [x] Frontend: Modulverwaltungs-Seite (ZIP-Upload, Tabelle mit Status-Badges, Lifecycle-Buttons, Health-Anzeige, Remove-Dialog) +- [x] Persistente Volumes für Modul-Dateien und Logs (`modules-data`, `module-logs`) +- [x] Demo-Modul (`modules/demo`) als Referenzimplementierung +- [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) + +- 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 \ No newline at end of file diff --git a/modules/demo/backend/server.js b/modules/demo/backend/server.js new file mode 100644 index 0000000..82ccd77 --- /dev/null +++ b/modules/demo/backend/server.js @@ -0,0 +1,50 @@ +/** + * Demo-Modul: Minimaler HTTP-Server, der den Modul-API-Vertrag erfüllt. + * Läuft als eigener Node-Prozess im Management-Container (Port via ENV PORT). + */ + +const http = require('node:http'); + +const PORT = Number(process.env.PORT ?? 41001); + +/** Manifest des Moduls (identisch zu module.json). */ +const MANIFEST = { + moduleId: 'demo', + version: '1.0.0', + status: 'healthy', +}; + +const server = http.createServer((request, response) => { + const url = new URL(request.url ?? '/', `http://127.0.0.1:${PORT}`); + + if (url.pathname === '/health') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ moduleId: 'demo', version: '1.0.0', status: 'healthy' })); + return; + } + + if (url.pathname === '/api/manifest') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(MANIFEST)); + return; + } + + if (url.pathname === '/api/me') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + user: { id: 'demo', username: 'demo' }, + module: { id: 'demo' }, + permissions: [], + }), + ); + return; + } + + response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Demo-Modul läuft'); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`Demo-Modul läuft auf Port ${PORT}`); +}); \ No newline at end of file diff --git a/modules/demo/demo-module.zip b/modules/demo/demo-module.zip new file mode 100644 index 0000000..123e415 Binary files /dev/null and b/modules/demo/demo-module.zip differ diff --git a/modules/demo/module.json b/modules/demo/module.json new file mode 100644 index 0000000..8c21ad4 --- /dev/null +++ b/modules/demo/module.json @@ -0,0 +1,13 @@ +{ + "id": "demo", + "name": "Demo-Modul", + "version": "1.0.0", + "slug": "demo", + "description": "Referenzmodul zum Testen des Modul-Systems", + "author": "MPM", + "runtime": "node", + "entrypoint": "server.js", + "port": 41001, + "healthcheck": "/health", + "apiVersion": "v1" +} \ No newline at end of file