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"