Files
ssmithxandClaude Opus 5.5 d968403d33 Hardening: key-only SSH, admin off TollGate's open SSID, sysctls
Both routers: sshd takes keys only (it is reachable from the open TollGate
SSID, and on the Pi from the WAN); shared %router-sysctls in router-base
(no ICMP redirects taken, loose rp_filter, kptr_restrict, BPF JIT hardened).

MT6000: the lab WAN SSH rule is gone; 99_zz-wired-private (a uci-defaults
script, after TollGate's setup) moves lan1-lan5 into TollGate's private
network (192.168.2.1), leaves br-lan to the open SSID, rejects SSH/telnet/
LuCI from it, trims nodogsplash's router ports, and turns on the private
network's DHCP, which TollGate's setup left disabled (PITFALLS #55).

The kernel has CONFIG_NFT_FLOW_OFFLOAD; fw4's offloading was measured and
is left off -- no gain at line rate, and it would let flows bypass
nodogsplash's session enforcement (PITFALLS #56).  Deployed as MT6000
generation 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:25:38 -04:00

353 lines
16 KiB
Scheme

;; Guix system for the Raspberry Pi 4B router testbed.
;;
;; Boot chain:
;; EEPROM -> start4.elf (FAT partition 1, see rpi4-image.scm) -> u-boot.bin
;; -> /boot/extlinux/extlinux.conf on the root partition -> linux + initrd
;;
;; extlinux.conf is generated by Guix (u-boot-bootloader), so
;; `guix system reconfigure` adds generations exactly like on other boards.
;;
;; Network:
;; WAN eth0 DHCP (netifd/udhcpc) from the upstream -- in the lab,
;; archy-dev-pa's NetworkManager shared connection on eno1
;; LAN br-lan WiFi AP on the onboard radio (netifd wireless, wpad),
;; 192.168.10.1/24, dnsmasq DHCPv4/DNS; ULA /60 (netifd),
;; odhcpd RA/DHCPv6
;; firewall4 (/etc/config/firewall, seeded from files/uci/firewall): lan
;; zone trusted, wan zone masqueraded, lan -> wan forwarding
;;
;; Build the flashable image with ./build-rpi-image.sh (uses rpi4-image.scm).
;; Load with GUIX_PACKAGE_PATH pointing at this directory's packages/.
;; Must come first: makes every cross build use a glibc whose system()/popen()
;; run /bin/sh, not the build host's bash (packages/cross-libc-fix.scm).
(use-modules (cross-libc-fix))
(use-modules (gnu)
(gnu bootloader u-boot)
(guix packages)
(guix utils)
((guix config) #:select (%guix-version))
(nongnu packages linux)
(nonguix licenses)
(ice-9 regex)
(rpi4-linux)
(u-boot-rpi-arm64-guix-router)
(wireless-regdb-signed)
(openwrt)
(openwrt-services)
(tollgate-services)
(router-base))
(use-service-modules avahi dns networking shepherd ssh sysctl)
(use-package-modules admin base bash dns linux networking ntp)
(define %rpi-linux-firmware
;; linux-firmware subset: Broadcom WiFi (brcmfmac) + Realtek USB NIC blobs.
;; The 43455 .bin/.clm_blob live in cypress/ (brcm/ has links to them), and
;; WHENCE quotes some names, e.g. "brcm/…raspberrypi,4-model-b.txt"; links
;; are kept by their own path, not their (often relative) target.
(package
(inherit linux-firmware)
(name "linux-firmware-rpi")
(arguments
(substitute-keyword-arguments (package-arguments linux-firmware)
((#:phases phases #~%standard-phases)
#~(modify-phases #$phases
(add-after 'unpack 'select-firmware
(lambda _
(use-modules (ice-9 regex))
(define (wanted? path)
(string-match "^\"?(brcm|cypress|rtl_nic)/" path))
(substitute* "WHENCE"
(("^(File|RawFile|Link|Symlink): *(.*)" _ type rest)
(string-append (if (wanted? rest) type "Skip")
": " rest)))))))))
(synopsis "Broadcom WiFi and Realtek USB NIC firmware for the Pi 4")
(description
"Subset of the linux-firmware package with the files needed on a
Raspberry Pi 4: @code{brcmfmac43455-sdio} WiFi firmware and
@code{rtl_nic} USB Ethernet firmware.")
(license (nonfree
"https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/WHENCE"))))
(define u-boot-rpi4-bootloader
;; Plain extlinux.conf for U-Boot's distro boot. Nothing is written to raw
;; disk sectors: the Pi firmware loads u-boot.bin from the FAT partition,
;; which rpi4-image.scm populates.
(bootloader
(inherit u-boot-bootloader)
(name 'u-boot-rpi4)
(package u-boot-rpi-arm64-guix-router)
(installer #f)
(disk-image-installer #f)))
;; Router interfaces. The wired WAN is netifd's (files/uci/network), the
;; onboard GENET port by name, eth0: fine while no USB NIC can race it for
;; the name (PITFALLS #26). lan-eee-off still finds the port by MAC. The
;; onboard brcmfmac radio is the LAN's WiFi AP.
;; /etc/board-base.json, as OpenWrt's board_detect would write it; wifi-setup
;; adds the radios (wlan) into /tmp/sysinfo/board.json = /etc/board.json.
(define %board-base.json
(plain-file "board-base.json" "\
{
\"model\": { \"id\": \"raspberrypi,4-model-b\",
\"name\": \"Raspberry Pi 4 Model B\" },
\"network\": {
\"lan\": { \"device\": \"br-lan\", \"protocol\": \"static\" },
\"wan\": { \"device\": \"eth0\", \"protocol\": \"dhcp\" }
}
}
"))
;; After `wifi config' on first boot: a WPA2 AP named guix-router in br-lan,
;; 5 GHz channel 36 (brcmfmac has no automatic channel selection), with a
;; random key -- read it with `uci get wireless.default_radio0.key'.
(define %wifi-first-config
(plain-file "wifi-first-config" "\
key=$(od -An -tx1 -N12 /dev/urandom | tr -d ' \\n')
uci -q batch <<EOF
set wireless.radio0.disabled='0'
set wireless.radio0.country='US'
set wireless.radio0.band='5g'
set wireless.radio0.channel='36'
set wireless.default_radio0.ssid='guix-router'
set wireless.default_radio0.encryption='psk2'
set wireless.default_radio0.key='$key'
set wireless.default_radio0.network='lan'
set wireless.default_radio0.disabled='0'
commit wireless
EOF
"))
;; Grow the root partition and its ext4 file system into free space after it,
;; so a flashed card uses the whole medium. Runs at every boot; once grown
;; there's nothing to do. Only for an ext4 root that is the last partition of
;; an MBR disk (the image's layout). Done online, like by hand (PITFALLS #31).
(define %grow-root-script
(mixed-text-file "grow-root"
"set -eu
export PATH=" util-linux "/sbin:" util-linux "/bin:" e2fsprogs "/sbin:"
coreutils "/bin:" sed "/bin:" grep "/bin
log() { echo \"grow-root: $*\"; echo \"grow-root: $*\" > /dev/kmsg; }
src=$(findmnt -no SOURCE /)
[ \"$(findmnt -no FSTYPE /)\" = ext4 ] || { log \"root is not ext4\"; exit 0; }
[ -b \"$src\" ] || { log \"root $src is not a block device\"; exit 0; }
part=${src##*/}
sys=/sys/class/block/$part
[ -e \"$sys/partition\" ] || { log \"$src is not a partition\"; exit 0; }
disk=$(basename \"$(readlink -f \"$sys/..\")\")
num=$(cat \"$sys/partition\") start=$(cat \"$sys/start\") size=$(cat \"$sys/size\")
total=$(cat /sys/class/block/$disk/size)
sfdisk -d /dev/$disk | grep -qx 'label: dos' || { log \"/dev/$disk is not MBR\"; exit 0; }
for p in /sys/class/block/$disk/$disk*/start; do
if [ \"$(cat \"$p\")\" -gt \"$start\" ]; then
log \"$src is not the last partition\"; exit 0
fi
done
free=$((total - start - size))
[ \"$free\" -ge 2048 ] || exit 0 # < 1 MiB: already grown
log \"growing $src: $((size / 2048)) MiB -> $(((total - start) / 2048)) MiB\"
echo ',+' | sfdisk --no-reread --no-tell-kernel -N \"$num\" /dev/$disk
new=$(partx -g -o SECTORS --nr \"$num\" /dev/$disk | tr -d ' ')
resizepart /dev/$disk \"$num\" \"$new\"
resize2fs \"$src\"
log \"done: $(df -h / | tail -1)\"
"))
(define %grow-root-service
(simple-service 'grow-root shepherd-root-service-type
(list (shepherd-service
(documentation "Grow the root partition to fill the disk.")
(provision '(grow-root))
(requirement '(file-systems))
(one-shot? #t)
(start #~(lambda _
(zero? (system* #$(file-append bash "/bin/bash")
#$%grow-root-script))))))))
;; Energy-Efficient Ethernet between the onboard BCM54213PE and the
;; workstation's Intel e1000e drops up to ~90% of frames (PITFALLS #30).
;; GENET only attaches its PHY when the interface is up, so this runs after
;; 'lan rather than from udev; the port is found by MAC like the bridge port.
(define %lan-eee-off-service
(simple-service 'lan-eee-off shepherd-root-service-type
(list (shepherd-service
(documentation "Disable EEE on the onboard Ethernet port.")
(provision '(lan-eee-off))
(requirement '(lan))
(one-shot? #t)
;; Only devices backed by hardware: br-lan inherits the port's
;; MAC and sorts first. Retry for 30 s in case the PHY isn't
;; attached yet.
(start #~(lambda _
(zero? (system* #$(file-append bash "/bin/bash") "-c"
"for i in {1..30}; do
for d in /sys/class/net/*; do
[ -e \"$d/device\" ] || continue
read -r mac < \"$d/address\"
[ \"$mac\" = e4:5f:01:3e:d2:15 ] &&
\"$0\" --set-eee \"${d##*/}\" eee off &&
exit 0
done
\"$1\" 1
done; exit 1"
#$(file-append ethtool "/sbin/ethtool")
#$(file-append coreutils "/bin/sleep")))))))))
;; GPIO 14/15 serial console. With the mainline device tree that is the
;; mini-UART (uart1, "serial1"); the PL011 is wired to Bluetooth.
(define %serial-console "ttyS1")
(define %rpi4-os
(operating-system
(host-name "pirouter")
(timezone "Etc/UTC")
(locale "en_US.utf8")
(bootloader (bootloader-configuration
(bootloader u-boot-rpi4-bootloader)
(targets (list "/dev/mmcblk0"))
(timeout 3)))
(kernel linux-rpi4)
(firmware (list %rpi-linux-firmware wireless-regdb-signed))
;; SD card, its clocks and regulators are built into linux-rpi4.
(initrd-modules '())
(kernel-arguments (list "console=tty1"
(string-append "console=" %serial-console ",115200")))
(file-systems (cons* (file-system
(mount-point "/")
(device (file-system-label "Guix"))
(type "ext4"))
(file-system
(mount-point "/boot/firmware")
(device (file-system-label "RPI-BOOT"))
(type "vfat")
;; Root-only: holds the WiFi password.
(options "umask=0077"))
%base-file-systems))
(swap-devices '())
(users (cons* %router-root-account
(user-account
(name "pi")
(group "users")
(supplementary-groups '("wheel" "netdev" "audio" "video"))
(home-directory "/home/pi"))
%base-user-accounts))
(packages (append (list usbutils iw ethtool
;; OpenWrt userland tools (Phase 3).
ucode jsonfilter)
%base-packages))
(services (cons*
;; No RTC: start from the last known time, not 1970. First,
;; so its activation runs before the others.
(service sysfixtime-service-type)
(service agetty-service-type
(agetty-configuration
(tty %serial-console)
(term "vt100")
(baud-rate "115200")))
(service avahi-service-type)
;; LAN and WAN: netifd from /etc/config/network (seeded
;; below): br-lan 192.168.10.1/24 (plus the WiFi AP);
;; wan = DHCP on eth0. Upstream DNS goes to
;; /etc/resolv.conf, which dnsmasq forwards to.
(service netifd-service-type
(netifd-configuration
(resolv-conf "/etc/resolv.conf")))
;; WiFi AP on the onboard radio, OpenWrt's way: netifd's
;; wireless handling configures wpad (hostapd) from
;; /etc/config/wireless, created at first boot by `wifi
;; config' plus %wifi-first-config. The AP is in br-lan.
(service openwrt-wireless-service-type
(openwrt-wireless-configuration
(board-base %board-base.json)
(first-config %wifi-first-config)))
;; DNS and DHCPv4 from /etc/config/dhcp (LuCI: Network >
;; DHCP and DNS), rendered by OpenWrt's init script. LAN
;; must come up even when the WiFi uplink is down.
(service openwrt-dnsmasq-service-type
(openwrt-dnsmasq-configuration
(dnsmasq dnsmasq/cross-fixed)))
(service firewall-service-type)
(simple-service 'router-sysctl sysctl-service-type
%router-sysctls)
;; OpenWrt userland (Phase 3/4): message bus and UCI.
(service ubus-service-type)
;; procd's ubus API: `uci commit X; reload_config' reloads
;; the services registered for X; `ubus call system board'.
(service procd-compat-service-type
(procd-compat-configuration
(release `(("distribution" . "Guix System")
("version" . ,%guix-version)
("target" . "bcm27xx/bcm2711")
("description"
. "Guix System, OpenWrt userland")))))
(service uci-service-type
(uci-configuration
(seeds `(("system" ,(local-file "files/uci/system"))
("network" ,(local-file "files/uci/network"))
("dhcp" ,(local-file "files/uci/dhcp"))
("firewall" ,(local-file "files/uci/firewall"))))
(defaults
`(("12_network-generate-ula"
,(file-append %openwrt-tree
"/package/base-files/files/etc/"
"uci-defaults/12_network-generate-ula"))))))
(service odhcpd-service-type)
;; LuCI at http(s)://192.168.10.1/ (root, system password);
;; brings in rpcd. uhttpd listens on all addresses, the
;; firewall's wan zone rejects it.
(service uhttpd-service-type)
(service luci-service-type)
;; Captive portal (TollGate's gate); seeded disabled:
;; `uci set nodogsplash.@nodogsplash[0].enabled=1'.
(service nodogsplash-service-type)
;; TollGate (Cashu/Nostr paid access through nodogsplash).
;; Its first-boot setup (99-tollgate-setup) reconfigures
;; uhttpd (:8080), WiFi (public + private SSIDs, br-private),
;; the firewall (tcp 2121) and nodogsplash.
(service tollgate-service-type)
%grow-root-service
%lan-eee-off-service
%chrony-accounts
%chrony-service
;; Keys only: sshd is reachable from the WAN (the admin path,
;; firewall rule Allow-SSH-WAN-lab) and the open TollGate SSID.
(service openssh-service-type
(openssh-configuration
(permit-root-login 'prohibit-password)
(password-authentication? #f)
(authorized-keys
`(("root" ,(local-file "files/workstation.pub"))))))
(modify-services %base-services
;; setfont: "Bad input file size" on the Pi's framebuffer
;; console; the built-in font is fine.
(delete console-font-service-type)
;; Accept store items signed by the build workstation, so
;; ./deploy-rpi.sh can `guix copy` cross-built systems.
(guix-service-type
config => (guix-configuration
(inherit config)
(authorized-keys
(cons (local-file "files/workstation-guix.pub")
%default-authorized-guix-keys)))))))))
%rpi4-os