update-dns.sh
1#!/usr/bin/env bash
2# Converges the sovrn mail zone on what the fleet expects (`just update-dns`):
3#
4# 1. Runs check-dns.sh and shows its report.
5# 2. Plans against the Marque record that report read: in every rrset the
6# fleet manages (check-dns.sh's expected and absent sets), records that
7# shouldn't be there are removed and missing ones added. Records at
8# unmanaged names are left exactly as they are.
9# 3. Shows the plan and asks for confirmation (FLEET_YES=1 skips it;
10# DRY=1 stops after the plan).
11# 4. Checks goat's session is the zone record's owner: the session DID is
12# $MARQUE_REPO and $MARQUE_HANDLE resolves to it.
13# 5. Writes the merged record with putRecord through goat, with swapRecord
14# set to the CID the plan was made from: if anything (the Marque web UI,
15# Marque's own normalisation) wrote the record since, the PDS refuses
16# and nothing changes; run it again.
17# 6. Waits for every nameserver to serve a new SOA serial and runs
18# check-dns.sh again.
19#
20# Marque serves the zone from this one record, so a write replaces the whole
21# zone; the plan is the only change. New records get TTL 300 like the rest.
22#
23# Usage: update-dns.sh <hosts.json> <fleet.json>
24# Needs check-dns.sh's tools plus goat (logged in: `goat account login`).
25set -euo pipefail
26
27here="$(dirname "$0")"
28INVENTORY="$1"
29FLEET="$2"
30MARQUE_REPO="${MARQUE_REPO:-did:plc:zpqnooj2vuoju6ssulbpdwxp}"
31MARQUE_HANDLE="${MARQUE_HANDLE:-rtw.run}"
32TTL=300
33export MARQUE_REPO
34
35command -v goat >/dev/null || { echo "update-dns: goat not on PATH (the devenv shell has it)" >&2; exit 2; }
36
37if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
38 red=$'\e[31m' green=$'\e[32m' bold=$'\e[1m' reset=$'\e[0m'
39else
40 red="" green="" bold="" reset=""
41fi
42
43state="$(mktemp -d)"
44trap 'rm -rf "$state"' EXIT
45
46# 1. Report.
47echo "${bold}== check-dns${reset}"
48if CHECK_DNS_STATE="$state" "$here/check-dns.sh" "$INVENTORY" "$FLEET"; then
49 exit 0
50fi
51[ -s "$state/record.json" ] || { echo "update-dns: check-dns didn't get as far as the Marque record" >&2; exit 2; }
52
53zone="$(jq -r .zone "$FLEET")"
54cid="$(jq -r .cid "$state/record.json")"
55count="$(jq '.value.records | length' "$state/record.json")"
56# marque.tsv must line up with the record's entries (a tab or newline inside
57# a value would shift it).
58[ "$(wc -l <"$state/marque.tsv")" = "$count" ] ||
59 { echo "update-dns: couldn't line up the record's $count entries; not touching it" >&2; exit 2; }
60
61# 2. Plan. drop: 0-based indices into .records; add: expected lines missing
62# from the record.
63awk -F'\t' '
64 FILENAME ~ /expected/ { want[$0]; managed[$1 FS $2]; next }
65 FILENAME ~ /absent/ { managed[$1 FS $2]; next }
66 {
67 i = FNR - 1
68 if (($1 FS $2) in managed && (!($0 in want) || ($0 in seen))) print i
69 seen[$0]
70 }' "$state/expected.tsv" "$state/absent.tsv" "$state/marque.tsv" >"$state/drop"
71awk -F'\t' 'NR == FNR { have[$0]; next } !($0 in have)' \
72 "$state/marque.tsv" "$state/expected.tsv" >"$state/add"
73
74echo
75echo "${bold}== plan for $zone (record $cid)${reset}"
76if [ ! -s "$state/drop" ] && [ ! -s "$state/add" ]; then
77 echo " nothing the Marque record can fix: the discrepancies above are elsewhere"
78 echo " (nameservers still propagating, DNSSEC, or a certificate that couldn't be fetched)"
79 exit 1
80fi
81while read -r i; do
82 line="$(sed -n "$((i + 1))p" "$state/marque.tsv")"
83 note=""; ! grep -qxF "$line" "$state/expected.tsv" || note=" (duplicate)"
84 awk -F'\t' -v r="$red" -v z="$reset" -v n="$note" '{ printf " %s- remove %s %s %s%s%s\n", r, $1, $2, $3, n, z }' <<<"$line"
85done <"$state/drop"
86awk -F'\t' -v g="$green" -v z="$reset" '{ printf " %s+ add %s %s %s (ttl '"$TTL"')%s\n", g, $1, $2, $3, z }' "$state/add"
87
88# The merged record: unmanaged entries untouched, in their order.
89jq --arg z "$zone" --argjson ttl "$TTL" \
90 --slurpfile drop <(jq -s . "$state/drop") \
91 --rawfile add "$state/add" '
92 def rel: if . == $z then "@" else rtrimstr("." + $z) end;
93 .value
94 | .records = ([.records | to_entries[] | select(.key as $k | ($drop[0] | index($k)) == null) | .value]
95 + [$add | split("\n")[] | select(. != "") | split("\t") as [$n, $t, $v]
96 | if $t == "MX" then ($v | split(" ")) as [$p, $h]
97 | {name: ($n | rel), recordType: $t, value: $h, priority: ($p | tonumber), ttl: $ttl}
98 else {name: ($n | rel), recordType: $t, value: $v, ttl: $ttl} end])
99 | .createdAt = (now | todate)' "$state/record.json" >"$state/new.json"
100
101before="$count"
102after="$(jq '.records | length' "$state/new.json")"
103echo " $before records -> $after"
104# Never write a zone that lost more than the plan says (or everything).
105[ "$after" -gt 0 ] && [ "$after" = "$((before - $(wc -l <"$state/drop") + $(wc -l <"$state/add")))" ] ||
106 { echo "update-dns: the merged record doesn't add up; not writing it" >&2; exit 2; }
107
108[ "${DRY:-}" != 1 ] || { echo "update-dns: DRY=1, nothing written"; exit 1; }
109
110# 3. Confirm.
111echo
112if [ "${FLEET_YES:-}" != 1 ]; then
113 read -r -p "Write this to at://$MARQUE_REPO/at.marque.dns/$zone? Type yes: " answer
114 [ "$answer" = yes ] || { echo "update-dns: nothing written"; exit 1; }
115fi
116
117# 4. Whose session is this?
118session_did="$(goat account check-auth 2>/dev/null | awk '/^DID:/ { print $2 }')"
119[ "$session_did" = "$MARQUE_REPO" ] ||
120 { echo "update-dns: goat is logged in as '${session_did:-nobody}', not $MARQUE_REPO; run: goat account login -u $MARQUE_HANDLE" >&2; exit 2; }
121handle_did="$(goat resolve "$MARQUE_HANDLE" 2>/dev/null | jq -r .id)"
122[ "$handle_did" = "$MARQUE_REPO" ] ||
123 { echo "update-dns: $MARQUE_HANDLE resolves to '${handle_did:-nothing}', not $MARQUE_REPO" >&2; exit 2; }
124echo "goat: logged in as $MARQUE_HANDLE ($session_did)"
125
126serials() {
127 for ns in $(dig +short NS "$zone"); do dig +short +norec SOA "$zone" @"$ns" | awk '{ print $3 }'; done | sort -u | paste -sd' '
128}
129old_serials="$(serials)"
130
131# 5. Write (swapRecord: only over the record the plan was made from).
132jq -n --arg repo "$MARQUE_REPO" --arg rkey "$zone" --arg swap "$cid" --slurpfile v "$state/new.json" \
133 '{repo: $repo, collection: "at.marque.dns", rkey: $rkey, validate: false, swapRecord: $swap, record: $v[0]}' |
134 goat xrpc procedure @pds com.atproto.repo.putRecord - 'Content-Type:application/json' >"$state/put.json" ||
135 { echo "${red}update-dns: putRecord failed (if InvalidSwap: the record changed since the plan; run again)${reset}" >&2; cat "$state/put.json" >&2; exit 1; }
136echo "written: cid $(jq -r .cid "$state/put.json")"
137
138# 6. Wait for the nameservers (Marque took ~45 s in testing), then re-check.
139echo "waiting for every nameserver to leave serial $old_serials ..."
140for _ in $(seq 1 40); do
141 now="$(serials)"
142 if [ "$now" != "$old_serials" ] && [ "$(wc -w <<<"$now")" = 1 ]; then break; fi
143 sleep 15
144done
145echo
146echo "${bold}== check-dns${reset}"
147"$here/check-dns.sh" "$INVENTORY" "$FLEET"