diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3ec217 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +configs/node-password.txt +.synology-password diff --git a/configs/installer-init.sh b/configs/installer-init.sh new file mode 100644 index 0000000..77d57a9 --- /dev/null +++ b/configs/installer-init.sh @@ -0,0 +1,168 @@ +#!/bin/busybox sh +# planck netboot installer - runs from initramfs, wipes /dev/sda, installs +# Raspberry Pi OS Lite from HTTP template, then reboots. The bootloader's +# self-update then applies the per-node pieeprom-revert.upd we PUT to the +# TFTP server, reverting boot order to USB-first, and the fresh OS boots. +export PATH=/bin:/sbin:/usr/bin:/usr/sbin +/bin/busybox --install -s /bin + +SERVER=192.168.1.157 +HTTP=http://$SERVER:8000 + +mkdir -p /proc /sys /dev /tmp /mnt/root /mnt/boot /mnt/stage /chk /conf +mount -t proc proc /proc +mount -t sysfs sysfs /sys +mount -t devtmpfs devtmpfs /dev 2>/dev/null || true + +log() { echo "[planck-installer] $*"; } +fail() { log "FATAL: $*"; sleep 3600; reboot -f; } +report() { wget -q -O /dev/null "$HTTP/events/$1" 2>/dev/null; } + +log "planck installer starting" +report "started" + +# --- identify this node ------------------------------------------------- +MAC=$(cat /sys/class/net/eth0/address) +SERIAL=$(awk '/Serial/ {print $3}' /proc/cpuinfo) +HOST=$(awk -v s="$SERIAL" 'tolower($1)==tolower(s) {print $2}' /conf/hostmap) +if [ -z "$HOST" ]; then + log "unknown serial $SERIAL (mac $MAC) - not in hostmap, aborting" + report "unknown-serial-$SERIAL" + sleep 3600 + reboot -f +fi +log "identity: $HOST serial=$SERIAL mac=$MAC" +report "$HOST-start" + +# --- network ------------------------------------------------------------ +cat > /etc/udhcpc.script <<'EOF' +#!/bin/sh +[ "$1" = bound ] || exit 0 +ifconfig "$interface" "$ip" netmask "$subnet" 2>/dev/null +[ -n "$router" ] && route add default gw "$router" 2>/dev/null +EOF +chmod +x /etc/udhcpc.script +ifconfig eth0 up +udhcpc -i eth0 -n -q -s /etc/udhcpc.script >/dev/null 2>&1 && log "dhcp ok" || log "dhcp failed (continuing)" +IP=$(ip -4 -o addr show eth0 | awk '{print $4}' | cut -d/ -f1) +log "ip: ${IP:-none}" +report "$HOST-ip-${IP:-none}" + +# --- idempotence: if a planck OS is already installed, just boot it ----- +if [ -b /dev/sda2 ]; then + if mount -t ext4 /dev/sda2 /chk 2>/dev/null; then + if [ -f /chk/etc/hostname ] && grep -q "^planck" /chk/etc/hostname 2>/dev/null; then + log "planck OS already installed on sda2 - booting it directly" + report "$HOST-reentry" + mount -t vfat /dev/sda1 /chk/boot/firmware 2>/dev/null + cd / + exec switch_root /chk /sbin/init + fi + umount /chk + fi +fi + +# --- fetch template ----------------------------------------------------- +log "downloading template (this takes a minute)..." +wget -q -O /tmp/rootfs.tar "$HTTP/rootfs.tar" || fail "template download" +log "template downloaded: $(du -h /tmp/rootfs.tar | cut -f1)" +report "$HOST-template-ok" + +# --- partition + format ------------------------------------------------- +log "partitioning /dev/sda" +dd if=/dev/zero of=/dev/sda bs=1M count=20 2>/dev/null +sfdisk /dev/sda >/dev/null 2>&1 <<'EOF' +label: dos +start=2048, size=524288, type=0c, bootable +type=83 +EOF +[ $? -eq 0 ] || fail "sfdisk" +mke2fs -F -t ext4 -L ROOTFS /dev/sda2 >/dev/null 2>&1 || fail "mkfs.ext4" +mkfs.vfat -F 32 -n BOOTFS /dev/sda1 >/dev/null 2>&1 || fail "mkfs.vfat" +mount -t ext4 /dev/sda2 /mnt/root || fail "mount root" +mount -t vfat /dev/sda1 /mnt/boot || fail "mount boot" +log "filesystems ready" + +# --- extract ------------------------------------------------------------ +mkdir -p /mnt/stage +tar xf /tmp/rootfs.tar -C /mnt/stage || fail "untar" +cp -a /mnt/stage/root/. /mnt/root/ || fail "copy root" +cp -a /mnt/stage/boot/. /mnt/boot/ || fail "copy boot" +rm -f /tmp/rootfs.tar +rm -rf /mnt/stage +log "rootfs + boot files extracted" +report "$HOST-extracted" + +# --- configure ---------------------------------------------------------- +echo "$HOST" > /mnt/root/etc/hostname +grep -q "127.0.1.1" /mnt/root/etc/hosts 2>/dev/null || echo "127.0.1.1 localhost" > /mnt/root/etc/hosts +echo "127.0.1.1 $HOST" >> /mnt/root/etc/hosts +cat > /mnt/root/etc/fstab <<'EOF' +/dev/sda1 /boot/firmware vfat defaults 0 2 +/dev/sda2 / ext4 defaults,noatime 0 1 +EOF + +# user + keys (SSH-key-only: password locked, sudo NOPASSWD) +PASSWD=$(cat /conf/nodepass) +mount -t proc proc /mnt/root/proc 2>/dev/null +chroot /mnt/root /bin/sh -c " +set -e +useradd -m -s /bin/bash -G sudo,adm,dialout,plugdev,input,netdev,render,games,users,video adamcarr +echo \"adamcarr:$PASSWD\" | chpasswd +mkdir -p /home/adamcarr/.ssh +cp /conf/authorized_keys /home/adamcarr/.ssh/authorized_keys +chown -R adamcarr:adamcarr /home/adamcarr/.ssh +chmod 700 /home/adamcarr/.ssh +chmod 600 /home/adamcarr/.ssh/authorized_keys +echo 'adamcarr ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/010-adamcarr +chmod 440 /etc/sudoers.d/010-adamcarr +" || fail "user setup" +umount /mnt/root/proc 2>/dev/null + +# ssh: fresh host keys per node, service enabled +rm -f /mnt/root/etc/ssh/ssh_host_* +chroot /mnt/root /bin/sh -c "ssh-keygen -A" || fail "ssh host keys" +ln -sf /usr/lib/systemd/system/ssh.service /mnt/root/etc/systemd/system/multi-user.target.wants/ssh.service + +# firstboot service: catch-up EEPROM updates + tidy markers +cat > /mnt/root/usr/local/sbin/planck-firstboot.sh <<'EOF' +#!/bin/sh +rpi-eeprom-update -a >/dev/null 2>&1 || true +rm -f /boot/firmware/pieeprom-revert.upd /boot/firmware/pieeprom-revert.sig +rm -f /etc/planck-fresh-install +systemctl disable planck-firstboot.service >/dev/null 2>&1 || true +EOF +chmod +x /mnt/root/usr/local/sbin/planck-firstboot.sh +cat > /mnt/root/etc/systemd/system/planck-firstboot.service <<'EOF' +[Unit] +Description=planck firstboot tidy-up +After=multi-user.target +ConditionPathExists=/etc/planck-fresh-install + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/planck-firstboot.sh + +[Install] +WantedBy=multi-user.target +EOF +ln -sf /etc/systemd/system/planck-firstboot.service /mnt/root/etc/systemd/system/multi-user.target.wants/planck-firstboot.service +touch /mnt/root/etc/planck-fresh-install + +# cmdline for the installed OS +printf 'console=serial0,115200 console=tty1 root=/dev/sda2 rootfstype=ext4 fsck.repair=yes rootwait quiet\n' > /mnt/boot/cmdline.txt + +# stage eeprom revert on the new boot partition too (ts-guard prevents reflash) +cp /conf/pieeprom-revert.upd /conf/pieeprom-revert.sig /mnt/boot/ 2>/dev/null + +sync +report "$HOST-installed" + +# --- hand revert EEPROM to bootloader via TFTP self-update -------------- +# busybox wget can only POST, so the helper server treats POST as upload. +wget -q -O /dev/null --post-file=/conf/pieeprom-revert.upd "$HTTP/tftp/$SERIAL/pieeprom.upd" 2>/dev/null || fail "revert PUT" +wget -q -O /dev/null --post-file=/conf/pieeprom-revert.sig "$HTTP/tftp/$SERIAL/pieeprom.sig" 2>/dev/null || fail "revert sig PUT" + +log "install complete - rebooting" +sleep 3 +reboot -f diff --git a/configs/planck-http-server.py b/configs/planck-http-server.py new file mode 100644 index 0000000..2512790 --- /dev/null +++ b/configs/planck-http-server.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""planck netboot helper: GET template tarball, PUT/GET per-node install artifacts. + +PUTs land under PUT_ROOT (tftp//...) for bootloader self-update. +""" +import os +import re +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +BASE = "/volume1/plancknetboot" +PUT_ROOT = os.path.join(BASE, "tftp") + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _safe_target(self, sub): + # each path component must be [A-Za-z0-9._-], no traversal + parts = sub.split("/") + if not parts or any(not re.fullmatch(r"[A-Za-z0-9._-]+", p) for p in parts): + return None + return sub + + def do_GET(self): + if self.path.startswith("/events/"): + name = self._safe_target(self.path[len("/events/"):].split("?")[0]) + if not name: + self.send_error(403) + return + with open(os.path.join(PUT_ROOT, "events.log"), "a") as log: + log.write(f"{self.log_date_time_string()} EVENT {name}\n") + self.send_response(204) + self.send_header("Content-Length", "0") + self.end_headers() + return + name = self._safe_target(self.path.lstrip("/")) + if not name: + self.send_error(403) + return + path = os.path.join(BASE, name) + if not os.path.isfile(path): + self.send_error(404) + return + size = os.path.getsize(path) + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(size)) + self.end_headers() + with open(path, "rb") as f: + while chunk := f.read(1024 * 1024): + self.wfile.write(chunk) + + def do_PUT(self): + name = self._safe_target(self.path.lstrip("/")) + if not name: + self.send_error(403) + return + path = os.path.join(PUT_ROOT, name) + os.makedirs(os.path.dirname(path), exist_ok=True) + length = int(self.headers.get("Content-Length", 0)) + if length > 512 * 1024 * 1024: + self.send_error(413) + return + with open(path + ".part", "wb") as f: + remaining = length + while remaining > 0: + chunk = self.rfile.read(min(1024 * 1024, remaining)) + if not chunk: + break + f.write(chunk) + remaining -= len(chunk) + os.rename(path + ".part", path) + self.send_response(201) + self.send_header("Content-Length", "0") + self.end_headers() + with open(os.path.join(PUT_ROOT, "events.log"), "a") as log: + log.write(f"{self.log_date_time_string()} PUT {name} {length}\n") + + do_POST = do_PUT + + +if __name__ == "__main__": + ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever() diff --git a/configs/planck-netboot.service b/configs/planck-netboot.service new file mode 100644 index 0000000..ea0a31d --- /dev/null +++ b/configs/planck-netboot.service @@ -0,0 +1,11 @@ +[Unit] +Description=planck cluster netboot helper (template GET / per-node PUT) +After=network.target + +[Service] +ExecStart=/usr/bin/python3 /volume1/plancknetboot/planck-http-server.py +Restart=always +User=root + +[Install] +WantedBy=multi-user.target diff --git a/configs/planck-tftp-server.py b/configs/planck-tftp-server.py new file mode 100644 index 0000000..2db6d91 --- /dev/null +++ b/configs/planck-tftp-server.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Minimal TFTP server (RRQ only) for planck netboot. Supports blksize and +windowsize options per RFC 2347/2348/2349. Serves files under TFTP_ROOT.""" +import os +import socket +import struct +import threading +import time + +TFTP_ROOT = "/volume1/plancknetboot/tftp" +PORT = 69 +BLKSIZE_MAX = 1468 # avoids IP fragmentation on ethernet +DEFAULT_BLKSIZE = 512 +DEFAULT_WINDOW = 1 +TIMEOUT = 5 +RETRIES = 6 + +OP_RRQ = 1 +OP_DATA = 3 +OP_ACK = 4 +OP_ERROR = 5 +OP_OACK = 6 + + +def parse_rrq(data): + # opcode, filename, mode, optional opts + parts = data[2:].split(b"\x00") + filename = parts[0].decode(errors="replace") + opts = {} + rest = parts[2:] + for i in range(0, len(rest) - 1, 2): + try: + opts[rest[i].decode().lower()] = rest[i + 1].decode() + except (IndexError, UnicodeDecodeError): + pass + return filename, opts + + +def safe_path(filename): + parts = filename.split("/") + if any(not p or p.startswith(".") or "/" in p for p in parts): + return None + if any(not all(c.isalnum() or c in "._-" for c in p) for p in parts): + return None + path = os.path.join(TFTP_ROOT, *parts) + if not os.path.isfile(path): + return None + return path + + +def send_err(sock, addr, code, msg): + sock.sendto(struct.pack("!HH", OP_ERROR, code) + msg.encode() + b"\x00", addr) + + +def handle_client(sock, data, addr, filepath, opts): + blksize = DEFAULT_BLKSIZE + window = DEFAULT_WINDOW + requested = {} + if "blksize" in opts: + try: + blksize = min(BLKSIZE_MAX, max(512, int(opts["blksize"]))) + requested["blksize"] = str(blksize) + except ValueError: + pass + if "windowsize" in opts: + try: + window = min(64, max(1, int(opts["windowsize"]))) + requested["windowsize"] = str(window) + except ValueError: + pass + + size = os.path.getsize(filepath) + if requested: + payload = b"\x00".join( + k.encode() + b"\x00" + v.encode() for k, v in requested.items() + ) + sock.sendto(struct.pack("!H", OP_OACK) + payload + b"\x00", addr) + else: + blksize = 512 + window = 1 + + with open(filepath, "rb") as f: + block = 0 + eof = False + while not eof: + # send one window + for _ in range(window): + block = (block % 65536) + 1 + chunk = f.read(blksize) + pkt = struct.pack("!HH", OP_DATA, block) + chunk + sock.sendto(pkt, addr) + if len(chunk) < blksize: + eof = True + # wait for the ACK of the last block of this window + expect = block + retries = 0 + while True: + sock.settimeout(TIMEOUT) + try: + rdata, raddr = sock.recvfrom(4 + blksize) + except socket.timeout: + retries += 1 + if retries > RETRIES: + return + # retransmit window + f.seek((expect - window) * blksize if expect >= window else 0) + block = expect - window if expect >= window else 0 + eof = os.path.getsize(filepath) <= f.tell() + window * blksize + break + if len(rdata) >= 4 and struct.unpack("!H", rdata[:2])[0] == OP_ACK: + acked = struct.unpack("!H", rdata[2:4])[0] + if acked == expect: + break + elif acked < expect: + continue # stale ack + else: + continue # shouldn't happen + + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("0.0.0.0", PORT)) + print(f"planck-tftp: serving {TFTP_ROOT} on :{PORT}", flush=True) + while True: + data, addr = srv.recvfrom(1024) + if len(data) < 4 or struct.unpack("!H", data[:2])[0] != OP_RRQ: + continue + try: + filename, opts = parse_rrq(data) + except Exception: + continue + filepath = safe_path(filename) + if filepath is None: + errsock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + send_err(errsock, addr, 1, "file not found") + errsock.close() + print(f"planck-tftp: RRQ denied {filename} from {addr}", flush=True) + continue + csock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + csock.bind(("0.0.0.0", 0)) + print(f"planck-tftp: RRQ {filename} from {addr}", flush=True) + threading.Thread( + target=handle_client, args=(csock, data, addr, filepath, opts), + daemon=True, + ).start() + + +if __name__ == "__main__": + main() diff --git a/configs/planck-tftp.service b/configs/planck-tftp.service new file mode 100644 index 0000000..aed1654 --- /dev/null +++ b/configs/planck-tftp.service @@ -0,0 +1,11 @@ +[Unit] +Description=planck cluster TFTP server (netboot firmware) +After=network.target + +[Service] +ExecStart=/usr/bin/python3 /volume1/plancknetboot/planck-tftp-server.py +Restart=always +User=root + +[Install] +WantedBy=multi-user.target diff --git a/docs/PLAN.md b/docs/PLAN.md index 874d0fc..a536064 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -28,6 +28,36 @@ - Synology NAS on same LAN — shared storage target (NFS) for cluster workloads +## Rebuild architecture (decided 2026-09-19) + +Fully unattended network reimage, no per-node physical access: + +- **Synology** (`192.168.1.157`) hosts three services (all auto-start, see + `configs/`): + - `planck-tftp.service` — custom python TFTP server on :69 serving + `/volume1/plancknetboot/tftp/` (firmware, kernel, installer initramfs) + - `planck-netboot.service` — python HTTP helper on :8000 (GET `rootfs.tar` + template, GET `/events/` progress log, POST = upload) + - NFS read-only export of the share for future use +- **Installer initramfs** (`configs/installer-init.sh`): busybox + sfdisk + + e2fsprogs. Identifies the node by CPU serial (map in + `configs/hostmap`), wipes `/dev/sda`, downloads the Debian 13 Raspberry Pi + OS Lite template over HTTP, installs it, sets hostname/ssh keys/user, + POSTs `pieeprom-revert.upd/.sig` to `tftp//`, reboots. +- **Bootloader flow**: nodes' EEPROM is flashed (via staged `pieeprom.upd` + from the old OS) to install-mode: `BOOT_ORDER=0xf142` (network first), + `TFTP_IP=192.168.1.157`, `TFTP_PREFIX=1` (per-serial dirs), + `ENABLE_SELF_UPDATE=1`. After install, the TFTP-served + `bootloader_update=1` config + `/pieeprom.upd` reverts the node to + `BOOT_ORDER=0xf14` (USB first). Fresh OS boots from SSD; firstboot service + tidies up. +- **Reinstall a node anytime**: stage install-mode EEPROM again + reboot + (delete `tftp//pieeprom.*` on the Synology first if it exists). +- Node password: see `configs/node-password.txt` (gitignored). SSH is + key-based; password exists for console/sudo recovery. +- UniFi MFA blocks API access; DHCP reservations deferred — mDNS + (`planck0NN.local`) works across subnets today. + ## Phases - [x] Phase 0 — Access + inventory (SSH keys on all 20, docs/)