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

235 lines
8.5 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
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import net from "node:net";
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 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 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}`);
}
}
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();
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(),
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) {
client.heartbeats++;
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);
});