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