Add UDP discovery and heartbeat-miss notices to remchild
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user