# guix-router — GNU Guix on Raspberry Pi 4 (router testbed for the GL-MT6000 port) Goal: run a Guix System router on a Raspberry Pi 4B (real aarch64, cheap, no brick risk), then port the stack to the GL.iNet GL-MT6000. OpenWrt's userland (ubus/uci/netifd) gets packaged for Guix as we go. See `plans/` for the full roadmaps. Status: **Phase 1 done — image boots on a real Pi 4B (2026-10-14).** Phase 2 (WISP router: WiFi WAN, Ethernet LAN) **working on hardware (2026-10-14)**: workstation → Pi → hakodosh 5 GHz → internet. `guix-rpi4-claude.img` (6.3 G, symlink into the store) flashes to an 8 GB+ microSD card; the root partition grows to fill the card on first boot. ## Repository layout | File | Purpose | |---|---| | `rpi4.scm` | The operating-system declaration (Pi 4 router) | | `rpi4-image.scm` | SD-card image: FAT boot partition + ext4 root | | `packages/rpi4-linux.scm` | Nonguix `linux` + the options a Pi 4 needs (SD, GENET) | | `packages/u-boot-rpi-arm64-guix-router.scm` | U-Boot with a memory layout that fits the kernel | | `packages/rpi-boot-firmware.scm` | Non-free Pi first-stage boot firmware (`start4.elf`, fixups, upstream overlays) | | `files/uci/` | UCI seeds for `/etc/config` (network, dhcp, firewall, system): copied once, then owned by UCI | | `scripts/check-closure-arch.sh` | Lists x86-64 store items leaked into a cross-built system (PITFALLS #39) | | `files/wpa_supplicant.conf.example` | WAN WiFi template, copied onto the boot partition | | `files/workstation.pub` | SSH key authorized for root | | `packages/openwrt.scm` | OpenWrt userland: `libubox`, `ubus`, `uci`, `libnl-tiny`, `jsonfilter`, `ucode`, `udebug`, `netifd`, `odhcpd`, `firewall4`, `openwrt-base-files`, `openwrt-dnsmasq`, `openwrt-uhttpd`, `nodogsplash`, `openwrt-nodogsplash`, `iptables-nft/cross`, `wpad`, `wifi-scripts`, `rpcd`, `ustream-ssl`, `uhttpd`, `iwinfo`, `lucihttp`, `cgi-io`, `rpcd-mod-luci`, `luci`, `procd-compat` | | `packages/openwrt-services.scm` | `ubus-service-type`, `uci-service-type` (seeds `/etc/config`, runs uci-defaults once), `netifd-service-type`, `odhcpd-service-type`, `firewall-service-type`, `procd-compat-service-type` (reload triggers), `sysfixtime-service-type`, `openwrt-init.d-service-type`, `openwrt-init-script-service-type`, `openwrt-dnsmasq-service-type`, `nodogsplash-service-type`, `openwrt-wireless-service-type`, `rpcd-service-type`, `uhttpd-service-type`, `luci-service-type`, `hotplug-call` | | `packages/patches/luci-busybox-top.patch` | LuCI's process list parses Guix's busybox `top` | | `packages/tollgate.scm`, `packages/tollgate-services.scm` | TollGate (`tollgate-wrt`, Go) and `tollgate-service-type` | | `packages/procd-compat/` | `procd-compat.uc` (ubus `service`/`system` objects), `reload_config`, `openwrt-rc` (procd init scripts without procd) | | `packages/cross-libc-fix.scm` | Cross glibc whose `system()`/`popen()` run `/bin/sh` (PITFALLS #35) | | `files/uci/` | Initial `/etc/config/*` files (seeded once, then owned by UCI) | | `build-rpi-image.sh` | Cross-build the flashable image (`guix system image rpi4-image.scm`) | | `flash-rpi.sh` | Guarded `dd` to an SD card | | `deploy-rpi.sh` | Update the running Pi over SSH: build, `guix copy`, switch, reboot; `list`, `switch N` (roll back) | | `scripts/extlinux-conf.scm` | Builds the Pi's `extlinux.conf` on the workstation (used by `deploy-rpi.sh`) | | `mt6000.scm`, `mt6000-image.scm` | The GL-MT6000 system and its root image | | `packages/mt6000-linux.scm`, `packages/patches/linux-mt6000/` | `linux-mt6000`: mainline + OpenWrt patches, board DTS + `-guix.dtsi` fixes | | `build-mt6000-root.sh`, `scripts/mt6000-fit.sh` | Build `mt6000-root.ext4` (for p7) and the FIT boot image (for p6) | | `deploy-mt6000.sh` | Update the running MT6000: build, `guix copy`, write FIT, switch, reboot; `list`, `switch N` | | `build-release.sh` | Build a release: Pi image, MT6000 root + FIT, installers, checksums, `INSTALL.md` (in `release/`) | | `install-mt6000.sh` | Install a release on an MT6000 over Ethernet (TFTP-boots OpenWrt to RAM, writes p6/p7, verifies) | | `docs/INSTALL.md` | Installation, first login, updates and recovery -- shipped in every release | | `files/workstation-guix.pub` | Workstation's Guix signing key, authorized on the Pi | | `run-qemu-rpi4.sh` | Boot the image in QEMU `raspi4b` (serial console, no network) | | `qemu-virt.scm`, `run-qemu.sh` | Draft QEMU (`-M virt`) prototype config | | `plans/` | Project plans (Pi 4 → MT6000, RB5009 fallback) | | `docs/HOST-SETUP.md` | How the build host was set up, step by step | | `docs/PITFALLS.md` | Everything that broke and how it was fixed — read before debugging | ## Quickstart (build host) ```sh export PATH=$HOME/.local/bin:$PATH export GUIX_PACKAGE_PATH="$HOME/guix-router/packages:$HOME/channels/nonguix" ./build-rpi-image.sh # -> guix-rpi4-claude.img ./run-qemu-rpi4.sh # optional smoke test, login root/guix sudo ./flash-rpi.sh /dev/sdX guix-rpi4-claude.img # check lsblk first! # After that, no reflashing: edit rpi4.scm, then ./deploy-rpi.sh # cross-build, guix copy, new generation, reboot ./deploy-rpi.sh list # generations on the Pi ./deploy-rpi.sh switch 1 # roll back (or pick an entry in the U-Boot menu) ``` `deploy-rpi.sh` expects an `~/.ssh/config` entry `pirouter` (root@192.168.10.1, your key) and `/etc/guix/signing-key.*` on the workstation (`sudo guix archive --generate-key`) whose public half is `files/workstation-guix.pub`. The Pi can't run `guix system reconfigure` itself (PITFALLS #28). GL-MT6000 (plans/guix-openwrt-mt6000-plan.md §0b): first install from OpenWrt booted from RAM (`mt6000-root.ext4` to p7, `mt6000-boot.itb` to p6); then `./deploy-mt6000.sh` the same way as the Pi (`~/.ssh/config` entry `mt6000`, root@192.168.1.1). No boot menu: rolling back rewrites p6, from ssh or, if nothing boots, through U-Boot's TFTP recovery (PITFALLS #51). The kernel (`linux-rpi4`) is a local build, ~50 min cross on this host the first time; everything else comes from substitutes. ## Boot flow ``` Pi4 EEPROM → start4.elf on p1 (FAT32 "RPI-BOOT") reads config.txt: arm_64bit=1, enable_uart=1, upstream_kernel=1, kernel=u-boot.bin → U-Boot 2025.01 (patched memory layout), bootstd → /boot/extlinux/extlinux.conf on p2 (ext4 "Guix"), generated by Guix → /gnu/store/…-linux-rpi4…/Image + initrd + FDTDIR …/lib/dtbs → root=, consoles tty1 + ttyS1 (mini-UART, GPIO 14/15) 115200 ``` MBR image, no GRUB, no post-processing: `rpi4-image.scm` declares both partitions and fills p1 with the firmware, `u-boot.bin`, `config.txt`, the upstream overlays and the kernel's `bcm2711-rpi-4-b.dtb`. The bootloader (`u-boot-rpi4-bootloader` in `rpi4.scm`) is Guix's `u-boot-bootloader` with no raw-disk installer, so `guix system reconfigure` rewrites `extlinux.conf` with every generation and nothing else. p1 is mounted at `/boot/firmware`. ## Network layout (Phase 2, WISP) ``` upstream WiFi "hakodosh" 5 GHz (192.168.88.0/24, MikroTik DHCP; the Pi │ has a lease for its wlan0 MAC e4:5f:01:3e:d2:18) │ wlan0 — wpa_supplicant + netifd (udhcpc) WAN [ Pi 4 ] firewall4 (wan zone masquerade), ip_forward=1 │ eth0 ∈ br-lan (netifd), 192.168.10.1/24 │ dnsmasq DHCPv4/DNS; ULA fd64:60fc:322c::/48 → /60 on lan, │ odhcpd RA + DHCPv6 LAN workstation eno1 (NM profile "pirouter-lan": DHCP + IPv6 auto, never-default) ``` - WiFi credentials: after flashing, on the FAT partition `RPI-BOOT`, copy `wpa_supplicant.conf.example` → `wpa_supplicant.conf` and fill in ssid/psk/country. Never in git or the store (`.gitignore`d); on the Pi it is `/boot/firmware/wpa_supplicant.conf`, root-only (`umask=0077`). - LAN DHCP pool `192.168.10.100–200`, names under `.lan`. - **Don't plug `eth0` into another LAN** — dnsmasq would serve DHCP there. - Lab-only: SSH is also open on `wlan0` (rule `Allow-SSH-WAN-lab` in `/etc/config/firewall`): `ssh -i ~/.ssh/archipelago root@192.168.88.222` — the way in when the LAN cable misbehaves (PITFALLS #30). - Clock: chronyd steps it from 1970 once the WAN is up (no RTC). ## OpenWrt on Guix (Phase 3+) Goal: OpenWrt's userland (ubus, UCI, netifd, firewall4, odhcpd, LuCI) on a Guix base — Guix replaces OpenWrt's kernel/init/opkg/sysupgrade. Decisions (2026-10-14): - **Init:** Shepherd supervises everything; no procd. `procd-compat` provides procd's ubus API (`service`, `system` objects) as far as OpenWrt tools need it; `/etc/init.d/*` shims later if rpcd/LuCI need them. - **Config:** Guix *seeds* `/etc/config/NAME` once (`uci-service-type` `seeds`); after that UCI/LuCI own it and changes survive deploys. Guix rollbacks don't roll back UCI state. - **WiFi WAN:** stays on `wpa-supplicant-service-type`; netifd's wireless (needs OpenWrt's ubus-patched wpad) maybe later. - **LuCI:** running (rpcd, uhttpd, ucode LuCI) since generation 28. - Packages live in `packages/` for now; split into a channel before the MT6000 port. Status: `libubox`, `ubus`, `uci`, `libnl-tiny`, `jsonfilter`, `ucode` (all 15 modules) packaged, pinned to OpenWrt main's commits, native + cross. On the Pi: `ubusd` under Shepherd, `uci` on `/etc/config`, and a ucode service publishing a ubus object that the `ubus` CLI calls (parsed with `jsonfilter`); ucode's rtnl/nl80211 modules see the real links and radio. `udebug` and `netifd` packaged; **netifd owns the LAN** (generation 10): `br-lan` + 192.168.10.1 from `/etc/config/network` (seeded), state on ubus (`network.interface.lan`), hotplug events via `hotplug-call` (`/etc/hotplug.d/*`). **netifd owns the WAN too** (generation 13): wpa_supplicant associates `wlan0`, `netifd-attach-wlan0` hands it to netifd over ubus, and netifd's `dhcp` protocol (busybox `udhcpc`, OpenWrt's `dhcp.sh`/ `dhcp.script`, the `openwrt-base-files` shell library) gets the lease, route and DNS; dhcpcd is gone. Cross-built glibc's `popen()`/`system()` are fixed (`cross-libc-fix`, generation 14, PITFALLS #35). **odhcpd serves IPv6 on the LAN** (generation 18). DHCP/DNS split as on OpenWrt: dnsmasq keeps DHCPv4 and DNS (from UCI since generation 31), odhcpd does router advertisements and DHCPv6 from `/etc/config/dhcp` (seeded). netifd gives `lan` a /60 of the ULA prefix (`network.globals.ula_prefix`, generated once by OpenWrt's `12_network-generate-ula` uci-defaults script). odhcpd's lease trigger runs `herd reload dnsmasq`, so DHCPv6 hostnames resolve. No upstream IPv6 yet (that needs `odhcp6c` on the WAN), so RAs carry no default route. **firewall4 is the firewall** (generation 19; replaces `router.nft`): OpenWrt's stock `/etc/config/firewall` (seeded) plus the lab rule `Allow-SSH-WAN-lab`. fw4 renders it into `table inet fw4`, resolving zones to devices over ubus (lan → br-lan, wan → wlan0); netifd's hotplug (`/etc/hotplug.d/iface/20-firewall`) reloads it when an interface comes up. `herd check firewall` validates a config; `nft list table inet fw4` shows the result. **Config changes reload services, as on OpenWrt** (generation 21): `procd-compat` (a ucode daemon, `packages/procd-compat/`) publishes procd's ubus `service` and `system` objects. `uci commit X` then `reload_config` (or rpcd's commit, for LuCI) sends `config.change`, and procd-compat runs `herd reload` on the services registered for X: firewall → `firewall`, dhcp → `odhcpd`, network/wireless → `netifd` (`ubus call network reload`). Services register by extending `procd-compat-service-type`. `ubus call system board`/`info` give LuCI's status data; `system reboot` works. **LuCI works** (generation 28): `https://192.168.10.1/` (self-signed certificate) or plain HTTP on the LAN, login `root` with the system password; the wan zone rejects it. uhttpd (`uhttpd-service-type`, HTTPS via ustream-ssl/OpenSSL; configured from UCI since generation 39) runs LuCI's ucode dispatcher and ubus JSON-RPC; rpcd (with the file, rpc-sys, iwinfo, ucode and `luci-rpc` plugins) is the backend. The `luci` package holds luci-base, the status, system and network modules, the Bootstrap theme and luci-app-firewall. Checked in a headless browser: overview, routes, system and kernel logs, processes, realtime graphs, nftables, system settings, interfaces, DHCP (odhcpd part), firewall zones/rules/forwards, diagnostics. Save & Apply works with rollback: an unconfirmed change reverts itself and reloads the service. How LuCI's FHS paths are handled: PITFALLS #42. **dnsmasq is configured from UCI** (generation 31), as on OpenWrt: OpenWrt's own init script renders `/etc/config/dhcp` (`config dnsmasq`, `config dhcp`, static leases, domains...) into `/var/etc/dnsmasq.conf.*` (`openwrt-dnsmasq-service-type`, an `openwrt-init-script`). LuCI's DNS and DHCP pages work; `uci commit dhcp; reload_config` restarts dnsmasq when its configuration changed, else sends SIGHUP; netifd's ifup hotplug re-renders (DHCP ranges come from interface state). Only the default instance runs. Lesson from the first try, which hung PID 1: PITFALLS #43. The clock no longer starts at 1970 (generation 34): `sysfixtime-service-type` sets it at boot to the last shutdown time (PITFALLS #44). `/etc/init.d/NAME start|stop|restart|reload|enable|disable|running|status` works for network (netifd), firewall, dnsmasq, odhcpd, rpcd and uhttpd (`openwrt-init.d-service-type`: shims to `herd`, plus `/etc/rc.d/S*` links), so OpenWrt scripts and LuCI's Startup page can drive services. enable/disable last until the next boot. **OpenWrt procd init scripts run under Shepherd** (generation 38): `openwrt-init-script-service-type` takes a script (with store paths) and makes a Shepherd service plus its `/etc/init.d` shim; procd-compat's `openwrt-rc` sources it with procd stubbed and execs its first instance; `herd reload` re-runs it and restarts the process when the instance (command, env, `file` contents) changed. dnsmasq runs this way. uhttpd is configured from `/etc/config/uhttpd` too (generation 39): OpenWrt's init script and stock config as an `openwrt-init-script`; LuCI's handler comes from a uci-defaults script, as luci-base's postinst adds it. uci-defaults scripts are now followed by `uci commit`, as on OpenWrt. **nodogsplash** (captive portal, TollGate's gate) is packaged and runs from OpenWrt's init script (generation 42), seeded disabled. Checked on the LAN: with `uci set nodogsplash.@nodogsplash[0].enabled=1; uci commit; reload_config`, HTTP from the workstation is redirected to the splash page on :2050; `ndsctl auth MAC` lets it through, `ndsctl deauth` gates it again; fw4 reloads leave it in place. Its rules use iptables-nft beside fw4's nftables table, as on OpenWrt. PITFALLS #46. **WAN moved to Ethernet** (generation 43): eth0 is `wan` (DHCP; in the lab from archy-dev-pa's NetworkManager shared connection on eno1, 10.42.0.1, which gives the Pi 10.42.0.2), the WiFi client is `wwan`, a second uplink with a higher metric. br-lan (192.168.10.1) has no wired port: the WiFi AP joins it next. Reach the Pi at 10.42.0.2 (`ssh pirouter`). **The onboard radio is the LAN's WiFi AP** (generation 45), OpenWrt's way: wpad (hostapd + wpa_supplicant with OpenWrt's ubus/ucode patches), netifd's wireless handling (wifi-scripts) and `/etc/config/wireless`, created at first boot by `wifi config` plus defaults: SSID `guix-router`, WPA2, 5 GHz channel 36, in br-lan; the key is random (`uci get wireless.default_radio0.key`). LuCI's Wireless page and `wifi` work. PITFALLS #47. **TollGate runs** (generation 46): `tollgate-wrt` v0.5.0 (Go, static arm64; `packages/tollgate.scm`) as an openwrt-init-script with its own uci-defaults first-boot setup (`tollgate-service-type`, `packages/tollgate-services.scm`). Its setup renamed the public SSID (TollGate-XXXX, open), moved LuCI to :8080 (HTTPS :443 stays), opened tcp 2121 and enabled nodogsplash; `curl http://10.42.0.2:2121/` returns its signed Nostr advertisement (prices in sats via Cashu mints) and `tollgate status` reports config, wallet and network ok. Its private SSID needs a second AP interface, which the Pi's radio doesn't have. PITFALLS #48. Next: a client paying through the portal (phone), then the MT6000 port. ## Prototype credentials - `root` / `guix` — console (serial GPIO14/15 = `ttyS1`, `enable_uart=1`) and SSH; SSH key `files/workstation.pub` also works. - `pi` user exists (wheel) but has no password; use root for the testbed. - `ssh root@192.168.10.1` from the workstation (eno1), or the Pi's hakodosh address over WiFi. - Replace `root` password and switch `password-authentication?` to `#f` before anything non-lab. ## Phase 1 exit checks (on first boot) - [ ] Serial console shows boot (`115200 8N1` on GPIO 14/15) — no adapter yet - [x] `ssh root@` works (key + password) - [x] `ip link` shows onboard `eth0`; USB NIC untested - [x] 6.17.12; brcmfmac loads `brcmfmac43455-sdio` (7.45.234 CY) - [x] `herd status` sane (all services started) ## Key environment facts - Host: Debian 13 x86_64; Guix **1.5.0 binary tarball** install (`docs/HOST-SETUP.md` has every step, incl. substitute keys, build users, `TMPDIR=/var/guix-tmp`, qemu binfmt). - Channels: `guix pull` is **broken upstream** right now (stale `guile-zlib` hash at channel tip; PITFALLS #1). We build on the 1.5.0 bundled channel. - Nonguix: pinned checkout at `~/channels/nonguix`, commit `f5338f6` (newest compatible with guix 1.5.0; master needs `linux-libre-7.2`). Load via `GUIX_PACKAGE_PATH`, not a channel. - Always build aarch64 images with `--target=aarch64-linux-gnu` (cross). `--system=aarch64-linux` is a *foreign* build: whole toolchain under QEMU user-mode, ~10× slower (PITFALLS #5). - Nonguix substitutes server authorized: `https://substitutes.nonguix.org` (signing key `C1FD53E5…EA98`). ## Next steps 1. Tick off the remaining exit checks (above) on hardware. 2. Phase 2 follow-ups (done: chrony time sync, signed regulatory.db → `country US`, EEE off on the LAN port): verify upstream can't reach LAN; USB NIC into `br-lan`; AP (needs a second radio); IPv6 on LAN; close SSH on WAN. (Root partition: done — `grow-root` fills the card on first boot, PITFALLS #31.) 3. Phase 3: finish `packages/openwrt.scm` (`libubox`, `ubus`, `uci` have real sources; hashes are placeholders), add `ucode`, `netifd`, `odhcpd`, `firewall4`. 4. Retest `guix pull` once upstream fixes `guile-zlib`; re-pin nonguix.