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>
This commit is contained in:
parent
0ee874ba99
commit
0c6c502aab
@ -17,45 +17,60 @@ On the **server** (the machine running the app):
|
||||
2. Enable **"Diagnose-Zugriff"**.
|
||||
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
|
||||
yourself when you connect. Treat the code as a **secret**: anyone with the code and network reach
|
||||
to the agent can read diagnostics.
|
||||
The code carries the **host**, **port** and a one-time auth **token** (`mhu1_<base64url{v,h,p,t,n}>`).
|
||||
The bridge dials the host from the code, so you usually just hand over the code. Treat the code as a
|
||||
**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
|
||||
to expose it on a LAN/Internet interface. You reach it through a tunnel (next step).
|
||||
**Two visibility modes** (Settings → Diagnose-Zugriff → Sichtbarkeit):
|
||||
|
||||
**Transport note:** the agent speaks **plaintext `ws://` over loopback**. The token and the
|
||||
diagnostic data are *not* encrypted on the wire by the agent itself — confidentiality comes
|
||||
entirely from the **tunnel** (SSH/WireGuard) you put in front of it. The loopback bind means the
|
||||
plaintext traffic never leaves the host except inside that encrypted tunnel. (A future build may
|
||||
add `wss`/TLS with cert pinning via an `fp` field in the code; it is not active today.)
|
||||
- **Nur lokal** (default): the agent binds to `127.0.0.1`. Reach it through a tunnel (next step).
|
||||
- **Im Netzwerk**: the agent binds to `0.0.0.0` but is gated by a **fail-closed IP allowlist** — only
|
||||
source IPs/CIDRs you list may connect (loopback is always allowed), *in addition to* the token. An
|
||||
empty allowlist means loopback only. This is the mode to use with **Tailscale**: set the allowlist
|
||||
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
|
||||
server's loopback. The agent's default port is `9110`.
|
||||
The agent's default port is `9110`. Pick the path that matches your setup.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
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.
|
||||
Leave that session open. Now `127.0.0.1:9110` on **your** machine is forwarded to the server's
|
||||
loopback. The code address is `127.0.0.1` (your local end of the tunnel).
|
||||
|
||||
### WireGuard (alternative)
|
||||
### WireGuard (manual, 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.
|
||||
Bring up a WireGuard tunnel and either bind the agent to the network with the peer's WG IP in the
|
||||
allowlist, or forward loopback over the tunnel and connect to `127.0.0.1`.
|
||||
|
||||
> The agent cannot be bound directly to a LAN/Internet IP in this build (loopback is enforced),
|
||||
> so the tunnel is the **only** way to reach it remotely — and the only thing encrypting the
|
||||
> transport. Keep the tunnel (SSH/WireGuard) up for the whole session.
|
||||
> In **Nur lokal** mode the agent is unreachable except through a tunnel to loopback. In **Im
|
||||
> Netzwerk** mode the fail-closed IP allowlist (plus the token) is the access gate — only bind to
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -42,18 +42,26 @@ export function decode(code) {
|
||||
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)) {
|
||||
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');
|
||||
}
|
||||
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');
|
||||
}
|
||||
if (typeof payload.label !== 'string') {
|
||||
if (label !== undefined && typeof 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;
|
||||
return { v: 1, host: host ? String(host) : undefined, port, token, label: label !== undefined ? String(label) : undefined, fp: payload.fp, scheme };
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
} else {
|
||||
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;
|
||||
try {
|
||||
@ -148,15 +148,16 @@ async function doConnect({ code, host, port, label }) {
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e.message ?? e) };
|
||||
}
|
||||
if (!host) {
|
||||
return { ok: false, error: 'host is required when connecting with a code' };
|
||||
const effHost = host || payload.host;
|
||||
if (!effHost) {
|
||||
return { ok: false, error: 'no host in the code and none provided — pass host (e.g. the Tailscale IP/MagicDNS name)' };
|
||||
}
|
||||
target = {
|
||||
host,
|
||||
host: effHost,
|
||||
port: typeof port === 'number' ? port : payload.port,
|
||||
token: payload.token,
|
||||
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',
|
||||
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: {
|
||||
code: z.string().optional(),
|
||||
host: z.string().optional(),
|
||||
|
||||
@ -2,23 +2,25 @@ 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);
|
||||
test('decode reads the host-bearing short-key format (h/p/t/n/s/fp)', () => {
|
||||
const code = encode({ v: 1, h: '100.64.0.5', p: 9110, t: 'deadbeefcafe1234', n: 'prod-3', s: 'wss', fp: 'AB:CD:EF:01' });
|
||||
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://)', () => {
|
||||
const payload = { v: 1, port: 9110, token: 'token-abc', label: 'localhost' };
|
||||
const code = encode(payload);
|
||||
assert.deepEqual(decode(code), payload);
|
||||
test('decode is tolerant of the legacy long-key format (port/token/label, no host -> ws)', () => {
|
||||
const d = decode(encode({ v: 1, port: 9110, token: 'token-abc', label: 'localhost' }));
|
||||
assert.equal(d.host, undefined);
|
||||
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', () => {
|
||||
|
||||
@ -110,7 +110,7 @@ function rejected(r) { return r.threw || r.isError || (r.data && r.data.ok === f
|
||||
},
|
||||
});
|
||||
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] });
|
||||
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();
|
||||
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' });
|
||||
check('connect_server ok', r.data && r.data.ok === true, r.text.slice(0, 120));
|
||||
let r = await callJSON('connect_server', { code });
|
||||
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');
|
||||
|
||||
r = await callJSON('current_server');
|
||||
|
||||
@ -107,6 +107,9 @@ const DEFAULTS = {
|
||||
token: '',
|
||||
label: '',
|
||||
codeIssuedAt: 0,
|
||||
bindMode: 'local',
|
||||
publicHost: '',
|
||||
allowlist: [],
|
||||
bindAddress: '127.0.0.1'
|
||||
}
|
||||
},
|
||||
|
||||
50
lib/ip-allowlist.js
Normal file
50
lib/ip-allowlist.js
Normal 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 };
|
||||
@ -1,5 +1,6 @@
|
||||
const { WebSocketServer } = require('ws');
|
||||
const crypto = require('crypto');
|
||||
const { evaluateClientAllowed } = require('./ip-allowlist');
|
||||
|
||||
function timingSafeEqualStr(a, b) {
|
||||
const x = Buffer.from(String(a == null ? '' : a));
|
||||
@ -70,6 +71,11 @@ class RemoteServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(this._config.allowlist) && !evaluateClientAllowed(ip, this._config.allowlist)) {
|
||||
ws.close(4005, 'Client IP not allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = crypto.randomUUID();
|
||||
this._clients.set(ws, { id: clientId, role: null, authenticated: false });
|
||||
|
||||
|
||||
65
main.js
65
main.js
@ -2459,7 +2459,7 @@ function _diagAgentInfo() {
|
||||
return {
|
||||
version: app.getVersion(),
|
||||
port: diag.port || 9110,
|
||||
bindAddress: diag.bindAddress || '127.0.0.1',
|
||||
bindAddress: _diagBindHost(diag),
|
||||
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
|
||||
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
|
||||
};
|
||||
@ -2484,19 +2484,42 @@ function _buildDiagnosticHandler() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildDiagnosticCode(diag, fp) {
|
||||
function _getSuggestedRemoteHosts() {
|
||||
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');
|
||||
const hosts = [];
|
||||
try {
|
||||
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) {
|
||||
const a = String(addr || '').trim();
|
||||
if (a === '127.0.0.1' || a === '::1') return a;
|
||||
function _diagAllowlist(diag) {
|
||||
return Array.isArray(diag && diag.allowlist) ? diag.allowlist.map((x) => String(x).trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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() {
|
||||
if (diagnosticAgent) { try { diagnosticAgent.stop(); } catch {} diagnosticAgent = null; }
|
||||
const config = configStore.load();
|
||||
@ -2511,7 +2534,8 @@ async function startDiagnosticAgent() {
|
||||
}
|
||||
|
||||
if (!_diagHandler) _diagHandler = _buildDiagnosticHandler();
|
||||
const host = _safeDiagBindAddress(diag.bindAddress);
|
||||
const host = _diagBindHost(diag);
|
||||
const allowlist = _diagAllowlist(diag);
|
||||
diagnosticAgent = new RemoteServer();
|
||||
try {
|
||||
await diagnosticAgent.start({
|
||||
@ -2519,9 +2543,10 @@ async function startDiagnosticAgent() {
|
||||
host,
|
||||
token,
|
||||
diagnosticMode: true,
|
||||
allowlist,
|
||||
onDiagnosticRequest: _diagHandler
|
||||
});
|
||||
debugLog(`diagnostics-agent started on ${host}:${diagnosticAgent.getPort()}`);
|
||||
debugLog(`diagnostics-agent started on ${host}:${diagnosticAgent.getPort()} (allowlist ${allowlist.length})`);
|
||||
} catch (e) {
|
||||
debugLog(`diagnostics-agent start failed: ${e.message}`);
|
||||
diagnosticAgent = null;
|
||||
@ -2538,7 +2563,11 @@ ipcMain.handle('diagnostics:get-settings', () => {
|
||||
return {
|
||||
enabled: !!diag.enabled,
|
||||
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(),
|
||||
codeIssuedAt: diag.codeIssuedAt || 0,
|
||||
code: diag.token ? buildDiagnosticCode(diag) : ''
|
||||
@ -2552,13 +2581,18 @@ ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
|
||||
...cur,
|
||||
enabled: !!(incoming && incoming.enabled),
|
||||
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
|
||||
};
|
||||
next.bindAddress = _diagBindHost(next);
|
||||
const gs = { ...cfg.globalSettings, diagnostics: next };
|
||||
await configStore.save({ globalSettings: gs });
|
||||
await startDiagnosticAgent();
|
||||
return { ok: true };
|
||||
return { ok: true, bindAddress: next.bindAddress, allowlistCount: next.allowlist.length };
|
||||
});
|
||||
|
||||
ipcMain.handle('diagnostics:regenerate', async () => {
|
||||
@ -2577,7 +2611,10 @@ ipcMain.handle('diagnostics:status', () => {
|
||||
return {
|
||||
running: !!diagnosticAgent,
|
||||
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,
|
||||
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
|
||||
};
|
||||
|
||||
@ -3216,12 +3216,25 @@ function renderSettings() {
|
||||
<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>
|
||||
<label>Sichtbarkeit</label>
|
||||
<select class="hs-input" id="diagBindModeInput" style="width:auto">
|
||||
<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>
|
||||
</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 203.0.113.5"></textarea>
|
||||
</div>
|
||||
<div class="settings-row"><span class="hint" id="diagBindHint"></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)">
|
||||
@ -3360,7 +3373,13 @@ function renderSettings() {
|
||||
(function wireDiagnostics() {
|
||||
const enabledEl = document.getElementById('diagEnabledInput');
|
||||
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 issuedEl = document.getElementById('diagCodeIssued');
|
||||
const badgeEl = document.getElementById('diagStatusBadge');
|
||||
@ -3370,13 +3389,40 @@ function renderSettings() {
|
||||
if (!ts) 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) => {
|
||||
if (!s) return;
|
||||
enabledEl.checked = !!s.enabled;
|
||||
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 || '';
|
||||
issuedEl.textContent = fmtIssued(s.codeIssuedAt);
|
||||
renderModeUi(lastSuggested);
|
||||
if (badgeEl) {
|
||||
badgeEl.textContent = s.enabled ? 'Aktiv' : 'Inaktiv';
|
||||
badgeEl.className = 'panel-status' + (s.enabled ? ' active' : '');
|
||||
@ -3388,7 +3434,8 @@ function renderSettings() {
|
||||
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}`;
|
||||
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';
|
||||
} else {
|
||||
el.textContent = 'Nicht aktiv';
|
||||
@ -3397,10 +3444,17 @@ function renderSettings() {
|
||||
}).catch(() => {});
|
||||
};
|
||||
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({
|
||||
enabled: enabledEl.checked,
|
||||
port: parseInt(portEl.value, 10) || 9110,
|
||||
bindAddress: bindEl.value
|
||||
bindMode: modeEl.value,
|
||||
publicHost: publicHostEl.value.trim(),
|
||||
allowlist
|
||||
});
|
||||
applySettings(await window.api.diagnosticsGetSettings());
|
||||
refreshStatus();
|
||||
@ -3411,7 +3465,9 @@ function renderSettings() {
|
||||
|
||||
enabledEl.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 () => {
|
||||
if (!codeEl.value) return;
|
||||
await window.api.copyToClipboard(codeEl.value);
|
||||
|
||||
@ -56,6 +56,28 @@ test('a diagnostic client NEVER triggers the screen-capture window', async () =>
|
||||
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();
|
||||
|
||||
52
tests/ip-allowlist.test.js
Normal file
52
tests/ip-allowlist.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user