328 lines
12 KiB
JavaScript
328 lines
12 KiB
JavaScript
/*
|
|
* 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);
|
|
});
|