# zds-pi A self-hosted AT Protocol PDS ([zds](https://tangled.org/zat.dev/zds), written in Zig) running on a **2011 Raspberry Pi 1 Model B** (ARMv6, 512 MB RAM, 700 MHz) behind T-Mobile Home Internet CGNAT, publicly served at **https://pi.chadtmiller.com** via Cloudflare Tunnel. Live since 2026-08-19. ~7 MB RSS. Federates with the Bluesky relay network. ## Architecture ``` internet ── Cloudflare edge (TLS, DNS: pi + *.pi CNAME → tunnel) │ (outbound-only tunnel: CGNAT never sees an inbound packet) cloudflared (systemd, ARMv6 build) ── zds :2583 (systemd, localhost) Raspberry Pi 1 ── Edimax EW-7811Un ── 2.4 GHz Wi-Fi ``` ## Reproducing from scratch 1. **Flash the SD card**: Raspberry Pi OS Lite **32-bit** (armhf). Seed the boot partition per [`boot/README.md`](boot/README.md) — user, SSH, Wi-Fi. 2. **Router**: must offer **WPA2-only** (not WPA2/WPA3 transition) — see hardware gotchas below. Boot the Pi, `ssh pi`. 3. **Build**: `scripts/build.sh` — clones zds at a pinned commit, applies the ARMv6 patches (below), cross-compiles from macOS with Zig (`-Dtarget=arm-linux-musleabihf -Dcpu=arm1176jzf_s`). Never compile on the Pi; 512 MB is not enough for the Zig compiler. 4. **Cloudflare**: `CF_TOKEN=... cloudflare/setup.sh` — creates the named tunnel (remotely-managed ingress), the `pi` + `*.pi` proxied CNAMEs, the Worker-route exclusions, and saves the tunnel token. 5. **Provision**: `scripts/provision-pi.sh` — installs both systemd services, generates secrets into `/etc/zds.env`, starts everything. 6. Grab the bootstrap invite code from the zds journal; sign up from any AT Protocol client using `pi.chadtmiller.com` as a custom hosting provider. Later rebuilds: `scripts/build.sh && scripts/deploy.sh`. ## ARMv6/32-bit patches (`patches/`) zds and its `zat` dependency assume 64-bit in a few places. All would be reasonable upstream contributions: | Patch | Problem on 32-bit | |---|---| | `zds-src-armv6.patch`: `PortableAtomic` + use in `store.zig`, `eventlog.zig`, `sync.zig` | ARMv6 has no 64-bit atomic instructions; `std.atomic.Value(u64/i64)` fails to compile. Falls back to a yielding spinlock; 64-bit targets compile to the same `std.atomic.Value` as before. | | `zds-src-armv6.patch`: `sync.zig` `nowMillis()` | `ts.sec * 1000` in 32-bit math — overflows instantly in 2026 and **crashed the server at startup**. Real bug, not a port papercut. | | `patch-zat-vendored.py`: `cbor.zig` | Comptime struct-size asserts hard-code 64-bit pointer sizes. | | `patch-zat-vendored.py`: `transport.zig` | Another `std.atomic.Value(u64)` counter. | (The zat edits live in the untracked `zig-pkg/` package cache, hence a patcher script keyed on content patterns instead of a `.patch` file.) ## Hardware gotchas (hard-won) - **The Edimax EW-7811Un (rtl8192cu) cannot join WPA2/WPA3 transition-mode networks.** It fails association with `CONN_FAILED` before the password is even tested; disabling PMF does not help. Router must be WPA2-only. If the router setting ever reverts, the Pi silently drops off the network. - The `rtl8xxxu` alternate driver does **not** support this chip on the RPi kernel — blacklisting `rtl8192cu` removes Wi-Fi entirely. - The dongle is 2.4 GHz-only, and its LED means "radio powered", not "connected". Wi-Fi powersave must be off or SSH sessions drop (`nmcli con modify 802-11-wireless.powersave 2` — already persisted). - cloudflared needs `TimeoutStartSec=300` (`pi/cloudflared-override.conf`); ARMv6 TLS handshakes overrun systemd's default 90 s notify window. - cloud-init re-runs (new Wi-Fi config etc.) require bumping `instance-id` (hyphen!) in boot `meta-data`; the stock file's `instance_id` key is a lie. ## Cloudflare zone quirks A Worker named `pds` owns `chadtmiller.com/*` and `*.chadtmiller.com/*` routes. Worker routes intercept proxied traffic **before** tunnels, so any subdomain that should bypass the Worker needs a null-script exclusion route (`setup.sh` handles this for `pi` and `*.pi`). ## Operations - Health: `curl https://pi.chadtmiller.com/xrpc/_health` - Logs: `ssh pi 'sudo journalctl -u zds -f'` - Wi-Fi watchdog (`pi/wifi-watchdog.*`): systemd timer pings the gateway (falling back to 1.1.1.1) every minute; after 3 consecutive failures it bounces the Wi-Fi via nmcli, after 6 it reboots. A reachable gateway counts as healthy — an upstream ISP outage isn't fixable by rebooting. Installed by `provision-pi.sh`; retrofit an existing Pi with `scripts/install-wifi-watchdog.sh`. Logs: `journalctl -t wifi-watchdog`. - Persistent journal (`pi/journald-persistent.conf`): survives reboots so Wi-Fi drops and watchdog reboots leave evidence, capped at 16 MB for the 4 GB SD card. Installed by `provision-pi.sh`; retrofit with `scripts/install-persistent-journal.sh`. Past boots: `journalctl --list-boots`. - Secrets: `/etc/zds.env` on the Pi (root:600). Admin token lives there. - Data: `/home/chad/zds-data/` (SQLite + blobs) on a 2015-era 4 GB SD card. **No backups yet** — fun-grade, not trust-grade. Set up an off-Pi backup before storing an identity you care about. ## First account (done 2026-08-19) - Handle: `zds.chadtmiller.com` · DID: `did:plc:3ei53t5k67z6i7gnznzb5ls2` - Handles live at the **first level** (`.chadtmiller.com`), not under `.pi.`: Cloudflare's free Universal SSL covers `*.chadtmiller.com` but not `*.pi.chadtmiller.com` (deep wildcards need Advanced Certificate Manager), so `.pi.` handles fail TLS during handle verification. - Creating accounts requires `ZDS_PLC_ROTATION_KEY` in `/etc/zds.env` — a secp256k1 key that signs PLC identity operations. **Back it up off-Pi**; it is the identity control root. - Flow: `com.atproto.server.createAccount` with an invite code, then `com.atproto.identity.updateHandle` if the handle changes. ## Permissioned data (enabled 2026-08-19) `ZDS_PERMISSIONED_DATA=true` in `/etc/zds.env` enables the experimental `com.atproto.space.*` / `com.atproto.simplespace.*` routes (private records, blobs, spaces). Explicitly experimental in zds — a prototype tracking Bluesky proposal 0016/PR #94, whose design is still moving. Expect breaking changes on zds upgrades; don't store anything precious in spaces yet.