summaryrefslogtreecommitdiff
path: root/prompts/skills/f3s/references/bootstrap-netbsd-pi.md
diff options
context:
space:
mode:
Diffstat (limited to 'prompts/skills/f3s/references/bootstrap-netbsd-pi.md')
-rw-r--r--prompts/skills/f3s/references/bootstrap-netbsd-pi.md356
1 files changed, 356 insertions, 0 deletions
diff --git a/prompts/skills/f3s/references/bootstrap-netbsd-pi.md b/prompts/skills/f3s/references/bootstrap-netbsd-pi.md
new file mode 100644
index 0000000..411b059
--- /dev/null
+++ b/prompts/skills/f3s/references/bootstrap-netbsd-pi.md
@@ -0,0 +1,356 @@
+# Bootstrapping a Raspberry Pi 3 f3s node on NetBSD
+
+Runbook for bringing a Rocky Pi node up to NetBSD parity, validated twice now:
+`pi0` first (2026-07-03), then `pi1` the same day using this exact procedure —
+both reboot-tested and confirmed durable. Only `pi2`/`pi3` (Pi-hole) remain
+Rocky; this is the reusable procedure for converting either of them next.
+
+**Do this one node at a time.** Never take down two of `pi0`–`pi3`'s static-HTTP
+pair (or the two Pi-hole nodes) simultaneously — one must always keep serving.
+
+## 0. Before you reinstall: back up state that doesn't come from git
+
+None of this lives in the conf repo — it only exists on the running Pi, so it
+must be pulled off **before** wiping it:
+
+- **Uptime history**: `scp` the target's `/var/spool/uptimed/records` (and
+ `.old` if present) somewhere safe (e.g. `f0.lan.buetow.org:/tmp/<host>.records`,
+ the same pattern used for `pi0`). Restoring it later preserves the uptime
+ streak instead of resetting to zero — see step 6.
+- **WireGuard identity**: not actually needed from the live host — the
+ private key and PSKs already live in `~/git/wireguardmeshgenerator/keys/`
+ (`keys/<host>/priv.key`, `keys/psk/<pair>.key`). Reuse those; do not
+ regenerate, or every peer (`blowfish`, `fishfinger`, `rocky`) needs updating.
+- **Static content**: only relevant if this node is currently the source of
+ truth for `/var/www/html`. Once `pi0` is up and holding the full synced
+ tree, `pi0` becomes the natural source for converting `pi1` next — no
+ separate backup needed, just sync FROM `pi0` afterward (see step 4).
+
+## 1. Flash/image plain NetBSD
+
+Not yet documented in this repo — `f3s/pi-netbsd/bake` and `flash/` exist as
+scaffolding in the conf repo but were empty as of 2026-07-03; the imaging
+process used for `pi0` happened outside this repo. Whatever process is used,
+the expected end state (confirmed on `pi0`) is:
+
+- NetBSD 10.1 `GENERIC64` evbarm64 (aarch64)
+- User `paul`, in group `wheel`, SSH key auth already working
+- Static LAN IP already set via `rc.conf` (`ifconfig_mue0="inet 192.168.1.12N
+ netmask 0xffffff00"`, `defaultroute="192.168.1.1"` — match the hardware
+ table's IP for this host)
+- Hostname set (`hostname="piN.lan.buetow.org"`)
+- **No** `doas`/`sudo`, **no** pkgsrc/pkgin bootstrapped — bare base install
+- `paul` has no private SSH key of its own yet (only `authorized_keys`)
+
+If any of these aren't true after flashing, do them first (they're baked into
+the disk image for `pi0`, presumably via sysinst answers or a post-install
+script — worth capturing properly in `f3s/pi-netbsd/` once that process is
+pinned down).
+
+**Note found on `pi0`**: the image already carried a **pre-baked root
+crontab** entry for the hourly goprecords upload, pointing at
+`/usr/pkg/bin/goprecords-upload-client.sh` with `GOPRECORDS_HOST` already set
+correctly. Check `doas crontab -l` early — if it's there, deploy the script to
+match that path instead of improvising a different location.
+
+## 2. Bootstrap pkgin + real doas
+
+```sh
+ssh paul@piN.lan.buetow.org
+su - # root password from the bake; rotate both root and
+ # paul's passwords once doas works (step below) —
+ # not automated, do it by hand
+export PKG_PATH=https://cdn.NetBSD.org/pub/pkgsrc/packages/NetBSD/aarch64/10.1/All/
+pkg_add -v pkgin
+pkgin -y update
+pkgin -y install doas rsync curl
+printf 'permit nopass :wheel\n' > /usr/pkg/etc/doas.conf # NOT "permit persist" --
+ # that still prompts once
+ # per session, which never
+ # succeeds over a
+ # non-interactive SSH
+ # command (no tty)
+chmod 644 /usr/pkg/etc/doas.conf
+exit # back to paul
+doas true # should succeed with no password prompt
+```
+
+**Why real `doas`, not the Rocky pattern**: `pi2`–`pi3` only alias `doas` to
+`sudo` via `/etc/profile.d/doas.sh`, which doesn't expand in the
+non-interactive shell an SSH command runs in — so
+`~/git/dotfiles/scripts/wol-f3s`'s `shutdown-pis`/`shutdown-all` (which runs
+`ssh paul@pi "doas poweroff"`) is silently broken on the Rocky Pis today. A
+real `doas` binary fixes it for whichever Pi gets NetBSD.
+
+**Gotcha**: commands run via `doas` get a minimal `PATH` that excludes
+`/usr/sbin` and `/usr/pkg/bin` — always use full paths (`doas
+/usr/sbin/chown`, `doas /usr/pkg/bin/wg`) or an explicit `PATH=` for cron.
+
+## 3. WireGuard (userspace — no native `wg(4)` on this platform)
+
+**`wg(4)` doesn't exist on evbarm-aarch64 10.1** — confirmed on `pi0`: the
+module is absent from all 249 files under `/stand/evbarm/10.1/modules`, so
+`ifconfig wg0 create` fails outright (`clone_command: Invalid argument`),
+despite `wg(4)` being upstream NetBSD since 9.2. Don't waste time on it;
+`wireguard-go` + `wg` (pkgsrc `wireguard-tools`, **no `wg-quick`** in this
+build) is the working path:
+
+```sh
+pkgin -y install wireguard-go wireguard-tools
+```
+
+Pull this host's existing private key and the three PSKs (`blowfish`,
+`fishfinger`, `rocky`) from `~/git/wireguardmeshgenerator/keys/` — do not
+regenerate. Also grab peer pubkeys/endpoints/AllowedIPs from
+`dist/<host>/etc/wireguard/wg0.conf` if the generator has already rendered
+them, or from another live node's `/etc/wireguard/wg0.conf` for the endpoint
+details (same for every Pi, only `AllowedIPs`/PSK differ per host).
+
+Key facts that cost time on `pi0`:
+
+- The interface **must** be named `tunN` (`wireguard-go` rejects `wg0`:
+ "Interface name must be tun[0-9]*"). Use `tun0`.
+- **Address the interface before starting `wireguard-go`**, or its read loop
+ dies immediately with `EHOSTDOWN` ("host is down") and does not retry:
+ ```sh
+ ifconfig tun0 create
+ ifconfig tun0 inet <wg-ip> <wg-ip> netmask 255.255.255.255
+ ifconfig tun0 inet6 <wg-ipv6>
+ ifconfig tun0 up
+ wireguard-go tun0 # daemonizes on its own
+ ```
+- Apply crypto config with the real `wg` CLI (supports `PersistentKeepalive`,
+ unlike the native `wgconfig` tool which has no keepalive flag at all):
+ ```sh
+ wg setconf tun0 /usr/pkg/etc/wireguard/tun0.conf
+ ```
+ `tun0.conf` is the normal `[Interface]`/`[Peer]` format — same content as
+ what `wireguardmeshgenerator` renders to `dist/<host>/etc/wireguard/wg0.conf`,
+ just handed to `wg` instead of `wg-quick`.
+- **No `wg-quick` means no automatic routes.** Each peer's AllowedIPs needs an
+ explicit host route via the local tun IP:
+ ```sh
+ route add -inet <peer-allowed-ip>/32 <local-tun4-ip> -iface
+ route add -inet6 <peer-allowed-ipv6>/128 <local-tun6-ip> -iface
+ ```
+
+Wire all of this into a custom `/etc/rc.d/wireguard` (there's no stock rc.d
+for this combination — write one, model it on `pi0`'s). Enable with
+`wireguard=YES` in `/etc/rc.conf`.
+
+**Follow-up not yet done for any host**: `wireguardmeshgenerator.rb` only
+branches on `os == 'Linux' | 'FreeBSD' | 'OpenBSD'` and always emits
+`wg-quick`-style files; the generator's YAML still lists NetBSD hosts as
+`os: Linux`. Until it gains NetBSD support, each converted Pi's WireGuard
+config is a manually-maintained exception that a future
+`--generate`/`--install` regen would clobber.
+
+## 4. Webserver — bozohttpd
+
+Built into NetBSD base, matches the "static-only, low-RAM" reasoning already
+used to justify lighttpd on the Rocky Pis. No stock rc.d exists that actually
+uses `httpd_flags` (the shipped `/etc/rc.d/httpd` computes `command_args`
+itself and never references that variable) — write a dedicated
+`/etc/rc.d/bozohttpd`:
+
+```sh
+command="/usr/libexec/httpd"
+pidfile="/var/run/bozohttpd.pid"
+command_args="-b -X -U _httpd -P ${pidfile} -v /var/www/html -V /var/www/html"
+required_dirs="/var/www/html"
+```
+
+- `-v /var/www/html -V /var/www/html`: vhost directory = same tree as the
+ default docroot. A `Host:` header matching a **literally-named**
+ subdirectory (e.g. `snonux.foo/`) is served from there; anything unmatched
+ falls back to the plain docroot via `-V`.
+- `www.snonux.foo` needs to be a symlink to `snonux.foo` (bozohttpd matches
+ the literal Host header as a directory name, not a regex like lighttpd's
+ `$HTTP["host"] =~ "^(www\.)?snonux\.foo$"`).
+- **`-X` (directory indexing) is required**, not optional — caught by a live
+ redundancy test (stopping the other node's lighttpd and curling every page):
+ bare directories with no `index.html` (e.g. a photo gallery folder under
+ `/fotos/`) 404 without it, because lighttpd has
+ `dir-listing.activate = "enable"` and bozohttpd needs the equivalent flag.
+- **Give every real routed hostname its own vhost entry, even the "default"
+ one** — don't rely on `-V` fallback for anything actually reachable from the
+ internet. `f3s.buetow.org` (checked in `relayd.conf` on the frontends: the
+ real routed names are `f3s.buetow.org`, `www.f3s.buetow.org`,
+ `standby.f3s.buetow.org` — `/scifi/` etc. are **paths** under it, not
+ separate subdomains) had no vhost dir, so it hit `-V`, and bozohttpd's
+ directory-without-trailing-slash redirect in that fallback path uses its own
+ **system hostname**, not the client's `Host:` header (unlike a real vhost
+ match, which correctly echoes back e.g. `snonux.foo`). Since the system
+ hostname (`piN.lan.buetow.org`) doesn't resolve outside the LAN, this
+ produced redirects that hang for external clients. Fix: self-referencing
+ symlinks so these become vhost matches instead of fallbacks —
+ `ln -sf . /var/www/html/f3s.buetow.org` (and the `www.`/`standby.` variants).
+
+Enable with `bozohttpd=YES` in `/etc/rc.conf`.
+
+## 5. Static content sync
+
+Direction depends on which Pi is being converted and what's already been
+migrated:
+
+- Converting the **first** Rocky Pi in a same-role pair (this was `pi0`):
+ pull from the Rocky sibling that's staying up (`pi1` for `pi0`).
+- Converting the **second** one (`pi1`, once `pi0` is already NetBSD and
+ holds the full current tree): reverse it — pull from `pi0` instead.
+
+One-time bring-up + hourly cron, same script either way (just swap source
+host):
+
+```sh
+#!/bin/sh
+set -e
+STAGE=/tmp/wwwsync-cron
+mkdir -p "$STAGE"
+rsync -a --delete -e "ssh -o StrictHostKeyChecking=accept-new" \
+ paul@<source-host>.lan.buetow.org:/var/www/html/ "$STAGE/"
+doas rsync -a --delete "$STAGE/index.html" "$STAGE/fotos" "$STAGE/scifi" /var/www/html/
+doas rsync -a --delete "$STAGE/snonux/" /var/www/html/snonux.foo/
+doas /usr/sbin/chown -R root:wheel /var/www/html
+```
+
+Needs a fresh SSH keypair for `paul` on this host (none exists on a bare
+image), authorized on the source host's `~/.ssh/authorized_keys`, plus a
+static `/etc/hosts` entry for the source hostname (Pi-to-Pi `.lan.buetow.org`
+resolution failed for both `pi0`→`pi1` and originally `pi0`→`pi1` during the
+very first Rocky bring-up — add the IP directly rather than debugging DNS).
+
+Install as `paul`'s crontab (not root's — needs the new SSH key):
+`17 * * * * /usr/local/bin/sync-from-<source>.sh >$HOME/sync-from-<source>.log 2>&1`
+
+## 6. uptimed (built from source — no prebuilt package)
+
+**No aarch64 binary package exists** in pkgsrc for `uptimed` on any branch
+checked (10.0, 10.1, 11.0, 9.4). Build from upstream instead — small C
+project, NetBSD base already has `gcc`/`make`:
+
+```sh
+pkgin -y install autoconf automake libtool pkg-config
+cd /tmp
+curl -sLO https://github.com/rpodgorny/uptimed/archive/refs/tags/v0.4.7.tar.gz
+tar xzf v0.4.7.tar.gz && cd uptimed-0.4.7
+PATH=/usr/pkg/bin:$PATH ./autogen.sh
+PATH=/usr/pkg/bin:$PATH ./configure --prefix=/usr/pkg --sysconfdir=/etc
+PATH=/usr/pkg/bin:$PATH make
+doas env PATH=/usr/pkg/bin:/usr/bin:/bin:/usr/sbin:/sbin make install
+```
+
+Installs `uptimed` to `/usr/pkg/sbin`, `uprecords` to `/usr/pkg/bin`, and uses
+`/var/spool/uptimed/records` (matches the Rocky path — this is hardcoded
+upstream, not an OS convention thing).
+
+**Before first start**, write `/etc/uptimed.conf` with `LOG_MAXIMUM_ENTRIES=0`
+(keep forever — copy the Rocky sibling's `/etc/uptimed.conf` verbatim,
+milestones included) and restore the backed-up history from step 0:
+
+```sh
+doas cp <backed-up-records-file> /var/spool/uptimed/records
+doas cp <backed-up-records-file> /var/spool/uptimed/records.old # both, not just one
+doas /usr/sbin/chown root:wheel /var/spool/uptimed/records /var/spool/uptimed/records.old
+```
+
+**Critical bug to know about**: `read_records()` in `libuptimed/urec.c`
+unconditionally sets `useold = -1` ("no useable database found") if
+`records.old` doesn't exist yet — **regardless of whether the primary
+`records` file is valid**. Seeding only `records` and starting the daemon
+loses the imported history immediately (it gets shunted to a fresh
+`records.old` on the first periodic rewrite, then overwritten again 60s
+later). Seed **both** files with the same content before the first start.
+
+Write a custom `/etc/rc.d/uptimed` (upstream ships a Linux-init `etc/rc.uptimed`,
+not usable directly):
+
+```sh
+command="/usr/pkg/sbin/uptimed"
+pidfile="/var/run/uptimed.pid"
+command_args="-p ${pidfile}"
+```
+
+Run `uptimed -b` once (creates the boot ID), enable with `uptimed=YES`.
+
+## 7. goprecords upload
+
+```sh
+kubectl exec -n services deployment/goprecords -- \
+ goprecords --create-client-key <host> -stats-dir=/data/stats
+```
+(from a machine with cluster access — this can occasionally 502 if the
+apiserver's exec proxy can't reach whichever k3s node the pod landed on; just
+retry, it's a transient networking issue, not a token problem.)
+
+Deploy `goprecords-upload-client.sh` (from `~/git/goprecords/scripts/`,
+already POSIX/generic and already handles `/var/spool/uptimed/records` and a
+NetBSD `dmesg.boot`/`sysctl` fallback for `os.txt`/`cpuinfo.txt` — no changes
+needed) to **`/usr/pkg/bin/`** if the pre-baked crontab expects that path (see
+the note in step 1), token at `/etc/goprecords-upload.token` (`0600`),
+`GOPRECORDS_HOST=<host>`.
+
+`curl` and `uprecords` need to be resolvable via whatever `PATH` the cron
+entry sets — if it's the pre-baked one with an explicit `PATH=` line
+including `/usr/pkg/bin`, no symlinking needed; test with that exact `PATH`
+before trusting a manual test run under plain `doas` (which won't have it).
+
+## 8. Firewall — npf, not firewalld
+
+```
+$ext_if = "mue0"
+
+group "external" on $ext_if {
+ pass stateful out final all
+ pass stateful in final family inet4 proto tcp to $ext_if port 22
+ pass stateful in final family inet4 proto tcp to $ext_if port 80
+}
+
+group "wireguard" on tun0 {
+ pass stateful out final all
+ pass stateful in final family inet4 all
+ pass stateful in final family inet6 all
+}
+
+group default {
+ pass final on lo0 all
+ block all
+}
+```
+
+`family inet4`/`inet6` must be explicit on multi-family interfaces or
+`npfctl validate` fails with "address family mismatch". Sequence carefully to
+avoid locking yourself out over SSH:
+
+```sh
+doas npfctl validate # syntax-check first
+doas npfctl reload # loads config, does NOT enable filtering yet
+doas npfctl start # enables filtering
+# from a FRESH ssh connection (not the one you're already in), confirm:
+# - ssh still connects
+# - curl http://localhost/ still works
+doas sh -c 'echo npf=YES >> /etc/rc.conf' # only after confirming the above
+```
+
+If you get a JIT warning (`error loading the bpfjit module... Operation not
+permitted`) — harmless, just means `kern.securelevel` blocks loading that
+optional performance module; filtering still works, just slightly slower
+packet matching.
+
+## 9. Verification
+
+- `curl -fsI http://<host>.lan.buetow.org/` and the vhost via `Host:` header.
+- `wg show tun0` shows recent handshakes with `blowfish` and `fishfinger` (and
+ `rocky` if that VM happens to be up — it's often not, unrelated to this
+ work).
+- goprecords report (`https://goprecords.f3s.buetow.org/report`) picks up the
+ new host after the hourly cron fires (won't rank in the "top 20 all-time"
+ table with a fresh/short history — that's expected, not a failure).
+- **Real redundancy test** (this is what actually caught the missing `-X`
+ flag on `pi0` — don't skip it): stop the *other* node in the pair's
+ webserver entirely, then curl every real page through the **public**
+ domains (not just localhost) — root page, each vhost, and any bare
+ directory paths (e.g. `/fotos/`). Restore the other node's webserver
+ immediately after.
+- `wol-f3s shutdown-pis` (or a targeted `ssh paul@<host> "doas poweroff"`)
+ actually powers the Pi off — confirms the real-`doas` fix, but there's no
+ WoL for Pis, so only do this when you can physically power it back on.