{ config, pkgs, lib, ... }: let choose = paths: builtins.head (builtins.filter (p: builtins.pathExists p) paths); # Domain and Cloudflare DDNS records configured here. Update this list to add/remove records. domain = "prg-radio.org"; records = ["git" "grafana" "anubis" "wavelog" "pelican" "partdb" "mail" "mailadmin" "@" "test" "minecraft"]; recordsStr = lib.concatStringsSep " " records; zoneId = "9fde8d0fa53502f2d1b7e0b1d3765d49"; envFile = "/home/traefikprg/cloudflare/cloudflare.env"; in { options.local = { hostname = lib.mkOption { type = lib.types.str; default = "nixos-default"; description = "System hostname"; }; username = lib.mkOption { type = lib.types.str; default = "user"; description = "Primary user username"; }; userDescription = lib.mkOption { type = lib.types.str; default = "NixOS User"; description = "Primary user description"; }; address = lib.mkOption { type = lib.types.str; default = "10.1.1.100"; description = "Static IP address"; }; }; imports = [ (choose [./modules/desktop-manager/sway_greetd_homemanager.nix ../desktop-manager/sway_greetd_homemanager.nix]) (choose [./modules/local/hostname_username.nix ../local/hostname_username.nix]) (choose [./modules/local/networking_local.nix ../local/networking_local.nix]) (choose [./modules/bootloader/seabios-assigned-iso-at-birth.nix ../bootloader/seabios-assigned-iso-at-birth.nix]) (choose [./modules/lix-default.nix ../lix-default.nix]) # Optionally: (choose [ ./modules/toolsets/remote_building.nix ../toolsets/remote_building.nix ]) ]; config = { local.hostname = "nixos-traefik"; local.username = "traefikprg"; local.userDescription = "NixOS PRG Traefik Service"; local.address = "10.1.1.250"; # Configure Anubis service services.anubis = { instances = { "" = { enable = true; settings = { # Bind to TCP instead of Unix socket for Docker-style integration BIND_NETWORK = "tcp"; BIND = "127.0.0.1:8090"; # Empty TARGET for redirect mode TARGET = " "; # Configure redirect domains - ALL domains that should be able to redirect back after challenge REDIRECT_DOMAINS = "prg-radio.org,anubis.prg-radio.org,git.prg-radio.org,lgbtq.prg-radio.org,wavelog.prg-radio.org,partdb.prg-radio.org,grafana.prg-radio.org"; # Public URL for Anubis PUBLIC_URL = "https://anubis.prg-radio.org"; # Cookie domain for proper scoping COOKIE_DOMAIN = ".prg-radio.org"; # Difficulty level DIFFICULTY = 7; #ALGOTIHM = "slow"; # Optional: serve robots.txt SERVE_ROBOTS_TXT = true; # Optional: webmaster email for error pages WEBMASTER_EMAIL = "dtu.prg@gmail.com"; # Prevents infinite loop of doom: # See: https://github.com/TecharoHQ/anubis/issues/970 JWT_RESTRICTION_HEADER = "CF-Connecting-IP"; # Metrics on separate port METRICS_BIND_NETWORK = "tcp"; METRICS_BIND = "127.0.0.1:8091"; SLOG_LEVEL = "DEBUG"; COOKIE_SECURE = true; COOKIE_SAME_SITE = "None"; # Only if Secure=true and you need cross-site }; }; }; }; services.loki = { enable = true; configuration = { auth_enabled = false; server = { http_listen_port = 3100; }; ingester = { lifecycler = { address = "0.0.0.0"; ring = { kvstore = { store = "inmemory"; }; replication_factor = 1; }; final_sleep = "0s"; }; chunk_idle_period = "1h"; max_chunk_age = "1h"; chunk_target_size = 1048576; chunk_retain_period = "30s"; }; schema_config = { configs = [ { from = "2020-10-24"; store = "boltdb-shipper"; object_store = "filesystem"; schema = "v11"; index = { prefix = "index_"; period = "24h"; }; } ]; }; storage_config = { boltdb_shipper = { active_index_directory = "/var/lib/loki/boltdb-shipper-active"; cache_location = "/var/lib/loki/boltdb-shipper-cache"; cache_ttl = "24h"; }; filesystem = { directory = "/var/lib/loki/chunks"; }; }; limits_config = { reject_old_samples = true; reject_old_samples_max_age = "168h"; # Disable structured metadata/OTLP native ingestion until schema is upgraded to v13+ and tsdb index is used. allow_structured_metadata = false; }; table_manager = { retention_deletes_enabled = false; retention_period = "0s"; }; }; }; services.alloy = { enable = true; }; services.prometheus = { enable = true; port = 9001; exporters.node = { enabledCollectors = [ "ethtool" "softirqs" "systemd" "tcpstat" ]; enable = true; port = 9002; }; globalConfig.scrape_interval = "5s"; # "1m" scrapeConfigs = [ { job_name = "traefik"; static_configs = [ { targets = ["localhost:8080"]; } ]; } ]; }; # See: https://github.com/grafana/alloy/blob/bbe11b0f6a3bd3391108b2d27122c654c535dd8d/docs/sources/set-up/migrate/from-promtail.md environment.etc."alloy/config.alloy" = { text = '' discovery.relabel "metrics_integrations_integrations_traefik" { targets = [{ __address__ = "localhost:8080", }] rule { target_label = "instance" replacement = constants.hostname } } prometheus.scrape "metrics_integrations_integrations_traefik" { targets = discovery.relabel.metrics_integrations_integrations_traefik.output job_name = "integrations/traefik" forward_to = [prometheus.remote_write.default.receiver] } prometheus.remote_write "default" { endpoint { url = "http://127.0.0.1:9001/api/v1/write" } } ''; group = "root"; mode = "0644"; }; services.traefik = { enable = true; group = "acme"; staticConfigOptions = { metrics = { prometheus = { addEntryPointsLabels = true; addRoutersLabels = true; addServicesLabels = true; buckets = [0.1 0.3 1.2 5.0]; }; }; entryPoints = { web = { # Bind on IPv6 as well as IPv4 by using [::]:80 address = "[::]:80"; asDefault = true; http.redirections.entrypoint = { to = "websecure"; scheme = "https"; }; }; websecure = { # Bind on IPv6 as well as IPv4 by using [::]:443 address = "[::]:443"; asDefault = true; http.tls = { domains = [ { main = "prg-radio.org"; sans = ["*.prg-radio.org"]; } ]; }; }; # Federation entrypoint: external TLS on 8448 federation = { # Bind on IPv6 as well as IPv4 by using [::]:8448 address = "[::]:8448"; }; # TeamSpeak entry points teamspeak-voice = { # UDP entrypoint bind to IPv6 as well address = "[::]:9987/udp"; }; teamspeak-squery = { address = "[::]:10022/tcp"; }; teamspeak-data = { address = "[::]:30033/tcp"; }; # Mail entrypoints: plain SMTP, implicit SMTPS (465) and IMAPS (993) smtp = { # plain SMTP (port 25) - TCP passthrough to backend address = "[::]:25"; }; smtps = { # implicit TLS SMTP (port 465) - passthrough to backend address = "[::]:465"; }; imaps = { # implicit TLS IMAP (port 993) - passthrough to backend address = "[::]:993"; }; # Minecraft TCP entrypoint (Minecraft Java Edition default port) minecraft = { address = "[::]:25565"; }; # Minecraft VoiceChat UDP entrypoint (VoiceChat mod default port) minecraft-voice = { address = "[::]:24454/udp"; }; }; log = { level = "INFO"; filePath = "${config.services.traefik.dataDir}/traefik.log"; format = "json"; }; api.dashboard = true; api.insecure = true; }; dynamicConfigOptions = { tls.certificates = [ { certFile = "/var/lib/acme/prg-radio.org/cert.pem"; keyFile = "/var/lib/acme/prg-radio.org/key.pem"; } ]; # Traefik middlewares: Anubis forwardAuth http.middlewares = { anubisForwardAuth = { forwardAuth = { # point at the Anubis check endpoint (the subrequest/UI instance) address = "http://127.0.0.1:8090/.within.website/x/cmd/anubis/api/check"; }; }; redirect-to-https = { redirectScheme = { scheme = "https"; }; }; # Middleware to strip the /management prefix when proxying to the mail admin mail-strip-management = { stripPrefix = {prefixes = ["/management"];}; }; }; http.routers = { # Anubis router (for challenge page) anubis = { rule = "Host(`anubis.prg-radio.org`)"; service = "anubis"; entryPoints = ["websecure"]; tls = { certresolver = "acme"; }; }; # Protected service example: Forgejo (attach Anubis forwardAuth middleware) forgejo = { rule = "Host(`git.prg-radio.org`)"; service = "forgejo"; entryPoints = ["websecure"]; tls = {}; middlewares = ["anubisForwardAuth"]; }; # Pelican panel - exposes the Pelican panel UI pelican = { rule = "Host(`pelican.prg-radio.org`)"; service = "pelican"; entryPoints = ["websecure"]; tls = { certresolver = "acme"; }; }; # Matrix HTTP router for client requests (Element etc.) matrix = { rule = "Host(`lgbtq.prg-radio.org`)"; service = "matrix"; entryPoints = ["websecure"]; tls = {}; }; # Protected service: Wavelog wavelog = { rule = "Host(`wavelog.prg-radio.org`)"; service = "wavelog"; entryPoints = ["websecure"]; tls = {}; }; # Protected service: PartDB partdb = { rule = "Host(`partdb.prg-radio.org`)"; service = "partdb"; entryPoints = ["websecure"]; tls = {}; middlewares = ["anubisForwardAuth"]; }; # Protected service: Grafana grafana = { rule = "Host(`grafana.prg-radio.org`)"; service = "grafana"; entryPoints = ["websecure"]; tls = {}; middlewares = ["anubisForwardAuth"]; }; # Mail HTTP (JMAP / web) - terminate TLS at Traefik and forward to Stalwart JMAP HTTP listener mail = { rule = "Host(`mail.prg-radio.org`)"; service = "mail-jmap"; entryPoints = ["websecure"]; tls = {certresolver = "acme";}; }; # Mail web administration UI (Stalwart management) - exposed under /management mail-webadmin = { rule = "Host(`mailadmin.prg-radio.org`) && PathPrefix(`/management`)"; service = "mail-webadmin"; entryPoints = ["websecure"]; tls = {certresolver = "acme";}; # strip the /management prefix before proxying to the backend middlewares = ["mail-strip-management"]; }; }; http.services = { # Anubis service (challenge UI / redirect endpoint) anubis.loadBalancer = { servers = [ {url = "http://127.0.0.1:8090";} ]; }; forgejo.loadBalancer = { servers = [ {url = "http://10.1.1.4:30000";} ]; }; matrix.loadBalancer = { servers = [ {url = "http://10.1.1.248:12244";} ]; }; wavelog.loadBalancer = { servers = [ {url = "http://10.1.1.249:8086";} ]; }; partdb.loadBalancer = { servers = [ {url = "http://10.1.1.249:8087";} ]; }; grafana.loadBalancer = { servers = [ {url = "http://10.1.1.10:3000";} ]; }; # Pelican panel backend - the wavelog host exposes the container on host port 8070 pelican.loadBalancer = { servers = [ {url = "http://10.1.1.249:8070";} ]; passHostHeader = true; }; # Mail JMAP HTTP backend mail-jmap.loadBalancer = { servers = [{url = "http://10.1.1.15:8080";}]; }; # Mail webadmin backend (management). Traefik will strip the /management # prefix and forward the remainder to the management listener on the mail # server which is bound to port 8081. mail-webadmin.loadBalancer = { servers = [{url = "http://10.1.1.15:8081";}]; }; # Minecraft service (plain TCP on 25565) minecraft.loadBalancer = { servers = [ {url = "http://10.1.1.244:25565";} ]; }; }; # TCP routing for TeamSpeak tcp.routers = { teamspeak-squery = { rule = "HostSNI(`*`)"; service = "teamspeak-squery"; entryPoints = ["teamspeak-squery"]; }; teamspeak-data = { rule = "HostSNI(`*`)"; service = "teamspeak-data"; entryPoints = ["teamspeak-data"]; }; # Federation TCP router: incoming on :8448 -> Conduit backend matrix-federation = { rule = "HostSNI(`*`)"; service = "matrix-federation"; entryPoints = ["federation"]; # Pass TLS through to the backend (Conduit handles TLS on port 12244) tls = { passthrough = true; }; }; # Also accept TLS passthrough on port 443 for the Matrix host so # other servers that contact :443/_matrix/* # will be forwarded to Conduit as well. # I guess I had to declare the rule explicitly here again? matrix-tls443 = { # match by SNI to avoid interfering with other HTTPS sites rule = "HostSNI(`lgbtq.prg-radio.org`)"; service = "matrix-federation"; entryPoints = ["websecure"]; tls = { passthrough = true; }; }; # Mail TCP routers: SMTP (25), SMTPS (465 implicit TLS), IMAPS (993 implicit TLS) mail-smtp = { # catch-all TCP router for port 25 rule = "HostSNI(`*`)"; service = "mail-smtp"; entryPoints = ["smtp"]; }; mail-smtps = { # SMTPS implicit TLS - passthrough to backend rule = "HostSNI(`*`)"; service = "mail-smtps"; entryPoints = ["smtps"]; tls = {passthrough = true;}; }; mail-imaps = { # IMAPS implicit TLS - passthrough to backend rule = "HostSNI(`*`)"; service = "mail-imaps"; entryPoints = ["imaps"]; tls = {passthrough = true;}; }; # TCP router for Minecraft (plain TCP on 25565) minecraft = { # Use catch-all SNI rule because Minecraft clients don't use TLS/SNI rule = "HostSNI(`*`)"; service = "minecraft"; entryPoints = ["minecraft"]; }; }; tcp.services = { teamspeak-squery.loadBalancer = { servers = [ {address = "10.1.1.248:10022";} ]; }; teamspeak-data.loadBalancer = { servers = [ {address = "10.1.1.248:30033";} ]; }; matrix-federation.loadBalancer = { servers = [ {address = "10.1.1.248:12244";} ]; }; # Mail TCP services mail-smtp.loadBalancer = { proxyProtocol = {version = 2;}; # Add this line servers = [{address = "10.1.1.15:25";}]; }; mail-smtps.loadBalancer = { proxyProtocol = {version = 2;}; # Add this line servers = [{address = "10.1.1.15:465";}]; }; mail-imaps.loadBalancer = { proxyProtocol = {version = 2;}; # Add this line servers = [{address = "10.1.1.15:993";}]; }; # TCP service for Minecraft minecraft.loadBalancer = { servers = [ {address = "10.1.1.244:25565";} ]; }; }; # UDP routing for TeamSpeak voice udp.routers = { teamspeak-voice = { entryPoints = ["teamspeak-voice"]; service = "teamspeak-voice"; }; # UDP router for Minecraft VoiceChat mod minecraft-voice = { entryPoints = ["minecraft-voice"]; service = "minecraft-voice"; }; }; udp.services = { teamspeak-voice.loadBalancer = { servers = [ {address = "10.1.1.248:9987";} ]; }; minecraft-voice.loadBalancer = { servers = [ {address = "10.1.1.244:24454";} ]; }; }; }; }; security.acme = { acceptTerms = true; defaults.email = "dtu.prg@gmail.com"; certs."prg-radio.org" = { domain = "*.prg-radio.org"; group = "acme"; dnsProvider = "cloudflare"; environmentFile = "/home/traefikprg/cloudflare/cloudflare.env"; reloadServices = ["traefik.service"]; }; }; environment.systemPackages = with pkgs; [ util-linux ]; # 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 = '' #!${pkgs.bash}/bin/bash ## K0p1-Git cloudflare-ddns-updater (packaged copy) ## Upstream: https://github.com/K0p1-Git/cloudflare-ddns-updater ## Author: K0p1-Git ## License: MIT ## change to "bin/sh" when necessary 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="" # 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" ########################################### ## Check if we have a public IP ########################################### REGEX_IPV4="^(0*(1?[0-9]{1,2}|2([0-4][0-9]|5[0-5]))\\.){3}0*(1?[0-9]{1,2}|2([0-4][0-9]|5[0-5]))$" IP_SERVICES=( "https://api.ipify.org" "https://ipv4.icanhazip.com" "https://ipinfo.io/ip" ) # Try all the ip services for a valid IPv4 address for service in ''${IP_SERVICES[@]}; do echo "Testing service: $service" RAW_IP=$(curl -s "$service") RAW_IP=$(echo "$RAW_IP" | tr -d '\n' | tr -d '\r' | xargs) echo "RAW_IP: '$RAW_IP'" if [[ $RAW_IP =~ $REGEX_IPV4 ]]; then CURRENT_IP=$BASH_REMATCH ${pkgs.util-linux}/bin/logger -s "DDNS Updater: Fetched IP $CURRENT_IP" break else ${pkgs.util-linux}/bin/logger -s "DDNS Updater: IP service $service failed. Raw: '$RAW_IP'" fi done # If a record name was passed as first argument, use it (wrapper now passes it) if [ -n "$1" ]; then record_name="$1" ${pkgs.util-linux}/bin/logger -s "DDNS Updater: Using record_name from arg: '$record_name'" else ${pkgs.util-linux}/bin/logger -s "DDNS Updater: No record_name arg provided; using record_name env: '$record_name'" fi # If a zone id was passed as second argument, use it (wrapper now passes it) if [ -n "$2" ]; then zone_identifier="$2" ${pkgs.util-linux}/bin/logger -s "DDNS Updater: Using zone_identifier from arg: '$zone_identifier'" else ${pkgs.util-linux}/bin/logger -s "DDNS Updater: No zone_identifier arg provided; using zone_identifier env: '$zone_identifier'" fi # If auth_email/auth_key were passed as the 3rd/4th arguments, use them (wrapper now passes them) if [ -n "$3" ]; then auth_email="$3" ${pkgs.util-linux}/bin/logger -s "DDNS Updater: Using auth_email from arg: '$auth_email'" else ${pkgs.util-linux}/bin/logger -s "DDNS Updater: No auth_email arg provided; using auth_email env: '$auth_email'" fi if [ -n "$4" ]; then auth_key="$4" # avoid printing the key itself to logs; show length instead ${pkgs.util-linux}/bin/logger -s "DDNS Updater: Using auth_key from arg (length=''${#auth_key})" else ${pkgs.util-linux}/bin/logger -s "DDNS Updater: No auth_key arg provided; using auth_key env (length=''${#auth_key})" fi # Fail fast if we still don't have a zone identifier if [ -z "$zone_identifier" ]; then ${pkgs.util-linux}/bin/logger -s "DDNS Updater: zone_identifier is empty — cannot proceed (invalid zone)." 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 # Output for debugging ${pkgs.util-linux}/bin/logger -s "DDNS Updater: Using auth_method ''${auth_method} with header ''${auth_header}" ########################################### ## Seek for the A record ########################################### ${pkgs.util-linux}/bin/logger "DDNS Updater: Check Initiated" record=$(${pkgs.curl}/bin/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") # Output for debugging CURL_EXIT=$? ${pkgs.util-linux}/bin/logger -s "DDNS Updater: Cloudflare GET exit=$CURL_EXIT response=$record" ########################################### ## Check if the domain has an A record ########################################### if [[ $record == *"\\"count\\":0"* ]]; then ${pkgs.util-linux}/bin/logger -s "DDNS Updater: Record does not exist, perhaps create one first? (''${CURRENT_IP} for ''${record_name})" exit 1 fi # Output for debugging ${pkgs.util-linux}/bin/logger -s "DDNS Updater: Record found for ''${record_name}, checking IP..." ########################################### ## Get existing IP ########################################### old_ip=$(echo "$record" | ${pkgs.jq}/bin/jq -r '.result[0].content') # Compare if they're the same if [[ $CURRENT_IP == $old_ip ]]; then ${pkgs.util-linux}/bin/logger "DDNS Updater: IP ($CURRENT_IP) for ''${record_name} has not changed." exit 0 fi # Output for debugging ${pkgs.util-linux}/bin/logger -s "DDNS Updater: Current IP ($CURRENT_IP) is different from old IP ($old_ip) for ''${record_name}, updating record..." ########################################### ## Set the record identifier from result ########################################### record_identifier=$(echo "$record" | ${pkgs.jq}/bin/jq -r '.result[0].id') # Output for debugging ${pkgs.util-linux}/bin/logger -s "DDNS Updater: Record identifier for ''${record_name} is ''${record_identifier}" ########################################### ## Change the IP@Cloudflare using the API ########################################### update=$(${pkgs.curl}/bin/curl -sS -o - -w '\nHTTP_STATUS:%{http_code}' -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}}" 2>&1) CURL_EXIT=$? ${pkgs.util-linux}/bin/logger -s "DDNS Updater: Cloudflare PATCH exit=$CURL_EXIT response=$update" ########################################### ## 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" | ${pkgs.util-linux}/bin/logger -s if [[ $slackuri != "" ]]; then msg="''${sitename} DDNS Update Failed: ''${record_name}: ''${record_identifier} (''${CURRENT_IP})." ${pkgs.curl}/bin/curl -L -X POST "''${slackuri}" --data-raw "{\"channel\":\"''${slackchannel}\",\"text\":\"''${msg}\"}" fi if [[ $discorduri != "" ]]; then msg="''${sitename} DDNS Update Failed: ''${record_name}: ''${record_identifier} (''${CURRENT_IP})." ${pkgs.curl}/bin/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}" ${pkgs.util-linux}/bin/logger "DDNS Updater: ''${CURRENT_IP} ''${record_name} DDNS updated." if [[ $slackuri != "" ]]; then ${pkgs.curl}/bin/curl -L -X POST "''${slackuri}" --data-raw "{\"channel\":\"''${slackchannel}\",\"text\":\"''${msg}\"}" fi if [[ $discorduri != "" ]]; then ${pkgs.curl}/bin/curl -i -H "Accept: application/json" -H "Content-Type:application/json" -X POST --data-raw "{\"content\":\"''${msg}\"}" "''${discorduri}" fi exit 0;; esac ''; mode = "0755"; group = "root"; }; environment.etc."cloudflare-ddns/update.sh" = { text = '' #!${pkgs.bash}/bin/bash set -euo pipefail # 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 # Default to Cloudflare proxy enabled for all records, but explicitly # disable the proxy for the Minecraft record so clients connect # directly to the origin on port 25565 (Cloudflare does not proxy # arbitrary Minecraft TCP without Spectrum). if [ "$r" = "minecraft" ]; then export proxy="false" else export proxy="true" fi # Invoke the single-run script explicitly with the system's bash via env to avoid /bin/bash shebang issues ${pkgs.bash}/bin/bash /etc/cloudflare-ddns/update-single.sh "$record_name" "$zone_identifier" "$auth_email" "$auth_key" || true done ''; mode = "0755"; group = "root"; }; # Enable Tailscale for remote access to Traefik dashboard and configuration services.tailscale.enable = true; systemd.services.traefik = { after = ["acme-finished-prg-radio.org.target"]; wants = ["acme-finished-prg-radio.org.target"]; }; systemd.services.prg-cloudflare-ddns-updater = { description = "PRG Cloudflare DDNS updater"; after = ["network-online.target"]; wantedBy = ["multi-user.target"]; serviceConfig = { Type = "oneshot"; ExecStart = "${pkgs.bash}/bin/bash /etc/cloudflare-ddns/update.sh"; User = "traefikprg"; # Make sure the service doesn't fail the boot if the script exits non-zero Restart = "no"; }; }; systemd.timers.prg-cloudflare-ddns-updater = { description = "Run PRG Cloudflare DDNS updater hourly"; wantedBy = ["timers.target"]; timerConfig = { OnBootSec = "1m"; # run shortly after boot OnUnitActiveSec = "1h"; # then every hour AccuracySec = "1m"; }; }; networking.firewall.allowedTCPPorts = [80 443 10022 30033 8448 9001 3001 993 465 25 25565 24454]; networking.firewall.allowedUDPPorts = [80 443 9987 9001 3001 993 465 25 25565 24454]; system.stateVersion = "25.11"; }; }