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

@ -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" ];
};
};
}