From 7dc278547bf1c6a7818ce49fe7a4f4e360e6e4ba Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 9 Jun 2026 14:23:52 -0700 Subject: [PATCH] fedac/native: lanserv LAN dev server (http + mdns/bonjour) + ssh in oven builds - lanserv.c: HTTP control endpoint (status/logs/piece push/jump) on :80 and an mDNS responder so the device answers at .local and shows up in Bonjour browsers via _http._tcp, printer-style - /tmp/ac-jump watcher in the main loop: shared hot-reload trigger for lanserv HTTP and ssh (echo notepat > /tmp/ac-jump) - docker-build.sh: bundle dropbear (already on the builder image) so OTA builds get the ssh daemon notepat already auto-starts on wifi; bake keys/authorized_keys and switch dropbear to key-only when present; fix /etc/passwd to full 7-field form (home + shell for dropbear) - persist the dropbear host key on the EFI partition for a stable ssh identity across reboots - scripts/dev-push.sh: curl-based push + reload loop (--watch) --- fedac/native/Makefile | 1 + fedac/native/docker-build.sh | 38 +- fedac/native/keys/authorized_keys | 1 + fedac/native/scripts/dev-push.sh | 101 +++++ fedac/native/src/ac-native.c | 35 ++ fedac/native/src/js-bindings.c | 23 +- fedac/native/src/lanserv.c | 698 ++++++++++++++++++++++++++++++ fedac/native/src/lanserv.h | 22 + 8 files changed, 911 insertions(+), 8 deletions(-) create mode 100644 fedac/native/keys/authorized_keys create mode 100755 fedac/native/scripts/dev-push.sh create mode 100644 fedac/native/src/lanserv.c create mode 100644 fedac/native/src/lanserv.h diff --git a/fedac/native/Makefile b/fedac/native/Makefile index ffb0babd58..aa80fdd18d 100644 --- a/fedac/native/Makefile +++ b/fedac/native/Makefile @@ -97,6 +97,7 @@ SRCS := $(SRCDIR)/ac-native.c \ $(SRCDIR)/camera.c \ $(SRCDIR)/pty.c \ $(SRCDIR)/machines.c \ + $(SRCDIR)/lanserv.c \ $(SRCDIR)/swank-bridge.c \ $(SRCDIR)/js-bindings.c \ $(SRCDIR)/audio-decode.c \ diff --git a/fedac/native/docker-build.sh b/fedac/native/docker-build.sh index 74f8bcb560..52bc1d6ab6 100644 --- a/fedac/native/docker-build.sh +++ b/fedac/native/docker-build.sh @@ -197,6 +197,40 @@ if [ -n "$CURL_BIN" ]; then fi # busybox wget as fallback ln -s busybox "$IROOT/bin/wget" 2>/dev/null || true + +# ── 2a3: dropbear SSH daemon + baked authorized_keys ── +# notepat auto-starts sshd once wifi connects (system.startSSH in +# js-bindings.c) and shows "ssh root@" on screen. OTA builds are +# key-only: js_start_ssh disables password auth whenever +# /.ssh/authorized_keys exists (root's home is "/"), which is baked here +# from fedac/native/keys/authorized_keys. Without that file the daemon +# falls back to -B (blank-password root) — local dev images only. +DROPBEAR_BIN=$(command -v dropbear 2>/dev/null || true) +DROPBEARKEY_BIN=$(command -v dropbearkey 2>/dev/null || true) +if [ -n "$DROPBEAR_BIN" ] && [ -n "$DROPBEARKEY_BIN" ]; then + mkdir -p "$IROOT/usr/sbin" "$IROOT/etc/dropbear" "$IROOT/.ssh" + cp -L "$DROPBEAR_BIN" "$IROOT/usr/sbin/dropbear" + cp -L "$DROPBEARKEY_BIN" "$IROOT/usr/sbin/dropbearkey" + chmod +x "$IROOT/usr/sbin/dropbear" "$IROOT/usr/sbin/dropbearkey" + ln -sf /usr/sbin/dropbear "$IROOT/bin/dropbear" + ln -sf /usr/sbin/dropbearkey "$IROOT/bin/dropbearkey" + for bin in "$DROPBEAR_BIN" "$DROPBEARKEY_BIN"; do + for dep in $(ldd "$bin" 2>/dev/null | grep -oP '/\S+'); do + base=$(basename "$dep") + [ ! -f "$IROOT/lib64/$base" ] && cp -L "$(readlink -f "$dep")" "$IROOT/lib64/$base" 2>/dev/null || true + done + done + if [ -f "$NATIVE/keys/authorized_keys" ]; then + cp "$NATIVE/keys/authorized_keys" "$IROOT/.ssh/authorized_keys" + chmod 600 "$IROOT/.ssh/authorized_keys" + log " dropbear bundled (key-only via keys/authorized_keys)" + else + log " dropbear bundled (no keys/authorized_keys — blank-password fallback)" + fi +else + log " dropbear not on builder image — device ssh disabled" +fi + # Minimal udhcpc script to configure interface after getting lease mkdir -p "$IROOT/usr/share/udhcpc" cat > "$IROOT/usr/share/udhcpc/default.script" << 'UDHCPC_SCRIPT' @@ -563,8 +597,10 @@ if [ -d /opt/alsa-ucm-conf-cros/ucm2 ]; then fi # ── 2m: /etc/group and /etc/passwd ── +# Full 7-field passwd entry: dropbear resolves uid 0's home ("/", so +# authorized_keys lives at /.ssh/) and login shell from here. echo "root:x:0:" > "$IROOT/etc/group" -echo "root:x:0:root" > "$IROOT/etc/passwd" +echo "root:x:0:0:root:/:/bin/sh" > "$IROOT/etc/passwd" # ── 2n: Claude Code ── # Prefer a freshly-fetched native binary from the official GCS "latest" diff --git a/fedac/native/keys/authorized_keys b/fedac/native/keys/authorized_keys new file mode 100644 index 0000000000..df3ca437b5 --- /dev/null +++ b/fedac/native/keys/authorized_keys @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJtloowUraFVv4YJ2RzX09gdpM+G4rEkz38Z4jPG/Gpq jas-neo diff --git a/fedac/native/scripts/dev-push.sh b/fedac/native/scripts/dev-push.sh new file mode 100755 index 0000000000..7be3013c69 --- /dev/null +++ b/fedac/native/scripts/dev-push.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# dev-push.sh — push a piece to a running AC Native device over LAN and +# hot-reload it, via the lanserv HTTP endpoint (port 80 on the device). +# +# Usage: +# scripts/dev-push.sh [piece] [--watch] +# +# scripts/dev-push.sh notepat.local # push notepat + reload +# scripts/dev-push.sh 192.168.1.214 notepat --watch +# +# is the device's mDNS name (slot.local / notepat.local) or IP. +# [piece] defaults to notepat. --watch re-pushes on save (0.5s mtime poll, +# no fswatch dependency). Lib deps (the /lib modules the oven bakes) are +# pushed when their mtimes change too — but QuickJS caches /lib imports +# per context, so lib changes only take effect after a device reboot; +# piece changes reload instantly (unique module name per load). +# +# Fallback for SSH-only builds: scp pieces/

.mjs root@:/pieces/ && +# ssh root@ 'echo

> /tmp/ac-jump' + +set -u + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +NATIVE="$(dirname "$SCRIPT_DIR")" +SRC="$(cd "$NATIVE/../.." && pwd)" +WEB_LIB="$SRC/system/public/aesthetic.computer/lib" + +HOST="${1:-}" +[ -z "$HOST" ] && { sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 1; } +shift + +PIECE="notepat" +WATCH=0 +for arg in "$@"; do + case "$arg" in + --watch) WATCH=1 ;; + *) PIECE="${arg%.mjs}" ;; + esac +done + +PIECE_FILE="$NATIVE/pieces/$PIECE.mjs" +[ -f "$PIECE_FILE" ] || { echo "dev-push: no $PIECE_FILE" >&2; exit 1; } + +# Same list as docker-build.sh §2p — modules pieces import as ../lib/X.mjs. +LIB_FILES=() +for libmjs in melody-parser.mjs notepat-convert.mjs note-colors.mjs num.mjs percussion.mjs; do + [ -f "$WEB_LIB/$libmjs" ] && LIB_FILES+=("$WEB_LIB/$libmjs") +done + +BASE="http://$HOST" + +push() { # push [jump] + local file="$1" remote="$2" jump="${3:-}" + local url="$BASE$remote" + [ -n "$jump" ] && url="$url?jump=1" + if curl -fsS --max-time 10 -X PUT --data-binary "@$file" "$url" > /dev/null; then + echo "→ $remote ($(wc -c < "$file" | tr -d ' ') bytes)${jump:+ + reload}" + else + echo "dev-push: PUT $url failed (is the device on? lanserv build?)" >&2 + return 1 + fi +} + +mtime() { + stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null +} + +echo "dev-push → $BASE piece=$PIECE" +curl -fsS --max-time 5 "$BASE/" 2>/dev/null || echo "(no status — pushing anyway)" + +for lib in ${LIB_FILES[@]+"${LIB_FILES[@]}"}; do + push "$lib" "/lib/$(basename "$lib")" || exit 1 +done +push "$PIECE_FILE" "/pieces/$PIECE.mjs" jump || exit 1 + +[ "$WATCH" = 0 ] && exit 0 + +echo "watching for changes (ctrl-c to stop)..." +declare -A SEEN +SEEN["$PIECE_FILE"]=$(mtime "$PIECE_FILE") +for lib in ${LIB_FILES[@]+"${LIB_FILES[@]}"}; do + SEEN["$lib"]=$(mtime "$lib") +done + +while sleep 0.5; do + changed=0 + for lib in ${LIB_FILES[@]+"${LIB_FILES[@]}"}; do + m=$(mtime "$lib") + if [ "$m" != "${SEEN[$lib]}" ]; then + SEEN["$lib"]=$m + push "$lib" "/lib/$(basename "$lib")" && changed=1 + fi + done + m=$(mtime "$PIECE_FILE") + if [ "$m" != "${SEEN[$PIECE_FILE]}" ]; then + SEEN["$PIECE_FILE"]=$m + push "$PIECE_FILE" "/pieces/$PIECE.mjs" jump + elif [ "$changed" = 1 ]; then + echo "(lib pushed — QuickJS caches /lib imports; reboot device to apply)" + fi +done diff --git a/fedac/native/src/ac-native.c b/fedac/native/src/ac-native.c index 6be3b45591..f319bb7d26 100644 --- a/fedac/native/src/ac-native.c +++ b/fedac/native/src/ac-native.c @@ -34,6 +34,7 @@ #include "tts.h" #include "js-bindings.h" #include "machines.h" +#include "lanserv.h" #include "recorder.h" #include "camera.h" #include @@ -2684,6 +2685,13 @@ static int draw_startup_fade(ACGraph *graph, ACFramebuffer *screen, // Initialize machines monitoring daemon machines_init(&g_machines); + // LAN dev server: HTTP control endpoint + mDNS (.local) responder +#ifdef AC_BUILD_NAME + lanserv_start(AC_BUILD_NAME); +#else + lanserv_start("dev"); +#endif + // Read cached city once for both TTS greeting (f==10) and subtitle (f>130). char greet_city[96]; read_cached_city(greet_city, sizeof greet_city); @@ -5045,6 +5053,32 @@ int main(int argc, char *argv[]) { } } + // LAN dev server: publish piece/ip for the status endpoint and + // mDNS announcer, then watch /tmp/ac-jump — the hot-reload + // trigger shared by lanserv HTTP and ssh + // (`echo notepat > /tmp/ac-jump`). + { + lanserv_update(rt->jump_target[0] ? rt->jump_target : rt->piece, + (wifi && wifi->state == WIFI_STATE_CONNECTED) + ? wifi->ip_address : ""); + if (access("/tmp/ac-jump", F_OK) == 0) { + char jbuf[64] = {0}; + FILE *jf = fopen("/tmp/ac-jump", "r"); + if (jf) { + if (fgets(jbuf, sizeof(jbuf), jf)) + jbuf[strcspn(jbuf, " \t\r\n")] = 0; + fclose(jf); + } + unlink("/tmp/ac-jump"); + if (jbuf[0]) { + strncpy(rt->jump_target, jbuf, sizeof(rt->jump_target) - 1); + rt->jump_target[sizeof(rt->jump_target) - 1] = 0; + rt->jump_requested = 1; + ac_log("[lanserv] jump → %s\n", jbuf); + } + } + } + // Submit frame to recorder (after cursor overlay, before display) if (recorder_is_recording(recorder)) recorder_submit_video(recorder, screen->pixels, screen->stride); @@ -5198,6 +5232,7 @@ int main(int argc, char *argv[]) { // Upload the complete boot-to-shutdown log before tearing down machines_flush_logs(&g_machines); machines_destroy(&g_machines); + lanserv_stop(); if (logfile) { fclose(logfile); logfile = NULL; } sync(); diff --git a/fedac/native/src/js-bindings.c b/fedac/native/src/js-bindings.c index 2964d0a933..73c2926b95 100644 --- a/fedac/native/src/js-bindings.c +++ b/fedac/native/src/js-bindings.c @@ -5906,18 +5906,27 @@ static JSValue js_start_ssh(JSContext *ctx, JSValueConst this_val, int argc, JSV return JS_NewBool(ctx, 0); } - // Generate host key if needed + // Host key: restore from the EFI partition so the device keeps a stable + // SSH identity across reboots (initramfs is volatile); generate + persist + // on first boot. if (access("/etc/dropbear/dropbear_ed25519_host_key", F_OK) != 0) { - ac_log("[ssh] generating host key...\n"); + ac_log("[ssh] preparing host key...\n"); system("mkdir -p /etc/dropbear && " - "dropbearkey -t ed25519 -f /etc/dropbear/dropbear_ed25519_host_key 2>/dev/null"); + "{ cp /mnt/.dropbear_ed25519_host_key /etc/dropbear/dropbear_ed25519_host_key 2>/dev/null || " + "{ dropbearkey -t ed25519 -f /etc/dropbear/dropbear_ed25519_host_key 2>/dev/null && " + "cp /etc/dropbear/dropbear_ed25519_host_key /mnt/.dropbear_ed25519_host_key 2>/dev/null; }; }"); } - // Start dropbear (no password auth, only key-based or allow all for now) - // -R = generate keys if missing, -B = allow blank passwords (for root w/o passwd) - // -p 22 = listen on port 22, -F = foreground (& to background) + // Key-only auth when an authorized_keys file is baked in (public OTA + // builds — root's home is "/" per /etc/passwd, so dropbear reads + // /.ssh/authorized_keys). -B (blank-password root) only as a fallback + // for local dev images without baked keys. ac_log("[ssh] starting dropbear on port 22...\n"); - system("dropbear -R -B -p 22 -P /tmp/dropbear.pid 2>/tmp/dropbear.log &"); + if (access("/.ssh/authorized_keys", F_OK) == 0) { + system("dropbear -R -s -g -p 22 -P /tmp/dropbear.pid 2>/tmp/dropbear.log &"); + } else { + system("dropbear -R -B -p 22 -P /tmp/dropbear.pid 2>/tmp/dropbear.log &"); + } ssh_started = 1; ac_log("[ssh] dropbear started\n"); return JS_NewBool(ctx, 1); diff --git a/fedac/native/src/lanserv.c b/fedac/native/src/lanserv.c new file mode 100644 index 0000000000..d87d29a08e --- /dev/null +++ b/fedac/native/src/lanserv.c @@ -0,0 +1,698 @@ +// lanserv — LAN dev server: HTTP control endpoint + mDNS/Bonjour responder. +// One pthread, select()-driven across the HTTP listener and the mDNS socket. +// Reload requests funnel through /tmp/ac-jump, the same file the ssh path +// uses, so the main loop has a single watcher for every trigger. + +#define _GNU_SOURCE // strcasestr + +#include "lanserv.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern void ac_log(const char *fmt, ...); + +#ifndef MSG_NOSIGNAL // absent on darwin (host-side syntax checks) +#define MSG_NOSIGNAL 0 +#endif + +#define LANSERV_HTTP_PORT 80 +#define LANSERV_HTTP_PORT_FALLBACK 8080 +#define LANSERV_MAX_BODY (8 * 1024 * 1024) +#define MDNS_PORT 5353 +#define MDNS_GROUP "224.0.0.251" +#define MDNS_TTL 120 + +typedef struct { + pthread_t thread; + pthread_mutex_t lock; + volatile int running; + char piece[64]; + char ip[16]; + char build[64]; + volatile int ip_changed; // thread should rejoin multicast + announce + time_t started; + int http_port; +} LanServ; + +static LanServ g_lan = {0}; + +// ── small helpers ────────────────────────────────────────────────────── + +static void lan_hostname(char *out, size_t outlen) { + out[0] = 0; + FILE *f = fopen("/mnt/.ac-device-slot", "r"); + if (f) { + if (fgets(out, (int)outlen, f)) out[strcspn(out, " \t\r\n")] = 0; + fclose(f); + } + if (!out[0]) snprintf(out, outlen, "notepat"); +} + +static void lan_snapshot(char *piece, size_t plen, char *ip, size_t iplen) { + pthread_mutex_lock(&g_lan.lock); + snprintf(piece, plen, "%s", g_lan.piece); + snprintf(ip, iplen, "%s", g_lan.ip); + pthread_mutex_unlock(&g_lan.lock); +} + +// Piece/lib filenames: [A-Za-z0-9._-], no leading dot, no "..". +static int lan_name_ok(const char *n) { + if (!n[0] || n[0] == '.') return 0; + if (strstr(n, "..")) return 0; + for (const char *p = n; *p; p++) + if (!isalnum((unsigned char)*p) && *p != '.' && *p != '_' && *p != '-') + return 0; + return 1; +} + +static void lan_trigger_jump(const char *piece) { + FILE *f = fopen("/tmp/ac-jump", "w"); + if (f) { + fprintf(f, "%s\n", piece); + fclose(f); + } +} + +// ── HTTP ─────────────────────────────────────────────────────────────── + +static int http_listen(int *port_out) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return -1; + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in a = {0}; + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_ANY); + int ports[2] = {LANSERV_HTTP_PORT, LANSERV_HTTP_PORT_FALLBACK}; + for (int i = 0; i < 2; i++) { + a.sin_port = htons((uint16_t)ports[i]); + if (bind(fd, (struct sockaddr *)&a, sizeof(a)) == 0) { + if (listen(fd, 8) == 0) { + *port_out = ports[i]; + return fd; + } + break; + } + } + close(fd); + return -1; +} + +static void http_send(int fd, int code, const char *status, + const char *ctype, const char *body, size_t blen) { + char head[256]; + int hl = snprintf(head, sizeof(head), + "HTTP/1.1 %d %s\r\nContent-Type: %s\r\n" + "Content-Length: %zu\r\nConnection: close\r\n" + "Access-Control-Allow-Origin: *\r\n\r\n", + code, status, ctype, blen); + send(fd, head, (size_t)hl, MSG_NOSIGNAL); + if (body && blen) send(fd, body, blen, MSG_NOSIGNAL); +} + +static void http_send_text(int fd, int code, const char *status, const char *msg) { + http_send(fd, code, status, "text/plain", msg, strlen(msg)); +} + +static void http_serve_file(int fd, const char *path, const char *ctype) { + FILE *f = fopen(path, "rb"); + if (!f) { + http_send_text(fd, 404, "Not Found", "no such file\n"); + return; + } + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + char head[256]; + int hl = snprintf(head, sizeof(head), + "HTTP/1.1 200 OK\r\nContent-Type: %s\r\n" + "Content-Length: %ld\r\nConnection: close\r\n\r\n", + ctype, sz); + send(fd, head, (size_t)hl, MSG_NOSIGNAL); + char buf[16384]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), f)) > 0) + if (send(fd, buf, n, MSG_NOSIGNAL) < 0) break; + fclose(f); +} + +// Tail the newest runtime log (last 64KB). +static void http_serve_logs(int fd) { + const char *cands[] = {"/mnt/cage-child.log", "/mnt/ac-native.log", + "/tmp/ac-native-cage.log"}; + FILE *f = NULL; + for (int i = 0; i < 3 && !f; i++) f = fopen(cands[i], "rb"); + if (!f) { + http_send_text(fd, 404, "Not Found", "no log file\n"); + return; + } + fseek(f, 0, SEEK_END); + long sz = ftell(f); + long off = sz > 65536 ? sz - 65536 : 0; + fseek(f, off, SEEK_SET); + char *buf = malloc((size_t)(sz - off) + 1); + size_t n = buf ? fread(buf, 1, (size_t)(sz - off), f) : 0; + fclose(f); + http_send(fd, 200, "OK", "text/plain", buf ? buf : "", n); + free(buf); +} + +// Receive `remain` body bytes (after `pre`/`prelen` already read) into a +// temp file next to `dest`, then rename into place so loads never see a +// half-written piece. +static int http_save_body(int fd, const char *dest, const char *pre, + size_t prelen, long remain) { + char tmp[300]; + snprintf(tmp, sizeof(tmp), "%s.tmp", dest); + FILE *f = fopen(tmp, "wb"); + if (!f) return -1; + if (prelen) fwrite(pre, 1, prelen, f); + char buf[16384]; + while (remain > 0) { + ssize_t n = recv(fd, buf, remain < (long)sizeof(buf) ? (size_t)remain : sizeof(buf), 0); + if (n <= 0) { + fclose(f); + unlink(tmp); + return -1; + } + fwrite(buf, 1, (size_t)n, f); + remain -= n; + } + fclose(f); + if (rename(tmp, dest) != 0) { + unlink(tmp); + return -1; + } + return 0; +} + +static void http_handle(int fd) { + struct timeval tv = {5, 0}; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + char req[8192]; + size_t got = 0; + char *hdr_end = NULL; + while (got < sizeof(req) - 1) { + ssize_t n = recv(fd, req + got, sizeof(req) - 1 - got, 0); + if (n <= 0) return; + got += (size_t)n; + req[got] = 0; + if ((hdr_end = strstr(req, "\r\n\r\n"))) break; + } + if (!hdr_end) { + http_send_text(fd, 400, "Bad Request", "headers too large\n"); + return; + } + hdr_end += 4; + + char method[8] = "", target[512] = ""; + if (sscanf(req, "%7s %511s", method, target) != 2) return; + + // Split query string. + char *query = strchr(target, '?'); + if (query) *query++ = 0; + int want_jump = query && strstr(query, "jump=1") != NULL; + + long clen = 0; + char *cl = strcasestr(req, "Content-Length:"); + if (cl) clen = strtol(cl + 15, NULL, 10); + if (clen < 0 || clen > LANSERV_MAX_BODY) { + http_send_text(fd, 413, "Payload Too Large", "body too large\n"); + return; + } + + int is_get = strcmp(method, "GET") == 0; + int is_put = strcmp(method, "PUT") == 0 || strcmp(method, "POST") == 0; + + if (is_get && (strcmp(target, "/") == 0 || strcmp(target, "/status") == 0)) { + char piece[64], ip[16], host[64], body[512]; + lan_snapshot(piece, sizeof(piece), ip, sizeof(ip)); + lan_hostname(host, sizeof(host)); + int blen = snprintf(body, sizeof(body), + "{\"name\":\"%s\",\"build\":\"%s\",\"piece\":\"%s\"," + "\"ip\":\"%s\",\"uptime\":%ld,\"port\":%d}\n", + host, g_lan.build, piece, ip, + (long)(time(NULL) - g_lan.started), g_lan.http_port); + http_send(fd, 200, "OK", "application/json", body, (size_t)blen); + return; + } + + if (is_get && strcmp(target, "/logs") == 0) { + http_serve_logs(fd); + return; + } + + // /pieces/ and /lib/ + const char *dir = NULL, *name = NULL; + if (strncmp(target, "/pieces/", 8) == 0) { dir = "/pieces"; name = target + 8; } + else if (strncmp(target, "/lib/", 5) == 0) { dir = "/lib"; name = target + 5; } + + if (dir) { + if (!lan_name_ok(name)) { + http_send_text(fd, 400, "Bad Request", "bad name\n"); + return; + } + char path[300]; + snprintf(path, sizeof(path), "%s/%s", dir, name); + if (is_get) { + http_serve_file(fd, path, "text/javascript"); + return; + } + if (is_put) { + size_t prelen = got - (size_t)(hdr_end - req); + if ((long)prelen > clen) prelen = (size_t)clen; + if (http_save_body(fd, path, hdr_end, prelen, clen - (long)prelen) != 0) { + http_send_text(fd, 500, "Internal Server Error", "write failed\n"); + return; + } + ac_log("[lanserv] wrote %s (%ld bytes)%s\n", path, clen, + want_jump ? " + jump" : ""); + if (want_jump && dir[1] == 'p') { + char base[128]; + snprintf(base, sizeof(base), "%s", name); + char *dot = strrchr(base, '.'); + if (dot) *dot = 0; + lan_trigger_jump(base); + } + http_send_text(fd, 200, "OK", "saved\n"); + return; + } + } + + if (strncmp(target, "/jump/", 6) == 0 && !is_get) { + const char *p = target + 6; + if (!lan_name_ok(p)) { + http_send_text(fd, 400, "Bad Request", "bad name\n"); + return; + } + lan_trigger_jump(p); + ac_log("[lanserv] jump requested → %s\n", p); + http_send_text(fd, 200, "OK", "jumping\n"); + return; + } + + http_send_text(fd, 404, "Not Found", "unknown route\n"); +} + +// ── mDNS / DNS-SD ────────────────────────────────────────────────────── +// Minimal RFC 6762 responder: answers A for .local, PTR/SRV/TXT for +// ._http._tcp.local, and the service-enumeration meta-query — enough +// for `ping host.local` and printer-style discovery in Bonjour browsers. + +static int mdns_open(void) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return -1; + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); +#ifdef SO_REUSEPORT + setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)); +#endif + struct sockaddr_in a = {0}; + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_ANY); + a.sin_port = htons(MDNS_PORT); + if (bind(fd, (struct sockaddr *)&a, sizeof(a)) < 0) { + close(fd); + return -1; + } + // loop=1 so a same-host mDNSResponder sees our answers (lets the dev + // harness verify discovery locally); the device ignores its own + // packets — responses are dropped by the QR-bit check in mdns_handle. + unsigned char ttl = 255, loop = 1; + setsockopt(fd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl)); + setsockopt(fd, IPPROTO_IP, IP_MULTICAST_LOOP, &loop, sizeof(loop)); + return fd; +} + +static void mdns_join(int fd) { + struct ip_mreq m = {0}; + m.imr_multiaddr.s_addr = inet_addr(MDNS_GROUP); + m.imr_interface.s_addr = htonl(INADDR_ANY); + // Drop first so a rejoin after an IP change doesn't EADDRINUSE. + setsockopt(fd, IPPROTO_IP, IP_DROP_MEMBERSHIP, &m, sizeof(m)); + setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &m, sizeof(m)); +} + +// Append dot-separated `name` as DNS labels. Returns new offset, -1 on overflow. +static int dn_put_name(uint8_t *buf, int off, int cap, const char *name) { + const char *p = name; + while (*p) { + const char *dot = strchr(p, '.'); + int len = dot ? (int)(dot - p) : (int)strlen(p); + if (len < 1 || len > 63 || off + len + 1 >= cap) return -1; + buf[off++] = (uint8_t)len; + memcpy(buf + off, p, (size_t)len); + off += len; + p += len; + if (*p == '.') p++; + } + if (off + 1 >= cap) return -1; + buf[off++] = 0; + return off; +} + +static int dn_put_u16(uint8_t *buf, int off, int cap, uint16_t v) { + if (off + 2 > cap) return -1; + buf[off] = (uint8_t)(v >> 8); + buf[off + 1] = (uint8_t)v; + return off + 2; +} + +static int dn_put_u32(uint8_t *buf, int off, int cap, uint32_t v) { + if (off + 4 > cap) return -1; + buf[off] = (uint8_t)(v >> 24); + buf[off + 1] = (uint8_t)(v >> 16); + buf[off + 2] = (uint8_t)(v >> 8); + buf[off + 3] = (uint8_t)v; + return off + 4; +} + +// Record header: name, type, class, ttl, then caller writes RDLENGTH+RDATA. +// `flush` sets the cache-flush bit (multicast responses only). +static int dn_put_rr_head(uint8_t *buf, int off, int cap, const char *name, + uint16_t type, int flush, uint32_t ttl) { + off = dn_put_name(buf, off, cap, name); + if (off < 0) return -1; + off = dn_put_u16(buf, off, cap, type); + if (off < 0) return -1; + off = dn_put_u16(buf, off, cap, flush ? 0x8001 : 0x0001); + if (off < 0) return -1; + return dn_put_u32(buf, off, cap, ttl); +} + +static int dn_put_a(uint8_t *buf, int off, int cap, const char *name, + uint32_t ip_be, int flush) { + off = dn_put_rr_head(buf, off, cap, name, 1 /*A*/, flush, MDNS_TTL); + if (off < 0) return -1; + off = dn_put_u16(buf, off, cap, 4); + if (off < 0 || off + 4 > cap) return -1; + memcpy(buf + off, &ip_be, 4); + return off + 4; +} + +static int dn_put_ptr(uint8_t *buf, int off, int cap, const char *name, + const char *target) { + off = dn_put_rr_head(buf, off, cap, name, 12 /*PTR*/, 0, MDNS_TTL); + if (off < 0) return -1; + int rdlen_at = off; + off = dn_put_u16(buf, off, cap, 0); + if (off < 0) return -1; + int start = off; + off = dn_put_name(buf, off, cap, target); + if (off < 0) return -1; + buf[rdlen_at] = (uint8_t)((off - start) >> 8); + buf[rdlen_at + 1] = (uint8_t)(off - start); + return off; +} + +static int dn_put_srv(uint8_t *buf, int off, int cap, const char *name, + uint16_t port, const char *target, int flush) { + off = dn_put_rr_head(buf, off, cap, name, 33 /*SRV*/, flush, MDNS_TTL); + if (off < 0) return -1; + int rdlen_at = off; + off = dn_put_u16(buf, off, cap, 0); + if (off < 0) return -1; + int start = off; + off = dn_put_u16(buf, off, cap, 0); // priority + off = off < 0 ? -1 : dn_put_u16(buf, off, cap, 0); // weight + off = off < 0 ? -1 : dn_put_u16(buf, off, cap, port); + off = off < 0 ? -1 : dn_put_name(buf, off, cap, target); + if (off < 0) return -1; + buf[rdlen_at] = (uint8_t)((off - start) >> 8); + buf[rdlen_at + 1] = (uint8_t)(off - start); + return off; +} + +static int dn_put_txt(uint8_t *buf, int off, int cap, const char *name, + const char *kv, int flush) { + off = dn_put_rr_head(buf, off, cap, name, 16 /*TXT*/, flush, MDNS_TTL); + if (off < 0) return -1; + int len = (int)strlen(kv); + if (len > 255) len = 255; + off = dn_put_u16(buf, off, cap, (uint16_t)(len + 1)); + if (off < 0 || off + len + 1 > cap) return -1; + buf[off++] = (uint8_t)len; + memcpy(buf + off, kv, (size_t)len); + return off + len; +} + +// Read a (possibly compressed) name from a query packet into dot form. +static int dn_read_name(const uint8_t *pkt, int plen, int off, char *out, + int outlen) { + int o = 0, hops = 0; + while (off < plen) { + uint8_t l = pkt[off]; + if (l == 0) { + out[o] = 0; + return off + 1; // only meaningful pre-pointer; callers track separately + } + if ((l & 0xC0) == 0xC0) { + if (off + 1 >= plen || ++hops > 4) return -1; + off = ((l & 0x3F) << 8) | pkt[off + 1]; + continue; + } + if (off + 1 + l > plen || o + l + 2 > outlen) return -1; + if (o) out[o++] = '.'; + for (int i = 0; i < l; i++) + out[o++] = (char)tolower(pkt[off + 1 + i]); + off += 1 + l; + } + return -1; +} + +// Skip past a name in the packet (without following pointers). +static int dn_skip_name(const uint8_t *pkt, int plen, int off) { + while (off < plen) { + uint8_t l = pkt[off]; + if (l == 0) return off + 1; + if ((l & 0xC0) == 0xC0) return off + 2; + off += 1 + l; + } + return -1; +} + +typedef struct { + char host[80]; // "ac0.local" + char svc[96]; // "_http._tcp.local" + char inst[160]; // "ac0._http._tcp.local" + uint32_t ip_be; + uint16_t port; + char txt[80]; // "build=..." +} MdnsIdentity; + +static void mdns_identity(MdnsIdentity *id) { + char host[64], piece[64], ip[16]; + lan_hostname(host, sizeof(host)); + lan_snapshot(piece, sizeof(piece), ip, sizeof(ip)); + (void)piece; + snprintf(id->host, sizeof(id->host), "%s.local", host); + snprintf(id->svc, sizeof(id->svc), "_http._tcp.local"); + snprintf(id->inst, sizeof(id->inst), "%s._http._tcp.local", host); + id->ip_be = ip[0] ? inet_addr(ip) : 0; + id->port = (uint16_t)g_lan.http_port; + snprintf(id->txt, sizeof(id->txt), "build=%s", g_lan.build); +} + +// Unsolicited announcement: A + service PTR + SRV + TXT, multicast. +static void mdns_announce(int fd) { + MdnsIdentity id; + mdns_identity(&id); + if (!id.ip_be) return; + uint8_t pkt[768]; + memset(pkt, 0, 12); + pkt[2] = 0x84; // QR=1 AA=1 + pkt[7] = 4; // ANCOUNT + int off = 12; + off = dn_put_a(pkt, off, (int)sizeof(pkt), id.host, id.ip_be, 1); + if (off > 0) off = dn_put_ptr(pkt, off, (int)sizeof(pkt), id.svc, id.inst); + if (off > 0) off = dn_put_srv(pkt, off, (int)sizeof(pkt), id.inst, id.port, id.host, 1); + if (off > 0) off = dn_put_txt(pkt, off, (int)sizeof(pkt), id.inst, id.txt, 1); + if (off < 0) return; + struct sockaddr_in dst = {0}; + dst.sin_family = AF_INET; + dst.sin_addr.s_addr = inet_addr(MDNS_GROUP); + dst.sin_port = htons(MDNS_PORT); + sendto(fd, pkt, (size_t)off, 0, (struct sockaddr *)&dst, sizeof(dst)); +} + +static void mdns_handle(int fd) { + uint8_t pkt[1500]; + struct sockaddr_in src; + socklen_t slen = sizeof(src); + ssize_t n = recvfrom(fd, pkt, sizeof(pkt), 0, (struct sockaddr *)&src, &slen); + if (n < 12) return; + if (pkt[2] & 0x80) return; // response, not query + int qd = (pkt[4] << 8) | pkt[5]; + if (qd < 1 || qd > 16) return; + + MdnsIdentity id; + mdns_identity(&id); + if (!id.ip_be) return; + + // Legacy unicast queries (source port != 5353) get a unicast reply + // echoing the query ID, without cache-flush bits (RFC 6762 §6.7). + int legacy = ntohs(src.sin_port) != MDNS_PORT; + + uint8_t out[1024]; + memset(out, 0, 12); + if (legacy) { + out[0] = pkt[0]; + out[1] = pkt[1]; + } + out[2] = 0x84; + int off = 12, answers = 0, extras = 0; + + int qoff = 12; + char qname[256]; + for (int i = 0; i < qd && qoff > 0 && qoff < (int)n; i++) { + if (dn_read_name(pkt, (int)n, qoff, qname, sizeof(qname)) < 0) return; + qoff = dn_skip_name(pkt, (int)n, qoff); + if (qoff < 0 || qoff + 4 > (int)n) return; + uint16_t qtype = (uint16_t)((pkt[qoff] << 8) | pkt[qoff + 1]); + qoff += 4; + int flush = legacy ? 0 : 1; + + if (strcmp(qname, id.host) == 0 && (qtype == 1 || qtype == 255)) { + off = dn_put_a(out, off, (int)sizeof(out), id.host, id.ip_be, flush); + answers++; + } else if (strcmp(qname, id.svc) == 0 && (qtype == 12 || qtype == 255)) { + off = dn_put_ptr(out, off, (int)sizeof(out), id.svc, id.inst); + answers++; + if (off > 0) off = dn_put_srv(out, off, (int)sizeof(out), id.inst, id.port, id.host, flush); + if (off > 0) off = dn_put_txt(out, off, (int)sizeof(out), id.inst, id.txt, flush); + if (off > 0) off = dn_put_a(out, off, (int)sizeof(out), id.host, id.ip_be, flush); + extras += 3; + } else if (strcmp(qname, id.inst) == 0 && + (qtype == 33 || qtype == 16 || qtype == 255)) { + off = dn_put_srv(out, off, (int)sizeof(out), id.inst, id.port, id.host, flush); + answers++; + if (off > 0) off = dn_put_txt(out, off, (int)sizeof(out), id.inst, id.txt, flush); + answers++; + if (off > 0) off = dn_put_a(out, off, (int)sizeof(out), id.host, id.ip_be, flush); + extras++; + } else if (strcmp(qname, "_services._dns-sd._udp.local") == 0 && qtype == 12) { + off = dn_put_ptr(out, off, (int)sizeof(out), qname, id.svc); + answers++; + } + if (off < 0) return; + } + if (!answers) return; + out[6] = (uint8_t)(answers >> 8); + out[7] = (uint8_t)answers; + out[10] = (uint8_t)(extras >> 8); + out[11] = (uint8_t)extras; + + struct sockaddr_in dst = src; + if (!legacy) { + dst.sin_addr.s_addr = inet_addr(MDNS_GROUP); + dst.sin_port = htons(MDNS_PORT); + } + sendto(fd, out, (size_t)off, 0, (struct sockaddr *)&dst, sizeof(dst)); +} + +// ── thread ───────────────────────────────────────────────────────────── + +static void *lanserv_thread(void *arg) { + (void)arg; + int http_fd = http_listen(&g_lan.http_port); + int mdns_fd = mdns_open(); + if (http_fd < 0) ac_log("[lanserv] http bind failed: %s\n", strerror(errno)); + else ac_log("[lanserv] http listening on :%d\n", g_lan.http_port); + if (mdns_fd < 0) ac_log("[lanserv] mdns bind failed: %s\n", strerror(errno)); + + int announced = 0; + while (g_lan.running) { + if (g_lan.ip_changed && mdns_fd >= 0) { + g_lan.ip_changed = 0; + mdns_join(mdns_fd); + // Announce twice ~1s apart per RFC 6762 probing/announcing guidance. + mdns_announce(mdns_fd); + announced = 1; + char host[64]; + lan_hostname(host, sizeof(host)); + ac_log("[lanserv] mdns announcing %s.local\n", host); + } + + fd_set rd; + FD_ZERO(&rd); + int maxfd = -1; + if (http_fd >= 0) { FD_SET(http_fd, &rd); maxfd = http_fd; } + if (mdns_fd >= 0) { FD_SET(mdns_fd, &rd); if (mdns_fd > maxfd) maxfd = mdns_fd; } + if (maxfd < 0) break; + struct timeval tv = {1, 0}; + int r = select(maxfd + 1, &rd, NULL, NULL, &tv); + if (r < 0) { + if (errno == EINTR) continue; + break; + } + if (r == 0) { + // Second announcement one tick after the first. + if (announced == 1 && mdns_fd >= 0) { + mdns_announce(mdns_fd); + announced = 2; + } + continue; + } + if (http_fd >= 0 && FD_ISSET(http_fd, &rd)) { + int cfd = accept(http_fd, NULL, NULL); + if (cfd >= 0) { + http_handle(cfd); + close(cfd); + } + } + if (mdns_fd >= 0 && FD_ISSET(mdns_fd, &rd)) mdns_handle(mdns_fd); + } + if (http_fd >= 0) close(http_fd); + if (mdns_fd >= 0) close(mdns_fd); + return NULL; +} + +// ── public api ───────────────────────────────────────────────────────── + +void lanserv_start(const char *build_name) { + if (g_lan.running) return; + pthread_mutex_init(&g_lan.lock, NULL); + snprintf(g_lan.build, sizeof(g_lan.build), "%s", + build_name && build_name[0] ? build_name : "dev"); + g_lan.started = time(NULL); + g_lan.running = 1; + if (pthread_create(&g_lan.thread, NULL, lanserv_thread, NULL) != 0) { + g_lan.running = 0; + ac_log("[lanserv] thread create failed\n"); + } +} + +void lanserv_update(const char *piece, const char *ip) { + if (!g_lan.running) return; + pthread_mutex_lock(&g_lan.lock); + if (piece && strcmp(g_lan.piece, piece) != 0) + snprintf(g_lan.piece, sizeof(g_lan.piece), "%s", piece); + const char *want = ip ? ip : ""; + if (strcmp(g_lan.ip, want) != 0) { + snprintf(g_lan.ip, sizeof(g_lan.ip), "%s", want); + if (want[0]) g_lan.ip_changed = 1; + } + pthread_mutex_unlock(&g_lan.lock); +} + +void lanserv_stop(void) { + if (!g_lan.running) return; + g_lan.running = 0; + pthread_join(g_lan.thread, NULL); +} diff --git a/fedac/native/src/lanserv.h b/fedac/native/src/lanserv.h new file mode 100644 index 0000000000..564e1085e8 --- /dev/null +++ b/fedac/native/src/lanserv.h @@ -0,0 +1,22 @@ +#pragma once + +// LAN dev server — HTTP control endpoint + mDNS/Bonjour responder. +// Runs on its own thread (like wifi). The device answers at +// http://.local/ (slot from /mnt/.ac-device-slot, else "notepat") +// and advertises _http._tcp via DNS-SD so it shows up in Bonjour +// browsers like a printer. +// +// HTTP routes (port 80): +// GET / → status JSON (name, build, piece, ip, uptime) +// GET /logs → tail of the runtime log +// GET /pieces/.mjs → read a piece back +// PUT /pieces/.mjs[?jump=1] → write piece (atomic), optionally reload +// PUT /lib/.mjs → write a lib module +// POST /jump/ → hot-reload piece (writes /tmp/ac-jump) + +void lanserv_start(const char *build_name); + +// Per-frame from the main loop: current piece + wifi ip ("" when down). +void lanserv_update(const char *piece, const char *ip); + +void lanserv_stop(void); -- 2.54.0