feat(infra-setup): create the public DNS records, idempotently
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

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
This commit is contained in:
2026-08-31 13:58:36 +03:00
co-authored by Claude Opus 5
parent 5244585ae0
commit a91aaf3d1c
2 changed files with 124 additions and 51 deletions
+48 -51
View File
@@ -9,6 +9,7 @@
#
# script/infra-setup.sh # provision everything
# script/infra-setup.sh --skip-minio # leave the bucket and credentials alone
# script/infra-setup.sh --skip-dns # leave public DNS alone
#
# What it does:
# 1. bob — gitea_ci user, runner key, scoped sudoers, SELinux port label
@@ -17,10 +18,11 @@
# (Let's Encrypt), both vhosts, the webroot, the renewal
# timers, and the gitea_ci account CI uses to ship the frontend
#
# Public DNS is NOT touched here. The Cloudflare token can rewrite DNS for
# every zone on the account, including MX records for domains whose mail we
# host (public-dns.md §1), so publishing the name is a separate, deliberate
# operator step. This script prints the exact call and verifies the result.
# Public DNS is created here if absent, from the proxy where the Cloudflare
# token already lives. It is never copied off that host: the token can rewrite
# DNS for every zone on the account, including MX records for domains whose
# mail we host (public-dns.md §1). A name that already points somewhere else is
# reported and left alone.
#
# Application config is NOT shipped here. The deploy workflow renders it from
# Gitea secrets on every deploy.
@@ -44,9 +46,11 @@ readonly PROVISIONER_PASSWORD="${PROVISIONER_PASSWORD:-$HOME/.step/secrets/provi
readonly ROOT_CA=/etc/pki/ca-trust/source/anchors/root-internal.pem
SKIP_MINIO=0
SKIP_DNS=0
for arg in "$@"; do
case "$arg" in
--skip-minio) SKIP_MINIO=1 ;;
--skip-dns) SKIP_DNS=1 ;;
-h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
@@ -204,6 +208,45 @@ then set the Gitea repo secrets S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY.
Re-run this script with --skip-minio once that is done."
}
# ---------------------------------------------------------------------------
# Public DNS for the WAN-facing name
# ---------------------------------------------------------------------------
# Idempotent: creates the records if absent, reports them if already correct,
# and refuses to touch a name that resolves somewhere else.
#
# The API calls run on the proxy, where the Cloudflare token already lives.
# That token can rewrite DNS for every zone on the account, including MX
# records for domains whose mail we host (public-dns.md §1), so it is never
# copied off the host.
provision_public_dns() {
info "public DNS (${PUBLIC_NAME})"
if [ "$SKIP_DNS" -eq 1 ]; then
ok "skipped (--skip-dns)"
return 0
fi
# The script is piped in from a file rather than embedded as a heredoc:
# quoting shell through a heredoc has silently mangled this project's
# scripts more than once, and a file can be linted and run on its own.
# It executes on the proxy because that is where the Cloudflare token is,
# and the token is never copied off that host.
ssh "$PROXY_HOST" \
"sudo bash -s ${PUBLIC_NAME} ${SITE_INDIRECTION} ${CERTBOT_CREDENTIALS}" \
< script/publish-dns.sh
# Checked from here rather than on the proxy: the operator workstation is
# on the mesh, and is the audience that has to be able to reach the name.
if getent hosts "${PUBLIC_NAME}" >/dev/null; then
ok "${PUBLIC_NAME} resolves to $(getent hosts "${PUBLIC_NAME}" | awk '{print $1}' | head -1)"
else
warn "${PUBLIC_NAME} does not resolve here yet. A record created moments ago takes
time to propagate, and a resolver that queried the name while it was missing caches the
NXDOMAIN: clear it with 'resolvectl flush-caches' or wait out the negative TTL."
fi
}
# ---------------------------------------------------------------------------
# 3. The edge proxy: hanzalova
# ---------------------------------------------------------------------------
@@ -383,53 +426,7 @@ mv /etc/sudoers.d/.${APP}_gitea_ci.new /etc/sudoers.d/${APP}_gitea_ci
REMOTE
ok "webroot ${WEBROOT} and scoped sudoers on the proxy"
info "public DNS (${PUBLIC_NAME})"
if getent hosts "${PUBLIC_NAME}" >/dev/null; then
ok "${PUBLIC_NAME} resolves to $(getent hosts "${PUBLIC_NAME}" | awk '{print $1}' | head -1)"
else
local wan
wan=$(dig +short "${SITE_INDIRECTION}" A | head -1)
warn "${PUBLIC_NAME} does not resolve yet, so nothing is publicly reachable.
Publishing it is deliberately not automated: the Cloudflare token can rewrite DNS for
every zone on the account, including MX records for domains whose mail we host
(public-dns.md §1). Run this on ${PROXY_HOST}, where the token already is -- copying it
off the host spreads a credential that can edit every zone we own.
BEFORE YOU DO: confirm the service is enforcing auth, or publishing the name makes every
stored model world-downloadable and lets anyone fill the bucket:
ssh ${SERVICE_HOST} 'sudo grep -E \"^(mode|anonymous|allow_new_repos)\" /etc/${APP}/config.toml'
expecting mode = \"bearer\", anonymous = \"catalog\", allow_new_repos = false."
# A quoted heredoc so the shell prints it exactly as written, then a
# sed pass for the three values -- substituting inside the heredoc
# would put the quoting back in play, which is what mangled the
# `tr -d` line the first time.
cat <<'DNS' | sed -e "s|__APEX__|${PUBLIC_NAME}|g" \
-e "s|__SITE__|${SITE_INDIRECTION}|g" \
-e "s|__WAN__|${wan}|g"
TOKEN=$(sudo grep -oP '(?<=dns_cloudflare_api_token\s=\s).*' /root/.certbot-internal \
| tr -d "\"' ")
zid=$(curl -sS -H "Authorization: Bearer $TOKEN" \
"https://api.cloudflare.com/client/v4/zones?name=__APEX__" | jq -r '.result[0].id')
# The apex takes an A record: a zone apex cannot be a CNAME (public-dns.md §3).
curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/$zid/dns_records" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
--data '{"type":"A","name":"__APEX__","content":"__WAN__","ttl":1,"proxied":false,"comment":"rustingface"}' \
| jq -r 'if .success then "created \(.result.name) -> \(.result.content)" else "FAILED \(.errors)" end'
# www goes through the site indirection, so re-homing the site is one record.
curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/$zid/dns_records" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
--data '{"type":"CNAME","name":"www.__APEX__","content":"__SITE__","ttl":1,"proxied":false,"comment":"rustingface"}' \
| jq -r 'if .success then "created \(.result.name) -> \(.result.content)" else "FAILED \(.errors)" end'
DNS
fi
provision_public_dns
info "mesh DNS"
if getent hosts "${VHOST}.internal" >/dev/null; then
+76
View File
@@ -0,0 +1,76 @@
#!/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"