just check-dns: compare the sovrn.at zone (Marque record, nameservers, DNSSEC) with hosts.json and sovrn's fleet.nix
3 files changed,  +222, -0
M Justfile
+12, -0
 1@@ -277,6 +277,18 @@ gen-host-secrets HOST:
 2       for secret in "${manual[@]}"; do echo "  printf '%s' '<value>' | secrets encrypt $secret"; done
 3     fi
 4 
 5+# Expected records come from hosts.json and sovrn's fleet.nix at the locked
 6+# input (what is deployed); see scripts/check-dns.sh. Needs dig and openssl.
 7+# Compare the sovrn.at zone (Marque record, nameservers, DNSSEC) with the fleet.
 8+check-dns:
 9+    #!/usr/bin/env bash
10+    set -euo pipefail
11+    fleet="$(mktemp)"; trap 'rm -f "$fleet"' EXIT
12+    nix eval --json --impure --expr \
13+      'let f = import ((builtins.getFlake (toString ./.)).inputs.sovrn + "/nix/fleet.nix"); in { inherit (f) mailDomain rootHost; }' \
14+      2>/dev/null >"$fleet"
15+    scripts/check-dns.sh hosts.json "$fleet"
16+
17 # Use on a new workstation, or after a host was rebuilt elsewhere.
18 # Pin every host in hosts.json into ~/.ssh/known_hosts.
19 known-hosts:
M README.md
+1, -0
1@@ -32,6 +32,7 @@ The recipes run from any shell: the tools only the dev shell provides (colmena,
2 | `just deploy <fqdn> [args]` | Check its secrets, then `colmena apply`. `just deploy <fqdn> --reboot` makes the new system the boot default and reboots into it. |
3 | `just deploy-project <sovrn\|moods>` | Pick up the project's last commit (`nix flake update <p>`) and deploy every host running one of its roles. Commit `flake.lock` afterwards: it is the deploy log. |
4 | `just check-secrets [fqdn]` | Verify every secret a host declares decrypts from the store. |
5+| `just check-dns` | Compare the sovrn.at zone with the fleet: the Marque record in the PDS, each authoritative nameserver, and DNSSEC validation of the TLSA records. Expected records come from `hosts.json` and sovrn's `fleet.nix` (see `scripts/check-dns.sh`). Needs `dig` and `openssl`. |
6 | `just known-hosts` | Pin every host's SSH key (from `hosts.json`) in `~/.ssh/known_hosts`. |
7 | `just sync-nixpkgs` | After `just update-nixpkgs` in sovrn and moods: move the fleet to the revision they lock. |
8 | `just new-host <ipv4> <fqdn> <hetzner\|netcup> <roles>` | Install NixOS on a fresh box with nixos-anywhere (**erases it**): probes the image, records it in `hosts.json`, generates its secrets and SSH host key, installs, pins the key. |
A scripts/check-dns.sh
+209, -0
  1@@ -0,0 +1,209 @@
  2+#!/usr/bin/env bash
  3+# Compares the sovrn mail zone with what the fleet says it should be, in one
  4+# report (`just check-dns`). Exits 1 on any discrepancy.
  5+#
  6+# Expected records, all derived (nothing is listed by hand):
  7+#   cells (hosts.json, role sovrn-cell)
  8+#     <cell>                    A / AAAA       the host's ipv4 / ipv6
  9+#     *<pdsOriginSuffix>        CNAME <cell>   the cell's PDS wildcard
 10+#     _25._tcp.<cell>           TLSA 3 1 1     SHA-256 of the SPKI the cell
 11+#                                              serves on :25 right now
 12+#   relay (hosts.json, role sovrn-relay, settings.sovrnRelay.hostname)
 13+#     <relay>                   A / AAAA       the host's ipv4 / ipv6
 14+#     _25._tcp.<relay>          no TLSA        the backup MX fails open
 15+#                                              (bug 209274a): it must be a
 16+#                                              validated NXDOMAIN, not a record
 17+#   apex (sovrn's fleet.nix at the locked input: mailDomain, rootHost)
 18+#     <mailDomain>              A / AAAA       rootHost's addresses
 19+#     <mailDomain>              MX 10 rootHost
 20+#   SMTP2GO branded names (bug e76f226)
 21+#     dkim.<mailDomain>         CNAME dkim.smtp2go.net
 22+#     return.<mailDomain>       CNAME return.smtp2go.net
 23+#
 24+# Checked against:
 25+#   marque   the zone record in the PDS (at.marque.dns/<mailDomain>), the
 26+#            source of truth Marque serves from. Records at names nobody
 27+#            manages are listed for information, not flagged.
 28+#   NS       every authoritative nameserver, queried directly for each
 29+#            managed name (catches Marque lag or ingest problems), plus their
 30+#            SOA serials.
 31+#   DNSSEC   a validating resolver ($RESOLVER) must return the cells' TLSA
 32+#            and the relay's TLSA denial with the AD flag: DANE senders defer
 33+#            on anything that doesn't validate.
 34+#
 35+# Usage: check-dns.sh <hosts.json> <fleet.json>
 36+#   fleet.json: {"mailDomain": ..., "rootHost": ...}
 37+# Needs curl, jq, dig, openssl on PATH.
 38+set -euo pipefail
 39+
 40+INVENTORY="$1"
 41+FLEET="$2"
 42+# The PDS repo holding the at.marque.dns record (@rtw.run).
 43+MARQUE_REPO="${MARQUE_REPO:-did:plc:zpqnooj2vuoju6ssulbpdwxp}"
 44+RESOLVER="${RESOLVER:-1.1.1.1}"
 45+
 46+for t in curl jq dig openssl; do
 47+  command -v "$t" >/dev/null || { echo "check-dns: $t not on PATH" >&2; exit 2; }
 48+done
 49+
 50+zone="$(jq -r .mailDomain "$FLEET")"
 51+root_host="$(jq -r .rootHost "$FLEET")"
 52+
 53+# Records are tab-separated lines: name, type, value. Names are FQDNs without
 54+# the trailing dot, lowercase; values are normalised the same way dig prints
 55+# them (TLSA hex lowercase, unsplit; MX "<prio> <host>").
 56+norm() {
 57+  awk -F'\t' -v OFS='\t' '{
 58+    n = tolower($1); sub(/\.$/, "", n)
 59+    t = toupper($2); v = $3
 60+    if (t == "CNAME" || t == "NS") { v = tolower(v); sub(/\.$/, "", v) }
 61+    else if (t == "MX") { split(v, a, " "); h = tolower(a[2]); sub(/\.$/, "", h); v = a[1] " " h }
 62+    else if (t == "TLSA") {
 63+      split(v, a, " "); hex = ""
 64+      for (i = 4; i in a; i++) hex = hex a[i]
 65+      v = a[1] " " a[2] " " a[3] " " tolower(hex)
 66+    }
 67+    else if (t == "AAAA") v = tolower(v)
 68+    print n, t, v
 69+  }'
 70+}
 71+
 72+spki_sha256() {
 73+  echo | timeout 20 openssl s_client -connect "$1:25" -starttls smtp -servername "$1" 2>/dev/null |
 74+    openssl x509 -noout -pubkey 2>/dev/null |
 75+    openssl pkey -pubin -outform DER 2>/dev/null |
 76+    sha256sum | cut -d' ' -f1
 77+}
 78+EMPTY_SHA256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
 79+
 80+problems=0
 81+problem() { echo "  ✗ $*"; problems=$((problems + 1)); }
 82+ok() { echo "  ✓ $*"; }
 83+
 84+# --- Expected --------------------------------------------------------------
 85+
 86+expected="$(mktemp)"; absent="$(mktemp)"; marque="$(mktemp)"
 87+trap 'rm -f "$expected" "$absent" "$marque"' EXIT
 88+
 89+addrs() { # host -> A/AAAA lines for name $2
 90+  jq -r --arg h "$1" --arg n "$2" '.[$h] |
 91+    (if .ipv4 then [$n, "A", .ipv4] else empty end),
 92+    (if .ipv6 then [$n, "AAAA", (.ipv6 | split("/")[0])] else empty end) | @tsv' "$INVENTORY"
 93+}
 94+
 95+{
 96+  for cell in $(jq -r 'to_entries[] | select(.value.roles | index("sovrn-cell")) | .key' "$INVENTORY"); do
 97+    addrs "$cell" "$cell"
 98+    suffix="$(jq -r --arg h "$cell" '.[$h].pdsOriginSuffix // empty' "$INVENTORY")"
 99+    [ -z "$suffix" ] || printf '*%s\tCNAME\t%s\n' "$suffix" "$cell"
100+    hash="$(spki_sha256 "$cell")"
101+    if [ "$hash" = "$EMPTY_SHA256" ]; then
102+      echo "check-dns: couldn't fetch $cell's certificate on :25; its TLSA isn't checked" >&2
103+      problems=$((problems + 1))
104+    else
105+      printf '_25._tcp.%s\tTLSA\t3 1 1 %s\n' "$cell" "$hash"
106+    fi
107+  done
108+  jq -r 'to_entries[] | select(.value.roles | index("sovrn-relay")) | [.key, .value.settings.sovrnRelay.hostname] | @tsv' "$INVENTORY" |
109+    while IFS=$'\t' read -r host relay; do addrs "$host" "$relay"; done
110+  addrs "$root_host" "$zone"
111+  printf '%s\tMX\t10 %s\n' "$zone" "$root_host"
112+  printf 'dkim.%s\tCNAME\tdkim.smtp2go.net\n' "$zone"
113+  printf 'return.%s\tCNAME\treturn.smtp2go.net\n' "$zone"
114+} >"$expected"
115+# Normalised outside the group: it runs in this shell, so problem counts stick.
116+norm <"$expected" | sort -u >"$expected.n" && mv "$expected.n" "$expected"
117+
118+# (name, type) pairs that must have no record.
119+jq -r 'to_entries[] | select(.value.roles | index("sovrn-relay")) | "_25._tcp.\(.value.settings.sovrnRelay.hostname)\tTLSA"' "$INVENTORY" |
120+  awk -F'\t' -v OFS='\t' '{ print tolower($1), $2 }' | sort -u >"$absent"
121+
122+# Managed rrsets: every (name, type) the fleet has an opinion on.
123+managed() { { cut -f1,2 "$expected"; cat "$absent"; } | sort -u; }
124+values() { awk -F'\t' -v n="$2" -v t="$3" '$1 == n && $2 == t { print $3 }' "$1" | sort; }
125+
126+# --- Marque (the PDS record) ----------------------------------------------
127+
128+echo "marque: at://$MARQUE_REPO/at.marque.dns/$zone"
129+pds="$(curl -sf "https://plc.directory/$MARQUE_REPO" | jq -r '.service[] | select(.id == "#atproto_pds") | .serviceEndpoint')"
130+record="$(curl -sf "$pds/xrpc/com.atproto.repo.getRecord?repo=$MARQUE_REPO&collection=at.marque.dns&rkey=$zone")" ||
131+  { echo "check-dns: couldn't fetch the Marque record from $pds" >&2; exit 2; }
132+jq -r --arg z "$zone" '.value.records[] |
133+  [ (if .name == "@" then $z else "\(.name).\($z)" end),
134+    .recordType,
135+    (if .recordType == "MX" then "\(.priority) \(.value)" else .value end) ] | @tsv' <<<"$record" |
136+  norm | sort -u >"$marque"
137+echo "  cid $(jq -r .cid <<<"$record"), $(wc -l <"$marque") records, written $(jq -r .value.createdAt <<<"$record")"
138+
139+while IFS=$'\t' read -r name type; do
140+  want="$(values "$expected" "$name" "$type" | paste -sd' ')"
141+  have="$(values "$marque" "$name" "$type" | paste -sd' ')"
142+  if [ "$want" = "$have" ]; then
143+    ok "$name $type ${want:-(none)}"
144+  else
145+    problem "$name $type: want [$want] have [$have]"
146+  fi
147+done < <(managed)
148+
149+echo "  unmanaged (information only):"
150+awk -F'\t' 'NR == FNR { m[$1 FS $2]; next }
151+  !(($1 FS $2) in m) { v = $3; if (length(v) > 60) v = substr(v, 1, 57) "..."; printf "    %s %s %s\n", $1, $2, v }' \
152+  <(managed) "$marque"
153+
154+# --- Authoritative nameservers --------------------------------------------
155+
156+mapfile -t nameservers < <(dig +short NS "$zone" | sed 's/\.$//' | sort)
157+echo
158+echo "nameservers: ${nameservers[*]}"
159+serials=""
160+for ns in "${nameservers[@]}"; do
161+  serials+="$(dig +short +norec SOA "$zone" @"$ns" | awk '{ print $3 }') "
162+done
163+if [ "$(tr ' ' '\n' <<<"$serials" | sed '/^$/d' | sort -u | wc -l)" = 1 ]; then
164+  ok "SOA serial ${serials%% *} on all"
165+else
166+  problem "SOA serials differ: $serials(${nameservers[*]}): a change is still propagating"
167+fi
168+
169+while IFS=$'\t' read -r name type; do
170+  want="$(values "$expected" "$name" "$type" | paste -sd' ')"
171+  bad=""
172+  for ns in "${nameservers[@]}"; do
173+    have="$(dig +short +norec "$type" "$name" @"$ns" | sed "s/^/$name\t$type\t/" | norm | cut -f3 | sort | paste -sd' ')"
174+    [ "$want" = "$have" ] || bad+=" $ns=[$have]"
175+  done
176+  if [ -z "$bad" ]; then
177+    ok "$name $type"
178+  else
179+    problem "$name $type: want [$want]$bad"
180+  fi
181+done < <(managed)
182+
183+# --- DNSSEC (DANE) ---------------------------------------------------------
184+
185+echo
186+echo "dnssec via $RESOLVER:"
187+while IFS=$'\t' read -r name type; do
188+  out="$(dig +dnssec "$type" "$name" @"$RESOLVER")"
189+  status="$(grep -o 'status: [A-Z]*' <<<"$out" | cut -d' ' -f2)"
190+  ad="$(grep -c '^;; flags:.* ad' <<<"$out" || true)"
191+  if grep -qx "$name"$'\t'"$type" "$absent"; then
192+    if [ "$status" = NXDOMAIN ] || { [ "$status" = NOERROR ] && ! grep -q $'\tIN\tTLSA\t' <<<"$out"; }; then
193+      [ "$ad" = 1 ] && ok "$name $type: validated denial ($status)" ||
194+        problem "$name $type: $status without AD; DANE senders can't prove it absent and defer"
195+    else
196+      problem "$name $type: expected no record, got $status"
197+    fi
198+  else
199+    [ "$status" = NOERROR ] && [ "$ad" = 1 ] && ok "$name $type: validated" ||
200+      problem "$name $type: status $status, AD=$ad"
201+  fi
202+done < <(managed | awk -F'\t' '$2 == "TLSA"')
203+
204+echo
205+if [ "$problems" = 0 ]; then
206+  echo "check-dns: $zone matches the fleet"
207+else
208+  echo "check-dns: $problems discrepancies in $zone"
209+  exit 1
210+fi