235 lines
8.5 KiB
JavaScript
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);
|
|
});
|