feat: deploy onto the pi and add scripts for boot

This commit is contained in:
Jet 2026-03-22 23:24:05 -07:00
parent f4d95c595e
commit 16ad3c6181
No known key found for this signature in database
13 changed files with 399 additions and 175 deletions

View file

@ -1,145 +1,181 @@
# Pi
Rust service and NixOS config for the Raspberry Pi at Noisebridge. Reads a magnetic door sensor via GPIO, serves the current state over HTTP, and pushes changes to the cache service.
Rust service and deployment workflow for the Raspberry Pi at Noisebridge.
Runs NixOS with Tailscale for remote access and agenix for secrets.
The current recommended setup is:
## How it works
1. run Raspberry Pi OS Lite on the Pi
2. keep the Pi itself free of Nix
3. build a static `aarch64` Noisebell binary on your laptop with Nix
4. copy the binary, secrets, and systemd service to the Pi over SSH
The service watches a GPIO pin for rising/falling edges with a configurable debounce. When the door state changes, it:
This avoids the Raspberry Pi Zero 2 W NixOS boot issues while still keeping the application build reproducible.
1. Updates in-memory state (atomics)
2. POSTs `{"status": "open", "timestamp": ...}` to the cache service with a Bearer token
3. Retries with exponential backoff on failure
## What stays on Raspberry Pi OS
On startup it also syncs the initial state.
- bootloader
- kernel
- firmware
- Wi-Fi and local networking
- SSH base access
- Tailscale package/runtime
- Avahi package/runtime
## Setup
## What Nix manages
### Prerequisites
- building a static `noisebell` binary for `aarch64-linux`
- the exact app binary you deploy
- encrypted secrets in the repo
- repeatable deployment from your laptop
If you're building on an x86_64 machine, you need binfmt emulation for aarch64. On NixOS, add this to your system config and rebuild:
## Initial Pi OS setup
```nix
boot.binfmt.emulatedSystems = [ "aarch64-linux" ];
```
### 1. Flash the SD card
Preferred: one command builds the bootstrap image, writes it to the SD card, and installs the
bootstrap agenix identity onto the boot partition so the full Pi system can come up on first boot:
### 1. Flash Raspberry Pi OS Lite
```sh
nix run .#flash-pi-sd -- /dev/sdX
curl -L "https://downloads.raspberrypi.org/raspios_lite_arm64_latest" | xz -d -c | sudo dd of=/dev/sdb bs=16M conv=fsync status=progress && sync
```
This bootstrap image already includes the normal Noisebell service, Tailscale, and the Pi config.
### 2. Configure the flashed SD card
Manual build if you need it:
Configure it for:
- Wi-Fi on `Noisebridge`
- SSH enabled
- serial enabled if you want a recovery console
The helper script is:
```sh
nix build .#nixosConfigurations.bootstrap.config.system.build.sdImage
dd if=result/sd-image/*.img of=/dev/sdX bs=4M status=progress
sudo scripts/configure-pios-sd.sh /run/media/jet/bootfs /run/media/jet/rootfs
```
Boot the Pi. It connects to the Noisebridge WiFi automatically.
This setup expects SSH key login for user `pi`; it does not configure a password.
### 2. SSH host key
### 3. Boot the Pi and verify SSH
Grab the key and add it to `secrets/secrets.nix`:
After boot, verify SSH works:
```sh
ssh pi@noisebridge-pi.local
```
## Add the Pi host key to age recipients
The deploy flow decrypts secrets locally on your laptop, but the Pi host key should still be a recipient for the Pi-facing secrets so the repo stays accurate.
Grab the Pi host key:
```sh
ssh-keyscan noisebridge-pi.local 2>/dev/null | grep ed25519
```
```nix
# secrets/secrets.nix
let
pi = "ssh-ed25519 AAAA...";
in
{
"api-key.age".publicKeys = [ pi ];
"inbound-api-key.age".publicKeys = [ pi ];
"tailscale-auth-key.age".publicKeys = [ pi ];
}
```
Add that key to `secrets/secrets.nix` for:
### 3. Create secrets
- `pi-to-cache-key.age`
- `cache-to-pi-key.age`
- `tailscale-auth-key.age`
```sh
cd secrets
agenix -e api-key.age # key for POSTing to the cache
agenix -e inbound-api-key.age # key the cache uses to poll us
agenix -e tailscale-auth-key.age # tailscale auth key
```
### 4. Bootstrap agenix identity
The Pi uses a dedicated bootstrap age identity stored at `/boot/noisebell-bootstrap.agekey` to
decrypt its runtime secrets, so first boot does not depend on the machine's freshly generated SSH
host key.
To refresh recipients after changing `secrets/secrets.nix`:
Then refresh recipients if needed:
```sh
cd secrets
agenix -r
```
If you use `nix run .#flash-pi-sd -- /dev/sdX`, this file is installed automatically.
To install the bootstrap identity manually onto a flashed card before first boot:
## Edit secrets
```sh
cd secrets
agenix -d bootstrap-identity.age > /boot/noisebell-bootstrap.agekey
chmod 600 /boot/noisebell-bootstrap.agekey
agenix -e pi-to-cache-key.age
agenix -e cache-to-pi-key.age
agenix -e tailscale-auth-key.age
```
### 5. SSH access
These stay encrypted in git. The deploy script decrypts them locally on your laptop and copies the plaintext files to the Pi as root-only files.
Add your public key to `configuration.nix`:
## Deploy to Raspberry Pi OS
```nix
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAA..."
];
```
### 6. Deploy
After first boot, the Pi should already be running the normal service stack from the flashed image.
Use this only for later updates:
From your laptop:
```sh
nixos-rebuild switch --flake .#pi --target-host root@noisebell
scripts/deploy-pios-pi.sh pi@noisebridge-pi.local
```
## Configuration
If you only know the IP:
Options under `services.noisebell` in `flake.nix`:
```sh
scripts/deploy-pios-pi.sh pi@10.21.x.x
```
| Option | Default | Description |
That script:
1. builds `.#packages.aarch64-linux.noisebell-static` locally
2. decrypts the Pi-facing secrets locally with `agenix`
3. uploads the binary and secrets to the Pi
4. installs Tailscale and Avahi if needed
5. writes `/etc/noisebell/noisebell.env`
6. installs `noisebell.service`
7. enables and starts the service
8. runs `tailscale up` with the decrypted auth key
## Files written on the Pi
The deploy script creates:
- `/opt/noisebell/releases/<timestamp>/noisebell`
- `/opt/noisebell/current` -> current release symlink
- `/etc/noisebell/pi-to-cache-key`
- `/etc/noisebell/cache-to-pi-key`
- `/etc/noisebell/tailscale-auth-key`
- `/etc/noisebell/noisebell.env`
- `/etc/systemd/system/noisebell.service`
All secret files are root-only.
## Tailscale
Tailscale is kept on Raspberry Pi OS rather than NixOS.
The deploy script:
- installs the Tailscale package if missing
- enables `tailscaled`
- runs `tailscale up --auth-key=... --hostname=noisebridge-pi`
So Tailscale stays part of the base OS, while its auth key is still managed as an encrypted `age` secret in this repo.
## Later updates
Normal iteration is just rerunning the deploy script:
```sh
scripts/deploy-pios-pi.sh pi@noisebridge-pi.local
```
That rebuilds the binary locally, uploads a new release, refreshes secrets, and restarts the service.
## Service configuration
The deployed service uses these environment variables:
| Variable | Default | Description |
|---|---|---|
| `endpointUrl` | required | Webhook URL to POST state changes to |
| `apiKeyFile` | required | Outbound API key file (agenix secret) |
| `inboundApiKeyFile` | required | Inbound API key file for GET auth |
| `gpioPin` | `17` | GPIO pin number |
| `debounceSecs` | `5` | Debounce delay in seconds |
| `port` | `8080` | HTTP server port |
| `retryAttempts` | `3` | Webhook retry count |
| `retryBaseDelaySecs` | `1` | Exponential backoff base delay |
| `httpTimeoutSecs` | `10` | Outbound request timeout |
| `bindAddress` | `0.0.0.0` | HTTP bind address |
| `activeLow` | `true` | Low GPIO = door open (depends on wiring) |
| `restartDelaySecs` | `5` | systemd restart delay on failure |
| `watchdogSecs` | `30` | systemd watchdog timeout |
| `NOISEBELL_GPIO_PIN` | `17` | GPIO pin number |
| `NOISEBELL_DEBOUNCE_SECS` | `5` | Debounce delay in seconds |
| `NOISEBELL_PORT` | `80` | HTTP server port |
| `NOISEBELL_ENDPOINT_URL` | required | Webhook URL to POST state changes to |
| `NOISEBELL_RETRY_ATTEMPTS` | `3` | Webhook retry count |
| `NOISEBELL_RETRY_BASE_DELAY_SECS` | `1` | Exponential backoff base delay |
| `NOISEBELL_HTTP_TIMEOUT_SECS` | `10` | Outbound request timeout |
| `NOISEBELL_BIND_ADDRESS` | `0.0.0.0` | HTTP bind address |
| `NOISEBELL_ACTIVE_LOW` | `true` | Low GPIO = door open |
## API
All endpoints require `Authorization: Bearer <token>`.
**`GET /`** — door state
**`GET /`**
```json
{"status": "open", "timestamp": 1710000000}