feat(diagnostics): MCP gateway + harden redaction so no secret ever leaves the box

Adds the connect-by-code side of remote diagnostics and closes two real
secret-leak vectors that an end-to-end gateway<->agent test surfaced.

Gateway (gateway/, local stdio MCP, Claude connects once):
- 14 read-only tools (server_health hub, read_log, list_logs, list_errors,
  get_queue_state, get_history, get_config_redacted, get_system_info,
  get_rotation_state, get_app_events + connect/disconnect/list/current).
- The HOST is always supplied by the operator, never taken from the code.
- TLS fingerprint pinning is enforced in the socket 'open' handler BEFORE the
  token is sent (wss opt-in); plain ws is loopback-only.
- registry.json (holds bearer tokens) is gitignored; only an empty example ships.

Security hardening (gates every off-box payload):
- redactLogText now scrubs opaque bearer/token-family secrets that are NOT
  stored config credentials (e.g. a session token a hoster returns inside an
  error string): bare token/auth_token/refresh_token/session_token + standalone
  "Bearer <opaque>". Benign "token bucket" prose is left intact.
- get_config_redacted deep-redacts every string leaf (JSON-safe, per-leaf, so
  the cookie/sess line patterns can't gobble across a compact-JSON field) and
  drops the history subtree (served by get_history with its own per-error
  redaction). This plugs leaks via globalSettings.pendingQueue[].error etc.

Bind-address safety:
- _safeDiagBindAddress() forces the diagnostic agent to 127.0.0.1/::1; the
  0.0.0.0 UI option is removed. Direct LAN/Internet bind stays disabled until
  encrypted transport (wss) exists — remote access goes through an SSH/VPN
  tunnel to loopback. (Never plaintext ws:// on all interfaces.)

Tests: end-to-end gateway<->agent gate (connect -> server_health/read_log/
get_config_redacted, asserts zero secret leakage, rejects doodstream log, path
traversal and write ops); + redaction regression tests in the main suite.
385 app tests + 9 gateway tests pass; lint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-06-19 17:39:31 +02:00
parent ab7313f32c
commit d69e5c39bf
17 changed files with 2267 additions and 9 deletions

View File

@ -0,0 +1,126 @@
# Remote Diagnostics Setup (read-only)
This guide explains how to let Claude Code run **read-only** diagnostics against a
Multi-Hoster-Uploader instance running on a remote Windows server, through the local
**stdio MCP gateway** in `gateway/`.
The gateway is an MCP server to Claude and a WebSocket client to a diagnostic agent inside the
app. It never touches the screen, never injects input, and never writes anything on the server.
---
## 1. Enable diagnostics on the server and copy the code
On the **server** (the machine running the app):
1. Open the app's **Settings**.
2. Enable **"Diagnose-Zugriff"**.
3. Copy the connection **code**. It looks like `mhu1_<base64url...>`.
The code carries a one-time auth **token** (and, for TLS, the server cert fingerprint). It does
**not** carry a host — you supply the host yourself when you connect. Treat the code as a
**secret**: anyone with the code and network reach to the agent can read diagnostics.
The agent's **safe default is to bind to `127.0.0.1`** (loopback only). It is not exposed to the
network. You reach it through a tunnel (next step).
---
## 2. Reach the agent over a tunnel (SSH local port-forward or WireGuard)
Because the agent binds to `127.0.0.1` on the server, open a tunnel from your machine to the
server's loopback. The agent's default port is `9110`.
### SSH local port-forward (recommended)
```
ssh -L 9110:127.0.0.1:9110 user@server
```
Leave that session open. Now `127.0.0.1:9110` on **your** machine is forwarded to
`127.0.0.1:9110` on the **server**. You connect Claude to **`127.0.0.1`** (your local end of the
tunnel), not the server's public IP.
### WireGuard (alternative)
Bring up a WireGuard tunnel to the server, then connect to the server's WireGuard address (or, if
you forward loopback over the tunnel, `127.0.0.1`). Use whichever address resolves to the agent's
`127.0.0.1:9110` on the server.
> Only expose the agent directly on a LAN/VPN IP if you fully trust that network segment. The
> default loopback + tunnel is the secure choice.
---
## 3. Install the gateway and register it with Claude (one time)
On the machine running Claude Code:
```
cd gateway
npm install
```
Register the gateway as an MCP server (one time):
```
claude mcp add --transport stdio mhu-diag -- node "C:\Users\ploet\Desktop\Claude Projekte\multi-hoster-uploader\gateway\index.js"
```
Adjust the absolute path if your checkout lives elsewhere.
---
## 4. Usage
With the tunnel up, tell Claude:
```
server prod-3 at 127.0.0.1, code mhu1_<...>
```
Claude will:
1. call `connect_server(code:"mhu1_...", host:"127.0.0.1")`,
2. immediately read the app version via `get_system_info`,
3. remember the server under its label in `registry.json`.
Next time you can just say:
```
connect_server(label:"prod-3")
```
with no code — the gateway dials it from the registry. Then ask Claude "what's wrong?" — it will
typically start with **`server_health`**, the one-shot hub that aggregates errors, queue, rotation
and system info in a single call. Other read-only tools: `read_log`, `list_logs`, `list_errors`,
`get_queue_state`, `get_history`, `get_config_redacted`, `get_rotation_state`, `get_app_events`.
---
## 5. Failure → meaning
When a connect or a request fails, the gateway returns a human-readable cause. Quick reference:
| Symptom / signal | What it means / what to do |
| -------------------------------------- | ----------------------------------------------------------------------- |
| `ECONNREFUSED` | app not running / wrong port / inbound firewall closed |
| `ETIMEDOUT` | firewall DROP / NAT not forwarded / tunnel down |
| close code **4001** | auth timeout, retry |
| close code **4002** | stale or rotated code — re-copy the current code from the server |
| close code **4003** | brute-force lockout, wait 60s |
| connected but no `auth-ok` | old app version without the diagnostic agent — update that server |
| wss fingerprint mismatch | server cert changed (reinstalled?) — re-copy the code |
If you tunnel and still get `ECONNREFUSED`, check that the SSH session is up and that the agent is
actually listening on `127.0.0.1:9110` on the server (Diagnose-Zugriff enabled).
---
## 6. What this is — and is not
- It is **READ-ONLY**. It reads logs, errors, queue/history, redacted config, rotation and system
info. **No screen capture. No input injection. No writes or config changes.**
- The **code is a secret** (it carries the auth token). Don't paste it into public channels.
- `gateway/registry.json` stores tokens for remembered servers and is **git-ignored** — never
commit it.

2
gateway/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
registry.json

56
gateway/README.md Normal file
View File

@ -0,0 +1,56 @@
# mhu-diagnostics-gateway
A standalone local **stdio MCP gateway** for remote, **read-only** diagnostics of the
Multi-Hoster-Uploader app.
It is two things at once:
- an **MCP server** to Claude Code (stdio transport), exposing read-only diagnostic tools, and
- a plain **WebSocket client** to a diagnostic agent running inside the Electron app on a
remote Windows server.
The operator enables "Diagnose-Zugriff" on a server, copies the connection **code**, and tells
Claude `server <name> at <host>, code <CODE>`. Claude calls `connect_server(code, host)` and then
the read-only diagnostic tools. After the first successful connect the server is remembered under
its label, so later you can just say `connect_server(label:"prod-3")` with no code.
This package is fully self-contained. It does **not** import anything from the parent Electron app
and is **not** part of the app build.
## Install
```
cd gateway
npm install
```
Requires Node >= 18.
## Register with Claude Code (one time)
```
claude mcp add --transport stdio mhu-diag -- node "C:\Users\ploet\Desktop\Claude Projekte\multi-hoster-uploader\gateway\index.js"
```
Adjust the absolute path if you cloned the repo elsewhere.
## Usage
In Claude Code, tell Claude:
```
server prod-3 at 127.0.0.1, code mhu1_<...>
```
Claude will call `connect_server` and then diagnostic tools such as `server_health`
(the one-shot "what's wrong" hub), `read_log`, `list_errors`, `get_queue_state`,
`get_rotation_state`, and so on.
## Security
- **Read-only.** No screen access, no input injection, no writes. Only reads logs, errors,
queue/history/config (redacted), rotation and system info.
- The **code is a secret** — it carries the auth token. Do not paste it anywhere public.
- The safe default is to reach the agent over `127.0.0.1` via an SSH local port-forward or
WireGuard. See `docs/remote-diagnostics-setup.md`.
- `registry.json` stores tokens and is **git-ignored** — never commit it.

219
gateway/agent-client.js Normal file
View File

@ -0,0 +1,219 @@
import WebSocket from 'ws';
import { randomUUID } from 'node:crypto';
const AUTH_TIMEOUT_MS = 6000;
const REQUEST_TIMEOUT_MS = 30000;
const CLOSE_CODE_GUIDANCE = {
4001: 'auth timeout, retry',
4002: 'stale or rotated code — re-copy the current code from the server',
4003: 'brute-force lockout, wait 60s',
};
function normalizeFingerprint(fp) {
if (typeof fp !== 'string') return '';
return fp.replace(/:/g, '').toLowerCase();
}
function mapSocketError(err) {
const code = err && err.code;
if (code === 'ECONNREFUSED') {
return 'app not running / wrong port / inbound firewall closed';
}
if (code === 'ETIMEDOUT') {
return 'firewall DROP / NAT not forwarded / tunnel down';
}
return (err && err.message) ? err.message : String(err);
}
function mapCloseBeforeAuth(code, sawOpen) {
if (CLOSE_CODE_GUIDANCE[code]) return CLOSE_CODE_GUIDANCE[code];
if (sawOpen) {
return 'old app version without the diagnostic agent — update that server';
}
return `connection closed before auth (code ${code})`;
}
export class AgentClient {
constructor({ host, port, token, fp }) {
this.host = host;
this.port = port;
this.token = token;
this.fp = fp;
this.connected = false;
this.clientId = null;
this.ws = null;
this.authed = false;
this.sawOpen = false;
this._pending = new Map();
this._authResolve = null;
this._authReject = null;
this._authTimer = null;
}
connect() {
return new Promise((resolve, reject) => {
this._authResolve = resolve;
this._authReject = reject;
const secure = typeof this.fp === 'string' && this.fp.length > 0;
const scheme = secure ? 'wss' : 'ws';
const url = `${scheme}://${this.host}:${this.port}`;
const pinned = secure ? normalizeFingerprint(this.fp) : '';
const options = secure ? { rejectUnauthorized: false } : undefined;
let ws;
try {
ws = options ? new WebSocket(url, options) : new WebSocket(url);
} catch (err) {
this._failAuth(mapSocketError(err));
return;
}
this.ws = ws;
this._authTimer = setTimeout(() => {
this._failAuth('auth timeout, retry');
try { ws.close(); } catch {}
}, AUTH_TIMEOUT_MS);
ws.on('open', () => {
this.sawOpen = true;
if (secure) {
const sock = ws._socket;
const cert = sock && typeof sock.getPeerCertificate === 'function'
? sock.getPeerCertificate()
: null;
const actual = normalizeFingerprint(cert && cert.fingerprint256);
if (!actual || actual !== pinned) {
this._failAuth('server cert changed (reinstalled?) — re-copy the code');
try { ws.close(); } catch {}
return;
}
}
this._send({ type: 'auth', token: this.token, role: 'diagnostic' });
});
ws.on('message', (raw) => this._onMessage(raw));
ws.on('error', (err) => {
if (!this.authed) {
this._failAuth(mapSocketError(err));
} else {
this._rejectAllPending(mapSocketError(err));
}
});
ws.on('close', (code) => {
this.connected = false;
if (!this.authed) {
this._failAuth(mapCloseBeforeAuth(code, this.sawOpen));
} else {
this._rejectAllPending(`connection closed (code ${code})`);
}
});
});
}
_onMessage(raw) {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
return;
}
if (msg.type === 'auth-ok') {
this.authed = true;
this.connected = true;
this.clientId = msg.clientId ?? null;
if (this._authTimer) {
clearTimeout(this._authTimer);
this._authTimer = null;
}
if (this._authResolve) {
const r = this._authResolve;
this._authResolve = null;
this._authReject = null;
r({ clientId: this.clientId });
}
return;
}
if (msg.type === 'diag-response' && msg.reqId) {
const entry = this._pending.get(msg.reqId);
if (!entry) return;
this._pending.delete(msg.reqId);
clearTimeout(entry.timer);
if (msg.ok) {
entry.resolve({ ok: true, data: msg.data });
} else {
entry.resolve({ ok: false, error: msg.error ?? 'unknown agent error' });
}
}
}
request(op, args) {
return new Promise((resolve) => {
if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
resolve({ ok: false, error: 'not connected to a diagnostic agent' });
return;
}
const reqId = randomUUID();
const timer = setTimeout(() => {
if (this._pending.has(reqId)) {
this._pending.delete(reqId);
resolve({ ok: false, error: `request timed out after ${REQUEST_TIMEOUT_MS}ms (op: ${op})` });
}
}, REQUEST_TIMEOUT_MS);
this._pending.set(reqId, { resolve, timer });
try {
this._send({ type: 'diag-request', reqId, op, args: args ?? {} });
} catch (err) {
this._pending.delete(reqId);
clearTimeout(timer);
resolve({ ok: false, error: mapSocketError(err) });
}
});
}
close() {
this.connected = false;
this.authed = false;
if (this._authTimer) {
clearTimeout(this._authTimer);
this._authTimer = null;
}
this._rejectAllPending('connection closed by client');
if (this.ws) {
try { this.ws.removeAllListeners(); } catch {}
try { this.ws.close(); } catch {}
this.ws = null;
}
}
_send(obj) {
this.ws.send(JSON.stringify(obj));
}
_failAuth(error) {
if (this._authTimer) {
clearTimeout(this._authTimer);
this._authTimer = null;
}
if (this._authReject) {
const rej = this._authReject;
this._authResolve = null;
this._authReject = null;
rej(new Error(error));
}
}
_rejectAllPending(error) {
for (const [reqId, entry] of this._pending) {
clearTimeout(entry.timer);
entry.resolve({ ok: false, error });
this._pending.delete(reqId);
}
}
}

59
gateway/code.js Normal file
View File

@ -0,0 +1,59 @@
const PREFIX = 'mhu1_';
export function encode(payload) {
if (!payload || typeof payload !== 'object') {
throw new Error('encode: payload must be an object');
}
const json = JSON.stringify(payload);
const b64 = Buffer.from(json, 'utf8').toString('base64url');
return PREFIX + b64;
}
export function decode(code) {
if (typeof code !== 'string') {
throw new Error('Invalid code: expected a string');
}
const trimmed = code.trim();
if (!trimmed.startsWith(PREFIX)) {
throw new Error('Invalid code: missing "mhu1_" prefix');
}
const b64 = trimmed.slice(PREFIX.length);
if (!b64) {
throw new Error('Invalid code: empty payload');
}
let json;
try {
json = Buffer.from(b64, 'base64url').toString('utf8');
} catch {
throw new Error('Invalid code: not valid base64url');
}
let payload;
try {
payload = JSON.parse(json);
} catch {
throw new Error('Invalid code: payload is not valid JSON');
}
if (!payload || typeof payload !== 'object') {
throw new Error('Invalid code: payload is not an object');
}
if (payload.v !== 1) {
throw new Error(`Invalid code: unsupported version (expected v=1, got ${payload.v})`);
}
if (typeof payload.port !== 'number' || !Number.isFinite(payload.port)) {
throw new Error('Invalid code: "port" must be a number');
}
if (typeof payload.token !== 'string' || payload.token.length === 0) {
throw new Error('Invalid code: "token" must be a non-empty string');
}
if (typeof payload.label !== 'string') {
throw new Error('Invalid code: "label" must be a string');
}
if (payload.fp !== undefined && typeof payload.fp !== 'string') {
throw new Error('Invalid code: "fp" must be a string when present');
}
return payload;
}

294
gateway/index.js Normal file
View File

@ -0,0 +1,294 @@
#!/usr/bin/env node
import { fileURLToPath } from 'node:url';
import { z } from 'zod';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { decode } from './code.js';
import { AgentClient } from './agent-client.js';
import { loadRegistry, upsertEntry } from './registry.js';
const state = {
current: null,
servers: new Map(),
};
function result(obj) {
return { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] };
}
function guard(handler) {
return async (args) => {
try {
return result(await handler(args ?? {}));
} catch (e) {
return result({ ok: false, error: String(e && e.message ? e.message : e) });
}
};
}
async function requireCurrent(op, args) {
if (!state.current || !state.current.client || !state.current.client.connected) {
return { ok: false, error: 'no server connected — call connect_server first' };
}
return state.current.client.request(op, args ?? {});
}
const DIAGNOSTIC_TOOLS = [
{
name: 'server_health',
title: 'Server health (one-shot hub)',
description:
'THE one-shot diagnostic hub: answers "what is wrong" in a single call by aggregating recent errors, queue state, rotation and system info.',
op: 'server_health',
inputSchema: {
errorLimit: z.number().optional(),
errorSinceMs: z.number().optional(),
},
},
{
name: 'read_log',
title: 'Read a log file',
description: 'Read a tail of one of the app log files, optionally grep-filtered, optionally a rotated backup.',
op: 'read_log',
inputSchema: {
name: z.enum(['debug', 'fileuploader', 'accountRotation', 'crash']),
tailKb: z.number().optional(),
grep: z.string().optional(),
backup: z.number().optional(),
},
},
{
name: 'list_logs',
title: 'List available logs',
description: 'List the available log files and their sizes.',
op: 'list_logs',
inputSchema: {},
},
{
name: 'list_errors',
title: 'List recent errors',
description: 'List recent structured errors, filterable by time window, category and hoster.',
op: 'list_errors',
inputSchema: {
sinceMs: z.number().optional(),
category: z.string().optional(),
hoster: z.string().optional(),
limit: z.number().optional(),
},
},
{
name: 'get_queue_state',
title: 'Get queue state',
description: 'Get the upload queue state, optionally including individual jobs.',
op: 'get_queue_state',
inputSchema: {
includeJobs: z.boolean().optional(),
maxJobs: z.number().optional(),
},
},
{
name: 'get_history',
title: 'Get upload history',
description: 'Get recent upload history, optionally including file names and URLs.',
op: 'get_history',
inputSchema: {
limit: z.number().optional(),
includeFiles: z.boolean().optional(),
includeUrls: z.boolean().optional(),
},
},
{
name: 'get_config_redacted',
title: 'Get redacted config',
description: 'Get the app configuration with secrets redacted. Choose a section to narrow the output.',
op: 'get_config_redacted',
inputSchema: {
section: z.enum(['all', 'hosters', 'hosterSettings', 'globalSettings', 'rotationCursors']).optional(),
},
},
{
name: 'get_system_info',
title: 'Get system info',
description: 'Get system and app version info (OS, app version, uptime).',
op: 'get_system_info',
inputSchema: {},
},
{
name: 'get_rotation_state',
title: 'Get rotation state',
description: 'Get the per-hoster account rotation state and round-robin cursors.',
op: 'get_rotation_state',
inputSchema: {},
},
{
name: 'get_app_events',
title: 'Get app events',
description: 'Get recent in-app lifecycle events.',
op: 'get_app_events',
inputSchema: {
limit: z.number().optional(),
},
},
];
async function doConnect({ code, host, port, label }) {
const registry = await loadRegistry();
let target;
if (label && registry[label]) {
const e = registry[label];
target = { host: e.host, port: e.port, token: e.token, fp: e.fp, label: e.label };
} else {
if (!code) {
return { ok: false, error: 'provide a known label, or a code plus host' };
}
let payload;
try {
payload = decode(code);
} catch (e) {
return { ok: false, error: String(e.message ?? e) };
}
if (!host) {
return { ok: false, error: 'host is required when connecting with a code' };
}
target = {
host,
port: typeof port === 'number' ? port : payload.port,
token: payload.token,
fp: payload.fp,
label: label || payload.label,
};
}
if (typeof port === 'number') target.port = port;
const client = new AgentClient(target);
try {
await client.connect();
} catch (e) {
return { ok: false, error: String(e.message ?? e) };
}
let version;
const info = await client.request('get_system_info', {});
if (info && info.ok && info.data) {
version = info.data.version ?? info.data.appVersion ?? undefined;
}
const id = `${target.label}@${target.host}:${target.port}`;
if (state.current && state.current.client && state.current.client !== client) {
state.current.client.close();
}
state.servers.set(id, { id, client, target });
state.current = { id, client, target };
await upsertEntry({
host: target.host,
port: target.port,
token: target.token,
fp: target.fp,
label: target.label,
version,
lastConnectedAt: new Date().toISOString(),
});
return {
ok: true,
server: { label: target.label, host: target.host, port: target.port, version },
};
}
export function buildServer() {
const server = new McpServer({ name: 'mhu-diagnostics-gateway', version: '1.0.0' });
server.registerTool(
'list_servers',
{
title: 'List known servers',
description: 'List the registry of known diagnostic servers and which one is currently connected.',
inputSchema: {},
},
guard(async () => {
const registry = await loadRegistry();
return {
ok: true,
servers: Object.values(registry),
current: state.current ? state.current.id : null,
};
}),
);
server.registerTool(
'connect_server',
{
title: 'Connect to a diagnostic server',
description:
'Connect to a remote diagnostic agent. Use label for a known server, or code+host for a new one. The host is always supplied by you, never taken from the code.',
inputSchema: {
code: z.string().optional(),
host: z.string().optional(),
port: z.number().optional(),
label: z.string().optional(),
},
},
guard((args) => doConnect(args)),
);
server.registerTool(
'disconnect_server',
{
title: 'Disconnect the current server',
description: 'Close the connection to the currently connected diagnostic server.',
inputSchema: {},
},
guard(async () => {
if (!state.current) return { ok: true, disconnected: false };
const id = state.current.id;
try { state.current.client.close(); } catch {}
state.servers.delete(id);
state.current = null;
return { ok: true, disconnected: true, id };
}),
);
server.registerTool(
'current_server',
{
title: 'Show the current server',
description: 'Show the currently connected diagnostic target, or null if none.',
inputSchema: {},
},
guard(async () => {
if (!state.current) return { ok: true, current: null };
const t = state.current.target;
return {
ok: true,
current: { id: state.current.id, label: t.label, host: t.host, port: t.port },
};
}),
);
for (const tool of DIAGNOSTIC_TOOLS) {
server.registerTool(
tool.name,
{ title: tool.title, description: tool.description, inputSchema: tool.inputSchema },
guard((args) => requireCurrent(tool.op, args)),
);
}
return server;
}
async function main() {
const server = buildServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[mhu-diag] gateway ready (stdio MCP)');
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((e) => {
console.error('[mhu-diag] fatal:', e);
process.exit(1);
});
}

1197
gateway/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
gateway/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "mhu-diagnostics-gateway",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Local stdio MCP gateway for remote diagnostics of the Multi-Hoster-Uploader app.",
"bin": {
"mhu-diag": "index.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@modelcontextprotocol/sdk": "~1.29.0",
"ws": "^8",
"zod": "^3.25.0"
}
}

View File

@ -0,0 +1 @@
{}

50
gateway/registry.js Normal file
View File

@ -0,0 +1,50 @@
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url));
const REGISTRY_PATH = join(HERE, 'registry.json');
export async function loadRegistry() {
try {
const raw = await readFile(REGISTRY_PATH, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
return parsed;
} catch {
return {};
}
}
export async function saveRegistry(registry) {
const data = registry && typeof registry === 'object' ? registry : {};
await writeFile(REGISTRY_PATH, JSON.stringify(data, null, 2) + '\n', 'utf8');
}
export async function upsertEntry(entry) {
if (!entry || typeof entry.label !== 'string' || entry.label.length === 0) {
throw new Error('upsertEntry: entry.label is required');
}
const registry = await loadRegistry();
registry[entry.label] = {
host: entry.host,
port: entry.port,
token: entry.token,
fp: entry.fp,
label: entry.label,
version: entry.version,
lastConnectedAt: entry.lastConnectedAt ?? new Date().toISOString(),
};
await saveRegistry(registry);
return registry[entry.label];
}
export async function getEntry(label) {
if (typeof label !== 'string' || label.length === 0) return null;
const registry = await loadRegistry();
return registry[label] ?? null;
}
export { REGISTRY_PATH };

62
gateway/test/code.test.js Normal file
View File

@ -0,0 +1,62 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { encode, decode } from '../code.js';
test('decode(encode(x)) round-trips a full payload', () => {
const payload = {
v: 1,
port: 9110,
token: 'deadbeefcafe1234',
label: 'prod-3',
fp: 'AB:CD:EF:01:23:45:67:89',
};
const code = encode(payload);
assert.ok(code.startsWith('mhu1_'));
assert.deepEqual(decode(code), payload);
});
test('decode(encode(x)) round-trips a payload without fp (ws://)', () => {
const payload = { v: 1, port: 9110, token: 'token-abc', label: 'localhost' };
const code = encode(payload);
assert.deepEqual(decode(code), payload);
});
test('decode rejects a string without the mhu1_ prefix', () => {
assert.throws(() => decode('hello-world'), /missing "mhu1_" prefix/);
});
test('decode rejects a wrong-version payload', () => {
const bad = 'mhu1_' + Buffer.from(
JSON.stringify({ v: 2, port: 9110, token: 'x', label: 'l' }),
'utf8',
).toString('base64url');
assert.throws(() => decode(bad), /unsupported version/);
});
test('decode rejects garbage after the prefix', () => {
assert.throws(() => decode('mhu1_!!!not-base64-or-json!!!'), /Invalid code/);
});
test('decode rejects an empty payload', () => {
assert.throws(() => decode('mhu1_'), /empty payload/);
});
test('decode rejects a non-string input', () => {
assert.throws(() => decode(null), /expected a string/);
});
test('decode rejects a missing token', () => {
const bad = 'mhu1_' + Buffer.from(
JSON.stringify({ v: 1, port: 9110, label: 'l' }),
'utf8',
).toString('base64url');
assert.throws(() => decode(bad), /token/);
});
test('decode rejects a non-number port', () => {
const bad = 'mhu1_' + Buffer.from(
JSON.stringify({ v: 1, port: 'nope', token: 'x', label: 'l' }),
'utf8',
).toString('base64url');
assert.throws(() => decode(bad), /port/);
});

124
gateway/test/e2e-verify.mjs Normal file
View File

@ -0,0 +1,124 @@
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import assert from 'node:assert';
import { encode, decode } from '../code.js';
import { AgentClient } from '../agent-client.js';
const require = createRequire(import.meta.url);
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const RemoteServer = require(join(root, 'lib', 'remote-server.js'));
const support = require(join(root, 'lib', 'support-bundle.js'));
const stats = require(join(root, 'lib', 'stats.js'));
const { createCollectors } = require(join(root, 'lib', 'diagnostics-collectors.js'));
const { createAgent } = require(join(root, 'lib', 'diagnostics-agent.js'));
const SECRET_API = 'SUPERSECRET_apikey_9f8e7d6c5b4a';
const SECRET_PW = 'hunter2_password_zxcv';
const SECRET_TOKEN = 'bearer_tok_qwerty12345';
const WEBHOOK = 'https://discord.com/api/webhooks/123456789012345678/abcDEF_secretWebhookToken-xyz';
const tmp = mkdtempSync(join(tmpdir(), 'mhu-e2e-'));
const debugLog = join(tmp, 'debug.log');
const dood = join(tmp, 'doodstream-debug.log');
writeFileSync(debugLog, [
`[2026-06-19T10:00:00Z] starting upload with apiKey=${SECRET_API}`,
`[2026-06-19T10:00:01Z] Authorization: Bearer ${SECRET_TOKEN}`,
`[2026-06-19T10:00:02Z] posting to ${WEBHOOK}`,
`[2026-06-19T10:00:03Z] password ${SECRET_PW} used`,
`[2026-06-19T10:00:04Z] normal benign line`,
].join('\n'));
writeFileSync(dood, `[doodstream] live apiKey=${SECRET_API}\n`);
const fakeConfig = {
hosters: { doodstream: [{ accountId: 'acc1', apiKey: SECRET_API, password: SECRET_PW }] },
hosterSettings: {},
globalSettings: {
webhookUrl: WEBHOOK,
diagnostics: { enabled: true, port: 9110, token: 'x'.repeat(64) },
pendingQueue: { savedAt: '2026-06-19T09:00:00Z', queueJobs: [{ file: 'a.mp4', fileName: 'a.mp4', hoster: 'doodstream', status: 'error', error: `failed with apiKey=${SECRET_API}` }], selectedUploadHosters: ['doodstream'], selectedFiles: ['a.mp4'] },
},
rotationCursors: { doodstream: 1 },
history: [{ timestamp: '2026-06-19T08:00:00Z', files: [{ name: 'a.mp4', results: [{ hoster: 'doodstream', status: 'error', error: `boom token=${SECRET_TOKEN}` }] }] }],
};
function getAllLogPaths() {
return { fileuploader: join(tmp, 'fileuploader.log'), debug: debugLog, accountRotation: join(tmp, 'rot.log'), doodstreamDebug: dood, crashLog: join(tmp, 'crash.log'), logDir: tmp };
}
const collectors = createCollectors({
loadConfig: () => fakeConfig,
getAllLogPaths,
support,
stats,
appInfo: () => ({ version: '9.9.9' }),
systemInfo: () => ({ platform: 'win32', hostname: 'TESTHOST' }),
agentInfo: () => ({ version: '9.9.9', port: 9110 }),
});
const agent = createAgent(collectors);
const TOKEN = 'z'.repeat(64);
const srv = new RemoteServer();
const SECRETS = [SECRET_API, SECRET_PW, SECRET_TOKEN, 'abcDEF_secretWebhookToken-xyz'];
function assertNoLeak(label, payload) {
const text = JSON.stringify(payload);
for (const s of SECRETS) {
assert.ok(!text.includes(s), `LEAK in ${label}: secret "${s.slice(0, 12)}…" appeared in response`);
}
}
(async () => {
await srv.start({
port: 0,
host: '127.0.0.1',
token: TOKEN,
diagnosticMode: true,
onDiagnosticRequest: (msg, _client, reply) => {
let r;
try { r = agent.handle(msg.op, msg.args); }
catch (e) { r = { ok: false, error: String(e && e.message || e) }; }
reply(r);
},
});
const port = srv.getPort();
const code = encode({ v: 1, port, token: TOKEN, label: 'e2e' });
const payload = decode(code);
assert.equal(payload.token, TOKEN);
const client = new AgentClient({ host: '127.0.0.1', port, token: payload.token });
await client.connect();
const health = await client.request('server_health', { errorLimit: 10 });
assert.equal(health.ok, true, 'server_health must succeed: ' + JSON.stringify(health));
assertNoLeak('server_health', health);
assert.ok(health.data.errors, 'server_health has errors section');
assert.ok(health.data.queue, 'server_health has queue section');
const log = await client.request('read_log', { name: 'debug', tailKb: 64 });
assert.equal(log.ok, true, 'read_log debug must succeed');
assert.ok(log.data.content.includes('normal benign line'), 'benign content preserved');
assertNoLeak('read_log:debug', log);
const dl = await client.request('read_log', { name: 'doodstream', tailKb: 64 });
assert.equal(dl.ok, false, 'doodstream log MUST NOT be readable (live api keys)');
const trav = await client.request('read_log', { name: '../../../etc/passwd' });
assert.equal(trav.ok, false, 'path traversal must be rejected');
const cfg = await client.request('get_config_redacted', { section: 'all' });
assert.equal(cfg.ok, true, 'get_config_redacted must succeed');
assertNoLeak('get_config_redacted', cfg);
const writeAttempt = await client.request('save_config', { x: 1 });
assert.equal(writeAttempt.ok, false, 'unknown/write op must be rejected by whitelist');
client.close();
srv.stop();
console.log('E2E PASS: connect → server_health/read_log/get_config_redacted succeeded; NO secrets leaked; doodstream + traversal + write rejected.');
process.exit(0);
})().catch((e) => { console.error('E2E FAIL:', e && e.stack || e); try { srv.stop(); } catch {} process.exit(1); });

View File

@ -21,6 +21,21 @@ function createCollectors(deps) {
try { return support.valueScrub(value, secrets || _secrets()); } catch { return value; }
}
function _deepRedact(value, secrets) {
const s = secrets || _secrets();
const walk = (v) => {
if (typeof v === 'string') return support.redactLogText(v, s);
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === 'object') {
const o = {};
for (const k of Object.keys(v)) o[k] = walk(v[k]);
return o;
}
return v;
};
try { return walk(value); } catch { return value; }
}
function _resolveLogPath(name, backup) {
const key = READABLE_LOGS[name];
if (!key) return null;
@ -40,9 +55,16 @@ function createCollectors(deps) {
const cfg = loadConfig();
const secrets = support.collectSecretValues(cfg);
const sanitized = support.sanitizeConfig(cfg);
let pick = sanitized;
if (section !== 'all') pick = sanitized[section] !== undefined ? sanitized[section] : null;
return { section, config: _scrub(pick, secrets) };
let pick;
let note;
if (section === 'all') {
pick = { ...sanitized };
delete pick.history;
note = 'history omitted from config — use get_history';
} else {
pick = sanitized[section] !== undefined ? sanitized[section] : null;
}
return { section, note, config: _deepRedact(pick, secrets) };
}
function listLogs() {

View File

@ -44,8 +44,9 @@ function redactLogText(text, secrets) {
out = out
.replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED)
.replace(/(authorization:\s*bearer\s+)\S+/gi, '$1' + REDACTED)
.replace(/\bbearer\s+[A-Za-z0-9._\-/+]{16,}/gi, 'bearer ' + REDACTED)
.replace(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\s&"'`]+/gi, '$1' + REDACTED)
.replace(/("?\b(?:api[_-]?key|apikey|password|secret|access_token)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED)
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|(?:access|refresh|auth|session)[_-]?token|token|sessionid)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED)
.replace(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED)
.replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/gi, '$1' + REDACTED);
return out;

13
main.js
View File

@ -2491,6 +2491,12 @@ function buildDiagnosticCode(diag, fp) {
return 'mhu1_' + Buffer.from(JSON.stringify(payload)).toString('base64url');
}
function _safeDiagBindAddress(addr) {
const a = String(addr || '').trim();
if (a === '127.0.0.1' || a === '::1') return a;
return '127.0.0.1';
}
async function startDiagnosticAgent() {
if (diagnosticAgent) { try { diagnosticAgent.stop(); } catch {} diagnosticAgent = null; }
const config = configStore.load();
@ -2505,16 +2511,17 @@ async function startDiagnosticAgent() {
}
if (!_diagHandler) _diagHandler = _buildDiagnosticHandler();
const host = _safeDiagBindAddress(diag.bindAddress);
diagnosticAgent = new RemoteServer();
try {
await diagnosticAgent.start({
port: diag.port || 9110,
host: diag.bindAddress || '127.0.0.1',
host,
token,
diagnosticMode: true,
onDiagnosticRequest: _diagHandler
});
debugLog(`diagnostics-agent started on ${diag.bindAddress || '127.0.0.1'}:${diagnosticAgent.getPort()}`);
debugLog(`diagnostics-agent started on ${host}:${diagnosticAgent.getPort()}`);
} catch (e) {
debugLog(`diagnostics-agent start failed: ${e.message}`);
diagnosticAgent = null;
@ -2545,7 +2552,7 @@ ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
...cur,
enabled: !!(incoming && incoming.enabled),
port: (incoming && Number(incoming.port)) || cur.port || 9110,
bindAddress: (incoming && incoming.bindAddress) || cur.bindAddress || '127.0.0.1',
bindAddress: _safeDiagBindAddress((incoming && incoming.bindAddress) || cur.bindAddress),
label: (incoming && incoming.label != null) ? String(incoming.label) : cur.label
};
const gs = { ...cfg.globalSettings, diagnostics: next };

View File

@ -3219,9 +3219,9 @@ function renderSettings() {
<label>Bind-Adresse</label>
<select class="hs-input" id="diagBindInput" style="width:auto">
<option value="127.0.0.1">Nur lokal (Tunnel/VPN) empfohlen</option>
<option value="0.0.0.0">Alle Adressen (LAN/Internet)</option>
</select>
</div>
<div class="settings-row"><span class="hint">Direkte LAN-/Internet-Bindung ist deaktiviert, bis verschlüsselter Transport (wss/TLS) verfügbar ist. Zugriff aus der Ferne läuft über einen SSH- oder VPN-Tunnel zu <code>127.0.0.1</code>.</span></div>
<div class="settings-row">
<label>Verbindungs-Code</label>
<input type="text" class="key-input" id="diagCodeInput" value="" readonly style="flex:1" placeholder="(aktivieren zum Erzeugen)">

View File

@ -3,7 +3,7 @@ const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { sanitizeConfig, collectFile, buildSupportBundleText, REDACTED } = require('../lib/support-bundle');
const { sanitizeConfig, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
const input = {
@ -24,6 +24,26 @@ test('sanitizeConfig redacts known credential keys at any nesting depth', () =>
assert.strictEqual(out.globalSettings.remote.token, REDACTED);
});
test('redactLogText scrubs opaque tokens that are NOT stored config secrets', () => {
const cases = [
'boom token=bearer_tok_qwerty12345',
'response auth_token: aGVsbG8td29ybGQtMTIz',
'refresh_token = abc123DEF456ghi789',
'using Bearer aaaabbbbccccddddeeeeffff',
'Authorization: Bearer deadbeefcafef00dba5e'
];
for (const line of cases) {
const out = redactLogText(line, []);
assert.ok(out.includes(REDACTED), `expected redaction in: ${line} -> ${out}`);
assert.ok(!/qwerty12345|aGVsbG8|abc123DEF456|aaaabbbbcccc|deadbeefcafe/.test(out), `secret survived: ${out}`);
}
});
test('redactLogText leaves benign "token" prose alone', () => {
const benign = 'token bucket refill rate is 5 per second';
assert.equal(redactLogText(benign, []), benign);
});
test('sanitizeConfig does not mutate input', () => {
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
const clone = JSON.parse(JSON.stringify(input));