Files
Veskslop-t/remchild/server/server.mjs
T

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);
});