Matches the Real-Debrid-Downloader's rd-diagnostics-mcp model so the read-only
diagnostics agent is reachable over Tailscale (or any private tunnel) the same way
the downloader is, instead of requiring an SSH local-forward.
- lib/ip-allowlist.js (NEW): fail-closed IP allowlist — normalizeIp strips
::ffff:, loopback is always allowed, an empty allowlist accepts loopback ONLY
(fail-closed), exact IP + CIDR (incl. the Tailscale CGNAT range 100.64.0.0/10) +
wildcard rules. The real socket peer IP is the authority (never a forwarded header).
- remote-server.js: rejects non-allowlisted peers at connection (close 4005). Opt-in
via config.allowlist (the existing remote-control server, which passes none, is
unaffected). Loopback always passes, so local + SSH-forward use keeps working.
- Two bind modes (config diagnostics.bindMode): "local" -> 127.0.0.1 (default),
"network" -> 0.0.0.0 but ONLY when a non-empty allowlist is set (else it stays
loopback, fail-closed). The allowlist + token gate access; the tunnel
(Tailscale/WireGuard) is the confidentiality layer (transport is still plaintext ws://).
- The connection code now carries the host: mhu1_<base64url{v,h,p,t,n,fp?,s?}>. The
gateway decode is tolerant of the legacy {port,token,label} keys; connect_server
takes the host from the code (host arg is an optional override). Proven end-to-end:
the integration harness now connects with NO host arg and resolves it from the code.
- Renderer: Sichtbarkeit selector (local/network), public-host input with
suggested-host chips (os.networkInterfaces — the Tailscale IP shows up there),
allowlist textarea (network mode), and network-requires-allowlist validation.
- main.js: bindMode->host, getSuggestedRemoteHosts, host-in-code, allowlist plumbed
into startDiagnosticAgent + the diagnostics IPC (get/save/status).
- docs: rewritten for Tailscale (set the allowlist to your tailnet, put the Tailscale
IP/MagicDNS in the code address — no SSH forward needed).
This supersedes the v3.3.85 hard loopback-lock with the downloader's allowlist model.
Tests: lib/ip-allowlist (8) + remote-server allowlist wiring/loopback (2) + gateway
decode (host short-key + legacy tolerance). 393 app tests + 9 gateway tests + e2e +
host-in-code integration + adversarial all green; lint 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
68 lines
2.3 KiB
JavaScript
68 lines
2.3 KiB
JavaScript
const PREFIX = 'mhu1_';
|
|
|
|
export function encode(payload) {
|
|
if (!payload || typeof payload !== 'object') {
|
|
throw new Error('encode: payload must be an object');
|
|
}
|
|
const json = JSON.stringify(payload);
|
|
const b64 = Buffer.from(json, 'utf8').toString('base64url');
|
|
return PREFIX + b64;
|
|
}
|
|
|
|
export function decode(code) {
|
|
if (typeof code !== 'string') {
|
|
throw new Error('Invalid code: expected a string');
|
|
}
|
|
const trimmed = code.trim();
|
|
if (!trimmed.startsWith(PREFIX)) {
|
|
throw new Error('Invalid code: missing "mhu1_" prefix');
|
|
}
|
|
const b64 = trimmed.slice(PREFIX.length);
|
|
if (!b64) {
|
|
throw new Error('Invalid code: empty payload');
|
|
}
|
|
|
|
let json;
|
|
try {
|
|
json = Buffer.from(b64, 'base64url').toString('utf8');
|
|
} catch {
|
|
throw new Error('Invalid code: not valid base64url');
|
|
}
|
|
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(json);
|
|
} catch {
|
|
throw new Error('Invalid code: payload is not valid JSON');
|
|
}
|
|
|
|
if (!payload || typeof payload !== 'object') {
|
|
throw new Error('Invalid code: payload is not an object');
|
|
}
|
|
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)) {
|
|
throw new Error('Invalid code: "port" must be a number');
|
|
}
|
|
if (typeof token !== 'string' || token.length === 0) {
|
|
throw new Error('Invalid code: "token" must be a non-empty string');
|
|
}
|
|
if (label !== undefined && typeof label !== 'string') {
|
|
throw new Error('Invalid code: "label" must be a string');
|
|
}
|
|
if (payload.fp !== undefined && typeof payload.fp !== 'string') {
|
|
throw new Error('Invalid code: "fp" must be a string when present');
|
|
}
|
|
|
|
return { v: 1, host: host ? String(host) : undefined, port, token, label: label !== undefined ? String(label) : undefined, fp: payload.fp, scheme };
|
|
}
|