# Restricted DNS-01 certificate issuance for ci.autonomic.zone (+ legacy ci.commoninternet.net). # # This host is authoritative only for acme.commoninternet.net. Gandi continues # to own commoninternet.net AND autonomic.zone; both zones delegate their narrow # _acme-challenge CNAME to the same acme-dns account. No Gandi credential is present here. # # Domain cutover 2026-09: ONE cert now covers BOTH zones (SANs: ci/*.ci.autonomic.zone + # ci/*.ci.commoninternet.net) — a single secret pair flows through the unchanged # warm_reconcile._traefik_setup ssl_cert/ssl_key flow, avoiding dual-cert SNI entirely. # The legacy commoninternet.net SANs are dropped in Phase 4 (post-bake reissue). { config, pkgs, ... }: let publicIPv4 = config.cc-ci.publicIPv4; acmeDnsConfig = pkgs.writeText "cc-ci-acme-dns.conf" '' [general] listen = "${publicIPv4}:53" protocol = "both4" domain = "acme.commoninternet.net" nsname = "ns-acme.commoninternet.net" nsadmin = "hostmaster.commoninternet.net" records = [ "acme.commoninternet.net. NS ns-acme.commoninternet.net.", "ns-acme.commoninternet.net. A ${publicIPv4}", ] debug = false [database] # acme-dns 2.x registers the embedded driver under `sqlite` (not the # legacy `sqlite3` identifier). engine = "sqlite" connection = "/var/lib/acme-dns/acme-dns.db" [api] ip = "127.0.0.1" port = "8080" tls = "none" # The one Lego account was bootstrapped before this configuration was # hardened. Updates authenticated by that account remain available. disable_registration = true corsorigins = [] [logconfig] loglevel = "info" logtype = "stdout" logformat = "json" ''; # These are wiring values only. The acme-dns account JSON is generated by # Lego below /var/lib/acme and never enters Nix, git, or /etc. # Storage path points at the NEW cert's dir; cc-ci-acme-storage-seed.service # (below) clones the legacy account entry into it under the new zone key so # Lego can write the new zone's TXT via the same (registration-disabled) # acme-dns account — the CNAME for _acme-challenge.ci.autonomic.zone already # delegates to that account's subdomain. legoEnvironment = pkgs.writeText "cc-ci-acme-dns-lego.env" '' ACME_DNS_API_BASE=http://127.0.0.1:8080 ACME_DNS_STORAGE_PATH=/var/lib/acme/ci.autonomic.zone/acme-dns-accounts.json ACME_DNS_ALLOWLIST=127.0.0.1/32 ''; # Seed the new cert's acmedns storage from the legacy one: same acme-dns # account (same subdomain/credentials — the only thing the DNS CNAME points # at), re-keyed for the new zone. Idempotent; runs before the ACME unit. acmeStorageSeed = pkgs.writeShellApplication { name = "cc-ci-acme-storage-seed"; runtimeInputs = with pkgs; [ jq coreutils ]; text = '' src=/var/lib/acme/ci.commoninternet.net/acme-dns-accounts.json dst=/var/lib/acme/ci.autonomic.zone/acme-dns-accounts.json install -d -m 0700 /var/lib/acme/ci.autonomic.zone if [ ! -s "$src" ]; then echo "storage seed: no legacy accounts.json — skipping (fresh host?)" exit 0 fi jq 'if has("ci.autonomic.zone") then . elif has("ci.commoninternet.net") then . + { "ci.autonomic.zone": .["ci.commoninternet.net"] } else . end' "$src" > "$dst.tmp" mv "$dst.tmp" "$dst" chmod 0600 "$dst" echo "storage seed: ok" ''; }; in { imports = [ ./options.nix ]; users.groups.acme-dns = { }; users.users.acme-dns = { isSystemUser = true; group = "acme-dns"; home = "/var/lib/acme-dns"; }; environment.etc."acme-dns/lego.env".source = legoEnvironment; networking.firewall = { allowedTCPPorts = [ 53 ]; allowedUDPPorts = [ 53 ]; }; # One `systemd` attrset (statix W20): the tmpfiles marker, the acme-dns daemon and the # traefik handoff oneshot. systemd = { # The staging order has completed successfully. This marker permits the # production ACME post-run hook to hand a renewed certificate to Traefik. tmpfiles.rules = [ "f /var/lib/ci-certs/acme-production-enabled 0600 root root -" ]; services.acme-dns = { description = "Restricted authoritative DNS for cc-ci ACME DNS-01"; wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; serviceConfig = { User = "acme-dns"; Group = "acme-dns"; StateDirectory = "acme-dns"; StateDirectoryMode = "0700"; WorkingDirectory = "/var/lib/acme-dns"; ExecStart = "${pkgs.acme-dns}/bin/acme-dns -c ${acmeDnsConfig}"; Restart = "on-failure"; RestartSec = "5s"; AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; NoNewPrivileges = true; PrivateTmp = true; PrivateDevices = true; ProtectHome = true; ProtectSystem = "strict"; ReadWritePaths = [ "/var/lib/acme-dns" ]; RestrictAddressFamilies = [ "AF_INET" "AF_UNIX" ]; }; }; # Seed the new cert's acmedns storage before the ACME unit first runs (see # acmeStorageSeed above). Ordering via the generated acme unit name. services.cc-ci-acme-storage-seed = { description = "Seed ci.autonomic.zone acme-dns storage from the legacy account"; after = [ "acme-dns.service" ]; wantedBy = [ "acme-ci.autonomic.zone.service" ]; before = [ "acme-ci.autonomic.zone.service" ]; serviceConfig = { Type = "oneshot"; UMask = "0077"; }; script = "${acmeStorageSeed}/bin/cc-ci-acme-storage-seed"; }; # Traefik consumes its wildcard as immutable Swarm secrets, so a renewed # host certificate must be copied and reconciled rather than merely reloaded. # This service is started only by the production-mode ACME postRun hook. services.cc-ci-acme-traefik-handoff = { description = "Install renewed cc-ci wildcard into Traefik Swarm secrets"; after = [ "docker.service" "deploy-proxy.service" ]; requires = [ "docker.service" ]; path = [ pkgs.coreutils pkgs.docker pkgs.systemd pkgs.gnugrep ]; serviceConfig = { Type = "oneshot"; UMask = "0077"; }; script = '' src=/var/lib/acme/ci.autonomic.zone dst=/var/lib/ci-certs/live test -s "$src/fullchain.pem" test -s "$src/key.pem" install -d -m 0700 "$dst" install -m 0444 "$src/fullchain.pem" "$dst/fullchain.pem.new" install -m 0400 "$src/key.pem" "$dst/privkey.pem.new" mv -f "$dst/fullchain.pem.new" "$dst/fullchain.pem" mv -f "$dst/privkey.pem.new" "$dst/privkey.pem" # deploy-proxy performs the health-gated Swarm rollout. Its reconciler # derives a fresh version from the public certificate chain and inserts # the matching ssl_cert/ssl_key secrets before deploying Traefik. systemctl restart deploy-proxy.service # A successful rollout no longer references old wildcard versions. Best # effort removal retains any secret Docker still reports as in use. keep="v$(sha256sum "$dst/fullchain.pem" | cut -c1-16)" docker secret ls --format '{{.Name}}' | \ grep -E '^traefik_ci_commoninternet_net_ssl_(cert|key)_v' | \ grep -v -E "_(ssl_cert|ssl_key)_$keep\$" | \ while IFS= read -r stale; do docker secret rm "$stale" || true; done ''; }; }; security.acme = { acceptTerms = true; certs."ci.autonomic.zone" = { domain = "ci.autonomic.zone"; # Dual-zone SANs: the new zone + the legacy one. One cert = one ssl_cert/ssl_key # secret pair = zero traefik reconciler changes. Phase 4 drops the legacy SANs. extraDomainNames = [ "*.ci.autonomic.zone" "ci.commoninternet.net" "*.ci.commoninternet.net" ]; # Staging issuance proved the permanent, narrowly delegated CNAME and the # restricted acme-dns account. Production uses the same account (re-keyed # for the new zone by cc-ci-acme-storage-seed.service). dnsProvider = "acmedns"; environmentFile = "/etc/acme-dns/lego.env"; dnsResolver = "1.1.1.1:53"; server = "https://acme-v02.api.letsencrypt.org/directory"; postRun = '' # The production marker is deployed only after staging proves the # permanent CNAME and restricted acme-dns account work end to end. if [ -e /var/lib/ci-certs/acme-production-enabled ]; then ${pkgs.systemd}/bin/systemctl --no-block start cc-ci-acme-traefik-handoff.service fi ''; }; }; }