#!/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)"