diff --git a/docs/remote-diagnostics-setup.md b/docs/remote-diagnostics-setup.md index 4efcce9..7a63d07 100644 --- a/docs/remote-diagnostics-setup.md +++ b/docs/remote-diagnostics-setup.md @@ -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_`. -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_`). +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 `: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. --- diff --git a/gateway/code.js b/gateway/code.js index 3d7149a..8ce5289 100644 --- a/gateway/code.js +++ b/gateway/code.js @@ -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 }; } diff --git a/gateway/index.js b/gateway/index.js index 6d09f06..0e51ae1 100644 --- a/gateway/index.js +++ b/gateway/index.js @@ -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(), diff --git a/gateway/test/code.test.js b/gateway/test/code.test.js index 705803c..acb5f74 100644 --- a/gateway/test/code.test.js +++ b/gateway/test/code.test.js @@ -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', () => { diff --git a/gateway/verify/integration-mcp.mjs b/gateway/verify/integration-mcp.mjs index 5b4ea0f..4a74e1e 100644 --- a/gateway/verify/integration-mcp.mjs +++ b/gateway/verify/integration-mcp.mjs @@ -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'); diff --git a/lib/config-store.js b/lib/config-store.js index 55b7dbd..97a7698 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -107,6 +107,9 @@ const DEFAULTS = { token: '', label: '', codeIssuedAt: 0, + bindMode: 'local', + publicHost: '', + allowlist: [], bindAddress: '127.0.0.1' } }, diff --git a/lib/ip-allowlist.js b/lib/ip-allowlist.js new file mode 100644 index 0000000..50ec344 --- /dev/null +++ b/lib/ip-allowlist.js @@ -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 }; diff --git a/lib/remote-server.js b/lib/remote-server.js index 26af0b9..7e246b5 100644 --- a/lib/remote-server.js +++ b/lib/remote-server.js @@ -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 }); diff --git a/main.js b/main.js index 154fe46..b8b4994 100644 --- a/main.js +++ b/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 }; diff --git a/renderer/app.js b/renderer/app.js index 5ce122a..4216db7 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3216,12 +3216,25 @@ function renderSettings() {
- - + +
-
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 127.0.0.1.
+
+ + +
+ + +
@@ -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 0.0.0.0. Nur IPs/CIDRs aus der Allowlist dürfen verbinden (Loopback immer) — zusätzlich zum Token. Über Tailscale: trage deinen Tailnet-Bereich ein (z.B. 100.64.0.0/10) und die Tailscale-IP/MagicDNS oben als Code-Adresse. Transport ist plaintext über den Tunnel — Tailscale/WireGuard verschlüsselt.' + : 'Bindet nur an 127.0.0.1. 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 = 'Netzwerkmodus braucht mindestens eine IP/CIDR in der Allowlist — sonst bleibt es fail-closed auf Loopback.'; } + 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); diff --git a/tests/diagnostics-protocol.test.js b/tests/diagnostics-protocol.test.js index fe88ee9..6b69f6f 100644 --- a/tests/diagnostics-protocol.test.js +++ b/tests/diagnostics-protocol.test.js @@ -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(); diff --git a/tests/ip-allowlist.test.js b/tests/ip-allowlist.test.js new file mode 100644 index 0000000..e0142fc --- /dev/null +++ b/tests/ip-allowlist.test.js @@ -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); +});