feat: add Cloudflare tunnel hosting
This commit is contained in:
parent
e6c1b82679
commit
23e087ae4b
15 changed files with 839 additions and 30 deletions
|
|
@ -32,14 +32,16 @@ Useful commands:
|
|||
- `./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/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
|
||||
|
||||
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
|
||||
- Loki: `http://noisebell-do:3100/` over Tailscale
|
||||
|
||||
|
|
|
|||
113
docs/hosting.md
Normal file
113
docs/hosting.md
Normal 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
|
||||
```
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
{
|
||||
imports = [
|
||||
(modulesPath + "/virtualisation/digital-ocean-config.nix")
|
||||
./public-gateway.nix
|
||||
./observability.nix
|
||||
];
|
||||
|
||||
|
|
@ -46,8 +47,10 @@
|
|||
];
|
||||
};
|
||||
networking.firewall = {
|
||||
allowedTCPPorts = [
|
||||
22
|
||||
# SSH is intentionally Tailscale-only via the trusted tailscale0 interface.
|
||||
# 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
|
||||
443
|
||||
];
|
||||
|
|
@ -74,6 +77,7 @@
|
|||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
openFirewall = false;
|
||||
settings = {
|
||||
PasswordAuthentication = false;
|
||||
PermitRootLogin = "prohibit-password";
|
||||
|
|
@ -98,17 +102,39 @@
|
|||
security.sudo.wheelNeedsPassword = false;
|
||||
|
||||
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.caddy = {
|
||||
enable = true;
|
||||
openFirewall = false;
|
||||
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 = {
|
||||
enable = true;
|
||||
domain = "noisebell.extremist.software";
|
||||
httpOnly = config.services.noisebell-public-gateway.enable;
|
||||
piAddress = "http://noisebell-pi";
|
||||
outboundWebhooks = [
|
||||
{
|
||||
|
|
@ -130,7 +156,8 @@
|
|||
|
||||
services.noisebell-rss = {
|
||||
enable = true;
|
||||
domain = "rss.noisebell.extremist.software";
|
||||
domain = "rss-noisebell.extremist.software";
|
||||
httpOnly = config.services.noisebell-public-gateway.enable;
|
||||
};
|
||||
|
||||
zramSwap = {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,89 @@ let
|
|||
prometheusPort = 9090;
|
||||
lokiPort = 3100;
|
||||
grafanaPort = 3030;
|
||||
grafanaDomain = "grafana-noisebell.extremist.software";
|
||||
nodeExporterPort = 9100;
|
||||
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" ''
|
||||
modules:
|
||||
|
|
@ -78,7 +159,7 @@ let
|
|||
|
||||
dashboard = pkgs.writeText "noisebell-dashboard.json" (builtins.toJSON {
|
||||
uid = "noisebell";
|
||||
title = "Noisebell DO + Pi";
|
||||
title = "Noisebell Full Debug";
|
||||
tags = [
|
||||
"noisebell"
|
||||
"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" { } ''
|
||||
mkdir -p "$out"
|
||||
cp ${dashboard} "$out/noisebell.json"
|
||||
cp ${publicDashboard} "$out/noisebell-public.json"
|
||||
'';
|
||||
|
||||
blackboxRelabels = [
|
||||
|
|
@ -608,22 +816,26 @@ in
|
|||
enable = true;
|
||||
settings = {
|
||||
server = {
|
||||
http_addr = "0.0.0.0";
|
||||
http_addr = "127.0.0.1";
|
||||
http_port = grafanaPort;
|
||||
domain = "noisebell-do";
|
||||
root_url = "http://noisebell-do:${toString grafanaPort}/";
|
||||
domain = grafanaDomain;
|
||||
root_url = "https://${grafanaDomain}/";
|
||||
};
|
||||
analytics.reporting_enabled = false;
|
||||
metrics.enabled = true;
|
||||
security = {
|
||||
admin_user = "admin";
|
||||
admin_password = "$__file{/var/lib/grafana/admin_password}";
|
||||
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;
|
||||
"auth.anonymous" = {
|
||||
enabled = true;
|
||||
org_role = "Viewer";
|
||||
enabled = false;
|
||||
};
|
||||
};
|
||||
provision = {
|
||||
|
|
@ -670,5 +882,21 @@ in
|
|||
umask 077
|
||||
${pkgs.coreutils}/bin/head -c 64 /dev/urandom | ${pkgs.coreutils}/bin/base64 --wrap=0 > /var/lib/grafana/secret_key
|
||||
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";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
244
hosts/noisebell-do/public-gateway.nix
Normal file
244
hosts/noisebell-do/public-gateway.nix
Normal 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" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
18
pi/README.md
18
pi/README.md
|
|
@ -124,11 +124,12 @@ That script:
|
|||
6. writes `/etc/noisebell/noisebell.env`
|
||||
7. writes `/etc/noisebell/noisebell-relay.env`
|
||||
8. installs `noisebell.service` and `noisebell-relay.service`
|
||||
9. enables persistent journald with a 30 day retention target
|
||||
10. installs and enables `prometheus-node-exporter`
|
||||
11. installs `noisebell-loki-journal.service` to ship Pi logs to Loki on `noisebell-do`
|
||||
12. enables and starts the Noisebell services
|
||||
13. runs `tailscale up` with the decrypted auth key
|
||||
9. runs `tailscale up` with the decrypted auth key
|
||||
10. installs `noisebell-tailscale-only-firewall.service`
|
||||
11. enables persistent journald with a 30 day retention target
|
||||
12. installs and enables `prometheus-node-exporter`
|
||||
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
|
||||
|
||||
|
|
@ -146,7 +147,9 @@ The deploy script creates:
|
|||
- `/etc/noisebell/noisebell-relay.env`
|
||||
- `/etc/systemd/system/noisebell.service`
|
||||
- `/etc/systemd/system/noisebell-relay.service`
|
||||
- `/etc/systemd/system/noisebell-tailscale-only-firewall.service`
|
||||
- `/etc/systemd/system/noisebell-loki-journal.service`
|
||||
- `/usr/local/sbin/noisebell-tailscale-only-firewall`
|
||||
- `/usr/local/bin/noisebell-loki-journal`
|
||||
- `/etc/systemd/journald.conf.d/noisebell-persistent.conf`
|
||||
|
||||
|
|
@ -161,9 +164,12 @@ The deploy script:
|
|||
- installs the Tailscale package if missing
|
||||
- enables `tailscaled`
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
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`.
|
||||
|
||||
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:
|
||||
|
||||
```nix
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ The production DigitalOcean host in this repo enables the cache, Discord, and RS
|
|||
|
||||
- `noisebell.extremist.software`
|
||||
- `discord.noisebell.extremist.software`
|
||||
- `rss.noisebell.extremist.software`
|
||||
- `rss-noisebell.extremist.software`
|
||||
|
||||
After installation, authenticate Tailscale interactively on the host with:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ pkg:
|
|||
let
|
||||
cfg = config.services.noisebell-cache;
|
||||
bin = "${pkg}/bin/noisebell-cache";
|
||||
caddyHost = if cfg.httpOnly then "http://${cfg.domain}" else cfg.domain;
|
||||
in
|
||||
{
|
||||
options.services.noisebell-cache = {
|
||||
|
|
@ -14,6 +15,12 @@ in
|
|||
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 {
|
||||
type = lib.types.str;
|
||||
description = "Address of the Pi (e.g. http://noisebell:80).";
|
||||
|
|
@ -87,9 +94,10 @@ in
|
|||
};
|
||||
users.groups.noisebell-cache = { };
|
||||
|
||||
services.caddy.virtualHosts.${cfg.domain}.extraConfig = ''
|
||||
services.caddy.virtualHosts.${caddyHost}.extraConfig = ''
|
||||
redir / https://git.extremist.software/jet/noisebell 302
|
||||
respond /metrics 404
|
||||
respond /webhook 404
|
||||
reverse_proxy localhost:${toString cfg.port}
|
||||
'';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ pkg:
|
|||
let
|
||||
cfg = config.services.noisebell-rss;
|
||||
bin = "${pkg}/bin/noisebell-rss";
|
||||
caddyHost = if cfg.httpOnly then "http://${cfg.domain}" else cfg.domain;
|
||||
in
|
||||
{
|
||||
options.services.noisebell-rss = {
|
||||
|
|
@ -14,6 +15,12 @@ in
|
|||
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 {
|
||||
type = lib.types.port;
|
||||
default = 3002;
|
||||
|
|
@ -37,7 +44,7 @@ in
|
|||
};
|
||||
users.groups.noisebell-rss = { };
|
||||
|
||||
services.caddy.virtualHosts.${cfg.domain}.extraConfig = ''
|
||||
services.caddy.virtualHosts.${caddyHost}.extraConfig = ''
|
||||
reverse_proxy localhost:${toString cfg.port}
|
||||
'';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ set -euo pipefail
|
|||
TARGET_HOST=${1:-pi@noisebell-pi.local}
|
||||
DEPLOY_HOSTNAME=${DEPLOY_HOSTNAME:-noisebell-pi}
|
||||
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)
|
||||
REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
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 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 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_DEBOUNCE_MS=50
|
||||
NOISEBELL_PORT=80
|
||||
NOISEBELL_RETRY_ATTEMPTS=3
|
||||
NOISEBELL_RETRY_BASE_DELAY_SECS=1
|
||||
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_ACTIVE_LOW=true
|
||||
RUST_LOG=info
|
||||
ENVEOF
|
||||
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_BIND_ADDRESS=0.0.0.0
|
||||
NOISEBELL_RELAY_TARGET_BASE_URL=$HOME_ASSISTANT_BASE_URL
|
||||
|
|
@ -168,6 +177,61 @@ RestartSec=5
|
|||
WantedBy=multi-user.target
|
||||
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'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
|
@ -251,6 +315,7 @@ UNITEOF
|
|||
|
||||
sudo ln -sfn "$REMOTE_RELEASE_DIR" "$REMOTE_CURRENT_LINK"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now noisebell-tailscale-only-firewall.service
|
||||
sudo systemctl enable noisebell.service
|
||||
sudo systemctl enable noisebell-relay.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 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
|
||||
|
||||
echo "Noisebell deployed on Raspberry Pi OS."
|
||||
|
|
|
|||
87
scripts/share-grafana-public-dashboard
Executable file
87
scripts/share-grafana-public-dashboard
Executable 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
|
||||
7
secrets/cloudflare-api-token.age
Normal file
7
secrets/cloudflare-api-token.age
Normal 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×nÙñ™€òBÁy
m²…ôc½/pÍûX&^£?n¼ç<C2BC>;×âXå}h2MjÊö§G²\Æj¸Üf6èQ
|
||||
7
secrets/cloudflare-tunnel-secret.age
Normal file
7
secrets/cloudflare-tunnel-secret.age
Normal 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
|
||||
|
|
@ -58,4 +58,12 @@ in
|
|||
server
|
||||
noisebellDo
|
||||
];
|
||||
"cloudflare-api-token.age".publicKeys = [
|
||||
jet
|
||||
noisebellDo
|
||||
];
|
||||
"cloudflare-tunnel-secret.age".publicKeys = [
|
||||
jet
|
||||
noisebellDo
|
||||
];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue