diff --git a/README.md b/README.md index 7ebd2e9..f43ffd8 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,20 @@ # Noisebell -Door status monitor for [Noisebridge](https://www.noisebridge.net). A Raspberry Pi watches a door sensor and reports open/closed state; remote services cache the data and fan it out to Discord and an Atom feed. +Monitors the door at [Noisebridge](https://www.noisebridge.net) and tells you whether it's open or closed. -## Architecture +A Raspberry Pi reads a magnetic sensor on the door and pushes state changes to a cache server. The cache fans updates out to Discord and an Atom feed. ``` -Pi (door sensor) ──webhook──▸ Cache Service ──webhook──▸ Discord Bot - │ │ - polls Pi ◂──┘ Atom feed ◂──┘ - RSS Service +Pi (door sensor) ──webhook──> Cache ──webhook──> Discord + | + polls Pi <-+ RSS reads from Cache ``` -The **Pi** runs a small HTTP service that reads GPIO and exposes `GET /` (status) and `GET /info`. It also pushes state changes to the cache service via webhook. +## Layout -The **Cache Service** is the central hub. It polls the Pi for status and info, stores everything in SQLite, and forwards state changes to downstream webhooks (Discord, etc.). It exposes a read API for consumers. +| Directory | What it is | +|-----------|------------| +| [`pi/`](pi/) | NixOS config + Rust service for the Pi | +| [`remote/`](remote/) | Server-side services (cache, discord bot, rss feed) | -The **Discord Bot** receives webhooks from the cache service and posts embeds to a Discord channel. - -The **RSS Service** fetches history from the cache service and serves an Atom feed of door events from the last 7 days. - -## Services - -| Service | Crate | Default Port | Description | -|---------|-------|-------------|-------------| -| Cache | `noisebell-cache` | 3000 | Polls Pi, caches state in SQLite, forwards webhooks | -| Discord | `noisebell-discord` | 3001 | Posts door status embeds to Discord | -| RSS | `noisebell-rss` | 3002 | Serves Atom feed of recent door events | - -## Cache API - -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| `GET` | `/status` | None | Current door status (`status`, `timestamp`, `last_seen`) | -| `GET` | `/info` | None | Pi system info (JSON blob) | -| `GET` | `/history` | None | Last 100 state changes | -| `POST` | `/webhook` | Bearer token | Inbound webhook from Pi | -| `GET` | `/health` | None | Returns `200 OK` | - -## Building - -All remote services live in a Cargo workspace under `remote/`. - -```sh -cd remote -cargo build --release -``` - -Or with Nix: - -```sh -cd remote -nix build .#noisebell-cache -nix build .#noisebell-discord -nix build .#noisebell-rss -``` - -## NixOS Configuration - -The flake at `remote/flake.nix` exports NixOS modules. Example configuration: - -```nix -{ - inputs.noisebell.url = "git+https://git.extremist.software/jet/noisebell?dir=remote"; - - outputs = { self, nixpkgs, noisebell, ... }: { - nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { - system = "x86_64-linux"; - modules = [ - noisebell.nixosModules.default - ({ ... }: { - services.noisebell-cache = { - enable = true; - domain = "cache.noisebell.example.com"; - piAddress = "http://noisebell-pi:80"; - piApiKeyFile = "/run/secrets/noisebell-pi-api-key"; - inboundApiKeyFile = "/run/secrets/noisebell-inbound-api-key"; - outboundWebhooks = [ - { - url = "http://localhost:3001/webhook"; - secretFile = "/run/secrets/noisebell-discord-webhook-secret"; - } - ]; - }; - - services.noisebell-discord = { - enable = true; - domain = "discord.noisebell.example.com"; - discordTokenFile = "/run/secrets/noisebell-discord-token"; - channelId = "123456789012345678"; - webhookSecretFile = "/run/secrets/noisebell-discord-webhook-secret"; - }; - - services.noisebell-rss = { - enable = true; - domain = "rss.noisebell.example.com"; - cacheUrl = "http://localhost:3000"; - }; - }) - ]; - }; - }; -} -``` - -## Secrets - -| Secret | Used by | Description | -|--------|---------|-------------| -| `piApiKeyFile` | Cache | Bearer token for authenticating to Pi GET endpoints | -| `inboundApiKeyFile` | Cache | Bearer token the Pi uses when POSTing to `/webhook` | -| `outboundWebhooks[].secretFile` | Cache | Bearer token sent with outbound webhook POSTs | -| `discordTokenFile` | Discord | Discord bot token | -| `webhookSecretFile` | Discord | Must match the cache's outbound webhook secret | +Each directory has its own README with setup and configuration details. diff --git a/pi/README.md b/pi/README.md index 93911a1..d6fe011 100644 --- a/pi/README.md +++ b/pi/README.md @@ -1,39 +1,41 @@ -# noisebell +# Pi -Monitors a GPIO pin on a Raspberry Pi to detect door open/close events. State changes get POSTed to a webhook endpoint. Current state is available over HTTP. +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. -Runs on NixOS with Tailscale for networking and agenix for secrets. +Runs NixOS with Tailscale for remote access and agenix for secrets. + +## How it works + +The service watches a GPIO pin for rising/falling edges with a configurable debounce. When the door state changes, it: + +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 + +On startup it also syncs the initial state. ## Setup -### 1. Bootstrap - -Build the SD image, flash it, and boot the Pi: +### 1. Flash the SD card ```sh nix build .#nixosConfigurations.bootstrap.config.system.build.sdImage dd if=result/sd-image/*.img of=/dev/sdX bs=4M status=progress ``` -Insert the SD card into the Pi and power it on. It will connect to the Noisebridge WiFi network automatically. +Boot the Pi. It connects to the Noisebridge WiFi automatically. ### 2. Find the Pi -Once booted, find the Pi on the network: - ```sh -# Scan the local subnet nmap -sn 192.168.1.0/24 - -# Or check ARP table +# or arp -a - -# Or check your router's DHCP leases ``` -### 3. Get SSH host key +### 3. SSH host key -Grab the Pi's ed25519 host key and put it in `secrets/secrets.nix`: +Grab the key and add it to `secrets/secrets.nix`: ```sh ssh-keyscan | grep ed25519 @@ -42,7 +44,7 @@ ssh-keyscan | grep ed25519 ```nix # secrets/secrets.nix let - pi = "ssh-ed25519 AAAA..."; # paste the key here + pi = "ssh-ed25519 AAAA..."; in { "api-key.age".publicKeys = [ pi ]; @@ -51,20 +53,18 @@ in } ``` -### 4. Secrets - -Create the encrypted secret files: +### 4. Create secrets ```sh cd secrets -agenix -e api-key.age # paste API key for the cache endpoint -agenix -e inbound-api-key.age # paste API key that the cache uses to poll the Pi -agenix -e tailscale-auth-key.age # paste Tailscale auth key +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 ``` -### 5. Add SSH key +### 5. SSH access -Add your SSH public key to `configuration.nix`: +Add your public key to `configuration.nix`: ```nix users.users.root.openssh.authorizedKeys.keys = [ @@ -84,29 +84,31 @@ Options under `services.noisebell` in `flake.nix`: | Option | Default | Description | |---|---|---| -| `endpointUrl` | — | Webhook endpoint URL to POST state changes to | -| `apiKeyFile` | — | Path to file containing outbound API key (agenix secret) | -| `inboundApiKeyFile` | — | Path to file containing inbound API key for GET endpoint auth (agenix secret) | -| `gpioPin` | 17 | GPIO pin to monitor | -| `debounceSecs` | 5 | Debounce delay | -| `port` | 8080 | HTTP status port | -| `retryAttempts` | 3 | Webhook retry count | -| `retryBaseDelaySecs` | 1 | Base delay for exponential backoff | -| `httpTimeoutSecs` | 10 | Timeout for outbound webhook requests | -| `bindAddress` | `0.0.0.0` | Address to bind the HTTP server to | -| `activeLow` | `true` | Whether low GPIO level means open (depends on wiring) | -| `restartDelaySecs` | 5 | Seconds before systemd restarts on failure | -| `watchdogSecs` | 30 | Watchdog timeout — service is restarted if unresponsive | +| `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 | ## API -`GET /` — current door state: +All endpoints require `Authorization: Bearer `. + +**`GET /`** — door state ```json {"status": "open", "timestamp": 1710000000} ``` -`GET /info` — system health and GPIO config: +**`GET /info`** — system health + GPIO config ```json { @@ -129,5 +131,3 @@ Options under `services.noisebell` in `flake.nix`: } } ``` - -State changes (and initial state on startup) are POSTed to the configured endpoint in the same format as `GET /`, with an `Authorization: Bearer ` header. diff --git a/remote/README.md b/remote/README.md new file mode 100644 index 0000000..fea192b --- /dev/null +++ b/remote/README.md @@ -0,0 +1,70 @@ +# Remote Services + +Cargo workspace with the server-side pieces of Noisebell. Runs on any Linux box. + +| Service | Port | What it does | +|---------|------|--------------| +| [`cache-service/`](cache-service/) | 3000 | Polls the Pi, stores history in SQLite, fans out webhooks | +| [`discord-bot/`](discord-bot/) | 3001 | Posts door status to a Discord channel | +| [`rss-service/`](rss-service/) | 3002 | Serves an Atom feed of door events | +| [`noisebell-common/`](noisebell-common/) | — | Shared types and helpers | + +See each service's README for configuration and API docs. + +## Building + +```sh +cargo build --release +``` + +Or with Nix: + +```sh +nix build .#noisebell-cache +nix build .#noisebell-discord +nix build .#noisebell-rss +``` + +## NixOS deployment + +The flake exports NixOS modules. Each service runs as a hardened systemd unit behind Caddy. + +```nix +{ + inputs.noisebell.url = "git+https://git.extremist.software/jet/noisebell?dir=remote"; + + outputs = { self, nixpkgs, noisebell, ... }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + noisebell.nixosModules.default + ({ ... }: { + services.noisebell-cache = { + enable = true; + domain = "cache.noisebell.example.com"; + piAddress = "http://noisebell-pi:80"; + piApiKeyFile = "/run/secrets/noisebell-pi-api-key"; + inboundApiKeyFile = "/run/secrets/noisebell-inbound-api-key"; + outboundWebhooks = [{ + url = "http://localhost:3001/webhook"; + secretFile = "/run/secrets/noisebell-discord-webhook-secret"; + }]; + }; + services.noisebell-discord = { + enable = true; + domain = "discord.noisebell.example.com"; + discordTokenFile = "/run/secrets/noisebell-discord-token"; + channelId = "123456789012345678"; + webhookSecretFile = "/run/secrets/noisebell-discord-webhook-secret"; + }; + services.noisebell-rss = { + enable = true; + domain = "rss.noisebell.example.com"; + cacheUrl = "http://localhost:3000"; + }; + }) + ]; + }; + }; +} +``` diff --git a/remote/cache-service/README.md b/remote/cache-service/README.md new file mode 100644 index 0000000..a043405 --- /dev/null +++ b/remote/cache-service/README.md @@ -0,0 +1,45 @@ +# Cache Service + +The central hub. Sits between the Pi and everything else. + +It does two things: polls the Pi on a timer to keep a local copy of the door state, and receives push webhooks from the Pi when the state actually changes. Either way, updates get written to SQLite and forwarded to downstream services (Discord, etc.) via outbound webhooks. + +If the Pi stops responding to polls (configurable threshold, default 3 misses), the cache marks it as offline and notifies downstream. + +## API + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/status` | — | Current door status (`status`, `timestamp`, `last_seen`) | +| `GET` | `/info` | — | Cached Pi system info | +| `GET` | `/history` | — | Last 100 state changes | +| `POST` | `/webhook` | Bearer | Inbound webhook from the Pi | +| `GET` | `/health` | — | Health check | + +## Configuration + +NixOS options under `services.noisebell-cache`: + +| Option | Default | Description | +|--------|---------|-------------| +| `domain` | required | Caddy virtual host domain | +| `piAddress` | required | Pi URL (e.g. `http://noisebell:80`) | +| `piApiKeyFile` | required | Key file for authenticating to the Pi | +| `inboundApiKeyFile` | required | Key file for validating inbound webhooks | +| `port` | `3000` | Listen port | +| `statusPollIntervalSecs` | `60` | How often to poll `GET /` on the Pi | +| `infoPollIntervalSecs` | `300` | How often to poll `GET /info` on the Pi | +| `offlineThreshold` | `3` | Consecutive failed polls before marking offline | +| `retryAttempts` | `3` | Outbound webhook retry count | +| `retryBaseDelaySecs` | `1` | Exponential backoff base delay | +| `httpTimeoutSecs` | `10` | HTTP request timeout | +| `dataDir` | `/var/lib/noisebell-cache` | SQLite database location | +| `outboundWebhooks` | `[]` | List of `{url, secretFile}` for downstream services | + +## Secrets + +| Secret | What it's for | +|--------|---------------| +| `piApiKeyFile` | Bearer token to poll the Pi's GET endpoints | +| `inboundApiKeyFile` | Bearer token the Pi sends when POSTing to `/webhook` | +| `outboundWebhooks[].secretFile` | Bearer token sent with outbound webhook POSTs | diff --git a/remote/discord-bot/README.md b/remote/discord-bot/README.md new file mode 100644 index 0000000..443c4b2 --- /dev/null +++ b/remote/discord-bot/README.md @@ -0,0 +1,28 @@ +# Discord Bot + +Receives webhooks from the cache service and posts door status updates to a Discord channel as embeds. + +- Green embed = door open +- Red embed = door closed +- Gray embed = Pi offline + +Validates inbound webhooks with a Bearer token. Maintains a persistent Discord gateway connection with automatic reconnection. + +## API + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `POST` | `/webhook` | Bearer | Status update from the cache service | +| `GET` | `/health` | — | Health check | + +## Configuration + +NixOS options under `services.noisebell-discord`: + +| Option | Default | Description | +|--------|---------|-------------| +| `domain` | required | Caddy virtual host domain | +| `discordTokenFile` | required | Discord bot token file | +| `channelId` | required | Discord channel ID to post to | +| `webhookSecretFile` | required | Shared secret with the cache service | +| `port` | `3001` | Listen port | diff --git a/remote/rss-service/README.md b/remote/rss-service/README.md new file mode 100644 index 0000000..230ca40 --- /dev/null +++ b/remote/rss-service/README.md @@ -0,0 +1,20 @@ +# RSS Service + +Serves an Atom feed of door status history. Stateless — it fetches from the cache service's `/history` endpoint on each request and renders the last 7 days as Atom XML. + +## API + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/feed` | Atom feed | +| `GET` | `/health` | Health check | + +## Configuration + +NixOS options under `services.noisebell-rss`: + +| Option | Default | Description | +|--------|---------|-------------| +| `domain` | required | Caddy virtual host domain | +| `cacheUrl` | required | Cache service URL (e.g. `http://localhost:3000`) | +| `port` | `3002` | Listen port |