Multi-Hoster-Upload/lib/remote-server.js
Administrator 0c6c502aab feat(diagnostics): network bind + fail-closed IP allowlist + host-in-code (Tailscale, like rd-diagnostics-mcp)
Matches the Real-Debrid-Downloader's rd-diagnostics-mcp model so the read-only
diagnostics agent is reachable over Tailscale (or any private tunnel) the same way
the downloader is, instead of requiring an SSH local-forward.

- lib/ip-allowlist.js (NEW): fail-closed IP allowlist — normalizeIp strips
  ::ffff:, loopback is always allowed, an empty allowlist accepts loopback ONLY
  (fail-closed), exact IP + CIDR (incl. the Tailscale CGNAT range 100.64.0.0/10) +
  wildcard rules. The real socket peer IP is the authority (never a forwarded header).
- remote-server.js: rejects non-allowlisted peers at connection (close 4005). Opt-in
  via config.allowlist (the existing remote-control server, which passes none, is
  unaffected). Loopback always passes, so local + SSH-forward use keeps working.
- Two bind modes (config diagnostics.bindMode): "local" -> 127.0.0.1 (default),
  "network" -> 0.0.0.0 but ONLY when a non-empty allowlist is set (else it stays
  loopback, fail-closed). The allowlist + token gate access; the tunnel
  (Tailscale/WireGuard) is the confidentiality layer (transport is still plaintext ws://).
- The connection code now carries the host: mhu1_<base64url{v,h,p,t,n,fp?,s?}>. The
  gateway decode is tolerant of the legacy {port,token,label} keys; connect_server
  takes the host from the code (host arg is an optional override). Proven end-to-end:
  the integration harness now connects with NO host arg and resolves it from the code.
- Renderer: Sichtbarkeit selector (local/network), public-host input with
  suggested-host chips (os.networkInterfaces — the Tailscale IP shows up there),
  allowlist textarea (network mode), and network-requires-allowlist validation.
- main.js: bindMode->host, getSuggestedRemoteHosts, host-in-code, allowlist plumbed
  into startDiagnosticAgent + the diagnostics IPC (get/save/status).
- docs: rewritten for Tailscale (set the allowlist to your tailnet, put the Tailscale
  IP/MagicDNS in the code address — no SSH forward needed).

This supersedes the v3.3.85 hard loopback-lock with the downloader's allowlist model.
Tests: lib/ip-allowlist (8) + remote-server allowlist wiring/loopback (2) + gateway
decode (host short-key + legacy tolerance). 393 app tests + 9 gateway tests + e2e +
host-in-code integration + adversarial all green; lint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:32:29 +02:00

215 lines
5.7 KiB
JavaScript

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));
const y = Buffer.from(String(b == null ? '' : b));
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
class RemoteServer {
constructor() {
this._wss = null;
this._clients = new Map(); // ws -> { id, role, authenticated }
this._config = null;
this._failedAttempts = new Map(); // ip -> { count, blockedUntil }
this._lastAccess = null;
}
start(opts) {
return new Promise((resolve, reject) => {
this._config = opts;
const wssOpts = { port: opts.port };
if (opts.host) wssOpts.host = opts.host;
this._wss = new WebSocketServer(wssOpts, () => {
resolve();
});
this._wss.on('error', (err) => {
reject(err);
});
this._wss.on('connection', (ws, req) => {
this._handleConnection(ws, req);
});
});
}
stop() {
if (this._wss) {
for (const [ws] of this._clients) {
ws.close(1000, 'Server shutting down');
}
this._clients.clear();
this._wss.close();
this._wss = null;
}
}
getClientCount() {
let count = 0;
for (const [, client] of this._clients) {
if (client.authenticated) count++;
}
return count;
}
getPort() {
if (this._wss && this._wss.address()) {
return this._wss.address().port;
}
return null;
}
_handleConnection(ws, req) {
const ip = req.socket.remoteAddress || 'unknown';
if (this._isBlocked(ip)) {
ws.close(4003, 'Too many failed attempts');
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 });
let authReceived = false;
const authTimeout = setTimeout(() => {
if (!authReceived) {
ws.close(4001, 'Auth timeout');
this._clients.delete(ws);
}
}, 5000);
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
const client = this._clients.get(ws);
if (!client) return;
if (!client.authenticated) {
authReceived = true;
clearTimeout(authTimeout);
if (msg.type === 'auth' && timingSafeEqualStr(msg.token, this._config.token)) {
client.authenticated = true;
client.role = this._config.diagnosticMode ? 'diagnostic' : (msg.role || 'viewer');
this._lastAccess = Date.now();
ws.send(JSON.stringify({ type: 'auth-ok', clientId }));
if (!this._config.diagnosticMode && this.getClientCount() === 1) {
this._config.onCreateCaptureWindow();
}
} else {
this._recordFailedAttempt(ip);
ws.close(4002, 'Invalid token');
this._clients.delete(ws);
}
return;
}
if (this._config.diagnosticMode) {
if (msg.type === 'diag-request' && typeof this._config.onDiagnosticRequest === 'function') {
this._lastAccess = Date.now();
this._config.onDiagnosticRequest(msg, client, (payload) => {
this.sendToClient(client.id, { type: 'diag-response', reqId: msg.reqId, ...payload });
});
}
return;
}
if (msg.type === 'offer' || msg.type === 'ice-candidate') {
msg.clientId = client.id;
msg.role = client.role;
this._config.onSignalingToCapture(msg);
}
});
ws.on('close', () => {
clearTimeout(authTimeout);
const client = this._clients.get(ws);
const wasAuthenticated = client && client.authenticated;
this._clients.delete(ws);
if (wasAuthenticated && !this._config.diagnosticMode) {
this._config.onSignalingToCapture({
type: 'client-disconnected',
clientId: client.id
});
if (this.getClientCount() === 0) {
this._config.onDestroyCaptureWindow();
}
}
});
ws.on('error', () => {
clearTimeout(authTimeout);
const client = this._clients.get(ws);
const wasAuthenticated = client && client.authenticated;
this._clients.delete(ws);
if (wasAuthenticated && !this._config.diagnosticMode) {
this._config.onSignalingToCapture({
type: 'client-disconnected',
clientId: client.id
});
if (this.getClientCount() === 0) {
this._config.onDestroyCaptureWindow();
}
}
});
}
getLastAccess() {
return this._lastAccess;
}
sendToClient(clientId, data) {
for (const [ws, client] of this._clients) {
if (client.id === clientId && client.authenticated) {
ws.send(JSON.stringify(data));
break;
}
}
}
broadcast(data) {
const msg = JSON.stringify(data);
for (const [ws, client] of this._clients) {
if (client.authenticated && ws.readyState === 1) {
ws.send(msg);
}
}
}
_isBlocked(ip) {
const entry = this._failedAttempts.get(ip);
if (!entry) return false;
if (entry.blockedUntil && Date.now() < entry.blockedUntil) return true;
if (entry.blockedUntil && Date.now() >= entry.blockedUntil) {
this._failedAttempts.delete(ip);
return false;
}
return false;
}
_recordFailedAttempt(ip) {
const entry = this._failedAttempts.get(ip) || { count: 0, blockedUntil: null };
entry.count++;
if (entry.count >= 5) {
entry.blockedUntil = Date.now() + 60000;
}
this._failedAttempts.set(ip, entry);
}
}
module.exports = RemoteServer;