feat: add Cloudflare tunnel hosting

This commit is contained in:
Jet 2026-05-28 14:50:07 -07:00
parent e6c1b82679
commit 23e087ae4b
No known key found for this signature in database
15 changed files with 839 additions and 30 deletions

View file

@ -32,14 +32,16 @@ Useful commands:
- `./scripts/deploy-do [jet@noisebell-do]` redeploys the DigitalOcean remote host - `./scripts/deploy-do [jet@noisebell-do]` redeploys the DigitalOcean remote host
- `./scripts/nhs` redeploys the old Hetzner host using the local checkout as the flake input - `./scripts/nhs` redeploys the old Hetzner host using the local checkout as the flake input
- `scripts/deploy-pios-pi.sh pi@100.66.45.36` redeploys the Raspberry Pi OS machine - `scripts/deploy-pios-pi.sh pi@100.66.45.36` redeploys the Raspberry Pi OS machine
- `scripts/share-grafana-public-dashboard jet@noisebell-do` repairs or prints the deterministic public-safe Grafana dashboard link
The full Home Assistant relay workflow is documented in `pi/README.md`. The full Home Assistant relay workflow is documented in `pi/README.md`. Public hosting, Cloudflare Tunnel, firewall, and Grafana sharing details are documented in `docs/hosting.md`.
## Observability ## Observability
The DigitalOcean host runs Prometheus, Loki, Grafana, Alloy, node_exporter, and blackbox_exporter via `hosts/noisebell-do/observability.nix`. Grafana provisions the `Noisebell DO + Pi` dashboard from code, with Prometheus panels for both hosts, detailed DO-to-Pi poll health, and Loki journal panels for both hosts. The DigitalOcean host runs Prometheus, Loki, Grafana, Alloy, node_exporter, and blackbox_exporter via `hosts/noisebell-do/observability.nix`. Grafana provisions `Noisebell Full Debug` for authenticated operators and `Noisebell Public` for externally shared, Prometheus-only status.
- Grafana: `http://noisebell-do:3030/` over Tailscale - Grafana: `https://grafana-noisebell.extremist.software/` through Cloudflare Tunnel, login required
- Public-safe Grafana dashboard: `https://grafana-noisebell.extremist.software/public-dashboards/6e6f69736562656c6c7075626c696330`
- Prometheus: `http://noisebell-do:9090/` over Tailscale - Prometheus: `http://noisebell-do:9090/` over Tailscale
- Loki: `http://noisebell-do:3100/` over Tailscale - Loki: `http://noisebell-do:3100/` over Tailscale

113
docs/hosting.md Normal file
View file

@ -0,0 +1,113 @@
# Hosting
Noisebell is hosted with DigitalOcean as the public service and observability hub, the Raspberry Pi on Tailscale, and Cloudflare Tunnel as the only intended public HTTP entry point.
## Public Routes
| Hostname | Target | Access |
|---|---|---|
| `noisebell.extremist.software` | cache public routes | public via Cloudflare Tunnel |
| `rss-noisebell.extremist.software` | RSS service | public via Cloudflare Tunnel |
| `grafana-noisebell.extremist.software` | Grafana | public via Cloudflare Tunnel, Grafana login required |
`discord.noisebell.extremist.software` is not routed through the tunnel. The Discord bot stays local to the DigitalOcean host and receives cache webhooks at `127.0.0.1`.
## Private Routes
| Route | Purpose |
|---|---|
| `jet@noisebell-do` | DO administration over Tailscale SSH |
| `pi@noisebell-pi` or `pi@100.66.45.36` | Pi administration over Tailscale SSH |
| `noisebell-do:3000/webhook` | Pi state-change webhook to cache |
| `noisebell-pi:80` | DO cache polling and Pi app metrics |
| `noisebell-pi:8090` | cache to Pi Home Assistant relay |
| `noisebell-pi:9100` | Pi node exporter metrics |
| `noisebell-do:3100` | Pi journal shipping to Loki |
## DigitalOcean Firewall
NixOS firewall policy is in `hosts/noisebell-do/configuration.nix`.
SSH is Tailscale-only because no public TCP SSH port is opened and `tailscale0` is trusted. Direct public HTTP and HTTPS stay open while `services.noisebell-public-gateway.enable = false` so an accidental pre-tunnel deploy does not break the existing site. Once the Cloudflare Tunnel module is enabled, public TCP ports close and all public web access enters through the tunnel.
After the tunnel is verified, mirror that policy in the DigitalOcean cloud firewall: close public inbound `22`, `80`, and `443`; keep outbound open; keep Tailscale UDP reachable as needed.
## Cloudflare Tunnel
The tunnel module is `hosts/noisebell-do/public-gateway.nix`. It defines ingress for the three public hostnames and returns `404` for everything else.
The module reconciles Cloudflare through the API when enabled:
- creates a locally-managed tunnel named `noisebell-do` if it does not exist
- writes the tunnel credentials JSON to `/var/lib/noisebell-public-gateway/credentials.json`
- upserts proxied CNAME records for the public hostnames
- starts `cloudflared` with local Nix-managed ingress rules
Create two age secrets before enabling the module:
```sh
cd secrets
agenix -e cloudflare-api-token.age
nix shell nixpkgs#openssl -c openssl rand -base64 32
agenix -e cloudflare-tunnel-secret.age
```
Paste the generated base64 value into `cloudflare-tunnel-secret.age`.
Set `services.noisebell-public-gateway.accountId` and `services.noisebell-public-gateway.zoneId` in `hosts/noisebell-do/configuration.nix`, then flip `services.noisebell-public-gateway.enable = true`.
Required Cloudflare API token scopes:
| Resource | Scope |
|---|---|
| Account | `Cloudflare Tunnel:Edit` or `Cloudflare One Connector: cloudflared:Edit` |
| Zone | `DNS:Edit` for `extremist.software` |
The token should be restricted to the Noisebell Cloudflare account and the `extremist.software` zone.
## Grafana
Grafana listens on `127.0.0.1:3030` on the DO host. Public access is through `https://grafana-noisebell.extremist.software/` and requires the Grafana login form.
The admin user is `admin`. The password is generated on the DO host at first start and stored in `/var/lib/grafana/admin_password`.
```sh
ssh jet@noisebell-do sudo cat /var/lib/grafana/admin_password
```
Two dashboards are provisioned from `hosts/noisebell-do/observability.nix`:
| Dashboard | Audience | Datasources |
|---|---|---|
| `Noisebell Full Debug` | authenticated operators | Prometheus and Loki |
| `Noisebell Public` | anyone with the shared link | Prometheus only |
Use Grafana externally shared dashboards for the public-safe view. `noisebell-grafana-public-dashboard.service` creates or refreshes the public share on boot/deploy with a deterministic token.
The public-safe URL is:
```text
https://grafana-noisebell.extremist.software/public-dashboards/6e6f69736562656c6c7075626c696330
```
The RSS and Grafana hostnames use one label under `extremist.software` so they are covered by Cloudflare Universal SSL. Nested names like `grafana.noisebell.extremist.software` require additional Cloudflare certificate setup.
The helper script `scripts/share-grafana-public-dashboard jet@noisebell-do` can still be used to repair or print that URL manually. The public dashboard intentionally avoids Loki/raw journal panels and uses stored Prometheus queries only.
## Pi Hardening
`scripts/deploy-pios-pi.sh` configures the Raspberry Pi OS host. It now uses `NOISEBELL_ENDPOINT_URL=http://noisebell-do:3000/webhook` by default, so state changes go to the cache over Tailscale instead of the public domain.
The deploy script applies a persistent firewall service, `noisebell-tailscale-only-firewall.service`, that drops non-Tailscale TCP traffic to `22`, `80`, `8090`, and `9100`. Existing SSH sessions survive because established connections are allowed. New SSH, app, relay, and node exporter access must use Tailscale.
Deploy the Pi over Tailscale after the first bootstrap:
```sh
HOME_ASSISTANT_BASE_URL=http://10.21.0.43:8123 scripts/deploy-pios-pi.sh pi@100.66.45.36
```
Override the cache webhook only if needed:
```sh
NOISEBELL_CACHE_WEBHOOK_URL=http://noisebell-do:3000/webhook scripts/deploy-pios-pi.sh pi@100.66.45.36
```

View file

@ -9,6 +9,7 @@
{ {
imports = [ imports = [
(modulesPath + "/virtualisation/digital-ocean-config.nix") (modulesPath + "/virtualisation/digital-ocean-config.nix")
./public-gateway.nix
./observability.nix ./observability.nix
]; ];
@ -46,8 +47,10 @@
]; ];
}; };
networking.firewall = { networking.firewall = {
allowedTCPPorts = [ # SSH is intentionally Tailscale-only via the trusted tailscale0 interface.
22 # Keep direct HTTP(S) open until the Cloudflare Tunnel is enabled, then all
# public web traffic enters through the tunnel instead of the droplet IP.
allowedTCPPorts = lib.optionals (!config.services.noisebell-public-gateway.enable) [
80 80
443 443
]; ];
@ -74,6 +77,7 @@
services.openssh = { services.openssh = {
enable = true; enable = true;
openFirewall = false;
settings = { settings = {
PasswordAuthentication = false; PasswordAuthentication = false;
PermitRootLogin = "prohibit-password"; PermitRootLogin = "prohibit-password";
@ -98,17 +102,39 @@
security.sudo.wheelNeedsPassword = false; security.sudo.wheelNeedsPassword = false;
age.identityPaths = [ "/etc/ssh/ssh_host_ed25519_key" ]; age.identityPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
age.secrets.noisebell-cloudflare-api-token = lib.mkIf config.services.noisebell-public-gateway.enable {
file = ../../secrets/cloudflare-api-token.age;
mode = "0400";
};
age.secrets.noisebell-cloudflare-tunnel-secret = lib.mkIf config.services.noisebell-public-gateway.enable {
file = ../../secrets/cloudflare-tunnel-secret.age;
mode = "0400";
};
services.tailscale.enable = true; services.tailscale.enable = true;
services.caddy = { services.caddy = {
enable = true; enable = true;
openFirewall = false;
email = "postmaster@extremist.software"; email = "postmaster@extremist.software";
globalConfig = lib.mkIf config.services.noisebell-public-gateway.enable ''
auto_https off
'';
};
# Reconciles the Cloudflare Tunnel and public DNS through the Cloudflare API.
services.noisebell-public-gateway = {
enable = true;
accountId = "9f7c0277922ab28c45cb85bf4e7838af";
zoneId = "710e3255f43066c4a6bb4081b05a6c3f";
apiTokenFile = lib.mkIf config.services.noisebell-public-gateway.enable config.age.secrets.noisebell-cloudflare-api-token.path;
tunnelSecretFile = lib.mkIf config.services.noisebell-public-gateway.enable config.age.secrets.noisebell-cloudflare-tunnel-secret.path;
}; };
services.noisebell-cache = { services.noisebell-cache = {
enable = true; enable = true;
domain = "noisebell.extremist.software"; domain = "noisebell.extremist.software";
httpOnly = config.services.noisebell-public-gateway.enable;
piAddress = "http://noisebell-pi"; piAddress = "http://noisebell-pi";
outboundWebhooks = [ outboundWebhooks = [
{ {
@ -130,7 +156,8 @@
services.noisebell-rss = { services.noisebell-rss = {
enable = true; enable = true;
domain = "rss.noisebell.extremist.software"; domain = "rss-noisebell.extremist.software";
httpOnly = config.services.noisebell-public-gateway.enable;
}; };
zramSwap = { zramSwap = {

View file

@ -4,8 +4,89 @@ let
prometheusPort = 9090; prometheusPort = 9090;
lokiPort = 3100; lokiPort = 3100;
grafanaPort = 3030; grafanaPort = 3030;
grafanaDomain = "grafana-noisebell.extremist.software";
nodeExporterPort = 9100; nodeExporterPort = 9100;
blackboxExporterPort = 9115; blackboxExporterPort = 9115;
publicDashboardToken = "6e6f69736562656c6c7075626c696330";
sharePublicDashboard = pkgs.writeShellApplication {
name = "noisebell-grafana-share-public-dashboard";
runtimeInputs = [
pkgs.coreutils
pkgs.curl
pkgs.jq
];
text = ''
set -euo pipefail
base_url=http://127.0.0.1:${toString grafanaPort}
dashboard_uid=noisebell-public
public_uid=${publicDashboardToken}
access_token=${publicDashboardToken}
password=$(tr -d '\r\n' < /var/lib/grafana/admin_password)
ready=0
for _ in $(seq 1 60); do
if curl -fsS -u "admin:$password" "$base_url/api/health" >/dev/null; then
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "Grafana did not become ready at $base_url" >&2
exit 1
fi
dashboard_ready=0
for _ in $(seq 1 60); do
if curl -fsS -u "admin:$password" "$base_url/api/dashboards/uid/$dashboard_uid" >/dev/null; then
dashboard_ready=1
break
fi
sleep 1
done
if [ "$dashboard_ready" -ne 1 ]; then
echo "Grafana dashboard '$dashboard_uid' was not provisioned" >&2
exit 1
fi
existing=$(curl -fsS -u "admin:$password" \
"$base_url/api/dashboards/uid/$dashboard_uid/public-dashboards/" 2>/dev/null || true)
existing_uid=""
existing_token=""
if [ -n "$existing" ]; then
existing_uid=$(jq -r '.uid // empty' <<<"$existing")
existing_token=$(jq -r '.accessToken // empty' <<<"$existing")
fi
if [ -n "$existing_uid" ] && { [ "$existing_uid" != "$public_uid" ] || [ "$existing_token" != "$access_token" ]; }; then
curl -fsS -u "admin:$password" \
-X DELETE \
"$base_url/api/dashboards/uid/$dashboard_uid/public-dashboards/$existing_uid" >/dev/null
existing_uid=""
fi
if [ -n "$existing_uid" ]; then
body=$(jq -cn '{timeSelectionEnabled:true,isEnabled:true,annotationsEnabled:false,share:"public"}')
curl -fsS -u "admin:$password" \
-H 'Content-Type: application/json' \
-X PATCH \
--data "$body" \
"$base_url/api/dashboards/uid/$dashboard_uid/public-dashboards/$existing_uid" >/dev/null
else
body=$(jq -cn \
--arg uid "$public_uid" \
--arg accessToken "$access_token" \
'{uid:$uid,accessToken:$accessToken,timeSelectionEnabled:true,isEnabled:true,annotationsEnabled:false,share:"public"}')
curl -fsS -u "admin:$password" \
-H 'Content-Type: application/json' \
-X POST \
--data "$body" \
"$base_url/api/dashboards/uid/$dashboard_uid/public-dashboards/" >/dev/null
fi
'';
};
blackboxConfig = pkgs.writeText "noisebell-blackbox.yml" '' blackboxConfig = pkgs.writeText "noisebell-blackbox.yml" ''
modules: modules:
@ -78,7 +159,7 @@ let
dashboard = pkgs.writeText "noisebell-dashboard.json" (builtins.toJSON { dashboard = pkgs.writeText "noisebell-dashboard.json" (builtins.toJSON {
uid = "noisebell"; uid = "noisebell";
title = "Noisebell DO + Pi"; title = "Noisebell Full Debug";
tags = [ tags = [
"noisebell" "noisebell"
"prometheus" "prometheus"
@ -297,9 +378,136 @@ let
]; ];
}); });
publicDashboard = pkgs.writeText "noisebell-public-dashboard.json" (builtins.toJSON {
uid = "noisebell-public";
title = "Noisebell Public";
tags = [
"noisebell"
"public"
"prometheus"
];
timezone = "browser";
schemaVersion = 39;
version = 1;
refresh = "30s";
time = {
from = "now-6h";
to = "now";
};
panels = [
(prometheusPanel {
id = 1;
title = "Door State";
type = "stat";
x = 0;
y = 0;
w = 6;
h = 6;
targets = [ (promTarget "A" "noisebell_cache_status" "{{status}}") ];
})
(prometheusPanel {
id = 2;
title = "Public Status Endpoint";
type = "stat";
x = 6;
y = 0;
w = 6;
h = 6;
targets = [ (promTarget "A" "probe_success{job=\"noisebell-http-probes\",instance=\"https://noisebell.extremist.software/status\"}" "status endpoint") ];
})
(prometheusPanel {
id = 3;
title = "Noisebell Service Health";
type = "stat";
x = 12;
y = 0;
w = 6;
h = 6;
targets = [ (promTarget "A" "up{job=~\"noisebell-(cache|pi-app|pi-relay)\"}" "{{job}}") ];
})
(prometheusPanel {
id = 4;
title = "Cache Poll Health";
x = 0;
y = 6;
w = 12;
targets = [
(promTarget "A" "noisebell_cache_poll_consecutive_failures" "consecutive failures")
(promTarget "B" "rate(noisebell_cache_poll_failure_total[5m])" "failure rate")
(promTarget "C" "rate(noisebell_cache_poll_success_total[5m])" "success rate")
(promTarget "D" "noisebell_cache_poll_last_duration_seconds" "last duration")
];
})
(prometheusPanel {
id = 5;
title = "Last Poll Result";
type = "stat";
x = 12;
y = 6;
w = 12;
targets = [
(promTarget "A" "noisebell_cache_poll_last_result" "result {{result}}")
(promTarget "B" "time() - noisebell_cache_poll_last_attempt_timestamp_seconds" "seconds since attempt")
(promTarget "C" "time() - noisebell_cache_poll_last_success_timestamp_seconds" "seconds since success")
];
})
(prometheusPanel {
id = 6;
title = "Poll Failure Types";
x = 0;
y = 14;
w = 12;
targets = [
(promTarget "A" "rate(noisebell_cache_poll_http_error_total[5m])" "http error")
(promTarget "B" "rate(noisebell_cache_poll_request_timeout_total[5m])" "timeout")
(promTarget "C" "rate(noisebell_cache_poll_request_connect_total[5m])" "connect")
(promTarget "D" "rate(noisebell_cache_poll_request_other_total[5m])" "request other")
(promTarget "E" "rate(noisebell_cache_poll_parse_failure_total[5m])" "parse")
];
})
(prometheusPanel {
id = 7;
title = "Pi App Delivery";
x = 12;
y = 14;
w = 12;
targets = [
(promTarget "A" "rate(noisebell_pi_notify_success_total[5m])" "success")
(promTarget "B" "rate(noisebell_pi_notify_attempt_failure_total[5m])" "attempt failures")
(promTarget "C" "rate(noisebell_pi_notify_failure_total[5m])" "final failures")
];
})
(prometheusPanel {
id = 8;
title = "Relay Delivery";
x = 0;
y = 22;
w = 12;
targets = [
(promTarget "A" "rate(noisebell_relay_forwarded_total[5m])" "forwarded")
(promTarget "B" "rate(noisebell_relay_attempt_failure_total[5m])" "attempt failures")
(promTarget "C" "rate(noisebell_relay_failed_total[5m])" "final failures")
(promTarget "D" "noisebell_relay_last_duration_seconds" "last duration")
];
})
(prometheusPanel {
id = 9;
title = "Pi Hardware Summary";
x = 12;
y = 22;
w = 12;
targets = [
(promTarget "A" "noisebell_pi_temperature_celsius" "temperature C")
(promTarget "B" "noisebell_pi_throttled_flags" "throttled flags")
];
})
];
});
dashboardDir = pkgs.runCommand "noisebell-grafana-dashboards" { } '' dashboardDir = pkgs.runCommand "noisebell-grafana-dashboards" { } ''
mkdir -p "$out" mkdir -p "$out"
cp ${dashboard} "$out/noisebell.json" cp ${dashboard} "$out/noisebell.json"
cp ${publicDashboard} "$out/noisebell-public.json"
''; '';
blackboxRelabels = [ blackboxRelabels = [
@ -608,22 +816,26 @@ in
enable = true; enable = true;
settings = { settings = {
server = { server = {
http_addr = "0.0.0.0"; http_addr = "127.0.0.1";
http_port = grafanaPort; http_port = grafanaPort;
domain = "noisebell-do"; domain = grafanaDomain;
root_url = "http://noisebell-do:${toString grafanaPort}/"; root_url = "https://${grafanaDomain}/";
}; };
analytics.reporting_enabled = false; analytics.reporting_enabled = false;
metrics.enabled = true; metrics.enabled = true;
security = { security = {
admin_user = "admin";
admin_password = "$__file{/var/lib/grafana/admin_password}";
secret_key = "$__file{/var/lib/grafana/secret_key}"; secret_key = "$__file{/var/lib/grafana/secret_key}";
disable_initial_admin_creation = true; disable_initial_admin_creation = false;
cookie_secure = true;
strict_transport_security = true;
strict_transport_security_max_age_seconds = 31536000;
}; };
auth.disable_login_form = true; auth.disable_login_form = false;
users.allow_sign_up = false; users.allow_sign_up = false;
"auth.anonymous" = { "auth.anonymous" = {
enabled = true; enabled = false;
org_role = "Viewer";
}; };
}; };
provision = { provision = {
@ -670,5 +882,21 @@ in
umask 077 umask 077
${pkgs.coreutils}/bin/head -c 64 /dev/urandom | ${pkgs.coreutils}/bin/base64 --wrap=0 > /var/lib/grafana/secret_key ${pkgs.coreutils}/bin/head -c 64 /dev/urandom | ${pkgs.coreutils}/bin/base64 --wrap=0 > /var/lib/grafana/secret_key
fi fi
if [ ! -s /var/lib/grafana/admin_password ]; then
umask 077
${pkgs.coreutils}/bin/head -c 36 /dev/urandom | ${pkgs.coreutils}/bin/base64 --wrap=0 > /var/lib/grafana/admin_password
fi
''; '';
systemd.services.noisebell-grafana-public-dashboard = {
description = "Ensure deterministic Noisebell public Grafana dashboard share";
wantedBy = [ "multi-user.target" ];
after = [ "grafana.service" ];
wants = [ "grafana.service" ];
restartTriggers = [ dashboardDir ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${sharePublicDashboard}/bin/noisebell-grafana-share-public-dashboard";
};
};
} }

View file

@ -0,0 +1,244 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.noisebell-public-gateway;
optionalSecretPath = path: if path == null then "" else toString path;
optionalString = value: if value == null then "" else value;
hostnames = [
cfg.cacheDomain
cfg.rssDomain
cfg.grafanaDomain
];
quotedHostnames = lib.concatMapStringsSep " " lib.escapeShellArg hostnames;
reconcileScript = pkgs.writeShellApplication {
name = "noisebell-cloudflare-tunnel-reconcile";
runtimeInputs = [
pkgs.coreutils
pkgs.curl
pkgs.jq
];
text = ''
set -euo pipefail
api=https://api.cloudflare.com/client/v4
account_id=${lib.escapeShellArg (optionalString cfg.accountId)}
zone_id=${lib.escapeShellArg (optionalString cfg.zoneId)}
tunnel_name=${lib.escapeShellArg cfg.tunnelName}
api_token_file=${lib.escapeShellArg (optionalSecretPath cfg.apiTokenFile)}
tunnel_secret_file=${lib.escapeShellArg (optionalSecretPath cfg.tunnelSecretFile)}
credentials_file=${lib.escapeShellArg (toString cfg.credentialsFile)}
hostnames=(${quotedHostnames})
api_token=$(tr -d '\r\n' < "$api_token_file")
tunnel_secret=$(tr -d '\r\n' < "$tunnel_secret_file")
cloudflare_request() {
local method=$1
local path=$2
local data=''${3:-}
local response
if [ -n "$data" ]; then
response=$(curl -sS \
--request "$method" \
--header "Authorization: Bearer $api_token" \
--header 'Content-Type: application/json' \
--data "$data" \
"$api$path")
else
response=$(curl -sS \
--request "$method" \
--header "Authorization: Bearer $api_token" \
--header 'Content-Type: application/json' \
"$api$path")
fi
if ! jq -e '.success == true' >/dev/null <<<"$response"; then
jq -r '.errors[]?.message // "Cloudflare API request failed"' <<<"$response" >&2
return 1
fi
printf '%s\n' "$response"
}
encoded_tunnel_name=$(jq -rn --arg value "$tunnel_name" '$value | @uri')
tunnels=$(cloudflare_request GET "/accounts/$account_id/cfd_tunnel?name=$encoded_tunnel_name&is_deleted=false")
tunnel=$(jq -c --arg name "$tunnel_name" '[.result[] | select(.name == $name and (.deleted_at == null))][0] // empty' <<<"$tunnels")
if [ -z "$tunnel" ]; then
payload=$(jq -cn \
--arg name "$tunnel_name" \
--arg secret "$tunnel_secret" \
'{name:$name, config_src:"local", tunnel_secret:$secret}')
tunnel=$(cloudflare_request POST "/accounts/$account_id/cfd_tunnel" "$payload" | jq -c '.result')
fi
tunnel_id=$(jq -r '.id' <<<"$tunnel")
remote_config=$(jq -r '.remote_config // false' <<<"$tunnel")
config_src=$(jq -r '.config_src // "local"' <<<"$tunnel")
if [ -z "$tunnel_id" ] || [ "$tunnel_id" = "null" ]; then
echo "Cloudflare tunnel '$tunnel_name' did not return an id" >&2
exit 1
fi
if [ "$remote_config" = "true" ] || [ "$config_src" = "cloudflare" ]; then
echo "Cloudflare tunnel '$tunnel_name' exists but is remotely managed; expected config_src=local" >&2
exit 1
fi
install -d -m 0700 "$(dirname "$credentials_file")"
tmp_credentials=$(mktemp)
jq -cn \
--arg account "$account_id" \
--arg id "$tunnel_id" \
--arg name "$tunnel_name" \
--arg secret "$tunnel_secret" \
'{AccountTag:$account,TunnelID:$id,TunnelName:$name,TunnelSecret:$secret}' > "$tmp_credentials"
install -m 0400 "$tmp_credentials" "$credentials_file"
rm -f "$tmp_credentials"
target="$tunnel_id.cfargotunnel.com"
for hostname in "''${hostnames[@]}"; do
encoded_hostname=$(jq -rn --arg value "$hostname" '$value | @uri')
records=$(cloudflare_request GET "/zones/$zone_id/dns_records?name=$encoded_hostname")
record_id=$(jq -r --arg target "$target" '.result[] | select(.type == "CNAME" and .content == $target) | .id' <<<"$records" | head -n 1)
payload=$(jq -cn \
--arg name "$hostname" \
--arg content "$target" \
'{type:"CNAME", name:$name, content:$content, proxied:true, ttl:1}')
jq -r --arg keep "$record_id" '.result[] | select(.id != $keep) | .id' <<<"$records" | while IFS= read -r old_record_id; do
if [ -n "$old_record_id" ]; then
cloudflare_request DELETE "/zones/$zone_id/dns_records/$old_record_id" >/dev/null
fi
done
if [ -n "$record_id" ]; then
cloudflare_request PUT "/zones/$zone_id/dns_records/$record_id" "$payload" >/dev/null
else
cloudflare_request POST "/zones/$zone_id/dns_records" "$payload" >/dev/null
fi
done
'';
};
in
{
options.services.noisebell-public-gateway = {
enable = lib.mkEnableOption "Noisebell public Cloudflare Tunnel gateway";
tunnelName = lib.mkOption {
type = lib.types.str;
default = "noisebell-do";
description = "Cloudflare Tunnel name.";
};
accountId = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Cloudflare account ID that owns the tunnel.";
};
zoneId = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Cloudflare zone ID for the public Noisebell hostnames.";
};
apiTokenFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to a Cloudflare API token with tunnel and DNS edit permissions.";
};
tunnelSecretFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to the base64-encoded local tunnel secret used to create the tunnel.";
};
credentialsFile = lib.mkOption {
type = lib.types.path;
default = "/var/lib/noisebell-public-gateway/credentials.json";
description = "Path where the reconciler writes the Cloudflare Tunnel credentials JSON file.";
};
cacheDomain = lib.mkOption {
type = lib.types.str;
default = "noisebell.extremist.software";
};
rssDomain = lib.mkOption {
type = lib.types.str;
default = "rss-noisebell.extremist.software";
};
grafanaDomain = lib.mkOption {
type = lib.types.str;
default = "grafana-noisebell.extremist.software";
};
grafanaPort = lib.mkOption {
type = lib.types.port;
default = 3030;
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.accountId != null;
message = "services.noisebell-public-gateway.accountId must be set.";
}
{
assertion = cfg.zoneId != null;
message = "services.noisebell-public-gateway.zoneId must be set.";
}
{
assertion = cfg.apiTokenFile != null;
message = "services.noisebell-public-gateway.apiTokenFile must point to the Cloudflare API token secret.";
}
{
assertion = cfg.tunnelSecretFile != null;
message = "services.noisebell-public-gateway.tunnelSecretFile must point to the Cloudflare Tunnel secret.";
}
];
systemd.services.noisebell-cloudflare-tunnel-reconcile = {
description = "Reconcile Noisebell Cloudflare Tunnel and DNS routes";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
before = [ "cloudflared-tunnel-${cfg.tunnelName}.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${reconcileScript}/bin/noisebell-cloudflare-tunnel-reconcile";
StateDirectory = "noisebell-public-gateway";
StateDirectoryMode = "0700";
};
};
services.cloudflared = {
enable = true;
tunnels.${cfg.tunnelName} = {
credentialsFile = cfg.credentialsFile;
edgeIPVersion = "auto";
default = "http_status:404";
ingress = {
${cfg.cacheDomain} = "http://127.0.0.1:80";
${cfg.rssDomain} = "http://127.0.0.1:80";
${cfg.grafanaDomain} = "http://127.0.0.1:${toString cfg.grafanaPort}";
};
};
};
systemd.services."cloudflared-tunnel-${cfg.tunnelName}" = {
after = [ "noisebell-cloudflare-tunnel-reconcile.service" ];
requires = [ "noisebell-cloudflare-tunnel-reconcile.service" ];
};
};
}

View file

@ -124,11 +124,12 @@ That script:
6. writes `/etc/noisebell/noisebell.env` 6. writes `/etc/noisebell/noisebell.env`
7. writes `/etc/noisebell/noisebell-relay.env` 7. writes `/etc/noisebell/noisebell-relay.env`
8. installs `noisebell.service` and `noisebell-relay.service` 8. installs `noisebell.service` and `noisebell-relay.service`
9. enables persistent journald with a 30 day retention target 9. runs `tailscale up` with the decrypted auth key
10. installs and enables `prometheus-node-exporter` 10. installs `noisebell-tailscale-only-firewall.service`
11. installs `noisebell-loki-journal.service` to ship Pi logs to Loki on `noisebell-do` 11. enables persistent journald with a 30 day retention target
12. enables and starts the Noisebell services 12. installs and enables `prometheus-node-exporter`
13. runs `tailscale up` with the decrypted auth key 13. installs `noisebell-loki-journal.service` to ship Pi logs to Loki on `noisebell-do`
14. enables and starts the Noisebell services
## Files written on the Pi ## Files written on the Pi
@ -146,7 +147,9 @@ The deploy script creates:
- `/etc/noisebell/noisebell-relay.env` - `/etc/noisebell/noisebell-relay.env`
- `/etc/systemd/system/noisebell.service` - `/etc/systemd/system/noisebell.service`
- `/etc/systemd/system/noisebell-relay.service` - `/etc/systemd/system/noisebell-relay.service`
- `/etc/systemd/system/noisebell-tailscale-only-firewall.service`
- `/etc/systemd/system/noisebell-loki-journal.service` - `/etc/systemd/system/noisebell-loki-journal.service`
- `/usr/local/sbin/noisebell-tailscale-only-firewall`
- `/usr/local/bin/noisebell-loki-journal` - `/usr/local/bin/noisebell-loki-journal`
- `/etc/systemd/journald.conf.d/noisebell-persistent.conf` - `/etc/systemd/journald.conf.d/noisebell-persistent.conf`
@ -161,9 +164,12 @@ The deploy script:
- installs the Tailscale package if missing - installs the Tailscale package if missing
- enables `tailscaled` - enables `tailscaled`
- runs `tailscale up --auth-key=... --hostname=noisebell-pi` - runs `tailscale up --auth-key=... --hostname=noisebell-pi`
- blocks non-Tailscale TCP access to SSH (`22`), the Pi app (`80`), the relay (`8090`), and node exporter (`9100`)
So Tailscale stays part of the base OS, while its auth key is still managed as an encrypted `age` secret in this repo. So Tailscale stays part of the base OS, while its auth key is still managed as an encrypted `age` secret in this repo.
After the first bootstrap, deploy over Tailscale with `pi@100.66.45.36` or `pi@noisebell-pi`. Local Wi-Fi SSH is intentionally blocked by the deploy-installed firewall.
## Later updates ## Later updates
Normal iteration is just rerunning the deploy script: Normal iteration is just rerunning the deploy script:
@ -207,6 +213,8 @@ The optional relay service accepts authenticated webhooks from cache-service and
If `.local` resolution is reliable on your Pi, you can override the deploy default with `HOME_ASSISTANT_BASE_URL=http://homeassistant.local:8123`. If `.local` resolution is reliable on your Pi, you can override the deploy default with `HOME_ASSISTANT_BASE_URL=http://homeassistant.local:8123`.
The deploy default for `NOISEBELL_ENDPOINT_URL` is `http://noisebell-do:3000/webhook`, so Pi state changes go to the cache over Tailscale. Override with `NOISEBELL_CACHE_WEBHOOK_URL=...` only for testing or recovery.
Example cache target for the relay: Example cache target for the relay:
```nix ```nix

View file

@ -66,7 +66,7 @@ The production DigitalOcean host in this repo enables the cache, Discord, and RS
- `noisebell.extremist.software` - `noisebell.extremist.software`
- `discord.noisebell.extremist.software` - `discord.noisebell.extremist.software`
- `rss.noisebell.extremist.software` - `rss-noisebell.extremist.software`
After installation, authenticate Tailscale interactively on the host with: After installation, authenticate Tailscale interactively on the host with:

View file

@ -18,7 +18,7 @@ If the Pi stops responding to polls (configurable threshold, default 3 misses),
`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.
The public Caddy vhost returns `404` for `/metrics`; Prometheus scrapes the cache directly on localhost. Metrics include the configured Pi target, poll interval, offline threshold, last poll result, last HTTP status, last poll duration, last poll attempt/success/failure timestamps, and failure counters split into HTTP, timeout, connect, request-other, and parse failures. The public Caddy vhost returns `404` for `/metrics` and `/webhook`; Prometheus scrapes the cache directly on localhost, and the Pi posts webhooks over Tailscale. Metrics include the configured Pi target, poll interval, offline threshold, last poll result, last HTTP status, last poll duration, last poll attempt/success/failure timestamps, and failure counters split into HTTP, timeout, connect, request-other, and parse failures.
Regular timer-driven poll data should be debugged from Prometheus and Grafana, not by scanning logs. The cache logs sparse events instead: state changes applied from the Pi, offline/online transitions, first or changed poll failures in a failure streak, stale events, auth/rate-limit rejections, outbound webhook deliveries, retries, and final failures. Successful unchanged polls, badge/image/status reads, and metrics scrapes are intentionally quiet at `INFO`. Regular timer-driven poll data should be debugged from Prometheus and Grafana, not by scanning logs. The cache logs sparse events instead: state changes applied from the Pi, offline/online transitions, first or changed poll failures in a failure streak, stale events, auth/rate-limit rejections, outbound webhook deliveries, retries, and final failures. Successful unchanged polls, badge/image/status reads, and metrics scrapes are intentionally quiet at `INFO`.

View file

@ -4,6 +4,7 @@ pkg:
let let
cfg = config.services.noisebell-cache; cfg = config.services.noisebell-cache;
bin = "${pkg}/bin/noisebell-cache"; bin = "${pkg}/bin/noisebell-cache";
caddyHost = if cfg.httpOnly then "http://${cfg.domain}" else cfg.domain;
in in
{ {
options.services.noisebell-cache = { options.services.noisebell-cache = {
@ -14,6 +15,12 @@ in
description = "Domain for the Caddy virtual host."; description = "Domain for the Caddy virtual host.";
}; };
httpOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Use an explicit HTTP-only Caddy virtual host, for example behind Cloudflare Tunnel.";
};
piAddress = lib.mkOption { piAddress = lib.mkOption {
type = lib.types.str; type = lib.types.str;
description = "Address of the Pi (e.g. http://noisebell:80)."; description = "Address of the Pi (e.g. http://noisebell:80).";
@ -87,9 +94,10 @@ in
}; };
users.groups.noisebell-cache = { }; users.groups.noisebell-cache = { };
services.caddy.virtualHosts.${cfg.domain}.extraConfig = '' services.caddy.virtualHosts.${caddyHost}.extraConfig = ''
redir / https://git.extremist.software/jet/noisebell 302 redir / https://git.extremist.software/jet/noisebell 302
respond /metrics 404 respond /metrics 404
respond /webhook 404
reverse_proxy localhost:${toString cfg.port} reverse_proxy localhost:${toString cfg.port}
''; '';

View file

@ -4,6 +4,7 @@ pkg:
let let
cfg = config.services.noisebell-rss; cfg = config.services.noisebell-rss;
bin = "${pkg}/bin/noisebell-rss"; bin = "${pkg}/bin/noisebell-rss";
caddyHost = if cfg.httpOnly then "http://${cfg.domain}" else cfg.domain;
in in
{ {
options.services.noisebell-rss = { options.services.noisebell-rss = {
@ -14,6 +15,12 @@ in
description = "Domain for the Caddy virtual host."; description = "Domain for the Caddy virtual host.";
}; };
httpOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Use an explicit HTTP-only Caddy virtual host, for example behind Cloudflare Tunnel.";
};
port = lib.mkOption { port = lib.mkOption {
type = lib.types.port; type = lib.types.port;
default = 3002; default = 3002;
@ -37,7 +44,7 @@ in
}; };
users.groups.noisebell-rss = { }; users.groups.noisebell-rss = { };
services.caddy.virtualHosts.${cfg.domain}.extraConfig = '' services.caddy.virtualHosts.${caddyHost}.extraConfig = ''
reverse_proxy localhost:${toString cfg.port} reverse_proxy localhost:${toString cfg.port}
''; '';

View file

@ -4,6 +4,7 @@ set -euo pipefail
TARGET_HOST=${1:-pi@noisebell-pi.local} TARGET_HOST=${1:-pi@noisebell-pi.local}
DEPLOY_HOSTNAME=${DEPLOY_HOSTNAME:-noisebell-pi} DEPLOY_HOSTNAME=${DEPLOY_HOSTNAME:-noisebell-pi}
HOME_ASSISTANT_BASE_URL=${HOME_ASSISTANT_BASE_URL:-http://10.21.0.43:8123} HOME_ASSISTANT_BASE_URL=${HOME_ASSISTANT_BASE_URL:-http://10.21.0.43:8123}
NOISEBELL_CACHE_WEBHOOK_URL=${NOISEBELL_CACHE_WEBHOOK_URL:-http://noisebell-do:3000/webhook}
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd) REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
RELEASE_ID=${RELEASE_ID:-$(date +%Y%m%d-%H%M%S)} RELEASE_ID=${RELEASE_ID:-$(date +%Y%m%d-%H%M%S)}
@ -66,11 +67,11 @@ scp "${SSH_OPTS[@]}" "$TMP_DIR/homeassistant-webhook-id" "$TARGET_HOST:$REMOTE_T
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..."
ssh "${SSH_OPTS[@]}" "$TARGET_HOST" "DEPLOY_HOSTNAME='$DEPLOY_HOSTNAME' HOME_ASSISTANT_BASE_URL='$HOME_ASSISTANT_BASE_URL' REMOTE_RELEASE_DIR='$REMOTE_RELEASE_DIR' REMOTE_CURRENT_LINK='$REMOTE_CURRENT_LINK' REMOTE_TMP_DIR='$REMOTE_TMP_DIR' bash -s" <<'EOF' ssh "${SSH_OPTS[@]}" "$TARGET_HOST" "DEPLOY_HOSTNAME='$DEPLOY_HOSTNAME' HOME_ASSISTANT_BASE_URL='$HOME_ASSISTANT_BASE_URL' NOISEBELL_CACHE_WEBHOOK_URL='$NOISEBELL_CACHE_WEBHOOK_URL' REMOTE_RELEASE_DIR='$REMOTE_RELEASE_DIR' REMOTE_CURRENT_LINK='$REMOTE_CURRENT_LINK' REMOTE_TMP_DIR='$REMOTE_TMP_DIR' bash -s" <<'EOF'
set -euo pipefail set -euo pipefail
sudo apt-get update sudo apt-get update
sudo apt-get install -y curl jq rsync avahi-daemon prometheus-node-exporter sudo apt-get install -y curl jq rsync avahi-daemon prometheus-node-exporter iptables
sudo hostnamectl set-hostname "$DEPLOY_HOSTNAME" sudo hostnamectl set-hostname "$DEPLOY_HOSTNAME"
sudo tee /etc/hostname >/dev/null <<<"$DEPLOY_HOSTNAME" sudo tee /etc/hostname >/dev/null <<<"$DEPLOY_HOSTNAME"
@ -107,21 +108,29 @@ 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/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/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/relay-webhook-secret /etc/noisebell/homeassistant-webhook-id /etc/noisebell/tailscale-auth-key
sudo tee /etc/noisebell/noisebell.env >/dev/null <<'ENVEOF' if ! sudo tailscale up --auth-key="$(sudo cat /etc/noisebell/tailscale-auth-key)" --hostname=noisebell-pi; then
sudo tailscale up --hostname=noisebell-pi
fi
if ! ip link show tailscale0 >/dev/null 2>&1; then
echo "tailscale0 is not available; refusing to apply Tailscale-only firewall" >&2
exit 1
fi
sudo tee /etc/noisebell/noisebell.env >/dev/null <<ENVEOF
NOISEBELL_GPIO_PIN=17 NOISEBELL_GPIO_PIN=17
NOISEBELL_DEBOUNCE_MS=50 NOISEBELL_DEBOUNCE_MS=50
NOISEBELL_PORT=80 NOISEBELL_PORT=80
NOISEBELL_RETRY_ATTEMPTS=3 NOISEBELL_RETRY_ATTEMPTS=3
NOISEBELL_RETRY_BASE_DELAY_SECS=1 NOISEBELL_RETRY_BASE_DELAY_SECS=1
NOISEBELL_HTTP_TIMEOUT_SECS=10 NOISEBELL_HTTP_TIMEOUT_SECS=10
NOISEBELL_ENDPOINT_URL=https://noisebell.extremist.software/webhook NOISEBELL_ENDPOINT_URL=$NOISEBELL_CACHE_WEBHOOK_URL
NOISEBELL_BIND_ADDRESS=0.0.0.0 NOISEBELL_BIND_ADDRESS=0.0.0.0
NOISEBELL_ACTIVE_LOW=true NOISEBELL_ACTIVE_LOW=true
RUST_LOG=info 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' sudo tee /etc/noisebell/noisebell-relay.env >/dev/null <<ENVEOF
NOISEBELL_RELAY_PORT=8090 NOISEBELL_RELAY_PORT=8090
NOISEBELL_RELAY_BIND_ADDRESS=0.0.0.0 NOISEBELL_RELAY_BIND_ADDRESS=0.0.0.0
NOISEBELL_RELAY_TARGET_BASE_URL=$HOME_ASSISTANT_BASE_URL NOISEBELL_RELAY_TARGET_BASE_URL=$HOME_ASSISTANT_BASE_URL
@ -168,6 +177,61 @@ RestartSec=5
WantedBy=multi-user.target WantedBy=multi-user.target
UNITEOF UNITEOF
sudo tee /usr/local/sbin/noisebell-tailscale-only-firewall >/dev/null <<'FIREWALLEOF'
#!/usr/bin/env bash
set -euo pipefail
restricted_tcp_ports=(22 80 8090 9100)
if [ "$(id -u)" -ne 0 ]; then
exec sudo "$0" "$@"
fi
if ! command -v iptables >/dev/null 2>&1; then
echo "iptables is required for the Noisebell Tailscale-only firewall" >&2
exit 1
fi
apply_rules() {
local cmd=$1
while "$cmd" -w -D INPUT -j NOISEBELL_TS_ONLY 2>/dev/null; do :; done
"$cmd" -w -F NOISEBELL_TS_ONLY 2>/dev/null || true
"$cmd" -w -X NOISEBELL_TS_ONLY 2>/dev/null || true
"$cmd" -w -N NOISEBELL_TS_ONLY
"$cmd" -w -A NOISEBELL_TS_ONLY -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
"$cmd" -w -A NOISEBELL_TS_ONLY -i lo -j RETURN
"$cmd" -w -A NOISEBELL_TS_ONLY -i tailscale0 -j RETURN
for port in "${restricted_tcp_ports[@]}"; do
"$cmd" -w -A NOISEBELL_TS_ONLY -p tcp --dport "$port" -j DROP
done
"$cmd" -w -A NOISEBELL_TS_ONLY -j RETURN
"$cmd" -w -I INPUT 1 -j NOISEBELL_TS_ONLY
}
apply_rules iptables
if command -v ip6tables >/dev/null 2>&1; then
apply_rules ip6tables || true
fi
FIREWALLEOF
sudo chmod 755 /usr/local/sbin/noisebell-tailscale-only-firewall
sudo tee /etc/systemd/system/noisebell-tailscale-only-firewall.service >/dev/null <<'UNITEOF'
[Unit]
Description=Restrict Noisebell service ports to Tailscale
After=tailscaled.service
Wants=tailscaled.service
Before=ssh.service prometheus-node-exporter.service noisebell.service noisebell-relay.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/noisebell-tailscale-only-firewall
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
UNITEOF
sudo tee /usr/local/bin/noisebell-loki-journal >/dev/null <<'SCRIPTEOF' sudo tee /usr/local/bin/noisebell-loki-journal >/dev/null <<'SCRIPTEOF'
#!/usr/bin/env bash #!/usr/bin/env bash
set -uo pipefail set -uo pipefail
@ -251,6 +315,7 @@ 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 --now noisebell-tailscale-only-firewall.service
sudo systemctl enable noisebell.service sudo systemctl enable noisebell.service
sudo systemctl enable noisebell-relay.service sudo systemctl enable noisebell-relay.service
sudo systemctl enable noisebell-loki-journal.service sudo systemctl enable noisebell-loki-journal.service
@ -259,8 +324,6 @@ sudo systemctl restart noisebell-relay.service
sudo systemctl restart noisebell-loki-journal.service sudo systemctl restart noisebell-loki-journal.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
rmdir "$REMOTE_TMP_DIR" 2>/dev/null || true rmdir "$REMOTE_TMP_DIR" 2>/dev/null || true
echo "Noisebell deployed on Raspberry Pi OS." echo "Noisebell deployed on Raspberry Pi OS."

View file

@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
target=${1:-jet@noisebell-do}
public_url=${GRAFANA_PUBLIC_URL:-https://grafana-noisebell.extremist.software}
ssh_opts=(
-o StrictHostKeyChecking=accept-new
)
printf -v remote_command 'GRAFANA_PUBLIC_URL=%q bash -s' "$public_url"
ssh "${ssh_opts[@]}" "$target" "$remote_command" <<'REMOTE'
set -euo pipefail
base_url=http://127.0.0.1:3030
dashboard_uid=noisebell-public
public_uid=noisebell-public
access_token=6e6f69736562656c6c7075626c696330
password=$(sudo tr -d '\r\n' < /var/lib/grafana/admin_password)
ready=0
for _ in $(seq 1 60); do
if curl -fsS -u "admin:$password" "$base_url/api/health" >/dev/null; then
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "Grafana did not become ready at $base_url" >&2
exit 1
fi
dashboard_ready=0
for _ in $(seq 1 60); do
if curl -fsS -u "admin:$password" "$base_url/api/dashboards/uid/$dashboard_uid" >/dev/null; then
dashboard_ready=1
break
fi
sleep 1
done
if [ "$dashboard_ready" -ne 1 ]; then
echo "Grafana dashboard '$dashboard_uid' was not provisioned" >&2
exit 1
fi
existing=$(curl -fsS -u "admin:$password" \
"$base_url/api/dashboards/uid/$dashboard_uid/public-dashboards/" 2>/dev/null || true)
existing_uid=""
existing_token=""
if [ -n "$existing" ]; then
existing_uid=$(jq -r '.uid // empty' <<<"$existing")
existing_token=$(jq -r '.accessToken // empty' <<<"$existing")
fi
if [ -n "$existing_uid" ] && { [ "$existing_uid" != "$public_uid" ] || [ "$existing_token" != "$access_token" ]; }; then
curl -fsS -u "admin:$password" \
-X DELETE \
"$base_url/api/dashboards/uid/$dashboard_uid/public-dashboards/$existing_uid" >/dev/null
existing_uid=""
fi
if [ -n "$existing_uid" ]; then
body=$(jq -cn '{timeSelectionEnabled:true,isEnabled:true,annotationsEnabled:false,share:"public"}')
response=$(curl -fsS -u "admin:$password" \
-H 'Content-Type: application/json' \
-X PATCH \
--data "$body" \
"$base_url/api/dashboards/uid/$dashboard_uid/public-dashboards/$existing_uid")
else
body=$(jq -cn \
--arg uid "$public_uid" \
--arg accessToken "$access_token" \
'{uid:$uid,accessToken:$accessToken,timeSelectionEnabled:true,isEnabled:true,annotationsEnabled:false,share:"public"}')
response=$(curl -fsS -u "admin:$password" \
-H 'Content-Type: application/json' \
-X POST \
--data "$body" \
"$base_url/api/dashboards/uid/$dashboard_uid/public-dashboards/")
fi
returned_token=$(jq -r '.accessToken // empty' <<<"$response")
if [ "$returned_token" != "$access_token" ]; then
echo "Grafana did not return a public dashboard access token" >&2
exit 1
fi
printf '%s/public-dashboards/%s\n' "${GRAFANA_PUBLIC_URL%/}" "$returned_token"
REMOTE

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 Ziw7aw a4aTUJHa+Gt4JRISoxE2rPWd5UrdoA9p/bj9B1pLuC8
FLj8/m1LDwop/n/LyvNBHcSu8NnvhM51cnTQ9aE+Or0
-> ssh-ed25519 l4GuVg wzegDtRxBSBL41NPweoCqWU2m6vNgKIQjCpm2zF7lws
BEEQ89pKx3ms/2DKWfnR9I4/9hNarVbPPnxvz53IX3k
--- ZFLvPnyhulOnkWvEcUt00GCVLQCaBrK5LDepbbUoDgE
^Ñë†ÿ<E280A0>ñ¸FB áz\ÆA‰WQ× ñ™€òBÁy m²…ôc½/pÍûX&^£?n¼ç<C2BC>;×âXå}h2MjÊö§G²\Æj¸ Üf6èQ

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 Ziw7aw bGptbB+PkaXQGBXvstrmcRxw6WFrTUF9WbA9EdSr8E4
BBvumYmu3t06e/9y9lFi02crV1gludNeatKi2bF9Cuw
-> ssh-ed25519 l4GuVg i4Gxx13lDP4oXgUy1Ydhab1K6G7oPQSe5Xh6KNXm4Ug
d1UnILTW/0QYKXmNOG1Z6p/+myUoV/dkBfoXCNC1Wz0
--- cyNExaOWhLSzWygoO7ci+kijauZQLhgYcIKoayHawfc
b6J}ï(dã=¤  d~9ÁiCÝr ¾þ¬ïDD<˜=P)<29>ìŠAÀÀÍ2¯„‡#•”ß»·ÙÁæñÀ±E<>7„©GÀ<47>àb¾G

View file

@ -58,4 +58,12 @@ in
server server
noisebellDo noisebellDo
]; ];
"cloudflare-api-token.age".publicKeys = [
jet
noisebellDo
];
"cloudflare-tunnel-secret.age".publicKeys = [
jet
noisebellDo
];
} }