221 lines
8.5 KiB
Nix
221 lines
8.5 KiB
Nix
# 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
|
|
'';
|
|
};
|
|
};
|
|
}
|