Compare commits

...

10 commits

Author SHA1 Message Date
Jet Pham
68c4f8a1fc
feat: add remote, with rss, cache, discord, and zulip 2026-03-09 23:10:45 -07:00
Jet Pham
50ec63a474
feat: expose configurations, add retry, make stable 2026-03-09 17:11:22 -07:00
Jet Pham
c6e726c430
feat: rewrite pi to be simple and nix based 2026-03-09 15:48:45 -07:00
Jet Pham
b2c8d08bdc
feat: remove vercel web frontend part and rename to pi 2026-03-03 22:25:44 -08:00
Jet Pham
dff2e96947
Update Main to monorepo structure (#5)
* feat: Convert to a webhooks api model!

feat: Update readme with new api docs and images and logo

feat: reoptimize jpgs and add comments to all images for credit

feat: Add database backend implementations
Todo is to update the readme

feat: use memory storage for endpoints

feat: add logging to rest api and remove ctrl override

feat: remove keyboard monitor

delete the discord api from direct reference

* feat: webhook sending with retries and backoff

Also some great readme changes

* feat: add a web based dev mode!

* feat: better error handling for webhook endopoints

* feat: remove verbose logs

* feat: add docs for local dev

* feat: remove complex webhook stuff
config file with endpoints listed instead

* feat: update logo

* feat: set endpoint and remove rest api

* fix: check for negative config numbers

* feat: remove timestamps from webhook
Use Date header instead

* feat: refactor to using one endpoint with env vars

* feat: change logging to be one rolling log
With a max line size of 10k lines

* feat: move config to toml, keep api in env var

* feat: use .env files for managing env vars

* fix: remove log files from dev

* fix: unblock web monitor thread

* feat: merge into a monorepo with noisebridge-status
2025-08-05 00:33:41 -05:00
Jet Pham
716153b1b6
feat: add tokio runtime and make adync gpio interups on seperate thread 2025-06-11 15:10:53 -07:00
Jet Pham
19862ecf70
feat: use async interupts for gpio monitoring 2025-06-08 00:13:58 -07:00
Jet Pham
bcf986ff1f
feat: move logs to new module 2025-06-08 00:13:58 -07:00
Jet Pham
2a7e30708a
feat: update readme to reflect current state of the project 2025-06-07 12:48:44 -07:00
Jet Pham
3f519376b2
feat: add a finite state machine for debouncing 2025-06-07 12:42:13 -07:00
54 changed files with 11591 additions and 1457 deletions

2
.gitignore vendored
View file

@ -1,2 +0,0 @@
/target
noisebell.service

View file

@ -1,14 +0,0 @@
[package]
name = "noisebell"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
tokio = { version = "1.36", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"
rppal = "0.22"
serde = { version = "1.0", features = ["derive"] }
tracing-appender = "0.2"
serenity = { version = "0.12", features = ["standard_framework"] }

107
README.md
View file

@ -1,107 +0,0 @@
# Noisebell
A switch monitoring system that detects circuit state changes via GPIO and sends webhook notifications to configured endpoints.
This is build by Jet Pham to be used at Noisebridge to replace their old discord status bot
## Features
- GPIO circuit monitoring with configurable pin
- Webhook notifications with retry mechanism
- REST API for managing webhook endpoints
- Daily rotating log files
- Cross-compilation support for Raspberry Pi deployment
## Requirements
- Rust toolchain
- Raspberry Pi (tested on aarch64)
- For development: Cross-compilation tools (for `cross` command)
## Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/noisebell.git
cd noisebell
```
2. Build the project:
```bash
cargo build --release
```
## Configuration
### GPIO Pin
The default GPIO pin is set to 17. You can modify this in `src/main.rs`.
### Webhook Endpoints
Webhook endpoints are stored in `endpoints.json`. The file should follow this format:
```json
{
"endpoints": [
{
"url": "https://your-webhook-url.com",
"description": "Description of this endpoint"
}
]
}
```
## Usage
1. Start the server:
```bash
./target/release/noisebell
```
The server will:
- Start listening on `127.0.0.1:8080`
- Begin monitoring the configured GPIO pin
- Send webhook notifications when circuit state changes
### API Endpoints
#### Add Webhook Endpoint
```bash
curl -X POST http://localhost:8080/endpoints \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-webhook-url.com",
"description": "My webhook"
}'
```
### Webhook Payload Format
When a circuit state change is detected, the following JSON payload is sent to all configured endpoints:
```json
{
"event_type": "circuit_state_change",
"timestamp": "2024-03-21T12:34:56Z",
"new_state": "open" // or "closed"
}
```
## Deployment
The project includes a deployment script for Raspberry Pi. To deploy:
1. Ensure you have cross-compilation tools installed:
```bash
cargo install cross
```
2. Run the deployment script:
```bash
./deploy.sh
```
This will:
- Cross-compile the project for aarch64
- Copy the binary and configuration to your Raspberry Pi
- Set appropriate permissions
## Logging
Logs are stored in the `logs` directory with daily rotation for the past 7 days

View file

@ -1,58 +0,0 @@
#!/bin/bash
# Exit on error
set -e
echo "Building for Raspberry Pi..."
cross build --release --target aarch64-unknown-linux-gnu
# Check if Discord credentials are already set
if [ -z "$DISCORD_TOKEN" ]; then
echo "Please enter your Discord bot token:"
read -s DISCORD_TOKEN
fi
if [ -z "$DISCORD_CHANNEL_ID" ]; then
echo "Please enter your Discord channel ID:"
read -s DISCORD_CHANNEL_ID
fi
# Create service file with credentials
cat > noisebell.service << EOL
[Unit]
Description=Noisebell Discord Notification Service
After=network.target
[Service]
Type=simple
User=noisebridge
WorkingDirectory=/home/noisebridge/noisebell
Environment=DISCORD_TOKEN=${DISCORD_TOKEN}
Environment=DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID}
ExecStart=/home/noisebridge/noisebell/noisebell
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOL
echo "Copying to Raspberry Pi..."
# Stop the service if it's running
ssh noisebridge@noisebell.local "sudo systemctl stop noisebell || true"
sleep 1
# Copy files
scp target/aarch64-unknown-linux-gnu/release/noisebell noisebridge@noisebell.local:~/noisebell/
scp noisebell.service noisebridge@noisebell.local:~/noisebell/
echo "Setting up service..."
ssh noisebridge@noisebell.local "sudo cp ~/noisebell/noisebell.service /etc/systemd/system/ && \
sudo systemctl daemon-reload && \
sudo systemctl enable noisebell && \
sudo systemctl restart noisebell"
# Clean up local service file
rm noisebell.service
echo "Deployment complete!"
echo "You can check the service status with: ssh noisebridge@noisebell.local 'sudo systemctl status noisebell'"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

133
pi/README.md Normal file
View file

@ -0,0 +1,133 @@
# 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. Bootstrap
Build the SD image, flash it, and boot the Pi:
```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.
### 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
arp -a
# Or check your router's DHCP leases
```
### 3. Get SSH host key
Grab the Pi's ed25519 host key and put it in `secrets/secrets.nix`:
```sh
ssh-keyscan <pi-ip> | grep ed25519
```
```nix
# secrets/secrets.nix
let
pi = "ssh-ed25519 AAAA..."; # paste the key here
in
{
"api-key.age".publicKeys = [ pi ];
"inbound-api-key.age".publicKeys = [ pi ];
"tailscale-auth-key.age".publicKeys = [ pi ];
}
```
### 4. Secrets
Create the encrypted secret files:
```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
```
### 5. Add SSH key
Add your SSH public key to `configuration.nix`:
```nix
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAA..."
];
```
### 6. Deploy
```sh
nixos-rebuild switch --flake .#pi --target-host root@noisebell
```
## Configuration
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 |
## API
`GET /` — current door state:
```json
{"status": "open", "timestamp": 1710000000}
```
`GET /info` — system health and GPIO config:
```json
{
"uptime_secs": 3600,
"started_at": 1710000000,
"cpu_temp_celsius": 42.3,
"memory_available_kb": 350000,
"memory_total_kb": 512000,
"disk_total_bytes": 16000000000,
"disk_available_bytes": 12000000000,
"load_average": [0.01, 0.05, 0.10],
"nixos_version": "24.11.20240308.9dcb002",
"commit": "c6e726c",
"gpio": {
"pin": 17,
"active_low": true,
"pull": "up",
"open_level": "low",
"current_raw_level": "low"
}
}
```
State changes (and initial state on startup) are POSTed to the configured endpoint in the same format as `GET /`, with an `Authorization: Bearer <api-key>` header.

23
pi/bootstrap.nix Normal file
View file

@ -0,0 +1,23 @@
{ modulesPath, ... }:
{
imports = [ "${modulesPath}/installer/sd-card/sd-image-aarch64.nix" ];
hardware.enableRedistributableFirmware = true;
networking.hostName = "noisebell";
networking.wireless = {
enable = true;
networks = {
"Noisebridge" = {
psk = "noisebridge";
};
};
};
services.openssh.enable = true;
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE40ISu3ydCqfdpb26JYD5cIN0Fu0id/FDS+xjB5zpqu"
];
}

38
pi/configuration.nix Normal file
View file

@ -0,0 +1,38 @@
{ config, pkgs, ... }:
{
system.stateVersion = "24.11";
networking.hostName = "noisebell";
# Decrypted at runtime by agenix
age.secrets.tailscale-auth-key.file = ./secrets/tailscale-auth-key.age;
age.secrets.api-key.file = ./secrets/api-key.age;
age.secrets.inbound-api-key.file = ./secrets/inbound-api-key.age;
services.noisebell = {
enable = true;
port = 80;
endpointUrl = "https://noisebell.extremist.software/webhook";
apiKeyFile = config.age.secrets.api-key.path;
inboundApiKeyFile = config.age.secrets.inbound-api-key.path;
};
nix.settings.experimental-features = [ "nix-command" "flakes" ];
services.tailscale = {
enable = true;
authKeyFile = config.age.secrets.tailscale-auth-key.path;
};
services.openssh.enable = true;
networking.firewall = {
trustedInterfaces = [ "tailscale0" ];
allowedUDPPorts = [ config.services.tailscale.port ];
};
users.users.root.openssh.authorizedKeys.keys = [
];
}

178
pi/flake.lock generated Normal file
View file

@ -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
}

184
pi/flake.nix Normal file
View file

@ -0,0 +1,184 @@
{
description = "NixOS configuration for noisebell Pi";
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, agenix }:
let
nixosModule = { config, lib, pkgs, ... }:
let
cfg = config.services.noisebell;
in
{
options.services.noisebell = {
enable = lib.mkEnableOption "noisebell GPIO door monitor";
gpioPin = lib.mkOption {
type = lib.types.ints.unsigned;
default = 17;
description = "GPIO pin number to monitor.";
};
debounceSecs = lib.mkOption {
type = lib.types.ints.positive;
default = 5;
description = "Debounce delay in seconds.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "HTTP port for the status endpoint.";
};
endpointUrl = lib.mkOption {
type = lib.types.str;
description = "Webhook endpoint URL to POST state changes to.";
};
apiKeyFile = lib.mkOption {
type = lib.types.path;
description = "Path to a file containing the outbound API key for the cache endpoint.";
};
inboundApiKeyFile = lib.mkOption {
type = lib.types.path;
description = "Path to a file containing the inbound API key for authenticating GET requests.";
};
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.";
};
watchdogSecs = lib.mkOption {
type = lib.types.ints.positive;
default = 30;
description = "Watchdog timeout in seconds. The service is restarted if it fails to notify systemd within this interval.";
};
};
config = lib.mkIf cfg.enable {
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" "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_RETRY_ATTEMPTS = toString cfg.retryAttempts;
NOISEBELL_RETRY_BASE_DELAY_SECS = toString cfg.retryBaseDelaySecs;
NOISEBELL_HTTP_TIMEOUT_SECS = toString cfg.httpTimeoutSecs;
NOISEBELL_ENDPOINT_URL = cfg.endpointUrl;
NOISEBELL_BIND_ADDRESS = cfg.bindAddress;
NOISEBELL_ACTIVE_LOW = if cfg.activeLow then "true" else "false";
NOISEBELL_COMMIT = self.shortRev or "dirty";
RUST_LOG = "info";
};
script = ''
export NOISEBELL_API_KEY="$(cat ${cfg.apiKeyFile})"
export NOISEBELL_INBOUND_API_KEY="$(cat ${cfg.inboundApiKeyFile})"
exec ${bin}
'';
serviceConfig = {
Type = "notify";
NotifyAccess = "all";
WatchdogSec = cfg.watchdogSecs;
Restart = "on-failure";
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" ];
};
};
};
};
in
{
nixosModules.default = nixosModule;
nixosConfigurations.pi = nixpkgs.lib.nixosSystem {
system = "aarch64-linux";
modules = [
agenix.nixosModules.default
nixosModule
./configuration.nix
./hardware-configuration.nix
];
};
nixosConfigurations.bootstrap = nixpkgs.lib.nixosSystem {
system = "aarch64-linux";
modules = [ ./bootstrap.nix ];
};
};
}

View file

@ -0,0 +1,13 @@
{ config, lib, pkgs, modulesPath, ... }:
{
imports = [ ];
boot.loader.grub.enable = false;
boot.loader.generic-extlinux-compatible.enable = true;
fileSystems."/" = {
device = "/dev/disk/by-label/NIXOS_SD";
fsType = "ext4";
};
}

1
pi/pi-service/.envrc Normal file
View file

@ -0,0 +1 @@
use flake

1831
pi/pi-service/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

17
pi/pi-service/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "noisebell"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
axum = "0.8"
libc = "0.2"
reqwest = { version = "0.12", features = ["json"] }
rppal = "0.22"
sd-notify = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

66
pi/pi-service/flake.nix Normal file
View file

@ -0,0 +1,66 @@
{
description = "Noisebell - GPIO door monitor service";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, crane, rust-overlay }:
let
forSystem = system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
crossPkgs = import nixpkgs {
inherit system;
crossSystem.config = "aarch64-unknown-linux-gnu";
overlays = [ rust-overlay.overlays.default ];
};
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
targets = [ "aarch64-unknown-linux-gnu" ];
};
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
src = craneLib.cleanCargoSource ./.;
commonArgs = {
inherit src;
strictDeps = true;
doCheck = false;
CARGO_BUILD_TARGET = "aarch64-unknown-linux-gnu";
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER =
"${crossPkgs.stdenv.cc.targetPrefix}cc";
HOST_CC = "${pkgs.stdenv.cc.nativePrefix}cc";
depsBuildBuild = [ crossPkgs.stdenv.cc ];
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
noisebell = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
in
{
packages.aarch64-linux.default = noisebell;
packages.aarch64-linux.noisebell = noisebell;
devShells.${system}.default = craneLib.devShell {
packages = [ pkgs.rust-analyzer ];
};
};
in
forSystem "x86_64-linux";
}

373
pi/pi-service/src/main.rs Normal file
View file

@ -0,0 +1,373 @@
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::get;
use axum::{Json, Router};
use rppal::gpio::{Gpio, Level, Trigger};
use serde::Serialize;
use tracing::{error, info, warn};
struct AppState {
is_open: AtomicBool,
last_changed: AtomicU64,
started_at: u64,
gpio_pin: u8,
active_low: bool,
commit: String,
inbound_api_key: String,
}
fn validate_bearer(headers: &HeaderMap, expected: &str) -> bool {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or("") == expected)
.unwrap_or(false)
}
#[derive(Serialize)]
struct StatusResponse {
status: &'static str,
timestamp: u64,
}
#[derive(Serialize)]
struct GpioInfo {
pin: u8,
active_low: bool,
pull: &'static str,
open_level: &'static str,
current_raw_level: &'static str,
}
#[derive(Serialize)]
struct InfoResponse {
uptime_secs: u64,
started_at: u64,
cpu_temp_celsius: Option<f64>,
memory_available_kb: Option<u64>,
memory_total_kb: Option<u64>,
disk_total_bytes: Option<u64>,
disk_available_bytes: Option<u64>,
load_average: Option<[f64; 3]>,
nixos_version: Option<String>,
commit: String,
gpio: GpioInfo,
}
fn unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn status_str(is_open: bool) -> &'static str {
if is_open {
"open"
} else {
"closed"
}
}
fn read_cpu_temp() -> Option<f64> {
std::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp")
.ok()
.and_then(|s| s.trim().parse::<f64>().ok())
.map(|m| m / 1000.0)
}
fn read_meminfo_field(contents: &str, field: &str) -> Option<u64> {
contents
.lines()
.find(|l| l.starts_with(field))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|v| v.parse().ok())
}
fn read_disk_usage() -> Option<(u64, u64)> {
let path = std::ffi::CString::new("/").ok()?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
let ret = unsafe { libc::statvfs(path.as_ptr(), &mut stat) };
if ret != 0 {
return None;
}
let block_size = stat.f_frsize as u64;
Some((
stat.f_blocks * block_size,
stat.f_bavail * block_size,
))
}
fn read_load_average() -> Option<[f64; 3]> {
let contents = std::fs::read_to_string("/proc/loadavg").ok()?;
let mut parts = contents.split_whitespace();
Some([
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
])
}
fn read_nixos_version() -> Option<String> {
std::fs::read_to_string("/run/current-system/nixos-version")
.ok()
.map(|s| s.trim().to_string())
}
async fn get_status(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<StatusResponse>, StatusCode> {
if !validate_bearer(&headers, &state.inbound_api_key) {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(Json(StatusResponse {
status: status_str(state.is_open.load(Ordering::Relaxed)),
timestamp: state.last_changed.load(Ordering::Relaxed),
}))
}
async fn get_info(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<InfoResponse>, StatusCode> {
if !validate_bearer(&headers, &state.inbound_api_key) {
return Err(StatusCode::UNAUTHORIZED);
}
let meminfo = std::fs::read_to_string("/proc/meminfo").ok();
let disk = read_disk_usage();
let is_open = state.is_open.load(Ordering::Relaxed);
let raw_level = match (state.active_low, is_open) {
(true, true) | (false, false) => "low",
_ => "high",
};
Ok(Json(InfoResponse {
uptime_secs: unix_timestamp() - state.started_at,
started_at: state.started_at,
cpu_temp_celsius: read_cpu_temp(),
memory_available_kb: meminfo
.as_deref()
.and_then(|m| read_meminfo_field(m, "MemAvailable:")),
memory_total_kb: meminfo
.as_deref()
.and_then(|m| read_meminfo_field(m, "MemTotal:")),
disk_total_bytes: disk.map(|(total, _)| total),
disk_available_bytes: disk.map(|(_, avail)| avail),
load_average: read_load_average(),
nixos_version: read_nixos_version(),
commit: state.commit.clone(),
gpio: GpioInfo {
pin: state.gpio_pin,
active_low: state.active_low,
pull: if state.active_low { "up" } else { "down" },
open_level: if state.active_low { "low" } else { "high" },
current_raw_level: raw_level,
},
}))
}
#[tokio::main]
async fn main() -> Result<()> {
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())
.parse()
.context("NOISEBELL_GPIO_PIN must be a valid u8")?;
let debounce_secs: u64 = std::env::var("NOISEBELL_DEBOUNCE_SECS")
.unwrap_or_else(|_| "5".into())
.parse()
.context("NOISEBELL_DEBOUNCE_SECS must be a valid u64")?;
let port: u16 = std::env::var("NOISEBELL_PORT")
.unwrap_or_else(|_| "8080".into())
.parse()
.context("NOISEBELL_PORT must be a valid u16")?;
let endpoint_url =
std::env::var("NOISEBELL_ENDPOINT_URL").context("NOISEBELL_ENDPOINT_URL is required")?;
let api_key =
std::env::var("NOISEBELL_API_KEY").context("NOISEBELL_API_KEY 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")?;
let inbound_api_key = std::env::var("NOISEBELL_INBOUND_API_KEY")
.context("NOISEBELL_INBOUND_API_KEY is required")?;
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}"))?;
let pin = if active_low {
pin.into_input_pullup()
} else {
pin.into_input_pulldown()
};
let open_level = if active_low { Level::Low } else { Level::High };
let initial_open = pin.read() == open_level;
let now = unix_timestamp();
let commit =
std::env::var("NOISEBELL_COMMIT").unwrap_or_else(|_| "unknown".into());
let state = Arc::new(AppState {
is_open: AtomicBool::new(initial_open),
last_changed: AtomicU64::new(now),
started_at: now,
gpio_pin,
active_low,
commit,
inbound_api_key,
});
info!(initial_status = status_str(initial_open), "GPIO initialized");
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, u64)>();
// Sync initial state with the cache on startup
let _ = tx.send((initial_open, now));
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 => active_low,
Trigger::RisingEdge => !active_low,
_ => return,
};
let was_open = state_for_interrupt.is_open.swap(new_open, Ordering::Relaxed);
if was_open != 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")?;
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");
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)
.bearer_auth(&api_key)
.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("/", get(get_status))
.route("/info", get(get_info))
.with_state(state);
let listener = tokio::net::TcpListener::bind((&*bind_address, port))
.await
.context(format!("failed to bind to {bind_address}:{port}"))?;
info!(port, "listening");
// Start watchdog task if systemd watchdog is enabled
if let Ok(usec_str) = std::env::var("WATCHDOG_USEC") {
if let Ok(usec) = usec_str.parse::<u64>() {
let period = Duration::from_micros(usec / 2);
tokio::spawn(async move {
loop {
sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]).ok();
tokio::time::sleep(period).await;
}
});
}
}
sd_notify::notify(false, &[sd_notify::NotifyState::Ready]).ok();
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("failed to register SIGTERM handler")?;
axum::serve(listener, app)
.with_graceful_shutdown(async move {
sigterm.recv().await;
})
.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(())
}

8
pi/secrets/secrets.nix Normal file
View file

@ -0,0 +1,8 @@
let
pi = "ssh-ed25519 AAAA..."; # Pi's SSH host public key
in
{
"api-key.age".publicKeys = [ pi ];
"inbound-api-key.age".publicKeys = [ pi ];
"tailscale-auth-key.age".publicKeys = [ pi ];
}

1688
remote/cache-service/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
[package]
name = "noisebell-cache"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rusqlite = { version = "0.33", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

64
remote/cache-service/flake.lock generated Normal file
View file

@ -0,0 +1,64 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1773115265,
"narHash": "sha256-5fDkKTYEgue2klksd52WvcXfZdY1EIlbk0QggAwpFog=",
"owner": "ipetkov",
"repo": "crane",
"rev": "27711550d109bf6236478dc9f53b9e29c1a374c5",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"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"
}
},
"root": {
"inputs": {
"crane": "crane",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1773115373,
"narHash": "sha256-bfK9FJFcQth6f3ydYggS5m0z2NRGF/PY6Y2XgZDJ6pg=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "1924b4672a2b8e4aee6e6652ec2e59a8d3c5648e",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -0,0 +1,45 @@
{
description = "Noisebell - cache service";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, crane, rust-overlay }:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
rustToolchain = pkgs.rust-bin.stable.latest.default;
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
src = craneLib.cleanCargoSource ./.;
commonArgs = {
inherit src;
strictDeps = true;
doCheck = false;
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
noisebell-cache = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
in
{
packages.${system}.default = noisebell-cache;
devShells.${system}.default = craneLib.devShell {
packages = [ pkgs.rust-analyzer ];
};
};
}

1
remote/cache-service/result Symbolic link
View file

@ -0,0 +1 @@
/nix/store/w4n48lrfqm5vhpyzjfcc9yxhmjr801xh-noisebell-cache-0.1.0

View file

@ -0,0 +1,116 @@
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use tokio::sync::Mutex;
use tracing::{error, info};
use crate::db;
use crate::types::{DoorStatus, InboundWebhook, OutboundPayload, WebhookTarget};
use crate::webhook;
pub struct AppState {
pub db: Arc<Mutex<rusqlite::Connection>>,
pub client: reqwest::Client,
pub inbound_api_key: String,
pub webhooks: Vec<WebhookTarget>,
pub retry_attempts: u32,
pub retry_base_delay_secs: u64,
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn validate_bearer(headers: &HeaderMap, expected: &str) -> bool {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or("") == expected)
.unwrap_or(false)
}
pub async fn post_webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<InboundWebhook>,
) -> StatusCode {
if !validate_bearer(&headers, &state.inbound_api_key) {
return StatusCode::UNAUTHORIZED;
}
let Some(status) = DoorStatus::from_str(&body.status) else {
return StatusCode::BAD_REQUEST;
};
let now = unix_now();
{
let conn = state.db.lock().await;
if let Err(e) = db::update_state(&conn, status, body.timestamp, now) {
error!(error = %e, "failed to update state from webhook");
return StatusCode::INTERNAL_SERVER_ERROR;
}
}
info!(status = status.as_str(), timestamp = body.timestamp, "state updated via webhook");
webhook::forward(
&state.client,
&state.webhooks,
&OutboundPayload {
status: status.as_str().to_string(),
timestamp: body.timestamp,
},
state.retry_attempts,
state.retry_base_delay_secs,
)
.await;
StatusCode::OK
}
pub async fn get_status(State(state): State<Arc<AppState>>) -> Result<Json<serde_json::Value>, StatusCode> {
let conn = state.db.lock().await;
match db::get_status(&conn) {
Ok(status) => Ok(Json(serde_json::to_value(status).unwrap())),
Err(e) => {
error!(error = %e, "failed to get status");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn get_info(State(state): State<Arc<AppState>>) -> Result<Json<serde_json::Value>, StatusCode> {
let conn = state.db.lock().await;
match db::get_pi_info(&conn) {
Ok(info) => Ok(Json(info)),
Err(e) => {
error!(error = %e, "failed to get pi info");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
#[derive(serde::Deserialize)]
pub struct HistoryQuery {
pub limit: Option<u32>,
}
pub async fn get_history(
State(state): State<Arc<AppState>>,
Query(query): Query<HistoryQuery>,
) -> Result<Json<Vec<crate::types::HistoryEntry>>, StatusCode> {
let limit = query.limit.unwrap_or(50);
let conn = state.db.lock().await;
match db::get_history(&conn, limit) {
Ok(entries) => Ok(Json(entries)),
Err(e) => {
error!(error = %e, "failed to get history");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}

View file

@ -0,0 +1,131 @@
use anyhow::{Context, Result};
use rusqlite::Connection;
use crate::types::{DoorStatus, HistoryEntry, StatusResponse};
pub fn init(path: &str) -> Result<Connection> {
let conn = Connection::open(path).context("failed to open SQLite database")?;
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS current_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
status TEXT,
timestamp INTEGER,
last_seen INTEGER
);
CREATE TABLE IF NOT EXISTS state_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL,
timestamp INTEGER NOT NULL,
recorded_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS pi_info (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
INSERT OR IGNORE INTO current_state (id, status, timestamp, last_seen) VALUES (1, NULL, NULL, NULL);
INSERT OR IGNORE INTO pi_info (id, data, fetched_at) VALUES (1, '{}', 0);
",
)
.context("failed to initialize database schema")?;
Ok(conn)
}
pub fn get_status(conn: &Connection) -> Result<StatusResponse> {
let (status, timestamp, last_seen) = conn.query_row(
"SELECT status, timestamp, last_seen FROM current_state WHERE id = 1",
[],
|row| {
Ok((
row.get::<_, Option<String>>(0)?,
row.get::<_, Option<u64>>(1)?,
row.get::<_, Option<u64>>(2)?,
))
},
)?;
Ok(StatusResponse {
status: status.unwrap_or_else(|| "offline".to_string()),
timestamp,
last_seen,
})
}
pub fn update_state(conn: &Connection, status: DoorStatus, timestamp: u64, now: u64) -> Result<()> {
let status_str = status.as_str();
conn.execute(
"UPDATE current_state SET status = ?1, timestamp = ?2, last_seen = ?3 WHERE id = 1",
rusqlite::params![status_str, timestamp, now],
)?;
conn.execute(
"INSERT INTO state_log (status, timestamp, recorded_at) VALUES (?1, ?2, ?3)",
rusqlite::params![status_str, timestamp, now],
)?;
Ok(())
}
pub fn update_last_seen(conn: &Connection, now: u64) -> Result<()> {
conn.execute(
"UPDATE current_state SET last_seen = ?1 WHERE id = 1",
rusqlite::params![now],
)?;
Ok(())
}
pub fn mark_offline(conn: &Connection, now: u64) -> Result<()> {
conn.execute(
"UPDATE current_state SET status = NULL WHERE id = 1",
[],
)?;
conn.execute(
"INSERT INTO state_log (status, timestamp, recorded_at) VALUES ('offline', ?1, ?1)",
rusqlite::params![now],
)?;
Ok(())
}
pub fn get_current_status_str(conn: &Connection) -> Result<Option<String>> {
let status = conn.query_row(
"SELECT status FROM current_state WHERE id = 1",
[],
|row| row.get::<_, Option<String>>(0),
)?;
Ok(status)
}
pub fn get_history(conn: &Connection, limit: u32) -> Result<Vec<HistoryEntry>> {
let mut stmt = conn.prepare(
"SELECT status, timestamp, recorded_at FROM state_log ORDER BY id DESC LIMIT ?1",
)?;
let entries = stmt
.query_map(rusqlite::params![limit], |row| {
Ok(HistoryEntry {
status: row.get(0)?,
timestamp: row.get(1)?,
recorded_at: row.get(2)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(entries)
}
pub fn get_pi_info(conn: &Connection) -> Result<serde_json::Value> {
let data: String = conn.query_row(
"SELECT data FROM pi_info WHERE id = 1",
[],
|row| row.get(0),
)?;
Ok(serde_json::from_str(&data).unwrap_or(serde_json::json!({})))
}
pub fn update_pi_info(conn: &Connection, data: &serde_json::Value, now: u64) -> Result<()> {
let json = serde_json::to_string(data)?;
conn.execute(
"INSERT OR REPLACE INTO pi_info (id, data, fetched_at) VALUES (1, ?1, ?2)",
rusqlite::params![json, now],
)?;
Ok(())
}

View file

@ -0,0 +1,149 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use axum::routing::{get, post};
use axum::Router;
use tokio::sync::Mutex;
use tracing::info;
mod api;
mod db;
mod poller;
mod types;
mod webhook;
use types::WebhookTarget;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let port: u16 = std::env::var("NOISEBELL_CACHE_PORT")
.unwrap_or_else(|_| "3000".into())
.parse()
.context("NOISEBELL_CACHE_PORT must be a valid u16")?;
let pi_address = std::env::var("NOISEBELL_CACHE_PI_ADDRESS")
.context("NOISEBELL_CACHE_PI_ADDRESS is required")?;
let pi_api_key = std::env::var("NOISEBELL_CACHE_PI_API_KEY")
.context("NOISEBELL_CACHE_PI_API_KEY is required")?;
let inbound_api_key = std::env::var("NOISEBELL_CACHE_INBOUND_API_KEY")
.context("NOISEBELL_CACHE_INBOUND_API_KEY is required")?;
let data_dir =
std::env::var("NOISEBELL_CACHE_DATA_DIR").unwrap_or_else(|_| "/var/lib/noisebell-cache".into());
let status_poll_interval_secs: u64 = std::env::var("NOISEBELL_CACHE_STATUS_POLL_INTERVAL_SECS")
.unwrap_or_else(|_| "60".into())
.parse()
.context("NOISEBELL_CACHE_STATUS_POLL_INTERVAL_SECS must be a valid u64")?;
let info_poll_interval_secs: u64 = std::env::var("NOISEBELL_CACHE_INFO_POLL_INTERVAL_SECS")
.unwrap_or_else(|_| "300".into())
.parse()
.context("NOISEBELL_CACHE_INFO_POLL_INTERVAL_SECS must be a valid u64")?;
let offline_threshold: u32 = std::env::var("NOISEBELL_CACHE_OFFLINE_THRESHOLD")
.unwrap_or_else(|_| "3".into())
.parse()
.context("NOISEBELL_CACHE_OFFLINE_THRESHOLD must be a valid u32")?;
let retry_attempts: u32 = std::env::var("NOISEBELL_CACHE_RETRY_ATTEMPTS")
.unwrap_or_else(|_| "3".into())
.parse()
.context("NOISEBELL_CACHE_RETRY_ATTEMPTS must be a valid u32")?;
let retry_base_delay_secs: u64 = std::env::var("NOISEBELL_CACHE_RETRY_BASE_DELAY_SECS")
.unwrap_or_else(|_| "1".into())
.parse()
.context("NOISEBELL_CACHE_RETRY_BASE_DELAY_SECS must be a valid u64")?;
let http_timeout_secs: u64 = std::env::var("NOISEBELL_CACHE_HTTP_TIMEOUT_SECS")
.unwrap_or_else(|_| "10".into())
.parse()
.context("NOISEBELL_CACHE_HTTP_TIMEOUT_SECS must be a valid u64")?;
// Parse outbound webhooks from NOISEBELL_CACHE_WEBHOOK_<n>_URL and _SECRET env vars
let mut webhooks = Vec::new();
for i in 0.. {
let url_key = format!("NOISEBELL_CACHE_WEBHOOK_{i}_URL");
match std::env::var(&url_key) {
Ok(url) => {
let secret_key = format!("NOISEBELL_CACHE_WEBHOOK_{i}_SECRET");
let secret = std::env::var(&secret_key).ok();
webhooks.push(WebhookTarget { url, secret });
}
Err(_) => break,
}
}
info!(
port,
%pi_address,
webhook_count = webhooks.len(),
"starting noisebell-cache"
);
let db_path = format!("{data_dir}/noisebell.db");
let conn = db::init(&db_path)?;
let db = Arc::new(Mutex::new(conn));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(http_timeout_secs))
.build()
.context("failed to build HTTP client")?;
let poller_config = Arc::new(poller::PollerConfig {
pi_address,
pi_api_key,
status_poll_interval: Duration::from_secs(status_poll_interval_secs),
info_poll_interval: Duration::from_secs(info_poll_interval_secs),
offline_threshold,
retry_attempts,
retry_base_delay_secs,
http_timeout_secs,
webhooks: webhooks.clone(),
});
poller::spawn_status_poller(poller_config.clone(), db.clone(), client.clone());
poller::spawn_info_poller(poller_config, db.clone(), client.clone());
let app_state = Arc::new(api::AppState {
db,
client,
inbound_api_key,
webhooks,
retry_attempts,
retry_base_delay_secs,
});
let app = Router::new()
.route("/webhook", post(api::post_webhook))
.route("/status", get(api::get_status))
.route("/info", get(api::get_info))
.route("/history", get(api::get_history))
.with_state(app_state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.context(format!("failed to bind to 0.0.0.0:{port}"))?;
info!(port, "listening");
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("failed to register SIGTERM handler")?;
axum::serve(listener, app)
.with_graceful_shutdown(async move {
sigterm.recv().await;
})
.await
.context("server error")?;
info!("shutdown complete");
Ok(())
}

View file

@ -0,0 +1,174 @@
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tracing::{error, info, warn};
use crate::db;
use crate::types::{DoorStatus, OutboundPayload, WebhookTarget};
use crate::webhook;
pub struct PollerConfig {
pub pi_address: String,
pub pi_api_key: String,
pub status_poll_interval: Duration,
pub info_poll_interval: Duration,
pub offline_threshold: u32,
pub retry_attempts: u32,
pub retry_base_delay_secs: u64,
pub http_timeout_secs: u64,
pub webhooks: Vec<WebhookTarget>,
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
pub fn spawn_status_poller(
config: Arc<PollerConfig>,
db: Arc<Mutex<rusqlite::Connection>>,
client: reqwest::Client,
) {
tokio::spawn(async move {
let mut consecutive_failures: u32 = 0;
let mut was_offline = false;
loop {
tokio::time::sleep(config.status_poll_interval).await;
let result = client
.get(format!("{}/", config.pi_address))
.bearer_auth(&config.pi_api_key)
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => {
consecutive_failures = 0;
if was_offline {
info!("Pi is back online");
was_offline = false;
}
let now = unix_now();
if let Ok(body) = resp.json::<serde_json::Value>().await {
let conn = db.lock().await;
if let Err(e) = db::update_last_seen(&conn, now) {
error!(error = %e, "failed to update last_seen");
}
// Update state if it differs from current
if let Some(status_str) = body.get("status").and_then(|s| s.as_str()) {
if let Some(status) = DoorStatus::from_str(status_str) {
let current = db::get_current_status_str(&conn);
let changed = match &current {
Ok(Some(s)) => s != status.as_str(),
Ok(None) => true, // was offline
Err(_) => true,
};
if changed {
let timestamp = body
.get("timestamp")
.and_then(|t| t.as_u64())
.unwrap_or(now);
if let Err(e) = db::update_state(&conn, status, timestamp, now) {
error!(error = %e, "failed to update state from poll");
} else {
info!(status = status.as_str(), "state updated from poll");
drop(conn);
webhook::forward(
&client,
&config.webhooks,
&OutboundPayload {
status: status.as_str().to_string(),
timestamp,
},
config.retry_attempts,
config.retry_base_delay_secs,
)
.await;
}
}
}
}
}
}
_ => {
consecutive_failures += 1;
let err_msg = match &result {
Ok(resp) => format!("HTTP {}", resp.status()),
Err(e) => e.to_string(),
};
warn!(
error = %err_msg,
consecutive_failures,
"status poll failed"
);
if consecutive_failures >= config.offline_threshold && !was_offline {
was_offline = true;
let now = unix_now();
let conn = db.lock().await;
if let Err(e) = db::mark_offline(&conn, now) {
error!(error = %e, "failed to mark Pi offline");
} else {
info!("Pi marked offline after {} consecutive failures", consecutive_failures);
drop(conn);
webhook::forward(
&client,
&config.webhooks,
&OutboundPayload {
status: "offline".to_string(),
timestamp: now,
},
config.retry_attempts,
config.retry_base_delay_secs,
)
.await;
}
}
}
}
}
});
}
pub fn spawn_info_poller(
config: Arc<PollerConfig>,
db: Arc<Mutex<rusqlite::Connection>>,
client: reqwest::Client,
) {
tokio::spawn(async move {
loop {
tokio::time::sleep(config.info_poll_interval).await;
let result = client
.get(format!("{}/info", config.pi_address))
.bearer_auth(&config.pi_api_key)
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
let now = unix_now();
let conn = db.lock().await;
if let Err(e) = db::update_pi_info(&conn, &data, now) {
error!(error = %e, "failed to update pi_info");
}
}
}
_ => {
let err_msg = match &result {
Ok(resp) => format!("HTTP {}", resp.status()),
Err(e) => e.to_string(),
};
warn!(error = %err_msg, "info poll failed");
}
}
}
});
}

View file

@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DoorStatus {
Open,
Closed,
}
impl DoorStatus {
pub fn as_str(self) -> &'static str {
match self {
DoorStatus::Open => "open",
DoorStatus::Closed => "closed",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"open" => Some(DoorStatus::Open),
"closed" => Some(DoorStatus::Closed),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct StatusResponse {
pub status: String, // "open", "closed", or "offline"
pub timestamp: Option<u64>,
pub last_seen: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct InboundWebhook {
pub status: String,
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct OutboundPayload {
pub status: String,
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct HistoryEntry {
pub status: String,
pub timestamp: u64,
pub recorded_at: u64,
}
#[derive(Debug, Clone)]
pub struct WebhookTarget {
pub url: String,
pub secret: Option<String>,
}

View file

@ -0,0 +1,53 @@
use std::time::Duration;
use tracing::{error, info, warn};
use crate::types::{OutboundPayload, WebhookTarget};
pub async fn forward(
client: &reqwest::Client,
targets: &[WebhookTarget],
payload: &OutboundPayload,
retry_attempts: u32,
retry_base_delay_secs: u64,
) {
for target in targets {
let payload = payload.clone();
let client = client.clone();
let url = target.url.clone();
let secret = target.secret.clone();
let retry_attempts = retry_attempts;
let retry_base_delay_secs = retry_base_delay_secs;
tokio::spawn(async move {
info!(url = %url, status = %payload.status, "forwarding to outbound webhook");
for attempt in 0..=retry_attempts {
let mut req = client.post(&url).json(&payload);
if let Some(ref secret) = secret {
req = req.bearer_auth(secret);
}
match req.send().await {
Ok(resp) if resp.status().is_success() => {
info!(url = %url, "outbound webhook delivered");
return;
}
result => {
let err_msg = match &result {
Ok(resp) => format!("HTTP {}", resp.status()),
Err(e) => e.to_string(),
};
if attempt == retry_attempts {
error!(url = %url, error = %err_msg, "outbound webhook failed after {} attempts", retry_attempts + 1);
} else {
let delay = Duration::from_secs(retry_base_delay_secs * 2u64.pow(attempt));
warn!(url = %url, error = %err_msg, attempt = attempt + 1, "outbound webhook failed, retrying in {:?}", delay);
tokio::time::sleep(delay).await;
}
}
}
}
});
}
}

86
remote/configuration.nix Normal file
View file

@ -0,0 +1,86 @@
{ config, pkgs, ... }:
{
system.stateVersion = "24.11";
networking.hostName = "noisebell-remote";
nix.settings.experimental-features = [ "nix-command" "flakes" ];
services.openssh.enable = true;
services.caddy.enable = true;
users.users.root.openssh.authorizedKeys.keys = [
# Add your SSH public key here
];
# ── Secrets ───────────────────────────────────────────────────────────
age.secrets.pi-api-key.file = ./secrets/pi-api-key.age;
age.secrets.pi-inbound-api-key.file = ./secrets/pi-inbound-api-key.age;
age.secrets.discord-token.file = ./secrets/discord-token.age;
age.secrets.discord-webhook-secret.file = ./secrets/discord-webhook-secret.age;
age.secrets.rss-webhook-secret.file = ./secrets/rss-webhook-secret.age;
age.secrets.zulip-api-key.file = ./secrets/zulip-api-key.age;
age.secrets.zulip-webhook-secret.file = ./secrets/zulip-webhook-secret.age;
age.secrets.matrix-access-token.file = ./secrets/matrix-access-token.age;
age.secrets.matrix-webhook-secret.file = ./secrets/matrix-webhook-secret.age;
# ── Cache ─────────────────────────────────────────────────────────────
services.noisebell-cache = {
enable = true;
domain = "noisebell.extremist.software";
piAddress = "http://noisebell:80";
inboundApiKeyFile = config.age.secrets.pi-api-key.path;
piApiKeyFile = config.age.secrets.pi-inbound-api-key.path;
outboundWebhooks = [
{ url = "https://discord.noisebell.extremist.software/webhook"; secretFile = config.age.secrets.discord-webhook-secret.path; }
{ url = "https://rss.noisebell.extremist.software/webhook"; secretFile = config.age.secrets.rss-webhook-secret.path; }
{ url = "https://zulip.noisebell.extremist.software/webhook"; secretFile = config.age.secrets.zulip-webhook-secret.path; }
{ url = "https://matrix.noisebell.extremist.software/webhook"; secretFile = config.age.secrets.matrix-webhook-secret.path; }
];
};
# ── Discord ───────────────────────────────────────────────────────────
services.noisebell-discord = {
enable = true;
domain = "discord.noisebell.extremist.software";
discordTokenFile = config.age.secrets.discord-token.path;
channelId = "000000000000000000"; # Replace with actual channel ID
webhookSecretFile = config.age.secrets.discord-webhook-secret.path;
};
# ── RSS ───────────────────────────────────────────────────────────────
services.noisebell-rss = {
enable = true;
domain = "rss.noisebell.extremist.software";
webhookSecretFile = config.age.secrets.rss-webhook-secret.path;
};
# ── Zulip ─────────────────────────────────────────────────────────────
services.noisebell-zulip = {
enable = true;
domain = "zulip.noisebell.extremist.software";
serverUrl = "https://noisebridge.zulipchat.com"; # Replace with actual Zulip server
botEmail = "noisebell-bot@noisebridge.zulipchat.com"; # Replace with actual bot email
apiKeyFile = config.age.secrets.zulip-api-key.path;
webhookSecretFile = config.age.secrets.zulip-webhook-secret.path;
stream = "general"; # Replace with target stream
topic = "door status";
};
# ── Matrix ────────────────────────────────────────────────────────────
services.noisebell-matrix = {
enable = true;
domain = "matrix.noisebell.extremist.software";
homeserver = "https://matrix.org"; # Replace with actual homeserver
accessTokenFile = config.age.secrets.matrix-access-token.path;
roomId = "!REPLACE:matrix.org"; # Replace with actual room ID
webhookSecretFile = config.age.secrets.matrix-webhook-secret.path;
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
[package]
name = "noisebell-discord"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
axum = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "model", "rustls_backend"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

64
remote/discord-bot/flake.lock generated Normal file
View file

@ -0,0 +1,64 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1773115265,
"narHash": "sha256-5fDkKTYEgue2klksd52WvcXfZdY1EIlbk0QggAwpFog=",
"owner": "ipetkov",
"repo": "crane",
"rev": "27711550d109bf6236478dc9f53b9e29c1a374c5",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"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"
}
},
"root": {
"inputs": {
"crane": "crane",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1773115373,
"narHash": "sha256-bfK9FJFcQth6f3ydYggS5m0z2NRGF/PY6Y2XgZDJ6pg=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "1924b4672a2b8e4aee6e6652ec2e59a8d3c5648e",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -0,0 +1,45 @@
{
description = "Noisebell - Discord bot";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, crane, rust-overlay }:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
rustToolchain = pkgs.rust-bin.stable.latest.default;
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
src = craneLib.cleanCargoSource ./.;
commonArgs = {
inherit src;
strictDeps = true;
doCheck = false;
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
noisebell-discord = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
in
{
packages.${system}.default = noisebell-discord;
devShells.${system}.default = craneLib.devShell {
packages = [ pkgs.rust-analyzer ];
};
};
}

1
remote/discord-bot/result Symbolic link
View file

@ -0,0 +1 @@
/nix/store/jnkl079ljh32p7m9jxnan8wis1sp2bm9-noisebell-discord-0.1.0

View file

@ -0,0 +1,142 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use axum::{Json, Router};
use serde::Deserialize;
use serenity::all::{ChannelId, Colour, CreateEmbed, CreateMessage, GatewayIntents, Http};
use tracing::{error, info};
#[derive(Deserialize)]
struct WebhookPayload {
status: String,
timestamp: u64,
}
struct AppState {
http: Arc<Http>,
channel_id: ChannelId,
webhook_secret: String,
}
fn validate_bearer(headers: &HeaderMap, expected: &str) -> bool {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or("") == expected)
.unwrap_or(false)
}
fn build_embed(status: &str, timestamp: u64) -> CreateEmbed {
let (colour, title, description) = match status {
"open" => (Colour::from_rgb(87, 242, 135), "Door is open", "The door at Noisebridge is open."),
"closed" => (Colour::from_rgb(237, 66, 69), "Door is closed", "The door at Noisebridge is closed."),
_ => (Colour::from_rgb(153, 170, 181), "Pi is offline", "The Noisebridge Pi is offline."),
};
CreateEmbed::new()
.title(title)
.description(description)
.colour(colour)
.timestamp(serenity::model::Timestamp::from_unix_timestamp(timestamp as i64).unwrap_or_else(|_| serenity::model::Timestamp::now()))
}
async fn post_webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<WebhookPayload>,
) -> StatusCode {
if !validate_bearer(&headers, &state.webhook_secret) {
return StatusCode::UNAUTHORIZED;
}
info!(status = %body.status, timestamp = body.timestamp, "received webhook");
let embed = build_embed(&body.status, body.timestamp);
let message = CreateMessage::new().embed(embed);
match state.channel_id.send_message(&state.http, message).await {
Ok(_) => {
info!(status = %body.status, "embed sent to Discord");
StatusCode::OK
}
Err(e) => {
error!(error = %e, "failed to send embed to Discord");
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
struct Handler;
impl serenity::all::EventHandler for Handler {}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let discord_token = std::env::var("NOISEBELL_DISCORD_TOKEN")
.context("NOISEBELL_DISCORD_TOKEN is required")?;
let channel_id: u64 = std::env::var("NOISEBELL_DISCORD_CHANNEL_ID")
.context("NOISEBELL_DISCORD_CHANNEL_ID is required")?
.parse()
.context("NOISEBELL_DISCORD_CHANNEL_ID must be a valid u64")?;
let webhook_secret = std::env::var("NOISEBELL_DISCORD_WEBHOOK_SECRET")
.context("NOISEBELL_DISCORD_WEBHOOK_SECRET is required")?;
let port: u16 = std::env::var("NOISEBELL_DISCORD_PORT")
.unwrap_or_else(|_| "3001".into())
.parse()
.context("NOISEBELL_DISCORD_PORT must be a valid u16")?;
info!(port, channel_id, "starting noisebell-discord");
let intents = GatewayIntents::empty();
let mut client = serenity::Client::builder(&discord_token, intents)
.event_handler(Handler)
.await
.context("failed to create Discord client")?;
let http = client.http.clone();
let app_state = Arc::new(AppState {
http,
channel_id: ChannelId::new(channel_id),
webhook_secret,
});
let app = Router::new()
.route("/webhook", post(post_webhook))
.with_state(app_state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.context(format!("failed to bind to 0.0.0.0:{port}"))?;
info!(port, "webhook listener ready");
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("failed to register SIGTERM handler")?;
tokio::select! {
result = client.start() => {
if let Err(e) = result {
error!(error = %e, "Discord client error");
}
}
result = axum::serve(listener, app).with_graceful_shutdown(async move { sigterm.recv().await; }) => {
if let Err(e) = result {
error!(error = %e, "HTTP server error");
}
}
}
info!("shutdown complete");
Ok(())
}

370
remote/flake.nix Normal file
View file

@ -0,0 +1,370 @@
{
description = "NixOS configuration for noisebell remote services";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
agenix = {
url = "github:ryantm/agenix";
inputs.nixpkgs.follows = "nixpkgs";
};
noisebell-cache.url = "path:./cache-service";
noisebell-discord.url = "path:./discord-bot";
noisebell-rss.url = "path:./rss-service";
noisebell-zulip.url = "path:./zulip-bot";
noisebell-matrix.url = "path:./matrix-bot";
};
outputs = { self, nixpkgs, agenix, noisebell-cache, noisebell-discord, noisebell-rss, noisebell-zulip, noisebell-matrix }:
let
# ── Cache module ──────────────────────────────────────────────────
cacheModule = { config, lib, pkgs, ... }:
let cfg = config.services.noisebell-cache; in
{
options.services.noisebell-cache = {
enable = lib.mkEnableOption "noisebell cache service";
domain = lib.mkOption { type = lib.types.str; };
piAddress = lib.mkOption { type = lib.types.str; };
piApiKeyFile = lib.mkOption { type = lib.types.path; description = "Path to agenix secret for authenticating to Pi GET endpoints."; };
inboundApiKeyFile = lib.mkOption { type = lib.types.path; };
port = lib.mkOption { type = lib.types.port; default = 3000; };
statusPollIntervalSecs = lib.mkOption { type = lib.types.ints.positive; default = 60; };
infoPollIntervalSecs = lib.mkOption { type = lib.types.ints.positive; default = 300; };
offlineThreshold = lib.mkOption { type = lib.types.ints.positive; default = 3; };
retryAttempts = lib.mkOption { type = lib.types.ints.unsigned; default = 3; };
retryBaseDelaySecs = lib.mkOption { type = lib.types.ints.positive; default = 1; };
httpTimeoutSecs = lib.mkOption { type = lib.types.ints.positive; default = 10; };
dataDir = lib.mkOption { type = lib.types.str; default = "/var/lib/noisebell-cache"; };
outboundWebhooks = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
url = lib.mkOption { type = lib.types.str; };
secretFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; };
};
});
default = [];
};
};
config = lib.mkIf cfg.enable {
users.users.noisebell-cache = { isSystemUser = true; group = "noisebell-cache"; };
users.groups.noisebell-cache = {};
services.caddy.virtualHosts."${cfg.domain}".extraConfig = ''
reverse_proxy localhost:${toString cfg.port}
'';
systemd.services.noisebell-cache = let
bin = "${noisebell-cache.packages.x86_64-linux.default}/bin/noisebell-cache";
webhookExports = lib.concatImapStringsSep "\n" (i: wh:
let idx = toString (i - 1); in
''export NOISEBELL_CACHE_WEBHOOK_${idx}_URL="${wh.url}"'' +
lib.optionalString (wh.secretFile != null)
''\nexport NOISEBELL_CACHE_WEBHOOK_${idx}_SECRET="$(cat ${wh.secretFile})"''
) cfg.outboundWebhooks;
in {
description = "Noisebell cache service";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
NOISEBELL_CACHE_PORT = toString cfg.port;
NOISEBELL_CACHE_PI_ADDRESS = cfg.piAddress;
NOISEBELL_CACHE_DATA_DIR = cfg.dataDir;
NOISEBELL_CACHE_STATUS_POLL_INTERVAL_SECS = toString cfg.statusPollIntervalSecs;
NOISEBELL_CACHE_INFO_POLL_INTERVAL_SECS = toString cfg.infoPollIntervalSecs;
NOISEBELL_CACHE_OFFLINE_THRESHOLD = toString cfg.offlineThreshold;
NOISEBELL_CACHE_RETRY_ATTEMPTS = toString cfg.retryAttempts;
NOISEBELL_CACHE_RETRY_BASE_DELAY_SECS = toString cfg.retryBaseDelaySecs;
NOISEBELL_CACHE_HTTP_TIMEOUT_SECS = toString cfg.httpTimeoutSecs;
RUST_LOG = "info";
};
script = ''
export NOISEBELL_CACHE_INBOUND_API_KEY="$(cat ${cfg.inboundApiKeyFile})"
export NOISEBELL_CACHE_PI_API_KEY="$(cat ${cfg.piApiKeyFile})"
${webhookExports}
exec ${bin}
'';
serviceConfig = {
Type = "simple";
Restart = "on-failure";
RestartSec = 5;
User = "noisebell-cache";
Group = "noisebell-cache";
StateDirectory = "noisebell-cache";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
ReadWritePaths = [ cfg.dataDir ];
};
};
};
};
# ── Discord module ────────────────────────────────────────────────
discordModule = { config, lib, pkgs, ... }:
let cfg = config.services.noisebell-discord; in
{
options.services.noisebell-discord = {
enable = lib.mkEnableOption "noisebell Discord bot";
domain = lib.mkOption { type = lib.types.str; };
port = lib.mkOption { type = lib.types.port; default = 3001; };
discordTokenFile = lib.mkOption { type = lib.types.path; };
channelId = lib.mkOption { type = lib.types.str; };
webhookSecretFile = lib.mkOption { type = lib.types.path; };
};
config = lib.mkIf cfg.enable {
users.users.noisebell-discord = { isSystemUser = true; group = "noisebell-discord"; };
users.groups.noisebell-discord = {};
services.caddy.virtualHosts."${cfg.domain}".extraConfig = ''
reverse_proxy localhost:${toString cfg.port}
'';
systemd.services.noisebell-discord = let
bin = "${noisebell-discord.packages.x86_64-linux.default}/bin/noisebell-discord";
in {
description = "Noisebell Discord bot";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
NOISEBELL_DISCORD_PORT = toString cfg.port;
NOISEBELL_DISCORD_CHANNEL_ID = cfg.channelId;
RUST_LOG = "info";
};
script = ''
export NOISEBELL_DISCORD_TOKEN="$(cat ${cfg.discordTokenFile})"
export NOISEBELL_DISCORD_WEBHOOK_SECRET="$(cat ${cfg.webhookSecretFile})"
exec ${bin}
'';
serviceConfig = {
Type = "simple";
Restart = "on-failure";
RestartSec = 5;
User = "noisebell-discord";
Group = "noisebell-discord";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
};
};
};
};
# ── RSS module ────────────────────────────────────────────────────
rssModule = { config, lib, pkgs, ... }:
let cfg = config.services.noisebell-rss; in
{
options.services.noisebell-rss = {
enable = lib.mkEnableOption "noisebell RSS/Atom feed";
domain = lib.mkOption { type = lib.types.str; };
port = lib.mkOption { type = lib.types.port; default = 3002; };
webhookSecretFile = lib.mkOption { type = lib.types.path; };
dataDir = lib.mkOption { type = lib.types.str; default = "/var/lib/noisebell-rss"; };
};
config = lib.mkIf cfg.enable {
users.users.noisebell-rss = { isSystemUser = true; group = "noisebell-rss"; };
users.groups.noisebell-rss = {};
services.caddy.virtualHosts."${cfg.domain}".extraConfig = ''
reverse_proxy localhost:${toString cfg.port}
'';
systemd.services.noisebell-rss = let
bin = "${noisebell-rss.packages.x86_64-linux.default}/bin/noisebell-rss";
in {
description = "Noisebell RSS/Atom feed";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
NOISEBELL_RSS_PORT = toString cfg.port;
NOISEBELL_RSS_DATA_DIR = cfg.dataDir;
NOISEBELL_RSS_SITE_URL = "https://${cfg.domain}";
RUST_LOG = "info";
};
script = ''
export NOISEBELL_RSS_WEBHOOK_SECRET="$(cat ${cfg.webhookSecretFile})"
exec ${bin}
'';
serviceConfig = {
Type = "simple";
Restart = "on-failure";
RestartSec = 5;
User = "noisebell-rss";
Group = "noisebell-rss";
StateDirectory = "noisebell-rss";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
ReadWritePaths = [ cfg.dataDir ];
};
};
};
};
# ── Zulip module ──────────────────────────────────────────────────
zulipModule = { config, lib, pkgs, ... }:
let cfg = config.services.noisebell-zulip; in
{
options.services.noisebell-zulip = {
enable = lib.mkEnableOption "noisebell Zulip bot";
domain = lib.mkOption { type = lib.types.str; };
port = lib.mkOption { type = lib.types.port; default = 3003; };
webhookSecretFile = lib.mkOption { type = lib.types.path; };
serverUrl = lib.mkOption { type = lib.types.str; description = "Zulip server URL (e.g. https://noisebridge.zulipchat.com)"; };
botEmail = lib.mkOption { type = lib.types.str; };
apiKeyFile = lib.mkOption { type = lib.types.path; };
stream = lib.mkOption { type = lib.types.str; default = "general"; };
topic = lib.mkOption { type = lib.types.str; default = "door status"; };
};
config = lib.mkIf cfg.enable {
users.users.noisebell-zulip = { isSystemUser = true; group = "noisebell-zulip"; };
users.groups.noisebell-zulip = {};
services.caddy.virtualHosts."${cfg.domain}".extraConfig = ''
reverse_proxy localhost:${toString cfg.port}
'';
systemd.services.noisebell-zulip = let
bin = "${noisebell-zulip.packages.x86_64-linux.default}/bin/noisebell-zulip";
in {
description = "Noisebell Zulip bot";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
NOISEBELL_ZULIP_PORT = toString cfg.port;
NOISEBELL_ZULIP_SERVER_URL = cfg.serverUrl;
NOISEBELL_ZULIP_BOT_EMAIL = cfg.botEmail;
NOISEBELL_ZULIP_STREAM = cfg.stream;
NOISEBELL_ZULIP_TOPIC = cfg.topic;
RUST_LOG = "info";
};
script = ''
export NOISEBELL_ZULIP_WEBHOOK_SECRET="$(cat ${cfg.webhookSecretFile})"
export NOISEBELL_ZULIP_API_KEY="$(cat ${cfg.apiKeyFile})"
exec ${bin}
'';
serviceConfig = {
Type = "simple";
Restart = "on-failure";
RestartSec = 5;
User = "noisebell-zulip";
Group = "noisebell-zulip";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
};
};
};
};
# ── Matrix module ─────────────────────────────────────────────────
matrixModule = { config, lib, pkgs, ... }:
let cfg = config.services.noisebell-matrix; in
{
options.services.noisebell-matrix = {
enable = lib.mkEnableOption "noisebell Matrix bot";
domain = lib.mkOption { type = lib.types.str; };
port = lib.mkOption { type = lib.types.port; default = 3004; };
webhookSecretFile = lib.mkOption { type = lib.types.path; };
homeserver = lib.mkOption { type = lib.types.str; description = "Matrix homeserver URL (e.g. https://matrix.org)"; };
accessTokenFile = lib.mkOption { type = lib.types.path; };
roomId = lib.mkOption { type = lib.types.str; description = "Matrix room ID (e.g. !abc123:matrix.org)"; };
};
config = lib.mkIf cfg.enable {
users.users.noisebell-matrix = { isSystemUser = true; group = "noisebell-matrix"; };
users.groups.noisebell-matrix = {};
services.caddy.virtualHosts."${cfg.domain}".extraConfig = ''
reverse_proxy localhost:${toString cfg.port}
'';
systemd.services.noisebell-matrix = let
bin = "${noisebell-matrix.packages.x86_64-linux.default}/bin/noisebell-matrix";
in {
description = "Noisebell Matrix bot";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
NOISEBELL_MATRIX_PORT = toString cfg.port;
NOISEBELL_MATRIX_HOMESERVER = cfg.homeserver;
NOISEBELL_MATRIX_ROOM_ID = cfg.roomId;
RUST_LOG = "info";
};
script = ''
export NOISEBELL_MATRIX_WEBHOOK_SECRET="$(cat ${cfg.webhookSecretFile})"
export NOISEBELL_MATRIX_ACCESS_TOKEN="$(cat ${cfg.accessTokenFile})"
exec ${bin}
'';
serviceConfig = {
Type = "simple";
Restart = "on-failure";
RestartSec = 5;
User = "noisebell-matrix";
Group = "noisebell-matrix";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
};
};
};
};
in
{
nixosModules = {
cache = cacheModule;
discord = discordModule;
rss = rssModule;
zulip = zulipModule;
matrix = matrixModule;
};
nixosConfigurations.remote = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
agenix.nixosModules.default
cacheModule
discordModule
rssModule
zulipModule
matrixModule
./configuration.nix
./hardware-configuration.nix
];
};
};
}

View file

@ -0,0 +1,13 @@
{ config, lib, pkgs, modulesPath, ... }:
{
imports = [ ];
boot.loader.grub.enable = true;
boot.loader.grub.devices = [ "/dev/sda" ];
fileSystems."/" = {
device = "/dev/disk/by-label/nixos";
fsType = "ext4";
};
}

1614
remote/matrix-bot/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
[package]
name = "noisebell-matrix"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -0,0 +1,45 @@
{
description = "Noisebell - Matrix bot";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, crane, rust-overlay }:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
rustToolchain = pkgs.rust-bin.stable.latest.default;
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
src = craneLib.cleanCargoSource ./.;
commonArgs = {
inherit src;
strictDeps = true;
doCheck = false;
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
noisebell-matrix = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
in
{
packages.${system}.default = noisebell-matrix;
devShells.${system}.default = craneLib.devShell {
packages = [ pkgs.rust-analyzer ];
};
};
}

View file

@ -0,0 +1,176 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{Context, Result};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use axum::{Json, Router};
use serde::Deserialize;
use tracing::{error, info};
#[derive(Deserialize)]
struct WebhookPayload {
status: String,
#[allow(dead_code)]
timestamp: u64,
}
struct AppState {
client: reqwest::Client,
webhook_secret: String,
homeserver: String,
access_token: String,
room_id: String,
txn_counter: AtomicU64,
}
fn validate_bearer(headers: &HeaderMap, expected: &str) -> bool {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or("") == expected)
.unwrap_or(false)
}
fn percent_encode_room_id(room_id: &str) -> String {
room_id
.chars()
.map(|c| match c {
'!' => "%21".to_string(),
':' => "%3A".to_string(),
'#' => "%23".to_string(),
_ => c.to_string(),
})
.collect()
}
fn format_message(status: &str) -> (&str, &str) {
match status {
"open" => (
"Door is open at Noisebridge.",
"<b>Door is open</b> at Noisebridge.",
),
"closed" => (
"Door is closed at Noisebridge.",
"<b>Door is closed</b> at Noisebridge.",
),
"offline" => (
"Pi is offline — the Noisebridge Pi is unreachable.",
"<b>Pi is offline</b> — the Noisebridge Pi is unreachable.",
),
_ => ("Unknown status.", "Unknown status."),
}
}
async fn post_webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<WebhookPayload>,
) -> StatusCode {
if !validate_bearer(&headers, &state.webhook_secret) {
return StatusCode::UNAUTHORIZED;
}
info!(status = %body.status, "received webhook");
let (plain, html) = format_message(&body.status);
let txn_id = state.txn_counter.fetch_add(1, Ordering::Relaxed);
let encoded_room = percent_encode_room_id(&state.room_id);
let url = format!(
"{}/_matrix/client/v3/rooms/{}/send/m.room.message/noisebell-{}",
state.homeserver, encoded_room, txn_id
);
let result = state
.client
.put(&url)
.bearer_auth(&state.access_token)
.json(&serde_json::json!({
"msgtype": "m.notice",
"body": plain,
"format": "org.matrix.custom.html",
"formatted_body": html,
}))
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => {
info!(status = %body.status, "message sent to Matrix");
StatusCode::OK
}
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
error!(%status, %body, "Matrix API error");
StatusCode::BAD_GATEWAY
}
Err(e) => {
error!(error = %e, "failed to contact Matrix homeserver");
StatusCode::BAD_GATEWAY
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let port: u16 = std::env::var("NOISEBELL_MATRIX_PORT")
.unwrap_or_else(|_| "3004".into())
.parse()
.context("NOISEBELL_MATRIX_PORT must be a valid u16")?;
let webhook_secret = std::env::var("NOISEBELL_MATRIX_WEBHOOK_SECRET")
.context("NOISEBELL_MATRIX_WEBHOOK_SECRET is required")?;
let homeserver = std::env::var("NOISEBELL_MATRIX_HOMESERVER")
.context("NOISEBELL_MATRIX_HOMESERVER is required (e.g. https://matrix.org)")?;
let access_token = std::env::var("NOISEBELL_MATRIX_ACCESS_TOKEN")
.context("NOISEBELL_MATRIX_ACCESS_TOKEN is required")?;
let room_id = std::env::var("NOISEBELL_MATRIX_ROOM_ID")
.context("NOISEBELL_MATRIX_ROOM_ID is required (e.g. !abc123:matrix.org)")?;
info!(port, %homeserver, %room_id, "starting noisebell-matrix");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("failed to build HTTP client")?;
let app_state = Arc::new(AppState {
client,
webhook_secret,
homeserver,
access_token,
room_id,
txn_counter: AtomicU64::new(0),
});
let app = Router::new()
.route("/webhook", post(post_webhook))
.with_state(app_state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.context(format!("failed to bind to 0.0.0.0:{port}"))?;
info!(port, "listening");
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("failed to register SIGTERM handler")?;
axum::serve(listener, app)
.with_graceful_shutdown(async move {
sigterm.recv().await;
})
.await
.context("server error")?;
info!("shutdown complete");
Ok(())
}

813
remote/rss-service/Cargo.lock generated Normal file
View file

@ -0,0 +1,813 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cc"
version = "1.2.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown",
]
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "libsqlite3-sys"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "noisebell-rss"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"rusqlite",
"serde",
"serde_json",
"time",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
[[package]]
name = "num-conv"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rusqlite"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tokio"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View file

@ -0,0 +1,15 @@
[package]
name = "noisebell-rss"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
axum = "0.8"
rusqlite = { version = "0.33", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
time = { version = "0.3", features = ["formatting"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -0,0 +1,45 @@
{
description = "Noisebell - RSS/Atom feed service";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, crane, rust-overlay }:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
rustToolchain = pkgs.rust-bin.stable.latest.default;
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
src = craneLib.cleanCargoSource ./.;
commonArgs = {
inherit src;
strictDeps = true;
doCheck = false;
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
noisebell-rss = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
in
{
packages.${system}.default = noisebell-rss;
devShells.${system}.default = craneLib.devShell {
packages = [ pkgs.rust-analyzer ];
};
};
}

View file

@ -0,0 +1,236 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Json, Router};
use rusqlite::Connection;
use serde::Deserialize;
use tokio::sync::Mutex;
use tracing::{error, info};
struct AppState {
db: Arc<Mutex<Connection>>,
webhook_secret: String,
site_url: String,
}
#[derive(Deserialize)]
struct WebhookPayload {
status: String,
timestamp: u64,
}
#[derive(Deserialize)]
struct FeedQuery {
limit: Option<u32>,
}
fn unix_to_rfc3339(ts: u64) -> String {
let dt = time::OffsetDateTime::from_unix_timestamp(ts as i64).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
dt.format(&time::format_description::well_known::Rfc3339).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
fn status_description(status: &str) -> &str {
match status {
"open" => "The door at Noisebridge is open.",
"closed" => "The door at Noisebridge is closed.",
"offline" => "The Noisebridge Pi is offline.",
_ => "Unknown status.",
}
}
fn status_title(status: &str) -> &str {
match status {
"open" => "Door is open",
"closed" => "Door is closed",
"offline" => "Pi is offline",
_ => "Unknown",
}
}
fn validate_bearer(headers: &HeaderMap, expected: &str) -> bool {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or("") == expected)
.unwrap_or(false)
}
fn init_db(path: &str) -> Result<Connection> {
let conn = Connection::open(path).context("failed to open SQLite database")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL,
timestamp INTEGER NOT NULL,
received_at INTEGER NOT NULL
);",
)
.context("failed to initialize database schema")?;
Ok(conn)
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
async fn post_webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<WebhookPayload>,
) -> StatusCode {
if !validate_bearer(&headers, &state.webhook_secret) {
return StatusCode::UNAUTHORIZED;
}
let now = unix_now();
let conn = state.db.lock().await;
match conn.execute(
"INSERT INTO events (status, timestamp, received_at) VALUES (?1, ?2, ?3)",
rusqlite::params![body.status, body.timestamp, now],
) {
Ok(_) => {
info!(status = %body.status, timestamp = body.timestamp, "event recorded");
StatusCode::OK
}
Err(e) => {
error!(error = %e, "failed to insert event");
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
async fn get_feed(
State(state): State<Arc<AppState>>,
Query(query): Query<FeedQuery>,
) -> impl IntoResponse {
let limit = query.limit.unwrap_or(50);
let conn = state.db.lock().await;
let mut stmt = match conn.prepare(
"SELECT status, timestamp FROM events ORDER BY id DESC LIMIT ?1",
) {
Ok(s) => s,
Err(e) => {
error!(error = %e, "failed to prepare query");
return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
}
};
let entries: Vec<(String, u64)> = match stmt
.query_map(rusqlite::params![limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, u64>(1)?))
}) {
Ok(rows) => rows.filter_map(|r| r.ok()).collect(),
Err(e) => {
error!(error = %e, "failed to query events");
return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
}
};
let updated = entries
.first()
.map(|(_, ts)| unix_to_rfc3339(*ts))
.unwrap_or_else(|| unix_to_rfc3339(unix_now()));
let mut xml = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Noisebell Door Status</title>
<link href="{site_url}/feed" rel="self"/>
<link href="{site_url}" rel="alternate"/>
<id>urn:noisebell:door-status</id>
<updated>{updated}</updated>
"#,
site_url = state.site_url,
updated = updated,
);
for (status, timestamp) in &entries {
let ts_rfc = unix_to_rfc3339(*timestamp);
xml.push_str(&format!(
r#" <entry>
<title>{title}</title>
<id>urn:noisebell:event:{timestamp}</id>
<updated>{ts}</updated>
<content type="text">{description}</content>
</entry>
"#,
title = status_title(status),
timestamp = timestamp,
ts = ts_rfc,
description = status_description(status),
));
}
xml.push_str("</feed>\n");
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8")],
xml,
)
.into_response()
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let port: u16 = std::env::var("NOISEBELL_RSS_PORT")
.unwrap_or_else(|_| "3002".into())
.parse()
.context("NOISEBELL_RSS_PORT must be a valid u16")?;
let webhook_secret = std::env::var("NOISEBELL_RSS_WEBHOOK_SECRET")
.context("NOISEBELL_RSS_WEBHOOK_SECRET is required")?;
let data_dir =
std::env::var("NOISEBELL_RSS_DATA_DIR").unwrap_or_else(|_| "/var/lib/noisebell-rss".into());
let site_url = std::env::var("NOISEBELL_RSS_SITE_URL")
.unwrap_or_else(|_| format!("https://rss.noisebell.extremist.software"));
info!(port, "starting noisebell-rss");
let db_path = format!("{data_dir}/rss.db");
let conn = init_db(&db_path)?;
let db = Arc::new(Mutex::new(conn));
let app_state = Arc::new(AppState {
db,
webhook_secret,
site_url,
});
let app = Router::new()
.route("/webhook", post(post_webhook))
.route("/feed", get(get_feed))
.with_state(app_state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.context(format!("failed to bind to 0.0.0.0:{port}"))?;
info!(port, "listening");
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("failed to register SIGTERM handler")?;
axum::serve(listener, app)
.with_graceful_shutdown(async move {
sigterm.recv().await;
})
.await
.context("server error")?;
info!("shutdown complete");
Ok(())
}

View file

@ -0,0 +1,14 @@
let
remote = "ssh-ed25519 AAAA..."; # Remote server's SSH host public key
in
{
"pi-api-key.age".publicKeys = [ remote ];
"pi-inbound-api-key.age".publicKeys = [ remote ];
"discord-token.age".publicKeys = [ remote ];
"discord-webhook-secret.age".publicKeys = [ remote ];
"rss-webhook-secret.age".publicKeys = [ remote ];
"zulip-api-key.age".publicKeys = [ remote ];
"zulip-webhook-secret.age".publicKeys = [ remote ];
"matrix-access-token.age".publicKeys = [ remote ];
"matrix-webhook-secret.age".publicKeys = [ remote ];
}

1614
remote/zulip-bot/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
[package]
name = "noisebell-zulip"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -0,0 +1,45 @@
{
description = "Noisebell - Zulip bot";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, crane, rust-overlay }:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
rustToolchain = pkgs.rust-bin.stable.latest.default;
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
src = craneLib.cleanCargoSource ./.;
commonArgs = {
inherit src;
strictDeps = true;
doCheck = false;
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
noisebell-zulip = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
in
{
packages.${system}.default = noisebell-zulip;
devShells.${system}.default = craneLib.devShell {
packages = [ pkgs.rust-analyzer ];
};
};
}

View file

@ -0,0 +1,157 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use axum::{Json, Router};
use serde::Deserialize;
use tracing::{error, info};
#[derive(Deserialize)]
struct WebhookPayload {
status: String,
#[allow(dead_code)]
timestamp: u64,
}
struct AppState {
client: reqwest::Client,
webhook_secret: String,
server_url: String,
bot_email: String,
api_key: String,
stream: String,
topic: String,
}
fn validate_bearer(headers: &HeaderMap, expected: &str) -> bool {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or("") == expected)
.unwrap_or(false)
}
fn format_message(status: &str) -> String {
match status {
"open" => "**Door is open** at Noisebridge.".to_string(),
"closed" => "**Door is closed** at Noisebridge.".to_string(),
"offline" => "**Pi is offline** — the Noisebridge Pi is unreachable.".to_string(),
_ => format!("Unknown status: {status}"),
}
}
async fn post_webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<WebhookPayload>,
) -> StatusCode {
if !validate_bearer(&headers, &state.webhook_secret) {
return StatusCode::UNAUTHORIZED;
}
info!(status = %body.status, "received webhook");
let message = format_message(&body.status);
let url = format!("{}/api/v1/messages", state.server_url);
let result = state
.client
.post(&url)
.basic_auth(&state.bot_email, Some(&state.api_key))
.form(&[
("type", "stream"),
("to", &state.stream),
("topic", &state.topic),
("content", &message),
])
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => {
info!(status = %body.status, "message sent to Zulip");
StatusCode::OK
}
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
error!(%status, %body, "Zulip API error");
StatusCode::BAD_GATEWAY
}
Err(e) => {
error!(error = %e, "failed to contact Zulip");
StatusCode::BAD_GATEWAY
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let port: u16 = std::env::var("NOISEBELL_ZULIP_PORT")
.unwrap_or_else(|_| "3003".into())
.parse()
.context("NOISEBELL_ZULIP_PORT must be a valid u16")?;
let webhook_secret = std::env::var("NOISEBELL_ZULIP_WEBHOOK_SECRET")
.context("NOISEBELL_ZULIP_WEBHOOK_SECRET is required")?;
let server_url = std::env::var("NOISEBELL_ZULIP_SERVER_URL")
.context("NOISEBELL_ZULIP_SERVER_URL is required (e.g. https://noisebridge.zulipchat.com)")?;
let bot_email = std::env::var("NOISEBELL_ZULIP_BOT_EMAIL")
.context("NOISEBELL_ZULIP_BOT_EMAIL is required")?;
let api_key = std::env::var("NOISEBELL_ZULIP_API_KEY")
.context("NOISEBELL_ZULIP_API_KEY is required")?;
let stream = std::env::var("NOISEBELL_ZULIP_STREAM")
.unwrap_or_else(|_| "general".into());
let topic = std::env::var("NOISEBELL_ZULIP_TOPIC")
.unwrap_or_else(|_| "door status".into());
info!(port, %server_url, %stream, %topic, "starting noisebell-zulip");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("failed to build HTTP client")?;
let app_state = Arc::new(AppState {
client,
webhook_secret,
server_url,
bot_email,
api_key,
stream,
topic,
});
let app = Router::new()
.route("/webhook", post(post_webhook))
.with_state(app_state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.context(format!("failed to bind to 0.0.0.0:{port}"))?;
info!(port, "listening");
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("failed to register SIGTERM handler")?;
axum::serve(listener, app)
.with_graceful_shutdown(async move {
sigterm.recv().await;
})
.await
.context("server error")?;
info!("shutdown complete");
Ok(())
}

View file

@ -1,90 +0,0 @@
use std::env;
use std::time::Instant;
use anyhow::Result;
use serenity::all::{prelude::*, Color, CreateEmbed, CreateMessage};
use serenity::model::id::ChannelId;
use tracing::{info, error};
const COLOR_OPEN: Color = Color::new(0x00FF00); // Green for open
const COLOR_CLOSED: Color = Color::new(0xFF0000); // Red for closed
const COLOR_STARTUP: Color = Color::new(0xFFA500); // Orange for startup
pub struct DiscordClient {
client: Client,
channel_id: ChannelId,
}
impl DiscordClient {
pub async fn new() -> Result<Self> {
let token = env::var("DISCORD_TOKEN").expect("Expected DISCORD_TOKEN in environment");
// Validate token format
if let Err(e) = serenity::utils::token::validate(&token) {
return Err(anyhow::anyhow!("Invalid Discord token format: {}", e));
}
let channel_id = env::var("DISCORD_CHANNEL_ID")
.expect("Expected DISCORD_CHANNEL_ID in environment")
.parse::<u64>()?;
let intents = GatewayIntents::GUILD_MESSAGES;
let client = Client::builder(&token, intents)
.await
.expect("Error creating Discord client");
Ok(Self {
client,
channel_id: ChannelId::new(channel_id),
})
}
pub async fn send_circuit_event(&self, event: &crate::gpio::CircuitEvent) -> Result<()> {
let start = Instant::now();
info!("Sending Discord message for circuit event: {:?}", event);
let embed = CreateEmbed::new()
.title(format!("Noisebridge is {}!", event))
.description(match event {
crate::gpio::CircuitEvent::Open => "It's time to start hacking.",
crate::gpio::CircuitEvent::Closed => "We'll see you again soon.",
})
.color(match event {
crate::gpio::CircuitEvent::Open => COLOR_OPEN,
crate::gpio::CircuitEvent::Closed => COLOR_CLOSED,
}).thumbnail(match event {
crate::gpio::CircuitEvent::Open => "https://www.noisebridge.net/images/7/7f/Open.png",
crate::gpio::CircuitEvent::Closed => "https://www.noisebridge.net/images/c/c9/Closed.png",
});
if let Err(why) = self.channel_id.send_message(&self.client.http, CreateMessage::default().add_embed(embed)).await {
error!("Error sending Discord message: {:?}", why);
return Err(anyhow::anyhow!("Failed to send Discord message: {}", why));
}
let duration = start.elapsed();
info!("Discord message sent successfully in {:?}", duration);
Ok(())
}
pub async fn send_startup_message(&self) -> Result<()> {
let start = Instant::now();
info!("Sending Discord startup message");
let embed = CreateEmbed::new()
.title("Noisebell is starting up!")
.description("The Noisebell service is initializing and will begin monitoring the space status.")
.color(COLOR_STARTUP)
.thumbnail("https://cats.com/wp-content/uploads/2024/07/Beautiful-red-cat-stretches-and-shows-tongue.jpg");
if let Err(why) = self.channel_id.send_message(&self.client.http, CreateMessage::default().add_embed(embed)).await {
error!("Error sending Discord startup message: {:?}", why);
return Err(anyhow::anyhow!("Failed to send Discord startup message: {}", why));
}
let duration = start.elapsed();
info!("Discord startup message sent successfully in {:?}", duration);
Ok(())
}
}

View file

@ -1,64 +0,0 @@
use std::time::Duration;
use std::fmt;
use serde::{Serialize, Deserialize};
use anyhow::{Result, Context};
use rppal::gpio::{Gpio, InputPin, Level};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CircuitEvent {
Open,
Closed,
}
impl fmt::Display for CircuitEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CircuitEvent::Open => write!(f, "open"),
CircuitEvent::Closed => write!(f, "closed"),
}
}
}
pub struct GpioMonitor {
pin: InputPin,
poll_interval: Duration,
}
impl GpioMonitor {
pub fn new(pin_number: u8, poll_interval: Duration) -> Result<Self> {
let gpio = Gpio::new()
.context("Failed to initialize GPIO")?;
let pin = gpio.get(pin_number)
.context(format!("Failed to get GPIO pin {}", pin_number))?
.into_input_pullup();
Ok(Self { pin, poll_interval })
}
pub async fn monitor<F>(&mut self, mut callback: F) -> Result<()>
where
F: FnMut(CircuitEvent) + Send + 'static,
{
let mut previous_state = self.get_current_state();
callback(previous_state); // Send initial state
loop {
let current_state = self.get_current_state();
if current_state != previous_state {
callback(current_state);
previous_state = current_state;
}
tokio::time::sleep(self.poll_interval).await;
}
}
pub fn get_current_state(&self) -> CircuitEvent {
match self.pin.read() {
Level::Low => CircuitEvent::Open,
Level::High => CircuitEvent::Closed,
}
}
}

View file

@ -1,78 +0,0 @@
mod gpio;
mod discord;
use std::fs;
use std::time::Duration;
use std::sync::Arc;
use anyhow::Result;
use tracing::{error, info};
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt};
const DEFAULT_GPIO_PIN: u8 = 17;
const DEFAULT_POLL_INTERVAL_MS: u64 = 100;
const LOG_DIR: &str = "logs";
const LOG_PREFIX: &str = "noisebell";
const LOG_SUFFIX: &str = "log";
const MAX_LOG_FILES: usize = 7;
#[tokio::main]
async fn main() -> Result<()> {
info!("creating logs directory");
fs::create_dir_all(LOG_DIR)?;
info!("initializing logging");
let file_appender = RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.filename_prefix(LOG_PREFIX)
.filename_suffix(LOG_SUFFIX)
.max_log_files(MAX_LOG_FILES)
.build(LOG_DIR)?;
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
// Only show our logs and hide hyper logs
let filter = tracing_subscriber::filter::Targets::new()
.with_target("noisebell", LevelFilter::INFO)
.with_target("hyper", LevelFilter::WARN)
.with_target("hyper_util", LevelFilter::WARN);
tracing_subscriber::registry()
.with(filter)
.with(fmt::Layer::default().with_writer(std::io::stdout))
.with(fmt::Layer::default().with_writer(non_blocking))
.init();
info!("initializing Discord client");
let discord_client = discord::DiscordClient::new().await?;
let discord_client = Arc::new(discord_client);
discord_client.send_startup_message().await?;
info!("initializing gpio monitor");
let mut gpio_monitor = gpio::GpioMonitor::new(
DEFAULT_GPIO_PIN,
Duration::from_millis(DEFAULT_POLL_INTERVAL_MS)
)?;
// Set up the callback for state changes
let callback = move |event: gpio::CircuitEvent| {
info!("Circuit state changed to: {:?}", event);
let discord_client = discord_client.clone();
tokio::spawn(async move {
if let Err(e) = discord_client.send_circuit_event(&event).await {
error!("Failed to send Discord message: {}", e);
}
});
};
// Start monitoring - this will block until an error occurs
if let Err(e) = gpio_monitor.monitor(callback).await {
error!("GPIO monitoring error: {}", e);
return Err(anyhow::anyhow!("GPIO monitoring failed"));
}
Ok(())
}