From a35ddf68b2cac2ac93e88babd7b23be21f9e4b44 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 19 Jun 2026 19:40:39 +0200 Subject: [PATCH] Ferndiagnose: Allowlist/Port/Modus im Backup mitsichern (Token bleibt pro Server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/main/app-controller.ts | 30 ++++++- src/main/backup-payload.ts | 38 +++++++++ src/main/constants.ts | 1 + src/main/storage.ts | 1 + src/renderer/App.tsx | 4 +- src/shared/types.ts | 1 + tests/backup-mcp.test.ts | 170 +++++++++++++++++++++++++++++++++++++ 7 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 tests/backup-mcp.test.ts diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 6b7b5b4..41df20d 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -45,7 +45,7 @@ import { runInstallWithResume } from "./update-install-flow"; import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server"; import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code"; import { encryptBackup, decryptBackup } from "./backup-crypto"; -import { buildBackupPayload, planBackupImport } from "./backup-payload"; +import { buildBackupPayload, planBackupImport, resolveMcpRemoteRestore, BackupMcpRemote } from "./backup-payload"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log"; import { initConversionLog, shutdownConversionLog } from "./conversion-trace"; @@ -372,6 +372,20 @@ export class AppController { return this.getRemoteDiagnostics(); } + private restoreMcpRemoteFromBackup(section: unknown): void { + const restore = resolveMcpRemoteRestore(section); + if (!restore) { + return; + } + writeDebugServerConfig({ host: restore.host, port: restore.port, allowlist: restore.allowlist }); + void restartDebugServer().catch(() => {}); + this.audit("INFO", "Ferndiagnose-Einstellungen aus Backup wiederhergestellt", { + port: restore.port ?? null, + allowlistCount: restore.allowlist?.length ?? 0, + host: restore.host ?? "unveraendert" + }); + } + public getDebugSetupCheck(): DebugSetupCheckResult { return getDebugSetupCheck(this.storagePaths.baseDir); } @@ -724,13 +738,22 @@ public async checkDebridAccounts(): Promise { } public exportBackup(): Buffer { - const includeDownloads = Boolean(this.settings.backupIncludeDownloads); + let mcpRemote: BackupMcpRemote | undefined; + if (Boolean(this.settings.backupIncludeMcp)) { + const status = getDebugServerRuntimeStatus(); + mcpRemote = { + allowlist: getDebugAllowlist(), + port: status.port, + hostMode: status.host === "0.0.0.0" ? "network" : "local" + }; + } const payloadObj = buildBackupPayload({ settings: { ...this.settings }, appVersion: APP_VERSION, exportedAt: new Date().toISOString(), session: this.manager.getSession(), - history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()) + history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()), + mcpRemote }); this.audit("INFO", "Backup exportiert", { kind: payloadObj.kind, @@ -804,6 +827,7 @@ public async checkDebridAccounts(): Promise { this.settings = restoredSettings; saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true }); + this.restoreMcpRemoteFromBackup(parsed.mcpRemote); this.audit("INFO", "Backup importiert (nur Einstellungen)", { accountSummary: buildAccountSummary(this.settings) }); diff --git a/src/main/backup-payload.ts b/src/main/backup-payload.ts index 8a3ba24..3f1066b 100644 --- a/src/main/backup-payload.ts +++ b/src/main/backup-payload.ts @@ -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. */ diff --git a/src/main/constants.ts b/src/main/constants.ts index b0312db..f3f9b1d 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -109,6 +109,7 @@ export function defaultSettings(): AppSettings { hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, + backupIncludeMcp: false, notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, diff --git a/src/main/storage.ts b/src/main/storage.ts index a1b016c..07ed598 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -461,6 +461,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems, confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads, + backupIncludeMcp: settings.backupIncludeMcp !== undefined ? Boolean(settings.backupIncludeMcp) : defaults.backupIncludeMcp, notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl, notifyMention: asText(settings.notifyMention) || defaults.notifyMention, notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7c95ab2..c9cf310 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -856,7 +856,7 @@ const emptySnapshot = (): UiSnapshot => ({ autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, - theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, + theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeMcp: false, notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, accountListShowDetailedDebridLinkKeys: false, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, @@ -5442,6 +5442,8 @@ export function App(): ReactElement {
Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.
Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.
+ +
Allowlist, Port und Freigabemodus (lokal/Netzwerk) reisen mit. Verbindungs-Token und eigene Adresse bleiben pro Server – nach dem Import einmal „Aktivieren" drücken.