Compare commits
4 Commits
0ee874ba99
...
3f8854693a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f8854693a | ||
|
|
b25b51840d | ||
|
|
8dff455062 | ||
|
|
0c6c502aab |
@ -17,45 +17,60 @@ On the **server** (the machine running the app):
|
|||||||
2. Enable **"Diagnose-Zugriff"**.
|
2. Enable **"Diagnose-Zugriff"**.
|
||||||
3. Copy the connection **code**. It looks like `mhu1_<base64url...>`.
|
3. Copy the connection **code**. It looks like `mhu1_<base64url...>`.
|
||||||
|
|
||||||
The code carries a one-time auth **token**. It does **not** carry a host — you supply the host
|
The code carries the **host**, **port** and a one-time auth **token** (`mhu1_<base64url{v,h,p,t,n}>`).
|
||||||
yourself when you connect. Treat the code as a **secret**: anyone with the code and network reach
|
The bridge dials the host from the code, so you usually just hand over the code. Treat the code as a
|
||||||
to the agent can read diagnostics.
|
**secret**: anyone with the code and network reach to the agent can read diagnostics.
|
||||||
|
|
||||||
The agent **always binds to `127.0.0.1`** (loopback only) — this is enforced; there is no setting
|
**Two visibility modes** (Settings → Diagnose-Zugriff → Sichtbarkeit):
|
||||||
to expose it on a LAN/Internet interface. You reach it through a tunnel (next step).
|
|
||||||
|
|
||||||
**Transport note:** the agent speaks **plaintext `ws://` over loopback**. The token and the
|
- **Nur lokal** (default): the agent binds to `127.0.0.1`. Reach it through a tunnel (next step).
|
||||||
diagnostic data are *not* encrypted on the wire by the agent itself — confidentiality comes
|
- **Im Netzwerk**: the agent binds to `0.0.0.0` but is gated by a **fail-closed IP allowlist** — only
|
||||||
entirely from the **tunnel** (SSH/WireGuard) you put in front of it. The loopback bind means the
|
source IPs/CIDRs you list may connect (loopback is always allowed), *in addition to* the token. An
|
||||||
plaintext traffic never leaves the host except inside that encrypted tunnel. (A future build may
|
empty allowlist means loopback only. This is the mode to use with **Tailscale**: set the allowlist
|
||||||
add `wss`/TLS with cert pinning via an `fp` field in the code; it is not active today.)
|
to your tailnet (e.g. `100.64.0.0/10`) and put the server's Tailscale IP / MagicDNS name into the
|
||||||
|
code address — then the bridge connects straight over the tailnet, no SSH forward needed.
|
||||||
|
|
||||||
|
**Transport note:** the agent speaks **plaintext `ws://`**. The token and the diagnostic data are
|
||||||
|
*not* encrypted on the wire by the agent itself — confidentiality comes from the **tunnel**
|
||||||
|
(Tailscale/WireGuard/SSH) you reach it through. In network mode the IP allowlist + token are the
|
||||||
|
access gate; **only bind to the network behind a private tunnel you trust** (a tailnet, a VPN, or a
|
||||||
|
trusted LAN). (A future build may add `wss`/TLS with cert pinning via an `fp` field in the code; it
|
||||||
|
is not active today.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Reach the agent over a tunnel (SSH local port-forward or WireGuard)
|
## 2. Reach the agent over a tunnel
|
||||||
|
|
||||||
Because the agent binds to `127.0.0.1` on the server, open a tunnel from your machine to the
|
The agent's default port is `9110`. Pick the path that matches your setup.
|
||||||
server's loopback. The agent's default port is `9110`.
|
|
||||||
|
|
||||||
### SSH local port-forward (recommended)
|
### Tailscale (recommended for many servers)
|
||||||
|
|
||||||
|
Put every server and your gateway machine on the same tailnet. On each server, set **Sichtbarkeit =
|
||||||
|
Im Netzwerk**, allowlist your tailnet (`100.64.0.0/10`, or the specific Tailscale IPs you'll connect
|
||||||
|
from), and set the **code address** to that server's Tailscale IP or MagicDNS name. The bridge then
|
||||||
|
connects straight to `<tailscale-name>:9110` — WireGuard (Tailscale) encrypts the transport, and the
|
||||||
|
allowlist + token gate access. No SSH forward, no per-session tunnel command.
|
||||||
|
|
||||||
|
### SSH local port-forward (keep the agent loopback-only)
|
||||||
|
|
||||||
|
With **Sichtbarkeit = Nur lokal**:
|
||||||
|
|
||||||
```
|
```
|
||||||
ssh -L 9110:127.0.0.1:9110 user@server
|
ssh -L 9110:127.0.0.1:9110 user@server
|
||||||
```
|
```
|
||||||
|
|
||||||
Leave that session open. Now `127.0.0.1:9110` on **your** machine is forwarded to
|
Leave that session open. Now `127.0.0.1:9110` on **your** machine is forwarded to the server's
|
||||||
`127.0.0.1:9110` on the **server**. You connect Claude to **`127.0.0.1`** (your local end of the
|
loopback. The code address is `127.0.0.1` (your local end of the tunnel).
|
||||||
tunnel), not the server's public IP.
|
|
||||||
|
|
||||||
### WireGuard (alternative)
|
### WireGuard (manual, alternative)
|
||||||
|
|
||||||
Bring up a WireGuard tunnel to the server, then connect to the server's WireGuard address (or, if
|
Bring up a WireGuard tunnel and either bind the agent to the network with the peer's WG IP in the
|
||||||
you forward loopback over the tunnel, `127.0.0.1`). Use whichever address resolves to the agent's
|
allowlist, or forward loopback over the tunnel and connect to `127.0.0.1`.
|
||||||
`127.0.0.1:9110` on the server.
|
|
||||||
|
|
||||||
> The agent cannot be bound directly to a LAN/Internet IP in this build (loopback is enforced),
|
> In **Nur lokal** mode the agent is unreachable except through a tunnel to loopback. In **Im
|
||||||
> so the tunnel is the **only** way to reach it remotely — and the only thing encrypting the
|
> Netzwerk** mode the fail-closed IP allowlist (plus the token) is the access gate — only bind to
|
||||||
> transport. Keep the tunnel (SSH/WireGuard) up for the whole session.
|
> the network behind a tunnel/VPN you trust (a tailnet, WireGuard, or a trusted LAN). The transport
|
||||||
|
> is plaintext; the tunnel is what encrypts it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -42,18 +42,26 @@ export function decode(code) {
|
|||||||
if (payload.v !== 1) {
|
if (payload.v !== 1) {
|
||||||
throw new Error(`Invalid code: unsupported version (expected v=1, got ${payload.v})`);
|
throw new Error(`Invalid code: unsupported version (expected v=1, got ${payload.v})`);
|
||||||
}
|
}
|
||||||
if (typeof payload.port !== 'number' || !Number.isFinite(payload.port)) {
|
const host = payload.h !== undefined ? payload.h : payload.host;
|
||||||
|
const port = payload.p !== undefined ? payload.p : payload.port;
|
||||||
|
const token = payload.t !== undefined ? payload.t : payload.token;
|
||||||
|
const label = payload.n !== undefined ? payload.n : payload.label;
|
||||||
|
const scheme = payload.s === 'wss' ? 'wss' : 'ws';
|
||||||
|
if (host !== undefined && typeof host !== 'string') {
|
||||||
|
throw new Error('Invalid code: "host" must be a string when present');
|
||||||
|
}
|
||||||
|
if (typeof port !== 'number' || !Number.isFinite(port)) {
|
||||||
throw new Error('Invalid code: "port" must be a number');
|
throw new Error('Invalid code: "port" must be a number');
|
||||||
}
|
}
|
||||||
if (typeof payload.token !== 'string' || payload.token.length === 0) {
|
if (typeof token !== 'string' || token.length === 0) {
|
||||||
throw new Error('Invalid code: "token" must be a non-empty string');
|
throw new Error('Invalid code: "token" must be a non-empty string');
|
||||||
}
|
}
|
||||||
if (typeof payload.label !== 'string') {
|
if (label !== undefined && typeof label !== 'string') {
|
||||||
throw new Error('Invalid code: "label" must be a string');
|
throw new Error('Invalid code: "label" must be a string');
|
||||||
}
|
}
|
||||||
if (payload.fp !== undefined && typeof payload.fp !== 'string') {
|
if (payload.fp !== undefined && typeof payload.fp !== 'string') {
|
||||||
throw new Error('Invalid code: "fp" must be a string when present');
|
throw new Error('Invalid code: "fp" must be a string when present');
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
return { v: 1, host: host ? String(host) : undefined, port, token, label: label !== undefined ? String(label) : undefined, fp: payload.fp, scheme };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,7 +140,7 @@ async function doConnect({ code, host, port, label }) {
|
|||||||
target = { host: e.host, port: e.port, token: e.token, fp: e.fp, label: e.label };
|
target = { host: e.host, port: e.port, token: e.token, fp: e.fp, label: e.label };
|
||||||
} else {
|
} else {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return { ok: false, error: 'provide a known label, or a code plus host' };
|
return { ok: false, error: 'provide a known label, or a code (the host is taken from the code; pass host only to override)' };
|
||||||
}
|
}
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
try {
|
||||||
@ -148,15 +148,16 @@ async function doConnect({ code, host, port, label }) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: String(e.message ?? e) };
|
return { ok: false, error: String(e.message ?? e) };
|
||||||
}
|
}
|
||||||
if (!host) {
|
const effHost = host || payload.host;
|
||||||
return { ok: false, error: 'host is required when connecting with a code' };
|
if (!effHost) {
|
||||||
|
return { ok: false, error: 'no host in the code and none provided — pass host (e.g. the Tailscale IP/MagicDNS name)' };
|
||||||
}
|
}
|
||||||
target = {
|
target = {
|
||||||
host,
|
host: effHost,
|
||||||
port: typeof port === 'number' ? port : payload.port,
|
port: typeof port === 'number' ? port : payload.port,
|
||||||
token: payload.token,
|
token: payload.token,
|
||||||
fp: payload.fp,
|
fp: payload.fp,
|
||||||
label: label || payload.label,
|
label: label || payload.label || effHost,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,7 +225,7 @@ export function buildServer() {
|
|||||||
{
|
{
|
||||||
title: 'Connect to a diagnostic server',
|
title: 'Connect to a diagnostic server',
|
||||||
description:
|
description:
|
||||||
'Connect to a remote diagnostic agent. Use label for a known server, or code+host for a new one. The host is always supplied by you, never taken from the code.',
|
'Connect to a remote diagnostic agent. Use label for a known server, or a code for a new one — the host (e.g. a Tailscale IP/MagicDNS name) is taken from the code. Pass host only to override what the code carries.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
code: z.string().optional(),
|
code: z.string().optional(),
|
||||||
host: z.string().optional(),
|
host: z.string().optional(),
|
||||||
|
|||||||
@ -2,23 +2,25 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { encode, decode } from '../code.js';
|
import { encode, decode } from '../code.js';
|
||||||
|
|
||||||
test('decode(encode(x)) round-trips a full payload', () => {
|
test('decode reads the host-bearing short-key format (h/p/t/n/s/fp)', () => {
|
||||||
const payload = {
|
const code = encode({ v: 1, h: '100.64.0.5', p: 9110, t: 'deadbeefcafe1234', n: 'prod-3', s: 'wss', fp: 'AB:CD:EF:01' });
|
||||||
v: 1,
|
|
||||||
port: 9110,
|
|
||||||
token: 'deadbeefcafe1234',
|
|
||||||
label: 'prod-3',
|
|
||||||
fp: 'AB:CD:EF:01:23:45:67:89',
|
|
||||||
};
|
|
||||||
const code = encode(payload);
|
|
||||||
assert.ok(code.startsWith('mhu1_'));
|
assert.ok(code.startsWith('mhu1_'));
|
||||||
assert.deepEqual(decode(code), payload);
|
const d = decode(code);
|
||||||
|
assert.equal(d.host, '100.64.0.5');
|
||||||
|
assert.equal(d.port, 9110);
|
||||||
|
assert.equal(d.token, 'deadbeefcafe1234');
|
||||||
|
assert.equal(d.label, 'prod-3');
|
||||||
|
assert.equal(d.scheme, 'wss');
|
||||||
|
assert.equal(d.fp, 'AB:CD:EF:01');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decode(encode(x)) round-trips a payload without fp (ws://)', () => {
|
test('decode is tolerant of the legacy long-key format (port/token/label, no host -> ws)', () => {
|
||||||
const payload = { v: 1, port: 9110, token: 'token-abc', label: 'localhost' };
|
const d = decode(encode({ v: 1, port: 9110, token: 'token-abc', label: 'localhost' }));
|
||||||
const code = encode(payload);
|
assert.equal(d.host, undefined);
|
||||||
assert.deepEqual(decode(code), payload);
|
assert.equal(d.port, 9110);
|
||||||
|
assert.equal(d.token, 'token-abc');
|
||||||
|
assert.equal(d.label, 'localhost');
|
||||||
|
assert.equal(d.scheme, 'ws');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decode rejects a string without the mhu1_ prefix', () => {
|
test('decode rejects a string without the mhu1_ prefix', () => {
|
||||||
|
|||||||
@ -110,7 +110,7 @@ function rejected(r) { return r.threw || r.isError || (r.data && r.data.ok === f
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const port = srv.getPort();
|
const port = srv.getPort();
|
||||||
const code = encode({ v: 1, port, token: TOKEN, label: 'integration' });
|
const code = encode({ v: 1, h: '127.0.0.1', p: port, t: TOKEN, n: 'integration' });
|
||||||
|
|
||||||
transport = new StdioClientTransport({ command: process.execPath, args: [indexPath] });
|
transport = new StdioClientTransport({ command: process.execPath, args: [indexPath] });
|
||||||
client = new Client({ name: 'mhu-int-test', version: '1.0.0' });
|
client = new Client({ name: 'mhu-int-test', version: '1.0.0' });
|
||||||
@ -121,8 +121,9 @@ function rejected(r) { return r.threw || r.isError || (r.data && r.data.ok === f
|
|||||||
const expected = ['connect_server', 'current_server', 'disconnect_server', 'get_app_events', 'get_config_redacted', 'get_history', 'get_queue_state', 'get_rotation_state', 'get_system_info', 'list_errors', 'list_logs', 'list_servers', 'read_log', 'server_health'].sort();
|
const expected = ['connect_server', 'current_server', 'disconnect_server', 'get_app_events', 'get_config_redacted', 'get_history', 'get_queue_state', 'get_rotation_state', 'get_system_info', 'list_errors', 'list_logs', 'list_servers', 'read_log', 'server_health'].sort();
|
||||||
check('listTools returns all 14 tools', JSON.stringify(names) === JSON.stringify(expected), names.join(','));
|
check('listTools returns all 14 tools', JSON.stringify(names) === JSON.stringify(expected), names.join(','));
|
||||||
|
|
||||||
let r = await callJSON('connect_server', { code, host: '127.0.0.1' });
|
let r = await callJSON('connect_server', { code });
|
||||||
check('connect_server ok', r.data && r.data.ok === true, r.text.slice(0, 120));
|
check('connect_server ok (host taken from the code, no host arg)', r.data && r.data.ok === true, r.text.slice(0, 120));
|
||||||
|
check('connect_server resolved host from code', r.data && r.data.server && r.data.server.host === '127.0.0.1');
|
||||||
check('connect_server reports version 3.3.84', r.data && r.data.server && r.data.server.version === '3.3.84');
|
check('connect_server reports version 3.3.84', r.data && r.data.server && r.data.server.version === '3.3.84');
|
||||||
|
|
||||||
r = await callJSON('current_server');
|
r = await callJSON('current_server');
|
||||||
|
|||||||
@ -107,6 +107,9 @@ const DEFAULTS = {
|
|||||||
token: '',
|
token: '',
|
||||||
label: '',
|
label: '',
|
||||||
codeIssuedAt: 0,
|
codeIssuedAt: 0,
|
||||||
|
bindMode: 'local',
|
||||||
|
publicHost: '',
|
||||||
|
allowlist: [],
|
||||||
bindAddress: '127.0.0.1'
|
bindAddress: '127.0.0.1'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
50
lib/ip-allowlist.js
Normal file
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 { WebSocketServer } = require('ws');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const { evaluateClientAllowed } = require('./ip-allowlist');
|
||||||
|
|
||||||
function timingSafeEqualStr(a, b) {
|
function timingSafeEqualStr(a, b) {
|
||||||
const x = Buffer.from(String(a == null ? '' : a));
|
const x = Buffer.from(String(a == null ? '' : a));
|
||||||
@ -70,6 +71,11 @@ class RemoteServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(this._config.allowlist) && !evaluateClientAllowed(ip, this._config.allowlist)) {
|
||||||
|
ws.close(4005, 'Client IP not allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const clientId = crypto.randomUUID();
|
const clientId = crypto.randomUUID();
|
||||||
this._clients.set(ws, { id: clientId, role: null, authenticated: false });
|
this._clients.set(ws, { id: clientId, role: null, authenticated: false });
|
||||||
|
|
||||||
|
|||||||
65
main.js
65
main.js
@ -2459,7 +2459,7 @@ function _diagAgentInfo() {
|
|||||||
return {
|
return {
|
||||||
version: app.getVersion(),
|
version: app.getVersion(),
|
||||||
port: diag.port || 9110,
|
port: diag.port || 9110,
|
||||||
bindAddress: diag.bindAddress || '127.0.0.1',
|
bindAddress: _diagBindHost(diag),
|
||||||
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
|
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
|
||||||
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
|
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
|
||||||
};
|
};
|
||||||
@ -2484,19 +2484,42 @@ function _buildDiagnosticHandler() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDiagnosticCode(diag, fp) {
|
function _getSuggestedRemoteHosts() {
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const payload = { v: 1, port: diag.port || 9110, token: diag.token, label: diag.label || os.hostname() };
|
const hosts = [];
|
||||||
if (fp) payload.fp = fp;
|
try {
|
||||||
return 'mhu1_' + Buffer.from(JSON.stringify(payload)).toString('base64url');
|
for (const entry of Object.values(os.networkInterfaces())) {
|
||||||
|
for (const net of (entry || [])) {
|
||||||
|
if (net && net.family === 'IPv4' && !net.internal && net.address) hosts.push(net.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return [...new Set(hosts)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function _safeDiagBindAddress(addr) {
|
function _diagAllowlist(diag) {
|
||||||
const a = String(addr || '').trim();
|
return Array.isArray(diag && diag.allowlist) ? diag.allowlist.map((x) => String(x).trim()).filter(Boolean) : [];
|
||||||
if (a === '127.0.0.1' || a === '::1') return a;
|
}
|
||||||
|
|
||||||
|
function _diagBindHost(diag) {
|
||||||
|
const mode = (diag && diag.bindMode) || 'local';
|
||||||
|
if (mode === 'network' && _diagAllowlist(diag).length > 0) return '0.0.0.0';
|
||||||
return '127.0.0.1';
|
return '127.0.0.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _diagPublicHost(diag) {
|
||||||
|
const explicit = String((diag && diag.publicHost) || '').trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
if (_diagBindHost(diag) === '127.0.0.1') return '127.0.0.1';
|
||||||
|
return _getSuggestedRemoteHosts()[0] || '127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDiagnosticCode(diag, fp) {
|
||||||
|
const payload = { v: 1, h: _diagPublicHost(diag), p: diag.port || 9110, t: diag.token, n: diag.label || require('os').hostname() };
|
||||||
|
if (fp) { payload.fp = fp; payload.s = 'wss'; }
|
||||||
|
return 'mhu1_' + Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
async function startDiagnosticAgent() {
|
async function startDiagnosticAgent() {
|
||||||
if (diagnosticAgent) { try { diagnosticAgent.stop(); } catch {} diagnosticAgent = null; }
|
if (diagnosticAgent) { try { diagnosticAgent.stop(); } catch {} diagnosticAgent = null; }
|
||||||
const config = configStore.load();
|
const config = configStore.load();
|
||||||
@ -2511,7 +2534,8 @@ async function startDiagnosticAgent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!_diagHandler) _diagHandler = _buildDiagnosticHandler();
|
if (!_diagHandler) _diagHandler = _buildDiagnosticHandler();
|
||||||
const host = _safeDiagBindAddress(diag.bindAddress);
|
const host = _diagBindHost(diag);
|
||||||
|
const allowlist = _diagAllowlist(diag);
|
||||||
diagnosticAgent = new RemoteServer();
|
diagnosticAgent = new RemoteServer();
|
||||||
try {
|
try {
|
||||||
await diagnosticAgent.start({
|
await diagnosticAgent.start({
|
||||||
@ -2519,9 +2543,10 @@ async function startDiagnosticAgent() {
|
|||||||
host,
|
host,
|
||||||
token,
|
token,
|
||||||
diagnosticMode: true,
|
diagnosticMode: true,
|
||||||
|
allowlist,
|
||||||
onDiagnosticRequest: _diagHandler
|
onDiagnosticRequest: _diagHandler
|
||||||
});
|
});
|
||||||
debugLog(`diagnostics-agent started on ${host}:${diagnosticAgent.getPort()}`);
|
debugLog(`diagnostics-agent started on ${host}:${diagnosticAgent.getPort()} (allowlist ${allowlist.length})`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugLog(`diagnostics-agent start failed: ${e.message}`);
|
debugLog(`diagnostics-agent start failed: ${e.message}`);
|
||||||
diagnosticAgent = null;
|
diagnosticAgent = null;
|
||||||
@ -2538,7 +2563,11 @@ ipcMain.handle('diagnostics:get-settings', () => {
|
|||||||
return {
|
return {
|
||||||
enabled: !!diag.enabled,
|
enabled: !!diag.enabled,
|
||||||
port: diag.port || 9110,
|
port: diag.port || 9110,
|
||||||
bindAddress: diag.bindAddress || '127.0.0.1',
|
bindMode: diag.bindMode === 'network' ? 'network' : 'local',
|
||||||
|
bindAddress: _diagBindHost(diag),
|
||||||
|
publicHost: diag.publicHost || '',
|
||||||
|
allowlist: _diagAllowlist(diag),
|
||||||
|
suggestedHosts: _getSuggestedRemoteHosts(),
|
||||||
label: diag.label || require('os').hostname(),
|
label: diag.label || require('os').hostname(),
|
||||||
codeIssuedAt: diag.codeIssuedAt || 0,
|
codeIssuedAt: diag.codeIssuedAt || 0,
|
||||||
code: diag.token ? buildDiagnosticCode(diag) : ''
|
code: diag.token ? buildDiagnosticCode(diag) : ''
|
||||||
@ -2552,13 +2581,18 @@ ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
|
|||||||
...cur,
|
...cur,
|
||||||
enabled: !!(incoming && incoming.enabled),
|
enabled: !!(incoming && incoming.enabled),
|
||||||
port: (incoming && Number(incoming.port)) || cur.port || 9110,
|
port: (incoming && Number(incoming.port)) || cur.port || 9110,
|
||||||
bindAddress: _safeDiagBindAddress((incoming && incoming.bindAddress) || cur.bindAddress),
|
bindMode: (incoming && incoming.bindMode === 'network') ? 'network' : 'local',
|
||||||
|
publicHost: (incoming && incoming.publicHost != null) ? String(incoming.publicHost).trim() : (cur.publicHost || ''),
|
||||||
|
allowlist: (incoming && Array.isArray(incoming.allowlist))
|
||||||
|
? incoming.allowlist.map((x) => String(x).trim()).filter(Boolean)
|
||||||
|
: _diagAllowlist(cur),
|
||||||
label: (incoming && incoming.label != null) ? String(incoming.label) : cur.label
|
label: (incoming && incoming.label != null) ? String(incoming.label) : cur.label
|
||||||
};
|
};
|
||||||
|
next.bindAddress = _diagBindHost(next);
|
||||||
const gs = { ...cfg.globalSettings, diagnostics: next };
|
const gs = { ...cfg.globalSettings, diagnostics: next };
|
||||||
await configStore.save({ globalSettings: gs });
|
await configStore.save({ globalSettings: gs });
|
||||||
await startDiagnosticAgent();
|
await startDiagnosticAgent();
|
||||||
return { ok: true };
|
return { ok: true, bindAddress: next.bindAddress, allowlistCount: next.allowlist.length };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('diagnostics:regenerate', async () => {
|
ipcMain.handle('diagnostics:regenerate', async () => {
|
||||||
@ -2577,7 +2611,10 @@ ipcMain.handle('diagnostics:status', () => {
|
|||||||
return {
|
return {
|
||||||
running: !!diagnosticAgent,
|
running: !!diagnosticAgent,
|
||||||
port: diagnosticAgent ? diagnosticAgent.getPort() : (diag.port || 9110),
|
port: diagnosticAgent ? diagnosticAgent.getPort() : (diag.port || 9110),
|
||||||
bindAddress: diag.bindAddress || '127.0.0.1',
|
bindMode: diag.bindMode === 'network' ? 'network' : 'local',
|
||||||
|
bindAddress: _diagBindHost(diag),
|
||||||
|
publicHost: _diagPublicHost(diag),
|
||||||
|
allowlistCount: _diagAllowlist(diag).length,
|
||||||
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
|
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
|
||||||
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
|
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "3.3.85",
|
"version": "3.3.86",
|
||||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -3216,12 +3216,25 @@ function renderSettings() {
|
|||||||
<input type="number" class="hs-input" id="diagPortInput" min="1024" max="65535" value="9110" style="width:100px">
|
<input type="number" class="hs-input" id="diagPortInput" min="1024" max="65535" value="9110" style="width:100px">
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<label>Bind-Adresse</label>
|
<label>Sichtbarkeit</label>
|
||||||
<select class="hs-input" id="diagBindInput" style="width:auto">
|
<select class="hs-input" id="diagBindModeInput" style="width:auto">
|
||||||
<option value="127.0.0.1">Nur lokal (Tunnel/VPN) — empfohlen</option>
|
<option value="local">Nur lokal (127.0.0.1) — Tunnel/VPN</option>
|
||||||
|
<option value="network">Im Netzwerk (0.0.0.0) — Allowlist nötig</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row"><span class="hint">Direkte LAN-/Internet-Bindung ist deaktiviert, bis verschlüsselter Transport (wss/TLS) verfügbar ist. Zugriff aus der Ferne läuft über einen SSH- oder VPN-Tunnel zu <code>127.0.0.1</code>.</span></div>
|
<div class="settings-row">
|
||||||
|
<label>Adresse für den Code</label>
|
||||||
|
<input type="text" class="hs-input" id="diagPublicHostInput" placeholder="127.0.0.1 oder Tunnel-/Tailscale-Adresse" style="flex:1">
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" id="diagSuggestRow" style="display:none">
|
||||||
|
<label></label>
|
||||||
|
<div id="diagSuggestChips" style="display:flex;gap:6px;flex-wrap:wrap"></div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" id="diagAllowlistRow" style="display:none;align-items:flex-start">
|
||||||
|
<label>Allowlist (IP/CIDR, eine pro Zeile)</label>
|
||||||
|
<textarea class="hs-input" id="diagAllowlistInput" rows="3" style="flex:1;font-family:monospace" placeholder="100.64.0.0/10 203.0.113.5"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row"><span class="hint" id="diagBindHint"></span></div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<label>Verbindungs-Code</label>
|
<label>Verbindungs-Code</label>
|
||||||
<input type="text" class="key-input" id="diagCodeInput" value="" readonly style="flex:1" placeholder="(aktivieren zum Erzeugen)">
|
<input type="text" class="key-input" id="diagCodeInput" value="" readonly style="flex:1" placeholder="(aktivieren zum Erzeugen)">
|
||||||
@ -3360,7 +3373,13 @@ function renderSettings() {
|
|||||||
(function wireDiagnostics() {
|
(function wireDiagnostics() {
|
||||||
const enabledEl = document.getElementById('diagEnabledInput');
|
const enabledEl = document.getElementById('diagEnabledInput');
|
||||||
const portEl = document.getElementById('diagPortInput');
|
const portEl = document.getElementById('diagPortInput');
|
||||||
const bindEl = document.getElementById('diagBindInput');
|
const modeEl = document.getElementById('diagBindModeInput');
|
||||||
|
const publicHostEl = document.getElementById('diagPublicHostInput');
|
||||||
|
const allowlistEl = document.getElementById('diagAllowlistInput');
|
||||||
|
const allowlistRow = document.getElementById('diagAllowlistRow');
|
||||||
|
const suggestRow = document.getElementById('diagSuggestRow');
|
||||||
|
const suggestChips = document.getElementById('diagSuggestChips');
|
||||||
|
const bindHintEl = document.getElementById('diagBindHint');
|
||||||
const codeEl = document.getElementById('diagCodeInput');
|
const codeEl = document.getElementById('diagCodeInput');
|
||||||
const issuedEl = document.getElementById('diagCodeIssued');
|
const issuedEl = document.getElementById('diagCodeIssued');
|
||||||
const badgeEl = document.getElementById('diagStatusBadge');
|
const badgeEl = document.getElementById('diagStatusBadge');
|
||||||
@ -3370,13 +3389,40 @@ function renderSettings() {
|
|||||||
if (!ts) return '';
|
if (!ts) return '';
|
||||||
try { return 'Code erstellt: ' + new Date(ts).toLocaleString('de-DE'); } catch { return ''; }
|
try { return 'Code erstellt: ' + new Date(ts).toLocaleString('de-DE'); } catch { return ''; }
|
||||||
};
|
};
|
||||||
|
const parseAllowlist = () => allowlistEl.value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
|
const renderModeUi = (suggestedHosts) => {
|
||||||
|
const network = modeEl.value === 'network';
|
||||||
|
allowlistRow.style.display = network ? '' : 'none';
|
||||||
|
bindHintEl.innerHTML = network
|
||||||
|
? 'Bindet an <code>0.0.0.0</code>. Nur IPs/CIDRs aus der Allowlist dürfen verbinden (Loopback immer) — zusätzlich zum Token. Über Tailscale: trage deinen Tailnet-Bereich ein (z.B. <code>100.64.0.0/10</code>) und die Tailscale-IP/MagicDNS oben als Code-Adresse. Transport ist plaintext über den Tunnel — Tailscale/WireGuard verschlüsselt.'
|
||||||
|
: 'Bindet nur an <code>127.0.0.1</code>. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH) — die sicherste Variante.';
|
||||||
|
const hosts = Array.isArray(suggestedHosts) ? suggestedHosts : [];
|
||||||
|
if (hosts.length) {
|
||||||
|
suggestRow.style.display = '';
|
||||||
|
suggestChips.innerHTML = '';
|
||||||
|
for (const h of hosts) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'btn btn-xs btn-secondary';
|
||||||
|
b.textContent = h;
|
||||||
|
b.addEventListener('click', () => { publicHostEl.value = h; save(); });
|
||||||
|
suggestChips.appendChild(b);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
suggestRow.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let lastSuggested = [];
|
||||||
const applySettings = (s) => {
|
const applySettings = (s) => {
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
enabledEl.checked = !!s.enabled;
|
enabledEl.checked = !!s.enabled;
|
||||||
portEl.value = s.port || 9110;
|
portEl.value = s.port || 9110;
|
||||||
bindEl.value = s.bindAddress || '127.0.0.1';
|
modeEl.value = s.bindMode === 'network' ? 'network' : 'local';
|
||||||
|
publicHostEl.value = s.publicHost || '';
|
||||||
|
allowlistEl.value = Array.isArray(s.allowlist) ? s.allowlist.join('\n') : '';
|
||||||
|
lastSuggested = Array.isArray(s.suggestedHosts) ? s.suggestedHosts : [];
|
||||||
codeEl.value = s.code || '';
|
codeEl.value = s.code || '';
|
||||||
issuedEl.textContent = fmtIssued(s.codeIssuedAt);
|
issuedEl.textContent = fmtIssued(s.codeIssuedAt);
|
||||||
|
renderModeUi(lastSuggested);
|
||||||
if (badgeEl) {
|
if (badgeEl) {
|
||||||
badgeEl.textContent = s.enabled ? 'Aktiv' : 'Inaktiv';
|
badgeEl.textContent = s.enabled ? 'Aktiv' : 'Inaktiv';
|
||||||
badgeEl.className = 'panel-status' + (s.enabled ? ' active' : '');
|
badgeEl.className = 'panel-status' + (s.enabled ? ' active' : '');
|
||||||
@ -3388,7 +3434,8 @@ function renderSettings() {
|
|||||||
if (!el || !st) return;
|
if (!el || !st) return;
|
||||||
if (st.running) {
|
if (st.running) {
|
||||||
const last = st.lastAccess ? new Date(st.lastAccess).toLocaleString('de-DE') : '—';
|
const last = st.lastAccess ? new Date(st.lastAccess).toLocaleString('de-DE') : '—';
|
||||||
el.textContent = `Aktiv auf ${st.bindAddress}:${st.port} — ${st.clientCount} Client(s) — Letzter Zugriff: ${last}`;
|
const scope = st.bindMode === 'network' ? `Netzwerk (Allowlist: ${st.allowlistCount})` : 'nur lokal';
|
||||||
|
el.textContent = `Aktiv auf ${st.bindAddress}:${st.port} (${scope}) — ${st.clientCount} Client(s) — Letzter Zugriff: ${last}`;
|
||||||
el.style.color = '#10b981';
|
el.style.color = '#10b981';
|
||||||
} else {
|
} else {
|
||||||
el.textContent = 'Nicht aktiv';
|
el.textContent = 'Nicht aktiv';
|
||||||
@ -3397,10 +3444,17 @@ function renderSettings() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
|
const allowlist = parseAllowlist();
|
||||||
|
if (enabledEl.checked && modeEl.value === 'network' && allowlist.length === 0) {
|
||||||
|
if (bindHintEl) { bindHintEl.innerHTML = '<span style="color:#f59e0b">Netzwerkmodus braucht mindestens eine IP/CIDR in der Allowlist — sonst bleibt es fail-closed auf Loopback.</span>'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
await window.api.diagnosticsSaveSettings({
|
await window.api.diagnosticsSaveSettings({
|
||||||
enabled: enabledEl.checked,
|
enabled: enabledEl.checked,
|
||||||
port: parseInt(portEl.value, 10) || 9110,
|
port: parseInt(portEl.value, 10) || 9110,
|
||||||
bindAddress: bindEl.value
|
bindMode: modeEl.value,
|
||||||
|
publicHost: publicHostEl.value.trim(),
|
||||||
|
allowlist
|
||||||
});
|
});
|
||||||
applySettings(await window.api.diagnosticsGetSettings());
|
applySettings(await window.api.diagnosticsGetSettings());
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
@ -3411,7 +3465,9 @@ function renderSettings() {
|
|||||||
|
|
||||||
enabledEl.addEventListener('change', save);
|
enabledEl.addEventListener('change', save);
|
||||||
portEl.addEventListener('change', save);
|
portEl.addEventListener('change', save);
|
||||||
bindEl.addEventListener('change', save);
|
modeEl.addEventListener('change', () => { renderModeUi(lastSuggested); save(); });
|
||||||
|
publicHostEl.addEventListener('change', save);
|
||||||
|
allowlistEl.addEventListener('change', save);
|
||||||
document.getElementById('diagCopyCodeBtn').addEventListener('click', async () => {
|
document.getElementById('diagCopyCodeBtn').addEventListener('click', async () => {
|
||||||
if (!codeEl.value) return;
|
if (!codeEl.value) return;
|
||||||
await window.api.copyToClipboard(codeEl.value);
|
await window.api.copyToClipboard(codeEl.value);
|
||||||
|
|||||||
115
tasks/todo.md
115
tasks/todo.md
@ -1,87 +1,36 @@
|
|||||||
# Queue-Persistenz Bug: fertige Dateien tauchen nach Neustart wieder auf
|
# Tailscale / network-bind diagnostics — match the downloader (rd-diagnostics-mcp)
|
||||||
|
|
||||||
## Symptom
|
Goal: make the MHU read-only diagnostics reachable like the Real-Debrid-Downloader does over
|
||||||
User: 300 Dateien, 100 übrig, Programm schließen + öffnen → manchmal sind bereits
|
Tailscale — host embedded in the connection code, two bind modes (local / network), and a
|
||||||
fertig hochgeladene Dateien wieder in der Liste.
|
fail-closed IP allowlist as the access gate. No Tailscale auto-detection (the downloader has none);
|
||||||
|
Tailscale is just one of the offered interface IPs reached over the tunnel, gated by the allowlist.
|
||||||
|
|
||||||
## Root Cause (verifiziert im Code)
|
## Plan
|
||||||
- **RC-1 (Persist-Starvation, code-confirmed):** `persistQueueStateSoon()` setzt bei
|
- [ ] lib/ip-allowlist.js — fail-closed allowlist (normalizeIp/::ffff:, isLoopback, ipv4ToInt, matchIpRule exact+CIDR+wildcard, evaluateClientAllowed: loopback always, empty=loopback-only). + unit tests.
|
||||||
jedem Progress-Event den Timer per `clearTimeout` zurück; Delay während Upload war
|
- [ ] remote-server.js — accept config.allowlist; reject non-allowlisted peers at connection (close 4005). + protocol test.
|
||||||
10000ms. Progress-Events feuern öfter als alle 10s → Timer feuert NIE während eines
|
- [ ] config-store.js — diagnostics subtree: bindMode ('local'), publicHost (''), allowlist ([]).
|
||||||
aktiven Uploads. Der Disk-Snapshot bleibt auf dem Stand VOR Upload-Start stehen
|
- [ ] main.js — bindMode->host (local=127.0.0.1, network=0.0.0.0, network requires non-empty allowlist); buildDiagnosticCode with host (h); getSuggestedRemoteHosts (os.networkInterfaces); pass allowlist; IPC save-settings/status.
|
||||||
(alle Jobs `preview`).
|
- [ ] gateway/code.js — decode h/p/t/n/fp/s (tolerant of old port/token/label). + test.
|
||||||
- **RC-2 (unzuverlässiger Close-Flush):** beforeunload-Sync-Flush existiert
|
- [ ] gateway/index.js — connect_server takes host from the code; host arg optional override.
|
||||||
(app.js:4605) und fängt den sauberen Close ab. Bei hartem Kill / Crash / OS-Kill
|
- [ ] renderer/app.js — bind-mode selector, publicHost input + suggested-host chips, allowlist textarea (network), network-requires-allowlist validation.
|
||||||
läuft er nicht → der stale Snapshot bleibt liegen.
|
- [ ] docs/remote-diagnostics-setup.md — network mode + allowlist + Tailscale (set allowlist to your tailnet, e.g. 100.64.0.0/10).
|
||||||
- **RC-3 (Dedup-Asymmetrie):** `_autoDeduplicateFromLog` droppt beim Start nur Jobs
|
- [ ] Tests: ip-allowlist unit, remote-server allowlist protocol, gateway decode, integration (network bind + allowlist), adversarial fail-closed (empty allowlist rejects non-loopback).
|
||||||
mit Status `done`. Die Ghosts aus dem stale Snapshot stehen aber als `preview` da
|
- [ ] Release v3.3.86 (gitea + github mirror).
|
||||||
→ werden NICHT gedroppt → fertige Dateien erscheinen erneut.
|
|
||||||
|
|
||||||
## Fix (mechanismus-unabhängig, vom Kern auf)
|
## Review (done)
|
||||||
- [x] **FIX A — Timestamp-gated Dedup (Kern-Fix, durable):** Beim Start jeden restored
|
All steps implemented and verified. lib/ip-allowlist.js (fail-closed, ::ffff:, CIDR incl.
|
||||||
Job droppen, dessen file+hoster im Log mit `ts >= floor(savedAt)` steht — egal ob
|
100.64.0.0/10) + 8 unit tests. remote-server.js rejects non-allowlisted peers (close 4005),
|
||||||
`preview` oder `done`. Fängt Ghosts auch nach hartem Kill (hängt vom Log ab, nicht
|
opt-in via config.allowlist (existing remote-control unaffected) + 2 protocol tests (fail-closed
|
||||||
vom Snapshot). `lib/queue-dedup.js` additiver 3. Param `savedAt`; `buildPersistedQueueState`
|
wiring + loopback-always-allowed). Code now carries the host (mhu1_{v,h,p,t,n,fp?,s?}); gateway
|
||||||
stempelt `savedAt`; `restoreQueueStateFromConfig` merkt `_restoredSnapshotSavedAt`;
|
decode is tolerant of the legacy long keys; connect_server takes the host from the code (host arg
|
||||||
Log-Zeile → `ts` geparst; `_autoDeduplicateFromLog` reicht savedAt durch.
|
optional override) — proven end-to-end by the integration harness connecting with NO host arg.
|
||||||
- [x] **FIX B — Throttle mit max-wait:** `lib/throttle-timer.js` (neu). Upload: delay 500
|
Renderer: bind-mode selector + public-host input + suggested-host chips + allowlist textarea +
|
||||||
+ maxWait 20000 → Snapshot alle ~20s statt nie. Idle: reine Debounce. Fallback-Shim
|
network-requires-allowlist validation. Docs rewritten for Tailscale (set allowlist to the tailnet,
|
||||||
honoriert maxWait (kein stilles Starvation-Reintro).
|
put the Tailscale IP/MagicDNS in the code address). 393 app tests + 9 gateway tests + e2e +
|
||||||
- [x] **FIX C — Close-Write-Härtung:** `save-global-settings-sync` renameSync-Retry bei
|
integration + adversarial all green, lint 0 errors.
|
||||||
EBUSY/EPERM/EACCES + pid-unique tmp + tmp-cleanup. Startup-Sweep `_sweepOrphanConfigTmps`
|
|
||||||
räumt verwaiste `<config>.<pid>.tmp` toter PIDs (gegen Orphan-Akkumulation).
|
|
||||||
- [x] **Seam-Extraktion (Advisor #2):** `lib/upload-log.js` (neu) — `formatUploadLogLine`
|
|
||||||
+ `parseUploadLogLine` aus main.js gezogen; Test fährt den ECHTEN Writer→Reader→Gate-
|
|
||||||
Vertrag (kein Mirror) → fängt künftige Format-/Epoch-Brüche.
|
|
||||||
|
|
||||||
## Tests
|
## Security model shift
|
||||||
- [x] `tests/throttle-timer.test.js`: Starvation ohne maxWait → 0 Fires; mit maxWait →
|
v3.3.85 hard-locked loopback. This change replaces that with the downloader's model: network bind
|
||||||
periodische Fires; last-write-wins (distinct fn); flushSync/cancel.
|
(0.0.0.0) is allowed ONLY with a non-empty fail-closed IP allowlist (empty => loopback only). The
|
||||||
- [x] `tests/queue-dedup.test.js`: ts>=savedAt→DROP; ts<savedAt→KEEP; same-second→DROP;
|
allowlist (real socket peer, ::ffff: normalized, CIDR) + token are the gate; the tunnel
|
||||||
max-ts; Multi-Hoster Teilabschluss (reale Bug-Form); ohne savedAt/ohne ts→Legacy.
|
(Tailscale/WireGuard) is the confidentiality layer. Plaintext ws:// — document the trust boundary.
|
||||||
- [x] `tests/upload-log.test.js`: realer Writer→Reader-Roundtrip + Seam-Drop/Keep.
|
|
||||||
- [x] 334/334 grün, ESLint clean, Smoke-Boot identisch zu Baseline (kein Regress).
|
|
||||||
|
|
||||||
## Review
|
|
||||||
- **Adversariale Multi-Agent-Review (4 Dimensionen, 15 Findings):** 14 refuted (meist
|
|
||||||
"ist korrekt"-Bestätigungen, Kommentar-Drift, Test-Härtungs-Vorschläge). 1 confirmed
|
|
||||||
(LOW): pid-unique tmp konnte bei Hard-Kill zwischen write und rename verwaisen → mit
|
|
||||||
Startup-Sweep behoben. Stale-Kommentare (queue-dedup Header + _autoDeduplicateFromLog)
|
|
||||||
auf die Zwei-Regel-Logik korrigiert.
|
|
||||||
- **Was bewiesen ist:** Komponenten-Logik (Unit-Tests inkl. realer Format-Seam),
|
|
||||||
Code-getraceter Wiring-Pfad, adversariale Gegenprüfung. Der Fix ist
|
|
||||||
MECHANISMUS-UNABHÄNGIG: greift egal ob der stale Snapshot von Starvation, einem
|
|
||||||
Mid-Upload-Close-Race ODER einem Hard-Kill kommt.
|
|
||||||
- **Ehrliche Einschränkung:** KEIN Live-Repro mit echtem byse-Key (Key unter anderem
|
|
||||||
Windows-Profil verschlüsselt, nicht entschlüsselbar). Symptom tritt nur auf bei
|
|
||||||
Close WÄHREND aktivem Upload oder Hard-Kill — ein sauberer Idle-Close war schon
|
|
||||||
vorher korrekt.
|
|
||||||
|
|
||||||
## Runde 2 — "noch intensiver" (v3.3.81)
|
|
||||||
- **Echte ausführbare Tests** für bisher nur logisch abgedeckte Pfade: `tests/orphan-tmp.test.js`
|
|
||||||
(Sweep-Entscheidung, extrahiert nach `lib/orphan-tmp.js`), config-store `pendingQueue`+`savedAt`
|
|
||||||
Roundtrip, `tests/queue-persistence-scenario.test.js` (exakte 300/100-Bug-Form + multi-hoster),
|
|
||||||
`tests/queue-dedup-property.test.js` (3000+500 Fuzz-Iterationen gegen die formale Invariante).
|
|
||||||
- **Breite adversariale Bug-Jagd** übers GANZE Subsystem: lief tooling-bedingt teils kaputt
|
|
||||||
(bug-analyzer ohne File-Tools, Socket-Fehler) → 3 unverifizierte Hypothesen SELBST am Code
|
|
||||||
geprüft:
|
|
||||||
- #2 (gedroppte done-Jobs reappear via buildQueuePreview) → REFUTED: `_completedUploadKeys`
|
|
||||||
(full path) == buildQueuePreview-Key; nach Gate räumt `syncSelectedFilesFromQueue` selectedFiles.
|
|
||||||
- #3 (done-File bleibt in selectedFiles → re-preview) → **REAL (Advisor-Catch) & gefixt.** Meine
|
|
||||||
erste Abweisung war zu schnell: `syncSelectedFilesFromQueue` läuft NICHT beim Mid-Upload-Close.
|
|
||||||
Bei `removeFromQueueOnDone=ON` werden fertige Jobs aus queueJobs entfernt, bleiben aber in
|
|
||||||
selectedFiles; `updateUploadView`→`buildQueuePreview` (Startup, Zeile 990) re-materialisiert sie
|
|
||||||
als Preview-Ghost NACH dem Gate → sticky. Fix: `completedSelectionKeys` (queue-dedup.js) seedet
|
|
||||||
beim Start `_completedUploadKeys` (full-path, log-basiert/hard-kill-durabel, gleiche Ambiguity-
|
|
||||||
Guard) → buildQueuePreview überspringt fertige (file|hoster)-Paare. Nur relevant bei
|
|
||||||
removeFromQueueOnDone=ON (Default OFF).
|
|
||||||
- #1 (basename-Kollision droppt PENDING Datei = Lost Work) → **REAL & gefixt.** FIX A's ts-Regel
|
|
||||||
keyt auf basename, Restore-Collapse auf full path → zwei gleichnamige Dateien aus verschiedenen
|
|
||||||
Ordnern an denselben Hoster: die geloggte droppte fälschlich auch die andere PENDING. **Ambiguity-
|
|
||||||
Guard** in queue-dedup.js: ts-Regel wird unterdrückt, wenn ein basename|hoster-Key auf mehrere
|
|
||||||
DISTINKTE Pfade zeigt (done-Regel unberührt). Fail-safe: schlimmstenfalls überlebt ein
|
|
||||||
sichtbarer Ghost, NIE stiller Datenverlust.
|
|
||||||
- **Un-gehuntete Bereiche selbst abgeklopft:** DST/Clock-Skew → Fehler nur in SICHERER Richtung
|
|
||||||
(Ghost bleibt, kein Lost Work), inhärente Grenze von Sekunden-Lokalzeit-Logs. Log-Discovery
|
|
||||||
readdir-Filter `startsWith(base)&&endsWith(ext)` fängt single/daily/session — keine verpassten
|
|
||||||
Log-Files. 353/353 grün, ESLint clean, 3× Suite ohne Flake.
|
|
||||||
|
|||||||
@ -1,10 +1,20 @@
|
|||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
|
const os = require('os');
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const RemoteServer = require('../lib/remote-server');
|
const RemoteServer = require('../lib/remote-server');
|
||||||
|
|
||||||
const TOKEN = 'a'.repeat(64);
|
const TOKEN = 'a'.repeat(64);
|
||||||
|
|
||||||
|
function firstLanIpv4() {
|
||||||
|
for (const entry of Object.values(os.networkInterfaces())) {
|
||||||
|
for (const net of (entry || [])) {
|
||||||
|
if (net && net.family === 'IPv4' && !net.internal && net.address) return net.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function startAgent(onDiagnosticRequest, extra) {
|
function startAgent(onDiagnosticRequest, extra) {
|
||||||
const srv = new RemoteServer();
|
const srv = new RemoteServer();
|
||||||
return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) })
|
return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) })
|
||||||
@ -56,6 +66,44 @@ test('a diagnostic client NEVER triggers the screen-capture window', async () =>
|
|||||||
ws.close(); agent.stop();
|
ws.close(); agent.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('allowlist gate (wiring): a non-loopback peer is closed 4005 when not allowlisted (fail-closed)', () => {
|
||||||
|
const srv = new RemoteServer();
|
||||||
|
const closeCodeFor = (remoteAddress, allowlist) => {
|
||||||
|
srv._config = { allowlist, token: TOKEN, diagnosticMode: true };
|
||||||
|
let closed = null;
|
||||||
|
srv._handleConnection({ close: (c) => { closed = c; }, on: () => {} }, { socket: { remoteAddress } });
|
||||||
|
return closed;
|
||||||
|
};
|
||||||
|
assert.equal(closeCodeFor('100.64.0.9', []), 4005, 'empty allowlist => non-loopback rejected (fail-closed)');
|
||||||
|
assert.equal(closeCodeFor('203.0.113.5', ['100.64.0.0/10']), 4005, 'peer outside the allowlist CIDR rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a loopback diagnostic client connects even with a non-matching allowlist (loopback is always allowed)', async () => {
|
||||||
|
const agent = await startAgent(() => {}, { allowlist: ['100.64.0.0/10'] });
|
||||||
|
const ws = connect(agent.getPort());
|
||||||
|
await new Promise((r) => ws.on('open', r));
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
|
||||||
|
const ok = await once(ws, 'auth-ok');
|
||||||
|
assert.ok(ok.clientId);
|
||||||
|
ws.close(); agent.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('network bind (0.0.0.0): an allowlisted non-loopback peer connects over a real socket (the Tailscale path)', async (t) => {
|
||||||
|
const lan = firstLanIpv4();
|
||||||
|
if (!lan) { t.skip('no non-internal IPv4 interface available'); return; }
|
||||||
|
const agent = await startAgent(() => {}, { host: '0.0.0.0', allowlist: [lan] });
|
||||||
|
const port = agent.getPort();
|
||||||
|
const ws = new WebSocket(`ws://${lan}:${port}`);
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
|
||||||
|
const ok = await once(ws, 'auth-ok');
|
||||||
|
assert.ok(ok.clientId, 'allowlisted LAN peer authed over the 0.0.0.0 bind');
|
||||||
|
} finally {
|
||||||
|
ws.close(); agent.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('wrong token is rejected and the ip is locked out after 5 attempts', async () => {
|
test('wrong token is rejected and the ip is locked out after 5 attempts', async () => {
|
||||||
const agent = await startAgent(() => {});
|
const agent = await startAgent(() => {});
|
||||||
const port = agent.getPort();
|
const port = agent.getPort();
|
||||||
|
|||||||
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