Multi-Hoster-Upload/tests/diagnostics-protocol.test.js
Administrator 0c6c502aab feat(diagnostics): network bind + fail-closed IP allowlist + host-in-code (Tailscale, like rd-diagnostics-mcp)
Matches the Real-Debrid-Downloader's rd-diagnostics-mcp model so the read-only
diagnostics agent is reachable over Tailscale (or any private tunnel) the same way
the downloader is, instead of requiring an SSH local-forward.

- lib/ip-allowlist.js (NEW): fail-closed IP allowlist — normalizeIp strips
  ::ffff:, loopback is always allowed, an empty allowlist accepts loopback ONLY
  (fail-closed), exact IP + CIDR (incl. the Tailscale CGNAT range 100.64.0.0/10) +
  wildcard rules. The real socket peer IP is the authority (never a forwarded header).
- remote-server.js: rejects non-allowlisted peers at connection (close 4005). Opt-in
  via config.allowlist (the existing remote-control server, which passes none, is
  unaffected). Loopback always passes, so local + SSH-forward use keeps working.
- Two bind modes (config diagnostics.bindMode): "local" -> 127.0.0.1 (default),
  "network" -> 0.0.0.0 but ONLY when a non-empty allowlist is set (else it stays
  loopback, fail-closed). The allowlist + token gate access; the tunnel
  (Tailscale/WireGuard) is the confidentiality layer (transport is still plaintext ws://).
- The connection code now carries the host: mhu1_<base64url{v,h,p,t,n,fp?,s?}>. The
  gateway decode is tolerant of the legacy {port,token,label} keys; connect_server
  takes the host from the code (host arg is an optional override). Proven end-to-end:
  the integration harness now connects with NO host arg and resolves it from the code.
- Renderer: Sichtbarkeit selector (local/network), public-host input with
  suggested-host chips (os.networkInterfaces — the Tailscale IP shows up there),
  allowlist textarea (network mode), and network-requires-allowlist validation.
- main.js: bindMode->host, getSuggestedRemoteHosts, host-in-code, allowlist plumbed
  into startDiagnosticAgent + the diagnostics IPC (get/save/status).
- docs: rewritten for Tailscale (set the allowlist to your tailnet, put the Tailscale
  IP/MagicDNS in the code address — no SSH forward needed).

This supersedes the v3.3.85 hard loopback-lock with the downloader's allowlist model.
Tests: lib/ip-allowlist (8) + remote-server allowlist wiring/loopback (2) + gateway
decode (host short-key + legacy tolerance). 393 app tests + 9 gateway tests + e2e +
host-in-code integration + adversarial all green; lint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:32:29 +02:00

95 lines
4.0 KiB
JavaScript

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('allowlist gate (wiring): a non-loopback peer is closed 4005 when not allowlisted (fail-closed)', () => {
const srv = new RemoteServer();
const closeCodeFor = (remoteAddress, allowlist) => {
srv._config = { allowlist, token: TOKEN, diagnosticMode: true };
let closed = null;
srv._handleConnection({ close: (c) => { closed = c; }, on: () => {} }, { socket: { remoteAddress } });
return closed;
};
assert.equal(closeCodeFor('100.64.0.9', []), 4005, 'empty allowlist => non-loopback rejected (fail-closed)');
assert.equal(closeCodeFor('203.0.113.5', ['100.64.0.0/10']), 4005, 'peer outside the allowlist CIDR rejected');
});
test('a loopback diagnostic client connects even with a non-matching allowlist (loopback is always allowed)', async () => {
const agent = await startAgent(() => {}, { allowlist: ['100.64.0.0/10'] });
const ws = connect(agent.getPort());
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.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();
});