Compare commits
No commits in common. "3f8854693a50847ca19a5f5f5bb2b5067c831ddd" and "0ee874ba99d3aa2508d2b2d7d87c4175c4f52552" have entirely different histories.
3f8854693a
...
0ee874ba99
@ -17,60 +17,45 @@ 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 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 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.
|
||||
|
||||
**Two visibility modes** (Settings → Diagnose-Zugriff → Sichtbarkeit):
|
||||
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).
|
||||
|
||||
- **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.)
|
||||
**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.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Reach the agent over a tunnel
|
||||
## 2. Reach the agent over a tunnel (SSH local port-forward or WireGuard)
|
||||
|
||||
The agent's default port is `9110`. Pick the path that matches your setup.
|
||||
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`.
|
||||
|
||||
### 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 local port-forward (recommended)
|
||||
|
||||
```
|
||||
ssh -L 9110:127.0.0.1:9110 user@server
|
||||
```
|
||||
|
||||
Leave that session open. Now `127.0.0.1:9110` on **your** machine is forwarded to the server's
|
||||
loopback. The code address is `127.0.0.1` (your local end of the tunnel).
|
||||
Leave that session open. Now `127.0.0.1:9110` on **your** machine is forwarded to
|
||||
`127.0.0.1:9110` on the **server**. You connect Claude to **`127.0.0.1`** (your local end of the
|
||||
tunnel), not the server's public IP.
|
||||
|
||||
### WireGuard (manual, alternative)
|
||||
### WireGuard (alternative)
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
> 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.
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -42,26 +42,18 @@ export function decode(code) {
|
||||
if (payload.v !== 1) {
|
||||
throw new Error(`Invalid code: unsupported version (expected v=1, got ${payload.v})`);
|
||||
}
|
||||
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)) {
|
||||
if (typeof payload.port !== 'number' || !Number.isFinite(payload.port)) {
|
||||
throw new Error('Invalid code: "port" must be a number');
|
||||
}
|
||||
if (typeof token !== 'string' || token.length === 0) {
|
||||
if (typeof payload.token !== 'string' || payload.token.length === 0) {
|
||||
throw new Error('Invalid code: "token" must be a non-empty string');
|
||||
}
|
||||
if (label !== undefined && typeof label !== 'string') {
|
||||
if (typeof payload.label !== 'string') {
|
||||
throw new Error('Invalid code: "label" must be a string');
|
||||
}
|
||||
if (payload.fp !== undefined && typeof payload.fp !== 'string') {
|
||||
throw new Error('Invalid code: "fp" must be a string when present');
|
||||
}
|
||||
|
||||
return { v: 1, host: host ? String(host) : undefined, port, token, label: label !== undefined ? String(label) : undefined, fp: payload.fp, scheme };
|
||||
return payload;
|
||||
}
|
||||
|
||||
@ -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 (the host is taken from the code; pass host only to override)' };
|
||||
return { ok: false, error: 'provide a known label, or a code plus host' };
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
@ -148,16 +148,15 @@ async function doConnect({ code, host, port, label }) {
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e.message ?? e) };
|
||||
}
|
||||
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)' };
|
||||
if (!host) {
|
||||
return { ok: false, error: 'host is required when connecting with a code' };
|
||||
}
|
||||
target = {
|
||||
host: effHost,
|
||||
host,
|
||||
port: typeof port === 'number' ? port : payload.port,
|
||||
token: payload.token,
|
||||
fp: payload.fp,
|
||||
label: label || payload.label || effHost,
|
||||
label: label || payload.label,
|
||||
};
|
||||
}
|
||||
|
||||
@ -225,7 +224,7 @@ export function buildServer() {
|
||||
{
|
||||
title: 'Connect to a diagnostic server',
|
||||
description:
|
||||
'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.',
|
||||
'Connect to a remote diagnostic agent. Use label for a known server, or code+host for a new one. The host is always supplied by you, never taken from the code.',
|
||||
inputSchema: {
|
||||
code: z.string().optional(),
|
||||
host: z.string().optional(),
|
||||
|
||||
@ -2,25 +2,23 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { encode, decode } from '../code.js';
|
||||
|
||||
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' });
|
||||
test('decode(encode(x)) round-trips a full payload', () => {
|
||||
const payload = {
|
||||
v: 1,
|
||||
port: 9110,
|
||||
token: 'deadbeefcafe1234',
|
||||
label: 'prod-3',
|
||||
fp: 'AB:CD:EF:01:23:45:67:89',
|
||||
};
|
||||
const code = encode(payload);
|
||||
assert.ok(code.startsWith('mhu1_'));
|
||||
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');
|
||||
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(encode(x)) round-trips a payload without fp (ws://)', () => {
|
||||
const payload = { v: 1, port: 9110, token: 'token-abc', label: 'localhost' };
|
||||
const code = encode(payload);
|
||||
assert.deepEqual(decode(code), payload);
|
||||
});
|
||||
|
||||
test('decode rejects a string without the mhu1_ prefix', () => {
|
||||
|
||||
@ -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, h: '127.0.0.1', p: port, t: TOKEN, n: 'integration' });
|
||||
const code = encode({ v: 1, port, token: TOKEN, label: 'integration' });
|
||||
|
||||
transport = new StdioClientTransport({ command: process.execPath, args: [indexPath] });
|
||||
client = new Client({ name: 'mhu-int-test', version: '1.0.0' });
|
||||
@ -121,9 +121,8 @@ 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 });
|
||||
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');
|
||||
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));
|
||||
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,9 +107,6 @@ const DEFAULTS = {
|
||||
token: '',
|
||||
label: '',
|
||||
codeIssuedAt: 0,
|
||||
bindMode: 'local',
|
||||
publicHost: '',
|
||||
allowlist: [],
|
||||
bindAddress: '127.0.0.1'
|
||||
}
|
||||
},
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
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,6 +1,5 @@
|
||||
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));
|
||||
@ -71,11 +70,6 @@ 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 });
|
||||
|
||||
|
||||
69
main.js
69
main.js
@ -2459,7 +2459,7 @@ function _diagAgentInfo() {
|
||||
return {
|
||||
version: app.getVersion(),
|
||||
port: diag.port || 9110,
|
||||
bindAddress: _diagBindHost(diag),
|
||||
bindAddress: diag.bindAddress || '127.0.0.1',
|
||||
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
|
||||
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
|
||||
};
|
||||
@ -2484,42 +2484,19 @@ function _buildDiagnosticHandler() {
|
||||
};
|
||||
}
|
||||
|
||||
function _getSuggestedRemoteHosts() {
|
||||
const os = require('os');
|
||||
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 _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'; }
|
||||
const os = require('os');
|
||||
const payload = { v: 1, port: diag.port || 9110, token: diag.token, label: diag.label || os.hostname() };
|
||||
if (fp) payload.fp = fp;
|
||||
return 'mhu1_' + Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
}
|
||||
|
||||
function _safeDiagBindAddress(addr) {
|
||||
const a = String(addr || '').trim();
|
||||
if (a === '127.0.0.1' || a === '::1') return a;
|
||||
return '127.0.0.1';
|
||||
}
|
||||
|
||||
async function startDiagnosticAgent() {
|
||||
if (diagnosticAgent) { try { diagnosticAgent.stop(); } catch {} diagnosticAgent = null; }
|
||||
const config = configStore.load();
|
||||
@ -2534,8 +2511,7 @@ async function startDiagnosticAgent() {
|
||||
}
|
||||
|
||||
if (!_diagHandler) _diagHandler = _buildDiagnosticHandler();
|
||||
const host = _diagBindHost(diag);
|
||||
const allowlist = _diagAllowlist(diag);
|
||||
const host = _safeDiagBindAddress(diag.bindAddress);
|
||||
diagnosticAgent = new RemoteServer();
|
||||
try {
|
||||
await diagnosticAgent.start({
|
||||
@ -2543,10 +2519,9 @@ async function startDiagnosticAgent() {
|
||||
host,
|
||||
token,
|
||||
diagnosticMode: true,
|
||||
allowlist,
|
||||
onDiagnosticRequest: _diagHandler
|
||||
});
|
||||
debugLog(`diagnostics-agent started on ${host}:${diagnosticAgent.getPort()} (allowlist ${allowlist.length})`);
|
||||
debugLog(`diagnostics-agent started on ${host}:${diagnosticAgent.getPort()}`);
|
||||
} catch (e) {
|
||||
debugLog(`diagnostics-agent start failed: ${e.message}`);
|
||||
diagnosticAgent = null;
|
||||
@ -2563,11 +2538,7 @@ ipcMain.handle('diagnostics:get-settings', () => {
|
||||
return {
|
||||
enabled: !!diag.enabled,
|
||||
port: diag.port || 9110,
|
||||
bindMode: diag.bindMode === 'network' ? 'network' : 'local',
|
||||
bindAddress: _diagBindHost(diag),
|
||||
publicHost: diag.publicHost || '',
|
||||
allowlist: _diagAllowlist(diag),
|
||||
suggestedHosts: _getSuggestedRemoteHosts(),
|
||||
bindAddress: diag.bindAddress || '127.0.0.1',
|
||||
label: diag.label || require('os').hostname(),
|
||||
codeIssuedAt: diag.codeIssuedAt || 0,
|
||||
code: diag.token ? buildDiagnosticCode(diag) : ''
|
||||
@ -2581,18 +2552,13 @@ ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
|
||||
...cur,
|
||||
enabled: !!(incoming && incoming.enabled),
|
||||
port: (incoming && Number(incoming.port)) || cur.port || 9110,
|
||||
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),
|
||||
bindAddress: _safeDiagBindAddress((incoming && incoming.bindAddress) || cur.bindAddress),
|
||||
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, bindAddress: next.bindAddress, allowlistCount: next.allowlist.length };
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('diagnostics:regenerate', async () => {
|
||||
@ -2611,10 +2577,7 @@ ipcMain.handle('diagnostics:status', () => {
|
||||
return {
|
||||
running: !!diagnosticAgent,
|
||||
port: diagnosticAgent ? diagnosticAgent.getPort() : (diag.port || 9110),
|
||||
bindMode: diag.bindMode === 'network' ? 'network' : 'local',
|
||||
bindAddress: _diagBindHost(diag),
|
||||
publicHost: _diagPublicHost(diag),
|
||||
allowlistCount: _diagAllowlist(diag).length,
|
||||
bindAddress: diag.bindAddress || '127.0.0.1',
|
||||
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
|
||||
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "3.3.86",
|
||||
"version": "3.3.85",
|
||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@ -3216,25 +3216,12 @@ 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>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>
|
||||
<label>Bind-Adresse</label>
|
||||
<select class="hs-input" id="diagBindInput" style="width:auto">
|
||||
<option value="127.0.0.1">Nur lokal (Tunnel/VPN) — empfohlen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<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"><span class="hint">Direkte LAN-/Internet-Bindung ist deaktiviert, bis verschlüsselter Transport (wss/TLS) verfügbar ist. Zugriff aus der Ferne läuft über einen SSH- oder VPN-Tunnel zu <code>127.0.0.1</code>.</span></div>
|
||||
<div class="settings-row">
|
||||
<label>Verbindungs-Code</label>
|
||||
<input type="text" class="key-input" id="diagCodeInput" value="" readonly style="flex:1" placeholder="(aktivieren zum Erzeugen)">
|
||||
@ -3373,13 +3360,7 @@ function renderSettings() {
|
||||
(function wireDiagnostics() {
|
||||
const enabledEl = document.getElementById('diagEnabledInput');
|
||||
const portEl = document.getElementById('diagPortInput');
|
||||
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 bindEl = document.getElementById('diagBindInput');
|
||||
const codeEl = document.getElementById('diagCodeInput');
|
||||
const issuedEl = document.getElementById('diagCodeIssued');
|
||||
const badgeEl = document.getElementById('diagStatusBadge');
|
||||
@ -3389,40 +3370,13 @@ 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;
|
||||
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 : [];
|
||||
bindEl.value = s.bindAddress || '127.0.0.1';
|
||||
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' : '');
|
||||
@ -3434,8 +3388,7 @@ function renderSettings() {
|
||||
if (!el || !st) return;
|
||||
if (st.running) {
|
||||
const last = st.lastAccess ? new Date(st.lastAccess).toLocaleString('de-DE') : '—';
|
||||
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.textContent = `Aktiv auf ${st.bindAddress}:${st.port} — ${st.clientCount} Client(s) — Letzter Zugriff: ${last}`;
|
||||
el.style.color = '#10b981';
|
||||
} else {
|
||||
el.textContent = 'Nicht aktiv';
|
||||
@ -3444,17 +3397,10 @@ 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,
|
||||
bindMode: modeEl.value,
|
||||
publicHost: publicHostEl.value.trim(),
|
||||
allowlist
|
||||
bindAddress: bindEl.value
|
||||
});
|
||||
applySettings(await window.api.diagnosticsGetSettings());
|
||||
refreshStatus();
|
||||
@ -3465,9 +3411,7 @@ function renderSettings() {
|
||||
|
||||
enabledEl.addEventListener('change', save);
|
||||
portEl.addEventListener('change', save);
|
||||
modeEl.addEventListener('change', () => { renderModeUi(lastSuggested); save(); });
|
||||
publicHostEl.addEventListener('change', save);
|
||||
allowlistEl.addEventListener('change', save);
|
||||
bindEl.addEventListener('change', save);
|
||||
document.getElementById('diagCopyCodeBtn').addEventListener('click', async () => {
|
||||
if (!codeEl.value) return;
|
||||
await window.api.copyToClipboard(codeEl.value);
|
||||
|
||||
115
tasks/todo.md
115
tasks/todo.md
@ -1,36 +1,87 @@
|
||||
# Tailscale / network-bind diagnostics — match the downloader (rd-diagnostics-mcp)
|
||||
# Queue-Persistenz Bug: fertige Dateien tauchen nach Neustart wieder auf
|
||||
|
||||
Goal: make the MHU read-only diagnostics reachable like the Real-Debrid-Downloader does over
|
||||
Tailscale — host embedded in the connection code, two bind modes (local / network), and a
|
||||
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.
|
||||
## Symptom
|
||||
User: 300 Dateien, 100 übrig, Programm schließen + öffnen → manchmal sind bereits
|
||||
fertig hochgeladene Dateien wieder in der Liste.
|
||||
|
||||
## Plan
|
||||
- [ ] lib/ip-allowlist.js — fail-closed allowlist (normalizeIp/::ffff:, isLoopback, ipv4ToInt, matchIpRule exact+CIDR+wildcard, evaluateClientAllowed: loopback always, empty=loopback-only). + unit tests.
|
||||
- [ ] remote-server.js — accept config.allowlist; reject non-allowlisted peers at connection (close 4005). + protocol test.
|
||||
- [ ] config-store.js — diagnostics subtree: bindMode ('local'), publicHost (''), allowlist ([]).
|
||||
- [ ] 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.
|
||||
- [ ] gateway/code.js — decode h/p/t/n/fp/s (tolerant of old port/token/label). + test.
|
||||
- [ ] gateway/index.js — connect_server takes host from the code; host arg optional override.
|
||||
- [ ] renderer/app.js — bind-mode selector, publicHost input + suggested-host chips, allowlist textarea (network), network-requires-allowlist validation.
|
||||
- [ ] docs/remote-diagnostics-setup.md — network mode + allowlist + Tailscale (set allowlist to your tailnet, e.g. 100.64.0.0/10).
|
||||
- [ ] Tests: ip-allowlist unit, remote-server allowlist protocol, gateway decode, integration (network bind + allowlist), adversarial fail-closed (empty allowlist rejects non-loopback).
|
||||
- [ ] Release v3.3.86 (gitea + github mirror).
|
||||
## Root Cause (verifiziert im Code)
|
||||
- **RC-1 (Persist-Starvation, code-confirmed):** `persistQueueStateSoon()` setzt bei
|
||||
jedem Progress-Event den Timer per `clearTimeout` zurück; Delay während Upload war
|
||||
10000ms. Progress-Events feuern öfter als alle 10s → Timer feuert NIE während eines
|
||||
aktiven Uploads. Der Disk-Snapshot bleibt auf dem Stand VOR Upload-Start stehen
|
||||
(alle Jobs `preview`).
|
||||
- **RC-2 (unzuverlässiger Close-Flush):** beforeunload-Sync-Flush existiert
|
||||
(app.js:4605) und fängt den sauberen Close ab. Bei hartem Kill / Crash / OS-Kill
|
||||
läuft er nicht → der stale Snapshot bleibt liegen.
|
||||
- **RC-3 (Dedup-Asymmetrie):** `_autoDeduplicateFromLog` droppt beim Start nur Jobs
|
||||
mit Status `done`. Die Ghosts aus dem stale Snapshot stehen aber als `preview` da
|
||||
→ werden NICHT gedroppt → fertige Dateien erscheinen erneut.
|
||||
|
||||
## Review (done)
|
||||
All steps implemented and verified. lib/ip-allowlist.js (fail-closed, ::ffff:, CIDR incl.
|
||||
100.64.0.0/10) + 8 unit tests. remote-server.js rejects non-allowlisted peers (close 4005),
|
||||
opt-in via config.allowlist (existing remote-control unaffected) + 2 protocol tests (fail-closed
|
||||
wiring + loopback-always-allowed). Code now carries the host (mhu1_{v,h,p,t,n,fp?,s?}); gateway
|
||||
decode is tolerant of the legacy long keys; connect_server takes the host from the code (host arg
|
||||
optional override) — proven end-to-end by the integration harness connecting with NO host arg.
|
||||
Renderer: bind-mode selector + public-host input + suggested-host chips + allowlist textarea +
|
||||
network-requires-allowlist validation. Docs rewritten for Tailscale (set allowlist to the tailnet,
|
||||
put the Tailscale IP/MagicDNS in the code address). 393 app tests + 9 gateway tests + e2e +
|
||||
integration + adversarial all green, lint 0 errors.
|
||||
## Fix (mechanismus-unabhängig, vom Kern auf)
|
||||
- [x] **FIX A — Timestamp-gated Dedup (Kern-Fix, durable):** Beim Start jeden restored
|
||||
Job droppen, dessen file+hoster im Log mit `ts >= floor(savedAt)` steht — egal ob
|
||||
`preview` oder `done`. Fängt Ghosts auch nach hartem Kill (hängt vom Log ab, nicht
|
||||
vom Snapshot). `lib/queue-dedup.js` additiver 3. Param `savedAt`; `buildPersistedQueueState`
|
||||
stempelt `savedAt`; `restoreQueueStateFromConfig` merkt `_restoredSnapshotSavedAt`;
|
||||
Log-Zeile → `ts` geparst; `_autoDeduplicateFromLog` reicht savedAt durch.
|
||||
- [x] **FIX B — Throttle mit max-wait:** `lib/throttle-timer.js` (neu). Upload: delay 500
|
||||
+ maxWait 20000 → Snapshot alle ~20s statt nie. Idle: reine Debounce. Fallback-Shim
|
||||
honoriert maxWait (kein stilles Starvation-Reintro).
|
||||
- [x] **FIX C — Close-Write-Härtung:** `save-global-settings-sync` renameSync-Retry bei
|
||||
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.
|
||||
|
||||
## Security model shift
|
||||
v3.3.85 hard-locked loopback. This change replaces that with the downloader's model: network bind
|
||||
(0.0.0.0) is allowed ONLY with a non-empty fail-closed IP allowlist (empty => loopback only). The
|
||||
allowlist (real socket peer, ::ffff: normalized, CIDR) + token are the gate; the tunnel
|
||||
(Tailscale/WireGuard) is the confidentiality layer. Plaintext ws:// — document the trust boundary.
|
||||
## Tests
|
||||
- [x] `tests/throttle-timer.test.js`: Starvation ohne maxWait → 0 Fires; mit maxWait →
|
||||
periodische Fires; last-write-wins (distinct fn); flushSync/cancel.
|
||||
- [x] `tests/queue-dedup.test.js`: ts>=savedAt→DROP; ts<savedAt→KEEP; same-second→DROP;
|
||||
max-ts; Multi-Hoster Teilabschluss (reale Bug-Form); ohne savedAt/ohne ts→Legacy.
|
||||
- [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.
|
||||
|
||||
@ -1,20 +1,10 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const os = require('os');
|
||||
const WebSocket = require('ws');
|
||||
const RemoteServer = require('../lib/remote-server');
|
||||
|
||||
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) {
|
||||
const srv = new RemoteServer();
|
||||
return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) })
|
||||
@ -66,44 +56,6 @@ 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('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 () => {
|
||||
const agent = await startAgent(() => {});
|
||||
const port = agent.getPort();
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
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