diff --git a/nix-system-configs/modules/system/traefik.nix b/nix-system-configs/modules/system/traefik.nix index 02eab7f..f6ff0f1 100644 --- a/nix-system-configs/modules/system/traefik.nix +++ b/nix-system-configs/modules/system/traefik.nix @@ -5,39 +5,12 @@ ... }: let choose = paths: builtins.head (builtins.filter (p: builtins.pathExists p) paths); - # Package the upstream cloudflare-ddns-updater from GitHub into the Nix store - upstreamOwner = "K0p1-Git"; - upstreamRepo = "cloudflare-ddns-updater"; - upstreamRev = "e9906c3aa0b73c26e3473618ad7a69db853e669d"; - upstreamSrc = pkgs.fetchFromGitHub { - owner = upstreamOwner; - repo = upstreamRepo; - rev = upstreamRev; - sha256 = "0j1pv57hk9rhr1kxqqjxg71y0d2d1hi3b4yiq908x5kcv3abbina"; - }; - - cloudflare-ddns-pkg = pkgs.stdenv.mkDerivation { - pname = "cloudflare-ddns-updater"; - version = upstreamRev; - src = upstreamSrc; - # no build required for a script-only repo - buildPhase = '': ''; - installPhase = '' - mkdir -p $out/bin $out/lib/cloudflare-ddns - cp -r $src/* $out/lib/cloudflare-ddns/ || true - if [ -x "$out/lib/cloudflare-ddns/update.sh" ]; then - cp "$out/lib/cloudflare-ddns/update.sh" $out/bin/cloudflare-ddns - chmod +x $out/bin/cloudflare-ddns - else - # If upstream layout changes, keep the repo contents available under $out/lib - true - fi - ''; - meta = with pkgs.lib; { - description = "Cloudflare DDNS updater"; - license = licenses.mit; - }; - }; + # Domain and Cloudflare DDNS records configured here. Update this list to add/remove records. + domain = "prg-radio.org"; + records = ["git" "grafana" "anubis" "wavelog" "partdb" "mail" "mailadmin" "@"]; + recordsStr = lib.concatStringsSep " " records; + zoneId = "9fde8d0fa53502f2d1b7e0b1d3765d49"; + envFile = "/home/traefikprg/cloudflare/cloudflare.env"; in { options.local = { hostname = lib.mkOption { @@ -595,113 +568,178 @@ in { }; }; - # PRG Cloudflare DDNS updater - environment.etc."cloudflare-ddns/update.sh" = { - text = let - domain = "prg-radio.org"; - records = ["git" "grafana" "anubis" "wavelog" "partdb" "mail" "mailadmin" "@"]; - recordsStr = lib.concatStringsSep " " records; - zoneId = "9fde8d0fa53502f2d1b7e0b1d3765d49"; - envFile = "/home/traefikprg/cloudflare/cloudflare.env"; - in '' - #!/usr/bin/env bash - set -euuo pipefail + # PRG Cloudflare DDNS updater - split into a single-run upstream script and a wrapper that loops records + environment.etc."cloudflare-ddns/update-single.sh" = { + text = '' + #!/bin/bash + ## K0p1-Git cloudflare-ddns-updater (packaged copy) + ## Upstream: https://github.com/K0p1-Git/cloudflare-ddns-updater + ## Author: K0p1-Git + ## License: MIT - # Load environment variables (must contain CLOUDFLARE_API_TOKEN) - if [ -f "${envFile}" ]; then - # shellcheck disable=SC1090 - source "${envFile}" - fi + ## change to "bin/sh" when necessary - if [ -z "$${CLOUDFLARE_API_TOKEN:-}" ]; then - echo "CLOUDFLARE_API_TOKEN is not set in ${envFile}, aborting" >&2 - exit 2 - fi + auth_email="" # The email used to login 'https://dash.cloudflare.com' + auth_method="token" # Set to "global" for Global API Key or "token" for Scoped API Token + auth_key="" # Your API Token or Global API Key + zone_identifier="" # Can be found in the "Overview" tab of your domain + record_name="" # Which record you want to be synced + ttl=3600 # Set the DNS TTL (seconds) + proxy="false" # Set the proxy to true or false + sitename="" # Title of site "Example Site" + slackchannel="" # Slack Channel #example + slackuri="" # URI for Slack WebHook "https://hooks.slack.com/services/xxxxx" + discorduri="" # URI for Discord WebHook "https://discordapp.com/api/webhooks/xxxxx" - API_BASE="https://api.cloudflare.com/client/v4" - # get current public IPv4 - CURRENT_IP=$(curl -4 -sS https://ipv4.icanhazip.com | tr -d '\n') || true - if [ -z "$CURRENT_IP" ]; then - echo "Failed to detect public IPv4 address" >&2 - exit 3 - fi - - jq=${pkgs.jq}/bin/jq - - update_or_create() { - local fqdn="$1" - # Query existing A records for fqdn - res=$(curl -sS -X GET "$API_BASE/zones/${zoneId}/dns_records?type=A&name=$fqdn" \ - -H "Authorization: Bearer $${CLOUDFLARE_API_TOKEN:-}" \ - -H "Content-Type: application/json") - - ok=$(echo "$res" | $jq -r '.success') - if [ "$ok" != "true" ]; then - echo "Cloudflare API query failed for $fqdn: $res" >&2 - return 1 - fi - - existing_ip=$(echo "$res" | $jq -r '.result[0].content // empty') - record_id=$(echo "$res" | $jq -r '.result[0].id // empty') - - if [ -n "$existing_ip" ] && [ "$existing_ip" = "$CURRENT_IP" ]; then - echo "$fqdn is already set to $CURRENT_IP, skipping" - return 0 - fi - - payload=$(cat < $CURRENT_IP" - curl -sS -X PUT "$API_BASE/zones/${zoneId}/dns_records/$record_id" \ - -H "Authorization: Bearer $${CLOUDFLARE_API_TOKEN:-}" \ - -H "Content-Type: application/json" \ - --data "$payload" | $jq -r '.' - else - echo "Creating record $fqdn -> $CURRENT_IP" - curl -sS -X POST "$API_BASE/zones/${zoneId}/dns_records" \ - -H "Authorization: Bearer $${CLOUDFLARE_API_TOKEN:-}" \ - -H "Content-Type: application/json" \ - --data "$payload" | $jq -r '.' - fi - } + # Try all the ip services for a valid IPv4 address + for service in $${IP_SERVICES[@]}; do + RAW_IP=$(curl -s $service) + if [[ $RAW_IP =~ $REGEX_IPV4 ]]; then + CURRENT_IP=$BASH_REMATCH + logger -s "DDNS Updater: Fetched IP $CURRENT_IP" + break + else + logger -s "DDNS Updater: IP service $service failed." + fi + done + + # Exit if IP fetching failed + if [[ -z "$${CURRENT_IP}" ]]; then + logger -s "DDNS Updater: Failed to find a valid IP." + exit 2 + fi + + ########################################### + ## Check and set the proper auth header + ########################################### + if [[ "$${auth_method}" == "global" ]]; then + auth_header="X-Auth-Key:" + else + auth_header="Authorization: Bearer" + fi + + ########################################### + ## Seek for the A record + ########################################### + + logger "DDNS Updater: Check Initiated" + record=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$${zone_identifier}/dns_records?type=A&name=$${record_name}" \ + -H "X-Auth-Email: $${auth_email}" \ + -H "$${auth_header} $${auth_key}" \ + -H "Content-Type: application/json") + + ########################################### + ## Check if the domain has an A record + ########################################### + if [[ $record == *"\\"count\\":0"* ]]; then + logger -s "DDNS Updater: Record does not exist, perhaps create one first? ($${CURRENT_IP} for $${record_name})" + exit 1 + fi + + ########################################### + ## Get existing IP + ########################################### + old_ip=$(echo "$record" | sed -E 's/.*"content":"(([0-9]{1,3}\\.){3}[0-9]{1,3})".*/\\1/') + # Compare if they're the same + if [[ $CURRENT_IP == $old_ip ]]; then + logger "DDNS Updater: IP ($CURRENT_IP) for $${record_name} has not changed." + exit 0 + fi + + ########################################### + ## Set the record identifier from result + ########################################### + record_identifier=$(echo "$record" | sed -E 's/.*"id":"([A-Za-z0-9_]+)".*/\\1/') + + ########################################### + ## Change the IP@Cloudflare using the API + ########################################### + update=$(curl -s -X PATCH "https://api.cloudflare.com/client/v4/zones/$${zone_identifier}/dns_records/$${record_identifier}" \ + -H "X-Auth-Email: $${auth_email}" \ + -H "$${auth_header} $${auth_key}" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"A\",\"name\":\"$${record_name}\",\"content\":\"$${CURRENT_IP}\",\"ttl\":$${ttl},\"proxied\":$${proxy}}") + + ########################################### + ## Report the status (simplified payload construction to avoid nested-quote issues) + ########################################### + case "$update" in + *"\\\"success\\\":false"*) + echo -e "DDNS Updater: $${CURRENT_IP} $${record_name} DDNS failed for $${record_identifier} ($${CURRENT_IP}). DUMPING RESULTS:\n$update" | logger -s + if [[ -n "$${slackuri}" ]]; then + msg="$${sitename} DDNS Update Failed: $${record_name}: $${record_identifier} ($${CURRENT_IP})." + curl -L -X POST "$${slackuri}" --data-raw "{\"channel\":\"$${slackchannel}\",\"text\":\"$${msg}\"}" + fi + if [[ -n "$${discorduri}" ]]; then + msg="$${sitename} DDNS Update Failed: $${record_name}: $${record_identifier} ($${CURRENT_IP})." + curl -i -H "Accept: application/json" -H "Content-Type:application/json" -X POST --data-raw "{\"content\":\"$${msg}\"}" "$${discorduri}" + fi + exit 1;; + *) + msg="$${sitename} Updated: $${record_name}'s new IP Address is $${CURRENT_IP}" + logger "DDNS Updater: $${CURRENT_IP} $${record_name} DDNS updated." + if [[ -n "$${slackuri}" ]]; then + curl -L -X POST "$${slackuri}" --data-raw "{\"channel\":\"$${slackchannel}\",\"text\":\"$${msg}\"}" + fi + if [[ -n "$${discorduri}" ]]; then + curl -i -H "Accept: application/json" -H "Content-Type:application/json" -X POST --data-raw "{\"content\":\"$${msg}\"}" "$${discorduri}" + fi + exit 0;; + esac - echo "Starting PRG Cloudflare DDNS updater: setting IP=$CURRENT_IP" - for r in ${recordsStr}; do - if [ "$r" = "@" ]; then - fqdn="${domain}" - else - fqdn="$r.${domain}" - fi - update_or_create "$fqdn" || true - done ''; mode = "0755"; group = "root"; }; - systemd.services."prg-cloudflare-ddns-updater" = { - description = "PRG Cloudflare DDNS updater"; - wantedBy = ["multi-user.target"]; - serviceConfig = { - Type = "oneshot"; - EnvironmentFile = "/home/traefikprg/cloudflare/cloudflare.env"; - ExecStart = "${pkgs.bash}/bin/bash /etc/cloudflare-ddns/update.sh"; - }; - }; + environment.etc."cloudflare-ddns/update.sh" = { + text = '' + #!/usr/bin/env bash + set -euo pipefail - systemd.timers."prg-cloudflare-ddns-updater" = { - description = "Run PRG Cloudflare DDNS updater periodically"; - wantedBy = ["timers.target"]; - timerConfig = { - OnBootSec = "1m"; - OnUnitActiveSec = "10m"; - Persistent = true; - }; + # Wrapper: source env, map tokens, loop declared records and call the upstream single-run script + if [ -f "${envFile}" ]; then + # shellcheck disable=SC1090 + source "${envFile}" + fi + + # Map env variables from the env file into auth_key/auth_email used by the upstream script + if [ -n "$${CLOUDFLARE_DNS_API_TOKEN:-}" ]; then + export auth_key="$${CLOUDFLARE_DNS_API_TOKEN:-}" + elif [ -n "$${CLOUDFLARE_API_TOKEN:-}" ]; then + export auth_key="$${CLOUDFLARE_API_TOKEN:-}" + fi + if [ -n "$${CLOUDFLARE_USERNAME:-}" ]; then + export auth_email="$${CLOUDFLARE_USERNAME:-}" + fi + + # Ensure zone id is exported for the single-run script + export zone_identifier="${zoneId}" + + # Loop records from the Nix list. "@" maps to the base domain + for r in ${recordsStr}; do + if [ "$r" = "@" ]; then + export record_name="${domain}" + else + export record_name="$r.${domain}" + fi + /etc/cloudflare-ddns/update-single.sh || true + done + + ''; + mode = "0755"; + group = "root"; }; # Enable Tailscale for remote access to Traefik dashboard and configuration