check-dns.sh

  1#!/usr/bin/env bash
  2# Compares the sovrn mail zone with what the fleet says it should be, in one
  3# report (`just check-dns`). Exits 1 on any discrepancy.
  4#
  5# Expected records, all derived (nothing is listed by hand):
  6#   cells (hosts.json, role sovrn-cell)
  7#     <cell>                    A / AAAA       the host's ipv4 / ipv6
  8#     *.pdsN.<rest>             CNAME <cell>   the PDS wildcard of a cell
  9#                                              mxN.<rest> with pdsAutoProvision
 10#                                              (named as sovrn derives it)
 11#     _25._tcp.<cell>           TLSA 3 1 1     SHA-256 of the SPKI the cell
 12#                                              serves on :25 right now
 13#   relay (hosts.json, role sovrn-relay, settings.sovrnRelay.hostname)
 14#     <relay>                   A / AAAA       the host's ipv4 / ipv6
 15#     <relay>                   TXT v=spf1 a -all
 16#                                              its DSNs go out direct (null
 17#                                              sender: SPF checks the HELO
 18#                                              name; bug b1f7229)
 19#     _25._tcp.<relay>          no TLSA        the backup MX fails open
 20#                                              (bug 209274a): it must be a
 21#                                              validated NXDOMAIN, not a record
 22#   apex (sovrn's fleet.nix at the locked input: zone, rootHost)
 23#     <zone>                    A / AAAA       rootHost's addresses
 24#     <zone>                    MX 10 rootHost
 25#   SMTP2GO branded names (bug e76f226)
 26#     dkim.<zone>               CNAME dkim.smtp2go.net
 27#     return.<zone>             CNAME return.smtp2go.net
 28#
 29# Checked against:
 30#   marque   the zone record in the PDS (at.marque.dns/<zone>), the
 31#            source of truth Marque serves from. Records at names nobody
 32#            manages are listed for information, not flagged.
 33#   NS       every authoritative nameserver, queried directly for each
 34#            managed name (catches Marque lag or ingest problems), plus their
 35#            SOA serials.
 36#   DNSSEC   a validating resolver ($RESOLVER) must return the cells' TLSA
 37#            and the relay's TLSA denial with the AD flag: DANE senders defer
 38#            on anything that doesn't validate.
 39#
 40# Usage: check-dns.sh <hosts.json> <fleet.json>
 41#   fleet.json: {"zone": ..., "rootHost": ...}
 42# With CHECK_DNS_STATE=<dir>, also leaves what it compared there for
 43# update-dns.sh: expected.tsv, absent.tsv, record.json (the getRecord
 44# response, with its CID) and marque.tsv (one normalised line per entry of
 45# record.json's records, in the same order).
 46# Needs curl, jq, dig, openssl on PATH.
 47set -euo pipefail
 48
 49INVENTORY="$1"
 50FLEET="$2"
 51# The PDS repo holding the at.marque.dns record (@rtw.run).
 52MARQUE_REPO="${MARQUE_REPO:-did:plc:zpqnooj2vuoju6ssulbpdwxp}"
 53RESOLVER="${RESOLVER:-1.1.1.1}"
 54
 55for t in curl jq dig openssl; do
 56  command -v "$t" >/dev/null || { echo "check-dns: $t not on PATH" >&2; exit 2; }
 57done
 58
 59zone="$(jq -r .zone "$FLEET")"
 60root_host="$(jq -r .rootHost "$FLEET")"
 61
 62# Records are tab-separated lines: name, type, value. Names are FQDNs without
 63# the trailing dot, lowercase; values are normalised the same way dig prints
 64# them (TLSA hex lowercase, unsplit; MX "<prio> <host>").
 65norm() {
 66  awk -F'\t' -v OFS='\t' '{
 67    n = tolower($1); sub(/\.$/, "", n)
 68    t = toupper($2); v = $3
 69    if (t == "CNAME" || t == "NS") { v = tolower(v); sub(/\.$/, "", v) }
 70    else if (t == "MX") { split(v, a, " "); h = tolower(a[2]); sub(/\.$/, "", h); v = a[1] " " h }
 71    else if (t == "TLSA") {
 72      split(v, a, " "); hex = ""
 73      for (i = 4; i in a; i++) hex = hex a[i]
 74      v = a[1] " " a[2] " " a[3] " " tolower(hex)
 75    }
 76    else if (t == "AAAA") v = tolower(v)
 77    # dig quotes TXT data and splits long values into strings; Marque does not.
 78    else if (t == "TXT") { gsub(/" "/, "", v); gsub(/^"|"$/, "", v) }
 79    print n, t, v
 80  }'
 81}
 82
 83spki_sha256() {
 84  echo | timeout 20 openssl s_client -connect "$1:25" -starttls smtp -servername "$1" 2>/dev/null |
 85    openssl x509 -noout -pubkey 2>/dev/null |
 86    openssl pkey -pubin -outform DER 2>/dev/null |
 87    sha256sum | cut -d' ' -f1
 88}
 89EMPTY_SHA256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
 90
 91# Colour only on a terminal, and not with NO_COLOR set (no-color.org).
 92if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
 93  red=$'\e[31m' green=$'\e[32m' reset=$'\e[0m'
 94else
 95  red="" green="" reset=""
 96fi
 97
 98problems=0
 99problem() { echo "  ${red}✗ $*${reset}"; problems=$((problems + 1)); }
100ok() { echo "  ${green}✓${reset} $*"; }
101
102# --- Expected --------------------------------------------------------------
103
104expected="$(mktemp)"; absent="$(mktemp)"; marque="$(mktemp)"
105trap 'rm -f "$expected" "$absent" "$marque" "$marque.ordered"' EXIT
106
107addrs() { # host -> A/AAAA lines for name $2
108  jq -r --arg h "$1" --arg n "$2" '.[$h] |
109    (if .ipv4 then [$n, "A", .ipv4] else empty end),
110    (if .ipv6 then [$n, "AAAA", (.ipv6 | split("/")[0])] else empty end) | @tsv' "$INVENTORY"
111}
112
113{
114  for cell in $(jq -r 'to_entries[] | select(.value.roles | index("sovrn-cell")) | .key' "$INVENTORY"); do
115    addrs "$cell" "$cell"
116    if [ "$(jq -r --arg h "$cell" '.[$h].pdsAutoProvision // false' "$INVENTORY")" = true ]; then
117      if [[ $cell =~ ^mx([0-9]+)\.(.+)$ ]]; then
118        printf '*.pds%s.%s\tCNAME\t%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "$cell"
119      else
120        echo "${red}check-dns: $cell has pdsAutoProvision but isn't named mxN.<rest>${reset}" >&2
121        problems=$((problems + 1))
122      fi
123    fi
124    hash="$(spki_sha256 "$cell")"
125    if [ "$hash" = "$EMPTY_SHA256" ]; then
126      echo "${red}check-dns: couldn't fetch $cell's certificate on :25; its TLSA isn't checked${reset}" >&2
127      problems=$((problems + 1))
128    else
129      printf '_25._tcp.%s\tTLSA\t3 1 1 %s\n' "$cell" "$hash"
130    fi
131  done
132  jq -r 'to_entries[] | select(.value.roles | index("sovrn-relay")) | [.key, .value.settings.sovrnRelay.hostname] | @tsv' "$INVENTORY" |
133    while IFS=$'\t' read -r host relay; do
134      addrs "$host" "$relay"
135      printf '%s\tTXT\tv=spf1 a -all\n' "$relay"
136    done
137  addrs "$root_host" "$zone"
138  printf '%s\tMX\t10 %s\n' "$zone" "$root_host"
139  printf 'dkim.%s\tCNAME\tdkim.smtp2go.net\n' "$zone"
140  printf 'return.%s\tCNAME\treturn.smtp2go.net\n' "$zone"
141} >"$expected"
142# Normalised outside the group: it runs in this shell, so problem counts stick.
143norm <"$expected" | sort -u >"$expected.n" && mv "$expected.n" "$expected"
144
145# (name, type) pairs that must have no record.
146jq -r 'to_entries[] | select(.value.roles | index("sovrn-relay")) | "_25._tcp.\(.value.settings.sovrnRelay.hostname)\tTLSA"' "$INVENTORY" |
147  awk -F'\t' -v OFS='\t' '{ print tolower($1), $2 }' | sort -u >"$absent"
148
149# Managed rrsets: every (name, type) the fleet has an opinion on.
150managed() { { cut -f1,2 "$expected"; cat "$absent"; } | sort -u; }
151values() { awk -F'\t' -v n="$2" -v t="$3" '$1 == n && $2 == t { print $3 }' "$1" | sort; }
152
153# --- Marque (the PDS record) ----------------------------------------------
154
155echo "marque: at://$MARQUE_REPO/at.marque.dns/$zone"
156pds="$(curl -sf "https://plc.directory/$MARQUE_REPO" | jq -r '.service[] | select(.id == "#atproto_pds") | .serviceEndpoint')"
157record="$(curl -sf "$pds/xrpc/com.atproto.repo.getRecord?repo=$MARQUE_REPO&collection=at.marque.dns&rkey=$zone")" ||
158  { echo "check-dns: couldn't fetch the Marque record from $pds" >&2; exit 2; }
159jq -r --arg z "$zone" '.value.records[] |
160  [ (if .name == "@" then $z else "\(.name).\($z)" end),
161    .recordType,
162    (if .recordType == "MX" then "\(.priority) \(.value)" else .value end) ] | @tsv' <<<"$record" |
163  norm >"$marque.ordered"
164sort -u "$marque.ordered" >"$marque"
165if [ -n "${CHECK_DNS_STATE:-}" ]; then
166  cp "$expected" "$CHECK_DNS_STATE/expected.tsv"
167  cp "$absent" "$CHECK_DNS_STATE/absent.tsv"
168  cp "$marque.ordered" "$CHECK_DNS_STATE/marque.tsv"
169  printf '%s\n' "$record" >"$CHECK_DNS_STATE/record.json"
170fi
171echo "  cid $(jq -r .cid <<<"$record"), $(wc -l <"$marque") records, written $(jq -r .value.createdAt <<<"$record")"
172
173while IFS=$'\t' read -r name type; do
174  want="$(values "$expected" "$name" "$type" | paste -sd' ')"
175  have="$(values "$marque" "$name" "$type" | paste -sd' ')"
176  if [ "$want" = "$have" ]; then
177    ok "$name $type ${want:-(none)}"
178  else
179    problem "$name $type: want [$want] have [$have]"
180  fi
181done < <(managed)
182
183echo "  unmanaged (information only):"
184awk -F'\t' 'NR == FNR { m[$1 FS $2]; next }
185  !(($1 FS $2) in m) { v = $3; if (length(v) > 60) v = substr(v, 1, 57) "..."; printf "    %s %s %s\n", $1, $2, v }' \
186  <(managed) "$marque"
187
188# --- Authoritative nameservers --------------------------------------------
189
190mapfile -t nameservers < <(dig +short NS "$zone" | sed 's/\.$//' | sort)
191echo
192echo "nameservers: ${nameservers[*]}"
193serials=""
194for ns in "${nameservers[@]}"; do
195  serials+="$(dig +short +norec SOA "$zone" @"$ns" | awk '{ print $3 }') "
196done
197if [ "$(tr ' ' '\n' <<<"$serials" | sed '/^$/d' | sort -u | wc -l)" = 1 ]; then
198  ok "SOA serial ${serials%% *} on all"
199else
200  problem "SOA serials differ: $serials(${nameservers[*]}): a change is still propagating"
201fi
202
203while IFS=$'\t' read -r name type; do
204  want="$(values "$expected" "$name" "$type" | paste -sd' ')"
205  bad=""
206  for ns in "${nameservers[@]}"; do
207    have="$(dig +short +norec "$type" "$name" @"$ns" | sed "s/^/$name\t$type\t/" | norm | cut -f3 | sort | paste -sd' ')"
208    [ "$want" = "$have" ] || bad+=" $ns=[$have]"
209  done
210  if [ -z "$bad" ]; then
211    ok "$name $type"
212  else
213    problem "$name $type: want [$want]$bad"
214  fi
215done < <(managed)
216
217# --- DNSSEC (DANE) ---------------------------------------------------------
218
219echo
220echo "dnssec via $RESOLVER:"
221while IFS=$'\t' read -r name type; do
222  out="$(dig +dnssec "$type" "$name" @"$RESOLVER")"
223  status="$(grep -o 'status: [A-Z]*' <<<"$out" | cut -d' ' -f2)"
224  ad="$(grep -c '^;; flags:.* ad' <<<"$out" || true)"
225  if grep -qx "$name"$'\t'"$type" "$absent"; then
226    if [ "$status" = NXDOMAIN ] || { [ "$status" = NOERROR ] && ! grep -q $'\tIN\tTLSA\t' <<<"$out"; }; then
227      [ "$ad" = 1 ] && ok "$name $type: validated denial ($status)" ||
228        problem "$name $type: $status without AD; DANE senders can't prove it absent and defer"
229    else
230      problem "$name $type: expected no record, got $status"
231    fi
232  else
233    [ "$status" = NOERROR ] && [ "$ad" = 1 ] && ok "$name $type: validated" ||
234      problem "$name $type: status $status, AD=$ad"
235  fi
236done < <(managed | awk -F'\t' '$2 == "TLSA"')
237
238echo
239if [ "$problems" = 0 ]; then
240  echo "${green}check-dns: $zone matches the fleet${reset}"
241else
242  echo "${red}check-dns: $problems discrepancies in $zone${reset}"
243  exit 1
244fi