feat(hosts): add the Raspberry Pi Zero 2 W Psion sidecar
CI / flake (push) Skipped
CI / flake (pull_request) Successful in 4m16s

A headless aarch64 companion for a Psion 5MX: PPP over RS232 with NAT out to
wifi and a telnet login, plus a cleartext POP3/SMTP proxy for the Psion's mail
client.

- hosts/PiZero2W/: host config, serial-ppp.nix, email-proxy.nix, an SD-image
  variant, and a hardware-configuration.nix placeholder.
- Host table entry on nixos-hardware's raspberry-pi-3 profile; the Zero 2 W is
  the Pi 3's BCM2837 SoC. nixpkgs' linuxPackages_rpi02w is deprecated and warns
  that the linux-rpi series is being removed in favour of nixos-hardware.
- The host owns its firmware partition (hardware.raspberry-pi.firmware), which
  is what puts the disable-bt and uart0/ctsrts overlays in config.txt so
  /dev/ttyAMA0 is the RS232 header rather than Bluetooth. uboot.enable keeps the
  U-Boot -> extlinux boot path the rewritten config.txt would otherwise lose.
- packages.aarch64-linux.zero2w-sd-image: the host's own configuration as an
  installable card. The board has no Ethernet and no free serial port, so a
  generic image would leave no way in.
- The mail proxy comes from the legacy-email-proxy flake, which provides the
  package and the NixOS module; nothing about it is vendored here.
- docs/hosts/pizero2w.md, plus README host table and shared-layer notes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Emma Thorpe
2026-08-21 13:38:27 +01:00
co-authored by Claude Opus 5
parent 1ff333a896
commit a94a749f29
9 changed files with 594 additions and 22 deletions
+111
View File
@@ -0,0 +1,111 @@
# Raspberry Pi Zero 2 W (aarch64) "Psion sidecar": an RS232 companion for a
# Psion 5MX. Two roles, split into submodules: ./serial-ppp.nix (PPP over the
# serial line, NAT out to wifi, telnet login) and ./email-proxy.nix (cleartext
# POP3/SMTP front end for the Psion's mail client). The raspberry-pi-3
# nixos-hardware profile (the Zero 2 W is the same BCM2837 SoC as the Pi 3) and
# key-only sshd (../../modules/ssh.nix) are layered on in the flake host table.
# Install notes: see ../../docs/hosts/pizero2w.md.
{ lib, ... }:
{
imports = [
./hardware-configuration.nix
./serial-ppp.nix
./email-proxy.nix
];
# Match the flake's nixosConfigurations attribute name so `nh os switch`
# (which selects by the local hostname) resolves without an explicit -H flag.
networking.hostName = "lyrathorpe-zero2w";
# Headless server: modules/sway.nix is not imported and
# features.swayDesktop.enable defaults to false, so this host keeps plain
# TTY/SSH login.
# Claude Code is a Node application. It runs on aarch64, but not usefully in
# 512 MB of RAM, and its closure is unwelcome on an SD card.
features.claudeCode.enable = false;
# 512 MB total and no swap partition -- SD cards wear out under swap writes.
# Compressed RAM swap instead; zstd is the best ratio-per-cycle the SoC can
# sustain.
zramSwap = {
enable = true;
algorithm = "zstd";
};
# The NixOS manual and man page index cost build time and a chunk of the card
# for a box that is administered over SSH from elsewhere.
documentation.nixos.enable = false;
# Own the firmware partition declaratively: every switch rewrites config.txt,
# the vendor device trees and the overlays below. Without this the card keeps
# whatever config.txt the flashed image wrote and the UART overlays never
# load. uboot.enable keeps the GPU firmware chainloading U-Boot -> extlinux,
# which is how the NixOS aarch64 SD image boots; leaving it off would rewrite
# config.txt without a `kernel=` line and the board would stop booting.
hardware.raspberry-pi.firmware = {
enable = true;
uboot.enable = true;
};
hardware.raspberry-pi.configtxt = {
settings.all = {
# Headless: hand the VideoCore the minimum and leave the rest to Linux.
# start_x/camera_auto_detect otherwise reserve VRAM for a camera stack
# this board does not have.
gpu_mem = 16;
start_x = 0;
camera_auto_detect = false;
# Left on, the firmware auto-loads the KMS display overlay, which wants
# more VRAM than this board can spare for a monitor it will never have.
display_auto_detect = false;
};
# Replaces the profile's default (vc4-kms-v3d), which is display hardware
# this host never uses.
deviceTreeOverlays.all = [
# Move the PL011 UART off Bluetooth and onto GPIO 14/15, so /dev/ttyAMA0
# is the RS232 header. The mini UART (ttyS0) derives its baud rate from
# the core clock and drifts at 115200.
{ disable-bt = { }; }
# RTS/CTS on GPIO 16/17: the Psion's modem profile uses hardware flow
# control, and so does pppd in ./serial-ppp.nix.
{ uart0.ctsrts = true; }
];
};
# Wifi is the Pi's uplink and the route the Psion reaches the internet over
# (./serial-ppp.nix masquerades onto it).
networking.interfaces.wlan0.useDHCP = true;
networking.wireless = {
enable = true;
interfaces = [ "wlan0" ];
# PSKs stay out of the Nix store: wpa_supplicant reads them at runtime from
# this file, which is created on the device (root-owned, 0600) and contains
# psk_home=<the pre-shared key>
# See ../../docs/hosts/pizero2w.md.
secretsFile = "/var/lib/wpa_supplicant/secrets.conf";
networks."CHANGE-ME-SSID".pskRaw = "ext:psk_home";
};
# The board takes a DHCP lease over wifi, so its address moves. mDNS makes it
# findable as lyrathorpe-zero2w.local instead of hunting through the router's
# lease table -- which matters most on first boot, when it is the only way in.
services.avahi = {
enable = true;
openFirewall = true;
publish = {
enable = true;
addresses = true;
workstation = true;
};
};
# Default-deny inbound. sshd opens 22 (../../modules/ssh.nix); everything the
# Psion talks to is reached over the PPP link, which ./serial-ppp.nix marks
# trusted.
networking.firewall.enable = true;
# See `man configuration.nix` / the stateVersion docs before changing.
system.stateVersion = "26.05";
}
+25
View File
@@ -0,0 +1,25 @@
# legacy-email-proxy: a cleartext POP3 (110) and SMTP (25) front end for the
# Psion's built-in mail client, forwarded to authenticated IMAPS/SMTPS.
#
# The package, the systemd unit and its hardening all live upstream
# (https://code.emmathe.dev/lyrathorpe/legacy-email-proxy); this host only
# enables the service and points it at the credentials.
{ inputs, ... }:
{
imports = [ inputs.legacy-email-proxy.nixosModules.default ];
services.legacy-email-proxy = {
enable = true;
# The listeners are unauthenticated and unencrypted by design, so the
# firewall is what confines them: ppp0 is trusted, wlan0 is not, and 110/25
# are never opened there (./serial-ppp.nix). They stay on the default
# 0.0.0.0 rather than the PPP address because 10.0.0.1 exists only while
# the Psion is plugged in, and a bind-time dependency on a serial cable is
# a restart loop waiting to happen.
# Backend hostnames and credentials. Kept out of the Nix store: created on
# the device, root-owned 0600. See ../../docs/hosts/pizero2w.md.
environmentFile = "/var/lib/legacy-email-proxy/backend.env";
};
}
+33
View File
@@ -0,0 +1,33 @@
# PLACEHOLDER hardware configuration for the Raspberry Pi Zero 2 W.
#
# This file is NOT the real generated config -- it exists only so the host
# evaluates in CI before the Pi is provisioned. The machine will not boot from
# it as-is. On first install, regenerate this file on the device with
# nixos-generate-config --root /mnt
# and replace this placeholder with the output (commit it). See ../../docs/hosts/pizero2w.md.
#
# Like every hardware-configuration.nix in this repo, this file is excluded from
# the formatter and linters (see the pre-commit/treefmt excludes in flake.nix).
{ modulesPath, ... }:
{
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
nixpkgs.hostPlatform = "aarch64-linux";
# The Zero 2 W boots from an SD card with a FAT firmware partition and an ext4
# root. Labels match the conventional sd-image layout; the real generated
# config will use by-uuid device paths instead.
fileSystems."/" = {
device = "/dev/disk/by-label/NIXOS_SD";
fsType = "ext4";
};
fileSystems."/boot/firmware" = {
device = "/dev/disk/by-label/FIRMWARE";
fsType = "vfat";
};
# 512 MB of RAM and an SD card: no swap partition (SD cards wear out under
# swap writes). zram takes its place; see ../../hosts/PiZero2W/configuration.nix.
swapDevices = [ ];
}
+44
View File
@@ -0,0 +1,44 @@
# SD-card image of this host, used exactly once: to bring the board up.
#
# Deliberately NOT imported by ./configuration.nix. The flake extends the host
# with it (see packages.aarch64-linux.zero2w-sd-image in ../../flake.nix), so
# the card carries the host's own kernel, config.txt and SSH keys rather than a
# generic installer that then has to be reconfigured over a console this host
# does not have -- pppd owns the serial port (./serial-ppp.nix).
#
# It does not carry the runtime secrets. Seed those into the card's root
# partition before first boot; see ../../docs/hosts/pizero2w.md.
{
config,
lib,
modulesPath,
...
}:
{
imports = [ "${modulesPath}/installer/sd-card/sd-image.nix" ];
# sd-image.nix pulls in profiles/all-hardware.nix, which is every driver and
# firmware blob NixOS knows about. The raspberry-pi-3 profile already carries
# what this board has, and the card is small.
hardware.enableAllHardware = lib.mkForce false;
image.baseName = "nixos-zero2w";
sdImage = {
# Compressing costs a long single-threaded pass and buys nothing: the image
# is written straight to a card with dd.
compressImage = false;
# The default 30 MiB does not hold the vendor GPU firmware, U-Boot and the
# BCM2837 device trees and overlays that nixos-hardware installs here.
firmwareSize = 128;
# The firmware partition is populated by nixos-hardware's firmware module
# (it takes over sdImage.populateFirmwareCommands); the root side is the
# stock extlinux install, which no longer arrives with it.
populateRootCommands = ''
mkdir -p ./files/boot
${config.boot.loader.generic-extlinux-compatible.populateCmd} -c ${config.system.build.toplevel} -d ./files/boot
'';
};
}
+89
View File
@@ -0,0 +1,89 @@
# The serial half of the Psion sidecar: a PPP link to a Psion 5MX over
# /dev/ttyAMA0 (RS232 level shifter on the GPIO header, 115200 8N1 with
# RTS/CTS), masqueraded out of wifi, plus a telnet login for the Psion's
# terminal client.
#
# Cleartext telnet and unauthenticated PPP are safe *only* because the link is
# a two-node cable: the peer is a machine from 1999 that speaks no TLS. Nothing
# here is exposed to wlan0.
{ pkgs, ... }:
let
# Point-to-point addresses for the serial link; nothing else routes here.
piAddress = "10.0.0.1";
psionAddress = "10.0.0.2";
in
{
# pppd needs exclusive use of the port. NixOS starts a getty on any serial
# console named in boot.kernelParams; ttyAMA0 is not one today, but disable it
# explicitly so a later kernel-param change cannot silently steal the line.
systemd.services."serial-getty@ttyAMA0".enable = false;
services.pppd = {
enable = true;
peers.psion.config = ''
/dev/ttyAMA0
115200
${piAddress}:${psionAddress}
# Hardware flow control, matching the Psion's modem profile.
crtscts
# A null-modem cable has no carrier detect and no peer to authenticate.
local
noauth
# The systemd unit is Type=notify, so pppd must stay in the foreground.
nodetach
lock
# Wait for the Psion rather than failing when it is unplugged, and keep
# waiting for the next time it is plugged back in.
passive
persist
maxfail 0
holdoff 1
# Hand the Psion resolvers over the link, so its Internet profile can set
# "get DNS from server = True" instead of hard-coding them.
ms-dns 1.1.1.1
ms-dns 8.8.8.8
'';
};
# The Psion's route to the internet. The original write-up used pppd's
# proxyarp instead; NAT keeps the Psion out of the LAN broadcast domain and
# does not depend on what the wifi router tolerates.
networking.nat = {
enable = true;
externalInterface = "wlan0";
internalIPs = [ "${psionAddress}/32" ];
};
# Everything the Psion connects to (telnet here, POP3/SMTP in
# ./email-proxy.nix) is reachable over the PPP link and nowhere else.
networking.firewall.trustedInterfaces = [ "ppp0" ];
# The Psion's terminal client speaks telnet over TCP, which it renders far
# better than the raw serial console. Socket-activated, one process per
# connection; busybox's telnetd in inetd mode hands straight over to login.
systemd.sockets.telnetd = {
description = "Telnet login socket for the Psion";
wantedBy = [ "sockets.target" ];
listenStreams = [ "${piAddress}:23" ];
socketConfig = {
Accept = true;
# ppp0 (and with it 10.0.0.1) only exists while the Psion is connected;
# FreeBind lets the socket be listening before that.
FreeBind = true;
};
};
systemd.services."telnetd@" = {
description = "Telnet login for the Psion";
serviceConfig = {
ExecStart = "-${pkgs.busybox}/bin/busybox telnetd -i -l ${pkgs.shadow}/bin/login";
StandardInput = "socket";
StandardError = "journal";
};
};
}