On a fresh image 99-tollgate-setup ran at activation, before wifi-setup had created /etc/config/wireless, so the open TollGate SSID never appeared (the Pi release image came up with `guix-router`). uci-service- type gains late-defaults, run by wifi-setup after `wifi config` and the first config, then one reboot if any ran. TollGate's setup and the MT6000's 99_zz-wired-private move there. Tested on the Pi (fresh WiFi state: TollGate-C69B after one automatic reboot); MT6000 generation 7 (scripts already done there: no reboot). PITFALLS #57. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
41 KiB
Pitfalls encountered (and fixes)
Numbered for cross-reference from the README. All verified on this host with guix 1.5.0 / Debian 13, 2026-10.
1. guix pull is broken upstream (stale guile-zlib hash)
Channel tip ded4de6 pins guile-zlib-0.2.2 to sha256 04p9lb…mnx2, but
Codeberg regenerated the archive/v0.2.2.tar.gz asset: the current archive
hashes to 1h9q7rw8…wlv3g and the stable release asset to 011flqx…g2hz.
Every guix pull of that tip fails with a download hash mismatch inside
compute-guix-derivation.
Workaround: don't pull — build with the 1.5.0 CLI's bundled channel
everything else keeps working (substitutes for 1.5.0 are on the CI). Re-test
guix pull periodically; when it works, re-pin nonguix to match.
Diagnosis note: guix hash prints nix-base32 by default (-f base32 is
lowercase RFC-4648, and the daemon's "actual hash:" error line is a different
representation again) — don't chase ghosts comparing them.
2. Nonguix master requires guix newer than 1.5.0
nongnu/packages/linux.scm defines linux-7.2 → (corrupt-linux linux-libre-7.2); loading aborts at linux-libre-7.2: unbound variable and
leaves a half-initialized module whose public interface is only
(corrupt-linux). Symptom in configs: error: linux: unbound variable with
no helpful backtrace.
Fix: pin nonguix to f5338f6 (last commit before linux-6.18/7.x were
added; provides linux = 6.17).
3. Missing substitute signing keys → silent bootstrap from source
The binary tarball ships /etc/guix/acl without the CI keys. Builds then
"work" but compile everything (you'll notice glibc-bootstrap-0.drv building
and Python-3.5.9.tar.xz unpacking). Diagnostic:
guix weather <pkg> → "substitutes from 'https://ci.guix.gnu.org' are
unauthorized". Fix: authorize the .pub files in
$GUIX_PROFILE/share/guix/ (see HOST-SETUP §5). aarch64 substitutes for
1.5.0 are 100% available on the CI.
4. /tmp is a 7.8 G tmpfs
Kernel build dies after ~50 min with
fatal error: error writing to /tmp/guix-build-linux-…/cc*.s: No space left on device.
Fix: TMPDIR=/var/guix-tmp in the daemon unit (world-writable, on the big
VG), Environment= line — not the shell.
5. --system=aarch64-linux ≠ cross build
On an x86_64 host, --system means foreign: the entire dependency chain is
built for aarch64 and executed under qemu-binfmt (check with
ps aux | grep binfmt-P — an aarch64 cc1 under qemu-aarch64 = foreign).
Foreign kernel: 3–6 h. --target=aarch64-linux-gnu cross-compiles natively:
~50 min. Always --target for images from this host.
6. Cross-built images install an x86 GRUB; bootloader chains are ignored
make-grub-efi-installer explicitly no-ops in disk-image mode, and
efi-bootloader-chain files/packages are only installed by
guix system init / the installer — not by guix system image. The efi-raw
image ends up with host-arch EFI/BOOT/BOOTX64.EFI (verified: file says
x86-64) and none of the chain's config.txt/u-boot/DTB/firmware.
Fix (this repo's design): don't use GRUB at all. rpi4-image.scm
declares the FAT boot partition (firmware, U-Boot, config.txt, DTB) and the
ext4 root directly, and rpi4.scm uses a u-boot-bootloader variant with no
raw-disk installer that only writes extlinux.conf (see README "Boot flow").
An earlier prepare-rpi-image.sh post-processed an efi-raw image instead; it
was removed once the declarative image booted on hardware.
7. make-raspi-bcm28-dtbs (gnu packages raspberry-pi, 1.5.0) is broken
Its #:install-plan gexp evaluates (search-input-directory %build-inputs …)
at compile time → Apply to non-list: #<gexp …>. Define your own trivial
DTB package if needed.
8. copy-build-system install-plan gotcha
("start4.elf" ".") → copy-file: Is a directory (the destination is used as
the full path, not appended to). The firmware package uses an explicit
trivial-build-system builder instead.
9. initrd module names must match .ko file names exactly
sdhci_arasan → not found; the module is sdhci-of-arasan (dashes!).
mmc_block, sdhci_pltfm do use underscores. Symptom:
kernel module not found "sdhci_arasan" during linux-modules build.
10. trivial-build-system builders: PATH is empty
tar xzf needs gzip present, not just tar+xz
(set-path-environment-variable "PATH" '("bin") (list tar xz gzip)).
11. guix build -f quirks
- the file's last expression must be the package variable (hint mentions
my-package); GUIX_PACKAGE_PATHis honored byguix build/guix systembut not byguix repl(useguix repl -L DIR);- flags are
-f/--formatlowercase (capital-Fis a different option).
12. Image partition labels ≠ config labels
With the stock efi-raw image type, genimage names partitions Guix_image /
GNU-ESP regardless of the file-system-labels in the OS config, and Guix
mounts by label → boot fails to find root. Fix: declare the partitions
yourself (rpi4-image.scm sets (label "RPI-BOOT") / (label "Guix")) so
they match rpi4.scm's file-systems.
13. extlinux path prefix semantics (U-Boot)
U-Boot's syslinux loader resolves image paths relative to the directory
containing extlinux.conf (prefix). Put the config at the partition root
/extlinux/extlinux.conf so the absolute-looking /gnu/store/… paths resolve
from the filesystem root.
14. 1.5.0 config API differences vs current guix
operating-systemhas nopassword/hashed-root-passwordfield and no(plain "…")— set root via(user-account (name "root") (group "root") (system? #t) (password "<crypt-hash>"))(generate withopenssl passwd -6).agetty-configurationtakes a singlettystring (notterminalslist).usbutils/iwlive in(gnu packages linux),ethtoolin(gnu packages networking),json-cin(gnu packages web); there is no(gnu packages json)/(gnu packages usb).substitute-keyword-argumentsneeds(guix utils).guix system imagetakes no-o(prints the store path); positional args areACTION … FILE.
15. Shell footguns during automation
pkill -f 'guix system image'matches the invoking shell's own command line → kills your session; usepgrep+ explicit PID.- The bash-tool timeout SIGKILLs the process group —
nohupalone does not survive; launch long builds withsetsid nohup … & disown.
16. Nonguix linux can't mount a Pi 4 SD card (or see its Ethernet)
Its arm64 config has CLK_BCM2835 off. emmc2's clock is the cprman
(clocks = <&clocks BCM2711_CLOCK_EMMC2>), and so is the mini-UART's, so the
SD card and serial console never probe; the SD regulators also hang off the
firmware GPIO expander (GPIO_RASPBERRYPI_EXP=m). BCMGENET is off too.
The SD driver is sdhci-iproc, not sdhci-of-arasan (that was a wrong guess
in the old initrd list). Fix: packages/rpi4-linux.scm (customize-linux
on top of nonguix linux), SD path built in, initrd-modules '().
MMC_BLOCK=y silently stays m unless RPMB=y (depends on RPMB || !RPMB);
verify-config catches it. DRM_SIMPLEDRM=y is impossible with DRM=m.
17. Stock Pi U-Boot layout overlaps a distro kernel
rpi.env: kernel_addr_r=0x80000, fdt_addr_r=0x02600000,
ramdisk_addr_r=0x02700000. booti relocates an Image with flags bit 3 to
the 2 MB boundary (0x200000); our image_size is 36.4 MB → ends 0x2660000,
on top of FDT and initrd. Fix: patches/rpi-env-layout.patch (kernel at
0x200000, everything else ≥ 0x08000000).
18. Mainline Pi 4 serial console is ttyS1, not ttyAMA0
With the upstream DT the PL011 (ttyAMA0) is wired to Bluetooth; GPIO 14/15
is the mini-UART, alias serial1 → ttyS1 (verified in QEMU:
fe215040.serial: ttyS1 at MMIO 0xfe215040).
19. Root user-account needs (uid 0)
Without it Guix still prepends its own passwordless %root-account
("the following accounts appear more than once: root").
20. QEMU raspi4b (10.0) can't boot a mainline kernel on the stock DTB
Unemulated: AON block at 0x7ef00000 (L2 irqchip → external abort; DVP clock
gate → abort in clk_disable_unused), firmware GPIO expander (-EIO), so
emmc2 regulators never probe; mini-UART IRQ storm → RCU stall on
/dev/console open. QEMU attaches the SD card to the WiFi SDHCI
(7e300000). Hence U-Boot → kernel in QEMU is silent forever.
run-qemu-rpi4.sh boots the image's kernel/initrd directly with a patched
DTB (see its header); none of this applies to real hardware.
21. pkill -f / grep pattern | kill match your own shell (again)
Any filter over full command lines matches the shell running it, because
the pattern is in its argv. Use pgrep -x <comm> (≤15 chars) or $!.
22. A plain symlink into the store is not a GC root
ln -s /gnu/store/…-guix-rpi4 foo.img doesn't stop guix gc from deleting
the image. build-rpi-image.sh uses guix system image -r <link>, which
registers the link as an indirect root (guix gc --list-roots shows it).
Remove old images with guix gc -d <path> — it refuses live paths. Avoid a
blanket guix gc: the 50-min linux-rpi4 cross build isn't rooted unless an
image or system that uses it is.
23. dnsmasq (1.5.0) doesn't cross-build
Its install-dbus phase uses %output, which only exists in native builds:
Unbound variable: %output. rpi4.scm defines dnsmasq/cross-fixed
(same phase via outputs) and passes it as the dnsmasq service's package.
24. linux-firmware subset filter dropped the Pi 4 WiFi firmware
brcmfmac error -2 (ENOENT), no wlan0. In WHENCE the 43455 .bin and
.clm_blob are Link: brcm/… -> ../cypress/…, and the Pi 4 NVRAM entry is
quoted (File: "brcm/…raspberrypi,4-model-b.txt"). The old filter tested
link targets and unquoted names against ^brcm/, so all three were
skipped. Now: keep any entry whose own path matches ^"?(brcm|cypress|rtl_nic)/.
Check with ls $(guix build … %rpi-linux-firmware)/lib/firmware/brcm | grep 43455-sdio.
25. sshd requires 'networking — don't let the WAN provide it
openssh-service-type (inetd-style under Shepherd) requires networking.
With dhcpcd on wlan0 providing it and wlan0 absent, Shepherd accepted
TCP on :22 but never spawned sshd -i → "timed out during banner exchange"
even from the LAN. dhcpcd now provides wan; the LAN static-networking
provides lan networking.
26. Onboard Ethernet name depends on probe order
GENET and USB NICs (r8152…) are both modules; plugging in a USB NIC can make
it eth0. Bridge ports are matched by MAC (network-link (mac-address …)).
27. 2.4 GHz WAN: associated, but DHCP never answered
On hakodosh 2.4 GHz ch 1 (UniFi AP, -43 dBm) the Pi associated and got
wpa_state=COMPLETED, but iw dev wlan0 station dump showed ~45% tx failed at MCS0 (7.2 Mbit/s) while RX ran at 72 Mbit/s; broadcasts (DHCP
DISCOVER) are unacked, so they were silently lost → IPv4LL fallback. An ARP
reply from the gateway proved nothing: UniFi APs can proxy-ARP. No USB 3
device was attached, so not the usual Pi 4 USB3 2.4 GHz noise. On 5 GHz
(ch 44, -49 dBm): 0 tx failed, 162 Mbit/s, lease at once. Fix:
freq_list= of 5 GHz channels in /boot/firmware/wpa_supplicant.conf.
Diagnose with station dump (tx failed / tx bitrate) before blaming DHCP.
Likely cause, found later: the router this project is meant for (GL-MT6000,
on the bench and powered) was flooding the 2.4 GHz channels; switched off on
2026-10-14. 5 GHz stays pinned anyway.
28. Updating the Pi: no guix system reconfigure/switch-generation on it
On the Pi both would build natively for aarch64: reconfigure evaluates the
config there (native derivations ≠ our cross-built ones → kernel from
source), and switch-generation/roll-back build extlinux.conf with a native
Guile the Pi doesn't have (its Guix is cross-built), and need substitutes
over TLS with a 1970 clock. They also look the bootloader up by name, and
u-boot-rpi4 isn't in any module. deploy-rpi.sh does it from the
workstation instead: cross-build, guix copy, generation link, host-built
extlinux.conf (scripts/extlinux-conf.scm), activate, reboot.
Gotchas found on the way:
activatepicks the system fromGUIX_NEW_SYSTEM; run bare, it re-activates/run/current-system(the old system).read-boot-parametersreturns the stored kernel args only;root=,gnu.system=,gnu.load=come from (private)bootable-kernel-arguments.guix archive --authorizeon the Pi turns/etc/guix/aclinto a regular file, which the next activation moves toacl.bakand replaces with the declared ACL — declare the workstation key inguix-service-type.- The image build swaps root
(file-system-label "Guix")for the image's UUID, so the first deploy's system differs from the image's even with no config change (harmless; same partition).
29. Time sync on a Pi without RTC: chrony, not ntpd/openntpd
The clock starts at 1970 every boot and the WAN arrives ~20 s after the
time service starts. ntp-service-type doesn't cross-build (ntp pulls
perl-net-ssleay, which links the build host's x86 OpenSSL). OpenNTPD
only steps the clock in a short window at startup, then slews — hopeless for
a 56-year offset. chronyd with makestep 1 -1 steps whenever it's > 1 s
off, any time. 1.5.0 has no chrony service: rpi4.scm runs chronyd -n
from a shepherd service, and needs a chrony system account (built-in
privilege drop: "Could not get user/group ID of chrony").
30. 90% packet loss on the LAN cable: Energy-Efficient Ethernet
Pi 4 onboard port (BCM54213PE/GENET) ↔ workstation Intel e1000e: with EEE
active, the Pi received ~83 of thousands of frames, with no error
counters; ping RTTs of ~1 s, ARP failures, SSH "timed out during banner
exchange", no DHCP. It looked like a hung Shepherd (sshd-1 disabled) or
breakage from the clock step — it wasn't. Intermittent: some boots were
fine. ethtool --set-eee eth0 eee off → 0% loss. rpi4.scm runs that
from a one-shot service after 'lan. Two bugs on the way: br-lan inherits
the port's MAC and sorts before eth0 (skip interfaces without
/sys/class/net/*/device), and service PATH isn't reliable (pass
sleep by store path). Diagnose with ethtool -S eth0 (rx_packets vs.
what the peer sent) and ethtool --show-eee eth0. SSH on the WAN
(192.168.88.222) was the way in while the cable was down.
31. Image root is only 6 GB: grow it on first boot
rpi4-image.scm makes a 6 GiB root so the image stays small; on a 119 GB
card the rest was unused (3.2 GB free after a few deploys). The
grow-root one-shot service (rpi4.scm) runs every boot: if root is ext4,
the last partition of an MBR disk, and ≥ 1 MiB is free after it, it does
what worked by hand on the live Pi —
echo ,+ | sfdisk --no-reread --no-tell-kernel -N 2 /dev/mmcblk1,
resizepart /dev/mmcblk1 2 <sectors> (tells the kernel about a partition on
a busy disk; plain sfdisk can't), resize2fs online (the image's ext4 has
resize_inode). Messages go to the kernel log as grow-root: ….
Tested in QEMU (run-qemu-rpi4.sh pads the SD to 8 GiB: 6144 → 7932 MiB)
and as a no-op on the already-grown Pi.
32. OpenWrt packages: CMake + pkg-config when cross-building
OpenWrt's CMakeLists use PKG_SEARCH_MODULE; in Guix cross builds only
aarch64-linux-gnu-pkg-config exists → "Could NOT find PkgConfig".
-DPKG_CONFIG_EXECUTABLE=<bare name> is resolved relative to the build
dir; which in #:configure-flags runs before PATH is set. What works:
a phase before 'configure that sets PKG_CONFIG to (which (pkg-config-for-target)). Trap: build that gexp in a procedure called
from arguments — a top-level gexp evaluates pkg-config-for-target at
module load (no target → native name), which returns #f, and
(setenv "PKG_CONFIG" #f) silently unsets it. Also: jshn.sh calls
jshn from PATH — patched to the store path. ubusd only allows root
unless ACL files say otherwise (-A dir).
33. OpenWrt package notes: pins, ucode module path, jsonfilter
- Pin to the commits OpenWrt main builds (
package/*/*/Makefile,PKG_SOURCE_VERSION; git.openwrt.org's gitweb redirects, the GitHub mirrorraw.githubusercontent.com/openwrt/openwrt/main/...works), not each project's master, so netifd/firewall4/LuCI get the versions they're tested with. ucode lives at github.com/jow-/ucode, not git.openwrt.org. - ucode's module search path is compiled in (
LIB_SEARCH_PATH), with no environment override; scripts can't pass-L. Built with its own prefix plus/run/current-system/profile/{lib/ucode/*.so,share/ucode/*.uc}so firewall4/LuCI modules installed in the system profile are found. - jsonfilter's CMake builds/runs
contrib/lemonrelative to the source tree:#:out-of-source? #f. Installed asjsonpath; symlinked tojsonfilter, the name OpenWrt scripts use. - Testing a ubus object: don't
callit from the same uloop that serves it (the reply can never be dispatched → null); call from another process.
34. netifd on Guix
- Needs libudebug (OpenWrt
project/udebug), not listed in our plan;main.cuses it unconditionally. - Its runtime files (
main.uc,proto*.uc,utils.uc, ifup/ifdown,proto/dhcp.sh) live in OpenWrt's main tree (package/network/config/netifd/files), not in netifd.git:%openwrt-treeinopenwrt.scmis that tree at the pinned commit, trimmed by a snippet to the directories we use. -psets the script dir for shell protocols only;ucode.cloadsDEFAULT_MAIN_PATH "/main.uc"(compile time). PatchDEFAULT_MAIN_PATHinnetifd.hto$out/lib/netifdinstead of symlinking/lib/netifd;netifd-proto.shalso hardcodes/lib/netifdand/usr/share/libubox/jshn.sh(patched).- In-source build (
make_ethtool_modes_h.shruns relative to the tree). - Bridge ports are by name only (no MAC match):
eth0, see #26. - Test before touching the Pi:
sudo unshare -n+ a dummy port + ubusd +netifd -s SOCK -c CONFDIR -h HOOK -r RESOLVshows the bridge, thenetwork.interface.*ubus objects and the hotplug event. - Deploy LAN changes over the WAN path (
PI=pirouter-wan), never both.
35. Cross-built glibc: popen()/system() exec an x86-64 bash (fixed)
On the Pi, every popen()/system() from glibc fails silently: the
cross-built glibc (glibc-cross-aarch64-linux-gnu) has _PATH_BSHELL
patched to the build host's bash-static (od -j18 -N2 → 3e 00 =
x86-64; running it: "Exec format error"). Guix 1.5.0 cross-base.scm
marks this ;FIXME: static-bash. Symptom here: netifd registered only the
built-in static protocol — its shell-handler discovery uses popen — so
wan stayed proto none/NO_DEVICE. It worked on x86 tests, where that
bash runs. Not affected: Guile/Shepherd (fork+exec), ucode's system()
(execs /bin/sh itself).
Fixed in packages/cross-libc-fix.scm: loading it wraps cross-libc
in (gnu packages cross-base) (looked up by name by
standard-cross-packages), so every cross build uses a glibc whose
system(), popen() and _PATH_BSHELL use /bin/sh -- always present on
Guix System -- and whose ldd/sotruss/tzselect/xtrace start with
#!/bin/sh. A target static-bash isn't possible: building it needs this
very libc. Cost: the whole cross closure rebuilds once (~5 h here, 325
derivations). Every file that cross-builds packages for the Pi
(rpi4.scm, packages/openwrt.scm) must (use-modules (cross-libc-fix))
first, or the store fills with both libcs. Verified on generation 14:
netifd maps the fixed libc, awk's system()/getline pipes work.
patches/netifd-popen-bin-sh.patch (fork + /bin/sh -c) is now redundant
but harmless; kept until netifd is next rebuilt anyway. Check a binary's
libc with grep -ao '/gnu/store/[^/]*bash-static[^/]*/bin/sh' <libc.so.6>
(no output = fixed). Unrelated leftover: cross-built man-db hardcodes the
build host's glibc/bin/iconv, dragging x86 glibc + bash-static into the
closure as dead weight.
36. netifd won't adopt a WiFi netdev from option device
device_create_default() refuses devices with /sys/class/net/X/phy80211
unless they're external: on OpenWrt the wireless scripts attach them.
With wpa_supplicant outside netifd, wan gets no device in UCI and the
netifd-attach-wlan0 one-shot (from netifd-configuration
external-devices) calls
ubus call network.interface.wan add_device '{"name":"wlan0","link-ext":true}'
after wpa-supplicant starts (retries until netifd's object exists).
Also: OpenWrt's scripts are written for busybox ash (export -n, ...):
shebangs are patched to busybox sh, not left to whatever /bin/sh is.
37. dnsmasq --bind-dynamic misses br-lan's IPv4 address at boot
dnsmasq starts before netifd has created br-lan ("interface br-lan does
not currently exist"); netifd then adds 192.168.10.1 while dnsmasq is still
starting up. With --bind-dynamic it binds per address on netlink events,
and that IPv4 address was never bound -- not even when later IPv6 address
events made it re-enumerate. Symptom: DHCP works (its own socket), DNS on
192.168.10.1 is "connection refused"; ss -lnu shows 127.0.0.1:53 and
br-lan's IPv6 addresses only. ubus call network reload doesn't help; a
lan down/up does. Seen on the first cold boot with odhcpd (more IPv6
address events), possibly latent before.
Fix: no --bind-dynamic. dnsmasq's default binds the wildcard address and
drops queries not arriving on --interface=br-lan, so address timing
doesn't matter. Nothing else uses port 53, and nftables drops WAN DNS
anyway (checked: a query to the WAN address times out).
38. Small traps met wiring odhcpd
- busybox
pkill -x dnsmasqmatches nothing: it compares the whole/gnu/store/.../sbin/dnsmasqargv[0]. Use the Shepherd action (herd reload dnsmasq) or the pid file. with-environment-variablesis unbound in activation snippets (the activation script's module set lacks it); the whole activation aborts there, after earlier snippets ran. Run commands viaenv VAR=... cmd.deploy-rpi.shoutput shows the exception -- read it after each deploy.- UCI seeds apply only to fresh systems:
/etc/config/networkon the running Pi was seeded long ago, so seed changes (hereglobalsandip6assign) must also be made live withuci set/uci commit. - Workstation IPv6 was off: the NM profile
pirouter-lanhadipv6.method ignore; nowauto,never-default,ignore-auto-dns.
39. copy-build-system ignores --target: x86 store references
Guix 1.5.0's copy-build-system lowers every build with #:target #f, so in
a cross-built system its inputs -- and any file-append in its phases --
are native x86_64 packages. openwrt-base-files (deployed since
generation 13) had uci.sh calling an x86 uci and sourcing a jshn.sh
that runs an x86 jshn: config_load, uci_* and json_* from it were
broken on the Pi (netifd's dhcp path didn't hit them). firewall4 would have
run an x86 nft. Also: under cross builds patch-source-shebangs points
/bin/sh scripts at the native bash.
Fix: script packages that refer to other packages use gnu-build-system
(cross-aware) running only copy-build-system's install phase --
cross-copy-phases / %cross-copy-modules in packages/openwrt.scm -- and
set script shebangs to the target busybox explicitly.
Check a system before deploying: scripts/check-closure-arch.sh /gnu/store/...-system lists store items holding x86-64 ELF files. About
40 are expected and pre-date this (dead weight, never run): the cross gcc
pulled in by gcc-cross-...-lib, the native tools the guix package and
man-db (PITFALLS #35) reference. Investigate anything new in that list;
trimming the old ones is a follow-up (closure is ~2.6 GB).
40. Publishing ubus service makes fw4 wait on it
fw4 asks ubus call service get_data '{"type":"firewall"}' on every start
and reload (rules that procd services register). With no service object
the call fails at once and fw4 carries on. Once procd-compat publishes
service, fw4 waits for the answer -- and the first procd-compat ran herd reload firewall with a blocking system(), so it never answered:
procd-compat, herd and fw4 all hung (no timeout).
Fix: procd-compat answers get_data (no data) and runs herd reload as a
child process (uloop.process), never blocking its loop. Rule for anything
that publishes a procd object: handle every method OpenWrt tools call on it,
and don't block the loop while a reload you started may call back. When
porting the next OpenWrt tool, grep it for "service"/"system" ubus calls.
41. ucode ubus objects reject undeclared arguments (LuCI's session id)
A method published with ucode's conn.publish() answers INVALID_ARGUMENT
when a call carries an argument its args don't declare -- including a
method with no args at all. Calls through uhttpd's JSON-RPC and rpcd
always add ubus_rpc_session, so ubus call system board worked from the
shell and failed from LuCI (result: [2]). Fix: every procd-compat method
declares ubus_rpc_session. Test new ubus methods with
'{"ubus_rpc_session":"x"}', not only with no message.
42. LuCI on Guix: FHS paths, and what the browser asks for
LuCI is written against OpenWrt's file system, in three places:
- Server side (ucode, rpcd's
luciplugin, menu.dfsdependencies): thelucipackage rewrites/usr/share/...,/usr/sbin/...etc. to/run/current-system/profile/...at build time. A menu entry whose dependency doesn't exist answers 404 (admin/network/firewallneedsfw3, so firewall4 now has thesbin/fw3link OpenWrt ships). - Browser side: views call rpcd
file.exec/file.list/cgi-execwith OpenWrt paths (/sbin/ip,/www/...,/usr/libexec/syslog-wrapper), and the ACLs inshare/rpcd/acl.dgrant exactly those paths, so they can't be rewritten on one side only.luci-service-typecreates them as symlinks into the profile (commandsfield,/wwwincluded). A view that execs the pathfile.statreturns gets the resolved store path, which no ACL names: syslog-wrapper is an exec script, not a link. - uhttpd:
-h DOCROOTgoes throughrealpath()at startup, so it serves the profile generation that was current then -- after adeploy-rpi.sh --no-reboot,/etc/init.d/uhttpd restart(a reload only restarts it when its UCI-derived command line changed). Its CGI PATH was hardcoded to/sbin:/usr/sbin:/bin:/usr/bin(patched: profile first).
Also: busybox top output differs between OpenWrt's and Guix's builds
(process list, patched), there is no logread (syslog-wrapper reads
/var/log/messages), and rpcd's apply/rollback send config.change
directly, bypassing reload_config's digests (procd-compat refreshes them).
Check pages with a real browser: ubus calls succeeding is not enough.
43. Never make Shepherd wait on a process that talks to Shepherd
Generation 29 hung PID 1 at boot. dnsmasq's start ran the UCI renderer
(then dnsmasq-uci, now procd-compat's openwrt-rc) and read its output with a blocking read-line; the
renderer called herd status odhcpd. Shepherd 1.0 runs services in fibers
on one thread, and a blocking read on an ordinary pipe stops all of them: no
answer to herd, ever, and no services after dnsmasq (sshd, ...). Netifd,
uhttpd and rpcd were up, so the Pi answered ping and HTTP but not SSH.
Killing the renderer wasn't enough either: the orphaned herd had inherited
the pipe's write end (the renderer's fd 3), so Shepherd never saw EOF.
It gets worse while PID 1 is blocked: Shepherd 1.0's syslog (system-log)
runs inside PID 1, so anything calling logger blocks on a full
/dev/log, and sshd is socket-activated by Shepherd, so new SSH
connections hang at the banner. Recovery that worked without a console:
log in to rpcd over uhttpd's JSON-RPC (session.login), list processes
(luci getProcessList) and file exec /bin/kill -9 the renderer, its
herd and logger children (all held the pipe), then deploy the fix.
Queued herd reload calls from hotplug re-entered the same old code, so
it took a few rounds.
Rules:
start/actions only fork (make-forkexec-constructor,fork+exec-command); a wrapper process does slow work and then execs the daemon. Nosystem*/open-pipe*waits on anything that may call herd, ubus services Shepherd starts, or the network.- Nothing run by a Shepherd action or start calls
herd. - Hotplug scripts calling
herd reloadare fine only because of the above. (firewall-service-type's start still runsfw4synchronously; fw4 talks to ubus/netifd/procd-compat, never to Shepherd -- keep it that way.)
44. Setting the clock from Guile: no settimeofday
The Pi has no RTC and booted at 1970 until chrony answered, so syslog,
dnsmasq leases (expiry "43221") and anything time-based were wrong.
sysfixtime-service-type (after OpenWrt's sysfixtime) sets the clock in
activation, which runs at boot before Shepherd starts services, to the
newest of a shutdown stamp (/var/lib/sysfixtime, written by its Shepherd
stop), /var/log/messages and /etc/config. Traps on the way:
- Guix's Guile 3.0.9 has neither
settimeofdaynorclock-settime: the snippet died with "Unbound variable" -- it runs coreutilsdate -u -s @N. - Activation output at boot only goes to the console, and activation order
isn't the service-list order: the snippet logs to
/var/log/sysfixtime.log. - An unbalanced paren in a services module shows up in
guix system buildonly as "unbound variable" for whatever the module exports; load it inguix repl -L packagesto see the reader error. Result: the clock is off by the reboot time (~25 s) until chrony steps it.
45. Guix records: later fields see earlier ones by name
In (shepherd-action (name 'reload) (procedure #~(... #$name ...))), the
name in procedure is the action's own name field, not a variable
name from an enclosing let: define-record-type* lets field
expressions refer to fields given before them. openwrt-rc got the symbol
reload instead of the service name ("Unbound variable: reload" from herd).
Don't name variables after fields of a record you build inside their scope.
46. nodogsplash: Shepherd's process attribution, idle services, iptables-nft
- Shepherd 1.0 attributes processes forked in a service's context to the
service. When the firewall's
startforked fw4 (to not block PID 1, #43), fw4's exit marked the service stopped and respawned it: the service's running value was#t, not a process, and Shepherd only ignores the exit of a process that isn't its running value. Fork such helpers with(parameterize ((current-service #f)) (fork+exec-command ...)). (openwrt-init-script reloads are fine: their running value is a process.) - A UCI-disabled OpenWrt service must not exit. openwrt-rc exited 1 when
start_serviceopened no instance (nodogsplash seededenabled 0), and Shepherd disabled the service at its respawn limit, so enabling it in UCI later did nothing. Now openwrt-rc idles (procd keeps such services with zero instances) and a reload that finds an instance restarts it. The/etc/init.dshims' start/restart alsoherd enablefirst: OpenWrt's disable only affects boot. - Guix's
iptables-nftis built with copy-build-system: in a cross-built system its links point at an x86_64 iptables (#39, caught bycheck-closure-arch.sh).iptables-nft/crossin openwrt.scm. - fw4 replaces only its
inet fw4table, so nodogsplash's iptables-nft tables (ip mangle/filter/nat) survive a firewall reload; its firewall include (restart.sh) is detached anyway, since it calls herd. - OpenWrt's stock
/etc/config/nodogsplashhasenabled 1: installing it gates br-lan at once. The seed here is disabled.
47. OpenWrt's wireless stack on Guix
- netifd's
main.ucloadswireless.ucfrom its own directory and handlers fromwireless/beside it (netifd.main_path), so wifi-scripts'lib/netifdfiles are copied into the netifd package; the ucode modules (wifi.*), hostap helpers and schemas live in the profile, with paths patched (wpad'sHOSTAPD_UC_PATHpoints at the profile too). wifi.utils,iwinfo.ucandwifi configread/etc/board.jsonunconditionally. As OpenWrt:/etc/board-base.json(ours, static) + wifi-detect ->/tmp/sysinfo/board.json,/etc/board.jsona link to it.- wpad.init opens two procd instances (hostapd, wpa_supplicant): two plain Shepherd services instead of an openwrt-init-script.
- A
program-filegexp using(guix build utils)needswith-imported-modules; without it the script dies at once, silently when forked outside a service (current-service #f). wifi-setup avoids it. - brcmfmac has no ACS: set an explicit channel (36 on 5 GHz here). "Could not set MAC address" from hostapd is harmless (the BSSID ends up the locally administered address it asked for).
48. Packaging TollGate (Go) for a cross-built Guix system
- Go module dependencies come from a fixed-output derivation
(
computed-filewith#:hash), the one kind of build with network access:go mod downloadwith GOSUMDB off (the checksum database's tiles change over time), keeping only the module cache'sdownload/part.guix build --checkconfirms it is reproducible. - Refer to that derivation, and inside it to Go and nss-certs, with
#+: with#$, a cross build lowered Go for the target, which pulled a cross-built GCC 14 (an hour of compiling, then failing in fixincludes). - Copied out of the store, the module cache is read-only, and Go opens its
.lockfiles for writing: make the copy writable. - Keep TollGate's
#!/bin/shscripts unpatched (patch-source-shebangs,patch-shebangsdeleted): in a cross build they'd point at the build host's bash (#39). - On the Pi: brcmfmac allows one AP interface, so TollGate's private SSID
(br-private) doesn't come up beside the public one; the MT6000's radios
can. fw4 logs "Include '/etc/config/firewall-tollgate' failed": TollGate's
setup adds a UCI file as a (script) firewall include; the rule in it is
also set directly (
tollgate_in).
49. GL-MT6000 under GL's stock firmware: mainline DTS changes
The kernel dies or misses hardware with mainline's mt7986a.dtsi and
OpenWrt's board DTS as they are; mt7986a-glinet-gl-mt6000-guix.dtsi
(appended to the DTS) fixes each of these:
- secmon: GL's ATF (BL31) uses 256 KiB at 0x43000000, mainline reserves 192 KiB. The kernel hands out the rest as RAM and dies at the first allocation there -- before any console or network: a reset loop.
- wo-ilm/wo-dlm/wo-boot: mainline reserves WED's WO MCU SRAM (in MMIO
space, 0x151xxxxx) as no-map reserved memory. Under GL's firmware the
kernel resets within seconds with those nodes present (found by
bisecting DTBs; OpenWrt maps them as syscons). Removing them means
disabling WED too: its firmware loader writes
wo-bootthrough a NULL mapping otherwise. - eMMC partitions: OpenWrt's MT6000 DTS nests them in
card@0/block/partitions; kernels since 6.13 readpartitions(compatible "gpt-partitions") right undercard@0, as OpenWrt's newer boards have it. Otherwise thefactorypartition has no NVMEM cells: WiFi runs on default calibration ("eeprom load fail") and mtk_eth_soc defers forever waiting for its MAC address -- no Ethernet, silently. - ramoops at 0x42ff0000 (where OpenWrt has it) with a console record: the only way to see a failed boot's kernel log without a serial cable -- after a warm reset. A power cycle clears it.
50. GL-MT6000: mtk_eth_soc as a module needs clk_ignore_unused
Loaded from the root file system (~20 s), mtk_eth_soc's MDIO bus times
out ("mdio: MDIO timeout", probe error -5): by then the kernel has turned
off (at late_initcall) a clock the MDIO block needs and the DT doesn't
list. OpenWrt builds the driver in, so it claims its clocks first.
clk_ignore_unused on the command line fixes it; building it in would need
BRIDGE, DSA and PHYLINK built in as well.
51. GL U-Boot's TFTP recovery, and what it does to the root partition
GL's U-Boot requests openwrt-gl-mt6000.bin from 192.168.1.2 (as
192.168.1.1) about 3 s after every power-on or reset, with a 1 s timeout.
Served an OpenWrt sysupgrade tar (sysupgrade-glinet_gl-mt6000/{CONTROL, kernel,root}), it writes kernel to p6 and root to p7 and boots it; a
tar without root, or a bare FIT, is fetched and ignored. It is the
no-button recovery path: sudo dnsmasq --port=0 --enable-tftp ... on the
workstation, then power-cycle (or let the board reset itself).
To boot OpenWrt from RAM yet keep the Guix root, use the OpenWrt
initramfs FIT as kernel and the first 1 MiB of the Guix ext4 as root.
That damages the file system in two ways. U-Boot writes 512 KiB of
overlay marker right after root, over group 0's bitmaps and inode
table. And the 1 MiB puts back the image's pre-boot superblock and group
descriptors, although Guix has resized the file system since. To read it:
copy p7 off, write blocks 256-383 back from the image, and use debugfs
with the backup superblock (-c -s 32768 -b 4096).
52. guix system image changes the system's root device
The image's system has root=<partition UUID> where the configuration
says (file-system-label "Guix"): it is a different store item from
guix system build's. Boot the one inside the image (its
/var/guix/profiles/system-1-link), or the initrd looks for a root that
isn't the one it gets.
53. TollGate on the MT6000: restoring the OpenWrt wallet
TollGate keeps its state in /etc/tollgate: config.json,
identities.json (the Nostr key: same identity), install.json, and
wallet.db (BoltDB; the wallet directory is the config file's). The
config versions written by OpenWrt's install matched v0.5.0's, so the
files went back as they were. Order: deploy with --no-reboot (new
Shepherd services don't start), copy the files in (mode 600), reboot --
otherwise TollGate's first start creates a new identity and wallet.
The restored config named the mint https://mint.minibits.cash, which
answers 404 at /v1/info: TollGate then reports "no reachable mints" and
never advertises. The mint is https://mint.minibits.cash/Bitcoin
(v0.5.0's own default). Check tollgate status, the advertisement at
http://<router>:2121/ (kind 10021 once ready) and "mint probe" lines in
/var/log/messages.
54. TollGate payments fail: short v2 keyset IDs (gonuts-tollgate)
Every payment failed, on the Pi and the MT6000 alike: "PurchaseSession:
Receive completed, amount=0, err=could not swap proofs: ... inputs[0].id:
NUT02: ID length invalid, expected 8 bytes (short/v1) or 33 bytes (v2)".
Mints such as minibits (cdk-mintd 0.17) sign with v2 keysets (33-byte
IDs, prefix 01); a v4 (cashuB) token carries their short form, the first
8 bytes, and the receiving wallet must expand it from the mint's
/v1/keysets (NUT-02). TollGate v0.5.0's Cashu library, gonuts-tollgate
v0.7.1, swaps with the short ID. packages/patches/gonuts-tollgate-short-
keyset-ids.patch expands it in Wallet.Receive and ReceiveHTLC; the package
patches a local copy of the module and points the daemon's go.mod at it
(go mod edit -replace), since a modified module zip would fail go.sum.
55. TollGate's private network: no DHCP, and admin isolation
TollGate's setup creates dhcp.private without option dhcpv4 'server';
OpenWrt's current dnsmasq init defaults that to disabled, so the private
network (its WPA2 SSID) never handed out addresses -- "no address range
available for DHCP request via br-private". files/uci-mt6000/99_zz-wired-
private sets it. The same script makes the MT6000's wired ports private:
TollGate's open SSID shares br-lan with the router's services, and
nodogsplash's users_to_router (22, 23, 8080 by default) plus its
authenticated-users rule (every port) open SSH and LuCI to anyone nearby or
anyone who paid. br-lan keeps only the open SSID, an fw4 rule rejects 22,
23, 443 and 8080 from it, and lan1-lan5 join br-private (192.168.2.1).
56. Flow offloading on the MT6000: available, off
CONFIG_NFT_FLOW_OFFLOAD is built; fw4's flow_offloading works
(flowtable on br-lan, br-private, eth1) but changed nothing measurable --
routing already runs at the 1 Gbit line rate; bidirectional CPU went from
19% to 15%. flow_offloading_hw falls back to software: fw4 puts every
bridge member in one flowtable, and the WiFi APs can't do hardware offload
without WED (eth1 and lan1-lan5 can). Both stay off because offloaded
flows skip the forward chain, where nodogsplash enforces TollGate sessions:
a customer's connections would outlive their paid time. Doing it right
means offloading only private <-> WAN flows (fw4 picks devices per
flowtable, not per zone).
57. Fresh installs: TollGate's setup ran before the WiFi config existed
uci-defaults run at activation, but /etc/config/wireless is created later,
by wifi-setup (wifi config needs the radios, i.e. their drivers loaded).
On a system that already had WiFi when TollGate was added this never
showed; on a fresh image 99-tollgate-setup found no radios: no open
TollGate SSID, the AP stayed guix-router (WPA2) -- and where it created
/etc/config/wireless for its private SSID, wifi-setup skipped wifi config
altogether. uci-service-type now has late-defaults: run by wifi-setup
once the WiFi config exists (/etc/uci-late-defaults), followed by one
reboot when any ran, so every service starts from the result, as on
OpenWrt. TollGate's setup and the MT6000's 99_zz-wired-private are late
defaults. Tested on the Pi by removing the WiFi config and the markers.
On a fresh install TollGate's setup also leaves uhttpd without HTTPS (it
keeps listen_https only if a certificate exists yet); LuCI is on :8080.