/* * remchild server — proof of concept. * * Listens for remchild clients on a dedicated port, accepts only known IPs, * records heartbeats, and keeps a history of first and last seen. The proveout * runs standalone; the plan moves it into the daemon with settings and the * launch-arg unlock. * * Run: node remchild/server/server.mjs --port 8799 --ips 127.0.0.1,192.168.1.5 * node remchild/server/server.mjs --allow-non-lan --ips 203.0.113.7 * node remchild/server/server.mjs --allow-all # testing: any address * * Discovery: a UDP beacon on 8798 answers probes and advertises the server on * every interface, so remchild can find it without a typed address. */ import dgram from "node:dgram"; import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import net from "node:net"; import { hostname, networkInterfaces } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); const STALE_MS = 12 * 60 * 1000; // no jobs after this const GONE_MS = 30 * 60 * 1000; // considered gone after this function arg(name, fallback = undefined) { const index = process.argv.indexOf(`--${name}`); if (index < 0) return fallback; const value = process.argv[index + 1]; return value && !value.startsWith("--") ? value : true; } const port = Number(arg("port", process.env.REMCHILD_PORT ?? 8799)); const allowNonLan = arg("allow-non-lan", false) !== false; const allowAll = arg("allow-all", false) !== false; const discoveryPort = Number(arg("discovery-port", process.env.REMCHILD_DISCOVERY_PORT ?? 8798)); const beaconMs = Number(arg("beacon-ms", 3000)); const historyFile = arg("history", join(HERE, "clients.jsonl")); const configFile = arg("config", join(HERE, "config.json")); let configured = []; if (typeof arg("ips") === "string") { configured = String(arg("ips")).split(",").map(value => value.trim()).filter(Boolean); } else if (existsSync(configFile)) { try { const config = JSON.parse(readFileSync(configFile, "utf8")); configured = Array.isArray(config.ips) ? config.ips.map(String) : []; } catch (error) { console.error(`[remchild] bad config ${configFile}: ${error.message}`); } } function isLan(address) { const parts = address.split(".").map(Number); if (parts.length !== 4 || parts.some(value => !Number.isInteger(value))) return false; if (parts[0] === 10 || parts[0] === 127) return true; if (parts[0] === 192 && parts[1] === 168) return true; if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) return true; // Tailscale return false; } /** The allowlist rule: known IPs only; non-LAN entries need the launch flag. */ function allowed(address) { if (allowAll) return true; if (configured.length === 0) return isLan(address); return configured.includes(address); } const HEARTBEAT_GRACE_MS = 5000; const clients = new Map(); mkdirSync(dirname(historyFile), { recursive: true }); function history(entry) { try { appendFileSync(historyFile, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n"); } catch (error) { console.error(`[remchild] history write failed: ${error.message}`); } } function stateOf(client, now) { if (now - client.lastSeen > GONE_MS) return "gone"; if (now - client.lastSeen > STALE_MS) return "stale"; return "active"; } function touch(client, reason) { client.lastSeen = Date.now(); const state = stateOf(client, client.lastSeen); if (state !== client.state) { console.log(`[remchild] ${client.name} is ${state}`); history({ event: state, id: client.id, name: client.name }); client.state = state; } else if (reason !== "heartbeat") { console.log(`[remchild] ${client.name} ${reason}`); } } function heartbeatInterval(client) { return Math.min(Math.max(Number(client.heartbeatMs) || 300000, 5000), 300000); } /** * Heartbeat misses are informational. Only the fast phases (10 s and 30 s * heartbeats) are watched; the 5 minute steady state relies on the stale and * gone states instead. */ setInterval(() => { const now = Date.now(); for (const client of clients.values()) { if (!client.socket) continue; if (heartbeatInterval(client) > 30000) continue; if (now <= client.nextDue) continue; client.missed++; client.nextDue = now + heartbeatInterval(client) + HEARTBEAT_GRACE_MS; console.log(`[remchild] ${client.name} missed heartbeat ${client.missed}`); client.socket.write( JSON.stringify({ type: "notice", code: "heartbeat-missed", missed: client.missed }) + "\n" ); } }, 2000).unref(); setInterval(() => { const now = Date.now(); for (const client of clients.values()) { const state = stateOf(client, now); if (state !== client.state) { if (state === "stale") console.log(`[remchild] ${client.name} is stale (no heartbeat for 12 min)`); if (state === "gone") console.log(`[remchild] ${client.name} is gone (no heartbeat for 30 min)`); history({ event: state, id: client.id, name: client.name }); client.state = state; } } }, 30_000).unref(); /* ── Discovery beacon ─────────────────────────────────────────────────────── */ function beacon() { return JSON.stringify({ type: "veskslop-remchild", name: hostname() || "veskslop", port, id: `veskslop-${process.pid}` }); } const discovery = dgram.createSocket({ type: "udp4", reuseAddr: true }); discovery.on("message", (message, remote) => { if (!message.toString("utf8").includes("probe")) return; const payload = Buffer.from(beacon()); discovery.send(payload, remote.port, remote.address, () => {}); }); discovery.on("error", error => { console.error(`[remchild] discovery failed: ${error.message}`); }); if (beaconMs > 0) { setInterval(() => { const payload = Buffer.from(beacon()); // Per-interface subnet broadcast, plus loopback for clients on this host. for (const list of Object.values(networkInterfaces())) { for (const info of list ?? []) { if (info.family !== "IPv4" || info.internal || !info.netmask) continue; const address = info.address.split(".").map(Number); const mask = info.netmask.split(".").map(Number); const broadcast = address.map((octet, index) => (octet | (~mask[index] & 255)) >>> 0).join("."); discovery.send(payload, discoveryPort, broadcast, () => {}); } } discovery.send(payload, discoveryPort, "127.0.0.1", () => {}); }, beaconMs).unref(); } discovery.bind(discoveryPort, () => { discovery.setBroadcast(true); console.log( `[remchild] discovery on udp/${discoveryPort}${beaconMs > 0 ? ` (beacon every ${beaconMs} ms)` : " (probe replies only)"}` ); }); const server = net.createServer(socket => { const address = socket.remoteAddress?.replace(/^::ffff:/, "") ?? "unknown"; if (!allowed(address)) { console.log(`[remchild] rejected ${address}${configured.length ? " (not in the allowlist)" : " (not LAN)"}`); socket.end(JSON.stringify({ type: "reject", reason: "not allowed" }) + "\n"); return; } if (!allowAll && configured.length && !isLan(address) && allowNonLan !== true) { console.log(`[remchild] rejected ${address} (non-LAN address needs --allow-non-lan)`); socket.end(JSON.stringify({ type: "reject", reason: "non-lan locked" }) + "\n"); return; } console.log(`[remchild] connection from ${address}`); let buffer = ""; socket.on("data", chunk => { buffer += chunk.toString("utf8"); let newline; while ((newline = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1); if (!line) continue; let message; try { message = JSON.parse(line); } catch { console.log(`[remchild] bad line from ${address}: ${line.slice(0, 80)}`); continue; } if (message.type === "hello") { const id = String(message.id ?? address); const existing = clients.get(id); const client = { id, name: String(message.name ?? "Unnamed"), platform: String(message.platform ?? "unknown"), address, socket, firstSeen: existing?.firstSeen ?? Date.now(), lastSeen: Date.now(), connectedAt: Date.now(), heartbeatMs: Number(message.heartbeatMs) > 0 ? Number(message.heartbeatMs) : 300000, missed: 0, nextDue: Date.now() + (Number(message.heartbeatMs) > 0 ? Number(message.heartbeatMs) : 300000) + HEARTBEAT_GRACE_MS, heartbeats: 0, state: "active" }; clients.set(id, client); history({ event: "hello", id, name: client.name, address, platform: client.platform }); console.log(`[remchild] ${client.name} (${id}) connected from ${address}`); socket.write( JSON.stringify({ type: "welcome", name: client.name, heartbeatMs: 300000, server: "veskslop-remchild-poc" }) + "\n" ); continue; } if (message.type === "heartbeat") { const client = [...clients.values()].find(item => item.socket === socket); if (client) { if (Number(message.heartbeatMs) > 0) client.heartbeatMs = Number(message.heartbeatMs); client.heartbeats++; client.nextDue = Date.now() + heartbeatInterval(client) + HEARTBEAT_GRACE_MS; touch(client, "heartbeat"); } continue; } if (message.type === "ping") { socket.write(JSON.stringify({ type: "pong", seq: message.seq ?? 0 }) + "\n"); continue; } if (message.type === "pong") { const client = [...clients.values()].find(item => item.socket === socket); if (client) console.log(`[remchild] ${client.name} pong seq=${message.seq ?? 0}`); continue; } console.log(`[remchild] ${address}: ${message.type}`); } }); socket.on("close", () => { const client = [...clients.values()].find(item => item.socket === socket); if (client) { client.socket = undefined; history({ event: "disconnect", id: client.id, name: client.name, address: client.address, lastSeen: new Date(client.lastSeen).toISOString() }); console.log(`[remchild] ${client.name} disconnected`); } }); socket.on("error", () => {}); }); server.listen(port, () => { console.log(`[remchild] listening on 0.0.0.0:${port}`); console.log( `[remchild] allowlist: ${ allowAll ? "ALL (testing mode)" : configured.length ? configured.join(", ") : "LAN only (no list configured)" }` ); if (allowAll) console.log("[remchild] WARNING: every address is accepted; there is no auth"); else if (allowNonLan === true) console.log("[remchild] non-LAN addresses are unlocked"); }); process.on("SIGINT", () => { console.log("\n[remchild] client history:"); for (const client of clients.values()) { console.log( ` ${client.name} first ${new Date(client.firstSeen).toISOString()} last ${new Date(client.lastSeen).toISOString()}` ); } process.exit(0); });