Files
rustingface/script/publish-dns.sh
rob thijssen a91aaf3d1c
All checks were successful
deploy / build-web (push) Successful in 1m40s
deploy / build (push) Successful in 6m32s
deploy / deploy (push) Successful in 18s
deploy / deploy-web (push) Successful in 11s
feat(infra-setup): create the public DNS records, idempotently
Publishing the name was a block of printed instructions, which put the
knowledge in whoever last read them. It is now a step like every other:
absent records are created, correct ones are reported, and a name that
points somewhere else is left alone with a pointer to public-dns.md §4.
`--skip-dns` opts out.

The apex takes a CNAME to the site indirection, not an A record.
Cloudflare flattens it, and it buys the property §2 is about -- a WAN
address change is one record rather than a hunt across zones. bansko.io
already does this; the claim in public-dns.md §3 that the fleet's apexes
use A records was wrong and is corrected there. Note that `dig` cannot
tell you which a zone uses, because flattening makes both answer with an
A; only the API can.

The script lives in script/publish-dns.sh and is piped to the proxy
rather than embedded as a heredoc. Quoting shell through an unquoted
heredoc silently mangled two earlier versions of this same code -- once
producing `tr -d "'" ")` -- and a file can be linted and run directly.
It executes on the proxy because that is where the Cloudflare token is,
and the token is never copied off that host: it can rewrite DNS for
every zone on the account.

Token parsing uses awk rather than a PCRE lookbehind. Widening the
lookbehind to tolerate variable spacing made grep fail outright --
lookbehinds must be fixed-length -- so the fixed-width form would have
broken silently the day someone reformatted the credentials file.

Verified: first run created both records, second reported them ok,
rustingface.com and www both resolve to the site address, and the
Let's Encrypt chain validates against the public trust store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 13:58:36 +03:00

77 lines
3.3 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Create the public DNS records for a WAN-facing name, if they are absent.
#
# Runs ON THE EDGE PROXY, piped in by infra-setup.sh, because that is where the
# Cloudflare credential lives. The token can rewrite DNS for every zone on the
# account — including MX records for domains whose mail we host — so it is
# never copied off that host (public-dns.md §1).
#
# sudo bash publish-dns.sh <apex> <site-indirection> <certbot-credentials>
#
# Idempotent: creates what is missing, confirms what is already correct, and
# refuses to touch a name that points somewhere else.
#
# A standalone file rather than a heredoc inside infra-setup.sh: quoting a
# script through a heredoc has silently mangled this project's shell twice,
# and a file can be read, linted and run directly.
set -euo pipefail
apex=${1:?apex domain required}
site=${2:?site indirection name required}
credentials=${3:?path to the certbot cloudflare credentials required}
# Parsed with awk rather than a PCRE lookbehind: a lookbehind must be
# fixed-length, so it cannot tolerate variable spacing around the `=`, and the
# fixed-width form fails silently the day someone reformats the file.
token=$(awk -F= '/dns_cloudflare_api_token/ {
sub(/^[^=]*=/, "", $0)
gsub(/[[:space:]"'"'"']/, "", $0)
print
exit
}' "$credentials")
[ -n "$token" ] || { echo "no Cloudflare token found in $credentials" >&2; exit 1; }
base=https://api.cloudflare.com/client/v4
api() { curl -sS -H "Authorization: Bearer $token" -H 'Content-Type: application/json' "$@"; }
zid=$(api "$base/zones?name=$apex" | jq -r '.result[0].id // empty')
[ -n "$zid" ] || { echo "$apex is not a zone on this Cloudflare account" >&2; exit 1; }
# A CNAME at the apex is fine: Cloudflare flattens it. Pointing every public
# name at the per-site indirection means a WAN address change is one record
# rather than a hunt across zones.
ensure_cname() {
local name=$1 target=$2 existing
# Ask for every address-type record on the name, not just the type
# expected. A name that is already a CNAME does not appear in an A-record
# query, and creating an A beside it is a conflict at best
# (public-dns.md §4). MX and TXT are excluded on purpose: they coexist with
# an address record and are never a reason to skip or to overwrite.
existing=$(api "$base/zones/$zid/dns_records?name=$name" |
jq -r '.result[]
| select(.type == "A" or .type == "AAAA" or .type == "CNAME")
| "\(.type) \(.content)"')
if [ -z "$existing" ]; then
api -X POST "$base/zones/$zid/dns_records" \
--data "$(jq -cn --arg n "$name" --arg c "$target" \
'{type: "CNAME", name: $n, content: $c, ttl: 1, proxied: false, comment: "rustingface"}')" |
jq -r 'if .success
then " created \(.result.name) -> \(.result.content)"
else " FAILED \(.errors)" end'
elif [ "$existing" = "CNAME $target" ]; then
echo " ok $name -> $target"
else
echo " !! $name already points elsewhere:" >&2
echo "$existing" | sed 's/^/ /' >&2
echo " leaving it alone. Repointing a live name is deliberate work;" >&2
echo " read public-dns.md §4 first." >&2
fi
}
ensure_cname "$apex" "$site"
ensure_cname "www.$apex" "$site"