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.
116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
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;
|
||
appVersion: string;
|
||
exportedAt: string;
|
||
settings: AppSettings;
|
||
session?: SessionState;
|
||
history?: HistoryEntry[];
|
||
mcpRemote?: BackupMcpRemote;
|
||
}
|
||
|
||
export interface BuildBackupInput {
|
||
settings: AppSettings;
|
||
appVersion: string;
|
||
exportedAt: string;
|
||
/** Only bundled when includeDownloads is true. */
|
||
session: SessionState;
|
||
history: HistoryEntry[];
|
||
mcpRemote?: BackupMcpRemote;
|
||
}
|
||
|
||
/**
|
||
* Build the backup payload. By default ("Download-Liste mitsichern" off) the
|
||
* payload contains ONLY settings — no session, no history. The download list is
|
||
* bundled solely when settings.backupIncludeDownloads is true. An explicit kind
|
||
* marker makes the import side unambiguous and survives hand-edited files.
|
||
*/
|
||
export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
||
const includeDownloads = Boolean(input.settings.backupIncludeDownloads);
|
||
const base: BackupPayload = {
|
||
version: 2,
|
||
kind: includeDownloads ? "full" : "settings-only",
|
||
appVersion: input.appVersion,
|
||
exportedAt: input.exportedAt,
|
||
settings: input.settings
|
||
};
|
||
if (includeDownloads) {
|
||
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. */
|
||
restoreDownloads: boolean;
|
||
message: string;
|
||
}
|
||
|
||
/**
|
||
* Decide how to apply an imported backup based on what the FILE physically
|
||
* contains — NOT the local toggle. A backup without a session restores settings
|
||
* only (no queue wipe, no relaunch); a full backup (with session) restores the
|
||
* queue too. This way an old full backup still restores fully even if the local
|
||
* toggle is currently off, and a settings-only backup never disturbs a running
|
||
* queue.
|
||
*/
|
||
export function planBackupImport(parsed: unknown): ImportPlan {
|
||
if (!parsed || typeof parsed !== "object") {
|
||
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
||
}
|
||
const record = parsed as Record<string, unknown>;
|
||
if (!record.settings || typeof record.settings !== "object") {
|
||
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
||
}
|
||
const hasSession = Boolean(record.session) && typeof record.session === "object";
|
||
return {
|
||
valid: true,
|
||
restoreDownloads: hasSession,
|
||
message: hasSession
|
||
? "Backup wiederhergestellt – App startet automatisch neu…"
|
||
: "Einstellungen wiederhergestellt"
|
||
};
|
||
}
|