Compare commits
4 Commits
26f34b4966
...
b423357a2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b423357a2c | ||
|
|
7b5420eeaa | ||
|
|
d69e5c39bf | ||
|
|
ab7313f32c |
126
docs/remote-diagnostics-setup.md
Normal file
126
docs/remote-diagnostics-setup.md
Normal 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
2
gateway/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
registry.json
|
||||
56
gateway/README.md
Normal file
56
gateway/README.md
Normal 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
219
gateway/agent-client.js
Normal 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
59
gateway/code.js
Normal 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
294
gateway/index.js
Normal 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
1197
gateway/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
gateway/package.json
Normal file
18
gateway/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
gateway/registry.example.json
Normal file
1
gateway/registry.example.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
50
gateway/registry.js
Normal file
50
gateway/registry.js
Normal 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
62
gateway/test/code.test.js
Normal 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/);
|
||||
});
|
||||
133
gateway/test/e2e-verify.mjs
Normal file
133
gateway/test/e2e-verify.mjs
Normal file
@ -0,0 +1,133 @@
|
||||
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}` }, { file: 'b.mp4', fileName: 'b.mp4', hoster: 'streamtape', status: 'error', error: `upload rejected: token=${SECRET_TOKEN}` }], selectedUploadHosters: ['doodstream'], selectedFiles: ['a.mp4', 'b.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 queue = await client.request('get_queue_state', { includeJobs: true });
|
||||
assert.equal(queue.ok, true, 'get_queue_state must succeed');
|
||||
assert.ok(Array.isArray(queue.data.jobs) && queue.data.jobs.length >= 2, 'jobs present');
|
||||
assertNoLeak('get_queue_state:includeJobs', queue);
|
||||
|
||||
const queueDefault = await client.request('get_queue_state', {});
|
||||
assert.equal(queueDefault.ok, true, 'get_queue_state (default args) must succeed');
|
||||
assertNoLeak('get_queue_state:default', queueDefault);
|
||||
|
||||
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); });
|
||||
@ -100,6 +100,14 @@ const DEFAULTS = {
|
||||
port: 9100,
|
||||
token: '',
|
||||
allowInput: true
|
||||
},
|
||||
diagnostics: {
|
||||
enabled: false,
|
||||
port: 9110,
|
||||
token: '',
|
||||
label: '',
|
||||
codeIssuedAt: 0,
|
||||
bindAddress: '127.0.0.1'
|
||||
}
|
||||
},
|
||||
history: [],
|
||||
|
||||
32
lib/diagnostics-agent.js
Normal file
32
lib/diagnostics-agent.js
Normal file
@ -0,0 +1,32 @@
|
||||
function createAgent(collectors) {
|
||||
const OPS = {
|
||||
get_system_info: (a) => collectors.getSystemInfo(a),
|
||||
server_health: (a) => collectors.serverHealth(a),
|
||||
get_config_redacted: (a) => collectors.getConfigRedacted(a),
|
||||
list_logs: () => collectors.listLogs(),
|
||||
read_log: (a) => collectors.readLog(a),
|
||||
tail_log: (a) => collectors.readLog(a),
|
||||
get_app_events: (a) => collectors.getAppEvents(a),
|
||||
list_errors: (a) => collectors.listErrors(a),
|
||||
get_queue_state: (a) => collectors.getQueueState(a),
|
||||
get_history: (a) => collectors.getHistory(a),
|
||||
get_rotation_state: () => collectors.getRotationState(),
|
||||
get_health: () => collectors.getHealth()
|
||||
};
|
||||
|
||||
function handle(op, args) {
|
||||
const fn = OPS[op];
|
||||
if (typeof fn !== 'function') return { ok: false, error: `unknown or non-readonly op: ${op}` };
|
||||
try {
|
||||
const data = fn(args || {});
|
||||
if (data && data.ok === false) return data;
|
||||
return { ok: true, data };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String((e && e.message) || e) };
|
||||
}
|
||||
}
|
||||
|
||||
return { handle, ops: Object.keys(OPS) };
|
||||
}
|
||||
|
||||
module.exports = { createAgent };
|
||||
274
lib/diagnostics-collectors.js
Normal file
274
lib/diagnostics-collectors.js
Normal file
@ -0,0 +1,274 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const READABLE_LOGS = {
|
||||
debug: 'debug',
|
||||
fileuploader: 'fileuploader',
|
||||
accountRotation: 'accountRotation',
|
||||
crash: 'crashLog'
|
||||
};
|
||||
|
||||
const QUEUE_STATUSES = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped'];
|
||||
|
||||
function createCollectors(deps) {
|
||||
const { loadConfig, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps;
|
||||
|
||||
function _secrets() {
|
||||
try { return support.collectSecretValues(loadConfig()); } catch { return []; }
|
||||
}
|
||||
|
||||
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;
|
||||
const paths = getAllLogPaths();
|
||||
let p = paths[key];
|
||||
if (!p) return null;
|
||||
if (backup === 1 || backup === 2) p = `${p}.${backup}`;
|
||||
return p;
|
||||
}
|
||||
|
||||
function getSystemInfo() {
|
||||
return { app: appInfo(), system: systemInfo(), agent: agentInfo() };
|
||||
}
|
||||
|
||||
function getConfigRedacted(args) {
|
||||
const section = (args && args.section) || 'all';
|
||||
const cfg = loadConfig();
|
||||
const secrets = support.collectSecretValues(cfg);
|
||||
const sanitized = support.sanitizeConfig(cfg);
|
||||
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() {
|
||||
const paths = getAllLogPaths();
|
||||
const dir = paths.logDir;
|
||||
const files = [];
|
||||
for (const [name, key] of Object.entries(READABLE_LOGS)) {
|
||||
const base = paths[key];
|
||||
if (!base) continue;
|
||||
const variants = [];
|
||||
for (const suffix of ['', '.1', '.2']) {
|
||||
const fp = base + suffix;
|
||||
try {
|
||||
const st = fs.statSync(fp);
|
||||
variants.push({ backup: suffix === '' ? 0 : Number(suffix.slice(1)), sizeBytes: st.size, mtime: st.mtime.toISOString() });
|
||||
} catch {}
|
||||
}
|
||||
files.push({ name, path: base, readable: true, present: variants.length > 0, variants });
|
||||
}
|
||||
let siblings = [];
|
||||
try {
|
||||
siblings = fs.readdirSync(dir)
|
||||
.filter(f => /\.log(\.\d+)?$/i.test(f))
|
||||
.filter(f => !files.some(x => path.basename(x.path) === f || f.startsWith(path.basename(x.path))));
|
||||
siblings = siblings.map(f => {
|
||||
let size = 0, mtime = null;
|
||||
try { const st = fs.statSync(path.join(dir, f)); size = st.size; mtime = st.mtime.toISOString(); } catch {}
|
||||
return { name: f, readable: false, sizeBytes: size, mtime };
|
||||
});
|
||||
} catch {}
|
||||
return { dir, files, otherLogs: siblings };
|
||||
}
|
||||
|
||||
function readLog(args) {
|
||||
const a = args || {};
|
||||
const name = a.name;
|
||||
const p = _resolveLogPath(name, a.backup);
|
||||
if (!p) return { ok: false, error: `unknown or non-readable log: ${name}` };
|
||||
const tailKb = Math.min(Math.max(Number(a.tailKb) || 256, 1), 1024);
|
||||
const raw = support.collectFile(p, name, tailKb * 1024);
|
||||
let content = support.redactLogText(raw, _secrets());
|
||||
let matchedLines;
|
||||
if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) {
|
||||
let re;
|
||||
try { re = new RegExp(a.grep, 'i'); } catch { re = null; }
|
||||
if (re) {
|
||||
const lines = content.split('\n').filter(l => re.test(l));
|
||||
matchedLines = lines.length;
|
||||
content = lines.join('\n');
|
||||
}
|
||||
}
|
||||
let sizeBytes = null;
|
||||
try { sizeBytes = fs.statSync(p).size; } catch {}
|
||||
return { name, path: p, sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content };
|
||||
}
|
||||
|
||||
function getAppEvents(args) {
|
||||
const limit = Math.min(Math.max(Number(args && args.limit) || 50, 1), 500);
|
||||
const out = [];
|
||||
const secrets = _secrets();
|
||||
for (const name of ['crash', 'debug']) {
|
||||
const p = _resolveLogPath(name);
|
||||
if (!p) continue;
|
||||
const raw = support.redactLogText(support.collectFile(p, name, 256 * 1024), secrets);
|
||||
const lines = raw.split('\n').filter(l => l.trim() && !l.startsWith('==='));
|
||||
for (const line of lines.slice(-limit)) out.push({ source: name, text: line });
|
||||
}
|
||||
return { events: out.slice(-limit), truncated: out.length > limit };
|
||||
}
|
||||
|
||||
function _historyErrors(history, opts) {
|
||||
const o = opts || {};
|
||||
const sinceMs = Number.isFinite(o.sinceMs) ? o.sinceMs : null;
|
||||
const secrets = _secrets();
|
||||
const errors = [];
|
||||
const byCategory = {};
|
||||
for (const batch of (Array.isArray(history) ? history : [])) {
|
||||
if (!batch || !Array.isArray(batch.files)) continue;
|
||||
const ts = batch.timestamp ? Date.parse(batch.timestamp) : null;
|
||||
if (sinceMs !== null && ts !== null && ts < sinceMs) continue;
|
||||
for (const file of batch.files) {
|
||||
if (!file || !Array.isArray(file.results)) continue;
|
||||
for (const r of file.results) {
|
||||
if (!r || r.status === 'done') continue;
|
||||
const category = stats.classifyErrorCategory(r.error);
|
||||
if (o.category && o.category !== category) continue;
|
||||
if (o.hoster && o.hoster !== r.hoster) continue;
|
||||
byCategory[category] = (byCategory[category] || 0) + 1;
|
||||
errors.push({
|
||||
ts: batch.timestamp || null,
|
||||
fileName: file.name || file.fileName || '',
|
||||
hoster: r.hoster || '',
|
||||
accountId: r.accountId || undefined,
|
||||
category,
|
||||
error: support.redactLogText(String(r.error || ''), secrets)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { errors, byCategory };
|
||||
}
|
||||
|
||||
function listErrors(args) {
|
||||
const a = args || {};
|
||||
const cfg = loadConfig();
|
||||
const { errors, byCategory } = _historyErrors(cfg.history, a);
|
||||
const limit = Math.min(Math.max(Number(a.limit) || 100, 1), 1000);
|
||||
const window = Number.isFinite(a.sinceMs) ? `since ${new Date(a.sinceMs).toISOString()}` : 'all history';
|
||||
return { window, total: errors.length, byCategory, errors: errors.slice(-limit) };
|
||||
}
|
||||
|
||||
function getQueueState(args) {
|
||||
const a = args || {};
|
||||
const cfg = loadConfig();
|
||||
const pending = cfg.globalSettings && cfg.globalSettings.pendingQueue;
|
||||
if (!pending || typeof pending !== 'object') {
|
||||
return { source: 'empty', stale: false, counts: {}, selectedHosters: [] };
|
||||
}
|
||||
const counts = {};
|
||||
for (const s of QUEUE_STATUSES) counts[s] = 0;
|
||||
const jobs = Array.isArray(pending.queueJobs) ? pending.queueJobs : [];
|
||||
for (const j of jobs) { if (counts[j.status] !== undefined) counts[j.status]++; }
|
||||
const result = {
|
||||
source: 'persisted',
|
||||
stale: true,
|
||||
savedAt: pending.savedAt || null,
|
||||
selectedHosters: Array.isArray(pending.selectedUploadHosters) ? pending.selectedUploadHosters : [],
|
||||
fileCount: Array.isArray(pending.selectedFiles) ? pending.selectedFiles.length : 0,
|
||||
counts
|
||||
};
|
||||
if (a.includeJobs !== false) {
|
||||
const maxJobs = Math.min(Math.max(Number(a.maxJobs) || 200, 1), 2000);
|
||||
result.jobs = _deepRedact(jobs.slice(0, maxJobs).map(j => ({
|
||||
file: j.file, fileName: j.fileName, hoster: j.hoster, status: j.status, error: j.error || null
|
||||
})));
|
||||
result.jobsTruncated = jobs.length > maxJobs;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getHistory(args) {
|
||||
const a = args || {};
|
||||
const cfg = loadConfig();
|
||||
const history = Array.isArray(cfg.history) ? cfg.history : [];
|
||||
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200);
|
||||
const perHoster = stats.summarizePerHoster(history);
|
||||
const recent = [...history].slice(-limit).reverse();
|
||||
const secrets = _secrets();
|
||||
const batches = recent.map(b => {
|
||||
const out = { timestamp: b.timestamp || null, fileCount: Array.isArray(b.files) ? b.files.length : 0 };
|
||||
if (a.includeFiles) {
|
||||
out.files = (b.files || []).map(f => ({
|
||||
name: f.name || f.fileName || '',
|
||||
results: (f.results || []).map(r => {
|
||||
const rr = { hoster: r.hoster, status: r.status };
|
||||
if (r.error) rr.error = support.redactLogText(String(r.error), secrets);
|
||||
if (a.includeUrls && r.url) rr.url = r.url;
|
||||
return rr;
|
||||
})
|
||||
}));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
return { totalBatches: history.length, returned: batches.length, perHoster, batches };
|
||||
}
|
||||
|
||||
function getRotationState() {
|
||||
const cfg = loadConfig();
|
||||
return { rotationCursors: _deepRedact(cfg.rotationCursors || {}) };
|
||||
}
|
||||
|
||||
function getHealth() {
|
||||
const cfg = loadConfig();
|
||||
const hosters = cfg.hosters && typeof cfg.hosters === 'object' ? Object.keys(cfg.hosters).filter(h => Array.isArray(cfg.hosters[h]) && cfg.hosters[h].length > 0) : [];
|
||||
return {
|
||||
reachabilityKnown: false,
|
||||
hint: 'Live hoster probing (run_health_check) is disabled in this build. Configured hosters with at least one account are listed.',
|
||||
configuredHosters: hosters
|
||||
};
|
||||
}
|
||||
|
||||
function serverHealth(args) {
|
||||
const a = args || {};
|
||||
const errorLimit = Math.min(Math.max(Number(a.errorLimit) || 20, 1), 200);
|
||||
const errArgs = Number.isFinite(a.errorSinceMs) ? { sinceMs: a.errorSinceMs, limit: errorLimit } : { limit: errorLimit };
|
||||
const errors = listErrors(errArgs);
|
||||
const queue = getQueueState({ includeJobs: false });
|
||||
const history = getHistory({ limit: 5 });
|
||||
const warnings = [];
|
||||
if (queue.source === 'persisted' && queue.stale) warnings.push('queue state is from the persisted snapshot (may lag live state; UploadManager not introspected in this build).');
|
||||
if (errors.total > 0) warnings.push(`${errors.total} non-success result(s) in the error window.`);
|
||||
return {
|
||||
server: getSystemInfo(),
|
||||
queue,
|
||||
recentBatches: history.batches,
|
||||
perHoster: history.perHoster,
|
||||
errors,
|
||||
hosters: getHealth(),
|
||||
logs: listLogs(),
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getSystemInfo, getConfigRedacted, listLogs, readLog, getAppEvents,
|
||||
listErrors, getQueueState, getHistory, getRotationState, getHealth, serverHealth,
|
||||
READABLE_LOGS
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createCollectors, READABLE_LOGS };
|
||||
@ -1,19 +1,28 @@
|
||||
const { WebSocketServer } = require('ws');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function timingSafeEqualStr(a, b) {
|
||||
const x = Buffer.from(String(a == null ? '' : a));
|
||||
const y = Buffer.from(String(b == null ? '' : b));
|
||||
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
||||
}
|
||||
|
||||
class RemoteServer {
|
||||
constructor() {
|
||||
this._wss = null;
|
||||
this._clients = new Map(); // ws -> { id, role, authenticated }
|
||||
this._config = null;
|
||||
this._failedAttempts = new Map(); // ip -> { count, blockedUntil }
|
||||
this._lastAccess = null;
|
||||
}
|
||||
|
||||
start(opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._config = opts;
|
||||
|
||||
this._wss = new WebSocketServer({ port: opts.port }, () => {
|
||||
const wssOpts = { port: opts.port };
|
||||
if (opts.host) wssOpts.host = opts.host;
|
||||
this._wss = new WebSocketServer(wssOpts, () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
@ -83,12 +92,13 @@ class RemoteServer {
|
||||
authReceived = true;
|
||||
clearTimeout(authTimeout);
|
||||
|
||||
if (msg.type === 'auth' && msg.token === this._config.token) {
|
||||
if (msg.type === 'auth' && timingSafeEqualStr(msg.token, this._config.token)) {
|
||||
client.authenticated = true;
|
||||
client.role = msg.role || 'viewer';
|
||||
client.role = this._config.diagnosticMode ? 'diagnostic' : (msg.role || 'viewer');
|
||||
this._lastAccess = Date.now();
|
||||
ws.send(JSON.stringify({ type: 'auth-ok', clientId }));
|
||||
|
||||
if (this.getClientCount() === 1) {
|
||||
if (!this._config.diagnosticMode && this.getClientCount() === 1) {
|
||||
this._config.onCreateCaptureWindow();
|
||||
}
|
||||
} else {
|
||||
@ -99,6 +109,16 @@ class RemoteServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._config.diagnosticMode) {
|
||||
if (msg.type === 'diag-request' && typeof this._config.onDiagnosticRequest === 'function') {
|
||||
this._lastAccess = Date.now();
|
||||
this._config.onDiagnosticRequest(msg, client, (payload) => {
|
||||
this.sendToClient(client.id, { type: 'diag-response', reqId: msg.reqId, ...payload });
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'offer' || msg.type === 'ice-candidate') {
|
||||
msg.clientId = client.id;
|
||||
msg.role = client.role;
|
||||
@ -112,7 +132,7 @@ class RemoteServer {
|
||||
const wasAuthenticated = client && client.authenticated;
|
||||
this._clients.delete(ws);
|
||||
|
||||
if (wasAuthenticated) {
|
||||
if (wasAuthenticated && !this._config.diagnosticMode) {
|
||||
this._config.onSignalingToCapture({
|
||||
type: 'client-disconnected',
|
||||
clientId: client.id
|
||||
@ -130,7 +150,7 @@ class RemoteServer {
|
||||
const wasAuthenticated = client && client.authenticated;
|
||||
this._clients.delete(ws);
|
||||
|
||||
if (wasAuthenticated) {
|
||||
if (wasAuthenticated && !this._config.diagnosticMode) {
|
||||
this._config.onSignalingToCapture({
|
||||
type: 'client-disconnected',
|
||||
clientId: client.id
|
||||
@ -142,6 +162,10 @@ class RemoteServer {
|
||||
});
|
||||
}
|
||||
|
||||
getLastAccess() {
|
||||
return this._lastAccess;
|
||||
}
|
||||
|
||||
sendToClient(clientId, data) {
|
||||
for (const [ws, client] of this._clients) {
|
||||
if (client.id === clientId && client.authenticated) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
const fs = require('fs');
|
||||
|
||||
const CRED_KEYS = new Set(['password', 'apiKey', 'token', 'cookie', 'sessionId']);
|
||||
const CRED_KEYS = new Set(['password', 'apiKey', 'token', 'cookie', 'sessionId', 'webhookUrl', 'diagToken']);
|
||||
const REDACTED = '<redacted>';
|
||||
|
||||
function sanitizeConfig(config) {
|
||||
@ -18,6 +18,52 @@ function sanitizeConfig(config) {
|
||||
return clone;
|
||||
}
|
||||
|
||||
function collectSecretValues(config) {
|
||||
const out = new Set();
|
||||
(function walk(o) {
|
||||
if (!o) return;
|
||||
if (Array.isArray(o)) { for (const e of o) walk(e); return; }
|
||||
if (typeof o !== 'object') return;
|
||||
for (const k of Object.keys(o)) {
|
||||
const v = o[k];
|
||||
if (CRED_KEYS.has(k) && typeof v === 'string' && v.length >= 6) out.add(v);
|
||||
else walk(v);
|
||||
}
|
||||
})(config);
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
function redactLogText(text, secrets) {
|
||||
if (typeof text !== 'string' || !text) return text;
|
||||
let out = text;
|
||||
if (Array.isArray(secrets)) {
|
||||
for (const s of secrets) {
|
||||
if (typeof s === 'string' && s.length >= 6) out = out.split(s).join(REDACTED);
|
||||
}
|
||||
}
|
||||
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|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;
|
||||
}
|
||||
|
||||
function valueScrub(value, secrets) {
|
||||
if (value == null) return value;
|
||||
const json = JSON.stringify(value);
|
||||
let scrubbed = json;
|
||||
if (Array.isArray(secrets)) {
|
||||
for (const s of secrets) {
|
||||
if (typeof s === 'string' && s.length >= 6) scrubbed = scrubbed.split(s).join(REDACTED);
|
||||
}
|
||||
}
|
||||
return JSON.parse(scrubbed);
|
||||
}
|
||||
|
||||
function collectFile(filePath, label, maxBytes) {
|
||||
if (!filePath) return `=== ${label} ===\n<no path configured>\n\n`;
|
||||
let stat;
|
||||
@ -61,4 +107,4 @@ function buildSupportBundleText({ header, sanitizedConfig, files }) {
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
module.exports = { sanitizeConfig, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED };
|
||||
module.exports = { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED };
|
||||
|
||||
207
main.js
207
main.js
@ -19,8 +19,11 @@ const { maybeRotateLogFile } = require('./lib/log-rotation');
|
||||
const { hosterLogToFileEnabled } = require('./lib/log-policy');
|
||||
const { formatUploadLogLine, parseUploadLogLine } = require('./lib/upload-log');
|
||||
const { selectOrphanTmps } = require('./lib/orphan-tmp');
|
||||
const { sanitizeConfig, buildSupportBundleText } = require('./lib/support-bundle');
|
||||
const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle');
|
||||
const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify');
|
||||
const stats = require('./lib/stats');
|
||||
const { createCollectors } = require('./lib/diagnostics-collectors');
|
||||
const { createAgent } = require('./lib/diagnostics-agent');
|
||||
|
||||
let mainWindow;
|
||||
let _lastImportPath = null;
|
||||
@ -28,6 +31,21 @@ let dropTargetWindow = null;
|
||||
let tray = null;
|
||||
const configStore = new ConfigStore(app);
|
||||
let uploadManager = null;
|
||||
let diagnosticAgent = null;
|
||||
let _diagHandler = null;
|
||||
|
||||
const _hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!_hasSingleInstanceLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
if (!mainWindow.isVisible()) mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Rotation memory that survives batch-done → new UploadManager within the
|
||||
// same app session. Without this, clicking "Retry failed" after a batch
|
||||
// ended would burn the full retry budget on accounts we already know are
|
||||
@ -1160,6 +1178,7 @@ function updateTrayTooltip(text) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (!_hasSingleInstanceLock) return;
|
||||
try {
|
||||
const _bootCfg = configStore.load();
|
||||
setLogVerbose(!!(_bootCfg.globalSettings && _bootCfg.globalSettings.logVerbose));
|
||||
@ -1209,6 +1228,12 @@ app.whenReady().then(() => {
|
||||
debugLog(`remote-server auto-start failed: ${err.message}`);
|
||||
});
|
||||
}
|
||||
const diagConfig = _remCfg.globalSettings && _remCfg.globalSettings.diagnostics;
|
||||
if (diagConfig && diagConfig.enabled) {
|
||||
startDiagnosticAgent().catch(err => {
|
||||
debugLog(`diagnostics-agent auto-start failed: ${err.message}`);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
debugLog(`remote-server auto-start failed: ${err.message}`);
|
||||
}
|
||||
@ -1251,6 +1276,7 @@ app.on('before-quit', () => {
|
||||
if (remoteServer) { remoteServer.stop(); remoteServer = null; }
|
||||
destroyCaptureWindow();
|
||||
} catch {}
|
||||
try { stopDiagnosticAgent(); } catch {}
|
||||
try { destroyDropTargetWindow(); } catch {}
|
||||
try { if (tray && !tray.isDestroyed()) { tray.destroy(); tray = null; } } catch {}
|
||||
// Flush pending log buffers synchronously so no lines are lost.
|
||||
@ -2246,7 +2272,19 @@ ipcMain.handle('get-global-settings', () => {
|
||||
return config.globalSettings || {};
|
||||
});
|
||||
|
||||
function _preserveDiagSubtree(globalSettings) {
|
||||
if (!globalSettings || typeof globalSettings !== 'object') return globalSettings;
|
||||
try {
|
||||
const cur = configStore.load();
|
||||
if (cur.globalSettings && cur.globalSettings.diagnostics) {
|
||||
globalSettings.diagnostics = cur.globalSettings.diagnostics;
|
||||
}
|
||||
} catch {}
|
||||
return globalSettings;
|
||||
}
|
||||
|
||||
ipcMain.handle('save-global-settings', async (_event, globalSettings) => {
|
||||
globalSettings = _preserveDiagSubtree(globalSettings);
|
||||
await configStore.save({ globalSettings });
|
||||
if (uploadManager) uploadManager.updateSettings(null, globalSettings);
|
||||
return true;
|
||||
@ -2287,7 +2325,9 @@ ipcMain.on('save-global-settings-sync', (event, globalSettings) => {
|
||||
const tmpPath = configStore.filePath + '.' + process.pid + '.tmp';
|
||||
try {
|
||||
const current = configStore.load();
|
||||
const _diskDiag = current.globalSettings && current.globalSettings.diagnostics;
|
||||
current.globalSettings = globalSettings;
|
||||
if (_diskDiag) current.globalSettings.diagnostics = _diskDiag;
|
||||
const data = configStore._serializeForDisk(current);
|
||||
const backupPath = configStore.filePath + '.bak';
|
||||
fs.writeFileSync(tmpPath, data, 'utf-8');
|
||||
@ -2378,6 +2418,171 @@ function generateToken() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// --- Remote Diagnostics (read-only) ---
|
||||
function _diagAppInfo() {
|
||||
return {
|
||||
name: app.getName(),
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
node: process.versions.node,
|
||||
chrome: process.versions.chrome,
|
||||
packaged: app.isPackaged,
|
||||
pid: process.pid,
|
||||
uptimeSec: Math.round(process.uptime())
|
||||
};
|
||||
}
|
||||
|
||||
function _diagSystemInfo() {
|
||||
const os = require('os');
|
||||
let disk = null;
|
||||
try {
|
||||
const sf = fs.statfsSync(app.getPath('userData'));
|
||||
disk = { freeBytes: sf.bavail * sf.bsize, totalBytes: sf.blocks * sf.bsize };
|
||||
} catch {}
|
||||
return {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osType: os.type(),
|
||||
osRelease: os.release(),
|
||||
hostname: os.hostname(),
|
||||
totalMemBytes: os.totalmem(),
|
||||
freeMemBytes: os.freemem(),
|
||||
cpuCount: (os.cpus() || []).length,
|
||||
osUptimeSec: Math.round(os.uptime()),
|
||||
disk
|
||||
};
|
||||
}
|
||||
|
||||
function _diagAgentInfo() {
|
||||
const cfg = configStore.load();
|
||||
const diag = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
|
||||
return {
|
||||
version: app.getVersion(),
|
||||
port: diag.port || 9110,
|
||||
bindAddress: diag.bindAddress || '127.0.0.1',
|
||||
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
|
||||
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
|
||||
};
|
||||
}
|
||||
|
||||
function _buildDiagnosticHandler() {
|
||||
const collectors = createCollectors({
|
||||
loadConfig: () => configStore.load(),
|
||||
getAllLogPaths,
|
||||
support: { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED },
|
||||
stats,
|
||||
appInfo: _diagAppInfo,
|
||||
systemInfo: _diagSystemInfo,
|
||||
agentInfo: _diagAgentInfo
|
||||
});
|
||||
const agent = createAgent(collectors);
|
||||
return (msg, _client, reply) => {
|
||||
let result;
|
||||
try { result = agent.handle(msg.op, msg.args); }
|
||||
catch (e) { result = { ok: false, error: String((e && e.message) || e) }; }
|
||||
reply(result);
|
||||
};
|
||||
}
|
||||
|
||||
function buildDiagnosticCode(diag, fp) {
|
||||
const os = require('os');
|
||||
const payload = { v: 1, port: diag.port || 9110, token: diag.token, label: diag.label || os.hostname() };
|
||||
if (fp) payload.fp = 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();
|
||||
const diag = config.globalSettings && config.globalSettings.diagnostics;
|
||||
if (!diag || !diag.enabled) return;
|
||||
|
||||
let token = diag.token;
|
||||
if (!token) {
|
||||
token = generateToken();
|
||||
const gs = { ...config.globalSettings, diagnostics: { ...diag, token, codeIssuedAt: Date.now() } };
|
||||
await configStore.save({ globalSettings: gs });
|
||||
}
|
||||
|
||||
if (!_diagHandler) _diagHandler = _buildDiagnosticHandler();
|
||||
const host = _safeDiagBindAddress(diag.bindAddress);
|
||||
diagnosticAgent = new RemoteServer();
|
||||
try {
|
||||
await diagnosticAgent.start({
|
||||
port: diag.port || 9110,
|
||||
host,
|
||||
token,
|
||||
diagnosticMode: true,
|
||||
onDiagnosticRequest: _diagHandler
|
||||
});
|
||||
debugLog(`diagnostics-agent started on ${host}:${diagnosticAgent.getPort()}`);
|
||||
} catch (e) {
|
||||
debugLog(`diagnostics-agent start failed: ${e.message}`);
|
||||
diagnosticAgent = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopDiagnosticAgent() {
|
||||
if (diagnosticAgent) { try { diagnosticAgent.stop(); } catch {} diagnosticAgent = null; }
|
||||
}
|
||||
|
||||
ipcMain.handle('diagnostics:get-settings', () => {
|
||||
const cfg = configStore.load();
|
||||
const diag = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
|
||||
return {
|
||||
enabled: !!diag.enabled,
|
||||
port: diag.port || 9110,
|
||||
bindAddress: diag.bindAddress || '127.0.0.1',
|
||||
label: diag.label || require('os').hostname(),
|
||||
codeIssuedAt: diag.codeIssuedAt || 0,
|
||||
code: diag.token ? buildDiagnosticCode(diag) : ''
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
|
||||
const cfg = configStore.load();
|
||||
const cur = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
|
||||
const next = {
|
||||
...cur,
|
||||
enabled: !!(incoming && incoming.enabled),
|
||||
port: (incoming && Number(incoming.port)) || cur.port || 9110,
|
||||
bindAddress: _safeDiagBindAddress((incoming && incoming.bindAddress) || cur.bindAddress),
|
||||
label: (incoming && incoming.label != null) ? String(incoming.label) : cur.label
|
||||
};
|
||||
const gs = { ...cfg.globalSettings, diagnostics: next };
|
||||
await configStore.save({ globalSettings: gs });
|
||||
await startDiagnosticAgent();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('diagnostics:regenerate', async () => {
|
||||
const cfg = configStore.load();
|
||||
const cur = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
|
||||
const next = { ...cur, token: generateToken(), codeIssuedAt: Date.now() };
|
||||
const gs = { ...cfg.globalSettings, diagnostics: next };
|
||||
await configStore.save({ globalSettings: gs });
|
||||
if (next.enabled) await startDiagnosticAgent();
|
||||
return { ok: true, code: buildDiagnosticCode(next), codeIssuedAt: next.codeIssuedAt };
|
||||
});
|
||||
|
||||
ipcMain.handle('diagnostics:status', () => {
|
||||
const cfg = configStore.load();
|
||||
const diag = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
|
||||
return {
|
||||
running: !!diagnosticAgent,
|
||||
port: diagnosticAgent ? diagnosticAgent.getPort() : (diag.port || 9110),
|
||||
bindAddress: diag.bindAddress || '127.0.0.1',
|
||||
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
|
||||
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
|
||||
};
|
||||
});
|
||||
|
||||
function createCaptureWindow() {
|
||||
if (captureWindow && !captureWindow.isDestroyed()) return;
|
||||
captureWindowReady = false;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "3.3.83",
|
||||
"version": "3.3.84",
|
||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@ -139,6 +139,12 @@ contextBridge.exposeInMainWorld('api', {
|
||||
ipcRenderer.on('remote:client-count', (_event, count) => callback(count));
|
||||
},
|
||||
|
||||
// Remote Diagnostics (read-only)
|
||||
diagnosticsGetSettings: () => ipcRenderer.invoke('diagnostics:get-settings'),
|
||||
diagnosticsSaveSettings: (settings) => ipcRenderer.invoke('diagnostics:save-settings', settings),
|
||||
diagnosticsRegenerate: () => ipcRenderer.invoke('diagnostics:regenerate'),
|
||||
diagnosticsStatus: () => ipcRenderer.invoke('diagnostics:status'),
|
||||
|
||||
// File path from drag & drop (Electron 33+ compatible)
|
||||
getPathForFile: (file) => webUtils.getPathForFile(file),
|
||||
removeAllListeners: () => {
|
||||
|
||||
107
renderer/app.js
107
renderer/app.js
@ -3005,12 +3005,13 @@ function renderSettings() {
|
||||
<button class="settings-subtab" data-subtab="automatik">Automatik</button>
|
||||
<button class="settings-subtab" data-subtab="logs">Logs & Diagnose</button>
|
||||
<button class="settings-subtab" data-subtab="remote">Fernsteuerung</button>
|
||||
<button class="settings-subtab" data-subtab="diagnose">Diagnose-Zugriff</button>
|
||||
<button class="settings-subtab" data-subtab="backup">Backup</button>
|
||||
`;
|
||||
container.appendChild(subtabBar);
|
||||
|
||||
const pages = {};
|
||||
['allgemein', 'automatik', 'logs', 'remote', 'backup'].forEach((id) => {
|
||||
['allgemein', 'automatik', 'logs', 'remote', 'diagnose', 'backup'].forEach((id) => {
|
||||
const page = document.createElement('div');
|
||||
page.className = id === 'allgemein' ? 'settings-subpage active' : 'settings-subpage';
|
||||
page.dataset.subpage = id;
|
||||
@ -3199,6 +3200,41 @@ function renderSettings() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
pages.diagnose.innerHTML = `
|
||||
<div class="settings-section-label">Diagnose-Zugriff (nur lesen) <span class="panel-status" id="diagStatusBadge">…</span></div>
|
||||
<p class="hint" style="margin:0 0 12px;padding:8px 10px;border-left:3px solid #f59e0b;background:rgba(245,158,11,0.08)">
|
||||
Erlaubt Claude <strong>nur lesenden</strong> Zugriff auf Logs, Queue-Status und sanitierte Config (Passwörter/API-Keys/Token werden maskiert). <strong>Kein Bildschirm, keine Eingabe-Steuerung.</strong> Der Verbindungs-Code ist ein Zugangsschlüssel — nur mit vertrauenswürdigen Stellen teilen; bei Verdacht „Neu" klicken. Standard-Bindung ist <code>127.0.0.1</code> (nur über SSH-/VPN-Tunnel erreichbar).
|
||||
</p>
|
||||
<div class="settings-grid-mini">
|
||||
<div class="settings-row checkbox-row">
|
||||
<label>Aktiviert</label>
|
||||
<input type="checkbox" id="diagEnabledInput">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label>Port</label>
|
||||
<input type="number" class="hs-input" id="diagPortInput" min="1024" max="65535" value="9110" style="width:100px">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<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>
|
||||
</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)">
|
||||
<button class="btn btn-xs btn-secondary" id="diagCopyCodeBtn" title="Kopieren">Kopieren</button>
|
||||
<button class="btn btn-xs btn-secondary" id="diagRegenerateBtn" title="Neu generieren (macht alte Codes ungültig)">Neu</button>
|
||||
</div>
|
||||
<div class="settings-row"><span class="hint" id="diagCodeIssued"></span></div>
|
||||
<div class="settings-section-label">Status</div>
|
||||
<div class="settings-row">
|
||||
<span id="diagConnectionStatus" style="color:#94a3b8">Prüfe…</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
pages.backup.innerHTML = `
|
||||
<p class="hint" style="margin:0 0 10px">Alle Accounts und Einstellungen exportieren oder importieren. Der Upload-Verlauf bleibt lokal und wird nicht übertragen; nach einem Import ist der Verlauf-Tab leer.</p>
|
||||
<div style="display:flex;gap:8px">
|
||||
@ -3321,6 +3357,75 @@ function renderSettings() {
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
(function wireDiagnostics() {
|
||||
const enabledEl = document.getElementById('diagEnabledInput');
|
||||
const portEl = document.getElementById('diagPortInput');
|
||||
const bindEl = document.getElementById('diagBindInput');
|
||||
const codeEl = document.getElementById('diagCodeInput');
|
||||
const issuedEl = document.getElementById('diagCodeIssued');
|
||||
const badgeEl = document.getElementById('diagStatusBadge');
|
||||
if (!enabledEl) return;
|
||||
|
||||
const fmtIssued = (ts) => {
|
||||
if (!ts) return '';
|
||||
try { return 'Code erstellt: ' + new Date(ts).toLocaleString('de-DE'); } catch { return ''; }
|
||||
};
|
||||
const applySettings = (s) => {
|
||||
if (!s) return;
|
||||
enabledEl.checked = !!s.enabled;
|
||||
portEl.value = s.port || 9110;
|
||||
bindEl.value = s.bindAddress || '127.0.0.1';
|
||||
codeEl.value = s.code || '';
|
||||
issuedEl.textContent = fmtIssued(s.codeIssuedAt);
|
||||
if (badgeEl) {
|
||||
badgeEl.textContent = s.enabled ? 'Aktiv' : 'Inaktiv';
|
||||
badgeEl.className = 'panel-status' + (s.enabled ? ' active' : '');
|
||||
}
|
||||
};
|
||||
const refreshStatus = () => {
|
||||
window.api.diagnosticsStatus().then((st) => {
|
||||
const el = document.getElementById('diagConnectionStatus');
|
||||
if (!el || !st) return;
|
||||
if (st.running) {
|
||||
const last = st.lastAccess ? new Date(st.lastAccess).toLocaleString('de-DE') : '—';
|
||||
el.textContent = `Aktiv auf ${st.bindAddress}:${st.port} — ${st.clientCount} Client(s) — Letzter Zugriff: ${last}`;
|
||||
el.style.color = '#10b981';
|
||||
} else {
|
||||
el.textContent = 'Nicht aktiv';
|
||||
el.style.color = '#94a3b8';
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
const save = async () => {
|
||||
await window.api.diagnosticsSaveSettings({
|
||||
enabled: enabledEl.checked,
|
||||
port: parseInt(portEl.value, 10) || 9110,
|
||||
bindAddress: bindEl.value
|
||||
});
|
||||
applySettings(await window.api.diagnosticsGetSettings());
|
||||
refreshStatus();
|
||||
};
|
||||
|
||||
window.api.diagnosticsGetSettings().then(applySettings).catch(() => {});
|
||||
refreshStatus();
|
||||
|
||||
enabledEl.addEventListener('change', save);
|
||||
portEl.addEventListener('change', save);
|
||||
bindEl.addEventListener('change', save);
|
||||
document.getElementById('diagCopyCodeBtn').addEventListener('click', async () => {
|
||||
if (!codeEl.value) return;
|
||||
await window.api.copyToClipboard(codeEl.value);
|
||||
const b = document.getElementById('diagCopyCodeBtn');
|
||||
b.textContent = 'Kopiert!';
|
||||
setTimeout(() => { b.textContent = 'Kopieren'; }, 1500);
|
||||
});
|
||||
document.getElementById('diagRegenerateBtn').addEventListener('click', async () => {
|
||||
const r = await window.api.diagnosticsRegenerate();
|
||||
if (r && r.code) { codeEl.value = r.code; issuedEl.textContent = fmtIssued(r.codeIssuedAt); }
|
||||
refreshStatus();
|
||||
});
|
||||
})();
|
||||
|
||||
document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport());
|
||||
document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport());
|
||||
|
||||
|
||||
51
tests/diagnostics-agent.test.js
Normal file
51
tests/diagnostics-agent.test.js
Normal file
@ -0,0 +1,51 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { createAgent } = require('../lib/diagnostics-agent');
|
||||
|
||||
function stubCollectors() {
|
||||
const calls = [];
|
||||
const mk = (name) => (a) => { calls.push([name, a]); return { name, a }; };
|
||||
return {
|
||||
calls,
|
||||
getSystemInfo: mk('getSystemInfo'),
|
||||
serverHealth: mk('serverHealth'),
|
||||
getConfigRedacted: mk('getConfigRedacted'),
|
||||
listLogs: mk('listLogs'),
|
||||
readLog: mk('readLog'),
|
||||
getAppEvents: mk('getAppEvents'),
|
||||
listErrors: mk('listErrors'),
|
||||
getQueueState: mk('getQueueState'),
|
||||
getHistory: mk('getHistory'),
|
||||
getRotationState: mk('getRotationState'),
|
||||
getHealth: mk('getHealth')
|
||||
};
|
||||
}
|
||||
|
||||
test('agent rejects unknown ops and any write/exec-shaped op', () => {
|
||||
const agent = createAgent(stubCollectors());
|
||||
for (const bad of ['delete_log', 'write_config', 'run_health_check', 'exec', 'eval', '__proto__', 'set_setting', 'restart']) {
|
||||
const r = agent.handle(bad, {});
|
||||
assert.equal(r.ok, false, `${bad} must be rejected`);
|
||||
assert.match(r.error, /unknown or non-readonly/);
|
||||
}
|
||||
});
|
||||
|
||||
test('agent maps each whitelisted op to its collector and is read-only only', () => {
|
||||
const stub = stubCollectors();
|
||||
const agent = createAgent(stub);
|
||||
assert.equal(agent.handle('server_health', { errorLimit: 5 }).ok, true);
|
||||
assert.equal(agent.handle('read_log', { name: 'debug' }).ok, true);
|
||||
assert.equal(agent.handle('tail_log', { name: 'debug' }).ok, true, 'tail_log aliases read_log');
|
||||
assert.equal(agent.handle('get_config_redacted', {}).ok, true);
|
||||
const ops = new Set(agent.ops);
|
||||
assert.ok(!ops.has('run_health_check'), 'no live probe op in this build');
|
||||
for (const op of agent.ops) assert.ok(!/write|delete|set_|exec|restart|cancel|retry/.test(op), `${op} must be read-only`);
|
||||
});
|
||||
|
||||
test('agent surfaces a collector ok:false verbatim and never throws', () => {
|
||||
const agent = createAgent({ readLog: () => ({ ok: false, error: 'unknown or non-readable log: x' }), getSystemInfo: () => { throw new Error('boom'); } });
|
||||
assert.equal(agent.handle('read_log', { name: 'x' }).ok, false);
|
||||
const thrown = agent.handle('get_system_info', {});
|
||||
assert.equal(thrown.ok, false);
|
||||
assert.match(thrown.error, /boom/);
|
||||
});
|
||||
106
tests/diagnostics-collectors.test.js
Normal file
106
tests/diagnostics-collectors.test.js
Normal file
@ -0,0 +1,106 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const support = require('../lib/support-bundle');
|
||||
const stats = require('../lib/stats');
|
||||
const { createCollectors } = require('../lib/diagnostics-collectors');
|
||||
const { createAgent } = require('../lib/diagnostics-agent');
|
||||
|
||||
function makeFixture() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-'));
|
||||
const paths = {
|
||||
fileuploader: path.join(dir, 'fileuploader.log'),
|
||||
debug: path.join(dir, 'debug.log'),
|
||||
accountRotation: path.join(dir, 'account-rotation.log'),
|
||||
doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
|
||||
crashLog: path.join(dir, 'crash.log'),
|
||||
logDir: dir
|
||||
};
|
||||
fs.writeFileSync(paths.debug, 'boot ok\nuploading file with token SECRETTOKEN123456 inline\nAuthorization: Bearer abcdef123456\n');
|
||||
fs.writeFileSync(paths.doodstreamDebug, 'api_key=LIVEKEY99999 sess=abc\n');
|
||||
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
|
||||
const config = {
|
||||
hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: 'HUNTER2SECRET' }], 'byse.sx': [{ id: 'b1', apiKey: 'BYSEKEY1234567' }] },
|
||||
hosterSettings: {},
|
||||
globalSettings: {
|
||||
webhookUrl: 'https://discord.com/api/webhooks/12345/WBHOOKSECRETTOKEN',
|
||||
diagnostics: { enabled: true, port: 9110, token: 'SECRETTOKEN123456', bindAddress: '127.0.0.1' },
|
||||
pendingQueue: { savedAt: 1, selectedUploadHosters: ['voe.sx'], selectedFiles: [{ path: 'C:/a.mkv' }], queueJobs: [{ file: 'C:/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'error', error: 'timeout' }] }
|
||||
},
|
||||
history: [{ timestamp: new Date(2026, 0, 1).toISOString(), files: [{ name: 'x.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }, { hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/x' }] }] }],
|
||||
rotationCursors: { 'voe.sx': 1 }
|
||||
};
|
||||
const collectors = createCollectors({
|
||||
loadConfig: () => JSON.parse(JSON.stringify(config)),
|
||||
getAllLogPaths: () => paths,
|
||||
support, stats,
|
||||
appInfo: () => ({ name: 'mhu', version: '9.9.9' }),
|
||||
systemInfo: () => ({ platform: 'win32', hostname: 'srv' }),
|
||||
agentInfo: () => ({ version: '9.9.9', port: 9110, clientCount: 0, lastAccess: null })
|
||||
});
|
||||
return { dir, paths, config, collectors };
|
||||
}
|
||||
|
||||
test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs the token mid-string', () => {
|
||||
const { collectors } = makeFixture();
|
||||
const out = collectors.getConfigRedacted({ section: 'all' });
|
||||
const json = JSON.stringify(out);
|
||||
assert.ok(!json.includes('HUNTER2SECRET'), 'password must be redacted');
|
||||
assert.ok(!json.includes('BYSEKEY1234567'), 'apiKey must be redacted');
|
||||
assert.ok(!json.includes('SECRETTOKEN123456'), 'diag token must be redacted');
|
||||
assert.ok(!json.includes('WBHOOKSECRETTOKEN'), 'webhook secret must be redacted');
|
||||
});
|
||||
|
||||
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
|
||||
const { collectors } = makeFixture();
|
||||
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
|
||||
assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs');
|
||||
assert.ok(!/Bearer abcdef123456/.test(dbg.content), 'pattern-scrub removes Authorization Bearer');
|
||||
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
|
||||
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
|
||||
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
|
||||
});
|
||||
|
||||
test('getQueueState flags stale=true for the persisted snapshot and counts by status', () => {
|
||||
const { collectors } = makeFixture();
|
||||
const q = collectors.getQueueState({});
|
||||
assert.equal(q.source, 'persisted');
|
||||
assert.equal(q.stale, true);
|
||||
assert.equal(q.counts.error, 1);
|
||||
});
|
||||
|
||||
test('getQueueState (includeJobs default) pattern-scrubs an opaque token in a job error that is NOT a config secret', () => {
|
||||
const config = {
|
||||
hosters: {}, hosterSettings: {},
|
||||
globalSettings: { pendingQueue: { savedAt: 1, selectedUploadHosters: [], selectedFiles: [], queueJobs: [
|
||||
{ file: 'C:/b.mkv', fileName: 'b.mkv', hoster: 'streamtape', status: 'error', error: 'upload rejected: token=OPAQUE_NONconfig_TOKEN_9988' }
|
||||
] } },
|
||||
history: [], rotationCursors: {}
|
||||
};
|
||||
const collectors = createCollectors({
|
||||
loadConfig: () => JSON.parse(JSON.stringify(config)),
|
||||
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
|
||||
support, stats,
|
||||
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||
});
|
||||
const q = collectors.getQueueState({});
|
||||
const json = JSON.stringify(q);
|
||||
assert.ok(!json.includes('OPAQUE_NONconfig_TOKEN_9988'), 'opaque token in a job error must be pattern-scrubbed even on the default includeJobs path');
|
||||
});
|
||||
|
||||
test('listErrors classifies via stats.classifyErrorCategory and redacts error text', () => {
|
||||
const { collectors } = makeFixture();
|
||||
const e = collectors.listErrors({});
|
||||
assert.equal(e.total, 1, 'only the non-done result is an error');
|
||||
assert.equal(e.byCategory['file-rejected'], 1, '"Not video file format" -> file-rejected');
|
||||
});
|
||||
|
||||
test('serverHealth assembles the one-shot hub without leaking secrets', () => {
|
||||
const { collectors } = makeFixture();
|
||||
const h = collectors.serverHealth({});
|
||||
const json = JSON.stringify(h);
|
||||
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
|
||||
assert.ok(!json.includes('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health');
|
||||
});
|
||||
72
tests/diagnostics-protocol.test.js
Normal file
72
tests/diagnostics-protocol.test.js
Normal file
@ -0,0 +1,72 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const WebSocket = require('ws');
|
||||
const RemoteServer = require('../lib/remote-server');
|
||||
|
||||
const TOKEN = 'a'.repeat(64);
|
||||
|
||||
function startAgent(onDiagnosticRequest, extra) {
|
||||
const srv = new RemoteServer();
|
||||
return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) })
|
||||
.then(() => srv);
|
||||
}
|
||||
|
||||
function connect(port) {
|
||||
return new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
function once(ws, type) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ws.on('message', (raw) => { const m = JSON.parse(raw); if (m.type === type) resolve(m); });
|
||||
ws.on('close', (code) => reject(new Error('closed ' + code)));
|
||||
ws.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('diagnostic client: auth -> diag-request -> reqId-correlated diag-response', async () => {
|
||||
const agent = await startAgent((msg, _client, reply) => {
|
||||
assert.equal(msg.op, 'server_health');
|
||||
reply({ ok: true, data: { hello: 'world', echo: msg.args } });
|
||||
});
|
||||
const port = agent.getPort();
|
||||
const ws = connect(port);
|
||||
await new Promise((r) => ws.on('open', r));
|
||||
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
|
||||
const ok = await once(ws, 'auth-ok');
|
||||
assert.ok(ok.clientId);
|
||||
ws.send(JSON.stringify({ type: 'diag-request', reqId: 'r1', op: 'server_health', args: { errorLimit: 3 } }));
|
||||
const resp = await once(ws, 'diag-response');
|
||||
assert.equal(resp.reqId, 'r1');
|
||||
assert.equal(resp.ok, true);
|
||||
assert.equal(resp.data.hello, 'world');
|
||||
assert.equal(resp.data.echo.errorLimit, 3);
|
||||
assert.equal(agent.getLastAccess() !== null, true, 'access timestamp recorded');
|
||||
ws.close(); agent.stop();
|
||||
});
|
||||
|
||||
test('a diagnostic client NEVER triggers the screen-capture window', async () => {
|
||||
let captureCreated = false;
|
||||
const agent = await startAgent(() => {}, { onCreateCaptureWindow: () => { captureCreated = true; } });
|
||||
const ws = connect(agent.getPort());
|
||||
await new Promise((r) => ws.on('open', r));
|
||||
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
|
||||
await once(ws, 'auth-ok');
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
assert.equal(captureCreated, false, 'diagnosticMode must not spawn the capture window');
|
||||
ws.close(); agent.stop();
|
||||
});
|
||||
|
||||
test('wrong token is rejected and the ip is locked out after 5 attempts', async () => {
|
||||
const agent = await startAgent(() => {});
|
||||
const port = agent.getPort();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const ws = connect(port);
|
||||
await new Promise((r) => ws.on('open', r));
|
||||
ws.send(JSON.stringify({ type: 'auth', token: 'wrong', role: 'diagnostic' }));
|
||||
await new Promise((r) => ws.on('close', r));
|
||||
}
|
||||
const ws = connect(port);
|
||||
const closeCode = await new Promise((resolve) => ws.on('close', (c) => resolve(c)));
|
||||
assert.equal(closeCode, 4003, 'locked out after 5 failed attempts');
|
||||
agent.stop();
|
||||
});
|
||||
@ -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));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user