Add the remchild PoC: client, server, naming, and plan
This commit is contained in:
@@ -8,3 +8,8 @@ node_modules
|
|||||||
GITEA.md
|
GITEA.md
|
||||||
# Media archive runtime data: scrape databases, downloads, previews.
|
# Media archive runtime data: scrape databases, downloads, previews.
|
||||||
media-scrape/db/
|
media-scrape/db/
|
||||||
|
|
||||||
|
# remchild PoC artifacts
|
||||||
|
remchild/client/remchild
|
||||||
|
remchild/server/clients.jsonl
|
||||||
|
remchild/server/config.json
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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 <arpa/inet.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <ifaddrs.h>
|
||||||
|
#include <net/if.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <netinet/tcp.h>
|
||||||
|
#include <poll.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#if defined(__linux__)
|
||||||
|
#include <netpacket/packet.h>
|
||||||
|
#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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user