diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 9a1a980..6b7b5b4 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import os from "node:os"; import v8 from "node:v8"; import { app } from "electron"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; @@ -9,9 +10,11 @@ import { DebridAccountStatus, DebridProvider, DuplicatePolicy, + EnableRemoteDiagnosticsInput, HistoryEntry, PackagePriority, ParsedPackageInput, + RemoteDiagnosticsInfo, SessionStats, StartConflictEntry, StartConflictResolutionResult, @@ -39,7 +42,8 @@ import { MegaWebFallback } from "./mega-web-fallback"; import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { runInstallWithResume } from "./update-install-flow"; -import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; +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 { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; @@ -291,6 +295,83 @@ export class AppController { return rotated; } + private getSuggestedRemoteHosts(): string[] { + const hosts: string[] = []; + try { + const interfaces = os.networkInterfaces(); + for (const entry of Object.values(interfaces)) { + for (const net of entry || []) { + if (net.family === "IPv4" && !net.internal && net.address) { + hosts.push(net.address); + } + } + } + } catch { + } + return [...new Set(hosts)]; + } + + public getRemoteDiagnostics(): RemoteDiagnosticsInfo { + const status = getDebugServerRuntimeStatus(); + const meta = loadRemoteMeta(this.storagePaths.baseDir); + const token = getActiveDebugToken(); + const allowlist = getDebugAllowlist(); + const suggestedHosts = this.getSuggestedRemoteHosts(); + const host = meta.publicHost + || (status.localOnly ? "127.0.0.1" : (suggestedHosts[0] || status.host)); + const code = (status.hasToken && token && host) + ? encodeConnectionCode({ host, port: status.port, token, name: meta.name || undefined }) + : null; + return { + status, + code, + publicHost: meta.publicHost, + name: meta.name, + allowlist, + suggestedHosts + }; + } + + public async enableRemoteDiagnostics(input: EnableRemoteDiagnosticsInput): Promise { + const baseDir = this.storagePaths.baseDir; + const port = input.port && Number.isInteger(input.port) && input.port >= 1024 && input.port <= 65535 + ? input.port + : 9868; + const bindHost = input.hostMode === "network" ? "0.0.0.0" : "127.0.0.1"; + const allowlist = (input.allowlist || []).map((entry) => entry.trim()).filter((entry) => entry.length > 0); + if (input.hostMode === "network" && allowlist.length === 0) { + throw new Error("Netzwerk-Freigabe erfordert mindestens eine erlaubte IP oder CIDR in der Allowlist."); + } + let token = getActiveDebugToken(); + if (!token || input.rotateToken) { + token = rotateDebugToken(baseDir).token; + } + writeDebugServerConfig({ host: bindHost, port, allowlist }); + saveRemoteMeta(baseDir, { publicHost: (input.publicHost || "").trim(), name: (input.name || "").trim() }); + await restartDebugServer(); + this.audit("WARN", "Ferndiagnose aktiviert", { + host: bindHost, + port, + allowlistCount: allowlist.length, + localOnly: input.hostMode === "local" + }); + return this.getRemoteDiagnostics(); + } + + public async disableRemoteDiagnostics(): Promise { + clearDebugToken(); + await restartDebugServer(); + this.audit("WARN", "Ferndiagnose deaktiviert (Token entfernt)"); + return this.getRemoteDiagnostics(); + } + + public async rotateRemoteDiagnosticsToken(): Promise { + rotateDebugToken(this.storagePaths.baseDir); + await restartDebugServer(); + this.audit("WARN", "Ferndiagnose-Token rotiert"); + return this.getRemoteDiagnostics(); + } + public getDebugSetupCheck(): DebugSetupCheckResult { return getDebugSetupCheck(this.storagePaths.baseDir); } diff --git a/src/main/connection-code.ts b/src/main/connection-code.ts new file mode 100644 index 0000000..2027efe --- /dev/null +++ b/src/main/connection-code.ts @@ -0,0 +1,59 @@ +import fs from "node:fs"; +import path from "node:path"; + +const PREFIX = "rddiag:v1:"; + +function base64urlEncode(value: string): string { + return Buffer.from(value, "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +export interface ConnectionCodeInput { + host: string; + port: number; + token: string; + name?: string; + scheme?: "http" | "https"; + fingerprint?: string; +} + +export function encodeConnectionCode(input: ConnectionCodeInput): string { + const host = String(input.host || "").trim(); + if (!host) throw new Error("Host fehlt fuer Verbindungscode"); + const port = Number(input.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port ungueltig fuer Verbindungscode"); + if (!input.token) throw new Error("Token fehlt fuer Verbindungscode"); + const payload: Record = { v: 1, h: host, p: port, t: input.token }; + if (input.name) payload.n = String(input.name); + if (input.fingerprint) payload.fp = String(input.fingerprint); + if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme); + return PREFIX + base64urlEncode(JSON.stringify(payload)); +} + +export interface RemoteMeta { + publicHost: string; + name: string; +} + +function remoteMetaPath(baseDir: string): string { + return path.join(baseDir, "debug_remote.json"); +} + +export function loadRemoteMeta(baseDir: string): RemoteMeta { + try { + const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8")); + return { + publicHost: String(parsed.publicHost || ""), + name: String(parsed.name || "") + }; + } catch { + return { publicHost: "", name: "" }; + } +} + +export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void { + fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8"); +} diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index e0fb34b..a26fa9d 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -65,6 +65,16 @@ let authToken = ""; let bindHost = DEFAULT_HOST; let bindPort = DEFAULT_PORT; let runtimeBaseDir = ""; +let allowlist: string[] = []; + +export interface DebugServerRuntimeStatus { + running: boolean; + host: string; + port: number; + hasToken: boolean; + localOnly: boolean; + allowlistCount: number; +} function getStoragePaths() { return createStoragePaths(runtimeBaseDir); @@ -140,6 +150,86 @@ function getHost(baseDir: string): string { return DEFAULT_HOST; } +function getAllowlistPath(baseDir: string = runtimeBaseDir): string { + return path.join(baseDir, "debug_allowlist.txt"); +} + +function loadAllowlist(baseDir: string): string[] { + try { + return fs.readFileSync(getAllowlistPath(baseDir), "utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); + } catch { + return []; + } +} + +function normalizeIp(ip: string): string { + return String(ip || "").trim().replace(/^::ffff:/i, "").toLowerCase(); +} + +function isLoopbackIp(ip: string): boolean { + const x = normalizeIp(ip); + return x === "::1" || x === "localhost" || x.startsWith("127."); +} + +function ipv4ToInt(ip: string): number | null { + const parts = ip.split("."); + if (parts.length !== 4) { + return null; + } + let result = 0; + for (const part of parts) { + const value = Number(part); + if (!Number.isInteger(value) || value < 0 || value > 255) { + return null; + } + result = (result * 256) + value; + } + return result >>> 0; +} + +function matchIpRule(clientIp: string, rule: string): boolean { + const client = normalizeIp(clientIp); + const r = rule.trim().toLowerCase(); + if (!r) { + return false; + } + if (r === "*" || r === "0.0.0.0/0") { + return true; + } + if (r === client) { + return true; + } + const slash = r.indexOf("/"); + if (slash > 0) { + const baseInt = ipv4ToInt(r.slice(0, slash)); + const clientInt = ipv4ToInt(client); + const bits = Number(r.slice(slash + 1)); + if (baseInt === null || clientInt === null || !Number.isInteger(bits) || bits < 0 || bits > 32) { + return false; + } + if (bits === 0) { + return true; + } + const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0; + return (clientInt & mask) === (baseInt & mask); + } + return false; +} + +function isClientAllowed(clientIp: string): boolean { + const client = normalizeIp(clientIp); + if (isLoopbackIp(client) || client === "") { + return true; + } + if (allowlist.length === 0) { + return false; + } + return allowlist.some((rule) => matchIpRule(client, rule)); +} + function checkAuth(req: http.IncomingMessage): boolean { if (!authToken) { return false; @@ -461,6 +551,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } + const clientIp = extractDebugClientIp(req); + if (!isClientAllowed(clientIp)) { + if (traceConfig.enabled && traceConfig.logDebugRequests) { + logTraceEvent("WARN", "debug-http", "Durch Allowlist blockiert", { + clientIp, + url: sanitizeRequestUrlForTrace(req.url || "/") + }); + } + jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp }); + return; + } + if (!checkAuth(req)) { if (traceConfig.enabled && traceConfig.logDebugRequests) { logTraceEvent("WARN", "debug-http", "Unauthorized request", { @@ -927,32 +1029,126 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi }); } +function openServerSocket(): Promise { + return new Promise((resolve) => { + authToken = loadToken(runtimeBaseDir); + bindPort = getPort(runtimeBaseDir); + bindHost = getHost(runtimeBaseDir); + allowlist = loadAllowlist(runtimeBaseDir); + writeAiManifest(runtimeBaseDir); + if (!authToken) { + logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet"); + resolve(); + return; + } + if (bindHost === "0.0.0.0" && allowlist.length === 0) { + logger.warn("Debug-Server: Netzwerk-Bind ohne Allowlist - nur Loopback-Clients werden akzeptiert (fail-closed)"); + } + const srv = http.createServer(handleRequest); + let settled = false; + const settle = (): void => { + if (!settled) { + settled = true; + resolve(); + } + }; + srv.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + logger.warn(`Debug-Server: Port ${bindPort} belegt (EADDRINUSE) - Server nicht gestartet`); + } else { + logger.warn(`Debug-Server Fehler: ${String(err)}`); + } + if (server === srv) { + server = null; + } + settle(); + }); + srv.listen(bindPort, bindHost, () => { + logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort} (Allowlist: ${allowlist.length})`); + settle(); + }); + server = srv; + }); +} + export function startDebugServer(mgr: DownloadManager, baseDir: string): void { runtimeBaseDir = baseDir; - authToken = loadToken(baseDir); - bindPort = getPort(baseDir); - bindHost = getHost(baseDir); - writeAiManifest(baseDir); - if (!authToken) { - logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet"); - return; - } - manager = mgr; + void openServerSocket(); +} - server = http.createServer(handleRequest); - server.listen(bindPort, bindHost, () => { - logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`); - }); - server.on("error", (err) => { - logger.warn(`Debug-Server Fehler: ${String(err)}`); +export async function restartDebugServer(): Promise { + const old = server; + if (old) { server = null; - }); + await new Promise((resolve) => { + let settled = false; + const done = (): void => { + if (!settled) { + settled = true; + resolve(); + } + }; + old.close(() => done()); + try { + old.closeAllConnections?.(); + } catch { + } + setTimeout(done, 1500); + }); + } + await openServerSocket(); + return getDebugServerRuntimeStatus(); +} + +export function getDebugServerRuntimeStatus(): DebugServerRuntimeStatus { + return { + running: Boolean(server && server.listening), + host: bindHost, + port: bindPort, + hasToken: Boolean(authToken), + localOnly: isLoopbackIp(bindHost) || bindHost === "127.0.0.1", + allowlistCount: allowlist.length + }; +} + +export function getActiveDebugToken(): string { + return authToken || loadToken(runtimeBaseDir); +} + +export function getDebugAllowlist(): string[] { + return [...allowlist]; +} + +export function writeDebugServerConfig(opts: { host?: string; port?: number; allowlist?: string[] }): void { + if (opts.host !== undefined) { + fs.writeFileSync(path.join(runtimeBaseDir, "debug_host.txt"), `${opts.host}\n`, "utf8"); + } + if (opts.port !== undefined) { + fs.writeFileSync(path.join(runtimeBaseDir, "debug_port.txt"), `${opts.port}\n`, "utf8"); + } + if (opts.allowlist !== undefined) { + const body = opts.allowlist.length > 0 ? opts.allowlist.join("\n") + "\n" : ""; + fs.writeFileSync(getAllowlistPath(), body, "utf8"); + } +} + +export function clearDebugToken(): void { + try { + fs.unlinkSync(getDebugTokenPath()); + } catch { + } + authToken = ""; + writeAiManifest(runtimeBaseDir); } export function stopDebugServer(): void { if (server) { server.close(); + try { + server.closeAllConnections?.(); + } catch { + } server = null; logger.info("Debug-Server gestoppt"); } diff --git a/src/main/main.ts b/src/main/main.ts index 2fcd947..0efa3f3 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron"; -import { AddLinksPayload, AppSettings, DebridProvider, UpdateInstallProgress } from "../shared/types"; +import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, UpdateInstallProgress } from "../shared/types"; import { AppController } from "./app-controller"; import { IPC_CHANNELS } from "../shared/ipc"; import { getLogFilePath, logger } from "./logger"; @@ -671,6 +671,33 @@ function registerIpcHandlers(): void { return { path: rotated.path }; }); + ipcMain.handle(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => { + return controller.getRemoteDiagnostics(); + }); + + ipcMain.handle(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, async (_event: IpcMainInvokeEvent, input: EnableRemoteDiagnosticsInput) => { + if (!input || (input.hostMode !== "local" && input.hostMode !== "network")) { + throw new Error("hostMode muss 'local' oder 'network' sein"); + } + const allowlist = Array.isArray(input.allowlist) ? input.allowlist.map((entry) => String(entry)) : []; + return controller.enableRemoteDiagnostics({ + hostMode: input.hostMode, + publicHost: String(input.publicHost || ""), + port: input.port ? Number(input.port) : undefined, + allowlist, + name: input.name ? String(input.name) : undefined, + rotateToken: Boolean(input.rotateToken) + }); + }); + + ipcMain.handle(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS, async () => { + return controller.disableRemoteDiagnostics(); + }); + + ipcMain.handle(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => { + return controller.rotateRemoteDiagnosticsToken(); + }); + ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => { validateString(itemId, "itemId"); const logPath = controller.getItemLogPath(itemId); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 6a12c28..89c46f5 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -7,8 +7,10 @@ import { DebridLinkHostLimitInfo, DebridProvider, DuplicatePolicy, + EnableRemoteDiagnosticsInput, HistoryEntry, PackagePriority, + RemoteDiagnosticsInfo, RendererErrorReport, SessionStats, StartConflictEntry, @@ -74,6 +76,10 @@ const api: ElectronApi = { getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG), setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes), rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN), + getRemoteDiagnostics: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS), + enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input), + disableRemoteDiagnostics: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS), + rotateRemoteDiagnosticsToken: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN), openRealDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), openAllDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), importBestDebridCookies: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c37cfea..7c95ab2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -16,6 +16,7 @@ import type { DuplicatePolicy, HistoryEntry, PackageEntry, + RemoteDiagnosticsInfo, StartConflictEntry, UiSnapshot, UpdateCheckResult, @@ -1761,6 +1762,14 @@ export function App(): ReactElement { const [startConflictPrompt, setStartConflictPrompt] = useState(null); const startConflictResolverRef = useRef<((result: { policy: Extract; applyToAll: boolean } | null) => void) | null>(null); const [confirmPrompt, setConfirmPrompt] = useState(null); + const [remoteDiag, setRemoteDiag] = useState(null); + const [remoteDiagOpen, setRemoteDiagOpen] = useState(false); + const [remoteDiagBusy, setRemoteDiagBusy] = useState(false); + const [rdHostMode, setRdHostMode] = useState<"local" | "network">("local"); + const [rdPublicHost, setRdPublicHost] = useState(""); + const [rdPort, setRdPort] = useState("9868"); + const [rdAllowlist, setRdAllowlist] = useState(""); + const [rdName, setRdName] = useState(""); const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const confirmQueueRef = useRef void }>>([]); const importQueueFocusHandlerRef = useRef<(() => void) | null>(null); @@ -4328,6 +4337,89 @@ export function App(): ReactElement { }); }; + const applyRemoteDiagInfo = (info: RemoteDiagnosticsInfo): void => { + setRemoteDiag(info); + setRdHostMode(info.status.localOnly ? "local" : "network"); + setRdPublicHost(info.publicHost || (info.status.localOnly ? "" : (info.suggestedHosts[0] || ""))); + setRdPort(String(info.status.port || 9868)); + setRdAllowlist(info.allowlist.join("\n")); + setRdName(info.name || ""); + }; + + const onOpenRemoteDiagnostics = async (): Promise => { + closeMenus(); + try { + const info = await window.rd.getRemoteDiagnostics(); + applyRemoteDiagInfo(info); + setRemoteDiagOpen(true); + } catch (error) { + showToast(`Ferndiagnose-Status fehlgeschlagen: ${String(error)}`, 3000); + } + }; + + const onSubmitRemoteDiagnostics = async (): Promise => { + const port = Number(rdPort) || 9868; + const allowlist = rdAllowlist.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0); + if (rdHostMode === "network" && allowlist.length === 0) { + showToast("Netzwerkmodus braucht mindestens eine IP oder CIDR in der Allowlist", 3600); + return; + } + setRemoteDiagBusy(true); + try { + const info = await window.rd.enableRemoteDiagnostics({ + hostMode: rdHostMode, + publicHost: rdPublicHost, + port, + allowlist, + name: rdName + }); + applyRemoteDiagInfo(info); + showToast(info.status.running ? "Ferndiagnose aktiv" : "Ferndiagnose konfiguriert", 2600); + } catch (error) { + showToast(`Aktivieren fehlgeschlagen: ${String(error)}`, 3600); + } finally { + setRemoteDiagBusy(false); + } + }; + + const onDisableRemoteDiagnostics = async (): Promise => { + setRemoteDiagBusy(true); + try { + const info = await window.rd.disableRemoteDiagnostics(); + applyRemoteDiagInfo(info); + showToast("Ferndiagnose deaktiviert", 2400); + } catch (error) { + showToast(`Deaktivieren fehlgeschlagen: ${String(error)}`, 3000); + } finally { + setRemoteDiagBusy(false); + } + }; + + const onRotateRemoteDiagnosticsToken = async (): Promise => { + setRemoteDiagBusy(true); + try { + const info = await window.rd.rotateRemoteDiagnosticsToken(); + applyRemoteDiagInfo(info); + showToast("Neues Token - alter Verbindungscode ist ungueltig", 3000); + } catch (error) { + showToast(`Token-Rotation fehlgeschlagen: ${String(error)}`, 3000); + } finally { + setRemoteDiagBusy(false); + } + }; + + const onCopyRemoteDiagnosticsCode = async (): Promise => { + if (!remoteDiag?.code) { + return; + } + try { + await navigator.clipboard.writeText(remoteDiag.code); + showToast("Verbindungscode kopiert", 2200); + } catch { + showToast("Kopieren fehlgeschlagen", 2200); + } + }; + const onMenuRestart = (): void => { closeMenus(); void window.rd.restart(); @@ -4718,6 +4810,7 @@ export function App(): ReactElement { {openSubmenu === "hilfe-remote" && (
+
@@ -5934,6 +6027,97 @@ export function App(): ReactElement { )} + {remoteDiagOpen && ( +
setRemoteDiagOpen(false)}> +
event.stopPropagation()}> +

Ferndiagnose (MCP)

+

Aktiviert einen abgesicherten Lesezugriff auf Status, Logs und Fehler dieses Servers. Den Verbindungscode dem Assistenten geben - er verbindet sich, sieht alles und behebt Probleme.

+
+ + + {remoteDiag?.status.running + ? `Aktiv auf ${remoteDiag.status.host}:${remoteDiag.status.port}${remoteDiag.status.localOnly ? " (nur lokal)" : ` (Allowlist: ${remoteDiag.status.allowlistCount})`}` + : "Inaktiv"} + +
+ +
+ +
+ + +
+ + {rdHostMode === "local" + ? "Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante." + : "Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen."} + +
+ +
+ + setRdPublicHost(event.target.value)} + /> + {remoteDiag && remoteDiag.suggestedHosts.length > 0 && ( +
+ {remoteDiag.suggestedHosts.map((host) => ( + + ))} +
+ )} +
+ +
+
+ + setRdPort(event.target.value.replace(/[^0-9]/g, ""))} /> +
+
+ + setRdName(event.target.value)} /> +
+
+ + {rdHostMode === "network" && ( +
+ +