Three of the four failures in the week to 2026-09-23 (09-19, 09-21, 09-23) had one signature. The Cloudflare zone lookup returned an empty body at its 15s cap, a bare json.load crashed with tracebacks instead of a cause, and the script carried empty IDs on to a PATCH against zones//dns_records/. Cloudflare rejected it, so there was no DNS impact, but only by accident. - Cloudflare calls go through cf(): 3 announced tries, and a call counts only when the body says success:true. - success:true is not trusted as shape. pick() validates every body and prints only the fields asked for, or one line saying why not. It requires exactly one zone named phasefinal.com and exactly one A record named headscale.phasefinal.com, each with a non-empty id and content. Two A records are refused rather than half-updated, and an empty id can no longer shift the content into the id slot. - No write without both IDs. The run ends on a confirmation that the record now reads the new address. The previous final line was an echo whose exit status was always 0, even when the parse inside it failed. - Only a global unicast IPv4 is published (python ipaddress is_global). Loopback, RFC1918, link-local, CGNAT and documentation ranges are retried and then refused. - curl -q as the first argument ignores ~/.curlrc, so a verbose config can never log the bearer token. The vault CLI path is quoted. The empty data-array expansion is safe under set -u on bash < 4.4. Tests: services/headscale-ddns/test_headscale_ddns.py, 12 cases with curl, the vault CLI and sleep stubbed. Each failure case asserts the FATAL line's stated reason, so a run that died earlier for an unrelated cause cannot pass it. The documentation-range fixtures (203.0.113.x) were themselves rejected by the new public-IP guard: a free positive control. Live: a manual run and a unit run both printed "unchanged 70.230.226.88", Result=success. Cross-model bug-hunt (heid "Talus", Gróa + seat): all 8 findings folded.
125 lines
6.4 KiB
Bash
Executable File
125 lines
6.4 KiB
Bash
Executable File
#!/bin/bash
|
|
# Keep headscale.phasefinal.com's A record on NH3's current WAN v4. Token from the vault at run time.
|
|
set -u
|
|
SECRET=${HEADSCALE_DDNS_SECRET_CLI:-/home/lkraven/development/eshpfi-management/services/secrets-broker/secret}
|
|
# ⚠ SAY WHY. Both failure paths below used to `|| exit 1` in silence, and on
|
|
# 2026-09-22 15:28 this unit failed for real: the failed-START alarm fired
|
|
# correctly and carried NO CAUSE, because the script had printed nothing. An
|
|
# alarm you cannot act on costs the same triage as no alarm at all.
|
|
T=$("$SECRET" get nh3-dev/.config/cloudflare/infra-ops-dns-token 2>/dev/null) || {
|
|
echo "FATAL: vault read failed for nh3-dev/.config/cloudflare/infra-ops-dns-token" >&2; exit 1; }
|
|
[ -n "$T" ] || { echo "FATAL: vault returned an EMPTY token (read succeeded, value blank)" >&2; exit 1; }
|
|
# The WAN lookup leans on a third party, so one blip should not page a human.
|
|
# Measured 2026-09-22: the whole script takes ~18s of which ~17s is the vault
|
|
# read, so three tries at a 10s cap is bounded and still well inside the 10-min
|
|
# timer. Retries are announced -- a silent retry hides a degrading dependency.
|
|
# A reachable-looking but non-public answer (127.x, 10.x, 169.254.x, CGNAT
|
|
# 100.64/10, 999.x) is junk too: Cloudflare would publish it. Only a global
|
|
# unicast IPv4 counts. `curl -q` must stay the FIRST argument everywhere: it
|
|
# makes curl ignore ~/.curlrc, so a verbose/trace config there can never write
|
|
# the Authorization header into the journal.
|
|
is_public_v4() { python3 -c 'import sys,ipaddress as i
|
|
try: a=i.ip_address(sys.argv[1])
|
|
except ValueError: sys.exit(1)
|
|
sys.exit(0 if a.version == 4 and a.is_global else 1)' "$1"; }
|
|
IP=""
|
|
for try in 1 2 3; do
|
|
IP=$(curl -q -s -m 10 -4 https://icanhazip.com | tr -d '[:space:]')
|
|
is_public_v4 "$IP" && break
|
|
echo "WARN: WAN lookup attempt $try/3 returned [$IP]" >&2
|
|
sleep 2
|
|
done
|
|
is_public_v4 "$IP" || {
|
|
echo "FATAL: could not determine a public WAN v4 after 3 attempts (icanhazip.com unreachable or returning junk: [$IP]); DNS left unchanged" >&2; exit 1; }
|
|
# Cloudflare gets the same treatment as the WAN lookup. Three of the four
|
|
# failures in the week to 2026-09-23 were the zone lookup returning an EMPTY
|
|
# body at its 15s cap; json.load raised, and the script then carried empty IDs
|
|
# all the way to a PATCH against zones//dns_records/. Now: a call only counts
|
|
# when Cloudflare says success:true, it is retried (announced), and nothing is
|
|
# written unless both IDs are in hand. PATCH sets an absolute value, so a retry
|
|
# of it is idempotent.
|
|
cf() { # cf METHOD URL [JSON] -> response body on stdout; non-zero after 3 failed tries
|
|
local body try data=()
|
|
[ -n "${3:-}" ] && data=(-H "Content-Type: application/json" -d "$3")
|
|
for try in 1 2 3; do
|
|
# ${data[@]+...}: an empty array under `set -u` is an unbound-variable
|
|
# error on bash < 4.4, which would fail every GET and blame Cloudflare.
|
|
body=$(curl -q -s -m 15 -X "$1" -H "Authorization: Bearer $T" ${data[@]+"${data[@]}"} "$2")
|
|
if python3 -c 'import sys,json; sys.exit(0 if json.loads(sys.argv[1]).get("success") is True else 1)' "$body" 2>/dev/null; then
|
|
printf '%s' "$body"; return 0
|
|
fi
|
|
echo "WARN: Cloudflare $1 attempt $try/3 failed: [${body:0:200}]" >&2
|
|
sleep 2
|
|
done
|
|
return 1
|
|
}
|
|
# success:true says the call worked, NOT that the body has the shape we are
|
|
# about to read. pick() validates the shape and prints exactly the fields
|
|
# asked for, one per line, or prints a one-line reason and exits non-zero.
|
|
# Every read of a Cloudflare body goes through it — a bare result[0]["id"]
|
|
# is how an empty id once shifted the record's CONTENT into the id slot.
|
|
pick() { # pick KIND BODY [EXPECT] ; KIND = zone | record | updated
|
|
python3 - "$@" <<'PY'
|
|
import sys, json
|
|
kind, body = sys.argv[1], sys.argv[2]
|
|
expect = sys.argv[3] if len(sys.argv) > 3 else None
|
|
def die(why):
|
|
print(why, file=sys.stderr); sys.exit(1)
|
|
try:
|
|
res = json.loads(body).get("result")
|
|
except Exception:
|
|
die("response is not a JSON object")
|
|
def one(name):
|
|
if not isinstance(res, list):
|
|
die(f"result is {type(res).__name__}, not a list")
|
|
if len(res) != 1:
|
|
die(f"expected exactly 1 {name}, got {len(res)}")
|
|
e = res[0]
|
|
if not isinstance(e, dict):
|
|
die(f"{name} entry is {type(e).__name__}, not an object")
|
|
return e
|
|
def text(e, key):
|
|
v = e.get(key)
|
|
if not isinstance(v, str) or not v.strip():
|
|
die(f"field {key!r} is missing or empty")
|
|
return v
|
|
if kind == "zone":
|
|
e = one("zone")
|
|
if e.get("name") != "phasefinal.com":
|
|
die(f"zone name is {e.get('name')!r}")
|
|
print(text(e, "id"))
|
|
elif kind == "record":
|
|
e = one("A record")
|
|
if e.get("type") != "A" or e.get("name") != "headscale.phasefinal.com":
|
|
die(f"record is {e.get('type')!r} {e.get('name')!r}")
|
|
print(text(e, "id")); print(text(e, "content"))
|
|
elif kind == "updated":
|
|
if not isinstance(res, dict):
|
|
die(f"result is {type(res).__name__}, not an object")
|
|
got = text(res, "content")
|
|
if got != expect:
|
|
die(f"record now reads {got!r}, not {expect!r}")
|
|
print(got)
|
|
else:
|
|
die(f"unknown kind {kind!r}")
|
|
PY
|
|
}
|
|
CF=https://api.cloudflare.com/client/v4
|
|
Z=$(cf GET "$CF/zones?name=phasefinal.com") || {
|
|
echo "FATAL: Cloudflare zone lookup for phasefinal.com failed after 3 attempts; DNS left unchanged" >&2; exit 1; }
|
|
ZID=$(pick zone "$Z" 2>&1) || {
|
|
echo "FATAL: Cloudflare zone lookup for phasefinal.com returned an unusable body ($ZID); DNS left unchanged" >&2; exit 1; }
|
|
R=$(cf GET "$CF/zones/$ZID/dns_records?type=A&name=headscale.phasefinal.com") || {
|
|
echo "FATAL: Cloudflare A record lookup for headscale.phasefinal.com failed after 3 attempts; DNS left unchanged" >&2; exit 1; }
|
|
F=$(pick record "$R" 2>&1) || {
|
|
echo "FATAL: Cloudflare A record for headscale.phasefinal.com is not usable ($F); refusing to guess; DNS left unchanged" >&2; exit 1; }
|
|
RID=$(sed -n 1p <<<"$F"); CUR=$(sed -n 2p <<<"$F")
|
|
[ "$CUR" = "$IP" ] && { echo "unchanged $IP"; exit 0; }
|
|
U=$(cf PATCH "$CF/zones/$ZID/dns_records/$RID" "{\"content\":\"$IP\"}") || {
|
|
echo "FATAL: Cloudflare update of headscale.phasefinal.com $CUR -> $IP failed after 3 attempts" >&2; exit 1; }
|
|
# The last word is the CONFIRMATION, not an echo that always exits 0: a
|
|
# success:true body we cannot read back as $IP fails the run.
|
|
NOW=$(pick updated "$U" "$IP" 2>&1) || {
|
|
echo "FATAL: Cloudflare accepted the update of headscale.phasefinal.com $CUR -> $IP but did not confirm it ($NOW)" >&2; exit 1; }
|
|
echo "updated $NOW (was $CUR)"
|