Ferndiagnose: Allowlist/Port/Modus im Backup mitsichern (Token bleibt pro Server)

Neuer Schalter "Ferndiagnose-Einstellungen mitsichern" (Einstellungen, Default aus,
spiegelt "Download-Liste mitsichern"). Damit reisen die wiederkehrenden, server-
unabhaengigen MCP-Einstellungen im Backup-Export/Import mit, statt sie auf jedem der
5-6 Server neu einzutippen.

Mitgesichert wird NUR was identisch ueber alle Server ist und kein Geheimnis/keine
Identitaet traegt: Allowlist, Port, Freigabemodus (lokal/Netzwerk). Bewusst NICHT
mitgesichert:
- Token: wird pro Server automatisch frisch erzeugt (nie vom Nutzer getippt → kein
  Tipp-Aufwand gespart). Der Backup-Schluessel ist eine fest verdrahtete Konstante →
  ein geleaktes .mdd duerfte sonst Lesezugriff auf ALLE Server geben. Backstop: ohne
  Token ist ein frisch importierter Server inert (checkAuth weist alles ab), bis der
  Nutzer einmal "Aktivieren" drueckt und ein Token erzeugt — keine stille Freigabe.
- Oeffentliche Adresse + Name: pro Server verschieden (sonst zeigte der erzeugte
  Verbindungscode des Zielservers auf den falschen Host).

Mechanik:
- mcpRemote als eigene Top-Level-Sektion im BackupPayload (NICHT in settings —
  normalizeSettings ist ein Whitelist-Rebuild und wuerde unbekannte Keys verwerfen).
  Form {allowlist, port, hostMode} erzwingt die Policy strukturell (kein Token-Feld).
  Kein version-Bump (Import ist lenient, additiv vor-/rueckwaertskompatibel).
- Export sammelt aus getDebugServerRuntimeStatus()+getDebugAllowlist(), nur wenn der
  Schalter an ist; liest bewusst KEIN Token, KEINE Remote-Meta.
- Import wendet die Sektion NUR im Settings-only-Zweig an (writeDebugServerConfig +
  restartDebugServer, damit der laufende Server die Allowlist sofort uebernimmt). Im
  Full-Backup-Zweig nicht — die App relauncht dort, der Boot liest die Dateien neu.
- Sicherheitsguard: Netzwerk-Modus mit LEERER Allowlist bindet local (127.0.0.1) statt
  0.0.0.0 — nie stille Freigabe aus korrupten Daten.
- backupIncludeMcp in AppSettings + defaultSettings + normalizeSettings (sonst wuerde
  der Schalter beim Speichern verworfen).

Tests (backup-mcp.test.ts, 11): Export-Gating + Policy-Guard (Sektion traegt nur
allowlist/port/hostMode), resolveMcpRemoteRestore-Matrix (hostMode-Mapping, Port-
Grenzen, Allowlist-Filter, Netzwerk-ohne-Allowlist→local, null bei fehlend), und der
load-bearing Live-Round-Trip: Export → Resolve → Apply → restartDebugServer →
getDebugAllowlist()/Status spiegeln die importierten Werte (beweist dass der Restart
feuert), Token unangetastet, kein debug_remote.json. Volle Suite 917 gruen, tsc=6.
This commit is contained in:
Sucukdeluxe
2026-06-19 19:40:39 +02:00
parent c79a031be8
commit a35ddf68b2
7 changed files with 241 additions and 4 deletions
+38
View File
@@ -2,6 +2,12 @@ import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
export type BackupKind = "full" | "settings-only";
export interface BackupMcpRemote {
allowlist: string[];
port: number;
hostMode: "local" | "network";
}
export interface BackupPayload {
version: 2;
kind: BackupKind;
@@ -10,6 +16,7 @@ export interface BackupPayload {
settings: AppSettings;
session?: SessionState;
history?: HistoryEntry[];
mcpRemote?: BackupMcpRemote;
}
export interface BuildBackupInput {
@@ -19,6 +26,7 @@ export interface BuildBackupInput {
/** Only bundled when includeDownloads is true. */
session: SessionState;
history: HistoryEntry[];
mcpRemote?: BackupMcpRemote;
}
/**
@@ -40,9 +48,39 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
base.session = input.session;
base.history = input.history;
}
if (Boolean(input.settings.backupIncludeMcp) && input.mcpRemote) {
base.mcpRemote = input.mcpRemote;
}
return base;
}
export interface McpRemoteRestore {
host?: "127.0.0.1" | "0.0.0.0";
port?: number;
allowlist?: string[];
}
export function resolveMcpRemoteRestore(section: unknown): McpRemoteRestore | null {
if (!section || typeof section !== "object") {
return null;
}
const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown };
const allowlist = Array.isArray(s.allowlist)
? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim())
: undefined;
const port = (typeof s.port === "number" && Number.isInteger(s.port) && s.port >= 1024 && s.port <= 65535) ? s.port : undefined;
let host: "127.0.0.1" | "0.0.0.0" | undefined;
if (s.hostMode === "network") {
host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1";
} else if (s.hostMode === "local") {
host = "127.0.0.1";
}
if (host === undefined && port === undefined && allowlist === undefined) {
return null;
}
return { host, port, allowlist };
}
export interface ImportPlan {
valid: boolean;
/** Restore the download list (session + history) and relaunch. */