Compare commits

..

4 Commits

Author SHA1 Message Date
Administrator
3f8854693a release: v3.3.86 2026-06-19 19:39:15 +02:00
Administrator
b25b51840d test(diagnostics): live network-bind path — allowlisted non-loopback peer connects over a real 0.0.0.0 socket
Closes the one link the unit/wiring tests covered only by composition: binds 0.0.0.0,
allowlists a real LAN IPv4, and asserts auth-ok over a real socket (the Tailscale path).
Skips when no non-internal IPv4 interface exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:38:25 +02:00
Administrator
8dff455062 docs(todo): Tailscale network-bind + fail-closed allowlist plan + review
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:35:38 +02:00
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
14 changed files with 386 additions and 158 deletions

View File

@ -17,45 +17,60 @@ On the **server** (the machine running the app):
2. Enable **"Diagnose-Zugriff"**. 2. Enable **"Diagnose-Zugriff"**.
3. Copy the connection **code**. It looks like `mhu1_<base64url...>`. 3. Copy the connection **code**. It looks like `mhu1_<base64url...>`.
The code carries a one-time auth **token**. It does **not** carry a host — you supply the host The code carries the **host**, **port** and a one-time auth **token** (`mhu1_<base64url{v,h,p,t,n}>`).
yourself when you connect. Treat the code as a **secret**: anyone with the code and network reach The bridge dials the host from the code, so you usually just hand over the code. Treat the code as a
to the agent can read diagnostics. **secret**: anyone with the code and network reach to the agent can read diagnostics.
The agent **always binds to `127.0.0.1`** (loopback only) — this is enforced; there is no setting **Two visibility modes** (Settings → Diagnose-Zugriff → Sichtbarkeit):
to expose it on a LAN/Internet interface. You reach it through a tunnel (next step).
**Transport note:** the agent speaks **plaintext `ws://` over loopback**. The token and the - **Nur lokal** (default): the agent binds to `127.0.0.1`. Reach it through a tunnel (next step).
diagnostic data are *not* encrypted on the wire by the agent itself — confidentiality comes - **Im Netzwerk**: the agent binds to `0.0.0.0` but is gated by a **fail-closed IP allowlist** — only
entirely from the **tunnel** (SSH/WireGuard) you put in front of it. The loopback bind means the source IPs/CIDRs you list may connect (loopback is always allowed), *in addition to* the token. An
plaintext traffic never leaves the host except inside that encrypted tunnel. (A future build may empty allowlist means loopback only. This is the mode to use with **Tailscale**: set the allowlist
add `wss`/TLS with cert pinning via an `fp` field in the code; it is not active today.) to your tailnet (e.g. `100.64.0.0/10`) and put the server's Tailscale IP / MagicDNS name into the
code address — then the bridge connects straight over the tailnet, no SSH forward needed.
**Transport note:** the agent speaks **plaintext `ws://`**. The token and the diagnostic data are
*not* encrypted on the wire by the agent itself — confidentiality comes from the **tunnel**
(Tailscale/WireGuard/SSH) you reach it through. In network mode the IP allowlist + token are the
access gate; **only bind to the network behind a private tunnel you trust** (a tailnet, a VPN, or a
trusted LAN). (A future build may add `wss`/TLS with cert pinning via an `fp` field in the code; it
is not active today.)
--- ---
## 2. Reach the agent over a tunnel (SSH local port-forward or WireGuard) ## 2. Reach the agent over a tunnel
Because the agent binds to `127.0.0.1` on the server, open a tunnel from your machine to the The agent's default port is `9110`. Pick the path that matches your setup.
server's loopback. The agent's default port is `9110`.
### SSH local port-forward (recommended) ### Tailscale (recommended for many servers)
Put every server and your gateway machine on the same tailnet. On each server, set **Sichtbarkeit =
Im Netzwerk**, allowlist your tailnet (`100.64.0.0/10`, or the specific Tailscale IPs you'll connect
from), and set the **code address** to that server's Tailscale IP or MagicDNS name. The bridge then
connects straight to `<tailscale-name>:9110` — WireGuard (Tailscale) encrypts the transport, and the
allowlist + token gate access. No SSH forward, no per-session tunnel command.
### SSH local port-forward (keep the agent loopback-only)
With **Sichtbarkeit = Nur lokal**:
``` ```
ssh -L 9110:127.0.0.1:9110 user@server 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 Leave that session open. Now `127.0.0.1:9110` on **your** machine is forwarded to the server's
`127.0.0.1:9110` on the **server**. You connect Claude to **`127.0.0.1`** (your local end of the loopback. The code address is `127.0.0.1` (your local end of the tunnel).
tunnel), not the server's public IP.
### WireGuard (alternative) ### WireGuard (manual, alternative)
Bring up a WireGuard tunnel to the server, then connect to the server's WireGuard address (or, if Bring up a WireGuard tunnel and either bind the agent to the network with the peer's WG IP in the
you forward loopback over the tunnel, `127.0.0.1`). Use whichever address resolves to the agent's allowlist, or forward loopback over the tunnel and connect to `127.0.0.1`.
`127.0.0.1:9110` on the server.
> The agent cannot be bound directly to a LAN/Internet IP in this build (loopback is enforced), > In **Nur lokal** mode the agent is unreachable except through a tunnel to loopback. In **Im
> so the tunnel is the **only** way to reach it remotely — and the only thing encrypting the > Netzwerk** mode the fail-closed IP allowlist (plus the token) is the access gate — only bind to
> transport. Keep the tunnel (SSH/WireGuard) up for the whole session. > the network behind a tunnel/VPN you trust (a tailnet, WireGuard, or a trusted LAN). The transport
> is plaintext; the tunnel is what encrypts it.
--- ---

View File

@ -42,18 +42,26 @@ export function decode(code) {
if (payload.v !== 1) { if (payload.v !== 1) {
throw new Error(`Invalid code: unsupported version (expected v=1, got ${payload.v})`); throw new Error(`Invalid code: unsupported version (expected v=1, got ${payload.v})`);
} }
if (typeof payload.port !== 'number' || !Number.isFinite(payload.port)) { const host = payload.h !== undefined ? payload.h : payload.host;
const port = payload.p !== undefined ? payload.p : payload.port;
const token = payload.t !== undefined ? payload.t : payload.token;
const label = payload.n !== undefined ? payload.n : payload.label;
const scheme = payload.s === 'wss' ? 'wss' : 'ws';
if (host !== undefined && typeof host !== 'string') {
throw new Error('Invalid code: "host" must be a string when present');
}
if (typeof port !== 'number' || !Number.isFinite(port)) {
throw new Error('Invalid code: "port" must be a number'); throw new Error('Invalid code: "port" must be a number');
} }
if (typeof payload.token !== 'string' || payload.token.length === 0) { if (typeof token !== 'string' || token.length === 0) {
throw new Error('Invalid code: "token" must be a non-empty string'); throw new Error('Invalid code: "token" must be a non-empty string');
} }
if (typeof payload.label !== 'string') { if (label !== undefined && typeof label !== 'string') {
throw new Error('Invalid code: "label" must be a string'); throw new Error('Invalid code: "label" must be a string');
} }
if (payload.fp !== undefined && typeof payload.fp !== 'string') { if (payload.fp !== undefined && typeof payload.fp !== 'string') {
throw new Error('Invalid code: "fp" must be a string when present'); throw new Error('Invalid code: "fp" must be a string when present');
} }
return payload; return { v: 1, host: host ? String(host) : undefined, port, token, label: label !== undefined ? String(label) : undefined, fp: payload.fp, scheme };
} }

View File

@ -140,7 +140,7 @@ async function doConnect({ code, host, port, label }) {
target = { host: e.host, port: e.port, token: e.token, fp: e.fp, label: e.label }; target = { host: e.host, port: e.port, token: e.token, fp: e.fp, label: e.label };
} else { } else {
if (!code) { if (!code) {
return { ok: false, error: 'provide a known label, or a code plus host' }; return { ok: false, error: 'provide a known label, or a code (the host is taken from the code; pass host only to override)' };
} }
let payload; let payload;
try { try {
@ -148,15 +148,16 @@ async function doConnect({ code, host, port, label }) {
} catch (e) { } catch (e) {
return { ok: false, error: String(e.message ?? e) }; return { ok: false, error: String(e.message ?? e) };
} }
if (!host) { const effHost = host || payload.host;
return { ok: false, error: 'host is required when connecting with a code' }; if (!effHost) {
return { ok: false, error: 'no host in the code and none provided — pass host (e.g. the Tailscale IP/MagicDNS name)' };
} }
target = { target = {
host, host: effHost,
port: typeof port === 'number' ? port : payload.port, port: typeof port === 'number' ? port : payload.port,
token: payload.token, token: payload.token,
fp: payload.fp, fp: payload.fp,
label: label || payload.label, label: label || payload.label || effHost,
}; };
} }
@ -224,7 +225,7 @@ export function buildServer() {
{ {
title: 'Connect to a diagnostic server', title: 'Connect to a diagnostic server',
description: 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.', 'Connect to a remote diagnostic agent. Use label for a known server, or a code for a new one — the host (e.g. a Tailscale IP/MagicDNS name) is taken from the code. Pass host only to override what the code carries.',
inputSchema: { inputSchema: {
code: z.string().optional(), code: z.string().optional(),
host: z.string().optional(), host: z.string().optional(),

View File

@ -2,23 +2,25 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { encode, decode } from '../code.js'; import { encode, decode } from '../code.js';
test('decode(encode(x)) round-trips a full payload', () => { test('decode reads the host-bearing short-key format (h/p/t/n/s/fp)', () => {
const payload = { const code = encode({ v: 1, h: '100.64.0.5', p: 9110, t: 'deadbeefcafe1234', n: 'prod-3', s: 'wss', fp: 'AB:CD:EF:01' });
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.ok(code.startsWith('mhu1_'));
assert.deepEqual(decode(code), payload); const d = decode(code);
assert.equal(d.host, '100.64.0.5');
assert.equal(d.port, 9110);
assert.equal(d.token, 'deadbeefcafe1234');
assert.equal(d.label, 'prod-3');
assert.equal(d.scheme, 'wss');
assert.equal(d.fp, 'AB:CD:EF:01');
}); });
test('decode(encode(x)) round-trips a payload without fp (ws://)', () => { test('decode is tolerant of the legacy long-key format (port/token/label, no host -> ws)', () => {
const payload = { v: 1, port: 9110, token: 'token-abc', label: 'localhost' }; const d = decode(encode({ v: 1, port: 9110, token: 'token-abc', label: 'localhost' }));
const code = encode(payload); assert.equal(d.host, undefined);
assert.deepEqual(decode(code), payload); assert.equal(d.port, 9110);
assert.equal(d.token, 'token-abc');
assert.equal(d.label, 'localhost');
assert.equal(d.scheme, 'ws');
}); });
test('decode rejects a string without the mhu1_ prefix', () => { test('decode rejects a string without the mhu1_ prefix', () => {

View File

@ -110,7 +110,7 @@ function rejected(r) { return r.threw || r.isError || (r.data && r.data.ok === f
}, },
}); });
const port = srv.getPort(); const port = srv.getPort();
const code = encode({ v: 1, port, token: TOKEN, label: 'integration' }); const code = encode({ v: 1, h: '127.0.0.1', p: port, t: TOKEN, n: 'integration' });
transport = new StdioClientTransport({ command: process.execPath, args: [indexPath] }); transport = new StdioClientTransport({ command: process.execPath, args: [indexPath] });
client = new Client({ name: 'mhu-int-test', version: '1.0.0' }); client = new Client({ name: 'mhu-int-test', version: '1.0.0' });
@ -121,8 +121,9 @@ function rejected(r) { return r.threw || r.isError || (r.data && r.data.ok === f
const expected = ['connect_server', 'current_server', 'disconnect_server', 'get_app_events', 'get_config_redacted', 'get_history', 'get_queue_state', 'get_rotation_state', 'get_system_info', 'list_errors', 'list_logs', 'list_servers', 'read_log', 'server_health'].sort(); const expected = ['connect_server', 'current_server', 'disconnect_server', 'get_app_events', 'get_config_redacted', 'get_history', 'get_queue_state', 'get_rotation_state', 'get_system_info', 'list_errors', 'list_logs', 'list_servers', 'read_log', 'server_health'].sort();
check('listTools returns all 14 tools', JSON.stringify(names) === JSON.stringify(expected), names.join(',')); check('listTools returns all 14 tools', JSON.stringify(names) === JSON.stringify(expected), names.join(','));
let r = await callJSON('connect_server', { code, host: '127.0.0.1' }); let r = await callJSON('connect_server', { code });
check('connect_server ok', r.data && r.data.ok === true, r.text.slice(0, 120)); check('connect_server ok (host taken from the code, no host arg)', r.data && r.data.ok === true, r.text.slice(0, 120));
check('connect_server resolved host from code', r.data && r.data.server && r.data.server.host === '127.0.0.1');
check('connect_server reports version 3.3.84', r.data && r.data.server && r.data.server.version === '3.3.84'); check('connect_server reports version 3.3.84', r.data && r.data.server && r.data.server.version === '3.3.84');
r = await callJSON('current_server'); r = await callJSON('current_server');

View File

@ -107,6 +107,9 @@ const DEFAULTS = {
token: '', token: '',
label: '', label: '',
codeIssuedAt: 0, codeIssuedAt: 0,
bindMode: 'local',
publicHost: '',
allowlist: [],
bindAddress: '127.0.0.1' bindAddress: '127.0.0.1'
} }
}, },

50
lib/ip-allowlist.js Normal file
View File

@ -0,0 +1,50 @@
function normalizeIp(ip) {
return String(ip || '').trim().replace(/^::ffff:/i, '').toLowerCase();
}
function isLoopbackIp(ip) {
const c = normalizeIp(ip);
return c === '' || c === '::1' || c === 'localhost' || /^127\./.test(c);
}
function ipv4ToInt(ip) {
const parts = String(ip).split('.');
if (parts.length !== 4) return null;
let n = 0;
for (const p of parts) {
if (!/^\d{1,3}$/.test(p)) return null;
const v = Number(p);
if (v < 0 || v > 255) return null;
n = (n << 8) + v;
}
return n >>> 0;
}
function matchIpRule(clientIp, rule) {
const client = normalizeIp(clientIp);
const r = String(rule || '').trim().toLowerCase();
if (!r) return false;
if (r === '*' || r === '0.0.0.0/0') return true;
if (r === client) return true;
const slash = r.indexOf('/');
if (slash > 0) {
const baseInt = ipv4ToInt(r.slice(0, slash));
const clientInt = ipv4ToInt(client);
const bits = Number(r.slice(slash + 1));
if (baseInt === null || clientInt === null || !Number.isInteger(bits) || bits < 0 || bits > 32) return false;
if (bits === 0) return true;
const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0;
return (clientInt & mask) === (baseInt & mask);
}
return false;
}
function evaluateClientAllowed(clientIp, rules) {
const client = normalizeIp(clientIp);
if (isLoopbackIp(client)) return true;
const list = Array.isArray(rules) ? rules : [];
if (list.length === 0) return false;
return list.some((rule) => matchIpRule(client, rule));
}
module.exports = { normalizeIp, isLoopbackIp, ipv4ToInt, matchIpRule, evaluateClientAllowed };

View File

@ -1,5 +1,6 @@
const { WebSocketServer } = require('ws'); const { WebSocketServer } = require('ws');
const crypto = require('crypto'); const crypto = require('crypto');
const { evaluateClientAllowed } = require('./ip-allowlist');
function timingSafeEqualStr(a, b) { function timingSafeEqualStr(a, b) {
const x = Buffer.from(String(a == null ? '' : a)); const x = Buffer.from(String(a == null ? '' : a));
@ -70,6 +71,11 @@ class RemoteServer {
return; return;
} }
if (Array.isArray(this._config.allowlist) && !evaluateClientAllowed(ip, this._config.allowlist)) {
ws.close(4005, 'Client IP not allowed');
return;
}
const clientId = crypto.randomUUID(); const clientId = crypto.randomUUID();
this._clients.set(ws, { id: clientId, role: null, authenticated: false }); this._clients.set(ws, { id: clientId, role: null, authenticated: false });

65
main.js
View File

@ -2459,7 +2459,7 @@ function _diagAgentInfo() {
return { return {
version: app.getVersion(), version: app.getVersion(),
port: diag.port || 9110, port: diag.port || 9110,
bindAddress: diag.bindAddress || '127.0.0.1', bindAddress: _diagBindHost(diag),
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0, clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
}; };
@ -2484,19 +2484,42 @@ function _buildDiagnosticHandler() {
}; };
} }
function buildDiagnosticCode(diag, fp) { function _getSuggestedRemoteHosts() {
const os = require('os'); const os = require('os');
const payload = { v: 1, port: diag.port || 9110, token: diag.token, label: diag.label || os.hostname() }; const hosts = [];
if (fp) payload.fp = fp; try {
return 'mhu1_' + Buffer.from(JSON.stringify(payload)).toString('base64url'); for (const entry of Object.values(os.networkInterfaces())) {
for (const net of (entry || [])) {
if (net && net.family === 'IPv4' && !net.internal && net.address) hosts.push(net.address);
}
}
} catch {}
return [...new Set(hosts)];
} }
function _safeDiagBindAddress(addr) { function _diagAllowlist(diag) {
const a = String(addr || '').trim(); return Array.isArray(diag && diag.allowlist) ? diag.allowlist.map((x) => String(x).trim()).filter(Boolean) : [];
if (a === '127.0.0.1' || a === '::1') return a; }
function _diagBindHost(diag) {
const mode = (diag && diag.bindMode) || 'local';
if (mode === 'network' && _diagAllowlist(diag).length > 0) return '0.0.0.0';
return '127.0.0.1'; return '127.0.0.1';
} }
function _diagPublicHost(diag) {
const explicit = String((diag && diag.publicHost) || '').trim();
if (explicit) return explicit;
if (_diagBindHost(diag) === '127.0.0.1') return '127.0.0.1';
return _getSuggestedRemoteHosts()[0] || '127.0.0.1';
}
function buildDiagnosticCode(diag, fp) {
const payload = { v: 1, h: _diagPublicHost(diag), p: diag.port || 9110, t: diag.token, n: diag.label || require('os').hostname() };
if (fp) { payload.fp = fp; payload.s = 'wss'; }
return 'mhu1_' + Buffer.from(JSON.stringify(payload)).toString('base64url');
}
async function startDiagnosticAgent() { async function startDiagnosticAgent() {
if (diagnosticAgent) { try { diagnosticAgent.stop(); } catch {} diagnosticAgent = null; } if (diagnosticAgent) { try { diagnosticAgent.stop(); } catch {} diagnosticAgent = null; }
const config = configStore.load(); const config = configStore.load();
@ -2511,7 +2534,8 @@ async function startDiagnosticAgent() {
} }
if (!_diagHandler) _diagHandler = _buildDiagnosticHandler(); if (!_diagHandler) _diagHandler = _buildDiagnosticHandler();
const host = _safeDiagBindAddress(diag.bindAddress); const host = _diagBindHost(diag);
const allowlist = _diagAllowlist(diag);
diagnosticAgent = new RemoteServer(); diagnosticAgent = new RemoteServer();
try { try {
await diagnosticAgent.start({ await diagnosticAgent.start({
@ -2519,9 +2543,10 @@ async function startDiagnosticAgent() {
host, host,
token, token,
diagnosticMode: true, diagnosticMode: true,
allowlist,
onDiagnosticRequest: _diagHandler onDiagnosticRequest: _diagHandler
}); });
debugLog(`diagnostics-agent started on ${host}:${diagnosticAgent.getPort()}`); debugLog(`diagnostics-agent started on ${host}:${diagnosticAgent.getPort()} (allowlist ${allowlist.length})`);
} catch (e) { } catch (e) {
debugLog(`diagnostics-agent start failed: ${e.message}`); debugLog(`diagnostics-agent start failed: ${e.message}`);
diagnosticAgent = null; diagnosticAgent = null;
@ -2538,7 +2563,11 @@ ipcMain.handle('diagnostics:get-settings', () => {
return { return {
enabled: !!diag.enabled, enabled: !!diag.enabled,
port: diag.port || 9110, port: diag.port || 9110,
bindAddress: diag.bindAddress || '127.0.0.1', bindMode: diag.bindMode === 'network' ? 'network' : 'local',
bindAddress: _diagBindHost(diag),
publicHost: diag.publicHost || '',
allowlist: _diagAllowlist(diag),
suggestedHosts: _getSuggestedRemoteHosts(),
label: diag.label || require('os').hostname(), label: diag.label || require('os').hostname(),
codeIssuedAt: diag.codeIssuedAt || 0, codeIssuedAt: diag.codeIssuedAt || 0,
code: diag.token ? buildDiagnosticCode(diag) : '' code: diag.token ? buildDiagnosticCode(diag) : ''
@ -2552,13 +2581,18 @@ ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
...cur, ...cur,
enabled: !!(incoming && incoming.enabled), enabled: !!(incoming && incoming.enabled),
port: (incoming && Number(incoming.port)) || cur.port || 9110, port: (incoming && Number(incoming.port)) || cur.port || 9110,
bindAddress: _safeDiagBindAddress((incoming && incoming.bindAddress) || cur.bindAddress), bindMode: (incoming && incoming.bindMode === 'network') ? 'network' : 'local',
publicHost: (incoming && incoming.publicHost != null) ? String(incoming.publicHost).trim() : (cur.publicHost || ''),
allowlist: (incoming && Array.isArray(incoming.allowlist))
? incoming.allowlist.map((x) => String(x).trim()).filter(Boolean)
: _diagAllowlist(cur),
label: (incoming && incoming.label != null) ? String(incoming.label) : cur.label label: (incoming && incoming.label != null) ? String(incoming.label) : cur.label
}; };
next.bindAddress = _diagBindHost(next);
const gs = { ...cfg.globalSettings, diagnostics: next }; const gs = { ...cfg.globalSettings, diagnostics: next };
await configStore.save({ globalSettings: gs }); await configStore.save({ globalSettings: gs });
await startDiagnosticAgent(); await startDiagnosticAgent();
return { ok: true }; return { ok: true, bindAddress: next.bindAddress, allowlistCount: next.allowlist.length };
}); });
ipcMain.handle('diagnostics:regenerate', async () => { ipcMain.handle('diagnostics:regenerate', async () => {
@ -2577,7 +2611,10 @@ ipcMain.handle('diagnostics:status', () => {
return { return {
running: !!diagnosticAgent, running: !!diagnosticAgent,
port: diagnosticAgent ? diagnosticAgent.getPort() : (diag.port || 9110), port: diagnosticAgent ? diagnosticAgent.getPort() : (diag.port || 9110),
bindAddress: diag.bindAddress || '127.0.0.1', bindMode: diag.bindMode === 'network' ? 'network' : 'local',
bindAddress: _diagBindHost(diag),
publicHost: _diagPublicHost(diag),
allowlistCount: _diagAllowlist(diag).length,
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0, clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "3.3.85", "version": "3.3.86",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@ -3216,12 +3216,25 @@ function renderSettings() {
<input type="number" class="hs-input" id="diagPortInput" min="1024" max="65535" value="9110" style="width:100px"> <input type="number" class="hs-input" id="diagPortInput" min="1024" max="65535" value="9110" style="width:100px">
</div> </div>
<div class="settings-row"> <div class="settings-row">
<label>Bind-Adresse</label> <label>Sichtbarkeit</label>
<select class="hs-input" id="diagBindInput" style="width:auto"> <select class="hs-input" id="diagBindModeInput" style="width:auto">
<option value="127.0.0.1">Nur lokal (Tunnel/VPN) empfohlen</option> <option value="local">Nur lokal (127.0.0.1) Tunnel/VPN</option>
<option value="network">Im Netzwerk (0.0.0.0) Allowlist nötig</option>
</select> </select>
</div> </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>Adresse für den Code</label>
<input type="text" class="hs-input" id="diagPublicHostInput" placeholder="127.0.0.1 oder Tunnel-/Tailscale-Adresse" style="flex:1">
</div>
<div class="settings-row" id="diagSuggestRow" style="display:none">
<label></label>
<div id="diagSuggestChips" style="display:flex;gap:6px;flex-wrap:wrap"></div>
</div>
<div class="settings-row" id="diagAllowlistRow" style="display:none;align-items:flex-start">
<label>Allowlist (IP/CIDR, eine pro Zeile)</label>
<textarea class="hs-input" id="diagAllowlistInput" rows="3" style="flex:1;font-family:monospace" placeholder="100.64.0.0/10&#10;203.0.113.5"></textarea>
</div>
<div class="settings-row"><span class="hint" id="diagBindHint"></span></div>
<div class="settings-row"> <div class="settings-row">
<label>Verbindungs-Code</label> <label>Verbindungs-Code</label>
<input type="text" class="key-input" id="diagCodeInput" value="" readonly style="flex:1" placeholder="(aktivieren zum Erzeugen)"> <input type="text" class="key-input" id="diagCodeInput" value="" readonly style="flex:1" placeholder="(aktivieren zum Erzeugen)">
@ -3360,7 +3373,13 @@ function renderSettings() {
(function wireDiagnostics() { (function wireDiagnostics() {
const enabledEl = document.getElementById('diagEnabledInput'); const enabledEl = document.getElementById('diagEnabledInput');
const portEl = document.getElementById('diagPortInput'); const portEl = document.getElementById('diagPortInput');
const bindEl = document.getElementById('diagBindInput'); const modeEl = document.getElementById('diagBindModeInput');
const publicHostEl = document.getElementById('diagPublicHostInput');
const allowlistEl = document.getElementById('diagAllowlistInput');
const allowlistRow = document.getElementById('diagAllowlistRow');
const suggestRow = document.getElementById('diagSuggestRow');
const suggestChips = document.getElementById('diagSuggestChips');
const bindHintEl = document.getElementById('diagBindHint');
const codeEl = document.getElementById('diagCodeInput'); const codeEl = document.getElementById('diagCodeInput');
const issuedEl = document.getElementById('diagCodeIssued'); const issuedEl = document.getElementById('diagCodeIssued');
const badgeEl = document.getElementById('diagStatusBadge'); const badgeEl = document.getElementById('diagStatusBadge');
@ -3370,13 +3389,40 @@ function renderSettings() {
if (!ts) return ''; if (!ts) return '';
try { return 'Code erstellt: ' + new Date(ts).toLocaleString('de-DE'); } catch { return ''; } try { return 'Code erstellt: ' + new Date(ts).toLocaleString('de-DE'); } catch { return ''; }
}; };
const parseAllowlist = () => allowlistEl.value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const renderModeUi = (suggestedHosts) => {
const network = modeEl.value === 'network';
allowlistRow.style.display = network ? '' : 'none';
bindHintEl.innerHTML = network
? 'Bindet an <code>0.0.0.0</code>. Nur IPs/CIDRs aus der Allowlist dürfen verbinden (Loopback immer) — zusätzlich zum Token. Über Tailscale: trage deinen Tailnet-Bereich ein (z.B. <code>100.64.0.0/10</code>) und die Tailscale-IP/MagicDNS oben als Code-Adresse. Transport ist plaintext über den Tunnel — Tailscale/WireGuard verschlüsselt.'
: 'Bindet nur an <code>127.0.0.1</code>. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH) — die sicherste Variante.';
const hosts = Array.isArray(suggestedHosts) ? suggestedHosts : [];
if (hosts.length) {
suggestRow.style.display = '';
suggestChips.innerHTML = '';
for (const h of hosts) {
const b = document.createElement('button');
b.className = 'btn btn-xs btn-secondary';
b.textContent = h;
b.addEventListener('click', () => { publicHostEl.value = h; save(); });
suggestChips.appendChild(b);
}
} else {
suggestRow.style.display = 'none';
}
};
let lastSuggested = [];
const applySettings = (s) => { const applySettings = (s) => {
if (!s) return; if (!s) return;
enabledEl.checked = !!s.enabled; enabledEl.checked = !!s.enabled;
portEl.value = s.port || 9110; portEl.value = s.port || 9110;
bindEl.value = s.bindAddress || '127.0.0.1'; modeEl.value = s.bindMode === 'network' ? 'network' : 'local';
publicHostEl.value = s.publicHost || '';
allowlistEl.value = Array.isArray(s.allowlist) ? s.allowlist.join('\n') : '';
lastSuggested = Array.isArray(s.suggestedHosts) ? s.suggestedHosts : [];
codeEl.value = s.code || ''; codeEl.value = s.code || '';
issuedEl.textContent = fmtIssued(s.codeIssuedAt); issuedEl.textContent = fmtIssued(s.codeIssuedAt);
renderModeUi(lastSuggested);
if (badgeEl) { if (badgeEl) {
badgeEl.textContent = s.enabled ? 'Aktiv' : 'Inaktiv'; badgeEl.textContent = s.enabled ? 'Aktiv' : 'Inaktiv';
badgeEl.className = 'panel-status' + (s.enabled ? ' active' : ''); badgeEl.className = 'panel-status' + (s.enabled ? ' active' : '');
@ -3388,7 +3434,8 @@ function renderSettings() {
if (!el || !st) return; if (!el || !st) return;
if (st.running) { if (st.running) {
const last = st.lastAccess ? new Date(st.lastAccess).toLocaleString('de-DE') : '—'; 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}`; const scope = st.bindMode === 'network' ? `Netzwerk (Allowlist: ${st.allowlistCount})` : 'nur lokal';
el.textContent = `Aktiv auf ${st.bindAddress}:${st.port} (${scope}) — ${st.clientCount} Client(s) — Letzter Zugriff: ${last}`;
el.style.color = '#10b981'; el.style.color = '#10b981';
} else { } else {
el.textContent = 'Nicht aktiv'; el.textContent = 'Nicht aktiv';
@ -3397,10 +3444,17 @@ function renderSettings() {
}).catch(() => {}); }).catch(() => {});
}; };
const save = async () => { const save = async () => {
const allowlist = parseAllowlist();
if (enabledEl.checked && modeEl.value === 'network' && allowlist.length === 0) {
if (bindHintEl) { bindHintEl.innerHTML = '<span style="color:#f59e0b">Netzwerkmodus braucht mindestens eine IP/CIDR in der Allowlist — sonst bleibt es fail-closed auf Loopback.</span>'; }
return;
}
await window.api.diagnosticsSaveSettings({ await window.api.diagnosticsSaveSettings({
enabled: enabledEl.checked, enabled: enabledEl.checked,
port: parseInt(portEl.value, 10) || 9110, port: parseInt(portEl.value, 10) || 9110,
bindAddress: bindEl.value bindMode: modeEl.value,
publicHost: publicHostEl.value.trim(),
allowlist
}); });
applySettings(await window.api.diagnosticsGetSettings()); applySettings(await window.api.diagnosticsGetSettings());
refreshStatus(); refreshStatus();
@ -3411,7 +3465,9 @@ function renderSettings() {
enabledEl.addEventListener('change', save); enabledEl.addEventListener('change', save);
portEl.addEventListener('change', save); portEl.addEventListener('change', save);
bindEl.addEventListener('change', save); modeEl.addEventListener('change', () => { renderModeUi(lastSuggested); save(); });
publicHostEl.addEventListener('change', save);
allowlistEl.addEventListener('change', save);
document.getElementById('diagCopyCodeBtn').addEventListener('click', async () => { document.getElementById('diagCopyCodeBtn').addEventListener('click', async () => {
if (!codeEl.value) return; if (!codeEl.value) return;
await window.api.copyToClipboard(codeEl.value); await window.api.copyToClipboard(codeEl.value);

View File

@ -1,87 +1,36 @@
# Queue-Persistenz Bug: fertige Dateien tauchen nach Neustart wieder auf # Tailscale / network-bind diagnostics — match the downloader (rd-diagnostics-mcp)
## Symptom Goal: make the MHU read-only diagnostics reachable like the Real-Debrid-Downloader does over
User: 300 Dateien, 100 übrig, Programm schließen + öffnen → manchmal sind bereits Tailscale — host embedded in the connection code, two bind modes (local / network), and a
fertig hochgeladene Dateien wieder in der Liste. fail-closed IP allowlist as the access gate. No Tailscale auto-detection (the downloader has none);
Tailscale is just one of the offered interface IPs reached over the tunnel, gated by the allowlist.
## Root Cause (verifiziert im Code) ## Plan
- **RC-1 (Persist-Starvation, code-confirmed):** `persistQueueStateSoon()` setzt bei - [ ] lib/ip-allowlist.js — fail-closed allowlist (normalizeIp/::ffff:, isLoopback, ipv4ToInt, matchIpRule exact+CIDR+wildcard, evaluateClientAllowed: loopback always, empty=loopback-only). + unit tests.
jedem Progress-Event den Timer per `clearTimeout` zurück; Delay während Upload war - [ ] remote-server.js — accept config.allowlist; reject non-allowlisted peers at connection (close 4005). + protocol test.
10000ms. Progress-Events feuern öfter als alle 10s → Timer feuert NIE während eines - [ ] config-store.js — diagnostics subtree: bindMode ('local'), publicHost (''), allowlist ([]).
aktiven Uploads. Der Disk-Snapshot bleibt auf dem Stand VOR Upload-Start stehen - [ ] main.js — bindMode->host (local=127.0.0.1, network=0.0.0.0, network requires non-empty allowlist); buildDiagnosticCode with host (h); getSuggestedRemoteHosts (os.networkInterfaces); pass allowlist; IPC save-settings/status.
(alle Jobs `preview`). - [ ] gateway/code.js — decode h/p/t/n/fp/s (tolerant of old port/token/label). + test.
- **RC-2 (unzuverlässiger Close-Flush):** beforeunload-Sync-Flush existiert - [ ] gateway/index.js — connect_server takes host from the code; host arg optional override.
(app.js:4605) und fängt den sauberen Close ab. Bei hartem Kill / Crash / OS-Kill - [ ] renderer/app.js — bind-mode selector, publicHost input + suggested-host chips, allowlist textarea (network), network-requires-allowlist validation.
läuft er nicht → der stale Snapshot bleibt liegen. - [ ] docs/remote-diagnostics-setup.md — network mode + allowlist + Tailscale (set allowlist to your tailnet, e.g. 100.64.0.0/10).
- **RC-3 (Dedup-Asymmetrie):** `_autoDeduplicateFromLog` droppt beim Start nur Jobs - [ ] Tests: ip-allowlist unit, remote-server allowlist protocol, gateway decode, integration (network bind + allowlist), adversarial fail-closed (empty allowlist rejects non-loopback).
mit Status `done`. Die Ghosts aus dem stale Snapshot stehen aber als `preview` da - [ ] Release v3.3.86 (gitea + github mirror).
→ werden NICHT gedroppt → fertige Dateien erscheinen erneut.
## Fix (mechanismus-unabhängig, vom Kern auf) ## Review (done)
- [x] **FIX A — Timestamp-gated Dedup (Kern-Fix, durable):** Beim Start jeden restored All steps implemented and verified. lib/ip-allowlist.js (fail-closed, ::ffff:, CIDR incl.
Job droppen, dessen file+hoster im Log mit `ts >= floor(savedAt)` steht — egal ob 100.64.0.0/10) + 8 unit tests. remote-server.js rejects non-allowlisted peers (close 4005),
`preview` oder `done`. Fängt Ghosts auch nach hartem Kill (hängt vom Log ab, nicht opt-in via config.allowlist (existing remote-control unaffected) + 2 protocol tests (fail-closed
vom Snapshot). `lib/queue-dedup.js` additiver 3. Param `savedAt`; `buildPersistedQueueState` wiring + loopback-always-allowed). Code now carries the host (mhu1_{v,h,p,t,n,fp?,s?}); gateway
stempelt `savedAt`; `restoreQueueStateFromConfig` merkt `_restoredSnapshotSavedAt`; decode is tolerant of the legacy long keys; connect_server takes the host from the code (host arg
Log-Zeile → `ts` geparst; `_autoDeduplicateFromLog` reicht savedAt durch. optional override) — proven end-to-end by the integration harness connecting with NO host arg.
- [x] **FIX B — Throttle mit max-wait:** `lib/throttle-timer.js` (neu). Upload: delay 500 Renderer: bind-mode selector + public-host input + suggested-host chips + allowlist textarea +
+ maxWait 20000 → Snapshot alle ~20s statt nie. Idle: reine Debounce. Fallback-Shim network-requires-allowlist validation. Docs rewritten for Tailscale (set allowlist to the tailnet,
honoriert maxWait (kein stilles Starvation-Reintro). put the Tailscale IP/MagicDNS in the code address). 393 app tests + 9 gateway tests + e2e +
- [x] **FIX C — Close-Write-Härtung:** `save-global-settings-sync` renameSync-Retry bei integration + adversarial all green, lint 0 errors.
EBUSY/EPERM/EACCES + pid-unique tmp + tmp-cleanup. Startup-Sweep `_sweepOrphanConfigTmps`
räumt verwaiste `<config>.<pid>.tmp` toter PIDs (gegen Orphan-Akkumulation).
- [x] **Seam-Extraktion (Advisor #2):** `lib/upload-log.js` (neu) — `formatUploadLogLine`
+ `parseUploadLogLine` aus main.js gezogen; Test fährt den ECHTEN Writer→Reader→Gate-
Vertrag (kein Mirror) → fängt künftige Format-/Epoch-Brüche.
## Tests ## Security model shift
- [x] `tests/throttle-timer.test.js`: Starvation ohne maxWait → 0 Fires; mit maxWait → v3.3.85 hard-locked loopback. This change replaces that with the downloader's model: network bind
periodische Fires; last-write-wins (distinct fn); flushSync/cancel. (0.0.0.0) is allowed ONLY with a non-empty fail-closed IP allowlist (empty => loopback only). The
- [x] `tests/queue-dedup.test.js`: ts>=savedAt→DROP; ts<savedAtKEEP; same-secondDROP; allowlist (real socket peer, ::ffff: normalized, CIDR) + token are the gate; the tunnel
max-ts; Multi-Hoster Teilabschluss (reale Bug-Form); ohne savedAt/ohne ts→Legacy. (Tailscale/WireGuard) is the confidentiality layer. Plaintext ws:// — document the trust boundary.
- [x] `tests/upload-log.test.js`: realer Writer→Reader-Roundtrip + Seam-Drop/Keep.
- [x] 334/334 grün, ESLint clean, Smoke-Boot identisch zu Baseline (kein Regress).
## Review
- **Adversariale Multi-Agent-Review (4 Dimensionen, 15 Findings):** 14 refuted (meist
"ist korrekt"-Bestätigungen, Kommentar-Drift, Test-Härtungs-Vorschläge). 1 confirmed
(LOW): pid-unique tmp konnte bei Hard-Kill zwischen write und rename verwaisen → mit
Startup-Sweep behoben. Stale-Kommentare (queue-dedup Header + _autoDeduplicateFromLog)
auf die Zwei-Regel-Logik korrigiert.
- **Was bewiesen ist:** Komponenten-Logik (Unit-Tests inkl. realer Format-Seam),
Code-getraceter Wiring-Pfad, adversariale Gegenprüfung. Der Fix ist
MECHANISMUS-UNABHÄNGIG: greift egal ob der stale Snapshot von Starvation, einem
Mid-Upload-Close-Race ODER einem Hard-Kill kommt.
- **Ehrliche Einschränkung:** KEIN Live-Repro mit echtem byse-Key (Key unter anderem
Windows-Profil verschlüsselt, nicht entschlüsselbar). Symptom tritt nur auf bei
Close WÄHREND aktivem Upload oder Hard-Kill — ein sauberer Idle-Close war schon
vorher korrekt.
## Runde 2 — "noch intensiver" (v3.3.81)
- **Echte ausführbare Tests** für bisher nur logisch abgedeckte Pfade: `tests/orphan-tmp.test.js`
(Sweep-Entscheidung, extrahiert nach `lib/orphan-tmp.js`), config-store `pendingQueue`+`savedAt`
Roundtrip, `tests/queue-persistence-scenario.test.js` (exakte 300/100-Bug-Form + multi-hoster),
`tests/queue-dedup-property.test.js` (3000+500 Fuzz-Iterationen gegen die formale Invariante).
- **Breite adversariale Bug-Jagd** übers GANZE Subsystem: lief tooling-bedingt teils kaputt
(bug-analyzer ohne File-Tools, Socket-Fehler) → 3 unverifizierte Hypothesen SELBST am Code
geprüft:
- #2 (gedroppte done-Jobs reappear via buildQueuePreview) → REFUTED: `_completedUploadKeys`
(full path) == buildQueuePreview-Key; nach Gate räumt `syncSelectedFilesFromQueue` selectedFiles.
- #3 (done-File bleibt in selectedFiles → re-preview) → **REAL (Advisor-Catch) & gefixt.** Meine
erste Abweisung war zu schnell: `syncSelectedFilesFromQueue` läuft NICHT beim Mid-Upload-Close.
Bei `removeFromQueueOnDone=ON` werden fertige Jobs aus queueJobs entfernt, bleiben aber in
selectedFiles; `updateUploadView`→`buildQueuePreview` (Startup, Zeile 990) re-materialisiert sie
als Preview-Ghost NACH dem Gate → sticky. Fix: `completedSelectionKeys` (queue-dedup.js) seedet
beim Start `_completedUploadKeys` (full-path, log-basiert/hard-kill-durabel, gleiche Ambiguity-
Guard) → buildQueuePreview überspringt fertige (file|hoster)-Paare. Nur relevant bei
removeFromQueueOnDone=ON (Default OFF).
- #1 (basename-Kollision droppt PENDING Datei = Lost Work) → **REAL & gefixt.** FIX A's ts-Regel
keyt auf basename, Restore-Collapse auf full path → zwei gleichnamige Dateien aus verschiedenen
Ordnern an denselben Hoster: die geloggte droppte fälschlich auch die andere PENDING. **Ambiguity-
Guard** in queue-dedup.js: ts-Regel wird unterdrückt, wenn ein basename|hoster-Key auf mehrere
DISTINKTE Pfade zeigt (done-Regel unberührt). Fail-safe: schlimmstenfalls überlebt ein
sichtbarer Ghost, NIE stiller Datenverlust.
- **Un-gehuntete Bereiche selbst abgeklopft:** DST/Clock-Skew → Fehler nur in SICHERER Richtung
(Ghost bleibt, kein Lost Work), inhärente Grenze von Sekunden-Lokalzeit-Logs. Log-Discovery
readdir-Filter `startsWith(base)&&endsWith(ext)` fängt single/daily/session — keine verpassten
Log-Files. 353/353 grün, ESLint clean, 3× Suite ohne Flake.

View File

@ -1,10 +1,20 @@
const { test } = require('node:test'); const { test } = require('node:test');
const assert = require('node:assert'); const assert = require('node:assert');
const os = require('os');
const WebSocket = require('ws'); const WebSocket = require('ws');
const RemoteServer = require('../lib/remote-server'); const RemoteServer = require('../lib/remote-server');
const TOKEN = 'a'.repeat(64); const TOKEN = 'a'.repeat(64);
function firstLanIpv4() {
for (const entry of Object.values(os.networkInterfaces())) {
for (const net of (entry || [])) {
if (net && net.family === 'IPv4' && !net.internal && net.address) return net.address;
}
}
return null;
}
function startAgent(onDiagnosticRequest, extra) { function startAgent(onDiagnosticRequest, extra) {
const srv = new RemoteServer(); const srv = new RemoteServer();
return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) }) return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) })
@ -56,6 +66,44 @@ test('a diagnostic client NEVER triggers the screen-capture window', async () =>
ws.close(); agent.stop(); 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('network bind (0.0.0.0): an allowlisted non-loopback peer connects over a real socket (the Tailscale path)', async (t) => {
const lan = firstLanIpv4();
if (!lan) { t.skip('no non-internal IPv4 interface available'); return; }
const agent = await startAgent(() => {}, { host: '0.0.0.0', allowlist: [lan] });
const port = agent.getPort();
const ws = new WebSocket(`ws://${lan}:${port}`);
try {
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
const ok = await once(ws, 'auth-ok');
assert.ok(ok.clientId, 'allowlisted LAN peer authed over the 0.0.0.0 bind');
} finally {
ws.close(); agent.stop();
}
});
test('wrong token is rejected and the ip is locked out after 5 attempts', async () => { test('wrong token is rejected and the ip is locked out after 5 attempts', async () => {
const agent = await startAgent(() => {}); const agent = await startAgent(() => {});
const port = agent.getPort(); const port = agent.getPort();

View File

@ -0,0 +1,52 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { normalizeIp, isLoopbackIp, matchIpRule, evaluateClientAllowed } = require('../lib/ip-allowlist');
test('normalizeIp strips ::ffff: and lowercases', () => {
assert.equal(normalizeIp('::ffff:100.64.0.5'), '100.64.0.5');
assert.equal(normalizeIp('::FFFF:127.0.0.1'), '127.0.0.1');
assert.equal(normalizeIp(' 100.64.0.5 '), '100.64.0.5');
});
test('loopback is always allowed, even with a non-matching allowlist', () => {
for (const ip of ['127.0.0.1', '::1', '::ffff:127.0.0.1', '', 'localhost', '127.5.5.5']) {
assert.equal(evaluateClientAllowed(ip, ['203.0.113.5']), true, `${ip} loopback`);
}
});
test('fail-closed: empty allowlist rejects every non-loopback peer', () => {
for (const ip of ['100.64.0.5', '203.0.113.5', '10.0.0.2', '::ffff:192.168.1.9']) {
assert.equal(evaluateClientAllowed(ip, []), false, `${ip} must be rejected with empty allowlist`);
}
});
test('exact IP allow + reject', () => {
assert.equal(evaluateClientAllowed('203.0.113.5', ['203.0.113.5']), true);
assert.equal(evaluateClientAllowed('203.0.113.6', ['203.0.113.5']), false);
});
test('CIDR matching incl. the Tailscale CGNAT range 100.64.0.0/10', () => {
assert.equal(evaluateClientAllowed('100.64.0.5', ['100.64.0.0/10']), true);
assert.equal(evaluateClientAllowed('100.127.255.254', ['100.64.0.0/10']), true);
assert.equal(evaluateClientAllowed('100.128.0.1', ['100.64.0.0/10']), false, 'just outside the /10');
assert.equal(evaluateClientAllowed('::ffff:100.64.0.5', ['100.64.0.0/10']), true, 'mapped v4 in CIDR');
assert.equal(evaluateClientAllowed('10.0.0.5', ['10.0.0.0/24']), true);
assert.equal(evaluateClientAllowed('10.0.1.5', ['10.0.0.0/24']), false);
});
test('wildcard rules allow everything', () => {
assert.equal(evaluateClientAllowed('8.8.8.8', ['*']), true);
assert.equal(evaluateClientAllowed('8.8.8.8', ['0.0.0.0/0']), true);
});
test('matchIpRule rejects malformed rules and out-of-range octets', () => {
assert.equal(matchIpRule('1.2.3.4', 'not-an-ip'), false);
assert.equal(matchIpRule('1.2.3.4', '1.2.3.0/33'), false);
assert.equal(matchIpRule('1.2.3.999', '1.2.3.0/24'), false);
});
test('isLoopbackIp recognizes loopback forms', () => {
assert.equal(isLoopbackIp('127.0.0.1'), true);
assert.equal(isLoopbackIp('::1'), true);
assert.equal(isLoopbackIp('100.64.0.1'), false);
});