feat: Phase 3 – Modul-System (Manifest, ZIP-Installation, Lifecycle, Prozess-Manager)

This commit is contained in:
MPM Dev
2026-10-07 15:37:09 +02:00
parent e87dc1f836
commit 4bbca21fd6
31 changed files with 2055 additions and 19 deletions

View File

@@ -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",

View File

@@ -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"
}
}
}

View File

@@ -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 },

View File

@@ -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];

View File

@@ -25,6 +25,7 @@ function createConfig(overrides: Partial<AppConfig['security']> = {}): AppConfig
...overrides,
},
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' },
};
}

View File

@@ -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),
},
};
}

View File

@@ -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];
export const MIGRATIONS = [migration001CoreSchema, migration002Modules];

View File

@@ -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);
});
});

View File

@@ -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<typeof moduleManifestSchema>;
/** 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;
}

View File

@@ -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)');
},
};

View File

@@ -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<ModuleHealthResult> {
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}` };
}
}
}

View File

@@ -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<ModuleManifest> {
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<string, string[]> = {};
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<void> {
const directory = path.join(modulesDir, moduleId);
await rm(directory, { recursive: true, force: true });
}
/** Liest das Manifest einer bestehenden Installation. */
async readInstalledManifest(directory: string): Promise<ModuleManifest | null> {
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;
}
}
}

View File

@@ -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<string, RunningProcess>();
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
/** Startet einen Modul-Prozess. */
async start(module: ModuleRecord): Promise<void> {
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<void> {
const process_ = this.running.get(moduleId);
if (!process_) {
return;
}
this.running.delete(moduleId);
const child = process_.child;
await new Promise<void>((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<void> {
const moduleIds = [...this.running.keys()];
await Promise.all(moduleIds.map((moduleId) => this.stop(moduleId)));
}
async onModuleDestroy(): Promise<void> {
await this.stopAll();
}
}

View File

@@ -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<ModuleRecord[]> {
const result = await this.database.query<ModuleRow>(
`SELECT ${MODULE_COLUMNS} FROM modules ORDER BY created_at ASC`,
);
return result.rows.map((row) => this.mapRow(row));
}
async findByModuleId(moduleId: string): Promise<ModuleRecord | null> {
const result = await this.database.query<ModuleRow>(
`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<ModuleRecord | null> {
const result = await this.database.query<ModuleRow>(
`SELECT ${MODULE_COLUMNS} FROM modules WHERE slug = $1`,
[slug],
);
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
}
async findByPort(port: number): Promise<ModuleRecord | null> {
const result = await this.database.query<ModuleRow>(
`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<ModuleRecord | null> {
const result = await this.database.query<ModuleRow>(
`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<ModuleRecord[]> {
const result = await this.database.query<ModuleRow>(
`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<ModuleRecord> {
const result = await this.database.query<ModuleRow>(
`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<void> {
await this.database.query(
'UPDATE modules SET status = $2, updated_at = now() WHERE id = $1',
[id, status],
);
}
async updateEnabled(id: string, enabled: boolean): Promise<void> {
await this.database.query(
'UPDATE modules SET enabled = $2, updated_at = now() WHERE id = $1',
[id, enabled],
);
}
async delete(id: string): Promise<void> {
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,
};
}
}

View File

@@ -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 };
}
}

View File

@@ -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 {}

View File

@@ -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> = {}): 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> = {}): 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<ModuleRecord[]> {
return this.modules;
}
async findById(id: string): Promise<ModuleRecord | null> {
return this.modules.find((module) => module.id === id) ?? null;
}
async findByModuleId(moduleId: string): Promise<ModuleRecord | null> {
return this.modules.find((module) => module.moduleId === moduleId) ?? null;
}
async findBySlug(slug: string): Promise<ModuleRecord | null> {
return this.modules.find((module) => module.slug === slug) ?? null;
}
async findByPort(port: number): Promise<ModuleRecord | null> {
return this.modules.find((module) => module.internalPort === port) ?? null;
}
async create(manifest: ModuleManifest, directory: string): Promise<ModuleRecord> {
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<void> {
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<void> {
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<void> {
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<ModuleManifest> {
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<void> {
this.removed.push(moduleId);
}
}
/** Mock des ModuleProcessManager. */
class MockProcessManager {
public started: string[] = [];
public stopped: string[] = [];
async start(module: ModuleRecord): Promise<void> {
this.started.push(module.moduleId);
}
async stop(moduleId: string): Promise<void> {
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<void> {
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);
});
});
});

View File

@@ -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<ModuleRecord[]> {
return this.moduleRepository.list();
}
async getById(id: string): Promise<ModuleRecord> {
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<ModuleRecord> {
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<ModuleRecord> {
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<ModuleRecord> {
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<ModuleRecord> {
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<ModuleRecord> {
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<void> {
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<ReturnType<ModuleHealthChecker['check']>> }> {
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<void> {
await this.auditService.record({
userId: actor.id,
username: actor.username,
action,
details: { moduleId: module.moduleId, version: module.version },
ipAddress,
});
}
}

View File

@@ -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 },
];

View File

@@ -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<string | null>(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 (
<Modal
open
title="Modul entfernen"
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? Dateien und Registrierung werden gelöscht.`}
onClose={onClose}
>
{formError && (
<p role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="danger"
loading={removeMutation.isPending}
onClick={() => removeMutation.mutate()}
>
Endgültig entfernen
</Button>
</div>
</Modal>
);
}
/** Modul-Verwaltung (nur Admin): Installation, Lifecycle, Healthchecks. */
export function ModulesPage(): ReactNode {
const { showToast } = useToast();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [removeTarget, setRemoveTarget] = useState<Module | null>(null);
const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({});
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<HTMLInputElement>): void {
setSelectedFile(event.target.files?.[0] ?? null);
}
function handleInstall(): void {
if (selectedFile) {
installMutation.mutate(selectedFile);
}
}
return (
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Modulverwaltung</h1>
<p className="mt-1 text-sm text-slate-500">
Module installieren, starten, stoppen und entfernen.
</p>
</div>
{/* Installation */}
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleFileChange}
className="text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
aria-label="Modul-Paket (ZIP) auswählen"
/>
<Button
disabled={!selectedFile}
loading={installMutation.isPending}
onClick={handleInstall}
>
Installieren
</Button>
<p className="text-xs text-slate-500">
ZIP-Paket mit module.json (Manifest). Maximal 10 MB.
</p>
</div>
</div>
{/* Modul-Liste */}
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="w-full min-w-[760px] text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<th className="px-4 py-3 font-semibold">Modul</th>
<th className="px-4 py-3 font-semibold">Status</th>
<th className="px-4 py-3 font-semibold">URL</th>
<th className="px-4 py-3 font-semibold">Aktionen</th>
</tr>
</thead>
<tbody>
{modulesQuery.isLoading && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-slate-500">
Module werden geladen…
</td>
</tr>
)}
{modulesQuery.isError && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-red-600">
Module konnten nicht geladen werden.
</td>
</tr>
)}
{modulesQuery.data?.map((module) => (
<tr key={module.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-slate-900">{module.name}</div>
<div className="text-xs text-slate-500">
{module.moduleId} · Version {module.version}
{module.author && ` · ${module.author}`}
</div>
{module.description && (
<div className="mt-0.5 text-xs text-slate-500">{module.description}</div>
)}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
${
statusVariant(module.status) === 'success'
? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20'
: statusVariant(module.status) === 'warning'
? 'bg-amber-50 text-amber-700 ring-amber-600/20'
: statusVariant(module.status) === 'danger'
? 'bg-red-50 text-red-700 ring-red-600/20'
: statusVariant(module.status) === 'neutral'
? 'bg-slate-100 text-slate-600 ring-slate-500/20'
: 'bg-brand-50 text-brand-700 ring-brand-600/20'
}`}
>
{module.status}
</span>
{healthResults[module.id] && (
<div className="mt-1 text-xs text-slate-500">
Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}
</div>
)}
</td>
<td className="px-4 py-3">
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">
/{module.slug}
</code>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{module.status !== 'RUNNING' && module.enabled && (
<Button
size="sm"
variant="ghost"
loading={
lifecycleMutation.isPending &&
lifecycleMutation.variables?.module.id === module.id &&
lifecycleMutation.variables?.action === 'start'
}
onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}
>
Start
</Button>
)}
{(module.status === 'RUNNING' || module.status === 'STARTING') && (
<Button
size="sm"
variant="ghost"
loading={
lifecycleMutation.isPending &&
lifecycleMutation.variables?.module.id === module.id &&
lifecycleMutation.variables?.action === 'stop'
}
onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}
>
Stop
</Button>
)}
<Button
size="sm"
variant="ghost"
loading={
lifecycleMutation.isPending &&
lifecycleMutation.variables?.module.id === module.id &&
lifecycleMutation.variables?.action === 'restart'
}
onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}
>
Restart
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => healthMutation.mutate(module)}
>
Health
</Button>
<Button
size="sm"
variant="ghost"
loading={
lifecycleMutation.isPending &&
lifecycleMutation.variables?.module.id === module.id &&
(lifecycleMutation.variables?.action === 'enable' ||
lifecycleMutation.variables?.action === 'disable')
}
onClick={() =>
lifecycleMutation.mutate({
module,
action: module.enabled ? 'disable' : 'enable',
})
}
>
{module.enabled ? 'Disable' : 'Enable'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setRemoveTarget(module)}>
Remove
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{modulesQuery.data?.length === 0 && (
<p className="px-4 py-8 text-center text-slate-500">
Noch keine Module installiert.
</p>
)}
</div>
{removeTarget && (
<RemoveModuleModal module={removeTarget} onClose={() => setRemoveTarget(null)} />
)}
</div>
);
}

View File

@@ -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<Module[]> {
const response = await apiRequest<{ modules: unknown[] }>('/api/v1/modules');
return response.modules.map((module) => moduleSchema.parse(module));
}
export async function installModule(file: File): Promise<Module> {
const formData = new FormData();
formData.append('package', file);
const headers: Record<string, string> = {};
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<string, string | string[]> | undefined;
try {
const errorBody = (await response.json()) as {
message?: string | string[];
details?: Record<string, string | string[]>;
};
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<Module> {
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<Module> {
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<Module> {
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<Module> {
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<void> {
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;
}

View File

@@ -106,4 +106,33 @@ export const changePasswordSchema = z
message: 'Passwörter stimmen nicht überein',
path: ['confirmPassword'],
});
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
/** 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<typeof moduleSchema>;

View File

@@ -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={<RequireAdmin><UsersPage /></RequireAdmin>}
/>
<Route
path="/admin/modules"
element={<RequireAdmin><ModulesPage /></RequireAdmin>}
/>
<Route
path="/admin/system"
element={<RequireAdmin><SystemStatusPage /></RequireAdmin>}