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