diff --git a/.gitignore b/.gitignore index 28903b9..5eab901 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ node_modules GITEA.md # Media archive runtime data: scrape databases, downloads, previews. media-scrape/db/ + +# remchild PoC artifacts +remchild/client/remchild +remchild/server/clients.jsonl +remchild/server/config.json diff --git a/remchild/README.md b/remchild/README.md new file mode 100644 index 0000000..5d133dd --- /dev/null +++ b/remchild/README.md @@ -0,0 +1,46 @@ +# remchild + +A remote child: a separate module that runs on another machine. Veskslop feeds it work, and it +returns results. Planned for STT, TTS, and later video and the desktop control app. See +[plan.md](plan.md) for the protocol, the heartbeat schedule, the naming rule, and the phases. + +## Layout + +| Path | What | +| --- | --- | +| `client/main.c` | The PoC client: C11, libc only, no runtime dependencies. | +| `client/words.h` | Generated from `words.json`; the client embeds the lists. | +| `words.json` | The two 128-word lists. The server and the generator read this file. | +| `server/server.mjs` | The PoC listener: allowlist, heartbeat states, ping test, history. | +| `plan.md` | The design and the phase list. | + +## Build and run + +```sh +cc -O2 -o remchild/client/remchild remchild/client/main.c + +# On the machine that runs Veskslop: +node remchild/server/server.mjs --port 8799 --ips 192.168.1.5 + +# On the remote machine: +./remchild/client/remchild --server 192.168.1.5:8799 +``` + +The client asks for the address when `--server` is absent. It prints the assigned name, then +accepts `p` (ping test) and `q` (quit). + +Non-LAN addresses in the allowlist need the launch argument: + +```sh +node remchild/server/server.mjs --port 8799 --ips 203.0.113.7 --allow-non-lan +``` + +## Test + +```sh +node remchild/server/server.mjs --port 8799 --ips 127.0.0.1 & +./remchild/client/remchild --server 127.0.0.1:8799 --once +``` + +`--once` sends one hello and one ping, prints the welcome and the round trip, and exits. History +lands in `server/clients.jsonl` (gitignored). diff --git a/remchild/client/main.c b/remchild/client/main.c new file mode 100644 index 0000000..62ef3df --- /dev/null +++ b/remchild/client/main.c @@ -0,0 +1,313 @@ +/* + * remchild — proof-of-concept remote child client. + * + * Self-contained Linux client: no runtime dependencies beyond libc. It finds + * the server, identifies itself, sends heartbeats on the documented schedule, + * and answers ping tests. Audio, STT, and video come later; see plan.md. + * + * Build: cc -O2 -o remchild remchild/client/main.c + * Run: ./remchild --server 192.168.1.10:8799 + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#endif + +#include "words.h" + +#define HEARTBEAT_FAST_MS 10000 +#define HEARTBEAT_MID_MS 30000 +#define HEARTBEAT_SLOW_MS 300000 +#define FAST_UNTIL_MS 60000 +#define MID_UNTIL_MS 360000 +#define LINE_MAX 4096 + +static long now_ms(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +/* FNV-1a over the machine bytes: stable across restarts. */ +static unsigned long long fnv1a(const unsigned char *data, size_t len) { + unsigned long long hash = 1469598103934665603ULL; + for (size_t i = 0; i < len; i++) { + hash ^= data[i]; + hash *= 1099511628211ULL; + } + return hash; +} + +/* First non-loopback MAC. Returns 0 when no usable interface exists. */ +static int machine_mac(unsigned char *out6) { + struct ifaddrs *list = NULL; + int found = 0; + + if (getifaddrs(&list) != 0) return 0; + + for (struct ifaddrs *it = list; it && !found; it = it->ifa_next) { +#if defined(__linux__) + if (!it->ifa_addr || it->ifa_addr->sa_family != AF_PACKET) continue; + if (it->ifa_flags & IFF_LOOPBACK) continue; + + struct sockaddr_ll *addr = (struct sockaddr_ll *)it->ifa_addr; + if (addr->sll_halen != 6) continue; + + int all_zero = 1; + for (int i = 0; i < 6; i++) { + out6[i] = addr->sll_addr[i]; + if (out6[i]) all_zero = 0; + } + if (!all_zero) found = 1; +#endif + } + + freeifaddrs(list); + return found; +} + +/* Fallback identity: /etc/machine-id. */ +static int machine_id(unsigned char *out, size_t cap) { + FILE *file = fopen("/etc/machine-id", "r"); + if (!file) return 0; + + size_t len = fread(out, 1, cap, file); + fclose(file); + + while (len && (out[len - 1] == '\n' || out[len - 1] == '\r')) len--; + return (int)len; +} + +/* Two words from the two lists. Same word on both sides bumps the second. */ +static void name_for_id(const unsigned char *id, size_t len, char *out, size_t cap) { + unsigned long long hash = fnv1a(id, len); + int first = (int)(hash % REMCHILD_WORD_COUNT); + int second = (int)((hash / REMCHILD_WORD_COUNT) % REMCHILD_WORD_COUNT); + + if (second == first) second = (second + 1) % REMCHILD_WORD_COUNT; + snprintf(out, cap, "%s %s", REMCHILD_FIRST[first], REMCHILD_SECOND[second]); +} + +static int connect_to(const char *host, int port) { + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons((unsigned short)port); + + if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) return -1; + + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return -1; + + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return -1; + } + + int one = 1; + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); + return fd; +} + +static void send_line(int fd, const char *line) { + char frame[1024]; + int len = snprintf(frame, sizeof(frame), "%s\n", line); + + if (len <= 0 || send(fd, frame, (size_t)len, MSG_NOSIGNAL) != len) { + fprintf(stderr, "[remchild] send failed: %s\n", strerror(errno)); + } +} + +int main(int argc, char **argv) { + char host[128] = ""; + int port = 8799; + int once = 0; + + for (int i = 1; i < argc; i++) { + if (!strcmp(argv[i], "--server") && i + 1 < argc) { + char *value = argv[++i]; + char *colon = strrchr(value, ':'); + if (colon) { + *colon = 0; + port = atoi(colon + 1); + } + snprintf(host, sizeof(host), "%s", value); + } else if (!strcmp(argv[i], "--once")) { + once = 1; + } else if (!strcmp(argv[i], "--help")) { + printf("usage: remchild [--server host:port] [--once]\n"); + return 0; + } + } + + signal(SIGPIPE, SIG_IGN); + int interactive = isatty(STDIN_FILENO); + + if (!host[0]) { + printf("Server address (host:port): "); + fflush(stdout); + char line[128]; + if (!fgets(line, sizeof(line), stdin)) return 1; + line[strcspn(line, "\n")] = 0; + char *colon = strrchr(line, ':'); + if (colon) { + *colon = 0; + port = atoi(colon + 1); + } + snprintf(host, sizeof(host), "%s", line); + } + + unsigned char id[32]; + size_t id_len = 0; + unsigned char mac[6]; + + if (machine_mac(mac)) { + memcpy(id, mac, 6); + id_len = 6; + } else { + int got = machine_id(id, sizeof(id)); + if (got <= 0) { + fprintf(stderr, "[remchild] no MAC and no /etc/machine-id; cannot identify this host\n"); + return 1; + } + id_len = (size_t)got; + } + + char name[64]; + name_for_id(id, id_len, name, sizeof(name)); + + char id_hex[17]; + snprintf(id_hex, sizeof(id_hex), "%016llx", fnv1a(id, id_len)); + + printf("[remchild] identity %s (name: %s)\n", id_len == 6 ? "MAC" : "machine-id", name); + printf("[remchild] connecting to %s:%d\n", host, port); + + int fd = connect_to(host, port); + if (fd < 0) { + if (errno == ECONNREFUSED) { + fprintf(stderr, "[remchild] could not reach %s:%d: no server is listening there.\n", host, port); + } else { + fprintf(stderr, + "[remchild] could not reach %s:%d (%s).\n" + "[remchild] A firewall may block TCP %d on the server or on this host.\n", + host, port, strerror(errno), port); + } + return 1; + } + + printf("[remchild] connected. type p + Enter for a ping test, q + Enter to quit.\n"); + + char hello[512]; + snprintf(hello, sizeof(hello), + "{\"type\":\"hello\",\"id\":\"%s\",\"name\":\"%s\",\"platform\":\"linux\",\"version\":\"0.1\"}", + id_hex, name); + send_line(fd, hello); + + long started = now_ms(); + long last_beat = started; + int ping_seq = 0; + long ping_sent_at = 0; + char buffer[LINE_MAX]; + size_t used = 0; + int answered_ping = 0; + + if (once) { + ping_seq = 1; + ping_sent_at = now_ms(); + send_line(fd, "{\"type\":\"ping\",\"seq\":1}"); + } + + for (;;) { + long now = now_ms(); + long interval = now - started < FAST_UNTIL_MS ? HEARTBEAT_FAST_MS + : now - started < MID_UNTIL_MS ? HEARTBEAT_MID_MS + : HEARTBEAT_SLOW_MS; + + if (now - last_beat >= interval) { + char beat[128]; + snprintf(beat, sizeof(beat), "{\"type\":\"heartbeat\",\"ts\":%ld}", (long)time(NULL)); + send_line(fd, beat); + last_beat = now; + } + + struct pollfd fds[2] = {{fd, POLLIN, 0}, {STDIN_FILENO, interactive ? POLLIN : 0, 0}}; + int ready = poll(fds, interactive ? 2 : 1, 500); + if (ready < 0 && errno != EINTR) break; + + if (ready > 0 && (fds[0].revents & (POLLERR | POLLHUP))) { + printf("[remchild] server closed the connection\n"); + break; + } + + if (ready > 0 && (fds[0].revents & POLLIN)) { + ssize_t got = recv(fd, buffer + used, sizeof(buffer) - used - 1, 0); + if (got <= 0) { + printf("[remchild] server closed the connection\n"); + break; + } + used += (size_t)got; + buffer[used] = 0; + + char *line = buffer; + char *newline; + while ((newline = strchr(line, '\n'))) { + *newline = 0; + if (strstr(line, "\"type\":\"welcome\"")) { + printf("[remchild] welcome: %s\n", line); + } else if (strstr(line, "\"type\":\"pong\"")) { + if (ping_sent_at) printf("[remchild] pong seq=%d rtt=%ldms\n", ping_seq, now_ms() - ping_sent_at); + answered_ping = 1; + } else if (strstr(line, "\"type\":\"ping\"")) { + int seq = 0; + sscanf(line, "%*[^\"]\"seq\":%d", &seq); + char pong[64]; + snprintf(pong, sizeof(pong), "{\"type\":\"pong\",\"seq\":%d}", seq); + send_line(fd, pong); + } else { + printf("[remchild] server: %s\n", line); + } + line = newline + 1; + } + + used = strlen(line); + memmove(buffer, line, used + 1); + } + + if (ready > 0 && (fds[1].revents & POLLIN)) { + char input[128]; + if (!fgets(input, sizeof(input), stdin)) break; + input[strcspn(input, "\n")] = 0; + + if (!strcmp(input, "q")) break; + if (!strcmp(input, "p")) { + ping_seq++; + ping_sent_at = now_ms(); + char ping[64]; + snprintf(ping, sizeof(ping), "{\"type\":\"ping\",\"seq\":%d}", ping_seq); + send_line(fd, ping); + } else if (strcmp(input, "")) { + printf("[remchild] commands: p = ping test, q = quit\n"); + } + } + + if (once && (answered_ping || now - started > 5000)) break; + } + + close(fd); + return 0; +} diff --git a/remchild/client/words.h b/remchild/client/words.h new file mode 100644 index 0000000..6c49643 --- /dev/null +++ b/remchild/client/words.h @@ -0,0 +1,269 @@ +/* Generated from words.json. One word per line, 128 per list. */ +#ifndef REMCHILD_WORDS_H +#define REMCHILD_WORDS_H + +#define REMCHILD_WORD_COUNT 128 + +static const char *const REMCHILD_FIRST[REMCHILD_WORD_COUNT] = { + "Funky", + "Chunky", + "Groovy", + "Soggy", + "Cursed", + "Blessed", + "Turbo", + "Hyper", + "Moldy", + "Slick", + "Randy", + "Beefy", + "Sleazy", + "Creepy", + "Silent", + "Loud", + "Radio", + "Cyber", + "Robo", + "Mega", + "Ultra", + "Micro", + "Nano", + "Quantum", + "Cosmic", + "Lunar", + "Solar", + "Wet", + "Dry", + "Deep", + "Grimy", + "Grumpy", + "Sleepy", + "Sneaky", + "Freaky", + "Geeky", + "Nerdy", + "Drunk", + "Sober", + "Feral", + "Tame", + "Wild", + "Mild", + "Salty", + "Sweet", + "Bitter", + "Sour", + "Spicy", + "Bland", + "Burnt", + "Raw", + "Crispy", + "Toasted", + "Frozen", + "Melting", + "Dripping", + "Leaking", + "Rusty", + "Chrome", + "Neon", + "Pastel", + "Goth", + "Punk", + "Disco", + "Techno", + "Opera", + "Jazz", + "Banjo", + "Kazoo", + "Bagpipe", + "Yodel", + "Mumble", + "Scream", + "Whisper", + "Giggle", + "Cackle", + "Sob", + "Sigh", + "Snore", + "Dozy", + "Dizzy", + "Wobbly", + "Stiff", + "Limp", + "Floaty", + "Heavy", + "Tiny", + "Huge", + "Long", + "Short", + "Thin", + "Fat", + "Bald", + "Hairy", + "Beardy", + "Toothless", + "One-Eyed", + "Two-Faced", + "Three-Legged", + "Four-Eyed", + "Six-Fingered", + "Seven-Toed", + "Nine-Lived", + "Zero-G", + "Pixel", + "Glitchy", + "Laggy", + "Buffering", + "Offline", + "Analog", + "Digital", + "Wired", + "Wireless", + "Nuclear", + "Toxic", + "Radioactive", + "Magnetic", + "Electric", + "Static", + "Vapor", + "Misty", + "Foggy", + "Dusty", + "Muddy", + "Slimy", + "Gooey", + "Crusty", + "Flaky", +}; + +static const char *const REMCHILD_SECOND[REMCHILD_WORD_COUNT] = { + "Monkey", + "President", + "Hobo", + "Queer", + "Groin", + "Wizard", + "Goblin", + "Gnome", + "Ogre", + "Troll", + "Yeti", + "Squid", + "Octopus", + "Walrus", + "Manatee", + "Possum", + "Raccoon", + "Weasel", + "Ferret", + "Hamster", + "Gerbil", + "Pigeon", + "Seagull", + "Penguin", + "Flamingo", + "Ostrich", + "Emu", + "Turkey", + "Chicken", + "Nugget", + "Waffle", + "Pancake", + "Bagel", + "Donut", + "Muffin", + "Biscuit", + "Gravy", + "Cheese", + "Pickle", + "Onion", + "Garlic", + "Potato", + "Turnip", + "Radish", + "Cabbage", + "Bean", + "Lentil", + "Tofu", + "Sausage", + "Meatball", + "Kebab", + "Taco", + "Burrito", + "Nacho", + "Pretzel", + "Crouton", + "Noodle", + "Dumpling", + "Pierogi", + "Goulash", + "Chili", + "Stew", + "Soup", + "Broth", + "Sludge", + "Goo", + "Ooze", + "Slime", + "Booger", + "Toenail", + "Earwax", + "Bellybutton", + "Armpit", + "Elbow", + "Kneecap", + "Ankle", + "Spleen", + "Kidney", + "Liver", + "Gizzard", + "Bladder", + "Gullet", + "Nostril", + "Eyeball", + "Eyelash", + "Eyebrow", + "Forehead", + "Chin", + "Cheek", + "Thumb", + "Pinky", + "Knuckle", + "Wrist", + "Shin", + "Thigh", + "Butt", + "Torso", + "Spine", + "Skull", + "Rib", + "Jaw", + "Tongue", + "Tooth", + "Molar", + "Denture", + "Braces", + "Splint", + "Cast", + "Crutch", + "Wheelchair", + "Scooter", + "Tractor", + "Bulldozer", + "Forklift", + "Zamboni", + "Segway", + "Unicycle", + "Tricycle", + "Skateboard", + "Rollerblade", + "Pogo", + "Trampoline", + "Kite", + "Balloon", + "Blimp", + "Zeppelin", + "Submarine", + "Dinghy", +}; + +#endif diff --git a/remchild/plan.md b/remchild/plan.md new file mode 100644 index 0000000..095c99f --- /dev/null +++ b/remchild/plan.md @@ -0,0 +1,105 @@ +# remchild — remote child plan + +`remchild` is a separate module that runs on a remote machine. Veskslop feeds it work and it feeds +results back. Planned uses: STT (local SAM/VOX stack or an AI model), TTS, later video (a window or +an MPV file), and finally the full desktop control app. + +Status: PoC on branch `remchild` (client + server + naming). No Web UI yet. + +## What the PoC does + +| Piece | Path | Notes | +| --- | --- | --- | +| Client | `client/main.c` | C11, libc only, no runtime dependencies. Linux. | +| Word lists | `words.json`, `client/words.h` | Two lists of 128 words. The header is generated for the client. | +| Server | `server/server.mjs` | Node, no dependencies. Proves the listener, allowlist, heartbeat, ping test, and history. | +| Test | `client/remchild --once` | One hello, one ping, one pong, exit. | + +Build and run: + +```sh +cc -O2 -o remchild/client/remchild remchild/client/main.c +node remchild/server/server.mjs --port 8799 --ips 192.168.1.5 +./remchild/client/remchild --server 192.168.1.5:8799 +``` + +The client prints its assigned name, connects, and shows `p` (ping test) and `q` (quit). The server +prints each connect, state change, and disconnect, and appends to `server/clients.jsonl`. + +## Naming + +Each client gets two words. The pair comes from the first non-loopback MAC address (fallback +`/etc/machine-id`): + +1. Hash the identity bytes with FNV-1a 64. +2. `first = hash % 128`, `second = (hash / 128) % 128`. +3. If both indexes are equal, `second = (second + 1) % 128`, so `Queer Queer` becomes + `Queer Groin`. + +The lists share words on purpose, so `Funky Monkey`, `Funky Queer`, and `Queer Monkey` are all +possible. A random MAC changes the name; a stable machine-id does not. + +## Heartbeats and states + +The client sends `heartbeat` on this schedule after boot: + +| Time since start | Interval | +| --- | --- | +| 0 to 60 s | 10 s | +| 60 s to 6 min | 30 s | +| after 6 min | 5 min | + +The server keeps three states from `lastSeen`: + +| State | Age | Effect | +| --- | --- | --- | +| `active` | under 12 min | Jobs and commands may be sent | +| `stale` | 12 to 30 min | No jobs or commands | +| `gone` | over 30 min | Considered offline; a late heartbeat revives it | + +First and last seen are kept in memory and written to `clients.jsonl`. `SIGINT` prints the table. + +## Transport + +Newline-delimited JSON over TCP on a dedicated port (PoC: 8799). No Discord connection, no HTTP +framework. + +| Direction | Message | Meaning | +| --- | --- | --- | +| client → server | `hello` | `id`, `name`, `platform`, `version` | +| server → client | `welcome` | Assigned name, heartbeat interval, server name | +| client → server | `heartbeat` | `ts` | +| both | `ping` / `pong` | `seq` echo, for the test button and latency | +| server → client | `job` | Later: a unit of work with an ID and a payload | +| client → server | `job-result` | Later: `id`, `ok`, `data` or `error` | + +Binary payloads (PCM, video frames) get their own framing later: a length-prefixed binary frame on +the same socket, or a second UDP channel for audio where loss is cheap. + +## Server side in Veskslop + +The PoC server moves into the daemon as `poc/src/remchild-server.ts`: + +- Separate listener, default port 8799 (`VESKSLOP_REMCHILD_PORT`). +- Settings: `preferences.remchild.allow` (IP list) and `preferences.remchild.allowNonLan`. +- The launch argument `--allow-non-lan` (or `VESKSLOP_REMCHILD_ALLOW_NON_LAN=1`) is required before + a non-LAN address in the list is accepted. Without the list, only LAN and Tailscale ranges pass. +- The daemon never scans for clients. It listens, records, and dispatches. +- The client registry feeds a Status section later; no Web UI work is needed now. + +## Phases + +| Phase | Work | +| --- | --- | +| M1 (done) | PoC: naming, listen, allowlist, heartbeat, ping test, history | +| M2 | Move the server into the daemon, add settings, the launch-arg unlock, and tests. Add `job` dispatch and `job-result` with retry and dedupe. | +| M3 | Audio: the daemon sends call audio to the client, the client runs VAD and STT (SAM/VOX or an AI model), and returns annotated text and voice commands. The same path carries TTS back. | +| M4 | Video: stream a window or play a file with MPV, and return frames or a stream URL. | +| M5 | Desktop: a rust/ImGui control app (Dear ImGui over GLFW or SDL2). The terminal UI in the PoC is a placeholder because the current host has no GUI dev libraries. | + +## Open questions + +- Track-to-user audio mapping for remote STT (see [../docs/voice-audio-split.md](../docs/voice-audio-split.md)). +- Job retry policy and idempotency keys. +- Whether audio uses the same socket or a second channel. +- Wake-on-LAN or reconnect backoff for a client that drops. diff --git a/remchild/server/server.mjs b/remchild/server/server.mjs new file mode 100644 index 0000000..e7f5062 --- /dev/null +++ b/remchild/server/server.mjs @@ -0,0 +1,228 @@ +/* + * 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 + */ + +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 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 (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 (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: ${configured.length ? configured.join(", ") : "LAN only (no list configured)"}` + ); + 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); +}); diff --git a/remchild/words.json b/remchild/words.json new file mode 100644 index 0000000..ffa53cd --- /dev/null +++ b/remchild/words.json @@ -0,0 +1,262 @@ +{ + "first": [ + "Funky", + "Chunky", + "Groovy", + "Soggy", + "Cursed", + "Blessed", + "Turbo", + "Hyper", + "Moldy", + "Slick", + "Randy", + "Beefy", + "Sleazy", + "Creepy", + "Silent", + "Loud", + "Radio", + "Cyber", + "Robo", + "Mega", + "Ultra", + "Micro", + "Nano", + "Quantum", + "Cosmic", + "Lunar", + "Solar", + "Wet", + "Dry", + "Deep", + "Grimy", + "Grumpy", + "Sleepy", + "Sneaky", + "Freaky", + "Geeky", + "Nerdy", + "Drunk", + "Sober", + "Feral", + "Tame", + "Wild", + "Mild", + "Salty", + "Sweet", + "Bitter", + "Sour", + "Spicy", + "Bland", + "Burnt", + "Raw", + "Crispy", + "Toasted", + "Frozen", + "Melting", + "Dripping", + "Leaking", + "Rusty", + "Chrome", + "Neon", + "Pastel", + "Goth", + "Punk", + "Disco", + "Techno", + "Opera", + "Jazz", + "Banjo", + "Kazoo", + "Bagpipe", + "Yodel", + "Mumble", + "Scream", + "Whisper", + "Giggle", + "Cackle", + "Sob", + "Sigh", + "Snore", + "Dozy", + "Dizzy", + "Wobbly", + "Stiff", + "Limp", + "Floaty", + "Heavy", + "Tiny", + "Huge", + "Long", + "Short", + "Thin", + "Fat", + "Bald", + "Hairy", + "Beardy", + "Toothless", + "One-Eyed", + "Two-Faced", + "Three-Legged", + "Four-Eyed", + "Six-Fingered", + "Seven-Toed", + "Nine-Lived", + "Zero-G", + "Pixel", + "Glitchy", + "Laggy", + "Buffering", + "Offline", + "Analog", + "Digital", + "Wired", + "Wireless", + "Nuclear", + "Toxic", + "Radioactive", + "Magnetic", + "Electric", + "Static", + "Vapor", + "Misty", + "Foggy", + "Dusty", + "Muddy", + "Slimy", + "Gooey", + "Crusty", + "Flaky" + ], + "second": [ + "Monkey", + "President", + "Hobo", + "Queer", + "Groin", + "Wizard", + "Goblin", + "Gnome", + "Ogre", + "Troll", + "Yeti", + "Squid", + "Octopus", + "Walrus", + "Manatee", + "Possum", + "Raccoon", + "Weasel", + "Ferret", + "Hamster", + "Gerbil", + "Pigeon", + "Seagull", + "Penguin", + "Flamingo", + "Ostrich", + "Emu", + "Turkey", + "Chicken", + "Nugget", + "Waffle", + "Pancake", + "Bagel", + "Donut", + "Muffin", + "Biscuit", + "Gravy", + "Cheese", + "Pickle", + "Onion", + "Garlic", + "Potato", + "Turnip", + "Radish", + "Cabbage", + "Bean", + "Lentil", + "Tofu", + "Sausage", + "Meatball", + "Kebab", + "Taco", + "Burrito", + "Nacho", + "Pretzel", + "Crouton", + "Noodle", + "Dumpling", + "Pierogi", + "Goulash", + "Chili", + "Stew", + "Soup", + "Broth", + "Sludge", + "Goo", + "Ooze", + "Slime", + "Booger", + "Toenail", + "Earwax", + "Bellybutton", + "Armpit", + "Elbow", + "Kneecap", + "Ankle", + "Spleen", + "Kidney", + "Liver", + "Gizzard", + "Bladder", + "Gullet", + "Nostril", + "Eyeball", + "Eyelash", + "Eyebrow", + "Forehead", + "Chin", + "Cheek", + "Thumb", + "Pinky", + "Knuckle", + "Wrist", + "Shin", + "Thigh", + "Butt", + "Torso", + "Spine", + "Skull", + "Rib", + "Jaw", + "Tongue", + "Tooth", + "Molar", + "Denture", + "Braces", + "Splint", + "Cast", + "Crutch", + "Wheelchair", + "Scooter", + "Tractor", + "Bulldozer", + "Forklift", + "Zamboni", + "Segway", + "Unicycle", + "Tricycle", + "Skateboard", + "Rollerblade", + "Pogo", + "Trampoline", + "Kite", + "Balloon", + "Blimp", + "Zeppelin", + "Submarine", + "Dinghy" + ] +}