Add UDP discovery and heartbeat-miss notices to remchild

This commit is contained in:
Fisto
2026-09-22 19:31:19 +02:00
parent a7ab2a22f7
commit e38d7d07a2
4 changed files with 278 additions and 22 deletions
+93
View File
@@ -9,10 +9,15 @@
* 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";
@@ -30,6 +35,8 @@ function arg(name, fallback = undefined) {
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"));
@@ -62,6 +69,8 @@ function allowed(address) {
return configured.includes(address);
}
const HEARTBEAT_GRACE_MS = 5000;
const clients = new Map();
mkdirSync(dirname(historyFile), { recursive: true });
@@ -92,6 +101,32 @@ function touch(client, 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()) {
@@ -105,6 +140,55 @@ setInterval(() => {
}
}, 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";
@@ -151,6 +235,13 @@ const server = net.createServer(socket => {
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"
};
@@ -173,7 +264,9 @@ const server = net.createServer(socket => {
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;