2026-10-16 19:10:52 -04:00

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)

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=<uuid>, 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 (.gitignored); 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
  • ssh root@<ip> works (key + password)
  • ip link shows onboard eth0; USB NIC untested
  • 6.17.12; brcmfmac loads brcmfmac43455-sdio (7.45.234 CY)
  • 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.
S
Description
No description provided
Readme
510 KiB
Languages
Scheme 72.4%
Shell 13.7%
Tree-sitter Query 11%
UnrealScript 2.9%