Ferndiagnose (MCP): Verbindungscode + abgesicherter Fernzugriff + stdio-Bridge
Neue Funktion, um Diagnose eines laufenden Servers aus der Ferne zu ermoeglichen:
Hilfe -> Remote-Support -> "Ferndiagnose (MCP)". Erzeugt einen Verbindungscode,
den der Assistent nutzt, um Status, Logs, Fehler und Accounts read-only zu lesen.
App-Seite:
- Live (re)startbarer Debug-Server ohne App-Neustart (restartDebugServer wartet auf
'close' + closeAllConnections, behandelt EADDRINUSE).
- IP-Allowlist (debug_allowlist.txt, exakte IP + CIDR), erzwungen VOR der Auth.
Fail-closed: Netzwerk-Bind (0.0.0.0) ohne Allowlist akzeptiert nur Loopback.
- One-Click Aktivieren/Aktualisieren/Deaktivieren + Token-Rotation (alter Code sofort
ungueltig). Sichtbarkeit waehlbar: "Nur lokal" (Tunnel-Empfehlung) vs "Im Netzwerk".
- Verbindungscode rddiag:v1:base64url({v,h,p,t,n?,fp?,s?}); oeffentlicher Host frei
waehlbar, Netzwerk-IPs als Schnellauswahl.
- Neue IPC: get/enable/disable/rotate Remote-Diagnostics; Controller-Methoden; Typen.
Bridge (tools/rd-diagnostics-mcp, standalone, KEINE App-Dependency):
- stdio MCP-Server (@modelcontextprotocol/sdk) mit 14 Tools, proxyt die bestehende
HTTP-Debug-API. Multi-Server ueber code/server/RDDIAG_CODE/RDDIAG_SERVERS.
- TLS-Fingerprint-Pinning auf secureConnect (vor Token-Versand), falls https genutzt.
- test/harness.mjs: faehrt einen Fake-Debug-Server hoch und treibt die Bridge als
echten stdio-Child per JSON-RPC -> voller Protokollpfad gruen.
Sicherheit (Audit): keine persistenten Secrets in den Logs der Debug-API (Passwoerter
redigiert, keine Debrid-Keys/aufgeloesten Download-URLs geloggt; settings/accounts
redigiert). Empfohlener Transport: Loopback + privater Tunnel; Direkt-Bind nur mit
Allowlist in vertrauenswuerdigen Netzen.
Tests: connection-code-Cross-Check (App-Encoder <-> Bridge-Decoder), Allowlist-Matrix
(Loopback, exakt, CIDR, fail-closed, Live-Restart). Volle Suite 905 gruen, tsc=6.
This commit is contained in:
@@ -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<StartConflictPromptState | null>(null);
|
||||
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
||||
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
||||
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(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<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 {
|
||||
<button className="menu-submenu-trigger">Remote-Support</button>
|
||||
{openSubmenu === "hilfe-remote" && (
|
||||
<div className="menu-submenu-dropdown">
|
||||
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose (MCP) …</span></button>
|
||||
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
|
||||
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
|
||||
</div>
|
||||
@@ -5934,6 +6027,97 @@ export function App(): ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remoteDiagOpen && (
|
||||
<div className="modal-backdrop" onClick={() => setRemoteDiagOpen(false)}>
|
||||
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>Ferndiagnose (MCP)</h3>
|
||||
<p>Aktiviert einen abgesicherten Lesezugriff auf Status, Logs und Fehler dieses Servers. Den Verbindungscode dem Assistenten geben - er verbindet sich, sieht alles und behebt Probleme.</p>
|
||||
<div className="rd-status-line">
|
||||
<span className={`rd-dot${remoteDiag?.status.running ? " on" : ""}`} />
|
||||
<span>
|
||||
{remoteDiag?.status.running
|
||||
? `Aktiv auf ${remoteDiag.status.host}:${remoteDiag.status.port}${remoteDiag.status.localOnly ? " (nur lokal)" : ` (Allowlist: ${remoteDiag.status.allowlistCount})`}`
|
||||
: "Inaktiv"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rd-field">
|
||||
<label>Sichtbarkeit</label>
|
||||
<div className="rd-seg">
|
||||
<button className={rdHostMode === "local" ? "active" : ""} onClick={() => setRdHostMode("local")}>Nur lokal</button>
|
||||
<button className={rdHostMode === "network" ? "active" : ""} onClick={() => setRdHostMode("network")}>Im Netzwerk</button>
|
||||
</div>
|
||||
<span className="rd-hint">
|
||||
{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."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rd-field">
|
||||
<label>Oeffentliche Adresse (fuer den Verbindungscode)</label>
|
||||
<input
|
||||
value={rdPublicHost}
|
||||
placeholder={rdHostMode === "local" ? "127.0.0.1 oder Tunnel-Adresse" : "Server-IP oder DNS-Name"}
|
||||
onChange={(event) => setRdPublicHost(event.target.value)}
|
||||
/>
|
||||
{remoteDiag && remoteDiag.suggestedHosts.length > 0 && (
|
||||
<div className="rd-chips">
|
||||
{remoteDiag.suggestedHosts.map((host) => (
|
||||
<button key={host} className="rd-chip" onClick={() => setRdPublicHost(host)}>{host}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rd-field rd-field-inline">
|
||||
<div className="rd-field">
|
||||
<label>Port</label>
|
||||
<input value={rdPort} onChange={(event) => setRdPort(event.target.value.replace(/[^0-9]/g, ""))} />
|
||||
</div>
|
||||
<div className="rd-field">
|
||||
<label>Name (optional)</label>
|
||||
<input value={rdName} placeholder="z.B. Server-Berlin" onChange={(event) => setRdName(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rdHostMode === "network" && (
|
||||
<div className="rd-field">
|
||||
<label>Allowlist - erlaubte IPs/CIDR (eine pro Zeile)</label>
|
||||
<textarea
|
||||
value={rdAllowlist}
|
||||
placeholder={"203.0.113.5\n10.0.0.0/24"}
|
||||
onChange={(event) => setRdAllowlist(event.target.value)}
|
||||
/>
|
||||
<span className="rd-hint">Pflicht im Netzwerkmodus. Nur diese Quell-IPs duerfen verbinden (zusaetzlich zum Token). Loopback ist immer erlaubt.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remoteDiag?.status.running && remoteDiag.code && (
|
||||
<div className="rd-field">
|
||||
<label>Verbindungscode</label>
|
||||
<div className="rd-code">{remoteDiag.code}</div>
|
||||
<div className="rd-chips">
|
||||
<button className="btn" onClick={() => { void onCopyRemoteDiagnosticsCode(); }}>Kopieren</button>
|
||||
<button className="btn" onClick={() => { void onRotateRemoteDiagnosticsToken(); }} disabled={remoteDiagBusy}>Token neu (Code ungueltig machen)</button>
|
||||
</div>
|
||||
<span className="rd-hint">Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => setRemoteDiagOpen(false)}>Schliessen</button>
|
||||
{remoteDiag?.status.running && (
|
||||
<button className="btn danger" onClick={() => { void onDisableRemoteDiagnostics(); }} disabled={remoteDiagBusy}>Deaktivieren</button>
|
||||
)}
|
||||
<button className="btn accent" onClick={() => { void onSubmitRemoteDiagnostics(); }} disabled={remoteDiagBusy}>
|
||||
{remoteDiag?.status.running ? "Aktualisieren" : "Aktivieren"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteConfirm && (() => {
|
||||
const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[id]).length;
|
||||
const pkgCount = [...deleteConfirm.ids].filter((id) => snapshot.session.packages[id]).length;
|
||||
|
||||
@@ -3360,3 +3360,110 @@ td {
|
||||
.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; }
|
||||
.rotation-event .rotation-body strong { font-weight: 600; }
|
||||
.rotation-event .rotation-reason { color: var(--muted, #a59c8e); }
|
||||
|
||||
.rd-status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.rd-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.rd-dot.on {
|
||||
background: #3fb950;
|
||||
}
|
||||
.rd-field {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.rd-field > label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.rd-field input,
|
||||
.rd-field textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.rd-field input:focus,
|
||||
.rd-field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.rd-field textarea {
|
||||
resize: vertical;
|
||||
min-height: 62px;
|
||||
}
|
||||
.rd-field-inline {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.rd-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.rd-seg {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rd-seg button {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: none;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rd-seg button.active {
|
||||
background: var(--accent);
|
||||
color: #1a1206;
|
||||
}
|
||||
.rd-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.rd-chip {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
.rd-chip:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.rd-code {
|
||||
font-family: ui-monospace, "Cascadia Code", monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 9px 10px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user