Compare commits

..

No commits in common. "460864912c71937178bc258e32510cafd6f6479e" and "3a0d464234b2df01be85da317b2dbbf54e6f4344" have entirely different histories.

18 changed files with 81 additions and 595 deletions

13
Cargo.lock generated
View file

@ -906,19 +906,6 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "noisebell-relay"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"noisebell-common",
"reqwest",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "noisebell-rss" name = "noisebell-rss"
version = "0.1.0" version = "0.1.0"

View file

@ -1,7 +1,6 @@
[workspace] [workspace]
members = [ members = [
"pi/pi-service", "pi/pi-service",
"pi/pi-relay",
"remote/noisebell-common", "remote/noisebell-common",
"remote/cache-service", "remote/cache-service",
"remote/rss-service", "remote/rss-service",

View file

@ -1,7 +1,5 @@
# Noisebell # Noisebell
[![Space status](https://your-cache-domain.example.com/badge.svg)](https://your-cache-domain.example.com/status)
Monitors the door at [Noisebridge](https://www.noisebridge.net) and tells you whether it's open or closed. Monitors the door at [Noisebridge](https://www.noisebridge.net) and tells you whether it's open or closed.
A Raspberry Pi reads a magnetic sensor on the door and pushes state changes to a cache server. The cache keeps the latest state and fans updates out to chat integrations such as Discord and Zulip. A Raspberry Pi reads a magnetic sensor on the door and pushes state changes to a cache server. The cache keeps the latest state and fans updates out to chat integrations such as Discord and Zulip.

View file

@ -108,23 +108,6 @@
cargoExtraArgs = "-p noisebell"; cargoExtraArgs = "-p noisebell";
}; };
piRelayArgs = {
inherit src;
pname = "noisebell-pi-relay";
version = "0.1.0";
strictDeps = true;
doCheck = false;
CARGO_BUILD_TARGET = "aarch64-unknown-linux-gnu";
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER = "${crossPkgs.stdenv.cc.targetPrefix}cc";
TARGET_CC = "${crossPkgs.stdenv.cc.targetPrefix}cc";
CC_aarch64_unknown_linux_gnu = "${crossPkgs.stdenv.cc.targetPrefix}cc";
HOST_CC = "${pkgs.stdenv.cc.nativePrefix}cc";
depsBuildBuild = [ crossPkgs.stdenv.cc ];
cargoExtraArgs = "-p noisebell-relay";
};
piArtifacts = piCraneLib.buildDepsOnly piArgs; piArtifacts = piCraneLib.buildDepsOnly piArgs;
noisebell-pi = piCraneLib.buildPackage ( noisebell-pi = piCraneLib.buildPackage (
@ -134,15 +117,6 @@
} }
); );
piRelayArtifacts = piCraneLib.buildDepsOnly piRelayArgs;
noisebell-pi-relay = piCraneLib.buildPackage (
piRelayArgs
// {
cargoArtifacts = piRelayArtifacts;
}
);
piStaticArgs = { piStaticArgs = {
inherit src; inherit src;
pname = "noisebell-pi-static"; pname = "noisebell-pi-static";
@ -160,23 +134,6 @@
cargoExtraArgs = "-p noisebell"; cargoExtraArgs = "-p noisebell";
}; };
piRelayStaticArgs = {
inherit src;
pname = "noisebell-pi-relay-static";
version = "0.1.0";
strictDeps = true;
doCheck = false;
CARGO_BUILD_TARGET = "aarch64-unknown-linux-musl";
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER = "${muslPkgs.stdenv.cc.targetPrefix}cc";
TARGET_CC = "${muslPkgs.stdenv.cc.targetPrefix}cc";
CC_aarch64_unknown_linux_musl = "${muslPkgs.stdenv.cc.targetPrefix}cc";
HOST_CC = "${pkgs.stdenv.cc.nativePrefix}cc";
depsBuildBuild = [ muslPkgs.stdenv.cc ];
cargoExtraArgs = "-p noisebell-relay";
};
piStaticArtifacts = piCraneLib.buildDepsOnly piStaticArgs; piStaticArtifacts = piCraneLib.buildDepsOnly piStaticArgs;
noisebell-pi-static = piCraneLib.buildPackage ( noisebell-pi-static = piCraneLib.buildPackage (
@ -186,15 +143,6 @@
} }
); );
piRelayStaticArtifacts = piCraneLib.buildDepsOnly piRelayStaticArgs;
noisebell-pi-relay-static = piCraneLib.buildPackage (
piRelayStaticArgs
// {
cargoArtifacts = piRelayStaticArtifacts;
}
);
piImageBaseModule = piImageBaseModule =
{ {
lib, lib,
@ -411,9 +359,7 @@
packages.aarch64-linux = { packages.aarch64-linux = {
noisebell = noisebell-pi; noisebell = noisebell-pi;
noisebell-relay = noisebell-pi-relay;
noisebell-static = noisebell-pi-static; noisebell-static = noisebell-pi-static;
noisebell-relay-static = noisebell-pi-relay-static;
default = noisebell-pi; default = noisebell-pi;
}; };

View file

@ -111,32 +111,25 @@ scripts/deploy-pios-pi.sh pi@10.21.x.x
That script: That script:
1. builds `.#packages.aarch64-linux.noisebell-static` locally 1. builds `.#packages.aarch64-linux.noisebell-static` locally
2. builds `.#packages.aarch64-linux.noisebell-relay-static` locally 2. decrypts the Pi-facing secrets locally with `agenix`
3. decrypts the Pi-facing secrets locally with `agenix` 3. uploads the binary and secrets to the Pi
4. uploads the binaries and secrets to the Pi 4. installs Tailscale and Avahi if needed
5. installs Tailscale and Avahi if needed 5. writes `/etc/noisebell/noisebell.env`
6. writes `/etc/noisebell/noisebell.env` 6. installs `noisebell.service`
7. writes `/etc/noisebell/noisebell-relay.env` 7. enables and starts the service
8. installs `noisebell.service` and `noisebell-relay.service` 8. runs `tailscale up` with the decrypted auth key
9. enables and starts both services
10. runs `tailscale up` with the decrypted auth key
## Files written on the Pi ## Files written on the Pi
The deploy script creates: The deploy script creates:
- `/opt/noisebell/releases/<timestamp>/noisebell` - `/opt/noisebell/releases/<timestamp>/noisebell`
- `/opt/noisebell/releases/<timestamp>/noisebell-relay`
- `/opt/noisebell/current` -> current release symlink - `/opt/noisebell/current` -> current release symlink
- `/etc/noisebell/pi-to-cache-key` - `/etc/noisebell/pi-to-cache-key`
- `/etc/noisebell/cache-to-pi-key` - `/etc/noisebell/cache-to-pi-key`
- `/etc/noisebell/relay-webhook-secret`
- `/etc/noisebell/homeassistant-webhook-id`
- `/etc/noisebell/tailscale-auth-key` - `/etc/noisebell/tailscale-auth-key`
- `/etc/noisebell/noisebell.env` - `/etc/noisebell/noisebell.env`
- `/etc/noisebell/noisebell-relay.env`
- `/etc/systemd/system/noisebell.service` - `/etc/systemd/system/noisebell.service`
- `/etc/systemd/system/noisebell-relay.service`
All secret files are root-only. All secret files are root-only.
@ -178,34 +171,6 @@ The deployed service uses these environment variables:
| `NOISEBELL_BIND_ADDRESS` | `0.0.0.0` | HTTP bind address | | `NOISEBELL_BIND_ADDRESS` | `0.0.0.0` | HTTP bind address |
| `NOISEBELL_ACTIVE_LOW` | `true` | Low GPIO = door open | | `NOISEBELL_ACTIVE_LOW` | `true` | Low GPIO = door open |
## Relay service configuration
The optional relay service accepts authenticated webhooks from cache-service and forwards them to Home Assistant on the local network.
| Variable | Default | Description |
|---|---|---|
| `NOISEBELL_RELAY_PORT` | `8090` | HTTP port for the relay webhook endpoint |
| `NOISEBELL_RELAY_BIND_ADDRESS` | `0.0.0.0` | HTTP bind address |
| `NOISEBELL_RELAY_TARGET_BASE_URL` | `http://homeassistant.local:8123` | Base URL for Home Assistant |
| `NOISEBELL_RELAY_TARGET_WEBHOOK_ID` | required | Home Assistant webhook ID |
| `NOISEBELL_RELAY_INBOUND_API_KEY` | required | Bearer token expected from cache-service |
| `NOISEBELL_RELAY_RETRY_ATTEMPTS` | `3` | Forward retry count |
| `NOISEBELL_RELAY_RETRY_BASE_DELAY_SECS` | `1` | Exponential backoff base delay |
| `NOISEBELL_RELAY_HTTP_TIMEOUT_SECS` | `10` | Outbound request timeout |
Example cache target for the relay:
```nix
{
services.noisebell-cache.outboundWebhooks = [
{
url = "http://noisebell-pi.local:8090/webhook";
secretFile = /run/agenix/noisebell-relay-webhook-secret;
}
];
}
```
## API ## API
All endpoints require `Authorization: Bearer <token>`. All endpoints require `Authorization: Bearer <token>`.

View file

@ -1,16 +0,0 @@
[package]
name = "noisebell-relay"
version = "0.1.0"
edition = "2021"
[lints]
workspace = true
[dependencies]
anyhow = "1.0"
axum = "0.8"
noisebell-common = { path = "../../remote/noisebell-common" }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -1,148 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::{get, post};
use axum::{Json, Router};
use noisebell_common::{validate_bearer, WebhookPayload};
use tracing::{error, info, warn};
#[derive(Clone)]
struct AppState {
client: reqwest::Client,
inbound_api_key: String,
target_url: String,
target_secret: Option<String>,
retry_attempts: u32,
retry_base_delay_secs: u64,
}
async fn post_webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(payload): Json<WebhookPayload>,
) -> StatusCode {
if !validate_bearer(&headers, &state.inbound_api_key) {
return StatusCode::UNAUTHORIZED;
}
info!(status = %payload.status, timestamp = payload.timestamp, "relay received webhook");
for attempt in 0..=state.retry_attempts {
let mut req = state.client.post(&state.target_url).json(&payload);
if let Some(secret) = &state.target_secret {
req = req.bearer_auth(secret);
}
match req.send().await {
Ok(resp) if resp.status().is_success() => {
info!(status = %payload.status, "relay forwarded webhook");
return StatusCode::OK;
}
result => {
let err_msg = match &result {
Ok(resp) => format!("HTTP {}", resp.status()),
Err(err) => err.to_string(),
};
if attempt == state.retry_attempts {
error!(error = %err_msg, "relay failed to forward webhook after {} attempts", state.retry_attempts + 1);
return StatusCode::BAD_GATEWAY;
}
let delay = Duration::from_secs(state.retry_base_delay_secs * 2u64.pow(attempt));
warn!(error = %err_msg, attempt = attempt + 1, "relay forward failed, retrying in {:?}", delay);
tokio::time::sleep(delay).await;
}
}
}
StatusCode::BAD_GATEWAY
}
async fn health() -> StatusCode {
StatusCode::OK
}
#[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_RELAY_PORT")
.unwrap_or_else(|_| "8090".into())
.parse()
.context("NOISEBELL_RELAY_PORT must be a valid u16")?;
let bind_address =
std::env::var("NOISEBELL_RELAY_BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0".into());
let inbound_api_key = std::env::var("NOISEBELL_RELAY_INBOUND_API_KEY")
.context("NOISEBELL_RELAY_INBOUND_API_KEY is required")?;
let target_base_url = std::env::var("NOISEBELL_RELAY_TARGET_BASE_URL")
.unwrap_or_else(|_| "http://homeassistant.local:8123".into())
.trim_end_matches('/')
.to_string();
let target_webhook_id = std::env::var("NOISEBELL_RELAY_TARGET_WEBHOOK_ID")
.context("NOISEBELL_RELAY_TARGET_WEBHOOK_ID is required")?;
let target_secret =
std::env::var("NOISEBELL_RELAY_TARGET_SECRET").ok().filter(|value| !value.is_empty());
let retry_attempts: u32 = std::env::var("NOISEBELL_RELAY_RETRY_ATTEMPTS")
.unwrap_or_else(|_| "3".into())
.parse()
.context("NOISEBELL_RELAY_RETRY_ATTEMPTS must be a valid u32")?;
let retry_base_delay_secs: u64 = std::env::var("NOISEBELL_RELAY_RETRY_BASE_DELAY_SECS")
.unwrap_or_else(|_| "1".into())
.parse()
.context("NOISEBELL_RELAY_RETRY_BASE_DELAY_SECS must be a valid u64")?;
let http_timeout_secs: u64 = std::env::var("NOISEBELL_RELAY_HTTP_TIMEOUT_SECS")
.unwrap_or_else(|_| "10".into())
.parse()
.context("NOISEBELL_RELAY_HTTP_TIMEOUT_SECS must be a valid u64")?;
let target_url = format!("{target_base_url}/api/webhook/{target_webhook_id}");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(http_timeout_secs))
.build()
.context("failed to build HTTP client")?;
let state = Arc::new(AppState {
client,
inbound_api_key,
target_url,
target_secret,
retry_attempts,
retry_base_delay_secs,
});
let app = Router::new()
.route("/health", get(health))
.route("/webhook", post(post_webhook))
.with_state(state);
let listener = tokio::net::TcpListener::bind((&*bind_address, port))
.await
.context(format!("failed to bind to {bind_address}:{port}"))?;
info!(port, "relay 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("relay server error")?;
Ok(())
}

View file

@ -69,7 +69,6 @@ The flake exports a NixOS module for the hosted remote machine. It imports `agen
| `secrets/pi-to-cache-key.age` | Pi + remote | Pi authenticates to cache `/webhook` | | `secrets/pi-to-cache-key.age` | Pi + remote | Pi authenticates to cache `/webhook` |
| `secrets/cache-to-pi-key.age` | Pi + remote | cache authenticates to Pi GET endpoints | | `secrets/cache-to-pi-key.age` | Pi + remote | cache authenticates to Pi GET endpoints |
| `secrets/discord-webhook-secret.age` | remote | cache authenticates to Discord bot `/webhook` | | `secrets/discord-webhook-secret.age` | remote | cache authenticates to Discord bot `/webhook` |
| `secrets/relay-webhook-secret.age` | Pi + remote | cache authenticates to the Pi relay `/webhook` |
| `secrets/zulip-webhook-secret.age` | remote | cache authenticates to Zulip bridge `/webhook` | | `secrets/zulip-webhook-secret.age` | remote | cache authenticates to Zulip bridge `/webhook` |
| `secrets/discord-token.age` | remote | Discord bot login | | `secrets/discord-token.age` | remote | Discord bot login |
| `secrets/zulip-api-key.age` | remote | Zulip bot API authentication | | `secrets/zulip-api-key.age` | remote | Zulip bot API authentication |

View file

@ -11,24 +11,11 @@ If the Pi stops responding to polls (configurable threshold, default 3 misses),
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| |--------|------|------|-------------|
| `GET` | `/status` | — | Current door status (`status`, `since`, `last_checked`) | | `GET` | `/status` | — | Current door status (`status`, `since`, `last_checked`) |
| `GET` | `/badge.svg` | — | Live README badge with Noisebridge logo |
| `POST` | `/webhook` | Bearer | Inbound webhook from the Pi | | `POST` | `/webhook` | Bearer | Inbound webhook from the Pi |
| `GET` | `/health` | — | Health check | | `GET` | `/health` | — | Health check |
`since` is the Pi-reported time when the current state began. `last_checked` is when the cache most recently attempted a poll. `since` is the Pi-reported time when the current state began. `last_checked` is when the cache most recently attempted a poll.
## Badge
`/badge.svg` serves a classic shields.io-style SVG badge with the Noisebridge logo and the current cache status (`open`, `closed`, or `offline`).
Use it in a GitHub README like this:
```md
[![Space status](https://your-cache-domain.example.com/badge.svg)](https://your-cache-domain.example.com/status)
```
That keeps the badge clickable and sends readers to the live `/status` endpoint.
## Configuration ## Configuration
NixOS options under `services.noisebell-cache`: NixOS options under `services.noisebell-cache`:

View file

@ -18,86 +18,6 @@ static OPEN_PNG: &[u8] = include_bytes!("../assets/open.png");
static CLOSED_PNG: &[u8] = include_bytes!("../assets/closed.png"); static CLOSED_PNG: &[u8] = include_bytes!("../assets/closed.png");
static OFFLINE_PNG: &[u8] = include_bytes!("../assets/offline.png"); static OFFLINE_PNG: &[u8] = include_bytes!("../assets/offline.png");
const BADGE_LABEL: &str = "space";
const BADGE_HEIGHT: usize = 20;
const BADGE_LOGO_WIDTH: usize = 21;
const BADGE_LEFT_PADDING: usize = 6;
const BADGE_RIGHT_PADDING: usize = 10;
const BADGE_FONT_FAMILY: &str = "Verdana,Geneva,DejaVu Sans,sans-serif";
const BADGE_LOGO_PATH: &str = "M215.863,155.875V65.776l-8.2,5.819l-22.357,15.869h-5.008V56.782c0.002-5.218-2.145-9.984-5.566-13.397c-3.412-3.421-8.177-5.567-13.396-5.565h-25.241c-1.08-6-3.964-11.391-8.092-15.517c-5.284-5.292-12.638-8.58-20.709-8.577c-8.072-0.003-15.427,3.286-20.71,8.579c-4.13,4.124-7.012,9.515-8.09,15.515H53.25c-5.218-0.001-9.983,2.144-13.396,5.565c-3.421,3.413-5.566,8.179-5.565,13.397v15.939L0.498,81.89l39.388,11.699L1.257,105.063l38.629,11.471L1.257,128.007L39.95,139.5l-5.661,1.694v23.675c-0.001,5.22,2.145,9.985,5.565,13.398c3.414,3.42,8.179,5.566,13.397,5.564h35.144v-5.194c0.001-5.234,2.105-9.927,5.533-13.366c3.437-3.429,8.129-5.533,13.365-5.533c5.234,0.004,9.927,2.107,13.362,5.533c3.429,3.439,5.533,8.132,5.536,13.366v5.194h35.143c5.221,0.002,9.985-2.145,13.397-5.564c3.421-3.416,5.566-8.181,5.566-13.398v-30.688h5.006L215.863,155.875z M192.152,126.306V95.344l13.321-9.455v49.872L192.152,126.306z M181.764,123.796h-21.126V97.854h21.126V123.796z M169.908,164.869c-0.002,2.356-0.954,4.476-2.523,6.053c-1.575,1.57-3.696,2.52-6.051,2.521h-25.241c-1.078-6.002-3.962-11.394-8.091-15.517c-5.287-5.29-12.641-8.58-20.71-8.576c-8.072-0.004-15.426,3.285-20.711,8.576c-4.128,4.123-7.012,9.515-8.09,15.517H53.25c-2.354-0.002-4.473-0.952-6.05-2.521c-1.57-1.577-2.521-3.699-2.523-6.053v-15.94l31.632-9.469l-38.564-11.453l38.628-11.473l-38.628-11.471l38.628-11.474L38.504,82.341l6.173-1.674V56.782c0.002-2.354,0.953-4.473,2.523-6.05c1.577-1.57,3.697-2.522,6.05-2.523h35.145v-5.194c0.001-5.236,2.105-9.927,5.533-13.364c3.437-3.428,8.129-5.532,13.364-5.535c5.235,0.001,9.925,2.107,13.361,5.535c3.431,3.438,5.535,8.13,5.535,13.364v5.194h35.145c2.354,0.001,4.474,0.953,6.051,2.523c1.57,1.578,2.522,3.696,2.522,6.05v30.684h-19.66v46.719h19.66v30.685H169.908z";
fn escape_xml(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn badge_color(status: DoorStatus) -> &'static str {
match status {
DoorStatus::Open => "#34a853",
DoorStatus::Closed => "#e05d44",
DoorStatus::Offline => "#9f9f9f",
}
}
fn badge_text_width(text: &str) -> usize {
text.chars().count() * 7 + BADGE_RIGHT_PADDING
}
fn render_badge_svg(status: DoorStatus, summary: &str) -> String {
let message = status.as_str();
let label_width = BADGE_LEFT_PADDING + BADGE_LOGO_WIDTH + badge_text_width(BADGE_LABEL);
let message_width = badge_text_width(message);
let total_width = label_width + message_width;
let label_text_x = BADGE_LEFT_PADDING + BADGE_LOGO_WIDTH;
let label_center = label_text_x + (badge_text_width(BADGE_LABEL) / 2);
let message_center = label_width + (message_width / 2);
let escaped_summary = escape_xml(summary);
format!(
concat!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total_width}\" height=\"{height}\" role=\"img\" aria-label=\"space status: {message}\">",
"<title>{summary}</title>",
"<linearGradient id=\"b\" x2=\"0\" y2=\"100%\">",
"<stop offset=\"0\" stop-color=\"#fff\" stop-opacity=\".7\"/>",
"<stop offset=\".1\" stop-opacity=\".1\"/>",
"<stop offset=\".9\" stop-opacity=\".3\"/>",
"<stop offset=\"1\" stop-opacity=\".5\"/>",
"</linearGradient>",
"<mask id=\"a\"><rect width=\"{total_width}\" height=\"{height}\" rx=\"3\" fill=\"#fff\"/></mask>",
"<g mask=\"url(#a)\">",
"<rect width=\"{label_width}\" height=\"{height}\" fill=\"#555\"/>",
"<rect x=\"{label_width}\" width=\"{message_width}\" height=\"{height}\" fill=\"{color}\"/>",
"<rect width=\"{total_width}\" height=\"{height}\" fill=\"url(#b)\"/>",
"</g>",
"<g fill=\"#fff\" text-anchor=\"middle\" font-family=\"{font_family}\" font-size=\"11\">",
"<g transform=\"translate({logo_x} 3) scale(0.055)\">",
"<path fill=\"#eb2026\" d=\"{logo_path}\"/>",
"</g>",
"<text x=\"{label_center}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{label}</text>",
"<text x=\"{label_center}\" y=\"14\">{label}</text>",
"<text x=\"{message_center}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{message}</text>",
"<text x=\"{message_center}\" y=\"14\">{message}</text>",
"</g></svg>",
),
total_width = total_width,
height = BADGE_HEIGHT,
label = BADGE_LABEL,
message = message,
summary = escaped_summary,
label_width = label_width,
message_width = message_width,
color = badge_color(status),
font_family = BADGE_FONT_FAMILY,
logo_x = BADGE_LEFT_PADDING,
logo_path = BADGE_LOGO_PATH,
label_center = label_center,
message_center = message_center,
)
}
pub struct AppState { pub struct AppState {
pub db: Arc<Mutex<rusqlite::Connection>>, pub db: Arc<Mutex<rusqlite::Connection>>,
pub client: reqwest::Client, pub client: reqwest::Client,
@ -324,35 +244,6 @@ pub async fn get_image(State(state): State<Arc<AppState>>) -> Response {
.into_response() .into_response()
} }
pub async fn get_badge(State(state): State<Arc<AppState>>) -> Response {
let db = state.db.clone();
let status = match tokio::task::spawn_blocking(move || {
let conn = db.blocking_lock();
db::get_status(&conn)
})
.await
.expect("db task panicked")
{
Ok(status) => status,
Err(e) => {
error!(error = %e, "failed to get status for badge");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let summary = status_summary(status.status, status.since, status.last_checked, unix_now());
let badge = render_badge_svg(status.status, &summary);
(
[
(header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=5"),
],
badge,
)
.into_response()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -373,16 +264,4 @@ mod tests {
assert!(summary.contains("Last checked")); assert!(summary.contains("Last checked"));
assert!(summary.contains("55 seconds ago")); assert!(summary.contains("55 seconds ago"));
} }
#[test]
fn render_badge_svg_includes_status_and_summary() {
let svg = render_badge_svg(DoorStatus::Offline, "Cache can't reach the space right now.");
assert!(svg.contains("aria-label=\"space status: offline\""));
assert!(svg.contains(">space<"));
assert!(svg.contains(">offline<"));
assert!(svg.contains("Cache can&apos;t reach the space right now."));
assert!(svg.contains("#9f9f9f"));
assert!(svg.contains("#eb2026"));
}
} }

View file

@ -127,7 +127,6 @@ async fn main() -> Result<()> {
.route("/health", get(api::health)) .route("/health", get(api::health))
.route("/webhook", post(api::post_webhook)) .route("/webhook", post(api::post_webhook))
.route("/status", get(api::get_status)) .route("/status", get(api::get_status))
.route("/badge.svg", get(api::get_badge))
.route("/image", get(api::get_image)) .route("/image", get(api::get_image))
.route("/image/open.png", get(api::get_image_open)) .route("/image/open.png", get(api::get_image_open))
.route("/image/closed.png", get(api::get_image_closed)) .route("/image/closed.png", get(api::get_image_closed))

View file

@ -12,20 +12,10 @@ in
users.groups.noisebell = { }; users.groups.noisebell = { };
users.users = lib.mkMerge [ users.users.noisebell-cache.extraGroups = lib.mkIf cfgCache.enable [ "noisebell" ];
(lib.mkIf cfgCache.enable { users.users.noisebell-rss.extraGroups = lib.mkIf cfgRss.enable [ "noisebell" ];
noisebell-cache.extraGroups = [ "noisebell" ]; users.users.noisebell-discord.extraGroups = lib.mkIf cfgDiscord.enable [ "noisebell" ];
}) users.users.noisebell-zulip.extraGroups = lib.mkIf cfgZulip.enable [ "noisebell" ];
(lib.mkIf cfgRss.enable {
noisebell-rss.extraGroups = [ "noisebell" ];
})
(lib.mkIf cfgDiscord.enable {
noisebell-discord.extraGroups = [ "noisebell" ];
})
(lib.mkIf cfgZulip.enable {
noisebell-zulip.extraGroups = [ "noisebell" ];
})
];
age.secrets.noisebell-pi-to-cache-key = { age.secrets.noisebell-pi-to-cache-key = {
file = "${self}/secrets/pi-to-cache-key.age"; file = "${self}/secrets/pi-to-cache-key.age";
@ -57,12 +47,6 @@ in
mode = "0440"; mode = "0440";
}; };
age.secrets.noisebell-relay-webhook-secret = {
file = "${self}/secrets/relay-webhook-secret.age";
group = "noisebell";
mode = "0440";
};
age.secrets.noisebell-zulip-webhook-secret = { age.secrets.noisebell-zulip-webhook-secret = {
file = "${self}/secrets/zulip-webhook-secret.age"; file = "${self}/secrets/zulip-webhook-secret.age";
group = "noisebell"; group = "noisebell";
@ -84,30 +68,30 @@ in
); );
}; };
services.noisebell-rss.cacheUrl = lib.mkIf (cfgRss.enable && cfgCache.enable) ( services.noisebell-rss = lib.mkIf cfgRss.enable (
lib.mkDefault "http://127.0.0.1:${toString cfgCache.port}" lib.optionalAttrs cfgCache.enable {
cacheUrl = lib.mkDefault "http://127.0.0.1:${toString cfgCache.port}";
}
); );
services.noisebell-discord.discordTokenFile = lib.mkIf cfgDiscord.enable ( services.noisebell-discord = lib.mkIf cfgDiscord.enable (
lib.mkDefault config.age.secrets.noisebell-discord-token.path {
); discordTokenFile = lib.mkDefault config.age.secrets.noisebell-discord-token.path;
services.noisebell-discord.webhookSecretFile = lib.mkIf cfgDiscord.enable ( webhookSecretFile = lib.mkDefault config.age.secrets.noisebell-discord-webhook-secret.path;
lib.mkDefault config.age.secrets.noisebell-discord-webhook-secret.path }
); // lib.optionalAttrs cfgCache.enable {
services.noisebell-discord.cacheUrl = lib.mkIf (cfgDiscord.enable && cfgCache.enable) ( cacheUrl = lib.mkDefault "http://127.0.0.1:${toString cfgCache.port}";
lib.mkDefault "http://127.0.0.1:${toString cfgCache.port}" imageBaseUrl = lib.mkDefault "https://${cfgCache.domain}/image";
); }
services.noisebell-discord.imageBaseUrl = lib.mkIf (cfgDiscord.enable && cfgCache.enable) (
lib.mkDefault "https://${cfgCache.domain}/image"
); );
services.noisebell-zulip.apiKeyFile = lib.mkIf cfgZulip.enable ( services.noisebell-zulip = lib.mkIf cfgZulip.enable (
lib.mkDefault config.age.secrets.noisebell-zulip-api-key.path {
); apiKeyFile = lib.mkDefault config.age.secrets.noisebell-zulip-api-key.path;
services.noisebell-zulip.webhookSecretFile = lib.mkIf cfgZulip.enable ( webhookSecretFile = lib.mkDefault config.age.secrets.noisebell-zulip-webhook-secret.path;
lib.mkDefault config.age.secrets.noisebell-zulip-webhook-secret.path }
); // lib.optionalAttrs cfgCache.enable {
services.noisebell-zulip.imageBaseUrl = lib.mkIf (cfgZulip.enable && cfgCache.enable) ( imageBaseUrl = lib.mkDefault "https://${cfgCache.domain}/image";
lib.mkDefault "https://${cfgCache.domain}/image" }
); );
} }

View file

@ -10,9 +10,8 @@ in
enable = lib.mkEnableOption "noisebell Zulip bridge"; enable = lib.mkEnableOption "noisebell Zulip bridge";
domain = lib.mkOption { domain = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.str;
default = null; description = "Domain for the Caddy virtual host.";
description = "Optional domain for the Caddy virtual host.";
}; };
port = lib.mkOption { port = lib.mkOption {
@ -58,14 +57,17 @@ in
}; };
}; };
config = lib.mkIf cfg.enable ( config = lib.mkIf cfg.enable {
{
users.users.noisebell-zulip = { users.users.noisebell-zulip = {
isSystemUser = true; isSystemUser = true;
group = "noisebell-zulip"; group = "noisebell-zulip";
}; };
users.groups.noisebell-zulip = { }; users.groups.noisebell-zulip = { };
services.caddy.virtualHosts.${cfg.domain}.extraConfig = ''
reverse_proxy localhost:${toString cfg.port}
'';
systemd.services.noisebell-zulip = { systemd.services.noisebell-zulip = {
description = "Noisebell Zulip bridge"; description = "Noisebell Zulip bridge";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@ -101,11 +103,5 @@ in
RestrictSUIDSGID = true; RestrictSUIDSGID = true;
}; };
}; };
} };
// lib.mkIf (cfg.domain != null) {
services.caddy.virtualHosts.${cfg.domain}.extraConfig = ''
reverse_proxy localhost:${toString cfg.port}
'';
}
);
} }

View file

@ -23,19 +23,12 @@ SSH_OPTS=(
echo "Building static aarch64 Noisebell binary locally..." echo "Building static aarch64 Noisebell binary locally..."
PACKAGE_PATH=$(nix build .#packages.aarch64-linux.noisebell-static --print-out-paths --no-link) PACKAGE_PATH=$(nix build .#packages.aarch64-linux.noisebell-static --print-out-paths --no-link)
BIN_PATH="$PACKAGE_PATH/bin/noisebell" BIN_PATH="$PACKAGE_PATH/bin/noisebell"
RELAY_PACKAGE_PATH=$(nix build .#packages.aarch64-linux.noisebell-relay-static --print-out-paths --no-link)
RELAY_BIN_PATH="$RELAY_PACKAGE_PATH/bin/noisebell-relay"
if [[ ! -x "$BIN_PATH" ]]; then if [[ ! -x "$BIN_PATH" ]]; then
echo "built binary not found: $BIN_PATH" >&2 echo "built binary not found: $BIN_PATH" >&2
exit 1 exit 1
fi fi
if [[ ! -x "$RELAY_BIN_PATH" ]]; then
echo "built relay binary not found: $RELAY_BIN_PATH" >&2
exit 1
fi
if ! command -v agenix >/dev/null 2>&1; then if ! command -v agenix >/dev/null 2>&1; then
echo "agenix is required in your shell to decrypt secrets locally" >&2 echo "agenix is required in your shell to decrypt secrets locally" >&2
exit 1 exit 1
@ -46,22 +39,17 @@ echo "Decrypting Pi secrets locally..."
cd "$REPO_ROOT/secrets" cd "$REPO_ROOT/secrets"
RULES="$REPO_ROOT/secrets/secrets.nix" agenix -d pi-to-cache-key.age > "$TMP_DIR/pi-to-cache-key" RULES="$REPO_ROOT/secrets/secrets.nix" agenix -d pi-to-cache-key.age > "$TMP_DIR/pi-to-cache-key"
RULES="$REPO_ROOT/secrets/secrets.nix" agenix -d cache-to-pi-key.age > "$TMP_DIR/cache-to-pi-key" RULES="$REPO_ROOT/secrets/secrets.nix" agenix -d cache-to-pi-key.age > "$TMP_DIR/cache-to-pi-key"
RULES="$REPO_ROOT/secrets/secrets.nix" agenix -d relay-webhook-secret.age > "$TMP_DIR/relay-webhook-secret"
RULES="$REPO_ROOT/secrets/secrets.nix" agenix -d homeassistant-webhook-id.age > "$TMP_DIR/homeassistant-webhook-id"
RULES="$REPO_ROOT/secrets/secrets.nix" agenix -d tailscale-auth-key.age > "$TMP_DIR/tailscale-auth-key" RULES="$REPO_ROOT/secrets/secrets.nix" agenix -d tailscale-auth-key.age > "$TMP_DIR/tailscale-auth-key"
) )
chmod 600 "$TMP_DIR"/* chmod 600 "$TMP_DIR"/*
echo "Preparing remote directories on $TARGET_HOST..." echo "Preparing remote directories on $TARGET_HOST..."
ssh "${SSH_OPTS[@]}" "$TARGET_HOST" "mkdir -p '$REMOTE_TMP_DIR' && rm -f '$REMOTE_TMP_DIR/noisebell' '$REMOTE_TMP_DIR/noisebell-relay' '$REMOTE_TMP_DIR/pi-to-cache-key' '$REMOTE_TMP_DIR/cache-to-pi-key' '$REMOTE_TMP_DIR/relay-webhook-secret' '$REMOTE_TMP_DIR/homeassistant-webhook-id' '$REMOTE_TMP_DIR/tailscale-auth-key' && sudo mkdir -p '$REMOTE_RELEASE_DIR' /etc/noisebell /opt/noisebell/releases /var/lib/noisebell" ssh "${SSH_OPTS[@]}" "$TARGET_HOST" "mkdir -p '$REMOTE_TMP_DIR' && rm -f '$REMOTE_TMP_DIR/noisebell' '$REMOTE_TMP_DIR/pi-to-cache-key' '$REMOTE_TMP_DIR/cache-to-pi-key' '$REMOTE_TMP_DIR/tailscale-auth-key' && sudo mkdir -p '$REMOTE_RELEASE_DIR' /etc/noisebell /opt/noisebell/releases /var/lib/noisebell"
echo "Uploading binary and secret files..." echo "Uploading binary and secret files..."
scp "${SSH_OPTS[@]}" "$BIN_PATH" "$TARGET_HOST:$REMOTE_TMP_DIR/noisebell" scp "${SSH_OPTS[@]}" "$BIN_PATH" "$TARGET_HOST:$REMOTE_TMP_DIR/noisebell"
scp "${SSH_OPTS[@]}" "$RELAY_BIN_PATH" "$TARGET_HOST:$REMOTE_TMP_DIR/noisebell-relay"
scp "${SSH_OPTS[@]}" "$TMP_DIR/pi-to-cache-key" "$TARGET_HOST:$REMOTE_TMP_DIR/pi-to-cache-key" scp "${SSH_OPTS[@]}" "$TMP_DIR/pi-to-cache-key" "$TARGET_HOST:$REMOTE_TMP_DIR/pi-to-cache-key"
scp "${SSH_OPTS[@]}" "$TMP_DIR/cache-to-pi-key" "$TARGET_HOST:$REMOTE_TMP_DIR/cache-to-pi-key" scp "${SSH_OPTS[@]}" "$TMP_DIR/cache-to-pi-key" "$TARGET_HOST:$REMOTE_TMP_DIR/cache-to-pi-key"
scp "${SSH_OPTS[@]}" "$TMP_DIR/relay-webhook-secret" "$TARGET_HOST:$REMOTE_TMP_DIR/relay-webhook-secret"
scp "${SSH_OPTS[@]}" "$TMP_DIR/homeassistant-webhook-id" "$TARGET_HOST:$REMOTE_TMP_DIR/homeassistant-webhook-id"
scp "${SSH_OPTS[@]}" "$TMP_DIR/tailscale-auth-key" "$TARGET_HOST:$REMOTE_TMP_DIR/tailscale-auth-key" scp "${SSH_OPTS[@]}" "$TMP_DIR/tailscale-auth-key" "$TARGET_HOST:$REMOTE_TMP_DIR/tailscale-auth-key"
echo "Installing service and Tailscale on $TARGET_HOST..." echo "Installing service and Tailscale on $TARGET_HOST..."
@ -88,14 +76,11 @@ fi
sudo systemctl enable --now ssh avahi-daemon tailscaled sudo systemctl enable --now ssh avahi-daemon tailscaled
sudo install -m 755 "$REMOTE_TMP_DIR/noisebell" "$REMOTE_RELEASE_DIR/noisebell" sudo install -m 755 "$REMOTE_TMP_DIR/noisebell" "$REMOTE_RELEASE_DIR/noisebell"
sudo install -m 755 "$REMOTE_TMP_DIR/noisebell-relay" "$REMOTE_RELEASE_DIR/noisebell-relay"
sudo mv "$REMOTE_TMP_DIR/pi-to-cache-key" /etc/noisebell/pi-to-cache-key sudo mv "$REMOTE_TMP_DIR/pi-to-cache-key" /etc/noisebell/pi-to-cache-key
sudo mv "$REMOTE_TMP_DIR/cache-to-pi-key" /etc/noisebell/cache-to-pi-key sudo mv "$REMOTE_TMP_DIR/cache-to-pi-key" /etc/noisebell/cache-to-pi-key
sudo mv "$REMOTE_TMP_DIR/relay-webhook-secret" /etc/noisebell/relay-webhook-secret
sudo mv "$REMOTE_TMP_DIR/homeassistant-webhook-id" /etc/noisebell/homeassistant-webhook-id
sudo mv "$REMOTE_TMP_DIR/tailscale-auth-key" /etc/noisebell/tailscale-auth-key sudo mv "$REMOTE_TMP_DIR/tailscale-auth-key" /etc/noisebell/tailscale-auth-key
sudo chown root:root /etc/noisebell/pi-to-cache-key /etc/noisebell/cache-to-pi-key /etc/noisebell/relay-webhook-secret /etc/noisebell/homeassistant-webhook-id /etc/noisebell/tailscale-auth-key sudo chown root:root /etc/noisebell/pi-to-cache-key /etc/noisebell/cache-to-pi-key /etc/noisebell/tailscale-auth-key
sudo chmod 600 /etc/noisebell/pi-to-cache-key /etc/noisebell/cache-to-pi-key /etc/noisebell/relay-webhook-secret /etc/noisebell/homeassistant-webhook-id /etc/noisebell/tailscale-auth-key sudo chmod 600 /etc/noisebell/pi-to-cache-key /etc/noisebell/cache-to-pi-key /etc/noisebell/tailscale-auth-key
sudo tee /etc/noisebell/noisebell.env >/dev/null <<'ENVEOF' sudo tee /etc/noisebell/noisebell.env >/dev/null <<'ENVEOF'
NOISEBELL_GPIO_PIN=17 NOISEBELL_GPIO_PIN=17
@ -111,17 +96,6 @@ RUST_LOG=info
ENVEOF ENVEOF
sudo chmod 600 /etc/noisebell/noisebell.env sudo chmod 600 /etc/noisebell/noisebell.env
sudo tee /etc/noisebell/noisebell-relay.env >/dev/null <<'ENVEOF'
NOISEBELL_RELAY_PORT=8090
NOISEBELL_RELAY_BIND_ADDRESS=0.0.0.0
NOISEBELL_RELAY_TARGET_BASE_URL=http://homeassistant.local:8123
NOISEBELL_RELAY_RETRY_ATTEMPTS=3
NOISEBELL_RELAY_RETRY_BASE_DELAY_SECS=1
NOISEBELL_RELAY_HTTP_TIMEOUT_SECS=10
RUST_LOG=info
ENVEOF
sudo chmod 600 /etc/noisebell/noisebell-relay.env
sudo tee /etc/systemd/system/noisebell.service >/dev/null <<'UNITEOF' sudo tee /etc/systemd/system/noisebell.service >/dev/null <<'UNITEOF'
[Unit] [Unit]
Description=Noisebell GPIO door monitor Description=Noisebell GPIO door monitor
@ -141,29 +115,10 @@ WatchdogSec=30
WantedBy=multi-user.target WantedBy=multi-user.target
UNITEOF UNITEOF
sudo tee /etc/systemd/system/noisebell-relay.service >/dev/null <<'UNITEOF'
[Unit]
Description=Noisebell relay webhook bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/noisebell/noisebell-relay.env
ExecStart=/bin/bash -lc 'export NOISEBELL_RELAY_INBOUND_API_KEY="$$(cat /etc/noisebell/relay-webhook-secret)"; export NOISEBELL_RELAY_TARGET_WEBHOOK_ID="$$(cat /etc/noisebell/homeassistant-webhook-id)"; exec /opt/noisebell/current/noisebell-relay'
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
UNITEOF
sudo ln -sfn "$REMOTE_RELEASE_DIR" "$REMOTE_CURRENT_LINK" sudo ln -sfn "$REMOTE_RELEASE_DIR" "$REMOTE_CURRENT_LINK"
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable noisebell.service sudo systemctl enable noisebell.service
sudo systemctl enable noisebell-relay.service
sudo systemctl restart noisebell.service sudo systemctl restart noisebell.service
sudo systemctl restart noisebell-relay.service
sudo systemctl restart avahi-daemon sudo systemctl restart avahi-daemon
sudo tailscale up --auth-key="$(sudo cat /etc/noisebell/tailscale-auth-key)" --hostname=noisebell-pi || true sudo tailscale up --auth-key="$(sudo cat /etc/noisebell/tailscale-auth-key)" --hostname=noisebell-pi || true

View file

@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
exec nixos-rebuild switch \
--flake /home/jet/Documents/extremist-software#extremist-software \
--target-host jet@extremist-software \
--sudo \
--override-input noisebell "$REPO_ROOT" \
"$@"

View file

@ -1,9 +0,0 @@
age-encryption.org/v1
-> ssh-ed25519 Ziw7aw o2OepqUVulb7kJMNRMs3MK4HjD9ufgXgPahtYydBbXk
gFdMCu/Wk4q9g7xByhXyRIbdx7V45cwmxVn580WuFwM
-> ssh-ed25519 NFB4qA fQRwLbNWTx/FWZBPjpbt4P0a2sZY3QR2szdRe1cfTE4
DR0fSdupUNJfpnbB3Ogkp27me4J0IcQ6VVQmBKXlNoI
-> X25519 fZhP22fLYaVseoaWRa+VPKqZ4aZFNibHgMp//vOp7AE
JtwFvGYJEOD26cBhmSWrCNmFkigb/ku56xNXkYn4xMM
--- pPugQ6fRUfnW6dG+GcIHDhAbn6/Za5g1vZmWZGXk3Wk
Càü§<EFBFBD> ìÖW™åQ]Ý1¼<31>`7ü"?‰ú]Uà9

View file

@ -1,12 +0,0 @@
age-encryption.org/v1
-> ssh-ed25519 Ziw7aw s7p6bAEzWqyyF4yGBGyYi4IAVMofpY+vofEdnAUASAo
3iFJpniAgjy9r5oSDO0w288S80iKWniV36RUfMRRubc
-> ssh-ed25519 NFB4qA eP/M902eua/ytCKABTrgcCV3vKDfuMlvg31b9jIc7E4
OI/iXdRD/Km1qMRpx9h2Kabn6HWtwnHstushKZfyi+E
-> X25519 dNnIn/Tq/llh8NBvrAMcVeKVMl8C1nq6gCvI+TpxOQI
vOLTD/uROCiPnG42MioEqA1A8Ei/acmI75Eg7CIJuL4
-> ssh-ed25519 uKftJg K2ZIMhMIEzvaowRkHk90FK9Xkq01qvBh+xZ1tZ5H8Cc
QE/BLIS+2W+kdKEaJdgJHoGWMJ3TsVmx3DZ2+B3spDw
--- qY2aDrsCm4Xs2AchoDxhQzfhWrGWLJ6Z5t8/4Gc5lF4
4!£2ýF³ì[(C}
ål<EFBFBD>m‰"×#kæL·è1

View file

@ -35,17 +35,6 @@ in
jet jet
server server
]; ];
"homeassistant-webhook-id.age".publicKeys = [
jet
pi
piBootstrap
];
"relay-webhook-secret.age".publicKeys = [
jet
pi
piBootstrap
server
];
"zulip-webhook-secret.age".publicKeys = [ "zulip-webhook-secret.age".publicKeys = [
jet jet
server server