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
This commit is contained in:
+48
-51
@@ -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
|
||||
|
||||
Executable
+76
@@ -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"
|
||||
Reference in New Issue
Block a user