summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-07 17:26:42 +0300
committerPaul Buetow <paul@buetow.org>2026-07-07 17:26:42 +0300
commit1aa5eb37c626f48bd725578637e5db4ae870ebfe (patch)
treeed0f2507a276947dc5cb9e285c8558873e4ea186
parent213c92a36424d55f7a4650851ceb45c8a2283926 (diff)
ychat: bump image to deacb3f (fix chat color picker / POST body parsing)
-rw-r--r--f3s/pi-netbsd/README.md323
-rwxr-xr-xf3s/pi-netbsd/bake/bake-golden.sh81
-rwxr-xr-xf3s/pi-netbsd/bake/config.exp41
-rwxr-xr-xf3s/pi-netbsd/bake/setup.sh69
-rwxr-xr-xf3s/pi-netbsd/flash/95netbsdflash/flash.sh82
-rwxr-xr-xf3s/pi-netbsd/flash/95netbsdflash/module-setup.sh20
-rwxr-xr-xf3s/pi-netbsd/flash/build-flasher.sh30
-rw-r--r--f3s/ychat/helm-chart/Chart.yaml2
-rw-r--r--f3s/ychat/helm-chart/templates/deployment.yaml2
9 files changed, 648 insertions, 2 deletions
diff --git a/f3s/pi-netbsd/README.md b/f3s/pi-netbsd/README.md
new file mode 100644
index 0000000..1166018
--- /dev/null
+++ b/f3s/pi-netbsd/README.md
@@ -0,0 +1,323 @@
+# Remote in-place NetBSD install on a Raspberry Pi 3B+ (no SD removal)
+
+Runbook + scripts to replace **Rocky Linux 9** with **NetBSD 10.1** on an f3s
+Raspberry Pi **3 Model B+**, entirely over SSH, **without pulling the microSD
+card**. Proven on `pi0` (2026-07-03); this document is written so the same can
+be done to `pi1` (or a rebuild of `pi0`) by changing two values.
+
+> **TL;DR of the hard-won lesson:** `kexec` is compiled out of the Rocky RPi
+> kernel and the systemd shutdown-pivot does not fire on these boxes, so the
+> only remote RAM-flasher that works is one loaded by the **Pi firmware** as an
+> `initramfs` via a one-shot `config.txt`. See [Why the obvious methods
+> fail](#why-the-obvious-methods-fail).
+
+---
+
+## Contents
+
+- [Outcome / definition of done](#outcome--definition-of-done)
+- [Hardware & facts](#hardware--facts)
+- [Why the obvious methods fail](#why-the-obvious-methods-fail)
+- [How it works (the method that does)](#how-it-works-the-method-that-does)
+- [Prerequisites](#prerequisites)
+- [Stage A — bake the golden image (on `earth`)](#stage-a--bake-the-golden-image-on-earth)
+- [Stage B — remote in-place flash (on the Pi)](#stage-b--remote-in-place-flash-on-the-pi)
+- [Doing pi1 specifically](#doing-pi1-specifically)
+- [Verification](#verification)
+- [Rollback / recovery](#rollback--recovery)
+- [Troubleshooting & gotchas](#troubleshooting--gotchas)
+- [Cleanup](#cleanup)
+- [Post-install (deferred)](#post-install-deferred)
+- [File manifest](#file-manifest)
+
+---
+
+## Outcome / definition of done
+
+`ssh paul@<pi-ip>` lands on a **NetBSD 10.1 (GENERIC64) evbarm/aarch64** system:
+
+- login as `paul` via **SSH key** (member of `wheel`); password auth also on as a backup
+- hostname correct (`piN.lan.buetow.org`)
+- **static IP on `mue0`** (the 3B+ onboard LAN78xx NIC in NetBSD), default route + DNS working
+- root filesystem auto-resized to the whole card on first boot
+
+## Hardware & facts
+
+| Item | pi0 | pi1 |
+|------|-----|-----|
+| Board | Raspberry Pi 3 Model B+ | Raspberry Pi 3 Model B+ |
+| Was running | Rocky Linux 9.7 aarch64 | Rocky Linux 9.x aarch64 |
+| LAN IP | 192.168.1.125 | **192.168.1.126** |
+| Hostname | pi0.lan.buetow.org | **pi1.lan.buetow.org** |
+| WireGuard | 192.168.2.203 | 192.168.2.204 |
+| RAM | 909 MiB | 909 MiB |
+| SD | `/dev/mmcblk0` (30 GB): p1 `/boot` vfat, p2 swap, p3 `/` ext4 | same |
+| NIC (Linux → NetBSD) | `lan78xx` → **`mue0`** | `lan78xx` → **`mue0`** |
+| Gateway / DNS | 192.168.1.1 | 192.168.1.1 |
+
+Confirmed on the RPi Rocky kernel (`6.1.31-v8.1.el9.altarch`): `CONFIG_KEXEC`
+disabled, `CONFIG_KEXEC_FILE` unset; **boots with no initramfs** (kernel mounts
+the ext4 root directly); `ext4`, `vfat`, `nls_cp437`, `nls_ascii`, `mmc_block`,
+`sdhci`, `tmpfs` are all **built-in** (`=y`) — so a flasher initramfs needs no
+extra modules to mount `/boot`, the root, or a tmpfs.
+
+NetBSD base image used: **NetBSD 10.1 evbarm-aarch64 gzimg**
+`https://cdn.netbsd.org/pub/NetBSD/NetBSD-10.1/evbarm-aarch64/binary/gzimg/arm64.img.gz`
+(GPT: EFI System partition + NetBSD FFS root; boots RPi 3/4/5).
+
+## Why the obvious methods fail
+
+Three RAM-flasher mechanisms were tried on `pi0`. Only the third works here:
+
+1. **`kexec` into a RAM flasher** (what most guides assume). ❌ Impossible:
+ `kexec_load(2)` returns `ENOSYS` and `kexec_file_load(2)` is not built
+ (`CONFIG_KEXEC_FILE` unset). `kexec-tools` is installed but the syscalls are
+ not in the kernel.
+2. **systemd shutdown-pivot** (`/run/initramfs/shutdown` switch-root at reboot).
+ ❌ Does **not fire** on these boxes — proven with a 3-channel evidence probe
+ over several test reboots; `systemd-shutdown` never switch-roots into
+ `/run/initramfs` here (the box was not booted from an initrd, and
+ `dracut-shutdown` is a no-op because `.need_shutdown` is never created).
+ *Also_ a red herring along the way: mounting a **separate tmpfs** at
+ `/run/initramfs` gets unmounted during shutdown — always populate it as a
+ plain dir on `/run` if you ever revisit this.
+3. **Pi-firmware `initramfs`** (this runbook). ✅ Works. The VideoCore firmware
+ loads a small dracut initramfs right after the kernel; a dracut *pre-mount*
+ hook runs in RAM before the real root is mounted and does the flash.
+
+## How it works (the method that does)
+
+```
+ EARTH (x86_64 laptop) piN (RPi 3B+, Rocky)
+ ───────────────────── ─────────────────────
+ Stage A: bake golden image Stage B: remote flash
+ qemu-system-aarch64 -M virt (TCG) 1. build flasher.img (dracut,
+ boots stock NetBSD arm64.img pre-mount hook) -> /boot
+ -> configure headless (sshd, 2. arm: write /boot/config.txt
+ static mue0, user+key, pw) = "initramfs flasher.img
+ -> sync + poweroff followkernel" + trigger
+ gzip -> netbsd-piN-golden.img.gz file /boot/netbsd-flash-mode
+ │ 3. systemctl reboot
+ └── scp to piN:/home/paul/ ───────────► ── firmware loads flasher.img ──┐
+ ▼
+ dracut pre-mount hook (95netbsdflash):
+ - DISARM: rm /boot/config.txt + trigger
+ (so any later failure self-heals to Rocky)
+ - mount ext4 root ro, copy golden gz -> tmpfs (RAM)
+ - dryrun: gunzip|wc -c (validate, reboot to Rocky)
+ - real: gunzip|dd of=/dev/mmcblk0 (reboot to NetBSD)
+ ▼
+ NetBSD 10.1 boots headless, static .12x, sshd
+ ▼
+ ssh paul@<ip> ✅ ACCEPTANCE
+```
+
+Key safety property: the hook **removes `config.txt` before touching anything
+else**, so the flasher is strictly one-shot and any failure *after the hook
+starts* leaves a card that boots normally (Rocky, or — once flashed — NetBSD).
+The **dry-run** proves the entire boot+stage+decompress path non-destructively
+before the real `dd`.
+
+## Prerequisites
+
+On **earth** (the flashing workstation):
+
+- `qemu-system-aarch64`, `qemu-img`, `edk2` AAVMF firmware, `expect`, passwordless `sudo`
+ (`sudo dnf install -y qemu-system-aarch64 qemu-img edk2-aarch64 expect`).
+- Note: earth is x86_64, so the aarch64 guest runs under **TCG emulation** (slow
+ but fine — a bake is a handful of minutes).
+- Your SSH public key (`~/.ssh/id_rsa.pub`) — it gets baked into the image.
+
+On the **Pi**:
+
+- Reachable over SSH as `paul` with **passwordless sudo** (the f3s default).
+- `dracut` present (it is on Rocky), `/boot` is the vfat firmware partition,
+ ~500 MB free on `/` to stage the image.
+- Its twin Pi still serving the shared role (so downtime is a non-event).
+
+## Stage A — bake the golden image (on `earth`)
+
+Scripts: [`bake/`](bake). Editing the NetBSD FFS root from Linux is unsafe, so
+we configure the image **from inside a real NetBSD** running under qemu.
+
+1. **Edit the two per-host values** at the top of [`bake/setup.sh`](bake/setup.sh):
+ `HOSTNAME` and `IPADDR` (see [Doing pi1](#doing-pi1-specifically)).
+2. Run the orchestrator:
+ ```bash
+ cd f3s/pi-netbsd/bake
+ ./bake-golden.sh piN # e.g. ./bake-golden.sh pi1
+ ```
+ It downloads `arm64.img.gz` (if absent), serves `setup.sh` over a localhost
+ HTTP server, boots the image in qemu, blind-drives the console via
+ `config.exp` to fetch+run `setup.sh`, verifies the on-disk result, then
+ `sync`+powers off and produces `netbsd-piN-golden.img.gz`.
+3. A random backup password is generated and printed (also saved to
+ `piN-cred.txt`). The **SSH key is the primary login**; change the password
+ after install.
+
+What `setup.sh` configures inside the image: `sshd=YES`, `hostname`,
+`ifconfig_mue0="inet <ip> netmask 0xffffff00"`, `defaultroute`, `dhcpcd=NO`,
+`/etc/resolv.conf`, user `paul` (+`wheel`) with your `authorized_keys` and an
+argon2id password (root too), `sshd_config` pubkey+password auth, and an
+`/etc/rc.local` fallback that puts the static IP on the first real ethernet
+interface if `mue0` is ever named differently.
+
+> **The qemu-automation gotchas** (already handled in the scripts, documented so
+> you understand them): add a **virtio-rng** device or NetBSD stalls on entropy
+> and never generates ssh host keys; run `expect` under **`LC_ALL=C`** or Tcl
+> chokes on the serial control bytes; **blind-drive** the login/commands with
+> fixed sleeps and judge success by markers written to the log rather than
+> matching the flaky console; and **`sync` then wait for a clean poweroff** —
+> killing qemu before the FFS is flushed corrupts `rc.conf`/`pwd.db`.
+
+## Stage B — remote in-place flash (on the Pi)
+
+Scripts: [`flash/`](flash). Copy this whole `pi-netbsd/` tree (or at least
+`flash/`) to the Pi first, e.g. `scp -r f3s/pi-netbsd paul@piN.lan.buetow.org:`.
+
+1. **Stage the golden image on the Pi** (on its ext4 root):
+ ```bash
+ scp netbsd-piN-golden.img.gz paul@piN.lan.buetow.org:/home/paul/
+ ```
+ The flasher hook globs for **`/home/paul/netbsd-*-golden.img.gz`** (or under
+ `/root/`), so any `netbsd-<pi>-golden.img.gz` name works — no rename needed.
+ Verify integrity: `gzip -t` and compare `sha256sum` against earth.
+2. **Build the flasher initramfs** (module name is `netbsdflash`, no numeric prefix):
+ ```bash
+ ssh paul@piN.lan.buetow.org 'sudo /home/paul/pi-netbsd/flash/build-flasher.sh'
+ ```
+ Produces `/boot/flasher.img` (~38 MB) and prints an `lsinitrd` sanity check.
+3. **Dry-run first (non-destructive):**
+ ```bash
+ ssh paul@piN.lan.buetow.org '
+ sudo rm -f /boot/flash-evidence.txt
+ printf "initramfs flasher.img followkernel\n" | sudo tee /boot/config.txt
+ printf "dryrun\n" | sudo tee /boot/netbsd-flash-mode
+ sync; sudo systemctl reboot'
+ ```
+ The Pi goes down ~90 s (staging + decompress), then returns as **Rocky**.
+ Read the proof:
+ ```bash
+ ssh paul@piN.lan.buetow.org 'sudo cat /boot/flash-evidence.txt'
+ # EXPECT:
+ # FLASHER-RAN mode=dryrun up=...
+ # DRYRUN-RESULT bytes=1586495488 rc=0
+ ```
+ `bytes=1586495488 rc=0` and `config.txt` gone ⇒ the whole path works.
+ **Do not proceed to the real flash unless the dry-run shows this.**
+4. **Real flash (destructive, irreversible):**
+ ```bash
+ ssh paul@piN.lan.buetow.org '
+ sudo rm -f /boot/flash-evidence.txt
+ printf "initramfs flasher.img followkernel\n" | sudo tee /boot/config.txt
+ printf "real\n" | sudo tee /boot/netbsd-flash-mode
+ sync; sudo systemctl reboot'
+ ```
+ The Pi stages to RAM, `dd`s the 1.59 GB image onto `/dev/mmcblk0`, then
+ reboots into **NetBSD**. Allow several minutes (SD write + first boot +
+ root resize).
+
+## Doing pi1 specifically
+
+Only two things differ from pi0. In [`bake/setup.sh`](bake/setup.sh):
+
+```sh
+HOSTNAME="pi1.lan.buetow.org"
+IPADDR="192.168.1.126"
+```
+
+Everything else is identical: same board, same NIC (`mue0`), same gateway/DNS,
+same flasher. WireGuard peer for pi1 is `192.168.2.204` (configured later,
+out of scope here). Then run Stage A (`./bake-golden.sh pi1`) and Stage B as
+above against `pi1.lan.buetow.org` / `192.168.1.126`.
+
+> Keep pi0 (or the other twin) up while flashing pi1 so the static
+> `f3s.buetow.org` backend stays served.
+
+## Verification
+
+```bash
+ssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null paul@192.168.1.126 '
+ uname -a # NetBSD piN.lan.buetow.org 10.1 ... evbarm
+ hostname # piN.lan.buetow.org
+ id # uid=1000(paul) ... groups ... wheel
+ ifconfig mue0 # inet 192.168.1.126
+ netstat -rn -f inet | grep default # default 192.168.1.1 ... mue0
+ host cdn.netbsd.org # DNS resolves
+ df -h / # root grown to ~29G'
+```
+
+NetBSD regenerates its own SSH **host keys**, so your `known_hosts` entry for the
+Pi will change — remove the old line or use the relaxed flags above.
+
+## Rollback / recovery
+
+- **Before the real flash:** nothing is destroyed. Any failure once the hook has
+ started leaves the card booting normally (the hook removes `config.txt`
+ first). If the initramfs never runs the hook (rare — a malformed
+ `flasher.img`), the Pi boot-loops on the missing/failed initramfs until you
+ fix it physically: pull the SD, delete `config.txt`, reinsert. Mitigation is
+ the dry-run, which proves the initramfs boots.
+- **After the real flash:** the card is NetBSD. To go back to Rocky, reflash the
+ Rocky image (keep a known-good one handy) — physically or by the same method
+ in reverse. **Keep a Rocky SD image before starting** as the ultimate fallback
+ (physical access is "inconvenient but possible" for these boxes).
+
+## Troubleshooting & gotchas
+
+- **Dry-run returns fast (~20 s) with no `flash-evidence.txt`:** the initramfs
+ did not run the hook — check `/boot/config.txt` really says
+ `initramfs flasher.img followkernel`, that `/boot/flasher.img` exists, and that
+ the trigger file `/boot/netbsd-flash-mode` contains exactly `dryrun`.
+- **`dracut module 'netbsdflash' cannot be found`:** use `--add netbsdflash`
+ (module name = directory name **without** the `95` prefix).
+- **`bytes=` is not `1586495488` or `rc!=0`:** the staged/decompressed image is
+ bad — re-check the `scp`/`sha256sum` of the golden gz on the Pi.
+- **NetBSD boots but is unreachable:** the NIC came up under a name other than
+ `mue0`; the `rc.local` fallback should still put `.12x` on the first real
+ ethernet iface — attach a console (HDMI/serial) to inspect if needed.
+- **qemu bake stalls / login never proceeds:** see the Stage A gotchas box
+ (virtio-rng, `LC_ALL=C`, blind-drive, sync-before-poweroff).
+
+## Cleanup
+
+On the Pi (once NetBSD is confirmed, these are on the wiped card anyway for a
+real flash; relevant only if you leave a Pi on Rocky after dry-runs):
+
+```bash
+sudo rm -f /boot/config.txt /boot/netbsd-flash-mode /boot/flash-evidence.txt \
+ /boot/flasher.img /home/paul/netbsd-pi0-golden.img.gz
+# optional: restore the untouched initramfs backup
+sudo mv /boot/initramfs-*.img.orig /boot/initramfs-$(uname -r).img # if present
+```
+
+On earth: stop any leftover `python3 -m http.server` from the bake; the
+`*.img`/`*.img.gz` work files can be deleted or kept for the next Pi.
+
+## Post-install (deferred)
+
+Base OS + networking + SSH is the whole scope of this runbook. Re-provisioning
+the Pi's role on NetBSD is separate work:
+
+- **lighttpd static site** (`f3s.buetow.org`): `pkgin install lighttpd`, recreate
+ `/var/www` + the Host-based vhost config. relayd backends keep the same IPs, so
+ the OpenBSD frontends need no change.
+- **DTail `dserver`**: needs a **`netbsd/arm64`** build (the Pis previously ran
+ `linux/arm64`).
+- **WireGuard** peer, monitoring agents, etc.
+
+## File manifest
+
+```
+f3s/pi-netbsd/
+├── README.md # this runbook
+├── bake/
+│ ├── bake-golden.sh # earth: orchestrate the qemu bake
+│ ├── setup.sh # image customization (EDIT HOSTNAME/IPADDR)
+│ └── config.exp # earth: expect driver for the qemu console
+└── flash/
+ ├── build-flasher.sh # Pi: build /boot/flasher.img via dracut
+ └── 95netbsdflash/ # dracut module (name: "netbsdflash")
+ ├── module-setup.sh
+ └── flash.sh # the pre-mount flasher hook
+```
diff --git a/f3s/pi-netbsd/bake/bake-golden.sh b/f3s/pi-netbsd/bake/bake-golden.sh
new file mode 100755
index 0000000..538a8a3
--- /dev/null
+++ b/f3s/pi-netbsd/bake/bake-golden.sh
@@ -0,0 +1,81 @@
+#!/bin/bash
+# Stage A orchestrator — run on `earth`. Produces netbsd-<piN>-golden.img.gz.
+# Usage: ./bake-golden.sh piN [workdir]
+# Requires: qemu-system-aarch64, qemu-img, edk2 AAVMF, expect, ~/.ssh/id_rsa.pub.
+# EDIT bake/setup.sh (HOSTNAME/IPADDR) for the target Pi BEFORE running this.
+set -euo pipefail
+
+PIN="${1:?usage: $0 piN [workdir]}"
+HERE="$(cd "$(dirname "$0")" && pwd)"
+WORK="${2:-$HOME/Downloads}"
+BASE="$WORK/NetBSD-10.1-evbarm-aarch64-arm64.img.gz"
+URL="https://cdn.netbsd.org/pub/NetBSD/NetBSD-10.1/evbarm-aarch64/binary/gzimg/arm64.img.gz"
+CODE=/usr/share/AAVMF/AAVMF_CODE.fd
+VARSRC=/usr/share/AAVMF/AAVMF_VARS.fd
+PUBKEY="${SSHKEY_FILE:-$HOME/.ssh/id_rsa.pub}"
+
+command -v qemu-system-aarch64 >/dev/null || { echo "install qemu-system-aarch64"; exit 1; }
+[ -f "$CODE" ] && [ -f "$VARSRC" ] || { echo "install edk2 AAVMF firmware"; exit 1; }
+[ -f "$PUBKEY" ] || { echo "missing $PUBKEY"; exit 1; }
+mkdir -p "$WORK"
+
+echo "== base image =="
+[ -f "$BASE" ] || curl -fSL "$URL" -o "$BASE"
+gzip -t "$BASE"
+
+echo "== fresh work image + varstore =="
+IMG="$WORK/netbsd-$PIN-work.img"
+VARS="$WORK/AAVMF_VARS_$PIN.fd"
+gunzip -kc "$BASE" > "$IMG"
+cp "$VARSRC" "$VARS"
+
+echo "== per-host values from the piN argument =="
+case "$PIN" in
+ pi0) IPADDR=192.168.1.125 ;;
+ pi1) IPADDR=192.168.1.126 ;;
+ pi2) IPADDR=192.168.1.127 ;;
+ pi3) IPADDR=192.168.1.128 ;;
+ *) IPADDR="${IPADDR:?unknown $PIN — set IPADDR env}" ;;
+esac
+HOSTNAME_FQDN="$PIN.lan.buetow.org"
+echo " $PIN -> $HOSTNAME_FQDN / $IPADDR"
+
+echo "== render setup.sh (inject host/ip + key + generated password) into a served dir =="
+SEED="$WORK/seed-$PIN"; mkdir -p "$SEED"
+PW=$(openssl rand -base64 9 | tr -d '/+=' | cut -c1-12)
+printf '%s\n' "$PW" > "$WORK/$PIN-cred.txt"; chmod 600 "$WORK/$PIN-cred.txt"
+KEY=$(cat "$PUBKEY")
+# substitute host/ip lines + placeholders; awk keeps the multiline key intact
+awk -v key="$KEY" -v pw="$PW" -v host="$HOSTNAME_FQDN" -v ip="$IPADDR" '
+ /^HOSTNAME=/ { print "HOSTNAME=\"" host "\""; next }
+ /^IPADDR=/ { print "IPADDR=\"" ip "\""; next }
+ { gsub(/__SSHKEY__/,key); gsub(/__PW__/,pw); print }
+' "$HERE/setup.sh" > "$SEED/setup.sh"
+
+echo "== serve setup.sh on 127.0.0.1:8000 (guest reaches it at 10.0.2.2) =="
+python3 -m http.server 8000 --bind 127.0.0.1 --directory "$SEED" >"$WORK/httpd-$PIN.log" 2>&1 &
+HTTPD=$!
+trap 'kill "$HTTPD" 2>/dev/null || true' EXIT
+until grep -q . "$WORK/httpd-$PIN.log" 2>/dev/null || curl -fsS http://127.0.0.1:8000/setup.sh -o /dev/null 2>/dev/null; do sleep 0.3; done
+
+echo "== boot + configure via qemu/expect (TCG, ~4 min) =="
+LOG="$WORK/config-$PIN.log"; : > "$LOG"
+NBIMG="$IMG" NBVARS="$VARS" NBLOG="$LOG" NBHTTP="http://10.0.2.2:8000/setup.sh" \
+ LC_ALL=C LANG=C expect -f "$HERE/config.exp" || true
+
+echo "== verify markers =="
+if ! grep -qa 'SETUP_OK' "$LOG" || ! grep -qa 'halt: halted' "$LOG"; then
+ echo "!! bake did not complete cleanly; inspect $LOG"; exit 1
+fi
+echo "-- on-disk verification block --"
+sed -n '/^VBEGIN/,/^VEND/p' <(tr -d '\r' < "$LOG")
+
+echo "== gzip -> golden =="
+GOLDEN="$WORK/netbsd-$PIN-golden.img.gz"
+gzip -c "$IMG" > "$GOLDEN"
+gzip -t "$GOLDEN"
+echo
+echo "DONE: $GOLDEN"
+echo "sha256: $(sha256sum "$GOLDEN" | awk '{print $1}')"
+echo "backup password for paul/root: $PW (also in $WORK/$PIN-cred.txt)"
+echo "Next: scp \"$GOLDEN\" paul@$PIN.lan.buetow.org:/home/paul/netbsd-pi0-golden.img.gz (see README Stage B)"
diff --git a/f3s/pi-netbsd/bake/config.exp b/f3s/pi-netbsd/bake/config.exp
new file mode 100755
index 0000000..1a3fe61
--- /dev/null
+++ b/f3s/pi-netbsd/bake/config.exp
@@ -0,0 +1,41 @@
+#!/usr/bin/expect -f
+# Blind-drive the NetBSD-in-qemu console to fetch+run setup.sh, verify, sync,
+# and power off. Paths come from the environment (set by bake-golden.sh):
+# NBIMG - raw work image (edited in place, then gzipped to the golden)
+# NBVARS - writable AAVMF (edk2) varstore
+# NBLOG - session log file
+# NBHTTP - host-side URL of setup.sh (reachable from the guest as 10.0.2.2)
+#
+# Lessons baked in: virtio-rng (entropy, else boot stalls / no ssh host keys),
+# run under LC_ALL=C (Tcl chokes on the serial's control bytes), blind login
+# with fixed sleeps + a quote-trick shell probe (expect's LIVE matching over the
+# TCG serial is unreliable), and judge success by the &&-chained command's
+# markers in NBLOG rather than by intermediate console matches.
+set timeout 700
+log_file -a $env(NBLOG)
+
+spawn qemu-system-aarch64 -M virt -cpu cortex-a72 -m 2048 -smp 2 -accel tcg \
+ -object rng-random,filename=/dev/urandom,id=rng0 -device virtio-rng-device,rng=rng0 \
+ -drive if=pflash,format=raw,readonly=on,file=/usr/share/AAVMF/AAVMF_CODE.fd \
+ -drive if=pflash,format=raw,file=$env(NBVARS) \
+ -drive file=$env(NBIMG),format=raw,if=virtio,cache=writeback \
+ -nic user -display none -serial stdio -monitor none
+
+# Fully blind login (root has no password on the stock image). expect's LIVE
+# console matching over the TCG serial is unreliable, so we do NOT probe for a
+# prompt — we just type, with generous settle time, and let bake-golden.sh judge
+# success from the SETUP_OK / halt markers in NBLOG afterward. Sending root twice
+# is harmless: the first logs in, a second lands as "root: not found" at a shell.
+sleep 135
+send "\r"; sleep 2
+send "root\r"; sleep 6
+send "\r"; sleep 2
+send "root\r"; sleep 6
+
+# one &&-chained command: fetch -> setup -> verify dump -> sync -> poweroff.
+# setup.sh prints SETUP_OK; the VBEGIN..VEND block is the on-disk verification.
+set timeout 600
+send "ftp -o /tmp/setup.sh $env(NBHTTP) && sh /tmp/setup.sh && echo VBEGIN && id paul && grep '^paul:' /etc/master.passwd && grep -E '^(hostname|ifconfig_mue0|defaultroute|dhcpcd|sshd)=' /etc/rc.conf && cat /home/paul/.ssh/authorized_keys && echo VEND && sync && sync && halt -p\r"
+expect { eof {} timeout { puts "\n!!TIMEOUT no poweroff" } }
+puts "\n==SESSION ENDED=="
+exit 0
diff --git a/f3s/pi-netbsd/bake/setup.sh b/f3s/pi-netbsd/bake/setup.sh
new file mode 100755
index 0000000..dc63064
--- /dev/null
+++ b/f3s/pi-netbsd/bake/setup.sh
@@ -0,0 +1,69 @@
+#!/bin/sh
+# NetBSD headless image customization. Fetched over qemu user-net and run as
+# root INSIDE the NetBSD-in-qemu VM by config.exp during a bake.
+#
+# `bake-golden.sh` substitutes __SSHKEY__ and __PW__ before serving this file.
+# EDIT the two per-host values below for the target Pi (see README "Doing pi1").
+set -eu
+
+HOSTNAME="pi0.lan.buetow.org" # <-- EDIT for the target Pi (e.g. pi1.lan.buetow.org)
+IPADDR="192.168.1.125" # <-- EDIT for the target Pi (e.g. 192.168.1.126)
+GW="192.168.1.1"
+DNS="192.168.1.1"
+
+RC=/etc/rc.conf
+
+# 1) rc.conf: static networking + hostname + sshd; strip conflicting keys first.
+tmp=$(mktemp)
+grep -vE '^(hostname|ifconfig_mue0|defaultroute|dhcpcd|sshd)=' "$RC" > "$tmp"
+mv "$tmp" "$RC"
+cat >> "$RC" <<EOF
+hostname="$HOSTNAME"
+sshd=YES
+dhcpcd=NO
+ifconfig_mue0="inet $IPADDR netmask 0xffffff00"
+defaultroute="$GW"
+EOF
+
+# 2) resolv.conf
+echo "nameserver $DNS" > /etc/resolv.conf
+
+# 3) rc.local fallback: if the LAN78xx iface is not named mue0, put the static IP
+# on the first real ethernet interface so the box stays reachable.
+cat > /etc/rc.local <<EOF
+#!/bin/sh
+if ! ifconfig mue0 >/dev/null 2>&1; then
+ iface=\$(ifconfig -l | tr ' ' '\n' | grep -E '^(mue|ure|axe|cdce|vioif)[0-9]' | head -1)
+ if [ -n "\$iface" ]; then
+ ifconfig "\$iface" inet $IPADDR netmask 0xffffff00 up
+ route add default $GW
+ fi
+fi
+EOF
+chmod 0755 /etc/rc.local
+
+# 4) user paul (+wheel) + authorized key
+if ! id paul >/dev/null 2>&1; then useradd -m -G wheel -s /bin/sh paul; fi
+mkdir -p /home/paul/.ssh
+cat > /home/paul/.ssh/authorized_keys <<'EOF'
+__SSHKEY__
+EOF
+chmod 0700 /home/paul/.ssh
+chmod 0600 /home/paul/.ssh/authorized_keys
+chown -R paul:users /home/paul/.ssh
+
+# 5) passwords (native NetBSD argon2id hash), same for paul + root (backup login)
+H=$(pwhash '__PW__')
+usermod -p "$H" paul
+usermod -p "$H" root
+
+# 6) sshd: ensure key + password auth (appended lines win over commented defaults)
+cat >> /etc/ssh/sshd_config <<'EOF'
+PubkeyAuthentication yes
+PasswordAuthentication yes
+EOF
+
+rm -f /tmp/setup.sh
+# Flush all writes to the image before qemu is stopped (soft-dep FFS).
+sync; sync
+echo SETUP_OK
diff --git a/f3s/pi-netbsd/flash/95netbsdflash/flash.sh b/f3s/pi-netbsd/flash/95netbsdflash/flash.sh
new file mode 100755
index 0000000..c15bb71
--- /dev/null
+++ b/f3s/pi-netbsd/flash/95netbsdflash/flash.sh
@@ -0,0 +1,82 @@
+#!/bin/sh
+# dracut pre-mount hook (SOURCED by dracut init — must not call exit/return at
+# top level in a way that aborts init; we keep all action inside an `if`).
+#
+# Trigger: a file `netbsd-flash-mode` (contents "dryrun" or "real") on the FAT
+# /boot partition. If absent, this hook does nothing and normal boot continues.
+#
+# SAFETY: the very first thing we do once triggered is DISARM — delete
+# config.txt and the trigger from the FAT partition — so that if anything later
+# fails, a power-cycle boots normally back into Rocky (config.txt gone => the Pi
+# firmware loads no initramfs).
+
+FATDEV=/dev/mmcblk0p1
+ROOTDEV=/dev/mmcblk0p3
+DISK=/dev/mmcblk0
+
+nf_log() { echo "netbsdflash: $*"; echo "netbsdflash: $*" > /dev/kmsg 2>/dev/null; }
+nf_reboot() { sync; sleep 2; reboot -f 2>/dev/null; sleep 2; echo b > /proc/sysrq-trigger 2>/dev/null; sleep 15; }
+
+mkdir -p /nf_fat /nf_root /nf_ram 2>/dev/null
+NF_MODE=""
+if mount -t vfat "$FATDEV" /nf_fat 2>/dev/null; then
+ [ -f /nf_fat/netbsd-flash-mode ] && NF_MODE=$(tr -d ' \r\n' < /nf_fat/netbsd-flash-mode 2>/dev/null)
+fi
+
+if [ -n "$NF_MODE" ]; then
+ nf_log "TRIGGERED mode=$NF_MODE"
+ # --- DISARM FIRST (self-heal on any later failure) ---
+ rm -f /nf_fat/config.txt /nf_fat/netbsd-flash-mode 2>/dev/null
+ sync
+ echo "FLASHER-RAN mode=$NF_MODE up=$(cat /proc/uptime 2>/dev/null)" > /nf_fat/flash-evidence.txt 2>/dev/null
+ sync
+ nf_log "disarmed; evidence written"
+
+ # --- stage golden image from ext4 root into RAM ---
+ NF_OK=1
+ if ! mount -t ext4 -o ro "$ROOTDEV" /nf_root 2>/dev/null; then
+ nf_log "FATAL mount root"; echo "FATAL mount-root" >> /nf_fat/flash-evidence.txt; NF_OK=0
+ fi
+ GZ=""
+ for cand in /nf_root/home/paul/netbsd-*-golden.img.gz /nf_root/root/netbsd-*-golden.img.gz; do
+ [ -f "$cand" ] && { GZ="$cand"; break; }
+ done
+ if [ "$NF_OK" = 1 ] && [ -z "$GZ" ]; then
+ nf_log "FATAL image-missing"; echo "FATAL image-missing" >> /nf_fat/flash-evidence.txt; NF_OK=0
+ fi
+ if [ "$NF_OK" = 1 ]; then
+ mount -t tmpfs -o size=550m tmpfs /nf_ram 2>/dev/null
+ if cp "$GZ" /nf_ram/g.gz 2>/dev/null; then
+ nf_log "staged $(wc -c < /nf_ram/g.gz 2>/dev/null) bytes in RAM"
+ else
+ nf_log "FATAL copy-to-RAM"; echo "FATAL copy-RAM" >> /nf_fat/flash-evidence.txt; NF_OK=0
+ fi
+ fi
+ umount /nf_root 2>/dev/null
+ sync
+
+ if [ "$NF_OK" = 1 ] && [ "$NF_MODE" = dryrun ]; then
+ bytes=$(gzip -dc /nf_ram/g.gz 2>/dev/null | wc -c); rc=$?
+ nf_log "DRYRUN bytes=$bytes rc=$rc"
+ echo "DRYRUN-RESULT bytes=$bytes rc=$rc" >> /nf_fat/flash-evidence.txt
+ sync; umount /nf_fat 2>/dev/null
+ nf_log "DRYRUN done; rebooting to Rocky"
+ nf_reboot
+ elif [ "$NF_OK" = 1 ]; then
+ echo "REAL-START up=$(cat /proc/uptime 2>/dev/null)" >> /nf_fat/flash-evidence.txt
+ sync; umount /nf_fat 2>/dev/null
+ nf_log "REAL: writing image onto $DISK"
+ gzip -dc /nf_ram/g.gz 2>/dev/null | dd of="$DISK" bs=4M conv=fsync 2>&1 | tail -2 | while read -r l; do nf_log "dd: $l"; done
+ sync
+ nf_log "REAL flash complete; rebooting into NetBSD"
+ nf_reboot
+ else
+ # a FATAL occurred; config.txt already removed, so reboot -> Rocky
+ sync; umount /nf_fat 2>/dev/null
+ nf_log "aborted; rebooting to Rocky"
+ nf_reboot
+ fi
+else
+ # not triggered: clean up and let the normal boot proceed
+ umount /nf_fat 2>/dev/null
+fi
diff --git a/f3s/pi-netbsd/flash/95netbsdflash/module-setup.sh b/f3s/pi-netbsd/flash/95netbsdflash/module-setup.sh
new file mode 100755
index 0000000..4d82ad9
--- /dev/null
+++ b/f3s/pi-netbsd/flash/95netbsdflash/module-setup.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+# dracut module: 95netbsdflash (Pi-firmware initramfs variant)
+# Loaded by the Pi firmware via config.txt `initramfs flasher.img followkernel`.
+# A pre-mount hook (runs in the initramfs, before the real root is mounted)
+# reads a trigger file from the FAT /boot partition and flashes NetBSD onto the
+# SD from a RAM copy of the golden image staged on the ext4 root.
+
+check() { return 0; }
+depends() { return 0; }
+
+installkernel() {
+ # vfat to read /boot (config.txt/trigger), ext4 to read the staged image,
+ # mmc drivers for the SD.
+ instmods vfat nls_cp437 nls_ascii ext4 mmc_block sdhci sdhci-iproc
+}
+
+install() {
+ inst_multiple gzip gunzip dd sync sleep sh cat wc mount umount mkdir rm ls cp grep tr
+ inst_hook pre-mount 99 "$moddir/flash.sh"
+}
diff --git a/f3s/pi-netbsd/flash/build-flasher.sh b/f3s/pi-netbsd/flash/build-flasher.sh
new file mode 100755
index 0000000..80f66fe
--- /dev/null
+++ b/f3s/pi-netbsd/flash/build-flasher.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+# Stage B, step 2 — run on the Pi (as root via sudo). Installs the 95netbsdflash
+# dracut module and builds a standalone /boot/flasher.img that the Pi firmware
+# loads via config.txt. Does NOT arm or reboot (see README Stage B steps 3-4).
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+KVER=$(uname -r)
+MODDIR=/usr/lib/dracut/modules.d/95netbsdflash
+OUT=/boot/flasher.img
+
+echo "== install dracut module 'netbsdflash' =="
+mkdir -p "$MODDIR"
+install -m0755 "$HERE/95netbsdflash/module-setup.sh" "$MODDIR/module-setup.sh"
+install -m0755 "$HERE/95netbsdflash/flash.sh" "$MODDIR/flash.sh"
+
+echo "== keep an untouched backup of the default initramfs (first time only) =="
+cp -n "/boot/initramfs-$KVER.img" "/boot/initramfs-$KVER.img.orig" 2>/dev/null || true
+
+echo "== build $OUT for kernel $KVER =="
+# ext4/vfat/mmc/nls are built-in on the RPi kernel, but --add-drivers is harmless
+dracut --force --no-hostonly \
+ --add netbsdflash \
+ --add-drivers "vfat nls_cp437 nls_ascii ext4 mmc_block sdhci sdhci-iproc" \
+ "$OUT" "$KVER"
+
+ls -la "$OUT"
+echo "== sanity: hook present in initramfs =="
+lsinitrd "$OUT" | grep -E 'pre-mount/99-flash.sh' && echo "hook OK" || { echo "HOOK MISSING"; exit 1; }
+echo "== done. Arm with a /boot/config.txt + /boot/netbsd-flash-mode (see README). =="
diff --git a/f3s/ychat/helm-chart/Chart.yaml b/f3s/ychat/helm-chart/Chart.yaml
index d85b4aa..ff3d105 100644
--- a/f3s/ychat/helm-chart/Chart.yaml
+++ b/f3s/ychat/helm-chart/Chart.yaml
@@ -3,4 +3,4 @@ name: ychat
description: yChat revival (SQLite-backed, persistent registered accounts) for f3s
type: application
version: 0.8.0
-appVersion: "1f28ff5" \ No newline at end of file
+appVersion: "deacb3f" \ No newline at end of file
diff --git a/f3s/ychat/helm-chart/templates/deployment.yaml b/f3s/ychat/helm-chart/templates/deployment.yaml
index 3bfbfa2..b838adf 100644
--- a/f3s/ychat/helm-chart/templates/deployment.yaml
+++ b/f3s/ychat/helm-chart/templates/deployment.yaml
@@ -43,7 +43,7 @@ spec:
- name: ychat
# SQLite is now mandatory (see ychat/DOCKER.md); the image tag tracks
# the ychat repo commit that build was cut from.
- image: registry.lan.buetow.org:30001/ychat:1f28ff5
+ image: registry.lan.buetow.org:30001/ychat:deacb3f
imagePullPolicy: IfNotPresent
securityContext:
allowPrivilegeEscalation: false