From 50ec63a474b795183fd921d08eeebb188dbb4d24 Mon Sep 17 00:00:00 2001 From: Jet Pham Date: Mon, 9 Mar 2026 17:11:10 -0700 Subject: [PATCH] feat: expose configurations, add retry, make stable --- pi/README.md | 70 +++++++++++++ pi/configuration.nix | 21 ++-- pi/flake.lock | 178 ++++++++++++++++++++++++++++++++++ pi/flake.nix | 102 ++++++++++++++++--- pi/hardware-configuration.nix | 9 -- pi/pi-service/.envrc | 1 + pi/pi-service/Cargo.lock | 161 +++--------------------------- pi/pi-service/Cargo.toml | 4 +- pi/pi-service/flake.nix | 4 + pi/pi-service/src/main.rs | 158 +++++++++++++++++++++++------- pi/secrets/secrets.nix | 7 ++ 11 files changed, 494 insertions(+), 221 deletions(-) create mode 100644 pi/README.md create mode 100644 pi/flake.lock create mode 100644 pi/pi-service/.envrc create mode 100644 pi/secrets/secrets.nix diff --git a/pi/README.md b/pi/README.md new file mode 100644 index 0000000..cfa212c --- /dev/null +++ b/pi/README.md @@ -0,0 +1,70 @@ +# noisebell + +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. + +Runs on NixOS with Tailscale for networking and agenix for secrets. + +## Setup + +### 1. Hardware config + +Replace `hardware-configuration.nix` with the output of `nixos-generate-config --show-hardware-config` on your Pi (or use an appropriate hardware module like `sd-card/sd-image-aarch64.nix`). + +### 2. SSH key + +Add your SSH public key to `configuration.nix`: + +```nix +users.users.root.openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAA..." +]; +``` + +### 3. Secrets + +Get your Pi's SSH host public key and put it in `secrets/secrets.nix`: + +```sh +ssh-keyscan | grep ed25519 +``` + +Then create the encrypted secret files: + +```sh +cd secrets +agenix -e endpoint-url.age # paste webhook URL +agenix -e tailscale-auth-key.age # paste Tailscale auth key +``` + +### 4. Deploy + +```sh +nix build .#nixosConfigurations.pi.config.system.build.toplevel +nixos-rebuild switch --flake .#pi --target-host root@noisebell +``` + +## Configuration + +Options under `services.noisebell` in `flake.nix`: + +| Option | Default | Description | +|---|---|---| +| `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 | + +## API + +`GET /` — current door state: + +```json +{"status": "open", "timestamp": 1710000000} +``` + +State changes (and initial state on startup) are POSTed to the configured endpoint in the same format. diff --git a/pi/configuration.nix b/pi/configuration.nix index 3609fd0..f0331b8 100644 --- a/pi/configuration.nix +++ b/pi/configuration.nix @@ -5,35 +5,30 @@ networking.hostName = "noisebell"; - # Enable the noisebell service + # Decrypted at runtime by agenix + age.secrets.endpoint-url.file = ./secrets/endpoint-url.age; + age.secrets.tailscale-auth-key.file = ./secrets/tailscale-auth-key.age; + services.noisebell = { enable = true; - endpointUrl = "https://example.com/webhook"; # TODO: set your endpoint + port = 80; + endpointUrlFile = config.age.secrets.endpoint-url.path; }; - # Basic system config nix.settings.experimental-features = [ "nix-command" "flakes" ]; - # Tailscale - services.tailscale.enable = true; - - # Caddy reverse proxy — proxies to the noisebell status endpoint - services.caddy = { + services.tailscale = { enable = true; - virtualHosts.":80".extraConfig = '' - reverse_proxy localhost:${toString config.services.noisebell.port} - ''; + authKeyFile = config.age.secrets.tailscale-auth-key.path; }; services.openssh.enable = true; - # Only allow traffic from Tailscale interface networking.firewall = { trustedInterfaces = [ "tailscale0" ]; allowedUDPPorts = [ config.services.tailscale.port ]; }; users.users.root.openssh.authorizedKeys.keys = [ - # TODO: add your SSH public key ]; } diff --git a/pi/flake.lock b/pi/flake.lock new file mode 100644 index 0000000..9da3ccd --- /dev/null +++ b/pi/flake.lock @@ -0,0 +1,178 @@ +{ + "nodes": { + "agenix": { + "inputs": { + "darwin": "darwin", + "home-manager": "home-manager", + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems" + }, + "locked": { + "lastModified": 1770165109, + "narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=", + "owner": "ryantm", + "repo": "agenix", + "rev": "b027ee29d959fda4b60b57566d64c98a202e0feb", + "type": "github" + }, + "original": { + "owner": "ryantm", + "repo": "agenix", + "type": "github" + } + }, + "crane": { + "locked": { + "lastModified": 1772560058, + "narHash": "sha256-NuVKdMBJldwUXgghYpzIWJdfeB7ccsu1CC7B+NfSoZ8=", + "owner": "ipetkov", + "repo": "crane", + "rev": "db590d9286ed5ce22017541e36132eab4e8b3045", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "darwin": { + "inputs": { + "nixpkgs": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1744478979, + "narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=", + "owner": "lnl7", + "repo": "nix-darwin", + "rev": "43975d782b418ebf4969e9ccba82466728c2851b", + "type": "github" + }, + "original": { + "owner": "lnl7", + "ref": "master", + "repo": "nix-darwin", + "type": "github" + } + }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1745494811, + "narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1772963539, + "narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9dcb002ca1690658be4a04645215baea8b95f31d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1772963539, + "narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9dcb002ca1690658be4a04645215baea8b95f31d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "noisebell": { + "inputs": { + "crane": "crane", + "nixpkgs": "nixpkgs_2", + "rust-overlay": "rust-overlay" + }, + "locked": { + "path": "./pi-service", + "type": "path" + }, + "original": { + "path": "./pi-service", + "type": "path" + }, + "parent": [] + }, + "root": { + "inputs": { + "agenix": "agenix", + "nixpkgs": "nixpkgs", + "noisebell": "noisebell" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "noisebell", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1773025773, + "narHash": "sha256-Wik8+xApNfldpUFjPmJkPdg0RrvUPSWGIZis+A/0N1w=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "3c06fdbbd36ff60386a1e590ee0cd52dcd1892bf", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/pi/flake.nix b/pi/flake.nix index 170de1e..008ae10 100644 --- a/pi/flake.nix +++ b/pi/flake.nix @@ -4,9 +4,13 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; noisebell.url = "path:./pi-service"; + agenix = { + url = "github:ryantm/agenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { self, nixpkgs, noisebell }: + outputs = { self, nixpkgs, noisebell, agenix }: let nixosModule = { config, lib, pkgs, ... }: let @@ -17,13 +21,13 @@ enable = lib.mkEnableOption "noisebell GPIO door monitor"; gpioPin = lib.mkOption { - type = lib.types.int; + type = lib.types.ints.unsigned; default = 17; description = "GPIO pin number to monitor."; }; debounceSecs = lib.mkOption { - type = lib.types.int; + type = lib.types.ints.positive; default = 5; description = "Debounce delay in seconds."; }; @@ -34,33 +38,104 @@ description = "HTTP port for the status endpoint."; }; - endpointUrl = lib.mkOption { - type = lib.types.str; - description = "URL to POST state changes to."; + endpointUrlFile = lib.mkOption { + type = lib.types.path; + description = "Path to a file containing the endpoint URL (e.g. an agenix secret)."; }; + retryAttempts = lib.mkOption { + type = lib.types.ints.unsigned; + default = 3; + description = "Number of retries after a failed webhook POST."; + }; + + retryBaseDelaySecs = lib.mkOption { + type = lib.types.ints.positive; + default = 1; + description = "Base delay in seconds for exponential backoff between retries."; + }; + + httpTimeoutSecs = lib.mkOption { + type = lib.types.ints.positive; + default = 10; + description = "Timeout in seconds for outbound HTTP requests to the webhook endpoint."; + }; + + bindAddress = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0"; + description = "Address to bind the HTTP server to."; + }; + + activeLow = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether a low GPIO level means open. Set to false if your sensor wiring is inverted."; + }; + + restartDelaySecs = lib.mkOption { + type = lib.types.ints.positive; + default = 5; + description = "Seconds to wait before systemd restarts the service on failure."; + }; }; config = lib.mkIf cfg.enable { - systemd.services.noisebell = { + users.users.noisebell = { + isSystemUser = true; + group = "noisebell"; + extraGroups = [ "gpio" ]; + }; + users.groups.noisebell = {}; + users.groups.gpio = {}; + + services.udev.extraRules = '' + KERNEL=="gpiomem", GROUP="gpio", MODE="0660" + KERNEL=="gpiochip[0-9]*", GROUP="gpio", MODE="0660" + ''; + + systemd.services.noisebell = let + bin = "${noisebell.packages.aarch64-linux.default}/bin/noisebell"; + in { description = "Noisebell GPIO door monitor"; wantedBy = [ "multi-user.target" ]; - after = [ "network-online.target" ]; + after = [ "network-online.target" "tailscaled.service" ]; wants = [ "network-online.target" ]; environment = { NOISEBELL_GPIO_PIN = toString cfg.gpioPin; NOISEBELL_DEBOUNCE_SECS = toString cfg.debounceSecs; NOISEBELL_PORT = toString cfg.port; - NOISEBELL_ENDPOINT_URL = cfg.endpointUrl; + NOISEBELL_RETRY_ATTEMPTS = toString cfg.retryAttempts; + NOISEBELL_RETRY_BASE_DELAY_SECS = toString cfg.retryBaseDelaySecs; + NOISEBELL_HTTP_TIMEOUT_SECS = toString cfg.httpTimeoutSecs; + NOISEBELL_BIND_ADDRESS = cfg.bindAddress; + NOISEBELL_ACTIVE_LOW = if cfg.activeLow then "true" else "false"; + RUST_LOG = "info"; }; + script = '' + export NOISEBELL_ENDPOINT_URL="$(cat ${cfg.endpointUrlFile})" + exec ${bin} + ''; + serviceConfig = { - ExecStart = "${noisebell.packages.aarch64-linux.default}/bin/noisebell"; Restart = "on-failure"; - RestartSec = 5; - DynamicUser = true; - SupplementaryGroups = [ "gpio" ]; + RestartSec = cfg.restartDelaySecs; + User = "noisebell"; + Group = "noisebell"; + AmbientCapabilities = lib.optionals (cfg.port < 1024) [ "CAP_NET_BIND_SERVICE" ]; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = true; + DevicePolicy = "closed"; + DeviceAllow = [ "char-gpiomem rw" "char-gpiochip rw" ]; }; }; }; @@ -72,6 +147,7 @@ nixosConfigurations.pi = nixpkgs.lib.nixosSystem { system = "aarch64-linux"; modules = [ + agenix.nixosModules.default nixosModule ./configuration.nix ./hardware-configuration.nix diff --git a/pi/hardware-configuration.nix b/pi/hardware-configuration.nix index 35dd3ad..ba7d2c6 100644 --- a/pi/hardware-configuration.nix +++ b/pi/hardware-configuration.nix @@ -1,15 +1,6 @@ { config, lib, pkgs, modulesPath, ... }: { - # TODO: Replace this file with the output of `nixos-generate-config --show-hardware-config` - # on your Raspberry Pi, or use an appropriate hardware module. - # - # Example for Raspberry Pi 4: - # - # imports = [ "${modulesPath}/installer/sd-card/sd-image-aarch64.nix" ]; - # - # hardware.enableRedistributableFirmware = true; - imports = [ ]; boot.loader.grub.enable = false; diff --git a/pi/pi-service/.envrc b/pi/pi-service/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/pi/pi-service/.envrc @@ -0,0 +1 @@ +use flake diff --git a/pi/pi-service/Cargo.lock b/pi/pi-service/Cargo.lock index b0929ab..1939f14 100644 --- a/pi/pi-service/Cargo.lock +++ b/pi/pi-service/Cargo.lock @@ -610,9 +610,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "linux-raw-sys" @@ -626,15 +626,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.29" @@ -761,29 +752,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -851,15 +819,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "reqwest" version = "0.12.28" @@ -990,12 +949,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "security-framework" version = "3.7.0" @@ -1106,16 +1059,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "slab" version = "0.4.12" @@ -1130,12 +1073,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1243,9 +1186,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1630,16 +1571,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -1657,31 +1589,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -1690,96 +1605,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/pi/pi-service/Cargo.toml b/pi/pi-service/Cargo.toml index 3d31271..0ae36d1 100644 --- a/pi/pi-service/Cargo.toml +++ b/pi/pi-service/Cargo.toml @@ -10,6 +10,6 @@ reqwest = { version = "0.12", features = ["json"] } rppal = "0.22" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal"] } tracing = "0.1" -tracing-subscriber = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/pi/pi-service/flake.nix b/pi/pi-service/flake.nix index 812dc56..2df2911 100644 --- a/pi/pi-service/flake.nix +++ b/pi/pi-service/flake.nix @@ -56,6 +56,10 @@ { packages.aarch64-linux.default = noisebell; packages.aarch64-linux.noisebell = noisebell; + + devShells.${system}.default = craneLib.devShell { + packages = [ pkgs.rust-analyzer ]; + }; }; in forSystem "x86_64-linux"; diff --git a/pi/pi-service/src/main.rs b/pi/pi-service/src/main.rs index f63f4a6..169125c 100644 --- a/pi/pi-service/src/main.rs +++ b/pi/pi-service/src/main.rs @@ -1,16 +1,29 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use axum::{extract::State, routing::get, Json, Router}; use rppal::gpio::{Gpio, Level, Trigger}; use serde::Serialize; -use tracing::{error, info}; +use tracing::{error, info, warn}; + +struct AppState { + is_open: AtomicBool, + last_changed: AtomicU64, +} #[derive(Serialize)] struct StatusResponse { status: &'static str, + timestamp: u64, +} + +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() } fn status_str(is_open: bool) -> &'static str { @@ -21,15 +34,18 @@ fn status_str(is_open: bool) -> &'static str { } } -async fn get_status(State(is_open): State>) -> Json { +async fn get_status(State(state): State>) -> Json { Json(StatusResponse { - status: status_str(is_open.load(Ordering::Relaxed)), + status: status_str(state.is_open.load(Ordering::Relaxed)), + timestamp: state.last_changed.load(Ordering::Relaxed), }) } #[tokio::main] async fn main() -> Result<()> { - tracing_subscriber::fmt::init(); + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); let gpio_pin: u8 = std::env::var("NOISEBELL_GPIO_PIN") .unwrap_or_else(|_| "17".into()) @@ -49,68 +65,136 @@ async fn main() -> Result<()> { let endpoint_url = std::env::var("NOISEBELL_ENDPOINT_URL").context("NOISEBELL_ENDPOINT_URL is required")?; + let retry_attempts: u32 = std::env::var("NOISEBELL_RETRY_ATTEMPTS") + .unwrap_or_else(|_| "3".into()) + .parse() + .context("NOISEBELL_RETRY_ATTEMPTS must be a valid u32")?; + + let retry_base_delay_secs: u64 = std::env::var("NOISEBELL_RETRY_BASE_DELAY_SECS") + .unwrap_or_else(|_| "1".into()) + .parse() + .context("NOISEBELL_RETRY_BASE_DELAY_SECS must be a valid u64")?; + + let http_timeout_secs: u64 = std::env::var("NOISEBELL_HTTP_TIMEOUT_SECS") + .unwrap_or_else(|_| "10".into()) + .parse() + .context("NOISEBELL_HTTP_TIMEOUT_SECS must be a valid u64")?; + + let bind_address = std::env::var("NOISEBELL_BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0".into()); + + let active_low: bool = std::env::var("NOISEBELL_ACTIVE_LOW") + .unwrap_or_else(|_| "true".into()) + .parse() + .context("NOISEBELL_ACTIVE_LOW must be true or false")?; + info!(gpio_pin, debounce_secs, port, %endpoint_url, "starting noisebell"); let gpio = Gpio::new().context("failed to initialize GPIO")?; let pin = gpio .get(gpio_pin) - .context(format!("failed to get GPIO pin {gpio_pin}"))? - .into_input_pullup(); + .context(format!("failed to get GPIO pin {gpio_pin}"))?; + let pin = if active_low { + pin.into_input_pullup() + } else { + pin.into_input_pulldown() + }; - let is_open = Arc::new(AtomicBool::new(pin.read() == Level::Low)); + let open_level = if active_low { Level::Low } else { Level::High }; + let initial_open = pin.read() == open_level; + let state = Arc::new(AppState { + is_open: AtomicBool::new(initial_open), + last_changed: AtomicU64::new(unix_timestamp()), + }); - info!(initial_status = status_str(is_open.load(Ordering::Relaxed)), "GPIO initialized"); + info!(initial_status = status_str(initial_open), "GPIO initialized"); - // Channel to bridge sync GPIO callback -> async notification task - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, u64)>(); - // Set up async interrupt for state changes - let state_for_interrupt = is_open.clone(); - pin.set_async_interrupt( + // Sync initial state with the cache on startup + let _ = tx.send((initial_open, unix_timestamp())); + + let state_for_interrupt = state.clone(); + // pin must live for the entire program — rppal runs interrupts on a background + // thread tied to the InputPin. If pin drops, the interrupt thread is joined and + // monitoring stops. We move it into a binding that lives until main() returns. + let _pin = pin; + _pin.set_async_interrupt( Trigger::Both, Some(Duration::from_secs(debounce_secs)), move |event| { let new_open = match event.trigger { - Trigger::FallingEdge => true, - Trigger::RisingEdge => false, + Trigger::FallingEdge => active_low, + Trigger::RisingEdge => !active_low, _ => return, }; - let was_open = state_for_interrupt.swap(new_open, Ordering::Relaxed); + let was_open = state_for_interrupt.is_open.swap(new_open, Ordering::Relaxed); if was_open != new_open { - let _ = tx.send(new_open); + let timestamp = unix_timestamp(); + state_for_interrupt.last_changed.store(timestamp, Ordering::Relaxed); + let _ = tx.send((new_open, timestamp)); } }, ) .context("failed to set GPIO interrupt")?; - // Task that POSTs state changes to the endpoint - tokio::spawn(async move { - let client = reqwest::Client::new(); - while let Some(new_open) = rx.recv().await { - let status = status_str(new_open); - info!(status, "state changed"); + let notify_handle = tokio::spawn(async move { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(http_timeout_secs)) + .build() + .expect("failed to build HTTP client"); - if let Err(e) = client - .post(&endpoint_url) - .json(&serde_json::json!({ "status": status })) - .send() - .await - { - error!(%e, "failed to notify endpoint"); + while let Some((new_open, timestamp)) = rx.recv().await { + let status = status_str(new_open); + info!(status, timestamp, "state changed"); + + let payload = serde_json::json!({ "status": status, "timestamp": timestamp }); + + for attempt in 0..=retry_attempts { + let result = client.post(&endpoint_url).json(&payload).send().await; + match result { + Ok(resp) if resp.status().is_success() => break, + _ => { + let err_msg = match &result { + Ok(resp) => format!("HTTP {}", resp.status()), + Err(e) => e.to_string(), + }; + if attempt == retry_attempts { + error!(error = %err_msg, "failed to notify endpoint after {} attempts", retry_attempts + 1); + } else { + let delay = Duration::from_secs( + retry_base_delay_secs * 2u64.pow(attempt), + ); + warn!(error = %err_msg, attempt = attempt + 1, "notify failed, retrying in {:?}", delay); + tokio::time::sleep(delay).await; + } + } + } } } }); let app = Router::new() - .route("/status", get(get_status)) - .with_state(is_open); + .route("/", get(get_status)) + .with_state(state); - let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)) + let listener = tokio::net::TcpListener::bind((&*bind_address, port)) .await - .context(format!("failed to bind to port {port}"))?; + .context(format!("failed to bind to {bind_address}:{port}"))?; info!(port, "listening"); - axum::serve(listener, app).await.context("server error")?; + let shutdown = tokio::signal::ctrl_c(); + axum::serve(listener, app) + .with_graceful_shutdown(async { shutdown.await.ok(); }) + .await + .context("server error")?; + + info!("shutting down, draining notification queue"); + // Drop the interrupt to stop producing new messages, then wait + // for the notification task to drain remaining messages. + drop(_pin); + let _ = notify_handle.await; + + info!("shutdown complete"); Ok(()) } diff --git a/pi/secrets/secrets.nix b/pi/secrets/secrets.nix new file mode 100644 index 0000000..27af76d --- /dev/null +++ b/pi/secrets/secrets.nix @@ -0,0 +1,7 @@ +let + pi = "ssh-ed25519 AAAA..."; # Pi's SSH host public key +in +{ + "endpoint-url.age".publicKeys = [ pi ]; + "tailscale-auth-key.age".publicKeys = [ pi ]; +}