Author SHA1 Message Date
Emma ThorpeandClaude Opus 5 a94a749f29 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>
2026-08-21 13:38:27 +01:00
9 changed files with 625 additions and 53 deletions
+22 -21
View File
@@ -7,22 +7,23 @@ single flake.
Defined in the host table in [`flake.nix`](https://code.emmathe.dev/lyrathorpe/nixfiles/src/branch/main/flake.nix):
| Configuration | System | Machine |
| --------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `lyrathorpe-mbp` | `aarch64-linux` | MacBook Pro (Apple Silicon, Asahi) |
| `lyrathorpe-t400` | `x86_64-linux` | ThinkPad T400 — [install notes](https://docs.lyrapup.pet/nixfiles/hosts/t400/) |
| `lyrathorpe-macpro31` | `x86_64-linux` | Mac Pro 3,1, desktop — [install notes](https://docs.lyrapup.pet/nixfiles/hosts/macpro31/) |
| `emmathorpe-edaas` | `x86_64-linux` | Work WSL box (NixOS-WSL) — [notes](https://docs.lyrapup.pet/nixfiles/hosts/edaas/) |
| `lyrathorpe-rpi5` | `aarch64-linux` | Raspberry Pi 5 headless server: Docker host + nginx reverse proxy — [install notes](https://docs.lyrapup.pet/nixfiles/hosts/rpi5/) |
| `lyrathorpe-mac` | `aarch64-darwin` | macOS (nix-darwin) — [notes](https://docs.lyrapup.pet/nixfiles/hosts/darwin/) |
| Configuration | System | Machine |
| --------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `lyrathorpe-mbp` | `aarch64-linux` | MacBook Pro (Apple Silicon, Asahi) |
| `lyrathorpe-t400` | `x86_64-linux` | ThinkPad T400 — [install notes](https://docs.lyrapup.pet/nixfiles/hosts/t400/) |
| `lyrathorpe-macpro31` | `x86_64-linux` | Mac Pro 3,1, desktop — [install notes](https://docs.lyrapup.pet/nixfiles/hosts/macpro31/) |
| `emmathorpe-edaas` | `x86_64-linux` | Work WSL box (NixOS-WSL) — [notes](https://docs.lyrapup.pet/nixfiles/hosts/edaas/) |
| `lyrathorpe-rpi5` | `aarch64-linux` | Raspberry Pi 5 headless server: Docker host + nginx reverse proxy — [install notes](https://docs.lyrapup.pet/nixfiles/hosts/rpi5/) |
| `lyrathorpe-zero2w` | `aarch64-linux` | Raspberry Pi Zero 2 W "Psion sidecar": PPP over RS232 + legacy mail proxy — [install notes](https://docs.lyrapup.pet/nixfiles/hosts/pizero2w/) |
| `lyrathorpe-mac` | `aarch64-darwin` | macOS (nix-darwin) — [notes](https://docs.lyrapup.pet/nixfiles/hosts/darwin/) |
Shared layers: `home` (home-manager: shell, git, editor),
`modules/common-nixos.nix` (all NixOS hosts: fonts, nix-ld, caches),
`modules/workstation.nix` (physical graphical hosts: audio, thermald,
earlyoom, fwupd), `modules/laptop.nix` (laptops: Wi-Fi, Bluetooth, power,
lid), `modules/desktop.nix` (wired desktops: NetworkManager), and
`modules/ssh.nix` (key-only sshd). The x86 hosts also pull `nixos-hardware`
profiles. The full module catalogue is below.
`modules/ssh.nix` (key-only sshd). The x86 hosts and both Raspberry Pis also
pull `nixos-hardware` profiles. The full module catalogue is below.
## Repository layout
@@ -56,17 +57,17 @@ module reaches a host: **baseModules** (every NixOS host, via `flake.nix`),
**host table** (listed explicitly per host in `flake.nix`), or **transitively**
(pulled in by another module's `imports`).
| Module | Imported by | What it does / when to use it |
| ------------------ | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `common-nixos.nix` | baseModules (all NixOS) | Timezone/locale, store hygiene (auto-optimise, big download buffer, **no** auto-GC), the nix-community binary cache, `nix-ld`, **sudo-rs** in place of sudo, base CLI (`git`, `fastfetch`), and the fleet-wide font stack. |
| `users.nix` | baseModules (all NixOS) | Builds `users.users` from the registry for the host's `hostUsers`; enables zsh; enables Firefox + Thunderbird **only** when `features.swayDesktop.enable` is on. Applies per-user `linger`. |
| `features.nix` | baseModules (all NixOS) | Declares the feature-flag options (`features.swayDesktop.enable`, `features.claudeCode.enable`) so any host can read/set them without importing the heavy implementation module, plus the CPU capability fact they derive from (`features.cpu.microarchLevel`) and the assertion that guards it. See "CPU capability gating". |
| `workstation.nix` | transitively (via laptop/desktop) | Form-factor-agnostic base for physical graphical hosts: turns on `swayDesktop`, Dvorak console, PipeWire, firewall (default-deny), fstrim, earlyoom, fwupd, thermald (x86), redistributable fw. |
| `laptop.nix` | host table (MBP, T400) | `imports` workstation.nix, then adds the portable bits: iwd Wi-Fi, lid suspend/lock, Bluetooth + blueman. |
| `desktop.nix` | host table (Mac Pro) | `imports` workstation.nix, then swaps Wi-Fi for wired NetworkManager. Pair with `portable = false` in the host table. |
| `sway.nix` | host table (graphical hosts) | Implementation of `features.swayDesktop`: the system Sway package, the greetd/ReGreet (cage) greeter forced to Dvorak, xdg-portal, Wayland utility packages. Home-side Sway config is in `home/sway.nix`. |
| `ssh.nix` | host table (T400, Mac Pro, RPi5) | Enables sshd, opens port 22, enforces a key-only policy (no password / keyboard-interactive, no root). Authorized keys come from the registry via `users.nix`. |
| `firmware/` | referenced by MBP host config | Committed Apple peripheral firmware blobs for the Asahi MBP (see "MacBook (Asahi) firmware"). |
| Module | Imported by | What it does / when to use it |
| ------------------ | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `common-nixos.nix` | baseModules (all NixOS) | Timezone/locale, store hygiene (auto-optimise, big download buffer, **no** auto-GC), the nix-community binary cache, `nix-ld`, **sudo-rs** in place of sudo, base CLI (`git`, `fastfetch`), and the fleet-wide font stack. |
| `users.nix` | baseModules (all NixOS) | Builds `users.users` from the registry for the host's `hostUsers`; enables zsh; enables Firefox + Thunderbird **only** when `features.swayDesktop.enable` is on. Applies per-user `linger`. |
| `features.nix` | baseModules (all NixOS) | Declares the feature-flag options (`features.swayDesktop.enable`, `features.claudeCode.enable`) so any host can read/set them without importing the heavy implementation module, plus the CPU capability fact they derive from (`features.cpu.microarchLevel`) and the assertion that guards it. See "CPU capability gating". |
| `workstation.nix` | transitively (via laptop/desktop) | Form-factor-agnostic base for physical graphical hosts: turns on `swayDesktop`, Dvorak console, PipeWire, firewall (default-deny), fstrim, earlyoom, fwupd, thermald (x86), redistributable fw. |
| `laptop.nix` | host table (MBP, T400) | `imports` workstation.nix, then adds the portable bits: iwd Wi-Fi, lid suspend/lock, Bluetooth + blueman. |
| `desktop.nix` | host table (Mac Pro) | `imports` workstation.nix, then swaps Wi-Fi for wired NetworkManager. Pair with `portable = false` in the host table. |
| `sway.nix` | host table (graphical hosts) | Implementation of `features.swayDesktop`: the system Sway package, the greetd/ReGreet (cage) greeter forced to Dvorak, xdg-portal, Wayland utility packages. Home-side Sway config is in `home/sway.nix`. |
| `ssh.nix` | host table (T400, Mac Pro, both Pis) | Enables sshd, opens port 22, enforces a key-only policy (no password / keyboard-interactive, no root). Authorized keys come from the registry via `users.nix`. |
| `firmware/` | referenced by MBP host config | Committed Apple peripheral firmware blobs for the Asahi MBP (see "MacBook (Asahi) firmware"). |
Form-factor decision: a **laptop** imports `laptop.nix` (default
`portable = true`); a **wired desktop** imports `desktop.nix` and sets
+205
View File
@@ -0,0 +1,205 @@
# Raspberry Pi Zero 2 W (`lyrathorpe-zero2w`)
Headless `aarch64-linux` "Psion sidecar": an RS232 companion for a Psion 5MX,
after [Kian Ryan's PPP modem and terminal
write-up](https://www.kianryan.co.uk/2022-11-28-psion-sidecar-ppp-modem-and-terminal/).
Two roles, split into submodules:
- **PPP link + telnet** (`serial-ppp.nix`) — `pppd` on `/dev/ttyAMA0`, the Psion
on the far end of a null-modem cable, NAT out to Wi-Fi, and a telnet login for
the Psion's terminal client.
- **Legacy mail proxy** (`email-proxy.nix`) — cleartext POP3/SMTP for the
Psion's built-in mail client, forwarded to authenticated IMAPS/SMTPS by
[legacy-email-proxy](https://code.emmathe.dev/lyrathorpe/legacy-email-proxy).
That project ships its own package and NixOS module, so `email-proxy.nix`
here is only `services.legacy-email-proxy.enable` plus a path to the
credentials — nothing about the proxy is vendored into this flake.
`sd-image.nix` in the same directory is not part of the running system: it is
the one-shot install card, built as `packages.aarch64-linux.zero2w-sd-image`.
See "Install".
## Hardware and boot
The Zero 2 W is a BCM2837 — the Pi 3's SoC — so the host table uses
`nixos-hardware`'s `raspberry-pi-3` profile for the kernel, firmware and device
tree. Boot is the same U-Boot + extlinux path as the other Pi.
Unlike the Pi 5, this host owns the firmware partition declaratively
(`hardware.raspberry-pi.firmware.enable`): every `switch` rewrites
`/boot/firmware`, including `config.txt`. Two settings there matter:
| `config.txt` | Why |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dtoverlay=disable-bt` | Moves the PL011 UART off Bluetooth onto GPIO 14/15, so `/dev/ttyAMA0` is the RS232 header. The mini UART (`ttyS0`) drifts at 115200. |
| `dtoverlay=uart0,ctsrts` | RTS/CTS on GPIO 16/17. Both `pppd` and the Psion's modem profile use hardware flow control. |
| `kernel=u-boot.bin` | `hardware.raspberry-pi.firmware.uboot.enable`. Without it the rewritten `config.txt` would have no `kernel=` line and the board would stop booting. |
`gpu_mem=16`, `start_x=0`, `camera_auto_detect=0` and `display_auto_detect=0`
hand the VideoCore the minimum: the board has 512 MB total and no display.
## Never build on the Pi
512 MB of RAM and an SD card. It cannot compile its own system, and there is
deliberately no swap partition (SD cards wear out under swap writes) — zram
takes its place. Build somewhere else and push the result:
```sh
# from a workstation, using another aarch64 machine as the builder
nixos-rebuild switch --flake .#lyrathorpe-zero2w \
--build-host lyrathorpe@lyrathorpe-rpi5 \
--target-host lyrathorpe@<pi-address> --use-remote-sudo
```
The `raspberry-pi-3` profile builds the vendor kernel from source and it is not
in the binary cache, so the first build is long (hours on the Pi 5, less on the
MacBook). Later builds reuse it. The same applies to the SD image below: it
contains that kernel, so it needs an `aarch64-linux` builder too. From an
`x86_64` box or a Mac, that means a remote builder (`nix.buildMachines`) or, on
Darwin, `nix.linux-builder.enable`.
## Install
The card is built from this flake, not downloaded. A generic NixOS image would
boot, but there would be no way into the machine afterwards: it has no Ethernet,
no wifi credentials, and this configuration hands the serial port to `pppd`, so
there is no console either. Building the host's own image sidesteps all three —
the first boot is already the real system, with the SSH key from the registry
in place.
1. **Set the SSID.** `networking.wireless.networks` in `configuration.nix` still
says `CHANGE-ME-SSID`. It is baked into the image at build time; only the PSK
is read at runtime.
2. **Build and write the card.** On an `aarch64-linux` machine (or with one
configured as a builder):
```sh
nix build .#packages.aarch64-linux.zero2w-sd-image
sudo dd if=result/sd-image/nixos-zero2w.img of=/dev/sdX bs=4M conv=fsync status=progress
```
Check `/dev/sdX` twice. `dd` does not ask.
3. **Seed the secrets before first boot.** They are not in the image. Mount the
card's second partition (the ext4 root) and write both files described under
"Secrets" below:
```sh
sudo mount /dev/sdX2 /mnt
sudo mkdir -p /mnt/var/lib/wpa_supplicant /mnt/var/lib/legacy-email-proxy
printf 'psk_home=%s\n' 'the-pre-shared-key' \
| sudo tee /mnt/var/lib/wpa_supplicant/secrets.conf > /dev/null
sudo chmod 600 /mnt/var/lib/wpa_supplicant/secrets.conf
# ... and /mnt/var/lib/legacy-email-proxy/backend.env, same permissions
sudo umount /mnt
```
Skip the PSK and the board boots with no network at all.
4. **Boot it.** Give it a few minutes on first boot — it resizes the root
partition and generates host keys on a slow card. Then:
```sh
ssh lyrathorpe@lyrathorpe-zero2w.local # mDNS; services.avahi publishes it
```
5. **Give the login user a password** (`passwd lyrathorpe`) if you want console
or telnet login; the SSH key from
[`users/registry.nix`](https://code.emmathe.dev/lyrathorpe/nixfiles/src/branch/main/users/registry.nix)
already works without one.
6. Thereafter, rebuild from another machine as in the previous section.
`hosts/PiZero2W/hardware-configuration.nix` is a **placeholder** — but its
layout (`/` on label `NIXOS_SD`, `/boot/firmware` on label `FIRMWARE`) is
exactly what the SD image produces, so there is nothing to regenerate for a card
install. Run `nixos-generate-config` and replace it only if you deviate from
that layout.
If the board never appears on the network, it is almost always the PSK file.
Re-mount the card and check it. Failing that, a mini-HDMI monitor and a
micro-USB keyboard get you a console on `tty1` — the serial port will not,
because `pppd` holds it.
## Secrets (not in the Nix store)
Both files are created on the device, owned by root, mode `0600`. Neither is
managed by this flake; the units that read them fail loudly if they are absent.
**Wi-Fi PSK** — `/var/lib/wpa_supplicant/secrets.conf`:
```
psk_home=<the pre-shared key>
```
The SSID itself _is_ in `configuration.nix` and is currently the placeholder
`CHANGE-ME-SSID`; set it to the real network. `wpa_supplicant` resolves
`pskRaw = "ext:psk_home"` against this file at runtime.
**Mail backend** — `/var/lib/legacy-email-proxy/backend.env`, a systemd
`EnvironmentFile`:
```
BACKEND_IMAP_HOST=imap.example.com
BACKEND_IMAP_USER=someone@example.com
BACKEND_IMAP_PASS=<app password>
BACKEND_SMTP_HOST=smtp.example.com
BACKEND_SMTP_USER=someone@example.com
BACKEND_SMTP_PASS=<app password>
```
Ports and TLS default sensibly (IMAPS 993, SMTPS 465); the full variable list is
in the proxy's README.
### Why POP3 and not IMAP
The Psion's built-in mail client speaks POP only, so POP3 is what the proxy
exposes. If a third-party IMAP client is ever installed on the device, the
answer is **not** to add an IMAP frontend to the proxy: the backend is already
IMAP, so there is no protocol to translate, only TLS to remove. An `stunnel`
client (plaintext 143 on the PPP link, IMAPS 993 outbound) does that in a few
lines with no code, and credentials pass straight through — IMAP clients always
authenticate.
SMTP stays on the proxy either way. A client of this vintage cannot do SMTP
AUTH, which is exactly why the proxy injects the backend credentials.
## Psion configuration
Matches the addressing in `serial-ppp.nix` (`10.0.0.1` the Pi, `10.0.0.2` the
Psion):
- **Modem** control panel, a "Direct Cable Connection" profile: 115200 baud,
Hardware (RTS/CTS) flow control; on the Advanced tab, Terminal Detect and
Carrier Detect both **off**.
- **Internet** control panel, a new profile: Connection Type **Direct**, Manual
Login **True**. Addresses: get IP from server **False**, static **10.0.0.2**.
Get DNS from server **True** — `pppd` sends resolvers over the link
(`ms-dns`), so nothing is hard-coded on the Psion.
- Advanced: PPP extensions **False**, plain-text authentication **True**.
- Terminal client: telnet to **10.0.0.1 port 23**. It renders non-ANSI output
far better than the raw serial console does.
- Mail client: POP3 and SMTP server **10.0.0.1**, no encryption, no
authentication.
## Security
Everything on this host that the Psion talks to is unauthenticated and
unencrypted, because a 1999 palmtop speaks no TLS:
- **telnet on 23** — cleartext login, including the password.
- **POP3 on 110 / SMTP on 25** — full mailbox access and an open relay to anyone
who reaches them.
The confinement is the firewall, and it is the only thing standing there:
`ppp0` is a trusted interface, `wlan0` is not, and those ports are never opened
on it. The proxy binds `0.0.0.0` rather than `10.0.0.1` on purpose — the PPP
address only exists while the Psion is plugged in, and a bind-time dependency on
a serial cable is a restart loop waiting to happen. Do not add these ports to
`networking.firewall.allowedTCPPorts`, and do not put this board on an untrusted
network.
Only sshd (port 22, key-only, via
[`modules/ssh.nix`](https://code.emmathe.dev/lyrathorpe/nixfiles/src/branch/main/modules/ssh.nix))
is reachable over Wi-Fi.
## Troubleshooting
| Symptom | Check |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No PPP at all | `systemctl status pppd-psion`, then `journalctl -u pppd-psion -f` while the Psion dials. `passive`/`persist` mean it waits, not fails. |
| PPP negotiates, then hangs | Flow control. Confirm `dtoverlay=uart0,ctsrts` is in `/boot/firmware/config.txt` and that the Psion's modem profile is set to Hardware. |
| `/dev/ttyAMA0` missing or is a Bluetooth device | `disable-bt` did not apply — the firmware partition was not rewritten. Confirm `/boot/firmware` is a mounted partition; the activation script skips with a warning if it is not. |
| Something else holds the port | `systemctl status serial-getty@ttyAMA0` — it is disabled in `serial-ppp.nix`, and must stay that way. |
| Mail proxy dead | `systemctl status legacy-email-proxy`. A missing `backend.env` fails the unit before it starts. |
Generated
+52 -31
View File
@@ -3,16 +3,16 @@
"brew-src": {
"flake": false,
"locked": {
"lastModified": 1786945682,
"narHash": "sha256-VBESSoJccikdhxh3vp3SQeG7cZXTOulMvVkoSqNDEhs=",
"lastModified": 1786348930,
"narHash": "sha256-bCQJkbgsAMDp5HQystZLCq11UHiyEuoWbxKulAPYrh8=",
"owner": "Homebrew",
"repo": "brew",
"rev": "5b90e281d4e0c8fbd6ca4d8358276fb305b8d0bd",
"rev": "3ecc9eff23feebf1bc73846d74e14a122c93b66f",
"type": "github"
},
"original": {
"owner": "Homebrew",
"ref": "6.0.18",
"ref": "6.0.16",
"repo": "brew",
"type": "github"
}
@@ -25,11 +25,11 @@
},
"locked": {
"dir": "pkgs/firefox-addons",
"lastModified": 1787457768,
"narHash": "sha256-cbgeu5NTb6DtB+tNs4E6z6K/1XKKM90gVmlkWMJe+gY=",
"lastModified": 1786853140,
"narHash": "sha256-O880FlUav75Q5aNlg9znyg/avf1X/W7o/cAtZFLtpWc=",
"owner": "rycee",
"repo": "nur-expressions",
"rev": "25cfc8fdc413d73b3a47e3e86dafcad51cf5c9f9",
"rev": "ba9568c9c0df6290dc2f34b032ab4cb575e73788",
"type": "gitlab"
},
"original": {
@@ -135,11 +135,11 @@
]
},
"locked": {
"lastModified": 1787424939,
"narHash": "sha256-O2tBn84NNuHrnqNVxx/XqsXwfYvS1YwBh+7CBnbCYsk=",
"lastModified": 1784288435,
"narHash": "sha256-ReRHaLgr/uVqdD8afFSn+myXIfpHeOhP0yYe0TJqAA8=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "809414f0cdadf82cf11b06c2b29ba9b3168b3297",
"rev": "43b3c1ab9d40fb1dbb008f451988a91e375825e9",
"type": "github"
},
"original": {
@@ -155,11 +155,11 @@
]
},
"locked": {
"lastModified": 1787377438,
"narHash": "sha256-Sxu1NLTD/Ern6hFGLlZmtKCSct3YQXZI/lls8RE1XeM=",
"lastModified": 1786924861,
"narHash": "sha256-hftabkb+73OcGzvwFAjCiQorAhprs9TnU1+FkGO5CIw=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "65258d5c65a250189fde2e35f490d15e064c4c62",
"rev": "09ae1b85a6db412d841d60f924b23f881f0d0a38",
"type": "github"
},
"original": {
@@ -185,6 +185,26 @@
"type": "github"
}
},
"legacy-email-proxy": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1787315211,
"narHash": "sha256-FuZ9nXMRtnMPO/wbjYkpsKn6K/FFc64P5XDmCyfyxGs=",
"ref": "refs/heads/main",
"rev": "f1e1373fd350fd77f1848eddfa67ed9e00724c25",
"revCount": 13,
"type": "git",
"url": "https://code.emmathe.dev/lyrathorpe/legacy-email-proxy"
},
"original": {
"type": "git",
"url": "https://code.emmathe.dev/lyrathorpe/legacy-email-proxy"
}
},
"nix-darwin": {
"inputs": {
"nixpkgs": [
@@ -211,11 +231,11 @@
"brew-src": "brew-src"
},
"locked": {
"lastModified": 1787330919,
"narHash": "sha256-LslMncqN7uOOH5S88WZtO/EVt2HwD8ltUnfyANk+mC0=",
"lastModified": 1786686423,
"narHash": "sha256-8q3WdB8o3VUI7rOz1OXfioXIaaWbFTAxRJAkWLlfc0s=",
"owner": "zhaofengli",
"repo": "nix-homebrew",
"rev": "b00218e4aec0e5bf07d61a0bb13f842faa582d7b",
"rev": "ccabf79a6b9845eb72b51ea1d9c7ce3446350df3",
"type": "github"
},
"original": {
@@ -231,11 +251,11 @@
]
},
"locked": {
"lastModified": 1787457452,
"narHash": "sha256-FJh4esFS3zqNNuKwvN3t6wrJGewqp1AUF9DAEvoKPD8=",
"lastModified": 1786852476,
"narHash": "sha256-IM5CYtf86W4w8eUPpKcY/LpdHElmVBtJhaKnoTKxZEA=",
"owner": "nix-community",
"repo": "nix-index-database",
"rev": "c51d5c2ba69c907a34e90c9b6b80cd2b93811745",
"rev": "c7962dc97b45129df8d751bedaf37beb5a17706e",
"type": "github"
},
"original": {
@@ -272,11 +292,11 @@
]
},
"locked": {
"lastModified": 1787144466,
"narHash": "sha256-HHfv2/HkNSKbbSyU9iD/g8lbP6r4tl33sSw1W4rXCk0=",
"lastModified": 1786867632,
"narHash": "sha256-ez+ubZlA1RtdjCB18a6zJ9M4u8qoPDy08EcnsW5M3Xw=",
"owner": "NixOS",
"repo": "nixos-hardware",
"rev": "0471accf8d0a8210b31d947497d179ecc99e0021",
"rev": "ff17823245ab9ff7bcae6acf950bd89cba82c38c",
"type": "github"
},
"original": {
@@ -308,11 +328,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1787414105,
"narHash": "sha256-WncT27+3BOkgTaJZLnCsf3LcYf9RXMuR9ONSN4rzQ7s=",
"lastModified": 1786711500,
"narHash": "sha256-QvnceIGTBeDvDd9oCn+GvdsnkquliuwbVgpiRH68qaQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a9e6d84f9c2f9012f5fe7d964a7851352300e61a",
"rev": "02e08985a27c65ffd33d434eeb2e660a2e4dc84d",
"type": "github"
},
"original": {
@@ -324,11 +344,11 @@
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1787360063,
"narHash": "sha256-dt4WdcvsA8/RCe+VZZwqU0X+XMM3wBbGCWA0/sFWzGo=",
"lastModified": 1786862985,
"narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2c423e03bbafcff28bfadc6781a4a8257f205cb5",
"rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44",
"type": "github"
},
"original": {
@@ -347,11 +367,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1787536726,
"narHash": "sha256-aBh5Yk9tX8ZV4k10BJr2fvTq0/+iWGegaCMUOU7YKas=",
"lastModified": 1786873773,
"narHash": "sha256-Hj/nkhKDv0aJly1PAUstrhrgEYn1mVSkLIYMh90r/Pc=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "e2c3f9f36326d07340626847543c557e2b95fb50",
"rev": "b397fb9f6950d57355d62bb92457d223464e0115",
"type": "github"
},
"original": {
@@ -368,6 +388,7 @@
"git-hooks": "git-hooks",
"home-manager": "home-manager",
"kube-tmux": "kube-tmux",
"legacy-email-proxy": "legacy-email-proxy",
"nix-darwin": "nix-darwin",
"nix-homebrew": "nix-homebrew",
"nix-index-database": "nix-index-database",
+44 -1
View File
@@ -67,6 +67,13 @@
url = "github:jonmosco/kube-tmux";
flake = false;
};
# legacy-email-proxy: cleartext POP3/SMTP front end for the Psion's mail
# client, proxied to authenticated IMAPS/SMTPS. Ships its own package and
# NixOS module; the Pi Zero 2 W host just enables the service.
legacy-email-proxy = {
url = "git+https://code.emmathe.dev/lyrathorpe/legacy-email-proxy";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
@@ -327,6 +334,26 @@
./users/lyrathorpe/home.nix
];
};
lyrathorpe-zero2w = {
system = "aarch64-linux";
portable = false;
# Headless "Psion sidecar": PPP over RS232 plus a legacy mail proxy
# (hosts/PiZero2W/). No sway.nix; the raspberry-pi-3 profile carries
# the kernel/firmware/device tree (the Zero 2 W is the Pi 3's
# BCM2837 SoC) and ssh.nix adds key-only sshd. This board has 512 MB
# of RAM and never builds its own system -- see
# docs/hosts/pizero2w.md.
modules = [
./hosts/PiZero2W/configuration.nix
inputs.nixos-hardware.nixosModules.raspberry-pi-3
./modules/ssh.nix
];
users.lyrathorpe.homeModules = [
./home
./users/lyrathorpe/home.nix
];
};
};
# Darwin host table — macOS machines built via mkDarwinHost. The shared
@@ -365,8 +392,24 @@
# nixpkgs instance for that system. Outputs here become per-system
# attrsets automatically (e.g. devShells.<system>.default).
perSystem =
{ config, pkgs, ... }:
{
config,
pkgs,
system,
...
}:
{
# One-shot SD card for bringing the Pi Zero 2 W up: that host's own
# configuration plus the sd-image module, so the first boot is
# already the real system. aarch64-linux only -- building it needs
# an aarch64 Linux builder. See docs/hosts/pizero2w.md.
packages = lib.optionalAttrs (system == "aarch64-linux") {
zero2w-sd-image =
((mkHost hosts.lyrathorpe-zero2w).extendModules {
modules = [ ./hosts/PiZero2W/sd-image.nix ];
}).config.system.build.sdImage;
};
# treefmt drives `nix fmt` and the formatting check below. nixfmt
# stays the .nix formatter (the tree is already nixfmt-formatted);
# shfmt covers shell and prettier covers markdown/yaml/json.
+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";
};
};
}