2 Commits

Author SHA1 Message Date
leon
21e2ccefb2 added new features 3.0 2026-10-10 15:51:30 +02:00
leon
84d8697f23 Polish module actions and marketplace updates 2026-10-09 23:43:05 +02:00
48 changed files with 1218 additions and 323 deletions

View File

@@ -10,7 +10,7 @@ POSTGRES_PASSWORD=<sicheres-passwort>
POSTGRES_DB=mpm POSTGRES_DB=mpm
# --- Plattform-Container --------------------------------------- # --- Plattform-Container ---------------------------------------
NODE_ENV=production NODE_ENV=development
PORT=3000 PORT=3000
# Host-Port, unter dem die Plattform erreichbar ist # Host-Port, unter dem die Plattform erreichbar ist
APP_PORT=8080 APP_PORT=8080
@@ -22,7 +22,7 @@ DATABASE_URL=postgresql://mpm:<sicheres-passwort>@postgres:5432/mpm
# Session-Gültigkeit in Minuten (kurz halten) # Session-Gültigkeit in Minuten (kurz halten)
SESSION_TTL_MINUTES=120 SESSION_TTL_MINUTES=120
# "true" sobald die Plattform hinter HTTPS/TLS betrieben wird # "true" sobald die Plattform hinter HTTPS/TLS betrieben wird
COOKIE_SECURE=true COOKIE_SECURE=false
# "true", wenn ein Reverse Proxy (Nginx im Container) vorgeschaltet ist # "true", wenn ein Reverse Proxy (Nginx im Container) vorgeschaltet ist
BEHIND_PROXY=true BEHIND_PROXY=true
@@ -46,6 +46,9 @@ ADMIN_PASSWORD=<mindestens-10-zeichen>
# --- Marketplace OAuth (optional) -------------------------------- # --- Marketplace OAuth (optional) --------------------------------
# Lokal: Host-Adresse einschließlich APP_PORT; Callback-Pfad wird von MPM ergänzt. # Lokal: Host-Adresse einschließlich APP_PORT; Callback-Pfad wird von MPM ergänzt.
MARKETPLACE_PUBLIC_URL=http://127.0.0.1:8080 MARKETPLACE_PUBLIC_URL=http://127.0.0.1:8080
# Browser-Origin für Module. Leer = lokal automatisch localhost/127.0.0.1,
# bei Domains automatisch modules.<Plattformhost>. DNS und TLS dafür einrichten.
MODULE_PUBLIC_ORIGIN=
# Zufälliger, dauerhafter Wert (mindestens 32 Zeichen), z. B. openssl rand -base64 32. # Zufälliger, dauerhafter Wert (mindestens 32 Zeichen), z. B. openssl rand -base64 32.
MARKETPLACE_TOKEN_ENCRYPTION_KEY= MARKETPLACE_TOKEN_ENCRYPTION_KEY=
# OAuth-App Callback: http://127.0.0.1:8080/api/v1/marketplace/oauth/github/callback # OAuth-App Callback: http://127.0.0.1:8080/api/v1/marketplace/oauth/github/callback

View File

@@ -44,7 +44,10 @@ RUN groupadd --gid 1001 app \
&& 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 \ && 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 && chown -R app:app /tmp/nginx /var/log/supervisor /app/data
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf.template
COPY docker/nginx/render-config.cjs /usr/local/lib/mpm/render-nginx-config.cjs
COPY docker/nginx/start-nginx.sh /usr/local/bin/start-mpm-nginx
RUN chmod +x /usr/local/bin/start-mpm-nginx
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
COPY --from=backend-build --chown=app:app /build/dist /app/platform-backend/dist COPY --from=backend-build --chown=app:app /build/dist /app/platform-backend/dist
@@ -56,6 +59,6 @@ USER root
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:8080/api/v1/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" CMD node -e "const http=require('node:http');const host=new URL(process.env.MARKETPLACE_PUBLIC_URL??'http://127.0.0.1:8080').host;http.get({hostname:'127.0.0.1',port:8080,path:'/api/v1/health',headers:{Host:host}},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"] CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"]

View File

@@ -37,7 +37,7 @@ Für lokale Frontend-/Backend-Entwicklung außerhalb von Docker zusätzlich Node
6. Bei rein lokaler HTTP-Entwicklung `NODE_ENV=development` und `COOKIE_SECURE=false` verwenden. In Produktion muss HTTPS aktiv sein und `COOKIE_SECURE=true` gesetzt werden. 6. Bei rein lokaler HTTP-Entwicklung `NODE_ENV=development` und `COOKIE_SECURE=false` verwenden. In Produktion muss HTTPS aktiv sein und `COOKIE_SECURE=true` gesetzt werden.
7. Für OAuth-Entwicklung sind pro Provider eigene OAuth-Clientdaten mit passender Callback-URL nötig. Ohne OAuth-Konfiguration können die übrigen Plattformfunktionen lokal verwendet werden; Provider dürfen nicht mit unvollständiger Konfiguration gesetzt werden. Bei aktivem OAuth einen dauerhaften `MARKETPLACE_TOKEN_ENCRYPTION_KEY` mit mindestens 32 Zeichen lokal generieren und geheim halten. 7. Für OAuth-Entwicklung sind pro Provider eigene OAuth-Clientdaten mit passender Callback-URL nötig. Ohne OAuth-Konfiguration können die übrigen Plattformfunktionen lokal verwendet werden; Provider dürfen nicht mit unvollständiger Konfiguration gesetzt werden. Bei aktivem OAuth einen dauerhaften `MARKETPLACE_TOKEN_ENCRYPTION_KEY` mit mindestens 32 Zeichen lokal generieren und geheim halten.
Für Modulkonfigurationen zusätzlich einen dauerhaften `MODULE_CONFIG_ENCRYPTION_KEY` mit mindestens 32 Zeichen generieren und geheim halten. Ohne diesen Schlüssel lassen sich gespeicherte Modul-Secrets nicht entschlüsseln; bei Schlüsselverlust oder Rotation müssen die Modulkonfigurationen erneuert werden. Für Modulkonfigurationen zusätzlich einen dauerhaften `MODULE_CONFIG_ENCRYPTION_KEY` mit mindestens 32 Zeichen generieren und geheim halten. Ohne diesen Schlüssel lassen sich gespeicherte Modul-Secrets nicht entschlüsseln; bei Schlüsselverlust oder Rotation müssen die Modulkonfigurationen erneuert werden.
8. `APP_PORT` bei Bedarf anpassen, falls 8080 belegt ist. `MARKETPLACE_PUBLIC_URL` muss die vom Browser erreichbare Basisadresse samt Port enthalten, etwa `http://127.0.0.1:8080`. 8. `APP_PORT` bei Bedarf anpassen, falls 8080 belegt ist. `MARKETPLACE_PUBLIC_URL` muss die vom Browser erreichbare Basisadresse samt Port enthalten, etwa `http://127.0.0.1:8080`. Diese Adresse auch zum Öffnen der Plattform verwenden. Moduloberflächen nutzen einen eigenen Host: lokal automatisch den jeweils anderen Loopback-Namen (`localhost` oder `127.0.0.1`), bei öffentlichen Domains standardmäßig `modules.<Plattformhost>`. Für einen anderen Host `MODULE_PUBLIC_ORIGIN` setzen; in Produktion DNS und HTTPS für beide Hosts einrichten.
9. `DOCKER_SOCKET_GID` ist hostabhängig. Docker Desktop verwendet häufig `0`; bei Linux ist die tatsächliche Gruppe des Docker-Sockets zu verwenden. Änderungen daran erst nach Prüfung der Docker-Berechtigungen vornehmen. 9. `DOCKER_SOCKET_GID` ist hostabhängig. Docker Desktop verwendet häufig `0`; bei Linux ist die tatsächliche Gruppe des Docker-Sockets zu verwenden. Änderungen daran erst nach Prüfung der Docker-Berechtigungen vornehmen.
Die Datenbankverbindung innerhalb des Compose-Netzwerks verwendet den Hostnamen `postgres`. Bei Backend-Ausführung direkt auf dem Host muss `DATABASE_URL` auf `127.0.0.1:5432` zeigen. Niemals den Compose-internen Hostnamen `postgres` für einen Backendprozess auf dem Host verwenden. Die Datenbankverbindung innerhalb des Compose-Netzwerks verwendet den Hostnamen `postgres`. Bei Backend-Ausführung direkt auf dem Host muss `DATABASE_URL` auf `127.0.0.1:5432` zeigen. Niemals den Compose-internen Hostnamen `postgres` für einen Backendprozess auf dem Host verwenden.
@@ -51,7 +51,7 @@ docker compose up --build -d
docker compose ps docker compose ps
``` ```
Danach die in `APP_PORT` konfigurierte Adresse öffnen (Standard `http://localhost:8080`). Das initiale Admin-Konto wird beim ersten Datenbankstart aus `ADMIN_USERNAME`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` angelegt. Spätere Änderungen dieser Variablen ändern ein bereits angelegtes Datenbankkonto nicht automatisch. Danach die in `MARKETPLACE_PUBLIC_URL` konfigurierte Adresse öffnen (Beispiel: `http://127.0.0.1:8080`). Das initiale Admin-Konto wird beim ersten Datenbankstart aus `ADMIN_USERNAME`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` angelegt. Spätere Änderungen dieser Variablen ändern ein bereits angelegtes Datenbankkonto nicht automatisch.
Logs und Neustart: Logs und Neustart:

View File

@@ -41,9 +41,9 @@ Details: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · Phasen: [`docs/PHASES
docker compose up --build -d docker compose up --build -d
# 3. Öffnen # 3. Öffnen
# http://localhost:8080 # Die Adresse aus MARKETPLACE_PUBLIC_URL öffnen, z. B. http://127.0.0.1:8080
# Anmeldung: ADMIN_USERNAME / ADMIN_PASSWORD aus .env # Anmeldung: ADMIN_USERNAME / ADMIN_PASSWORD aus .env
# API-Dokumentation (Swagger): http://localhost:8080/api/docs # API-Dokumentation (Swagger): <MARKETPLACE_PUBLIC_URL>/api/docs
``` ```
Definition of Done Phase 1: Webseite erreichbar ✓ Login möglich ✓ Admin-Dashboard sichtbar ✓ Definition of Done Phase 1: Webseite erreichbar ✓ Login möglich ✓ Admin-Dashboard sichtbar ✓
@@ -102,7 +102,7 @@ Vollständige Benutzer-CRUD-API (nur Admin) mit Duplikat-Schutz, Schutz des letz
Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installation mit Zip-Slip-Schutz, eigene Compose-Stacks je Modul, Lifecycle (INSTALLED/STARTING/RUNNING/STOPPED/ERROR/DISABLED), Healthchecks mit Startup-Grace, Modulverwaltungs-UI und persistente Datenvolumes. Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installation mit Zip-Slip-Schutz, eigene Compose-Stacks je Modul, Lifecycle (INSTALLED/STARTING/RUNNING/STOPPED/ERROR/DISABLED), Healthchecks mit Startup-Grace, Modulverwaltungs-UI und persistente Datenvolumes.
### Phase 4 – Gateway & Routing ### Phase 4 – Gateway & Routing
Dynamisches Routing `/slug` über Nginx → Modul-Gateway (Middleware): Session-Check, Modul-Status-Check, Permission-Check (fail-closed), Proxy zu internen Ports. Sichere Identitätsübergabe über Header, Startup-Recovery mit Autostart nach Container-Neustarts. Dynamisches Routing `/<slug>` auf einem eigenen Modulhost über Nginx → Modul-Gateway (Middleware): getrennte Browser-Session, Modul-Status-Check, Permission-Check (fail-closed), Proxy zu internen Ports. Der Einstieg erfolgt über einen authentifizierten Einmal-Ticket-Redirect vom Plattformhost. Module müssen Assets und APIs unter `/<slug>/` bereitstellen; die Management-API ist auf dem Modulhost nicht erreichbar. Sichere Identitätsübergabe über Header, Startup-Recovery mit Autostart nach Container-Neustarts.
### Phase 5 – Berechtigungssystem ### Phase 5 – Berechtigungssystem
Zweistufiges Rechtekonzept: Plattform-Rollen (ADMIN/USER) + Modul-Berechtigungen (`user_module_permissions`, GRANTED/DENIED). Admin-API für Zuweisungen, Gateway prüft Berechtigungen fail-closed, Dashboard zeigt nur freigegebene Module als Kacheln. Zweistufiges Rechtekonzept: Plattform-Rollen (ADMIN/USER) + Modul-Berechtigungen (`user_module_permissions`, GRANTED/DENIED). Admin-API für Zuweisungen, Gateway prüft Berechtigungen fail-closed, Dashboard zeigt nur freigegebene Module als Kacheln.

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, HttpCode, Inject, Post, Req, Res, UseGuards } from '@nestjs/common'; import { Body, Controller, ForbiddenException, Get, HttpCode, Inject, NotFoundException, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
@@ -8,6 +8,8 @@ import type { AuthenticatedRequest } from './authenticated-request';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { CsrfGuard } from './guards/csrf.guard'; import { CsrfGuard } from './guards/csrf.guard';
import { SessionGuard } from './guards/session.guard'; import { SessionGuard } from './guards/session.guard';
import { SessionService } from './session.service';
import { requireSameOrigin } from './request-origin';
import { loginSchema, type LoginDto } from '../users/user.types'; import { loginSchema, type LoginDto } from '../users/user.types';
import type { AuthUser } from '../users/user.types'; import type { AuthUser } from '../users/user.types';
@@ -38,6 +40,7 @@ function toAuthUserResponse(user: AuthUser): AuthUserResponse {
export class AuthController { export class AuthController {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly sessionService: SessionService,
@Inject(APP_CONFIG) private readonly config: AppConfig, @Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {}
@@ -49,6 +52,7 @@ export class AuthController {
@Req() request: AuthenticatedRequest & Request, @Req() request: AuthenticatedRequest & Request,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
): Promise<{ user: AuthUserResponse }> { ): Promise<{ user: AuthUserResponse }> {
requireSameOrigin(request, this.config.marketplace.publicUrl);
const result = await this.authService.login({ const result = await this.authService.login({
username: body.username, username: body.username,
password: body.password, password: body.password,
@@ -57,14 +61,14 @@ export class AuthController {
// Express expects cookie maxAge in milliseconds (the DB TTL is in minutes). // Express expects cookie maxAge in milliseconds (the DB TTL is in minutes).
const cookieMaxAgeMs = this.config.security.sessionTtlMinutes * 60 * 1000; const cookieMaxAgeMs = this.config.security.sessionTtlMinutes * 60 * 1000;
response.cookie('mpm_session', result.sessionToken, { response.cookie(this.config.security.cookieSecure ? '__Host-mpm_session' : 'mpm_session', result.sessionToken, {
httpOnly: true, httpOnly: true,
secure: this.config.security.cookieSecure, secure: this.config.security.cookieSecure,
sameSite: 'lax', sameSite: 'lax',
path: '/', path: '/',
maxAge: cookieMaxAgeMs, maxAge: cookieMaxAgeMs,
}); });
response.cookie('mpm_csrf', result.session.csrfToken, { response.cookie(this.config.security.cookieSecure ? '__Host-mpm_csrf' : 'mpm_csrf', result.session.csrfToken, {
httpOnly: false, httpOnly: false,
secure: this.config.security.cookieSecure, secure: this.config.security.cookieSecure,
sameSite: 'lax', sameSite: 'lax',
@@ -75,6 +79,63 @@ export class AuthController {
return { user: toAuthUserResponse(result.user) }; return { user: toAuthUserResponse(result.user) };
} }
/** Navigationspunkt auf dem Plattformhost für einen eigenen Modul-Origin. */
@UseGuards(SessionGuard)
@Get('module-open/:slug')
async openModule(
@Param('slug') slug: string,
@CurrentUser() user: AuthUser,
@Req() request: AuthenticatedRequest & Request,
@Res() response: Response,
): Promise<void> {
if (!/^[a-z0-9][a-z0-9-]{2,100}$/.test(slug) || !request.session) {
throw new NotFoundException('Modul nicht gefunden');
}
const ticket = await this.sessionService.createModuleAccessTicket(request.session.id, user.id, slug);
response.setHeader('Cache-Control', 'no-store');
response.setHeader('Referrer-Policy', 'no-referrer');
response.redirect(303, `${this.config.modulePublicOrigin}/__mpm_module_handoff?ticket=${encodeURIComponent(ticket)}`);
}
/** Einmaliger Cookie-Übergang auf dem separaten Modulhost. */
@Public()
@Get('module-handoff')
async moduleHandoff(
@Query('ticket') ticket: string,
@Req() request: Request,
@Res() response: Response,
): Promise<void> {
if (request.headers.host !== new URL(this.config.modulePublicOrigin).host) {
throw new ForbiddenException('Ungültiger Modul-Host');
}
if (typeof ticket !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(ticket)) {
throw new ForbiddenException('Ungültiges Modul-Ticket');
}
const exchanged = await this.sessionService.exchangeModuleAccessTicket(
ticket,
this.config.security.sessionTtlMinutes,
);
if (!exchanged) {
throw new ForbiddenException('Modul-Ticket ist abgelaufen oder bereits verwendet');
}
// Ein Browser, der diesen Host früher als Plattformhost genutzt hat,
// darf keine alten Plattform-Cookies an Modul-JavaScript weitergeben.
response.clearCookie('mpm_session', { path: '/' });
response.clearCookie('mpm_csrf', { path: '/' });
response.clearCookie('__Host-mpm_session', { path: '/', secure: true });
response.clearCookie('__Host-mpm_csrf', { path: '/', secure: true });
response.cookie('mpm_module_session', exchanged.token, {
httpOnly: true,
secure: this.config.security.cookieSecure,
sameSite: 'lax',
path: `/${exchanged.moduleSlug}`,
maxAge: this.config.security.sessionTtlMinutes * 60 * 1000,
});
response.setHeader('Cache-Control', 'no-store');
response.setHeader('Referrer-Policy', 'no-referrer');
response.redirect(303, `${this.config.modulePublicOrigin}/${exchanged.moduleSlug}`);
}
@UseGuards(SessionGuard, CsrfGuard) @UseGuards(SessionGuard, CsrfGuard)
@Post('logout') @Post('logout')
@HttpCode(200) @HttpCode(200)
@@ -88,6 +149,8 @@ export class AuthController {
} }
response.clearCookie('mpm_session', { path: '/' }); response.clearCookie('mpm_session', { path: '/' });
response.clearCookie('mpm_csrf', { path: '/' }); response.clearCookie('mpm_csrf', { path: '/' });
response.clearCookie('__Host-mpm_session', { path: '/', secure: true });
response.clearCookie('__Host-mpm_csrf', { path: '/', secure: true });
return { success: true }; return { success: true };
} }

View File

@@ -27,6 +27,7 @@ function createConfig(overrides: Partial<AppConfig['security']> = {}): AppConfig
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' }, adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' }, runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' },
marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} }, marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} },
modulePublicOrigin: 'http://localhost:8081',
}; };
} }
@@ -80,7 +81,7 @@ class MockSessionService {
}, },
}; };
async create(): Promise<{ token: string; data: SessionData }> { async createForVerifiedPassword(): Promise<{ token: string; data: SessionData }> {
return this.createResult; return this.createResult;
} }

View File

@@ -25,6 +25,10 @@ const INVALID_CREDENTIALS_MESSAGE = 'Benutzername oder Passwort ist falsch';
*/ */
@Injectable() @Injectable()
export class AuthService { export class AuthService {
// Ein echter Argon2-Hash für unbekannte Benutzernamen hält den teuren
// Verifikationsschritt in beiden Fehlpfaden vergleichbar.
private readonly dummyPasswordHash: Promise<string>;
constructor( constructor(
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly passwordHasher: PasswordHasher, private readonly passwordHasher: PasswordHasher,
@@ -32,7 +36,9 @@ export class AuthService {
private readonly rateLimiter: RateLimiterService, private readonly rateLimiter: RateLimiterService,
private readonly auditService: AuditService, private readonly auditService: AuditService,
@Inject(APP_CONFIG) private readonly config: AppConfig, @Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {
this.dummyPasswordHash = this.passwordHasher.hash('mpm-invalid-user-placeholder');
}
async login(input: { async login(input: {
username: string; username: string;
@@ -62,6 +68,7 @@ export class AuthService {
// Gleiches Verhalten für "unbekannter Benutzer" und "falsches Passwort" // Gleiches Verhalten für "unbekannter Benutzer" und "falsches Passwort"
// (keine User-Enumeration). // (keine User-Enumeration).
if (!user) { if (!user) {
await this.passwordHasher.verify(await this.dummyPasswordHash, input.password);
await this.auditService.record({ await this.auditService.record({
userId: null, userId: null,
username: input.username, username: input.username,
@@ -96,12 +103,23 @@ export class AuthService {
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE); throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
} }
await this.userRepository.updateLoginSuccess(user.id); const createdSession = await this.sessionService.createForVerifiedPassword(
const { token, data } = await this.sessionService.create(
user.id, user.id,
user.passwordHash,
security.sessionTtlMinutes, security.sessionTtlMinutes,
); );
if (!createdSession) {
await this.auditService.record({
userId: user.id,
username: user.username,
action: 'LOGIN_FAILED',
details: { reason: 'PASSWORD_CHANGED_DURING_LOGIN' },
ipAddress: input.ipAddress,
});
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
}
const { token, data } = createdSession;
await this.userRepository.updateLoginSuccess(user.id);
await this.auditService.record({ await this.auditService.record({
userId: user.id, userId: user.id,

View File

@@ -4,6 +4,7 @@ import { UserRepository } from '../../users/user.repository';
import type { UserRecord } from '../../users/user.types'; import type { UserRecord } from '../../users/user.types';
import { SessionService } from '../session.service'; import { SessionService } from '../session.service';
import { SessionGuard } from './session.guard'; import { SessionGuard } from './session.guard';
import type { AppConfig } from '../../config/config.tokens';
/** Erzeugt einen Benutzer-Datensatz für Tests. */ /** Erzeugt einen Benutzer-Datensatz für Tests. */
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord { function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
@@ -74,6 +75,7 @@ describe('SessionGuard', () => {
sessionService as unknown as SessionService, sessionService as unknown as SessionService,
userRepository as unknown as UserRepository, userRepository as unknown as UserRepository,
reflector, reflector,
{ security: { cookieSecure: false } } as AppConfig,
); );
}); });

View File

@@ -1,5 +1,6 @@
import { type CanActivate, type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { Inject, type CanActivate, type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { APP_CONFIG, type AppConfig } from '../../config/config.tokens';
import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator';
import { UserRepository } from '../../users/user.repository'; import { UserRepository } from '../../users/user.repository';
import type { AuthenticatedRequest } from '../authenticated-request'; import type { AuthenticatedRequest } from '../authenticated-request';
@@ -11,7 +12,7 @@ interface RequestWithCookieHeader {
} }
/** Extrahiert das Session-Cookie aus einem Request. */ /** Extrahiert das Session-Cookie aus einem Request. */
export function extractSessionToken(request: RequestWithCookieHeader): string | null { export function extractSessionToken(request: RequestWithCookieHeader, secureCookie = false): string | null {
const cookieHeader = request.headers.cookie; const cookieHeader = request.headers.cookie;
if (!cookieHeader) { if (!cookieHeader) {
return null; return null;
@@ -19,7 +20,7 @@ export function extractSessionToken(request: RequestWithCookieHeader): string |
let sessionToken: string | null = null; let sessionToken: string | null = null;
for (const part of cookieHeader.split(';')) { for (const part of cookieHeader.split(';')) {
const [name, ...value] = part.trim().split('='); const [name, ...value] = part.trim().split('=');
if (name === 'mpm_session') { if (name === (secureCookie ? '__Host-mpm_session' : 'mpm_session')) {
// Browsers may send same-name cookies from an older, narrower Path // Browsers may send same-name cookies from an older, narrower Path
// before the current Path=/ cookie. The last value is the root cookie. // before the current Path=/ cookie. The last value is the root cookie.
sessionToken = decodeURIComponent(value.join('=')); sessionToken = decodeURIComponent(value.join('='));
@@ -40,6 +41,7 @@ export class SessionGuard implements CanActivate {
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly reflector: Reflector, private readonly reflector: Reflector,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -52,7 +54,7 @@ export class SessionGuard implements CanActivate {
} }
const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = extractSessionToken(request); const token = extractSessionToken(request, this.config.security.cookieSecure);
if (!token) { if (!token) {
throw new UnauthorizedException('Nicht authentifiziert'); throw new UnauthorizedException('Nicht authentifiziert');
} }

View File

@@ -0,0 +1,23 @@
import { ForbiddenException } from '@nestjs/common';
import type { Request } from 'express';
/**
* Browsers senden bei POST/PUT/PATCH/DELETE einen Origin-Header. Der Vergleich
* verhindert auch Anfragen von einer anderen Subdomain derselben Site.
*/
export function requireSameOrigin(request: Request, canonicalOrigin: string): void {
const origin = request.headers.origin;
const host = request.headers.host;
if (typeof origin !== 'string' || !host) {
throw new ForbiddenException('Ungültiger Request-Ursprung');
}
const canonical = new URL(canonicalOrigin);
// Hinter einem TLS-Reverse-Proxy sieht NestJS eventuell nur HTTP. Für den
// konfigurierten öffentlichen Host ist die veröffentlichte URL maßgeblich.
const protocol = canonical.host === host ? canonical.protocol : `${request.protocol}:`;
const expected = `${protocol}//${host}`;
if (origin !== expected) {
throw new ForbiddenException('Ungültiger Request-Ursprung');
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { DatabaseService } from '../database/database.service'; import { DatabaseService } from '../database/database.service';
@@ -17,6 +17,12 @@ interface SessionRow {
expires_at: Date; expires_at: Date;
} }
interface ModuleAccessRow {
user_id: string;
module_slug: string;
platform_session_id: string;
}
/** /**
* Serverseitige Session-Verwaltung (Infrastructure): * Serverseitige Session-Verwaltung (Infrastructure):
* - 256-Bit-Zufalls-Token, in der DB wird nur der SHA-256-Hash gespeichert * - 256-Bit-Zufalls-Token, in der DB wird nur der SHA-256-Hash gespeichert
@@ -43,6 +49,121 @@ export class SessionService {
return { token, data: this.mapRow(result.rows[0]) }; return { token, data: this.mapRow(result.rows[0]) };
} }
/**
* Erstellt die Session nur, wenn der gerade verifizierte Passwort-Hash noch
* aktuell ist. Die Zeilensperre serialisiert diesen Schritt mit Resets:
* entweder wird die Session vom Reset gelöscht oder der alte Hash abgewiesen.
*/
async createForVerifiedPassword(
userId: string,
verifiedPasswordHash: string,
ttlMinutes: number,
): Promise<{ token: string; data: SessionData } | null> {
const token = randomBytes(32).toString('base64url');
const csrfToken = randomBytes(32).toString('base64url');
return this.database.transaction(async (client) => {
const user = await client.query<{ password_hash: string; is_active: boolean }>(
'SELECT password_hash, is_active FROM users WHERE id = $1 FOR UPDATE',
[userId],
);
if (!user.rows[0]?.is_active || user.rows[0].password_hash !== verifiedPasswordHash) {
return null;
}
const result = await client.query<SessionRow>(
`INSERT INTO sessions (user_id, token_hash, csrf_token, expires_at)
VALUES ($1, $2, $3, now() + make_interval(mins => $4::int))
RETURNING id, user_id, csrf_token, expires_at`,
[userId, this.hashToken(token), csrfToken, ttlMinutes],
);
return { token, data: this.mapRow(result.rows[0]) };
});
}
/** Einmal-Ticket, das an die noch gültige Plattform-Session gebunden ist. */
async createModuleAccessTicket(
platformSessionId: string,
userId: string,
moduleSlug: string,
): Promise<string> {
const ticket = randomBytes(32).toString('base64url');
await this.database.transaction(async (client) => {
const platformSession = await client.query(
'SELECT id FROM sessions WHERE id = $1 AND user_id = $2 AND expires_at > now() FOR SHARE',
[platformSessionId, userId],
);
if (!platformSession.rows[0]) throw new UnauthorizedException('Nicht authentifiziert');
await client.query('DELETE FROM module_access_tickets WHERE expires_at <= now()');
await client.query('DELETE FROM module_sessions WHERE expires_at <= now()');
await client.query(
`INSERT INTO module_access_tickets
(ticket_hash, platform_session_id, user_id, module_slug, expires_at)
VALUES ($1, $2, $3, $4, now() + interval '60 seconds')`,
[this.hashToken(ticket), platformSessionId, userId, moduleSlug],
);
});
return ticket;
}
/** Verbraucht das Ticket atomar und legt nur für den angegebenen Modulpfad eine Session an. */
async exchangeModuleAccessTicket(
ticket: string,
ttlMinutes: number,
): Promise<{ token: string; userId: string; moduleSlug: string } | null> {
const token = randomBytes(32).toString('base64url');
return this.database.transaction(async (client) => {
const ticketHash = this.hashToken(ticket);
const ticketRow = await client.query<ModuleAccessRow>(
`SELECT user_id, module_slug, platform_session_id FROM module_access_tickets
WHERE ticket_hash = $1 AND expires_at > now()`,
[ticketHash],
);
if (!ticketRow.rows[0]) return null;
// Dieselbe Zeilensperre wie bei Passwortwechsel und Login verhindert,
// dass ein Reset nach der Prüfung eine neue Modulsession überlebt.
const activeUser = await client.query<{ id: string }>(
'SELECT id FROM users WHERE id = $1 AND is_active FOR UPDATE',
[ticketRow.rows[0].user_id],
);
if (!activeUser.rows[0]) return null;
const consumed = await client.query<ModuleAccessRow>(
`DELETE FROM module_access_tickets t
WHERE t.ticket_hash = $1 AND t.expires_at > now()
AND EXISTS (
SELECT 1 FROM sessions s
WHERE s.id = t.platform_session_id AND s.expires_at > now()
)
RETURNING t.user_id, t.module_slug, t.platform_session_id`,
[ticketHash],
);
if (!consumed.rows[0]) return null;
await client.query(
`INSERT INTO module_sessions (token_hash, platform_session_id, user_id, module_slug, expires_at)
VALUES ($1, $2, $3, $4, now() + make_interval(mins => $5::int))`,
[this.hashToken(token), consumed.rows[0].platform_session_id, consumed.rows[0].user_id, consumed.rows[0].module_slug, ttlMinutes],
);
return {
token,
userId: consumed.rows[0].user_id,
moduleSlug: consumed.rows[0].module_slug,
};
});
}
/** Modul-Cookies gelten ausschließlich für das ausgestellte Modul. */
async findValidModuleSession(token: string, moduleSlug: string): Promise<string | null> {
const result = await this.database.query<{ user_id: string }>(
`SELECT m.user_id FROM module_sessions m
JOIN sessions s ON s.id = m.platform_session_id
WHERE m.token_hash = $1 AND m.module_slug = $2
AND m.expires_at > now() AND s.expires_at > now()`,
[this.hashToken(token), moduleSlug],
);
return result.rows[0]?.user_id ?? null;
}
/** Findet eine gültige Session anhand des Klartext-Tokens. */ /** Findet eine gültige Session anhand des Klartext-Tokens. */
async findValid(token: string): Promise<SessionData | null> { async findValid(token: string): Promise<SessionData | null> {
const result = await this.database.query<SessionRow>( const result = await this.database.query<SessionRow>(
@@ -75,6 +196,8 @@ export class SessionService {
/** Löscht alle Sessions eines Benutzers (Deaktivierung, Passwort-Reset). */ /** Löscht alle Sessions eines Benutzers (Deaktivierung, Passwort-Reset). */
async deleteAllForUser(userId: string, exceptSessionId?: string): Promise<void> { async deleteAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
await this.database.query('DELETE FROM module_sessions WHERE user_id = $1', [userId]);
await this.database.query('DELETE FROM module_access_tickets WHERE user_id = $1', [userId]);
if (exceptSessionId) { if (exceptSessionId) {
await this.database.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [ await this.database.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [
userId, userId,
@@ -87,6 +210,8 @@ export class SessionService {
/** Löscht alle abgelaufenen Sessions (Aufräumjob, später via Cron). */ /** Löscht alle abgelaufenen Sessions (Aufräumjob, später via Cron). */
async deleteExpired(): Promise<void> { async deleteExpired(): Promise<void> {
await this.database.query('DELETE FROM module_access_tickets WHERE expires_at <= now()');
await this.database.query('DELETE FROM module_sessions WHERE expires_at <= now()');
await this.database.query('DELETE FROM sessions WHERE expires_at <= now()'); await this.database.query('DELETE FROM sessions WHERE expires_at <= now()');
} }

View File

@@ -62,6 +62,22 @@ export interface AppConfig {
readonly adminSeed: AdminSeedConfig; readonly adminSeed: AdminSeedConfig;
readonly runtime: RuntimeConfig; readonly runtime: RuntimeConfig;
readonly marketplace: MarketplaceConfig; readonly marketplace: MarketplaceConfig;
/** Eigener Browser-Host für Moduloberflächen (Host muss von MPM abweichen). */
readonly modulePublicOrigin: string;
}
function moduleOriginFor(publicUrl: string, configuredOrigin: string): string {
if (configuredOrigin) return configuredOrigin;
const platform = new URL(publicUrl);
const modules = new URL(platform.origin);
if (platform.hostname === 'localhost') {
modules.hostname = '127.0.0.1';
} else if (platform.hostname === '127.0.0.1' || platform.hostname === '[::1]') {
modules.hostname = 'localhost';
} else {
modules.hostname = `modules.${platform.hostname}`;
}
return modules.origin;
} }
const booleanFromString = z const booleanFromString = z
@@ -89,6 +105,7 @@ const environmentSchema = z.object({
MODULE_CONFIG_ENCRYPTION_KEY: z.string().default(''), MODULE_CONFIG_ENCRYPTION_KEY: z.string().default(''),
MODULE_UID_BASE: z.coerce.number().int().min(10_000).max(64_535).optional(), MODULE_UID_BASE: z.coerce.number().int().min(10_000).max(64_535).optional(),
MARKETPLACE_PUBLIC_URL: z.string().url().default('http://127.0.0.1:8081'), MARKETPLACE_PUBLIC_URL: z.string().url().default('http://127.0.0.1:8081'),
MODULE_PUBLIC_ORIGIN: z.string().default(''),
MARKETPLACE_TOKEN_ENCRYPTION_KEY: z.string().default(''), MARKETPLACE_TOKEN_ENCRYPTION_KEY: z.string().default(''),
GITHUB_OAUTH_CLIENT_ID: z.string().default(''), GITHUB_OAUTH_CLIENT_ID: z.string().default(''),
GITHUB_OAUTH_CLIENT_SECRET: z.string().default(''), GITHUB_OAUTH_CLIENT_SECRET: z.string().default(''),
@@ -99,6 +116,26 @@ const environmentSchema = z.object({
FORGEJO_OAUTH_CLIENT_ID: z.string().default(''), FORGEJO_OAUTH_CLIENT_ID: z.string().default(''),
FORGEJO_OAUTH_CLIENT_SECRET: z.string().default(''), FORGEJO_OAUTH_CLIENT_SECRET: z.string().default(''),
}).superRefine((environment, context) => { }).superRefine((environment, context) => {
try {
const platform = new URL(environment.MARKETPLACE_PUBLIC_URL);
const moduleOrigin = moduleOriginFor(environment.MARKETPLACE_PUBLIC_URL, environment.MODULE_PUBLIC_ORIGIN);
const modules = new URL(moduleOrigin);
if (modules.origin !== moduleOrigin || modules.hostname === platform.hostname || modules.username || modules.password || modules.search || modules.hash || modules.pathname !== '/') {
throw new Error('origin');
}
if (environment.NODE_ENV === 'production' && modules.protocol !== 'https:') {
throw new Error('https');
}
if (!/^[a-z0-9.-]+$/i.test(modules.hostname)) {
throw new Error('hostname');
}
} catch {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['MODULE_PUBLIC_ORIGIN'],
message: 'Modul-Origin benötigt einen eigenen Host (in Produktion HTTPS), ohne Pfad oder Zugangsdaten',
});
}
if (environment.NODE_ENV === 'production' && !environment.COOKIE_SECURE) { if (environment.NODE_ENV === 'production' && !environment.COOKIE_SECURE) {
context.addIssue({ context.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@@ -206,5 +243,6 @@ export function loadConfiguration(): AppConfig {
: {}), : {}),
}, },
}, },
modulePublicOrigin: moduleOriginFor(environment.MARKETPLACE_PUBLIC_URL, environment.MODULE_PUBLIC_ORIGIN),
}; };
} }

View File

@@ -0,0 +1,32 @@
import type { Migration } from '../migration.types';
/** Einmalige Übergabe vom Plattformhost auf den getrennten Modulhost. */
export const migration013ModuleBrowserSessions: Migration = {
id: '013-module-browser-sessions',
description: 'Einmal-Tickets und getrennte Browser-Sessions für Module',
up: async (client) => {
await client.query(`
CREATE TABLE module_access_tickets (
ticket_hash TEXT PRIMARY KEY,
platform_session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
module_slug TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
)
`);
await client.query(`
CREATE TABLE module_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash TEXT NOT NULL UNIQUE,
platform_session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
module_slug TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await client.query('CREATE INDEX idx_module_sessions_user ON module_sessions(user_id)');
await client.query('CREATE INDEX idx_module_sessions_expiry ON module_sessions(expires_at)');
await client.query('CREATE INDEX idx_module_tickets_expiry ON module_access_tickets(expires_at)');
},
};

View File

@@ -10,6 +10,7 @@ import { migration009MarketplaceInstallations } from '../../modules/migrations/0
import { migration010ModuleContainers } from '../../modules/migrations/010-module-containers'; import { migration010ModuleContainers } from '../../modules/migrations/010-module-containers';
import { migration011ModuleConfiguration } from '../../modules/migrations/011-module-configuration'; import { migration011ModuleConfiguration } from '../../modules/migrations/011-module-configuration';
import { migration012MarketplaceBranchUpdates } from '../../modules/migrations/012-marketplace-branch-updates'; import { migration012MarketplaceBranchUpdates } from '../../modules/migrations/012-marketplace-branch-updates';
import { migration013ModuleBrowserSessions } from './013-module-browser-sessions';
/** Registrierte Migrationen in aufsteigender Reihenfolge. */ /** Registrierte Migrationen in aufsteigender Reihenfolge. */
export const MIGRATIONS = [ export const MIGRATIONS = [
@@ -25,4 +26,5 @@ export const MIGRATIONS = [
migration010ModuleContainers, migration010ModuleContainers,
migration011ModuleConfiguration, migration011ModuleConfiguration,
migration012MarketplaceBranchUpdates, migration012MarketplaceBranchUpdates,
migration013ModuleBrowserSessions,
]; ];

View File

@@ -42,7 +42,7 @@ async function bootstrap(): Promise<void> {
.setTitle('MPM Management API') .setTitle('MPM Management API')
.setDescription('Zentrale Management-API der MPM-Plattform (Auth, RBAC, Health)') .setDescription('Zentrale Management-API der MPM-Plattform (Auth, RBAC, Health)')
.setVersion('0.1.0') .setVersion('0.1.0')
.addCookieAuth('mpm_session') .addCookieAuth(config.security.cookieSecure ? '__Host-mpm_session' : 'mpm_session')
.build(); .build();
const document = SwaggerModule.createDocument(app, swaggerConfig); const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);

View File

@@ -55,6 +55,7 @@ const MODULE_ID_PATTERN = /^[a-z][a-z0-9-]{2,63}$/;
/** URL-Slugs für das spätere Routing (/slug). */ /** URL-Slugs für das spätere Routing (/slug). */
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{2,99}$/; const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{2,99}$/;
const RESERVED_SLUGS = new Set(['api', 'assets', 'login', 'admin', 'profile', '403', '404']);
/** Semantische Versionierung (major.minor.patch). */ /** Semantische Versionierung (major.minor.patch). */
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/; const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
@@ -69,7 +70,9 @@ export const moduleManifestSchema = z.object({
.regex(MODULE_ID_PATTERN, 'Modul-ID muss dem Muster [a-z][a-z0-9-]{2,63} folgen'), .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), 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'), 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'), slug: z.string()
.regex(SLUG_PATTERN, 'Slug muss dem Muster [a-z0-9-]{3,100} folgen')
.refine((slug) => !RESERVED_SLUGS.has(slug), 'Dieser Slug ist für die Plattform reserviert'),
description: z.string().max(500).default(''), description: z.string().max(500).default(''),
author: z.string().max(200).default(''), author: z.string().max(200).default(''),
runtime: z.literal('node'), runtime: z.literal('node'),

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Body, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common'; import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Post, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
@@ -6,6 +6,8 @@ import { Public } from '../common/decorators/public.decorator';
import { Roles } from '../common/decorators/roles.decorator'; import { Roles } from '../common/decorators/roles.decorator';
import type { AuthUser } from '../users/user.types'; import type { AuthUser } from '../users/user.types';
import { SessionService } from '../auth/session.service'; import { SessionService } from '../auth/session.service';
import { extractSessionToken } from '../auth/guards/session.guard';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
import type { AuthenticatedRequest } from '../auth/authenticated-request'; import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { MarketplaceService } from './marketplace.service'; import { MarketplaceService } from './marketplace.service';
import { ModulesService } from './modules.service'; import { ModulesService } from './modules.service';
@@ -18,6 +20,7 @@ export class MarketplaceController {
private readonly marketplaceService: MarketplaceService, private readonly marketplaceService: MarketplaceService,
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
private readonly modulesService: ModulesService, private readonly modulesService: ModulesService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {}
@Get('providers') @Get('providers')
@@ -46,11 +49,13 @@ export class MarketplaceController {
configuration: ModuleRecord['configuration']; configurationReady: boolean; configuration: ModuleRecord['configuration']; configurationReady: boolean;
} }> { } }> {
const branch = await this.marketplaceService.defaultBranch(provider, owner, repository); const branch = await this.marketplaceService.defaultBranch(provider, owner, repository);
const archive = await this.marketplaceService.downloadRepositoryArchive(provider, owner, repository, branch); const { archive, commit } = await this.marketplaceService.downloadRepositoryArchive(provider, owner, repository, branch);
const manifest = await this.modulesService.validatePackage(archive); const manifest = await this.modulesService.validatePackage(archive);
const module = await this.modulesService.findByModuleId(manifest.id) ?? if (await this.modulesService.findByModuleId(manifest.id)) {
await this.modulesService.install(archive, actor, request.ip ?? null); throw new BadRequestException('Ein Modul mit dieser ID ist bereits installiert');
await this.marketplaceService.recordInstallation(provider, owner, repository, module.id, branch); }
const module = await this.modulesService.install(archive, actor, request.ip ?? null);
await this.marketplaceService.recordInstallation(provider, owner, repository, module.id, branch, commit);
return { return {
module: { module: {
id: module.id, id: module.id,
@@ -77,19 +82,48 @@ export class MarketplaceController {
return this.marketplaceService.availableUpdates(moduleId); return this.marketplaceService.availableUpdates(moduleId);
} }
@Get('operations/:operationId')
@Roles('ADMIN')
operationProgress(@Param('operationId') operationId: string, @CurrentUser() actor: AuthUser) {
return this.marketplaceService.getOperationProgress(operationId, actor.id);
}
@Post('modules/:moduleId/update') @Post('modules/:moduleId/update')
@Roles('ADMIN') @Roles('ADMIN')
async updateModule( async updateModule(
@Param('moduleId') moduleId: string, @Param('moduleId') moduleId: string,
@Body() body: { branch?: unknown }, @Body() body: { branch?: unknown; operationId?: unknown },
@CurrentUser() actor: AuthUser, @CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest, @Req() request: AuthenticatedRequest,
): Promise<{ module: ModuleRecord }> { ): Promise<{ module: ModuleRecord }> {
if (typeof body.branch !== 'string') throw new BadRequestException('Bitte eine Update-Branch auswählen'); if (typeof body.branch !== 'string') throw new BadRequestException('Bitte eine Update-Branch auswählen');
const update = await this.marketplaceService.updateInstalledBranch(moduleId, body.branch); const operationId = this.marketplaceService.beginOperation(body.operationId, actor.id);
const module = await this.modulesService.updateFromMarketplace(moduleId, update.archive, actor, request.ip ?? null); const report = (phase: string, message: string, progress: number) =>
await this.marketplaceService.commitInstalledBranch(moduleId, update.provider, update.owner, update.repository, update.branch, update.commit); this.marketplaceService.reportOperation(operationId, actor.id, phase, message, progress);
return { module }; try {
report('branch', 'Branch wird geprüft', 8);
const update = await this.marketplaceService.updateInstalledBranch(moduleId, body.branch, report);
report('validation', 'Update-Paket wird geprüft', 40);
const module = await this.modulesService.updateFromMarketplace(
moduleId,
update.archive,
actor,
request.ip ?? null,
report,
async () => {
report('commit', 'Neue Version wird registriert', 96);
await this.marketplaceService.commitInstalledBranch(
moduleId, update.provider, update.owner, update.repository, update.branch, update.commit,
update.previousBranch, update.previousCommit,
);
},
);
this.marketplaceService.finishOperation(operationId, actor.id, true);
return { module };
} catch (error) {
this.marketplaceService.finishOperation(operationId, actor.id, false);
throw error;
}
} }
@Post('connections/:provider/start') @Post('connections/:provider/start')
@@ -128,7 +162,7 @@ export class MarketplaceController {
destination.searchParams.set('reason', 'callback'); destination.searchParams.set('reason', 'callback');
} else { } else {
try { try {
const token = request.cookies?.mpm_session as string | undefined; const token = extractSessionToken(request, this.config.security.cookieSecure);
const session = token ? await this.sessionService.findValid(token) : null; const session = token ? await this.sessionService.findValid(token) : null;
if (!session) { if (!session) {
destination.searchParams.set('marketplace', 'error'); destination.searchParams.set('marketplace', 'error');

View File

@@ -1,6 +1,7 @@
import { import {
BadGatewayException, BadGatewayException,
BadRequestException, BadRequestException,
ConflictException,
Injectable, Injectable,
InternalServerErrorException, InternalServerErrorException,
Logger, Logger,
@@ -52,6 +53,20 @@ export interface MarketplaceUpdatePackage {
owner: string; owner: string;
repository: string; repository: string;
branch: string; branch: string;
previousBranch: string;
previousCommit: string | null;
}
export interface MarketplaceRepositoryArchive {
archive: Buffer;
commit: string;
}
export interface MarketplaceOperationProgress {
status: 'running' | 'completed' | 'failed';
message: string;
phase: string;
progress: number;
} }
interface MarketplaceInstallationRow { interface MarketplaceInstallationRow {
@@ -67,6 +82,10 @@ interface MarketplaceInstallationRow {
const MAX_MARKETPLACE_DOWNLOAD = 10 * 1024 * 1024; const MAX_MARKETPLACE_DOWNLOAD = 10 * 1024 * 1024;
function isCommitSha(value: string): boolean {
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
}
function isProvider(value: string): value is MarketplaceProvider { function isProvider(value: string): value is MarketplaceProvider {
return MARKETPLACE_PROVIDERS.includes(value as MarketplaceProvider); return MARKETPLACE_PROVIDERS.includes(value as MarketplaceProvider);
} }
@@ -106,6 +125,7 @@ function isNewerVersionBranch(candidate: string, installed: string): boolean {
export class MarketplaceService implements OnModuleInit, OnModuleDestroy { export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MarketplaceService.name); private readonly logger = new Logger(MarketplaceService.name);
private branchCheckTimer: NodeJS.Timeout | undefined; private branchCheckTimer: NodeJS.Timeout | undefined;
private readonly operationProgress = new Map<string, { userId: string; state: MarketplaceOperationProgress }>();
constructor( constructor(
private readonly database: DatabaseService, private readonly database: DatabaseService,
@@ -123,6 +143,37 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
if (this.branchCheckTimer) clearInterval(this.branchCheckTimer); if (this.branchCheckTimer) clearInterval(this.branchCheckTimer);
} }
beginOperation(operationId: unknown, userId: string): string | null {
if (typeof operationId !== 'string' || !/^[0-9a-f-]{36}$/i.test(operationId)) return null;
const state: MarketplaceOperationProgress = { status: 'running', phase: 'starting', message: 'Update wird vorbereitet', progress: 3 };
this.operationProgress.set(operationId, { userId, state });
return operationId;
}
reportOperation(operationId: string | null, userId: string, phase: string, message: string, progress: number): void {
if (!operationId) return;
const operation = this.operationProgress.get(operationId);
if (!operation || operation.userId !== userId || operation.state.status !== 'running') return;
operation.state = { status: 'running', phase, message, progress: Math.max(0, Math.min(99, progress)) };
}
finishOperation(operationId: string | null, userId: string, success: boolean): void {
if (!operationId) return;
const operation = this.operationProgress.get(operationId);
if (!operation || operation.userId !== userId) return;
operation.state = success
? { status: 'completed', phase: 'completed', message: 'Installation abgeschlossen', progress: 100 }
: { ...operation.state, status: 'failed', phase: 'failed', message: 'Installation fehlgeschlagen' };
const cleanup = setTimeout(() => this.operationProgress.delete(operationId), 15 * 60 * 1000);
cleanup.unref();
}
getOperationProgress(operationId: string, userId: string): MarketplaceOperationProgress {
const operation = this.operationProgress.get(operationId);
if (!operation || operation.userId !== userId) throw new NotFoundException('Installationsvorgang wurde nicht gefunden');
return operation.state;
}
async providers(): Promise<ProviderStatus[]> { async providers(): Promise<ProviderStatus[]> {
const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>( const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>(
'SELECT provider, account_login FROM marketplace_connections', 'SELECT provider, account_login FROM marketplace_connections',
@@ -266,10 +317,15 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
})); }));
} }
async recordInstallation(providerParam: string, owner: string, repository: string, moduleId: string, branch: string): Promise<void> { async recordInstallation(providerParam: string, owner: string, repository: string, moduleId: string, branch: string, commit: string): Promise<void> {
const provider = this.requireProvider(providerParam); const provider = this.requireProvider(providerParam);
const branchInfo = await this.getBranch(provider, owner, repository, branch); if (!isCommitSha(commit)) throw new BadRequestException('Commit-ID ist ungültig');
const branches = await this.fetchBranches(provider, owner, repository); let branches: MarketplaceBranch[] = [];
try {
branches = await this.fetchBranches(provider, owner, repository);
} catch (error) {
this.logger.warn(`Branch-Prüfung für Modul ${moduleId} nach Installation fehlgeschlagen: ${error instanceof Error ? error.message : 'unbekannter Fehler'}`);
}
await this.database.query( await this.database.query(
`INSERT INTO marketplace_module_installations `INSERT INTO marketplace_module_installations
(provider, owner, repository, module_id, installed_branch, installed_commit, available_branches, observed_branches, branches_checked_at) (provider, owner, repository, module_id, installed_branch, installed_commit, available_branches, observed_branches, branches_checked_at)
@@ -278,7 +334,7 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
module_id = EXCLUDED.module_id, installed_branch = EXCLUDED.installed_branch, module_id = EXCLUDED.module_id, installed_branch = EXCLUDED.installed_branch,
installed_commit = EXCLUDED.installed_commit, available_branches = '[]'::jsonb, installed_commit = EXCLUDED.installed_commit, available_branches = '[]'::jsonb,
observed_branches = EXCLUDED.observed_branches, branches_checked_at = now()`, observed_branches = EXCLUDED.observed_branches, branches_checked_at = now()`,
[provider, owner, repository, moduleId, branch, branchInfo.commit, JSON.stringify(branches)], [provider, owner, repository, moduleId, branch, commit, JSON.stringify(branches)],
); );
} }
@@ -315,7 +371,13 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
}; };
} }
async downloadRepositoryArchive(providerParam: string, owner: string, repository: string, branch?: string): Promise<Buffer> { async downloadRepositoryArchive(
providerParam: string,
owner: string,
repository: string,
branch?: string,
pinnedCommit?: string,
): Promise<MarketplaceRepositoryArchive> {
const provider = this.requireProvider(providerParam); const provider = this.requireProvider(providerParam);
if (![owner, repository].every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) { if (![owner, repository].every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) {
throw new BadRequestException('Repository-Angabe ist ungueltig'); throw new BadRequestException('Repository-Angabe ist ungueltig');
@@ -331,20 +393,26 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
if (repo.private === true) throw new BadRequestException('Private Repositories werden aktuell nicht unterstuetzt'); if (repo.private === true) throw new BadRequestException('Private Repositories werden aktuell nicht unterstuetzt');
const selectedBranch = branch ?? String(repo.default_branch ?? 'main'); const selectedBranch = branch ?? String(repo.default_branch ?? 'main');
if (!selectedBranch || selectedBranch.length > 200 || selectedBranch.includes('\0')) throw new BadRequestException('Branch ist ungueltig'); if (!selectedBranch || selectedBranch.length > 200 || selectedBranch.includes('\0')) throw new BadRequestException('Branch ist ungueltig');
if (branch) await this.getBranch(provider, owner, repository, branch); // Resolve once, then request the immutable commit instead of the movable branch ref.
const commit = pinnedCommit ?? (await this.getBranch(provider, owner, repository, selectedBranch)).commit;
if (!isCommitSha(commit)) throw new BadGatewayException('Forge hat eine ungültige Commit-ID geliefert');
const archivePath = provider === 'github' const archivePath = provider === 'github'
? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + encodeURIComponent(selectedBranch) ? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + commit
: new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + encodeURIComponent(selectedBranch) + '.zip'; : new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + commit + '.zip';
const archiveUrl = provider === 'github' const archiveUrl = provider === 'github'
? new URL(archivePath, 'https://api.github.com').toString() ? new URL(archivePath, 'https://api.github.com').toString()
: new URL(archivePath, providerConfig.baseUrl).toString(); : new URL(archivePath, providerConfig.baseUrl).toString();
const allowedHosts = this.downloadHosts(providerConfig.baseUrl, provider); const allowedHosts = this.downloadHosts(providerConfig.baseUrl, provider);
const headers: Record<string, string> = provider === 'github' ? { 'User-Agent': 'MPM-Module-Marketplace' } : {}; const headers: Record<string, string> = provider === 'github' ? { 'User-Agent': 'MPM-Module-Marketplace' } : {};
const archive = await this.downloadBounded(archiveUrl, headers, allowedHosts, MAX_MARKETPLACE_DOWNLOAD); const archive = await this.downloadBounded(archiveUrl, headers, allowedHosts, MAX_MARKETPLACE_DOWNLOAD);
return this.normalizeRepositoryArchive(archive); return { archive: await this.normalizeRepositoryArchive(archive), commit: commit.toLowerCase() };
} }
async updateInstalledBranch(moduleId: string, branch: string): Promise<MarketplaceUpdatePackage> { async updateInstalledBranch(
moduleId: string,
branch: string,
onProgress?: (phase: string, message: string, progress: number) => void,
): Promise<MarketplaceUpdatePackage> {
const result = await this.database.query<MarketplaceInstallationRow>( const result = await this.database.query<MarketplaceInstallationRow>(
`SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches `SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches
FROM marketplace_module_installations WHERE module_id = $1`, [moduleId], FROM marketplace_module_installations WHERE module_id = $1`, [moduleId],
@@ -359,27 +427,50 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
if (!Array.isArray(installation.available_branches) || !installation.available_branches.some((item) => item.name === branch)) { if (!Array.isArray(installation.available_branches) || !installation.available_branches.some((item) => item.name === branch)) {
throw new BadRequestException('Diese Branch wurde bei der letzten Repository-Prüfung nicht als Update gefunden'); throw new BadRequestException('Diese Branch wurde bei der letzten Repository-Prüfung nicht als Update gefunden');
} }
onProgress?.('branch', 'Branch wird geprüft', 12);
const branchInfo = await this.getBranch(installation.provider, installation.owner, installation.repository, branch); const branchInfo = await this.getBranch(installation.provider, installation.owner, installation.repository, branch);
if (installation.installed_commit && branchInfo.commit.toLowerCase() === installation.installed_commit.toLowerCase()) { if (installation.installed_commit && branchInfo.commit.toLowerCase() === installation.installed_commit.toLowerCase()) {
throw new BadRequestException('Diese Branch enthält keine Änderungen gegenüber der installierten Version'); throw new BadRequestException('Diese Branch enthält keine Änderungen gegenüber der installierten Version');
} }
const archive = await this.downloadRepositoryArchive(installation.provider, installation.owner, installation.repository, branch); onProgress?.('download', 'Update-Archiv wird geladen', 28);
const { archive } = await this.downloadRepositoryArchive(
installation.provider, installation.owner, installation.repository, branch, branchInfo.commit,
);
// Caller updates and validates the module files before this source record is advanced. // Caller updates and validates the module files before this source record is advanced.
return { archive, commit: branchInfo.commit, provider: installation.provider, return { archive, commit: branchInfo.commit, provider: installation.provider,
owner: installation.owner, repository: installation.repository, branch }; owner: installation.owner, repository: installation.repository, branch,
previousBranch: installation.installed_branch, previousCommit: installation.installed_commit };
} }
async commitInstalledBranch(moduleId: string, provider: MarketplaceProvider, owner: string, repository: string, branch: string, commit: string): Promise<void> { async commitInstalledBranch(
await this.database.query( moduleId: string,
provider: MarketplaceProvider,
owner: string,
repository: string,
branch: string,
commit: string,
expectedBranch: string,
expectedCommit: string | null,
): Promise<void> {
const result = await this.database.query<{ module_id: string }>(
`UPDATE marketplace_module_installations SET installed_branch = $2, installed_commit = $3, `UPDATE marketplace_module_installations SET installed_branch = $2, installed_commit = $3,
available_branches = COALESCE(( available_branches = COALESCE((
SELECT jsonb_agg(item.value) FROM jsonb_array_elements(available_branches) AS item(value) SELECT jsonb_agg(item.value) FROM jsonb_array_elements(available_branches) AS item(value)
WHERE item.value->>'name' <> $2 WHERE item.value->>'name' <> $2
), '[]'::jsonb), branches_checked_at = now() ), '[]'::jsonb), branches_checked_at = now()
WHERE module_id = $1 AND provider = $4 AND owner = $5 AND repository = $6`, WHERE module_id = $1 AND provider = $4 AND owner = $5 AND repository = $6
[moduleId, branch, commit, provider, owner, repository], AND installed_branch = $7 AND installed_commit IS NOT DISTINCT FROM $8
RETURNING module_id`,
[moduleId, branch, commit, provider, owner, repository, expectedBranch, expectedCommit],
); );
await this.refreshInstallation(moduleId); if (!result.rows.length) {
throw new ConflictException('Die installierte Branch wurde zwischenzeitlich geändert. Bitte Updates neu laden.');
}
try {
await this.refreshInstallation(moduleId);
} catch (error) {
this.logger.warn(`Branch-Prüfung für Modul ${moduleId} nach Update fehlgeschlagen: ${error instanceof Error ? error.message : 'unbekannter Fehler'}`);
}
} }
private async getBranch(provider: MarketplaceProvider, owner: string, repository: string, branch: string): Promise<MarketplaceBranch> { private async getBranch(provider: MarketplaceProvider, owner: string, repository: string, branch: string): Promise<MarketplaceBranch> {
@@ -392,7 +483,7 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
const value = await this.forgeJson<Record<string, unknown>>(provider, this.config.marketplace.providers[provider]!.baseUrl, null, basePath); const value = await this.forgeJson<Record<string, unknown>>(provider, this.config.marketplace.providers[provider]!.baseUrl, null, basePath);
const commit = value.commit as Record<string, unknown> | undefined; const commit = value.commit as Record<string, unknown> | undefined;
const sha = String(commit?.id ?? commit?.sha ?? ''); const sha = String(commit?.id ?? commit?.sha ?? '');
if (!sha) throw new NotFoundException('Branch konnte beim Forge nicht gefunden werden'); if (!isCommitSha(sha)) throw new BadGatewayException('Forge hat eine ungültige Commit-ID geliefert');
return { name: String(value.name ?? branch), commit: sha }; return { name: String(value.name ?? branch), commit: sha };
} }

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { chmod, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { stringify, parseDocument } from 'yaml'; import { stringify, parseDocument } from 'yaml';
@@ -15,13 +15,25 @@ const SAFE_SERVICE_KEYS = new Set([
'stop_grace_period', 'read_only', 'tty', 'stdin_open', 'stop_grace_period', 'read_only', 'tty', 'stdin_open',
]); ]);
const SAFE_BUILD_KEYS = new Set(['context', 'dockerfile', 'target', 'args']);
const MAX_MODULE_SERVICES = 8;
const MODULE_MEMORY_LIMIT = '512m';
const MODULE_CPU_LIMIT = 1;
const MODULE_PIDS_LIMIT = 256;
const COMPOSE_COMMAND_TIMEOUT_MS = 15 * 60_000;
const COMPOSE_CONTROL_TIMEOUT_MS = 2 * 60_000;
const DOCKER_COMMAND_TIMEOUT_MS = 45_000;
const COMMAND_TERMINATION_GRACE_MS = 5_000;
/** Ein Docker-CLI-Fehler mit einer für die Admin-Oberfläche bereinigten Diagnose. */ /** Ein Docker-CLI-Fehler mit einer für die Admin-Oberfläche bereinigten Diagnose. */
export class ModuleCommandError extends Error { export class ModuleCommandError extends Error {
constructor( constructor(
readonly exitCode: number | null, readonly exitCode: number | null,
readonly diagnostic: string, readonly diagnostic: string,
readonly timedOut = false,
) { ) {
super(exitCode === null ? 'Docker-Befehl konnte nicht gestartet werden' : `Docker-Befehl endete mit Status ${exitCode}`); super(timedOut ? 'Docker-Befehl hat das Zeitlimit überschritten' :
exitCode === null ? 'Docker-Befehl konnte nicht gestartet werden' : `Docker-Befehl endete mit Status ${exitCode}`);
this.name = 'ModuleCommandError'; this.name = 'ModuleCommandError';
} }
} }
@@ -83,6 +95,19 @@ export class ModuleContainerManager {
} }
} }
/** Removes containers from a failed start while retaining all named data volumes. */
async cleanupFailedStart(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork, cleanupValues } = await this.prepare(module);
try {
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans'], cleanupValues);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
await this.runDocker(['network', 'rm', gatewayNetwork], true);
this.logger.log(`Teilweise gestarteter Stack für "${module.moduleId}" ohne Datenverlust bereinigt`);
} finally {
await rm(overridePath, { force: true });
}
}
private async prepare(module: ModuleRecord): Promise<{ private async prepare(module: ModuleRecord): Promise<{
composePath: string; composePath: string;
overridePath: string; overridePath: string;
@@ -97,13 +122,18 @@ export class ModuleContainerManager {
const root = path.resolve(module.path); const root = path.resolve(module.path);
const composePath = path.resolve(root, module.composeFile); const composePath = path.resolve(root, module.composeFile);
if (!composePath.startsWith(root + path.sep)) throw new BadRequestException('Compose-Datei liegt außerhalb des Modulpakets'); if (!composePath.startsWith(root + path.sep)) throw new BadRequestException('Compose-Datei liegt außerhalb des Modulpakets');
const source = await readFile(composePath, 'utf8').catch(() => { const actualRoot = await realpath(root);
const actualCompose = await realpath(composePath).catch(() => {
throw new BadRequestException(`Compose-Datei "${module.composeFile}" wurde nicht gefunden`); throw new BadRequestException(`Compose-Datei "${module.composeFile}" wurde nicht gefunden`);
}); });
if (!this.isInside(actualRoot, actualCompose)) {
throw new BadRequestException('Compose-Datei liegt außerhalb des Modulpakets');
}
const source = await readFile(composePath, 'utf8');
const document = parseDocument(source, { uniqueKeys: true }); const document = parseDocument(source, { uniqueKeys: true });
if (document.errors.length) throw new BadRequestException('Compose-Datei enthält ungültiges YAML'); if (document.errors.length) throw new BadRequestException('Compose-Datei enthält ungültiges YAML');
const compose = document.toJS() as Record<string, unknown>; const compose = document.toJS() as Record<string, unknown>;
this.validateCompose(compose, module); await this.validateCompose(compose, module, actualRoot, path.dirname(composePath));
const serviceMap = compose.services as Record<string, unknown>; const serviceMap = compose.services as Record<string, unknown>;
const moduleValues = await this.configurationService.values(module); const moduleValues = await this.configurationService.values(module);
// Compose validates required interpolations even for stop/down. Supply // Compose validates required interpolations even for stop/down. Supply
@@ -126,21 +156,28 @@ export class ModuleContainerManager {
// Keep generated secrets outside the package/build context so Dockerfiles // Keep generated secrets outside the package/build context so Dockerfiles
// cannot accidentally copy them into an application image. // cannot accidentally copy them into an application image.
const overridePath = path.join(tmpdir(), 'mpm-compose', `${module.moduleId}.yml`); const overridePath = path.join(tmpdir(), 'mpm-compose', `${module.moduleId}.yml`);
const overrideServices: Record<string, Record<string, unknown>> = { const overrideServices: Record<string, Record<string, unknown>> = {};
[module.appService]: { for (const serviceName of Object.keys(serviceMap)) {
container_name: `mpm-${module.moduleId}-app`, overrideServices[serviceName] = {
environment: {
PORT: String(module.internalPort),
NODE_ENV: process.env.NODE_ENV ?? 'production',
MPM_MODULE_DATA_DIR: '/var/lib/mpm-module',
MPM_MODULE_IDENTITY_KEY: this.identityService.keyForModule(module.moduleId),
},
volumes: ['mpm-runtime-data:/var/lib/mpm-module'],
networks: {
default: {},
'mpm-gateway': { aliases: [`mpm-${module.moduleId}`] },
},
security_opt: ['no-new-privileges:true'], security_opt: ['no-new-privileges:true'],
mem_limit: MODULE_MEMORY_LIMIT,
cpus: MODULE_CPU_LIMIT,
pids_limit: MODULE_PIDS_LIMIT,
};
}
overrideServices[module.appService] = {
...overrideServices[module.appService],
container_name: `mpm-${module.moduleId}-app`,
environment: {
PORT: String(module.internalPort),
NODE_ENV: process.env.NODE_ENV ?? 'production',
MPM_MODULE_DATA_DIR: '/var/lib/mpm-module',
MPM_MODULE_IDENTITY_KEY: this.identityService.keyForModule(module.moduleId),
},
volumes: ['mpm-runtime-data:/var/lib/mpm-module'],
networks: {
default: {},
'mpm-gateway': { aliases: [`mpm-${module.moduleId}`] },
}, },
}; };
for (const field of module.configuration) { for (const field of module.configuration) {
@@ -164,13 +201,18 @@ export class ModuleContainerManager {
volumes: { 'mpm-runtime-data': {} }, volumes: { 'mpm-runtime-data': {} },
networks: { 'mpm-gateway': { external: true, name: gatewayNetwork } }, networks: { 'mpm-gateway': { external: true, name: gatewayNetwork } },
}; };
await mkdir(path.dirname(overridePath), { recursive: true, mode: 0o700 }); try {
await writeFile(overridePath, stringify(override), { mode: 0o600 }); await mkdir(path.dirname(overridePath), { recursive: true, mode: 0o700 });
await chmod(overridePath, 0o600); await writeFile(overridePath, stringify(override), { mode: 0o600 });
await chmod(overridePath, 0o600);
} catch (error) {
await rm(overridePath, { force: true });
throw error;
}
return { composePath, overridePath, projectName, gatewayNetwork, moduleValues, cleanupValues }; return { composePath, overridePath, projectName, gatewayNetwork, moduleValues, cleanupValues };
} }
private validateCompose(compose: Record<string, unknown>, module: ModuleRecord): void { private async validateCompose(compose: Record<string, unknown>, module: ModuleRecord, root: string, composeDir: string): Promise<void> {
if (!compose || typeof compose !== 'object' || Array.isArray(compose)) { if (!compose || typeof compose !== 'object' || Array.isArray(compose)) {
throw new BadRequestException('Compose-Datei muss ein YAML-Objekt enthalten'); throw new BadRequestException('Compose-Datei muss ein YAML-Objekt enthalten');
} }
@@ -183,6 +225,9 @@ export class ModuleContainerManager {
throw new BadRequestException('Compose benötigt mindestens einen Service'); throw new BadRequestException('Compose benötigt mindestens einen Service');
} }
const serviceMap = services as Record<string, unknown>; const serviceMap = services as Record<string, unknown>;
if (Object.keys(serviceMap).length > MAX_MODULE_SERVICES) {
throw new BadRequestException(`Compose darf höchstens ${MAX_MODULE_SERVICES} Services enthalten`);
}
if (!Object.hasOwn(serviceMap, module.appService!)) { if (!Object.hasOwn(serviceMap, module.appService!)) {
throw new BadRequestException(`Compose-Service "${module.appService}" fehlt`); throw new BadRequestException(`Compose-Service "${module.appService}" fehlt`);
} }
@@ -205,9 +250,8 @@ export class ModuleContainerManager {
service.container_name !== undefined || service.secrets !== undefined || service.configs !== undefined) { service.container_name !== undefined || service.secrets !== undefined || service.configs !== undefined) {
throw new BadRequestException(`Compose-Service "${name}" darf keine Host- oder privilegierten Ressourcen verwenden`); throw new BadRequestException(`Compose-Service "${name}" darf keine Host- oder privilegierten Ressourcen verwenden`);
} }
if (service.build !== undefined) this.validateBuild(service.build, module.path); if (service.build !== undefined) await this.validateBuild(service.build, root, composeDir);
if (service.volumes !== undefined) this.validateVolumes(service.volumes); if (service.volumes !== undefined) this.validateVolumes(service.volumes);
service.security_opt = ['no-new-privileges:true'];
} }
for (const [name, volume] of Object.entries(definedVolumes)) { for (const [name, volume] of Object.entries(definedVolumes)) {
if (name === 'mpm-runtime-data' || (volume !== undefined && volume !== null && if (name === 'mpm-runtime-data' || (volume !== undefined && volume !== null &&
@@ -235,18 +279,64 @@ export class ModuleContainerManager {
return value as Record<string, unknown>; return value as Record<string, unknown>;
} }
private validateBuild(build: unknown, modulePath: string): void { private async validateBuild(build: unknown, root: string, composeDir: string): Promise<void> {
const context = typeof build === 'string' const options: Record<string, unknown> | null = typeof build === 'string'
? build ? { context: build }
: build && typeof build === 'object' && !Array.isArray(build) : build && typeof build === 'object' && !Array.isArray(build)
? String((build as Record<string, unknown>).context ?? '.') ? build as Record<string, unknown>
: ''; : null;
if (!context || path.isAbsolute(context) || context.split(/[\\/]/).includes('..')) { if (!options || Object.keys(options).some((key) => !SAFE_BUILD_KEYS.has(key))) {
throw new BadRequestException('Build-Kontext muss innerhalb des Modulpakets liegen'); throw new BadRequestException('Compose-Build enthält nicht erlaubte Optionen');
} }
const resolved = path.resolve(modulePath, context); const context = options.context ?? '.';
if (resolved !== modulePath && !resolved.startsWith(modulePath + path.sep)) { if (typeof context !== 'string' || !this.isStaticRelativePath(context)) {
throw new BadRequestException('Build-Kontext liegt außerhalb des Modulpakets'); throw new BadRequestException('Build-Kontext muss ein fester relativer Pfad sein');
}
const contextPath = path.resolve(composeDir, context);
const actualContext = await realpath(contextPath).catch(() => {
throw new BadRequestException('Build-Kontext wurde nicht gefunden');
});
if (!this.isInside(root, actualContext) || !(await stat(actualContext)).isDirectory()) {
throw new BadRequestException('Build-Kontext muss ein Verzeichnis innerhalb des Modulpakets sein');
}
const dockerfile = options.dockerfile ?? 'Dockerfile';
if (typeof dockerfile !== 'string' || !this.isStaticRelativePath(dockerfile, true)) {
throw new BadRequestException('Dockerfile muss ein fester relativer Pfad sein');
}
const actualDockerfile = await realpath(path.resolve(actualContext, dockerfile)).catch(() => {
throw new BadRequestException('Dockerfile wurde nicht gefunden');
});
if (!this.isInside(root, actualDockerfile) || !(await stat(actualDockerfile)).isFile()) {
throw new BadRequestException('Dockerfile muss eine Datei innerhalb des Modulpakets sein');
}
if (options.target !== undefined && (typeof options.target !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(options.target))) {
throw new BadRequestException('Compose-Build-Target ist ungültig');
}
if (options.args !== undefined) this.validateBuildArgs(options.args);
}
private isStaticRelativePath(value: string, allowParent = false): boolean {
return value.length > 0 && !path.isAbsolute(value) && !value.includes('\\') && !value.includes('$') &&
!value.includes(':') && !value.includes('#') && !value.startsWith('~') &&
(allowParent || !value.split('/').includes('..'));
}
private isInside(root: string, target: string): boolean {
return target === root || target.startsWith(root + path.sep);
}
private validateBuildArgs(args: unknown): void {
if (Array.isArray(args)) {
if (!args.every((arg) => typeof arg === 'string' && /^[A-Za-z_][A-Za-z0-9_]*(=.*)?$/.test(arg))) {
throw new BadRequestException('Compose-Build-Argumente sind ungültig');
}
return;
}
if (!args || typeof args !== 'object' || Object.entries(args).some(([key, value]) =>
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) ||
(value !== null && !['string', 'number', 'boolean'].includes(typeof value)))) {
throw new BadRequestException('Compose-Build-Argumente sind ungültig');
} }
} }
@@ -265,17 +355,21 @@ export class ModuleContainerManager {
} }
private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[], config: Record<string, string>): Promise<void> { private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[], config: Record<string, string>): Promise<void> {
return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd, false, config); const timeoutMs = args[0] === 'up' ? COMPOSE_COMMAND_TIMEOUT_MS : COMPOSE_CONTROL_TIMEOUT_MS;
return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd, false, config, timeoutMs);
} }
private runDocker(args: string[], ignoreFailure = false): Promise<void> { private runDocker(args: string[], ignoreFailure = false): Promise<void> {
return this.run('docker', args, process.cwd(), ignoreFailure); return this.run('docker', args, process.cwd(), ignoreFailure, {}, DOCKER_COMMAND_TIMEOUT_MS);
} }
private run(command: string, args: string[], cwd: string, ignoreFailure = false, extraEnv: Record<string, string> = {}): Promise<void> { private run(command: string, args: string[], cwd: string, ignoreFailure = false, extraEnv: Record<string, string> = {}, timeoutMs = DOCKER_COMMAND_TIMEOUT_MS): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn(command, args, { const child = spawn(command, args, {
cwd, cwd,
// Give Docker Compose and its subprocesses one process group so a
// timeout can stop the whole operation before cleanup starts.
detached: process.platform !== 'win32',
env: { env: {
...extraEnv, ...extraEnv,
PATH: process.env.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', PATH: process.env.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
@@ -293,18 +387,52 @@ export class ModuleContainerManager {
child.stdout.on('data', (chunk: string) => { stdout = keepTail(stdout, chunk); }); child.stdout.on('data', (chunk: string) => { stdout = keepTail(stdout, chunk); });
child.stderr.setEncoding('utf8'); child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk: string) => { stderr = keepTail(stderr, chunk); }); child.stderr.on('data', (chunk: string) => { stderr = keepTail(stderr, chunk); });
let processStartFailed = false; let settled = false;
let timedOut = false;
let terminationTimer: NodeJS.Timeout | undefined;
const stopProcessGroup = (signal: NodeJS.Signals): void => {
if (process.platform !== 'win32' && child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// The process group may already have exited.
}
}
child.kill(signal);
};
const timeout = setTimeout(() => {
if (settled) return;
timedOut = true;
terminationTimer = setTimeout(() => stopProcessGroup('SIGKILL'), COMMAND_TERMINATION_GRACE_MS);
terminationTimer.unref();
stopProcessGroup('SIGTERM');
}, timeoutMs);
child.once('error', (error) => { child.once('error', (error) => {
if (timedOut) return;
clearTimeout(timeout);
if (terminationTimer) clearTimeout(terminationTimer);
if (settled) return;
settled = true;
if (ignoreFailure) resolve(); if (ignoreFailure) resolve();
else { else {
processStartFailed = true;
const errorCode = (error as NodeJS.ErrnoException).code ?? 'unbekannt'; const errorCode = (error as NodeJS.ErrnoException).code ?? 'unbekannt';
this.logger.error(`${command} konnte nicht gestartet werden (${errorCode})`); this.logger.error(`${command} konnte nicht gestartet werden (${errorCode})`);
reject(new ModuleCommandError(null, `Der Befehl „${command}“ konnte nicht gestartet werden. Prüfe, ob Docker auf dem System verfügbar ist.`)); reject(new ModuleCommandError(null, `Der Befehl „${command}“ konnte nicht gestartet werden. Prüfe, ob Docker auf dem System verfügbar ist.`));
} }
}); });
child.once('close', (code) => { child.once('close', (code) => {
if (processStartFailed) return; clearTimeout(timeout);
if (terminationTimer) clearTimeout(terminationTimer);
if (settled) return;
settled = true;
if (timedOut) {
const duration = timeoutMs < 60_000 ? `${timeoutMs / 1_000} Sekunden` : `${timeoutMs / 60_000} Minuten`;
const diagnostic = `${command} hat das Zeitlimit von ${duration} überschritten.`;
this.logger.error(diagnostic);
reject(new ModuleCommandError(null, diagnostic, true));
return;
}
if (code === 0 || ignoreFailure) resolve(); if (code === 0 || ignoreFailure) resolve();
else { else {
const diagnostic = this.sanitizeDiagnostic(`${stdout}\n${stderr}`, extraEnv); const diagnostic = this.sanitizeDiagnostic(`${stdout}\n${stderr}`, extraEnv);

View File

@@ -54,7 +54,11 @@ function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
function createRequest(url: string, cookie?: string): Request { function createRequest(url: string, cookie?: string): Request {
return { return {
url, url,
headers: cookie ? { cookie } : {}, method: 'GET',
headers: {
host: 'localhost:8081',
...(cookie ? { cookie } : {}),
},
} as unknown as Request; } as unknown as Request;
} }
@@ -79,8 +83,8 @@ function createResponse(): Response & { sentStatus: number; sentBody: unknown }
class MockSessionService { class MockSessionService {
public session: SessionData | null = null; public session: SessionData | null = null;
async findValid(): Promise<SessionData | null> { async findValidModuleSession(): Promise<string | null> {
return this.session; return this.session?.userId ?? null;
} }
} }
@@ -135,6 +139,7 @@ describe('ModuleGatewayMiddleware', () => {
sessionService as unknown as SessionService, sessionService as unknown as SessionService,
userRepository as unknown as UserRepository, userRepository as unknown as UserRepository,
permissionsService as unknown as ModulePermissionsService, permissionsService as unknown as ModulePermissionsService,
{ modulePublicOrigin: 'http://localhost:8081' } as never,
); );
sessionService.session = { sessionService.session = {
id: 'session-1', id: 'session-1',
@@ -163,7 +168,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 401 bei ungültiger Session', async () => { it('antwortet 401 bei ungültiger Session', async () => {
sessionService.session = null; sessionService.session = null;
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=invalid'); const request = createRequest('/api/v1/gateway/demo/', 'mpm_module_session=invalid');
const response = createResponse(); const response = createResponse();
await middleware.use(request, response, makeNext()); await middleware.use(request, response, makeNext());
@@ -172,7 +177,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 401 bei deaktiviertem Benutzer', async () => { it('antwortet 401 bei deaktiviertem Benutzer', async () => {
userRepository.user = createUserRecord({ isActive: false }); userRepository.user = createUserRecord({ isActive: false });
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
await middleware.use(request, response, makeNext()); await middleware.use(request, response, makeNext());
@@ -181,7 +186,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 404 bei unbekanntem Modul-Slug', async () => { it('antwortet 404 bei unbekanntem Modul-Slug', async () => {
moduleRepository.module = null; moduleRepository.module = null;
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
await middleware.use(request, response, makeNext()); await middleware.use(request, response, makeNext());
@@ -190,7 +195,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 503 bei gestopptem Modul', async () => { it('antwortet 503 bei gestopptem Modul', async () => {
moduleRepository.module = createModuleRecord({ status: 'STOPPED' }); moduleRepository.module = createModuleRecord({ status: 'STOPPED' });
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
await middleware.use(request, response, makeNext()); await middleware.use(request, response, makeNext());
@@ -199,7 +204,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 503 bei deaktiviertem Modul', async () => { it('antwortet 503 bei deaktiviertem Modul', async () => {
moduleRepository.module = createModuleRecord({ enabled: false }); moduleRepository.module = createModuleRecord({ enabled: false });
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
await middleware.use(request, response, makeNext()); await middleware.use(request, response, makeNext());
@@ -208,7 +213,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 403 für USER ohne Berechtigung (fail-closed)', async () => { it('antwortet 403 für USER ohne Berechtigung (fail-closed)', async () => {
permissionsService.hasAccessResult = false; permissionsService.hasAccessResult = false;
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
await middleware.use(request, response, makeNext()); await middleware.use(request, response, makeNext());
@@ -217,7 +222,7 @@ describe('ModuleGatewayMiddleware', () => {
it('leitet USER-Requests mit GRANTED-Berechtigung an den Proxy weiter', async () => { it('leitet USER-Requests mit GRANTED-Berechtigung an den Proxy weiter', async () => {
permissionsService.hasAccessResult = true; permissionsService.hasAccessResult = true;
const request = createRequest('/api/v1/gateway/demo/health', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/health', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
const proxySpy = jest const proxySpy = jest
@@ -235,7 +240,7 @@ describe('ModuleGatewayMiddleware', () => {
it('leitet ADMIN-Requests an den Modul-Proxy weiter', async () => { it('leitet ADMIN-Requests an den Modul-Proxy weiter', async () => {
userRepository.user = createUserRecord({ role: 'ADMIN' }); userRepository.user = createUserRecord({ role: 'ADMIN' });
const request = createRequest('/api/v1/gateway/demo/health', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/health', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
// proxy.web würde einen echten Request starten – hier nur prüfen, // proxy.web würde einen echten Request starten – hier nur prüfen,

View File

@@ -1,8 +1,9 @@
import { Injectable, type NestMiddleware } from '@nestjs/common'; import { ForbiddenException, Inject, Injectable, type NestMiddleware } from '@nestjs/common';
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import httpProxy from 'http-proxy'; import httpProxy from 'http-proxy';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
import { SessionService } from '../auth/session.service'; import { SessionService } from '../auth/session.service';
import { extractSessionToken } from '../auth/guards/session.guard'; import { requireSameOrigin } from '../auth/request-origin';
import { UserRepository } from '../users/user.repository'; import { UserRepository } from '../users/user.repository';
import { ModuleRepository } from './module.repository'; import { ModuleRepository } from './module.repository';
import { ModulePermissionsService } from './module-permissions.service'; import { ModulePermissionsService } from './module-permissions.service';
@@ -10,6 +11,23 @@ import { ModuleIdentityService } from './module-identity.service';
/** Gateway-Pfad-Präfix für interne Nginx-Weiterleitung. */ /** Gateway-Pfad-Präfix für interne Nginx-Weiterleitung. */
const GATEWAY_PREFIX = '/api/v1/gateway/'; const GATEWAY_PREFIX = '/api/v1/gateway/';
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
function moduleCookie(request: Request): string | null {
let value: string | null = null;
for (const part of (request.headers.cookie ?? '').split(';')) {
const [name, ...parts] = part.trim().split('=');
if (name === 'mpm_module_session') value = decodeURIComponent(parts.join('='));
}
return value;
}
function cookiesForModule(cookieHeader: string | undefined): string | undefined {
const remaining = (cookieHeader ?? '').split(';').map((part) => part.trim()).filter((part) =>
part && !/^(mpm_module_session|mpm_session|mpm_csrf|__Host-mpm_session|__Host-mpm_csrf)=/.test(part),
);
return remaining.length ? remaining.join('; ') : undefined;
}
/** /**
* Modul-Gateway (Phase 4/5): Dynamisches Routing /slug → Modul-Prozess. * Modul-Gateway (Phase 4/5): Dynamisches Routing /slug → Modul-Prozess.
@@ -35,6 +53,7 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly permissionsService: ModulePermissionsService, private readonly permissionsService: ModulePermissionsService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
private readonly identityService: ModuleIdentityService = new ModuleIdentityService(), private readonly identityService: ModuleIdentityService = new ModuleIdentityService(),
) { ) {
this.proxy = httpProxy.createProxyServer({ this.proxy = httpProxy.createProxyServer({
@@ -69,6 +88,21 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
proxyRequest.setHeader('Content-Length', Buffer.byteLength(body)); proxyRequest.setHeader('Content-Length', Buffer.byteLength(body));
proxyRequest.write(body); proxyRequest.write(body);
}); });
// Modul-Cookies dürfen weder für die ganze Parent-Domain noch für andere
// Module gelten. Eigene App-Cookies bleiben innerhalb des Modulpfads nutzbar.
this.proxy.on('proxyRes', (proxyResponse, request) => {
const slug = (request as Request & { mpmModuleSlug?: string }).mpmModuleSlug;
const cookies = proxyResponse.headers['set-cookie'];
if (!slug || !cookies) return;
proxyResponse.headers['set-cookie'] = cookies.map((cookie) => {
const [nameAndValue, ...attributes] = cookie.split(';');
const restrictedAttributes = attributes.filter((attribute) =>
!/^\s*(domain|path|samesite)\s*=/i.test(attribute),
);
return `${nameAndValue};${restrictedAttributes.join(';')}; Path=/${slug}; SameSite=Lax`;
});
});
} }
async use(request: Request, response: Response, next: NextFunction): Promise<void> { async use(request: Request, response: Response, next: NextFunction): Promise<void> {
@@ -77,26 +111,45 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
return; return;
} }
// Die API ist auf dem Modulhost nicht sichtbar. Das Gateway darf nur
// Requests vom dedizierten Browser-Origin weiterleiten.
if (request.headers.host !== new URL(this.config.modulePublicOrigin).host) {
response.status(403).json({ statusCode: 403, message: 'Ungültiger Modul-Host' });
return;
}
if (STATE_CHANGING_METHODS.has(request.method)) {
try {
requireSameOrigin(request, this.config.modulePublicOrigin);
} catch (error) {
if (error instanceof ForbiddenException) {
response.status(403).json({ statusCode: 403, message: error.message });
return;
}
throw error;
}
}
// Slug aus dem Gateway-Pfad extrahieren: /api/v1/gateway/<slug>/<rest> // Slug aus dem Gateway-Pfad extrahieren: /api/v1/gateway/<slug>/<rest>
const pathAfterPrefix = request.url.slice(GATEWAY_PREFIX.length); const gatewayUrl = new URL(request.url, 'http://gateway.internal');
const pathAfterPrefix = gatewayUrl.pathname.slice(GATEWAY_PREFIX.length);
const slashIndex = pathAfterPrefix.indexOf('/'); const slashIndex = pathAfterPrefix.indexOf('/');
const slug = slashIndex === -1 ? pathAfterPrefix : pathAfterPrefix.slice(0, slashIndex); const slug = slashIndex === -1 ? pathAfterPrefix : pathAfterPrefix.slice(0, slashIndex);
const modulePath = slashIndex === -1 ? '/' : pathAfterPrefix.slice(slashIndex); const modulePath = (slashIndex === -1 ? '/' : pathAfterPrefix.slice(slashIndex)) + gatewayUrl.search;
// 1. Authentifizierung: Session aus Cookie laden // 1. Authentifizierung: Session aus Cookie laden
const token = extractSessionToken(request); const token = moduleCookie(request);
if (!token) { if (!token) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' }); response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return; return;
} }
const session = await this.sessionService.findValid(token); const userId = await this.sessionService.findValidModuleSession(token, slug);
if (!session) { if (!userId) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' }); response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return; return;
} }
const user = await this.userRepository.findById(session.userId); const user = await this.userRepository.findById(userId);
if (!user || !user.isActive) { if (!user || !user.isActive) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' }); response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return; return;
@@ -139,8 +192,11 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
request.headers['x-user-role'] = user.role; request.headers['x-user-role'] = user.role;
request.headers['x-mpm-identity-timestamp'] = signedIdentity.timestamp; request.headers['x-mpm-identity-timestamp'] = signedIdentity.timestamp;
request.headers['x-mpm-identity-signature'] = signedIdentity.signature; request.headers['x-mpm-identity-signature'] = signedIdentity.signature;
// Session-Cookie niemals an das Modul weiterleiten (request as Request & { mpmModuleSlug?: string }).mpmModuleSlug = slug;
delete request.headers.cookie; // Nur eigene App-Cookies, nie Plattform- oder Modul-Gateway-Cookies weiterreichen.
const appCookies = cookiesForModule(request.headers.cookie);
if (appCookies) request.headers.cookie = appCookies;
else delete request.headers.cookie;
this.proxy.web(request, response, { this.proxy.web(request, response, {
target: `http://${module.composeFile && module.appService ? `mpm-${module.moduleId}` : '127.0.0.1'}:${module.internalPort}`, target: `http://${module.composeFile && module.appService ? `mpm-${module.moduleId}` : '127.0.0.1'}:${module.internalPort}`,

View File

@@ -8,6 +8,10 @@ import { parseDocument } from 'yaml';
/** Maximale Größe eines Modul-Pakets (10 MB). */ /** Maximale Größe eines Modul-Pakets (10 MB). */
const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024; const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024;
const MAX_EXTRACTED_SIZE_BYTES = 50 * 1024 * 1024;
const MAX_ARCHIVE_ENTRIES = 2000;
const MAX_SINGLE_FILE_BYTES = 20 * 1024 * 1024;
const MAX_METADATA_FILE_BYTES = 1024 * 1024;
/** Dateien, die in einem Modul-Paket erwartet werden. */ /** Dateien, die in einem Modul-Paket erwartet werden. */
const REQUIRED_MANIFEST_FILE = 'module.json'; const REQUIRED_MANIFEST_FILE = 'module.json';
@@ -28,20 +32,19 @@ export class ModuleInstaller {
/** Validiert ein hochgeladenes Paket und gibt das Manifest zurück. */ /** Validiert ein hochgeladenes Paket und gibt das Manifest zurück. */
async validatePackage(buffer: Buffer): Promise<ModuleManifest> { async validatePackage(buffer: Buffer): Promise<ModuleManifest> {
if (buffer.length === 0) { this.assertCompressedSize(buffer);
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 AdmZip = (await import('adm-zip')).default;
const zip = new AdmZip(buffer); const zip = new AdmZip(buffer);
this.assertSafeArchive(zip.getEntries(), path.resolve('/module-package'));
const manifestEntry = zip.getEntry(REQUIRED_MANIFEST_FILE); const manifestEntry = zip.getEntry(REQUIRED_MANIFEST_FILE);
if (!manifestEntry) { if (!manifestEntry) {
throw new BadRequestException(`Paket enthält keine ${REQUIRED_MANIFEST_FILE}`); throw new BadRequestException(`Paket enthält keine ${REQUIRED_MANIFEST_FILE}`);
} }
if (manifestEntry.header.size > MAX_METADATA_FILE_BYTES) {
throw new BadRequestException(`${REQUIRED_MANIFEST_FILE} ist zu groß`);
}
let manifestJson: unknown; let manifestJson: unknown;
try { try {
@@ -75,10 +78,14 @@ export class ModuleInstaller {
manifest.composeFile.split('/').includes('..')) { manifest.composeFile.split('/').includes('..')) {
throw new BadRequestException('composeFile muss ein relativer Pfad innerhalb des Modulpakets sein'); throw new BadRequestException('composeFile muss ein relativer Pfad innerhalb des Modulpakets sein');
} }
if (!zip.getEntry(manifest.composeFile)) { const composeEntry = zip.getEntry(manifest.composeFile);
if (!composeEntry) {
throw new BadRequestException(`Container-Konfiguration ${manifest.composeFile} fehlt im Paket`); throw new BadRequestException(`Container-Konfiguration ${manifest.composeFile} fehlt im Paket`);
} }
const composeDocument = parseDocument(zip.getEntry(manifest.composeFile)!.getData().toString('utf8'), { uniqueKeys: true }); if (composeEntry.header.size > MAX_METADATA_FILE_BYTES) {
throw new BadRequestException('Compose-Datei ist zu groß');
}
const composeDocument = parseDocument(composeEntry.getData().toString('utf8'), { uniqueKeys: true });
if (composeDocument.errors.length) { if (composeDocument.errors.length) {
throw new BadRequestException('Compose-Datei enthält keine gültige Service-Definition'); throw new BadRequestException('Compose-Datei enthält keine gültige Service-Definition');
} }
@@ -106,23 +113,14 @@ export class ModuleInstaller {
manifest: ModuleManifest, manifest: ModuleManifest,
modulesDir: string, modulesDir: string,
): Promise<{ directory: string; manifest: ModuleManifest }> { ): Promise<{ directory: string; manifest: ModuleManifest }> {
this.assertCompressedSize(buffer);
const directory = path.join(modulesDir, manifest.id); const directory = path.join(modulesDir, manifest.id);
// Zip-Slip-Schutz: Alle Einträge müssen innerhalb des Zielverzeichnisses liegen. // Zip-Slip-Schutz: Alle Einträge müssen innerhalb des Zielverzeichnisses liegen.
const AdmZip = (await import('adm-zip')).default; const AdmZip = (await import('adm-zip')).default;
const zip = new AdmZip(buffer); const zip = new AdmZip(buffer);
const resolvedDirectory = path.resolve(directory); const resolvedDirectory = path.resolve(directory);
this.assertSafeArchive(zip.getEntries(), resolvedDirectory);
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). // Bestehende Installation entfernen (Update-Szenario).
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
@@ -148,6 +146,7 @@ export class ModuleInstaller {
manifest: ModuleManifest, manifest: ModuleManifest,
modulesDir: string, modulesDir: string,
): Promise<{ directory: string; backupDirectory: string }> { ): Promise<{ directory: string; backupDirectory: string }> {
this.assertCompressedSize(buffer);
const directory = path.join(modulesDir, manifest.id); const directory = path.join(modulesDir, manifest.id);
const suffix = randomUUID(); const suffix = randomUUID();
const stagingDirectory = path.join(modulesDir, `.update-${manifest.id}-${suffix}`); const stagingDirectory = path.join(modulesDir, `.update-${manifest.id}-${suffix}`);
@@ -155,14 +154,7 @@ export class ModuleInstaller {
const AdmZip = (await import('adm-zip')).default; const AdmZip = (await import('adm-zip')).default;
const zip = new AdmZip(buffer); const zip = new AdmZip(buffer);
const resolvedStage = path.resolve(stagingDirectory); const resolvedStage = path.resolve(stagingDirectory);
for (const entry of zip.getEntries()) { this.assertSafeArchive(zip.getEntries(), resolvedStage);
const entryName = entry.entryName;
if (entryName.startsWith('/') || entryName.includes('..') || entryName.includes('\\') || /^[A-Za-z]:/.test(entryName)) {
throw new BadRequestException(`Unsicherer Pfad im Paket: ${entryName}`);
}
const resolvedEntry = path.resolve(resolvedStage, entryName);
if (!resolvedEntry.startsWith(resolvedStage + path.sep)) throw new BadRequestException(`Unsicherer Pfad im Paket: ${entryName}`);
}
try { try {
await mkdir(stagingDirectory, { recursive: true }); await mkdir(stagingDirectory, { recursive: true });
zip.extractAllTo(resolvedStage, true); zip.extractAllTo(resolvedStage, true);
@@ -208,4 +200,48 @@ export class ModuleInstaller {
return null; return null;
} }
} }
private assertCompressedSize(buffer: Buffer): void {
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)');
}
}
private assertSafeArchive(
entries: ReadonlyArray<{ entryName: string; header: { size: number; attr: number } }>,
destination: string,
): void {
if (entries.length > MAX_ARCHIVE_ENTRIES) {
throw new BadRequestException(`Paket enthält zu viele Dateien (maximal ${MAX_ARCHIVE_ENTRIES})`);
}
let extractedSize = 0;
const seenTargets = new Set<string>();
for (const entry of entries) {
const name = entry.entryName;
if (!name || name.startsWith('/') || name.includes('\\') || name.includes('\0') ||
/^[A-Za-z]:/.test(name) || name.split('/').some((part) => part === '..' || part === '.')) {
throw new BadRequestException(`Unsicherer Pfad im Paket: ${name}`);
}
const resolvedEntry = path.resolve(destination, name);
if (!resolvedEntry.startsWith(destination + path.sep)) {
throw new BadRequestException(`Unsicherer Pfad im Paket: ${name}`);
}
if (seenTargets.has(resolvedEntry)) {
throw new BadRequestException(`Doppelter Pfad im Paket: ${name}`);
}
seenTargets.add(resolvedEntry);
if (((entry.header.attr >>> 16) & 0xf000) === 0xa000) {
throw new BadRequestException(`Symbolischer Link im Paket ist nicht erlaubt: ${name}`);
}
const size = entry.header.size;
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_SINGLE_FILE_BYTES) {
throw new BadRequestException('Paket enthält eine zu große oder ungültige Datei');
}
extractedSize += size;
if (extractedSize > MAX_EXTRACTED_SIZE_BYTES) {
throw new BadRequestException('Entpacktes Paket ist zu groß (maximal 50 MB)');
}
}
}
} }

View File

@@ -0,0 +1,19 @@
/** Serializes filesystem and Docker changes for each installed module. */
export class ModuleOperationLock {
private readonly pending = new Map<string, Promise<void>>();
async run<T>(moduleId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.pending.get(moduleId);
let release!: () => void;
const current = new Promise<void>((resolve) => { release = resolve; });
this.pending.set(moduleId, current);
if (previous) await previous;
try {
return await operation();
} finally {
if (this.pending.get(moduleId) === current) this.pending.delete(moduleId);
release();
}
}
}

View File

@@ -52,7 +52,7 @@ export class ModuleProcessManager implements OnModuleDestroy {
// Compose kann beim Build oder beim Start teilweise Container angelegt // Compose kann beim Build oder beim Start teilweise Container angelegt
// haben. Bereinige den Stack, bevor der ursprüngliche Fehler zurückgeht. // haben. Bereinige den Stack, bevor der ursprüngliche Fehler zurückgeht.
try { try {
await this.containerManager.remove(module); await this.containerManager.cleanupFailedStart(module);
} catch { } catch {
this.logger.error(`Teilweise gestarteter Container-Stack für "${module.moduleId}" konnte nicht bereinigt werden`); this.logger.error(`Teilweise gestarteter Container-Stack für "${module.moduleId}" konnte nicht bereinigt werden`);
} }

View File

@@ -195,6 +195,7 @@ const TEST_CONFIG: AppConfig = {
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' }, adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' }, runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' },
marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} }, marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} },
modulePublicOrigin: 'http://localhost:8081',
}; };
describe('ModulesService', () => { describe('ModulesService', () => {

View File

@@ -15,8 +15,9 @@ import { ModuleInstaller } from './module-installer';
import { ModuleProcessManager } from './module-process-manager'; import { ModuleProcessManager } from './module-process-manager';
import { ModuleCommandError } from './module-container-manager'; import { ModuleCommandError } from './module-container-manager';
import { ModuleRepository } from './module.repository'; import { ModuleRepository } from './module.repository';
import type { ModuleRecord } from './manifest.types'; import type { ModuleManifest, ModuleRecord } from './manifest.types';
import { ModuleConfigurationService } from './module-configuration.service'; import { ModuleConfigurationService } from './module-configuration.service';
import { ModuleOperationLock } from './module-operation-lock';
/** /**
* Modul-Verwaltung (Application-Layer): Lifecycle-Logik für Module. * Modul-Verwaltung (Application-Layer): Lifecycle-Logik für Module.
@@ -32,6 +33,7 @@ import { ModuleConfigurationService } from './module-configuration.service';
@Injectable() @Injectable()
export class ModulesService { export class ModulesService {
private readonly logger = new Logger(ModulesService.name); private readonly logger = new Logger(ModulesService.name);
private readonly operationLock = new ModuleOperationLock();
constructor( constructor(
private readonly moduleRepository: ModuleRepository, private readonly moduleRepository: ModuleRepository,
@@ -64,6 +66,15 @@ export class ModulesService {
input: { values?: unknown; clearKeys?: unknown }, input: { values?: unknown; clearKeys?: unknown },
actor: ActingUser, actor: ActingUser,
ipAddress: string | null, ipAddress: string | null,
) {
return this.withModuleLock(id, () => this.saveConfigurationUnlocked(id, input, actor, ipAddress));
}
private async saveConfigurationUnlocked(
id: string,
input: { values?: unknown; clearKeys?: unknown },
actor: ActingUser,
ipAddress: string | null,
) { ) {
const module = await this.getById(id); const module = await this.getById(id);
const result = await this.configurationService.save(module, input); const result = await this.configurationService.save(module, input);
@@ -73,9 +84,11 @@ export class ModulesService {
action: AUDIT_ACTIONS.MODULE_CONFIG_UPDATED, action: AUDIT_ACTIONS.MODULE_CONFIG_UPDATED,
details: { moduleId: module.moduleId, keys: result.changedKeys }, details: { moduleId: module.moduleId, keys: result.changedKeys },
ipAddress, ipAddress,
}).catch((auditError: unknown) => {
this.logger.error(`Konfiguration von ${module.moduleId} gespeichert, aber Audit konnte nicht gespeichert werden: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
}); });
if (result.changedKeys.length && module.status === 'RUNNING') { if (result.changedKeys.length && module.status === 'RUNNING') {
await this.restart(id, actor, ipAddress); await this.restartUnlocked(id, actor, ipAddress);
} }
return this.configurationService.state(await this.getById(id)); return this.configurationService.state(await this.getById(id));
} }
@@ -87,7 +100,15 @@ export class ModulesService {
ipAddress: string | null, ipAddress: string | null,
): Promise<ModuleRecord> { ): Promise<ModuleRecord> {
const manifest = await this.installer.validatePackage(packageBuffer); const manifest = await this.installer.validatePackage(packageBuffer);
return this.operationLock.run(manifest.id, () => this.installUnlocked(packageBuffer, manifest, actor, ipAddress));
}
private async installUnlocked(
packageBuffer: Buffer,
manifest: ModuleManifest,
actor: ActingUser,
ipAddress: string | null,
): Promise<ModuleRecord> {
const [existingId, existingSlug, existingPort] = await Promise.all([ const [existingId, existingSlug, existingPort] = await Promise.all([
this.moduleRepository.findByModuleId(manifest.id), this.moduleRepository.findByModuleId(manifest.id),
this.moduleRepository.findBySlug(manifest.slug), this.moduleRepository.findBySlug(manifest.slug),
@@ -136,6 +157,8 @@ export class ModulesService {
action: AUDIT_ACTIONS.MODULE_INSTALLED, action: AUDIT_ACTIONS.MODULE_INSTALLED,
details: { moduleId: manifest.id, version: manifest.version, slug: manifest.slug }, details: { moduleId: manifest.id, version: manifest.version, slug: manifest.slug },
ipAddress, ipAddress,
}).catch((auditError: unknown) => {
this.logger.error(`Installation von ${manifest.id} erfolgreich, aber Audit konnte nicht gespeichert werden: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
}); });
return module; return module;
} }
@@ -149,7 +172,21 @@ export class ModulesService {
packageBuffer: Buffer, packageBuffer: Buffer,
actor: ActingUser, actor: ActingUser,
ipAddress: string | null, ipAddress: string | null,
onProgress?: (phase: string, message: string, progress: number) => void,
afterApplied?: () => Promise<void>,
): Promise<ModuleRecord> { ): Promise<ModuleRecord> {
return this.withModuleLock(id, () => this.updateFromMarketplaceUnlocked(id, packageBuffer, actor, ipAddress, onProgress, afterApplied));
}
private async updateFromMarketplaceUnlocked(
id: string,
packageBuffer: Buffer,
actor: ActingUser,
ipAddress: string | null,
onProgress?: (phase: string, message: string, progress: number) => void,
afterApplied?: () => Promise<void>,
): Promise<ModuleRecord> {
onProgress?.('validation', 'Update-Paket wird geprüft', 42);
const current = await this.getById(id); const current = await this.getById(id);
if (['STARTING', 'STOPPING', 'ERROR'].includes(current.status)) { if (['STARTING', 'STOPPING', 'ERROR'].includes(current.status)) {
throw new ConflictException('Das Modul muss einen stabilen Status haben, bevor ein Update gestartet werden kann'); throw new ConflictException('Das Modul muss einen stabilen Status haben, bevor ein Update gestartet werden kann');
@@ -162,16 +199,24 @@ export class ModulesService {
throw new BadRequestException('Das Update muss Modul-ID, URL-Slug, Port und Compose-Service beibehalten'); throw new BadRequestException('Das Update muss Modul-ID, URL-Slug, Port und Compose-Service beibehalten');
} }
const wasRunning = current.status === 'RUNNING'; const wasRunning = current.status === 'RUNNING';
if (wasRunning) await this.stop(id, actor, ipAddress); if (wasRunning) {
onProgress?.('stopping', 'Laufendes Modul wird gestoppt', 55);
await this.stopUnlocked(id, actor, ipAddress);
}
let replacement: { directory: string; backupDirectory: string } | undefined; let replacement: { directory: string; backupDirectory: string } | undefined;
try { try {
onProgress?.('replacing', 'Moduldateien werden aktualisiert', 66);
replacement = await this.installer.replace(packageBuffer, manifest, this.config.runtime.modulesDir); replacement = await this.installer.replace(packageBuffer, manifest, this.config.runtime.modulesDir);
const candidate = { ...current, name: manifest.name, version: manifest.version, description: manifest.description, const candidate = { ...current, name: manifest.name, version: manifest.version, description: manifest.description,
author: manifest.author, configuration: manifest.configuration }; author: manifest.author, configuration: manifest.configuration };
const configuration = await this.configurationService.state(candidate); const configuration = await this.configurationService.state(candidate);
await this.moduleRepository.updateManifest(id, manifest, configuration.ready); await this.moduleRepository.updateManifest(id, manifest, configuration.ready);
if (wasRunning) await this.start(id, actor, ipAddress); if (wasRunning) await this.startUnlocked(id, actor, ipAddress, onProgress);
const updated = await this.getById(id);
// Source metadata belongs to the same serialized operation. A failed
// metadata write still has a backup available for the rollback below.
await afterApplied?.();
await this.installer.finalizeReplacement(replacement.backupDirectory).catch((cleanupError: unknown) => { await this.installer.finalizeReplacement(replacement.backupDirectory).catch((cleanupError: unknown) => {
this.logger.warn(`Alte Moduldateien für ${current.moduleId} konnten nicht bereinigt werden: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`); this.logger.warn(`Alte Moduldateien für ${current.moduleId} konnten nicht bereinigt werden: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
}); });
@@ -181,8 +226,10 @@ export class ModulesService {
action: AUDIT_ACTIONS.MODULE_UPDATED, action: AUDIT_ACTIONS.MODULE_UPDATED,
details: { moduleId: current.moduleId, fromVersion: current.version, toVersion: manifest.version }, details: { moduleId: current.moduleId, fromVersion: current.version, toVersion: manifest.version },
ipAddress, ipAddress,
}).catch((auditError: unknown) => {
this.logger.error(`Update von ${current.moduleId} erfolgreich, aber Audit konnte nicht gespeichert werden: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
}); });
return await this.getById(id); return updated;
} catch (error) { } catch (error) {
if (replacement) { if (replacement) {
try { try {
@@ -192,13 +239,13 @@ export class ModulesService {
await this.installer.rollbackReplacement(replacement.directory, replacement.backupDirectory); await this.installer.rollbackReplacement(replacement.directory, replacement.backupDirectory);
await this.moduleRepository.updateManifest(id, previousManifest, current.configurationReady); await this.moduleRepository.updateManifest(id, previousManifest, current.configurationReady);
await this.moduleRepository.updateStatus(id, wasRunning ? 'STOPPED' : current.status); await this.moduleRepository.updateStatus(id, wasRunning ? 'STOPPED' : current.status);
if (wasRunning) await this.start(id, actor, ipAddress); if (wasRunning) await this.startUnlocked(id, actor, ipAddress);
} catch (rollbackError) { } catch (rollbackError) {
this.logger.error(`Rollback des Modulupdates für ${current.moduleId} fehlgeschlagen: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`); this.logger.error(`Rollback des Modulupdates für ${current.moduleId} fehlgeschlagen: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
throw new InternalServerErrorException('Update fehlgeschlagen; die vorherige Modulversion konnte nicht vollständig wiederhergestellt werden. Plattform-Logs prüfen.'); throw new InternalServerErrorException('Update fehlgeschlagen; die vorherige Modulversion konnte nicht vollständig wiederhergestellt werden. Plattform-Logs prüfen.');
} }
} else if (wasRunning) { } else if (wasRunning) {
try { await this.start(id, actor, ipAddress); } catch { /* Preserve the original update error. */ } try { await this.startUnlocked(id, actor, ipAddress); } catch { /* Preserve the original update error. */ }
} }
throw error; throw error;
} }
@@ -209,7 +256,21 @@ export class ModulesService {
} }
/** Startet ein Modul (INSTALLED/STOPPED → STARTING → RUNNING). */ /** Startet ein Modul (INSTALLED/STOPPED → STARTING → RUNNING). */
async start(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> { async start(
id: string,
actor: ActingUser,
ipAddress: string | null,
onProgress?: (phase: string, message: string, progress: number) => void,
): Promise<ModuleRecord> {
return this.withModuleLock(id, () => this.startUnlocked(id, actor, ipAddress, onProgress));
}
private async startUnlocked(
id: string,
actor: ActingUser,
ipAddress: string | null,
onProgress?: (phase: string, message: string, progress: number) => void,
): Promise<ModuleRecord> {
const module = await this.getById(id); const module = await this.getById(id);
this.assertEnabled(module); this.assertEnabled(module);
if (module.status === 'RUNNING' || module.status === 'STARTING') { if (module.status === 'RUNNING' || module.status === 'STARTING') {
@@ -219,9 +280,11 @@ export class ModulesService {
await this.moduleRepository.updateStatus(id, 'STARTING'); await this.moduleRepository.updateStatus(id, 'STARTING');
try { try {
onProgress?.('starting', 'Modulcontainer werden gestartet', 80);
await this.processManager.start(module); await this.processManager.start(module);
// Startup-Grace: Der Modul-Prozess braucht einen Moment zum Starten. // Startup-Grace: Der Modul-Prozess braucht einen Moment zum Starten.
// Der Healthcheck wird mit Retries wiederholt, bevor er als Fehlschlag gilt. // Der Healthcheck wird mit Retries wiederholt, bevor er als Fehlschlag gilt.
onProgress?.('healthcheck', 'Healthcheck läuft', 91);
const health = await this.waitForHealthy(module); const health = await this.waitForHealthy(module);
if (!health.healthy) { if (!health.healthy) {
throw new Error(`Healthcheck fehlgeschlagen: ${health.detail}`); throw new Error(`Healthcheck fehlgeschlagen: ${health.detail}`);
@@ -273,6 +336,10 @@ export class ModulesService {
/** Stoppt ein Modul (RUNNING → STOPPING → STOPPED). */ /** Stoppt ein Modul (RUNNING → STOPPING → STOPPED). */
async stop(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> { async stop(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
return this.withModuleLock(id, () => this.stopUnlocked(id, actor, ipAddress));
}
private async stopUnlocked(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
const module = await this.getById(id); const module = await this.getById(id);
if (module.status === 'STOPPED' || module.status === 'STOPPING') { if (module.status === 'STOPPED' || module.status === 'STOPPING') {
return module; return module;
@@ -298,11 +365,15 @@ export class ModulesService {
/** Startet ein Modul neu (Stop + Start). */ /** Startet ein Modul neu (Stop + Start). */
async restart(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> { async restart(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
return this.withModuleLock(id, () => this.restartUnlocked(id, actor, ipAddress));
}
private async restartUnlocked(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
const module = await this.getById(id); const module = await this.getById(id);
if (module.status === 'RUNNING' || module.status === 'STARTING') { if (module.status === 'RUNNING' || module.status === 'STARTING') {
await this.stop(id, actor, ipAddress); await this.stopUnlocked(id, actor, ipAddress);
} }
return this.start(id, actor, ipAddress); return this.startUnlocked(id, actor, ipAddress);
} }
/** Aktiviert oder deaktiviert ein Modul (DISABLED-Zustand). */ /** Aktiviert oder deaktiviert ein Modul (DISABLED-Zustand). */
@@ -311,6 +382,15 @@ export class ModulesService {
enabled: boolean, enabled: boolean,
actor: ActingUser, actor: ActingUser,
ipAddress: string | null, ipAddress: string | null,
): Promise<ModuleRecord> {
return this.withModuleLock(id, () => this.setEnabledUnlocked(id, enabled, actor, ipAddress));
}
private async setEnabledUnlocked(
id: string,
enabled: boolean,
actor: ActingUser,
ipAddress: string | null,
): Promise<ModuleRecord> { ): Promise<ModuleRecord> {
const module = await this.getById(id); const module = await this.getById(id);
if (module.enabled === enabled) { if (module.enabled === enabled) {
@@ -318,7 +398,7 @@ export class ModulesService {
} }
if (!enabled && (module.status === 'RUNNING' || module.status === 'STARTING')) { if (!enabled && (module.status === 'RUNNING' || module.status === 'STARTING')) {
await this.stop(id, actor, ipAddress); await this.stopUnlocked(id, actor, ipAddress);
} }
await this.moduleRepository.updateEnabled(id, enabled); await this.moduleRepository.updateEnabled(id, enabled);
@@ -333,16 +413,22 @@ export class ModulesService {
action: enabled ? AUDIT_ACTIONS.MODULE_ENABLED : AUDIT_ACTIONS.MODULE_DISABLED, action: enabled ? AUDIT_ACTIONS.MODULE_ENABLED : AUDIT_ACTIONS.MODULE_DISABLED,
details: { moduleId: module.moduleId }, details: { moduleId: module.moduleId },
ipAddress, ipAddress,
}).catch((auditError: unknown) => {
this.logger.error(`Status von ${module.moduleId} geändert, aber Audit konnte nicht gespeichert werden: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
}); });
return (await this.moduleRepository.findById(id)) ?? module; return (await this.moduleRepository.findById(id)) ?? module;
} }
/** Entfernt ein Modul vollständig (Prozess, Dateien, Registry). */ /** Entfernt ein Modul vollständig (Prozess, Dateien, Registry). */
async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<{ cleanupWarning?: string }> { async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<{ cleanupWarning?: string }> {
return this.withModuleLock(id, () => this.removeUnlocked(id, actor, ipAddress));
}
private async removeUnlocked(id: string, actor: ActingUser, ipAddress: string | null): Promise<{ cleanupWarning?: string }> {
const module = await this.getById(id); const module = await this.getById(id);
if (module.status === 'RUNNING' || module.status === 'STARTING') { if (module.status === 'RUNNING' || module.status === 'STARTING') {
await this.stop(id, actor, ipAddress); await this.stopUnlocked(id, actor, ipAddress);
} }
try { try {
@@ -370,6 +456,8 @@ export class ModulesService {
action: AUDIT_ACTIONS.MODULE_REMOVED, action: AUDIT_ACTIONS.MODULE_REMOVED,
details: { moduleId: module.moduleId }, details: { moduleId: module.moduleId },
ipAddress, ipAddress,
}).catch((auditError: unknown) => {
this.logger.error(`Modul ${module.moduleId} entfernt, aber Audit konnte nicht gespeichert werden: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
}); });
return cleanupWarning ? { cleanupWarning } : {}; return cleanupWarning ? { cleanupWarning } : {};
} }
@@ -387,6 +475,11 @@ export class ModulesService {
} }
} }
private async withModuleLock<T>(id: string, operation: () => Promise<T>): Promise<T> {
const module = await this.getById(id);
return this.operationLock.run(module.moduleId, operation);
}
private lifecycleFailure( private lifecycleFailure(
action: 'start' | 'stop' | 'remove', action: 'start' | 'stop' | 'remove',
module: ModuleRecord, module: ModuleRecord,
@@ -424,6 +517,8 @@ export class ModulesService {
action, action,
details: { moduleId: module.moduleId, version: module.version }, details: { moduleId: module.moduleId, version: module.version },
ipAddress, ipAddress,
}).catch((auditError: unknown) => {
this.logger.error(`Modulaktion ${action} für ${module.moduleId} erfolgreich, aber Audit konnte nicht gespeichert werden: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
}); });
} }
} }

View File

@@ -1,6 +1,5 @@
import { UnauthorizedException } from '@nestjs/common'; import { UnauthorizedException } from '@nestjs/common';
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service'; import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from './password-hasher'; import { PasswordHasher } from './password-hasher';
import { UserRepository } from './user.repository'; import { UserRepository } from './user.repository';
import type { UserRecord } from './user.types'; import type { UserRecord } from './user.types';
@@ -28,23 +27,14 @@ function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
/** Mock des UserRepository. */ /** Mock des UserRepository. */
class MockUserRepository { class MockUserRepository {
public user: UserRecord | null = createUserRecord(); public user: UserRecord | null = createUserRecord();
public updatedPasswords: Array<{ id: string; hash: string }> = []; public updatedPasswords: Array<{ id: string; hash: string; exceptSessionId?: string; expectedHash?: string }> = [];
async findById(id: string): Promise<UserRecord | null> { async findById(id: string): Promise<UserRecord | null> {
return this.user && this.user.id === id ? this.user : null; return this.user && this.user.id === id ? this.user : null;
} }
async updatePassword(id: string, hash: string): Promise<void> { async updatePassword(id: string, hash: string, exceptSessionId?: string, expectedHash?: string): Promise<void> {
this.updatedPasswords.push({ id, hash }); this.updatedPasswords.push({ id, hash, exceptSessionId, expectedHash });
}
}
/** Mock des SessionService. */
class MockSessionService {
public deletedForUser: Array<{ userId: string; exceptSessionId?: string }> = [];
async deleteAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
this.deletedForUser.push({ userId, exceptSessionId });
} }
} }
@@ -59,20 +49,17 @@ class MockAuditService {
describe('ProfileService', () => { describe('ProfileService', () => {
let userRepository: MockUserRepository; let userRepository: MockUserRepository;
let sessionService: MockSessionService;
let auditService: MockAuditService; let auditService: MockAuditService;
let profileService: ProfileService; let profileService: ProfileService;
let passwordHasher: PasswordHasher; let passwordHasher: PasswordHasher;
beforeEach(async () => { beforeEach(async () => {
userRepository = new MockUserRepository(); userRepository = new MockUserRepository();
sessionService = new MockSessionService();
auditService = new MockAuditService(); auditService = new MockAuditService();
passwordHasher = new PasswordHasher(); passwordHasher = new PasswordHasher();
profileService = new ProfileService( profileService = new ProfileService(
userRepository as unknown as UserRepository, userRepository as unknown as UserRepository,
passwordHasher, passwordHasher,
sessionService as unknown as SessionService,
auditService as unknown as AuditService, auditService as unknown as AuditService,
); );
@@ -101,9 +88,8 @@ describe('ProfileService', () => {
); );
expect(userRepository.updatedPasswords).toHaveLength(1); expect(userRepository.updatedPasswords).toHaveLength(1);
expect(sessionService.deletedForUser).toEqual([ expect(userRepository.updatedPasswords[0].exceptSessionId).toBe('session-1');
{ userId: 'user-1', exceptSessionId: 'session-1' }, expect(userRepository.updatedPasswords[0].expectedHash).toBe(userRepository.user?.passwordHash);
]);
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_CHANGED); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_CHANGED);
}); });

View File

@@ -1,6 +1,5 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service'; import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from './password-hasher'; import { PasswordHasher } from './password-hasher';
import { UserRepository } from './user.repository'; import { UserRepository } from './user.repository';
import type { ChangePasswordDto, UserRecord } from './user.types'; import type { ChangePasswordDto, UserRecord } from './user.types';
@@ -16,7 +15,6 @@ export class ProfileService {
constructor( constructor(
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly passwordHasher: PasswordHasher, private readonly passwordHasher: PasswordHasher,
private readonly sessionService: SessionService,
private readonly auditService: AuditService, private readonly auditService: AuditService,
) {} ) {}
@@ -48,8 +46,7 @@ export class ProfileService {
} }
const newPasswordHash = await this.passwordHasher.hash(input.newPassword); const newPasswordHash = await this.passwordHasher.hash(input.newPassword);
await this.userRepository.updatePassword(userId, newPasswordHash); await this.userRepository.updatePassword(userId, newPasswordHash, currentSessionId, user.passwordHash);
await this.sessionService.deleteAllForUser(userId, currentSessionId);
await this.auditService.record({ await this.auditService.record({
userId, userId,

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { DatabaseService } from '../database/database.service'; import { DatabaseService } from '../database/database.service';
import { PasswordHasher } from './password-hasher'; import { PasswordHasher } from './password-hasher';
import type { CreateUserDto, RoleName, UpdateUserDto, UserRecord } from './user.types'; import type { CreateUserDto, RoleName, UpdateUserDto, UserRecord } from './user.types';
@@ -146,11 +146,31 @@ export class UserRepository {
} }
/** Setzt einen neuen Passwort-Hash. */ /** Setzt einen neuen Passwort-Hash. */
async updatePassword(id: string, passwordHash: string): Promise<void> { async updatePassword(
await this.database.query( id: string,
'UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1', passwordHash: string,
[id, passwordHash], exceptSessionId?: string,
); expectedPasswordHash?: string,
): Promise<void> {
await this.database.transaction(async (client) => {
// Login hält dieselbe Benutzerzeile bis zur Session-Anlage gesperrt.
const updated = await client.query(
`UPDATE users SET password_hash = $2, updated_at = now()
WHERE id = $1 AND ($3::text IS NULL OR password_hash = $3)`,
[id, passwordHash, expectedPasswordHash ?? null],
);
if (updated.rowCount !== 1) {
throw new UnauthorizedException('Passwort wurde zwischenzeitlich geändert');
}
await client.query(
exceptSessionId
? 'DELETE FROM sessions WHERE user_id = $1 AND id <> $2'
: 'DELETE FROM sessions WHERE user_id = $1',
exceptSessionId ? [id, exceptSessionId] : [id],
);
await client.query('DELETE FROM module_sessions WHERE user_id = $1', [id]);
await client.query('DELETE FROM module_access_tickets WHERE user_id = $1', [id]);
});
} }
/** Setzt Fehlversuchs-Zähler und Sperre zurück (bei Aktivierung). */ /** Setzt Fehlversuchs-Zähler und Sperre zurück (bei Aktivierung). */

View File

@@ -225,7 +225,7 @@ describe('UsersService', () => {
null, null,
); );
expect(userRepository.updatedPasswords).toHaveLength(1); expect(userRepository.updatedPasswords).toHaveLength(1);
expect(sessionService.deletedSessionsForUser).toEqual(['user-1']); expect(sessionService.deletedSessionsForUser).toEqual([]);
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_RESET); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_RESET);
}); });
}); });

View File

@@ -135,7 +135,6 @@ export class UsersService {
const user = await this.findById(id); const user = await this.findById(id);
const passwordHash = await this.passwordHasher.hash(input.newPassword); const passwordHash = await this.passwordHasher.hash(input.newPassword);
await this.userRepository.updatePassword(id, passwordHash); await this.userRepository.updatePassword(id, passwordHash);
await this.sessionService.deleteAllForUser(id);
await this.auditService.record({ await this.auditService.record({
userId: actor.id, userId: actor.id,
username: actor.username, username: actor.username,

View File

@@ -148,9 +148,8 @@ function AppLayoutContent(): ReactNode {
<SidebarTrigger className="mr-auto h-9 w-9 rounded-lg p-2 text-slate-600 hover:bg-slate-100 md:hidden" aria-label="Menü öffnen"> <SidebarTrigger className="mr-auto h-9 w-9 rounded-lg p-2 text-slate-600 hover:bg-slate-100 md:hidden" aria-label="Menü öffnen">
<Icon name="menu" className="h-5 w-5" /> <Icon name="menu" className="h-5 w-5" />
</SidebarTrigger> </SidebarTrigger>
<div className="ml-auto inline-flex h-9 items-center gap-2 rounded-lg border border-slate-200 px-3 text-sm text-slate-600 transition-colors hover:bg-slate-100"> <div className="ml-auto inline-flex h-9 items-center gap-3">
<Icon name={darkMode ? 'sun' : 'moon'} className="h-4 w-4" /> <Icon name={darkMode ? 'sun' : 'moon'} className="h-4 w-4" />
<span>{darkMode ? 'Hell' : 'Dunkel'}</span>
<Switch checked={darkMode} onCheckedChange={setDarkMode} aria-label="Darkmode" /> <Switch checked={darkMode} onCheckedChange={setDarkMode} aria-label="Darkmode" />
</div> </div>
</header> </header>

View File

@@ -22,15 +22,17 @@ export interface EmptyStateProps {
title: string; title: string;
description?: string; description?: string;
icon?: ReactNode; icon?: ReactNode;
action?: ReactNode;
} }
/** Anzeige für leere Zustände (Design-System). */ /** Anzeige für leere Zustände (Design-System). */
export function EmptyState({ title, description, icon }: EmptyStateProps): ReactNode { export function EmptyState({ title, description, icon, action }: EmptyStateProps): ReactNode {
return ( return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center"> <div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
{icon && <div className="text-slate-300">{icon}</div>} {icon && <div className="text-slate-300">{icon}</div>}
<h3 className="text-sm font-semibold text-slate-900">{title}</h3> <h3 className="text-sm font-semibold text-slate-900">{title}</h3>
{description && <p className="max-w-sm text-sm text-slate-500">{description}</p>} {description && <p className="max-w-sm text-sm text-slate-500">{description}</p>}
{action}
</div> </div>
); );
} }
@@ -38,12 +40,14 @@ export function EmptyState({ title, description, icon }: EmptyStateProps): React
export interface ErrorStateProps { export interface ErrorStateProps {
title?: string; title?: string;
message?: string; message?: string;
action?: ReactNode;
} }
/** Anzeige für Fehlerzustände (Design-System). */ /** Anzeige für Fehlerzustände (Design-System). */
export function ErrorState({ export function ErrorState({
title = 'Ein Fehler ist aufgetreten', title = 'Ein Fehler ist aufgetreten',
message = 'Bitte versuchen Sie es später erneut.', message = 'Bitte versuchen Sie es später erneut.',
action,
}: ErrorStateProps): ReactNode { }: ErrorStateProps): ReactNode {
return ( return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center" role="alert"> <div className="flex flex-col items-center justify-center gap-2 py-12 text-center" role="alert">
@@ -52,6 +56,7 @@ export function ErrorState({
</div> </div>
<h3 className="text-sm font-semibold text-slate-900">{title}</h3> <h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<p className="max-w-sm text-sm text-slate-500">{message}</p> <p className="max-w-sm text-sm text-slate-500">{message}</p>
{action}
</div> </div>
); );
} }

View File

@@ -5,7 +5,7 @@ import { z } from 'zod';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { ErrorState, Spinner } from '../../components/ui/states'; import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
/** Audit-Eintrag (API-Vertrag /api/v1/audit). */ /** Audit-Eintrag (API-Vertrag /api/v1/audit). */
@@ -119,11 +119,9 @@ export function AuditPage(): ReactNode {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{auditQuery.isLoading && <Spinner label="Audit-Log wird geladen…" />} {auditQuery.isLoading && <Spinner label="Audit-Log wird geladen…" />}
{auditQuery.isError && <ErrorState message="Audit-Log konnte nicht geladen werden." />} {auditQuery.isError && <ErrorState title="Audit-Log konnte nicht geladen werden" message="Prüfe die Verbindung und lade die Einträge erneut." action={<Button variant="secondary" onClick={() => void auditQuery.refetch()}>Erneut versuchen</Button>} />}
{auditQuery.data && auditQuery.data.entries.length === 0 && ( {auditQuery.data && auditQuery.data.entries.length === 0 && (
<p className="py-8 text-center text-sm text-slate-500"> <EmptyState title="Keine Audit-Einträge gefunden" description="Passe die Filter an oder entferne sie, um weitere Einträge zu sehen." />
Keine Einträge für die gewählten Filter.
</p>
)} )}
{auditQuery.data && auditQuery.data.entries.length > 0 && ( {auditQuery.data && auditQuery.data.entries.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">

View File

@@ -10,6 +10,9 @@ import { Modal } from '../../components/ui/modal';
import { SelectControl } from '../../components/ui/select'; import { SelectControl } from '../../components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
import { Skeleton } from '../../components/ui/skeleton';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '../../components/ui/dropdown-menu';
import { useToast } from '../../components/ui/toast'; import { useToast } from '../../components/ui/toast';
import { ApiError } from '../../lib/api-client'; import { ApiError } from '../../lib/api-client';
import { import {
@@ -23,6 +26,7 @@ import {
installModule, installModule,
installMarketplaceRepository, installMarketplaceRepository,
fetchMarketplaceUpdates, fetchMarketplaceUpdates,
fetchMarketplaceOperationProgress,
updateMarketplaceModule, updateMarketplaceModule,
removeModule, removeModule,
restartModule, restartModule,
@@ -156,8 +160,8 @@ function ModuleConfigurationModal({ module, onClose }: { module: Module; onClose
const inputClass = 'mt-1 h-auto w-full rounded-lg border border-slate-300 px-3 py-2 text-sm'; const inputClass = 'mt-1 h-auto w-full rounded-lg border border-slate-300 px-3 py-2 text-sm';
return ( return (
<Modal open title={`Konfiguration: ${module.name}`} description="Werte werden verschlüsselt gespeichert." onClose={onClose} panelClassName="max-w-2xl"> <Modal open title={`Konfiguration: ${module.name}`} description="Werte werden verschlüsselt gespeichert." onClose={onClose} panelClassName="max-w-2xl">
{configQuery.isLoading && <p className="text-sm text-slate-500">Konfiguration wird geladen…</p>} {configQuery.isLoading && <Spinner label="Konfiguration wird geladen…" className="py-8" />}
{configQuery.isError && <p role="alert" className="text-sm text-red-600">Konfiguration konnte nicht geladen werden.</p>} {configQuery.isError && <ErrorState title="Konfiguration konnte nicht geladen werden" message="Prüfe die Verbindung und versuche es erneut." action={<Button variant="secondary" onClick={() => void configQuery.refetch()}>Erneut versuchen</Button>} />}
{configQuery.data && <form onSubmit={(event) => { event.preventDefault(); setFormError(null); saveMutation.mutate(); }}> {configQuery.data && <form onSubmit={(event) => { event.preventDefault(); setFormError(null); saveMutation.mutate(); }}>
{module.status === 'RUNNING' && <p className="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">Das laufende Modul wird nach dem Speichern neu gestartet.</p>} {module.status === 'RUNNING' && <p className="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">Das laufende Modul wird nach dem Speichern neu gestartet.</p>}
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-1"> <div className="max-h-[60vh] space-y-4 overflow-y-auto pr-1">
@@ -181,8 +185,9 @@ function ModuleUpdateModal({ module, state, onClose }: { module: Module; state:
const [branch, setBranch] = useState(state.branches[0]?.name ?? ''); const [branch, setBranch] = useState(state.branches[0]?.name ?? '');
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [completed, setCompleted] = useState<{ module: Module; branch: string; commit: string | null } | null>(null); const [completed, setCompleted] = useState<{ module: Module; branch: string; commit: string | null } | null>(null);
const [operationId] = useState(() => crypto.randomUUID());
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: () => updateMarketplaceModule(module.id, branch), mutationFn: () => updateMarketplaceModule(module.id, branch, operationId),
onSuccess: async (updatedModule) => { onSuccess: async (updatedModule) => {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ['modules'] }), queryClient.invalidateQueries({ queryKey: ['modules'] }),
@@ -192,7 +197,15 @@ function ModuleUpdateModal({ module, state, onClose }: { module: Module; state:
}, },
onError: (error) => setFormError(error instanceof ApiError ? error.message : 'Modulupdate fehlgeschlagen'), onError: (error) => setFormError(error instanceof ApiError ? error.message : 'Modulupdate fehlgeschlagen'),
}); });
const progressQuery = useQuery({
queryKey: ['marketplace-operation', operationId],
queryFn: () => fetchMarketplaceOperationProgress(operationId),
enabled: updateMutation.isPending,
refetchInterval: (query) => query.state.data?.status === 'running' ? 750 : false,
retry: false,
});
const selected = state.branches.find((item) => item.name === branch); const selected = state.branches.find((item) => item.name === branch);
const progress = progressQuery.data?.progress ?? 3;
const handleClose = () => { if (!updateMutation.isPending) onClose(); }; const handleClose = () => { if (!updateMutation.isPending) onClose(); };
return ( return (
<Modal open title={completed ? 'Update erfolgreich' : `Update verfügbar: ${module.name}`} <Modal open title={completed ? 'Update erfolgreich' : `Update verfügbar: ${module.name}`}
@@ -220,12 +233,12 @@ function ModuleUpdateModal({ module, state, onClose }: { module: Module; state:
{selected && <p className="mt-1 text-xs text-slate-500">Commit {selected.commit.slice(0, 12)}</p>} {selected && <p className="mt-1 text-xs text-slate-500">Commit {selected.commit.slice(0, 12)}</p>}
</div> </div>
<p className="text-xs text-slate-500">Die Konfiguration und persistenten Daten bleiben erhalten. Bei einem fehlgeschlagenen Start stellt MPM die vorherige Modulversion wieder her.</p> <p className="text-xs text-slate-500">Die Konfiguration und persistenten Daten bleiben erhalten. Bei einem fehlgeschlagenen Start stellt MPM die vorherige Modulversion wieder her.</p>
{updateMutation.isPending && <div className="space-y-2" role="status" aria-live="polite"> {updateMutation.isPending && <div className="space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3" role="status" aria-live="polite">
<div role="progressbar" aria-label="Modulupdate läuft" aria-valuemin={0} aria-valuemax={100} aria-valuetext="Installationsvorgang läuft" className="h-2 overflow-hidden rounded-full bg-brand-100"> <div role="progressbar" aria-label="Modulupdate läuft" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress} aria-valuetext={progressQuery.data?.message ?? 'Update wird vorbereitet'} className="h-2.5 overflow-hidden rounded-full bg-slate-200">
<div className="mpm-indeterminate-progress h-full w-2/5 rounded-full bg-brand-600" /> <div className="h-full rounded-full bg-brand-600 transition-[width] duration-500" style={{ width: `${progress}%` }} />
</div> </div>
<p className="text-sm font-medium text-slate-700">Update auf „{branch}“ wird installiert…</p> <p className="text-sm font-medium text-slate-800">{progressQuery.data?.message ?? `Update auf „${branch}“ wird vorbereitet`}</p>
<p className="text-xs text-slate-500">MPM aktualisiert das Modul und prüft anschließend den Start. Das kann einige Minuten dauern.</p> <p className="text-xs text-slate-500">Schritt {progressQuery.data?.phase ?? 'starting'} · Das kann je nach Build einige Minuten dauern.</p>
</div>} </div>}
{formError && <p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{formError}</p>} {formError && <p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{formError}</p>}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@@ -367,6 +380,41 @@ export function ModulesPage(): ReactNode {
}, },
}); });
const renderModuleUpdates = (module: Module): ReactNode => {
const index = (modulesQuery.data ?? []).findIndex((item) => item.id === module.id);
const updateQuery = moduleUpdateQueries[index];
const updateState = updateQuery?.data;
if (updateState?.branches.length) {
return <Button size="sm" variant="secondary" className="whitespace-nowrap" title="Neue getestete Branch installieren" onClick={() => setUpdateTarget(module)}>Update verfügbar</Button>;
}
if (updateState?.installedBranch) return <span className="text-xs text-slate-500">Keine Updates</span>;
if (updateQuery?.isLoading) return <Skeleton className="my-1 h-5 w-24" aria-label="Updates werden geprüft" />;
return <span className="text-xs text-slate-500" title="Kein Marketplace-Repository verknüpft">Kein Update</span>;
};
const renderModuleActions = (module: Module, compact = false): ReactNode => {
const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id;
const action = lifecycleMutation.variables?.action;
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" disabled={actionPending} className={`focus-visible:outline-none focus-visible:ring-0 ${compact ? 'w-full justify-center' : ''}`} aria-label={`Aktionen auswählen für ${module.name}`}>
<span>Aktionen auswählen</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{module.status !== 'RUNNING' && module.enabled && <DropdownMenuItem disabled={actionPending} onSelect={() => lifecycleMutation.mutate({ module, action: 'start' })}>Start</DropdownMenuItem>}
{(module.status === 'RUNNING' || module.status === 'STARTING') && <DropdownMenuItem disabled={actionPending} onSelect={() => lifecycleMutation.mutate({ module, action: 'stop' })}>Stop</DropdownMenuItem>}
<DropdownMenuItem disabled={actionPending} onSelect={() => lifecycleMutation.mutate({ module, action: 'restart' })}>Restart</DropdownMenuItem>
<DropdownMenuItem disabled={actionPending || healthMutation.isPending} onSelect={() => healthMutation.mutate(module)}>{healthMutation.isPending && healthMutation.variables?.id === module.id ? 'Health läuft…' : 'Health prüfen'}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={actionPending} onSelect={() => lifecycleMutation.mutate({ module, action: module.enabled ? 'disable' : 'enable' })}>{module.enabled ? 'Disable' : 'Enable'}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={actionPending} className="text-red-600 focus:bg-red-50 focus:text-red-700" onSelect={() => setRemoveTarget(module)}>Remove</DropdownMenuItem>
</DropdownMenuContent>
{actionPending && action && <span className="sr-only" role="status">{action} läuft</span>}
</DropdownMenu>;
};
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>): void { function handleFileChange(event: React.ChangeEvent<HTMLInputElement>): void {
setSelectedFile(event.target.files?.[0] ?? null); setSelectedFile(event.target.files?.[0] ?? null);
} }
@@ -487,53 +535,68 @@ export function ModulesPage(): ReactNode {
{/* Modul-Liste */} {/* Modul-Liste */}
<Card className="p-5"> <Card className="p-5">
<h2 className="mb-4 text-lg font-semibold text-slate-900">Installierte Module</h2> <h2 className="mb-4 text-lg font-semibold text-slate-900">Installierte Module</h2>
<div className="max-h-[42rem] overflow-auto rounded-xl border border-slate-200"> {modulesQuery.isLoading && <div className="space-y-3" role="status" aria-label="Module werden geladen">
<Table className="w-full min-w-[900px] table-fixed text-sm"> {[0, 1, 2].map((item) => <div key={item} className="space-y-2 rounded-lg border border-slate-200 p-4"><Skeleton className="h-5 w-1/3" /><Skeleton className="h-4 w-2/3" /><Skeleton className="h-9 w-full" /></div>)}
<TableHeader className="sticky top-0 z-10"> </div>}
<TableRow className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500"> {modulesQuery.isError && <ErrorState title="Module konnten nicht geladen werden" message="Prüfe die Verbindung und lade die Liste erneut." action={<Button variant="secondary" onClick={() => void modulesQuery.refetch()}>Erneut versuchen</Button>} />}
<TableHead className="w-[29%] px-4 py-3 font-semibold">Modul</TableHead> {modulesQuery.data && modulesQuery.data.length === 0 && <EmptyState title="Noch keine Module installiert" description="Verbinde ein Forge-Konto oder installiere ein Modul als ZIP." />}
<TableHead className="w-[12%] px-4 py-3 text-center font-semibold">Status</TableHead> {modulesQuery.data && modulesQuery.data.length > 0 && <>
<TableHead className="w-[13%] px-4 py-3 text-center font-semibold">URL</TableHead> <div className="space-y-3 xl:hidden">
<TableHead className="w-[22%] px-4 py-3 text-center font-semibold">Konfiguration</TableHead> {modulesQuery.data.map((module) => {
<TableHead className="w-[24%] px-4 py-3 font-semibold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modulesQuery.isLoading && <TableRow><TableCell colSpan={5} className="px-5 py-10 text-center text-slate-500">Module werden geladen…</TableCell></TableRow>}
{modulesQuery.isError && <TableRow><TableCell colSpan={5} className="px-5 py-10 text-center text-red-600">Module konnten nicht geladen werden.</TableCell></TableRow>}
{modulesQuery.data?.map((module) => {
const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id; const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id;
return ( return <article key={module.id} className="space-y-4 rounded-xl border border-slate-200 p-4">
<TableRow key={module.id} className="border-b border-slate-100 last:border-0"> <div className="flex min-w-0 items-start justify-between gap-3">
<TableCell className="align-top px-4 py-3"> <div className="min-w-0">
<div className="font-semibold text-slate-900">{module.name}</div> <h3 className="truncate font-semibold text-slate-900">{module.name}</h3>
<div className="text-xs text-slate-500">{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}</div> <p className="break-words text-xs text-slate-500">{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}</p>
{module.description && <div className="mt-0.5 text-xs text-slate-500">{module.description}</div>} </div>
</TableCell> <Badge variant={statusVariant(module.status)}>{module.status}</Badge>
<TableCell className="align-top px-4 py-3"><div className="flex w-full justify-center"><Badge variant={statusVariant(module.status)}>{module.status}</Badge></div> </div>
{healthResults[module.id] && <div className="mt-1 text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</div>} {module.description && <p className="text-sm text-slate-500">{module.description}</p>}
</TableCell> <div className="grid grid-cols-2 gap-3 text-sm">
<TableCell className="align-top px-4 py-3"><div className="flex justify-center"><code className="whitespace-nowrap rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></div></TableCell> <div className="min-w-0"><p className="text-xs font-medium uppercase text-slate-500">URL</p><code className="mt-1 inline-block max-w-full break-all rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></div>
<TableCell className="align-top px-4 py-3">{module.configuration.length > 0 && <div className="flex flex-col items-center gap-1 text-center"><div className={`text-xs font-medium ${module.configurationReady ? 'text-emerald-700' : 'text-amber-700'}`}>{module.configurationReady ? 'Konfig gesetzt' : 'Konfiguration erforderlich'}</div><Button size="sm" variant="ghost" disabled={actionPending} onClick={() => setConfigurationTarget(module)}>{module.configurationReady ? 'Konfiguration ändern' : 'Konfigurieren'}</Button></div>}</TableCell> {module.configuration.length > 0 && <div className="min-w-0"><p className="text-xs font-medium uppercase text-slate-500">Konfiguration</p><p className={`mt-1 text-xs font-medium ${module.configurationReady ? 'text-emerald-700' : 'text-amber-700'}`}>{module.configurationReady ? 'Konfig gesetzt' : 'Konfiguration erforderlich'}</p><Button size="sm" variant="ghost" disabled={actionPending} className="mt-1 px-0" onClick={() => setConfigurationTarget(module)}>{module.configurationReady ? 'Ändern' : 'Konfigurieren'}</Button></div>}
<TableCell className="align-top px-4 py-3"><div className="flex flex-wrap gap-1"> </div>
{(() => { {healthResults[module.id] && <p className="text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</p>}
const updateState = moduleUpdateQueries[(modulesQuery.data ?? []).findIndex((item) => item.id === module.id)]?.data; <div className="grid grid-cols-1 gap-3 border-t border-slate-200 pt-3 sm:grid-cols-2">
return <Button size="sm" variant="ghost" disabled={!updateState?.branches.length} title={updateState?.branches.length ? 'Neue getestete Branch installieren' : updateState?.installedBranch ? 'Keine neue Branch gefunden' : 'Kein Marketplace-Repository verknüpft'} onClick={() => updateState && setUpdateTarget(module)}>Update verfügbar</Button>; <div className="space-y-2"><p className="text-xs font-medium uppercase tracking-wide text-slate-500">Updates</p>{renderModuleUpdates(module)}</div>
})()} <div className="space-y-2"><p className="text-xs font-medium uppercase tracking-wide text-slate-500">Aktionen</p>{renderModuleActions(module, true)}</div>
{module.status !== 'RUNNING' && module.enabled && <Button size="sm" variant="ghost" disabled={actionPending} loading={actionPending && lifecycleMutation.variables?.action === 'start'} onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}>Start</Button>} </div>
{(module.status === 'RUNNING' || module.status === 'STARTING') && <Button size="sm" variant="ghost" disabled={actionPending} loading={actionPending && lifecycleMutation.variables?.action === 'stop'} onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}>Stop</Button>} </article>;
<Button size="sm" variant="ghost" disabled={actionPending} loading={actionPending && lifecycleMutation.variables?.action === 'restart'} onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}>Restart</Button>
<Button size="sm" variant="ghost" disabled={actionPending} onClick={() => healthMutation.mutate(module)}>Health</Button>
<Button size="sm" variant="ghost" disabled={actionPending} loading={actionPending && (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" disabled={actionPending} onClick={() => setRemoveTarget(module)}>Remove</Button>
</div></TableCell>
</TableRow>
);
})} })}
</TableBody> </div>
</Table> <div className="hidden overflow-auto rounded-xl border border-slate-200 xl:block">
{modulesQuery.data?.length === 0 && <p className="px-4 py-8 text-center text-slate-500">Noch keine Module installiert.</p>} <Table className="w-full min-w-[1040px] table-fixed text-sm">
</div> <TableHeader className="sticky top-0 z-10">
<TableRow className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<TableHead className="w-[25%] px-4 py-3 font-semibold">Modul</TableHead>
<TableHead className="w-[10%] px-4 py-3 text-center font-semibold">Status</TableHead>
<TableHead className="w-[11%] px-4 py-3 text-center font-semibold">URL</TableHead>
<TableHead className="w-[19%] px-4 py-3 text-center font-semibold">Konfiguration</TableHead>
<TableHead className="w-[17%] px-4 py-3 text-center font-semibold">Updates</TableHead>
<TableHead className="w-[18%] px-4 py-3 text-center font-semibold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modulesQuery.data.map((module) => {
const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id;
return <TableRow key={module.id} className="border-b border-slate-100 last:border-0">
<TableCell className="align-top px-4 py-3">
<div className="font-semibold 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>}
</TableCell>
<TableCell className="align-top px-4 py-3"><div className="flex w-full justify-center"><Badge variant={statusVariant(module.status)}>{module.status}</Badge></div>{healthResults[module.id] && <div className="mt-1 text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</div>}</TableCell>
<TableCell className="align-top px-4 py-3"><div className="flex justify-center"><code className="whitespace-nowrap rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></div></TableCell>
<TableCell className="align-top px-4 py-3">{module.configuration.length > 0 && <div className="flex flex-col items-center gap-1 text-center"><div className={`text-xs font-medium ${module.configurationReady ? 'text-emerald-700' : 'text-amber-700'}`}>{module.configurationReady ? 'Konfig gesetzt' : 'Konfiguration erforderlich'}</div><Button size="sm" variant="ghost" disabled={actionPending} onClick={() => setConfigurationTarget(module)}>{module.configurationReady ? 'Konfiguration ändern' : 'Konfigurieren'}</Button></div>}</TableCell>
<TableCell className="align-top px-4 py-3 text-center">{renderModuleUpdates(module)}</TableCell>
<TableCell className="align-top px-4 py-3"><div className="flex justify-center">{renderModuleActions(module)}</div></TableCell>
</TableRow>;
})}
</TableBody>
</Table>
</div>
</>}
</Card> </Card>
</div> </div>
<Card className="p-5"> <Card className="p-5">
@@ -543,19 +606,15 @@ export function ModulesPage(): ReactNode {
&Ouml;ffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM pr&uuml;ft das Modulmanifest vor der Installation. &Ouml;ffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM pr&uuml;ft das Modulmanifest vor der Installation.
</p> </p>
</div> </div>
{connectedProviders.length === 0 && ( {connectedProviders.length === 0 && <EmptyState title="Kein Forge-Konto verbunden" description="Verbinde zuerst GitHub, Gitea oder Forgejo, um Repositories zu installieren." />}
<p className="rounded-lg border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500"> {repositoriesQuery.isLoading && <Spinner label="Repositories werden geladen…" className="py-8" />}
Verbinde zuerst GitHub, Gitea oder Forgejo. {repositoriesQuery.isError && <ErrorState title="Repositories konnten nicht geladen werden" message="Prüfe die Forge-Verbindung und versuche es erneut." action={<Button variant="secondary" onClick={() => void repositoriesQuery.refetch()}>Erneut versuchen</Button>} />}
</p>
)}
{repositoriesQuery.isLoading && <p className="py-4 text-sm text-slate-500">Repositories werden geladen...</p>}
{repositoriesQuery.isError && <p role="alert" className="py-4 text-sm text-red-600">Repositories konnten nicht geladen werden.</p>}
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-2"> <div className="max-h-[42rem] space-y-4 overflow-y-auto pr-2">
{repositoriesQuery.data?.map((provider) => ( {repositoriesQuery.data?.map((provider) => (
<div key={provider.provider}> <div key={provider.provider}>
<h3 className="mb-3 text-base font-semibold text-slate-800">{provider.label}</h3> <h3 className="mb-3 text-base font-semibold text-slate-800">{provider.label}</h3>
{provider.repositories.length === 0 ? ( {provider.repositories.length === 0 ? (
<p className="text-sm text-slate-500">Keine &ouml;ffentlichen Repositories gefunden.</p> <EmptyState title="Keine öffentlichen Repositories gefunden" description="In diesem Forge-Konto sind derzeit keine installierbaren öffentlichen Repositories verfügbar." />
) : ( ) : (
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
{provider.repositories.map((repo) => ( {provider.repositories.map((repo) => (

View File

@@ -4,7 +4,8 @@ import { apiRequest } from '../../lib/api-client';
import { z } from 'zod'; import { z } from 'zod';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { ErrorState, Spinner } from '../../components/ui/states'; import { Button } from '../../components/ui/button';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
/** Erweiterter Systemstatus (API-Vertrag /api/v1/system/status). */ /** Erweiterter Systemstatus (API-Vertrag /api/v1/system/status). */
const systemStatusSchema = z.object({ const systemStatusSchema = z.object({
@@ -57,10 +58,9 @@ export function SystemStatusPage(): ReactNode {
<CardHeader><CardTitle>Gesamtstatus</CardTitle></CardHeader> <CardHeader><CardTitle>Gesamtstatus</CardTitle></CardHeader>
<CardContent> <CardContent>
{statusQuery.isLoading && <Spinner />} {statusQuery.isLoading && <Spinner />}
{statusQuery.isError && ( {statusQuery.isError && <ErrorState title="Systemstatus nicht erreichbar" message="Prüfe die Verbindung und versuche es erneut." action={<Button variant="secondary" onClick={() => void statusQuery.refetch()}>Erneut versuchen</Button>} />}
<ErrorState message="Der Systemstatus ist nicht erreichbar." /> {statusQuery.data && statusQuery.data.components.length === 0 && <EmptyState title="Keine Komponenten gemeldet" description="Sobald Plattformdienste registriert sind, erscheinen sie hier." />}
)} {statusQuery.data && statusQuery.data.components.length > 0 && (
{statusQuery.data && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge <Badge
variant={ variant={

View File

@@ -9,6 +9,7 @@ import { Modal } from '../../components/ui/modal';
import { Select } from '../../components/ui/select'; import { Select } from '../../components/ui/select';
import { useToast } from '../../components/ui/toast'; import { useToast } from '../../components/ui/toast';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
import { ApiError } from '../../lib/api-client'; import { ApiError } from '../../lib/api-client';
import { import {
createUser, createUser,
@@ -418,18 +419,15 @@ export function UsersPage(): ReactNode {
<TableBody> <TableBody>
{usersQuery.isLoading && ( {usersQuery.isLoading && (
<TableRow> <TableRow>
<TableCell colSpan={5} className="px-4 py-8 text-center text-slate-500"> <TableCell colSpan={5}><Spinner label="Benutzer werden geladen…" className="py-8" /></TableCell>
Benutzer werden geladen…
</TableCell>
</TableRow> </TableRow>
)} )}
{usersQuery.isError && ( {usersQuery.isError && (
<TableRow> <TableRow>
<TableCell colSpan={5} className="px-4 py-8 text-center text-red-600"> <TableCell colSpan={5}><ErrorState title="Benutzer konnten nicht geladen werden" message="Prüfe die Verbindung und lade die Liste erneut." action={<Button variant="secondary" onClick={() => void usersQuery.refetch()}>Erneut versuchen</Button>} /></TableCell>
Benutzer konnten nicht geladen werden.
</TableCell>
</TableRow> </TableRow>
)} )}
{usersQuery.data?.length === 0 && <TableRow><TableCell colSpan={5}><EmptyState title="Noch keine Benutzer angelegt" description="Lege den ersten Benutzer über die Schaltfläche oben an." /></TableCell></TableRow>}
{usersQuery.data?.map((user) => ( {usersQuery.data?.map((user) => (
<TableRow key={user.id} className="border-b border-slate-100 last:border-0"> <TableRow key={user.id} className="border-b border-slate-100 last:border-0">
<TableCell className="px-4 py-3"> <TableCell className="px-4 py-3">

View File

@@ -64,7 +64,7 @@ export function DashboardPage(): ReactNode {
{modulesQuery.data.map((module) => ( {modulesQuery.data.map((module) => (
<a <a
key={module.id} key={module.id}
href={`/${module.slug}`} href={`/api/v1/auth/module-open/${encodeURIComponent(module.slug)}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mpm-module-tile group rounded-xl border border-slate-200 p-5 transition-all duration-150 hover:-translate-y-0.5 hover:border-slate-400 hover:bg-slate-50 hover:shadow-md" className="mpm-module-tile group rounded-xl border border-slate-200 p-5 transition-all duration-150 hover:-translate-y-0.5 hover:border-slate-400 hover:bg-slate-50 hover:shadow-md"

View File

@@ -1,15 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@keyframes mpm-progress-sweep {
from { transform: translateX(-110%); }
to { transform: translateX(260%); }
}
.mpm-indeterminate-progress {
animation: mpm-progress-sweep 1.4s ease-in-out infinite;
}
@custom-variant dark (&:where(html[data-theme='dark'], html[data-theme='dark'] *)); @custom-variant dark (&:where(html[data-theme='dark'], html[data-theme='dark'] *));
/* ============================================================= /* =============================================================

View File

@@ -24,6 +24,9 @@ function readCsrfToken(): string | null {
let csrfToken: string | null = null; let csrfToken: string | null = null;
for (const part of document.cookie.split(';')) { for (const part of document.cookie.split(';')) {
const [name, ...value] = part.trim().split('='); const [name, ...value] = part.trim().split('=');
if (name === '__Host-mpm_csrf') {
return decodeURIComponent(value.join('='));
}
if (name === 'mpm_csrf') { if (name === 'mpm_csrf') {
csrfToken = decodeURIComponent(value.join('=')); csrfToken = decodeURIComponent(value.join('='));
} }

View File

@@ -145,6 +145,9 @@ function readCsrfToken(): string | null {
let csrfToken: string | null = null; let csrfToken: string | null = null;
for (const part of document.cookie.split(';')) { for (const part of document.cookie.split(';')) {
const [name, ...value] = part.trim().split('='); const [name, ...value] = part.trim().split('=');
if (name === '__Host-mpm_csrf') {
return decodeURIComponent(value.join('='));
}
if (name === 'mpm_csrf') { if (name === 'mpm_csrf') {
csrfToken = decodeURIComponent(value.join('=')); csrfToken = decodeURIComponent(value.join('='));
} }
@@ -193,14 +196,25 @@ export interface MarketplaceUpdateState {
branches: Array<{ name: string; commit: string }>; branches: Array<{ name: string; commit: string }>;
} }
export interface MarketplaceOperationProgress {
status: 'running' | 'completed' | 'failed';
phase: string;
message: string;
progress: number;
}
export async function fetchMarketplaceUpdates(moduleId: string): Promise<MarketplaceUpdateState> { export async function fetchMarketplaceUpdates(moduleId: string): Promise<MarketplaceUpdateState> {
return apiRequest<MarketplaceUpdateState>(`/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/updates`); return apiRequest<MarketplaceUpdateState>(`/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/updates`);
} }
export async function updateMarketplaceModule(moduleId: string, branch: string): Promise<Module> { export async function fetchMarketplaceOperationProgress(operationId: string): Promise<MarketplaceOperationProgress> {
return apiRequest<MarketplaceOperationProgress>(`/api/v1/marketplace/operations/${encodeURIComponent(operationId)}`);
}
export async function updateMarketplaceModule(moduleId: string, branch: string, operationId?: string): Promise<Module> {
const response = await apiRequest<{ module: unknown }>( const response = await apiRequest<{ module: unknown }>(
`/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/update`, `/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/update`,
{ method: 'POST', body: { branch } }, { method: 'POST', body: { branch, operationId } },
); );
return moduleSchema.parse(response.module); return moduleSchema.parse(response.module);
} }

View File

@@ -50,6 +50,7 @@ services:
ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL in .env setzen} ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL in .env setzen}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Bitte ADMIN_PASSWORD in .env setzen} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Bitte ADMIN_PASSWORD in .env setzen}
MARKETPLACE_PUBLIC_URL: ${MARKETPLACE_PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-8080}} MARKETPLACE_PUBLIC_URL: ${MARKETPLACE_PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-8080}}
MODULE_PUBLIC_ORIGIN: ${MODULE_PUBLIC_ORIGIN:-}
MARKETPLACE_TOKEN_ENCRYPTION_KEY: ${MARKETPLACE_TOKEN_ENCRYPTION_KEY:-} MARKETPLACE_TOKEN_ENCRYPTION_KEY: ${MARKETPLACE_TOKEN_ENCRYPTION_KEY:-}
GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-} GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-} GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}

View File

@@ -3,7 +3,8 @@
# Master has restricted capabilities; workers run as unprivileged user app. # Master has restricted capabilities; workers run as unprivileged user app.
# - / -> Management-Frontend (SPA, statische Dateien) # - / -> Management-Frontend (SPA, statische Dateien)
# - /api/ -> Management-Backend (127.0.0.1:3000) # - /api/ -> Management-Backend (127.0.0.1:3000)
# Ab Phase 4 werden hier dynamisch Modul-Routen (/slug) ergänzt. # Moduloberflächen liegen auf einem eigenen Host; der Hostname wird beim Start
# aus MODULE_PUBLIC_ORIGIN in diese Konfiguration eingesetzt.
# ============================================================= # =============================================================
worker_processes auto; worker_processes auto;
@@ -43,7 +44,7 @@ http {
} }
server { server {
listen 8080; listen 8080 default_server;
server_name _; server_name _;
root /app/public; root /app/public;
@@ -67,7 +68,7 @@ http {
location /api/v1/modules/ { location /api/v1/modules/ {
proxy_pass http://platform_backend; proxy_pass http://platform_backend;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
@@ -79,7 +80,7 @@ http {
location /api/v1/marketplace/modules/ { location /api/v1/marketplace/modules/ {
proxy_pass http://platform_backend; proxy_pass http://platform_backend;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
@@ -89,48 +90,23 @@ http {
location /api/ { location /api/ {
proxy_pass http://platform_backend; proxy_pass http://platform_backend;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s; proxy_read_timeout 30s;
} }
# Next.js benötigt Inline-Skripte für die React-Hydrierung. Die globale # Alte Modul-Links auf dem Plattformhost führen über das Einmal-Ticket
# Plattform-CSP ohne 'unsafe-inline' würde trotz geladener JS-Dateien # zum getrennten Modulhost. Hier wird kein Modul-JavaScript ausgeliefert.
# alle Client-Handler des Kalendertools blockieren.
location ~ "^/kalendartool(?<calendar_module_path>/.*)?$" {
proxy_pass http://platform_backend/api/v1/gateway/kalendartool$calendar_module_path;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_hide_header Content-Security-Policy;
# add_header überschreibt hier die globale Header-Liste; die übrigen
# Sicherheits-Header deshalb erneut setzen.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
}
# Modul-Routing (Phase 4): /slug/* wird intern an den
# Modul-Gateway des Backends übergeben (/api/v1/gateway/slug/*).
# Der Gateway prüft Session, Modul-Status und Berechtigung,
# bevor der Request an den Modul-Prozess proxied wird.
# WICHTIG: Plattform-Pfade (api, assets, login, …) sind ausgeschlossen,
# damit nur echte Modul-Slugs (3–100 Zeichen) weitergeleitet werden.
location ~ "^/(?!api/|assets/|login|profile|admin|403|404)(?<module_slug>[a-z0-9][a-z0-9-]{2,100})(?<module_path>/.*)?$" { location ~ "^/(?!api/|assets/|login|profile|admin|403|404)(?<module_slug>[a-z0-9][a-z0-9-]{2,100})(?<module_path>/.*)?$" {
proxy_pass http://platform_backend/api/v1/gateway/$module_slug$module_path; proxy_pass http://platform_backend/api/v1/auth/module-open/$module_slug;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s; proxy_read_timeout 30s;
} }
# SPA-Fallback für React Router # SPA-Fallback für React Router
@@ -138,4 +114,51 @@ http {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
} }
# Dieser Host liefert ausschließlich Modulpfade und den Ticket-Übergang.
# Management-API, Login, Admin-Frontend und Plattform-Cookies sind hier
# nicht erreichbar. Der Name wird beim Containerstart validiert eingesetzt.
server {
listen 8080;
server_name __MODULE_HOSTNAME__;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; worker-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# Auf lokalen Macs kann dieser Host zuvor für MPM verwendet worden sein.
# Bekannte Plattform-Einstiege führen zum konfigurierten Plattformhost.
location = / { return 302 __PLATFORM_ORIGIN__/; }
location ~ "^/(login|admin|profile|assets|403|404)(/|$)" {
return 302 __PLATFORM_ORIGIN__$request_uri;
}
location = /__mpm_module_handoff {
access_log off;
proxy_pass http://platform_backend/api/v1/auth/module-handoff$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
location ^~ /api/ { return 404; }
location ~ "^/(?<module_slug>[a-z0-9][a-z0-9-]{2,100})(?<module_path>/.*)?$" {
proxy_pass http://platform_backend/api/v1/gateway/$module_slug$module_path$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Service-Worker-Allowed;
}
location / { return 404; }
}
} }

View File

@@ -0,0 +1,14 @@
const fs = require('node:fs');
const { loadConfiguration } = require('/app/platform-backend/dist/config/configuration.js');
const configuration = loadConfiguration();
const { hostname } = new URL(configuration.modulePublicOrigin);
const platformOrigin = new URL(configuration.marketplace.publicUrl).origin;
if (!/^[a-z0-9.-]+$/i.test(hostname)) {
throw new Error('Ungültiger Modul-Hostname');
}
const template = fs.readFileSync('/etc/nginx/nginx.conf.template', 'utf8');
fs.writeFileSync(
'/tmp/mpm-nginx.conf',
template.replaceAll('__MODULE_HOSTNAME__', hostname).replaceAll('__PLATFORM_ORIGIN__', platformOrigin),
);

View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
node /usr/local/lib/mpm/render-nginx-config.cjs
exec /usr/sbin/nginx -g 'daemon off;' -c /tmp/mpm-nginx.conf

View File

@@ -30,7 +30,7 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=0
[program:nginx] [program:nginx]
command=/usr/sbin/nginx -g "daemon off;" -c /etc/nginx/nginx.conf command=/usr/local/bin/start-mpm-nginx
autorestart=true autorestart=true
startretries=5 startretries=5
stopsignal=QUIT stopsignal=QUIT

View File

@@ -84,14 +84,15 @@ Browser ──▶ Nginx ──▶ SessionGuard (Session gültig? User aktiv?)
### Modul-Routing (ab Phase 4, geplant) ### Modul-Routing (ab Phase 4, geplant)
``` ```
Browser ──▶ Nginx (/slug) ──▶ Management-Gateway Browser ──▶ MPM-Host (/api/v1/auth/module-open/slug)
└── Einmal-Ticket ──▶ eigener Modul-Host (/<slug>) ──▶ Management-Gateway
├── User identifizieren (Session) ├── User identifizieren (Session)
├── Permission Check (user_module_permissions) ├── Permission Check (user_module_permissions)
├── DENIED → 403 ├── DENIED → 403
└── ALLOWED → Modul-Gateway → Modulprozess └── ALLOWED → Modul-Gateway → Modulprozess
``` ```
Ein Modul vertraut **niemals** allein auf die URL; die Plattform übergibt die Identität sicher an das Modul (Modul-API-Vertrag, Phase 6). Ein Modul vertraut **niemals** allein auf die URL; die Plattform übergibt die Identität sicher an das Modul (Modul-API-Vertrag, Phase 6). Der Modulhost bedient keine Management-API. Ein kurzlebiges, einmalig verwendbares Ticket stellt dort eine an die Plattformsession gebundene Modulsession aus. Modulpfade für Assets und API-Aufrufe müssen unter `/<slug>/` liegen; root-relative `/api/` ist auf dem Modulhost gesperrt. In Produktion verwenden Plattform-Cookies den `__Host-`-Präfix.
## 5. Datenmodell (Phase 1) ## 5. Datenmodell (Phase 1)

View File

@@ -20,6 +20,7 @@ OAuth-Verbindung -> Repository auswählen -> Release-Katalog -> Paket prüfen ->
- Der Katalog lädt öffentliche Repositories verbundener Forge-Konten. Installiert wird der aktuelle Stand des jeweiligen Standard-Branches. - Der Katalog lädt öffentliche Repositories verbundener Forge-Konten. Installiert wird der aktuelle Stand des jeweiligen Standard-Branches.
- MPM lädt das vom Forge erzeugte Quellarchiv serverseitig, entfernt den Archiv-Stammordner und erwartet `module.json` im Repository-Stamm. - MPM lädt das vom Forge erzeugte Quellarchiv serverseitig, entfernt den Archiv-Stammordner und erwartet `module.json` im Repository-Stamm.
- Nach der Branch-Prüfung lädt MPM das Archiv über die ermittelte Commit-ID. ZIP-Dateien dürfen komprimiert höchstens 10 MiB, entpackt höchstens 50 MiB und insgesamt höchstens 2000 Einträge enthalten.
## Container-Vertrag für Module ## Container-Vertrag für Module
@@ -34,6 +35,8 @@ Installierbare Module müssen neben `module.json` eine Compose-Datei und einen A
Der App-Service muss den Manifest-Port im Container bereitstellen (`expose`, kein `ports`) und auf `0.0.0.0` lauschen. Datenbanken gehören als weitere Services in dieselbe Compose-Datei. Die Dienste teilen ein privates Compose-Netz; nur der App-Service wird zusätzlich an das MPM-Gateway angeschlossen. Für SQLite kann der App-Service `/var/lib/mpm-module` als persistenten Speicher unter `MPM_MODULE_DATA_DIR` verwenden. Datenbankcontainer definieren eigene projektlokale named volumes. Der App-Service muss den Manifest-Port im Container bereitstellen (`expose`, kein `ports`) und auf `0.0.0.0` lauschen. Datenbanken gehören als weitere Services in dieselbe Compose-Datei. Die Dienste teilen ein privates Compose-Netz; nur der App-Service wird zusätzlich an das MPM-Gateway angeschlossen. Für SQLite kann der App-Service `/var/lib/mpm-module` als persistenten Speicher unter `MPM_MODULE_DATA_DIR` verwenden. Datenbankcontainer definieren eigene projektlokale named volumes.
Ein Compose-Stack darf höchstens acht Services enthalten. MPM erzwingt für jeden Service `no-new-privileges`, 512 MiB Arbeitsspeicher, eine CPU und höchstens 256 Prozesse. Build-Kontext und Dockerfile müssen feste relative Pfade innerhalb des Modulpakets sein; Variablenersetzung in diesen Pfaden und zusätzliche Build-Zugriffe auf Hostdateien sind nicht erlaubt. Ein Build/Start darf höchstens 15 Minuten dauern, Stoppen und Entfernen höchstens zwei Minuten pro Compose-Befehl.
MPM startet/stoppt den gesamten Stack gemeinsam. Beim Entfernen löscht Compose alle App- und Datenbankcontainer, das Projekt-Netzwerk und sämtliche projektbezogenen Datenvolumes. Eine spätere Neuinstallation beginnt dadurch ohne die vorherigen Modul-Daten. Pakete dürfen keine Host-Ports, Host-Verzeichnisse, externen Docker-Ressourcen, privilegierten Optionen oder Docker-Socket-Mounts anfordern. Die Modulverwaltung benötigt Zugriff auf den Docker-Socket des Hosts; deshalb dürfen nur vertrauenswürdige Administratoren Module installieren. MPM startet/stoppt den gesamten Stack gemeinsam. Beim Entfernen löscht Compose alle App- und Datenbankcontainer, das Projekt-Netzwerk und sämtliche projektbezogenen Datenvolumes. Eine spätere Neuinstallation beginnt dadurch ohne die vorherigen Modul-Daten. Pakete dürfen keine Host-Ports, Host-Verzeichnisse, externen Docker-Ressourcen, privilegierten Optionen oder Docker-Socket-Mounts anfordern. Die Modulverwaltung benötigt Zugriff auf den Docker-Socket des Hosts; deshalb dürfen nur vertrauenswürdige Administratoren Module installieren.
- Vor der Installation prüft MPM Downloadgröße, Archivpfade, Symlinks, Manifest und Modul-ID. Die bestehende `ModuleInstaller`-Validierung bleibt die letzte Instanz. - Vor der Installation prüft MPM Downloadgröße, Archivpfade, Symlinks, Manifest und Modul-ID. Die bestehende `ModuleInstaller`-Validierung bleibt die letzte Instanz.
- Der Browser übermittelt keine Download-URL; MPM erstellt sie aus Anbieter, Besitzer, Repository und Standard-Branch. - Der Browser übermittelt keine Download-URL; MPM erstellt sie aus Anbieter, Besitzer, Repository und Standard-Branch.
@@ -57,7 +60,7 @@ Gitea und Forgejo verwenden kompatible Release- und Repository-APIs, aber jede s
## Branch-basierte Modulupdates ## Branch-basierte Modulupdates
Marketplace-Installationen speichern Repository, installierte Branch und Commit-ID. MPM prüft das Repository alle fünf Minuten und bietet in der Modulverwaltung über **Update verfügbar** jede andere Branch als mögliche getestete Version an. Der Administrator wählt im Dialog eine Branch aus. Es wird nichts automatisch installiert. Marketplace-Installationen speichern Repository, installierte Branch und Commit-ID. MPM prüft das Repository jede Minute und bietet in der Modulverwaltung über **Update verfügbar** jede neuere Versions-Branch als mögliche getestete Version an. Der Administrator wählt im Dialog eine Branch aus. Es wird nichts automatisch installiert.
Das Update wird anhand des gewählten Branch-Commits geladen und durchläuft dieselbe Archiv- und Manifestprüfung wie eine Neuinstallation. Modul-ID, URL-Slug, Port und Compose-App-Service müssen stabil bleiben. MPM tauscht den Modulcode mit einer temporären Sicherung aus, behält die persistenten Daten und verschlüsselte Modulkonfiguration und startet zuvor laufende Module anschließend erneut. Schlägt der Start fehl, stellt MPM den vorherigen Code und das Datenbankmanifest wieder her. Datenbankinhalte in Modulvolumes werden nicht automatisch zurückgerollt; Modulmigrationen müssen daher rückwärtskompatibel sein oder eigene Sicherungs-/Wiederherstellungsverfahren bieten. Das Update wird anhand des gewählten Branch-Commits geladen und durchläuft dieselbe Archiv- und Manifestprüfung wie eine Neuinstallation. Modul-ID, URL-Slug, Port und Compose-App-Service müssen stabil bleiben. MPM tauscht den Modulcode mit einer temporären Sicherung aus, behält die persistenten Daten und verschlüsselte Modulkonfiguration und startet zuvor laufende Module anschließend erneut. Schlägt der Start fehl, stellt MPM den vorherigen Code und das Datenbankmanifest wieder her. Datenbankinhalte in Modulvolumes werden nicht automatisch zurückgerollt; Modulmigrationen müssen daher rückwärtskompatibel sein oder eigene Sicherungs-/Wiederherstellungsverfahren bieten.