217 lines
10 KiB
Text
217 lines
10 KiB
Text
#!/usr/bin/env nu
|
|
# Cloudflare DNS reconcile — runs on the provisioning machine with native http/json.
|
|
# Shared by every cluster component that publishes an A record for its gateway IP.
|
|
# The cluster nodes never touch Cloudflare and carry no JSON tooling; the whole
|
|
# reconcile lives here, on provisioning, where nushell parses responses natively.
|
|
|
|
# Derive the apex (registrable) zone from a fully-qualified domain when the
|
|
# component does not declare one explicitly: the last two labels.
|
|
def _apex_zone [domain: string]: nothing -> string {
|
|
$domain | split row "." | last 2 | str join "."
|
|
}
|
|
|
|
# Upsert a single A record to point $domain at $target_ip inside $zone.
|
|
# Idempotent: skips when the record already resolves to the target. Never fails
|
|
# the deploy — a DNS hiccup is reported as a warning, not a hard error.
|
|
export def cf-dns-upsert [
|
|
cf_token: string # Cloudflare API token scoped to the zone
|
|
domain: string # FQDN to publish (e.g. git.example.com)
|
|
target_ip: string # A-record value (the gateway floating IP)
|
|
zone: string = "" # registrable zone; derived from domain when empty
|
|
]: nothing -> nothing {
|
|
if ($cf_token | is-empty) {
|
|
print $" [warn] DNS: no Cloudflare token for ($domain) — skipping"
|
|
return
|
|
}
|
|
if ($domain | is-empty) or ($target_ip | is-empty) {
|
|
print " [skip] DNS: domain or target_ip empty — skipping"
|
|
return
|
|
}
|
|
let zone = if ($zone | is-not-empty) { $zone } else { _apex_zone $domain }
|
|
let base = "https://api.cloudflare.com/client/v4"
|
|
let hdr = { Authorization: $"Bearer ($cf_token)" }
|
|
|
|
let zone_resp = (do -i { http get --headers $hdr $"($base)/zones?name=($zone)&status=active" } | default {})
|
|
let zone_id = ($zone_resp | get -o result.0.id | default "")
|
|
if ($zone_id | is-empty) {
|
|
print $" [warn] DNS: zone '($zone)' not found in Cloudflare"
|
|
return
|
|
}
|
|
|
|
let rec_resp = (do -i { http get --headers $hdr $"($base)/zones/($zone_id)/dns_records?name=($domain)&type=A" } | default {})
|
|
let record_id = ($rec_resp | get -o result.0.id | default "")
|
|
let current_ip = ($rec_resp | get -o result.0.content | default "")
|
|
|
|
if ($record_id | is-not-empty) and ($current_ip == $target_ip) {
|
|
print $" [ok] DNS: ($domain) → ($target_ip) \(already correct\)"
|
|
return
|
|
}
|
|
|
|
let payload = { type: "A", name: $domain, content: $target_ip, ttl: 300, proxied: false }
|
|
let resp = if ($record_id | is-not-empty) {
|
|
do -i {
|
|
http put --headers $hdr --content-type application/json $"($base)/zones/($zone_id)/dns_records/($record_id)" $payload
|
|
} | default {}
|
|
} else {
|
|
do -i {
|
|
http post --headers $hdr --content-type application/json $"($base)/zones/($zone_id)/dns_records" $payload
|
|
} | default {}
|
|
}
|
|
|
|
if (($resp | get -o success | default false) == true) {
|
|
let from = if ($current_ip | is-empty) { "none" } else { $current_ip }
|
|
print $" [ok] DNS: ($domain) → ($target_ip) \(updated from ($from)\)"
|
|
} else {
|
|
let err = ($resp | get -o errors.0.message | default "unknown")
|
|
print $" [warn] DNS update failed for ($domain): ($err) \(continuing\)"
|
|
}
|
|
}
|
|
|
|
# Verify the A record for $domain actually resolves to $target_ip in Cloudflare.
|
|
# Read-only (no writes). Returns true ONLY when the record exists AND its content
|
|
# equals target_ip. This is the safety net for cf-dns-upsert, which is do -i
|
|
# throughout and never returns an error — the caller must confirm the outcome.
|
|
export def cf-dns-verify [
|
|
cf_token: string
|
|
domain: string
|
|
target_ip: string
|
|
zone: string = ""
|
|
]: nothing -> bool {
|
|
if ($cf_token | is-empty) or ($domain | is-empty) or ($target_ip | is-empty) { return false }
|
|
let zone = if ($zone | is-not-empty) { $zone } else { _apex_zone $domain }
|
|
let base = "https://api.cloudflare.com/client/v4"
|
|
let hdr = { Authorization: $"Bearer ($cf_token)" }
|
|
let zone_resp = (do -i { http get --headers $hdr $"($base)/zones?name=($zone)&status=active" } | default {})
|
|
let zone_id = ($zone_resp | get -o result.0.id | default "")
|
|
if ($zone_id | is-empty) { return false }
|
|
let rec_resp = (do -i { http get --headers $hdr $"($base)/zones/($zone_id)/dns_records?name=($domain)&type=A" } | default {})
|
|
($rec_resp | get -o result.0.content | default "") == $target_ip
|
|
}
|
|
|
|
# True when an A record for $domain currently exists in Cloudflare (any value).
|
|
# Existence probe for the delete-phase postcondition (record must be ABSENT after
|
|
# cf-dns-delete) — distinct from cf-dns-verify, which checks value equality.
|
|
export def cf-dns-exists [
|
|
cf_token: string
|
|
domain: string
|
|
zone: string = ""
|
|
]: nothing -> bool {
|
|
if ($cf_token | is-empty) or ($domain | is-empty) { return false }
|
|
let zone = if ($zone | is-not-empty) { $zone } else { _apex_zone $domain }
|
|
let base = "https://api.cloudflare.com/client/v4"
|
|
let hdr = { Authorization: $"Bearer ($cf_token)" }
|
|
let zone_resp = (do -i { http get --headers $hdr $"($base)/zones?name=($zone)&status=active" } | default {})
|
|
let zone_id = ($zone_resp | get -o result.0.id | default "")
|
|
if ($zone_id | is-empty) { return false }
|
|
let rec_resp = (do -i { http get --headers $hdr $"($base)/zones/($zone_id)/dns_records?name=($domain)&type=A" } | default {})
|
|
($rec_resp | get -o result.0.id | default "") | is-not-empty
|
|
}
|
|
|
|
# Delete the A record for $domain in Cloudflare. Idempotent: an already-absent
|
|
# record is a no-op success. The inverse of cf-dns-upsert for the op delete phase
|
|
# (ADR-051 delete-inverts-cleanup) — so a deleted component leaves no orphaned A
|
|
# record. Runs on the provisioner; the token never reaches the nodes.
|
|
export def cf-dns-delete [
|
|
cf_token: string
|
|
domain: string
|
|
zone: string = ""
|
|
]: nothing -> nothing {
|
|
if ($cf_token | is-empty) {
|
|
print $" [warn] DNS: no Cloudflare token for ($domain) — skipping delete"
|
|
return
|
|
}
|
|
if ($domain | is-empty) {
|
|
print " [skip] DNS: domain empty — skipping delete"
|
|
return
|
|
}
|
|
let zone = if ($zone | is-not-empty) { $zone } else { _apex_zone $domain }
|
|
let base = "https://api.cloudflare.com/client/v4"
|
|
let hdr = { Authorization: $"Bearer ($cf_token)" }
|
|
let zone_resp = (do -i { http get --headers $hdr $"($base)/zones?name=($zone)&status=active" } | default {})
|
|
let zone_id = ($zone_resp | get -o result.0.id | default "")
|
|
if ($zone_id | is-empty) {
|
|
print $" [warn] DNS: zone '($zone)' not found in Cloudflare"
|
|
return
|
|
}
|
|
let rec_resp = (do -i { http get --headers $hdr $"($base)/zones/($zone_id)/dns_records?name=($domain)&type=A" } | default {})
|
|
let record_id = ($rec_resp | get -o result.0.id | default "")
|
|
if ($record_id | is-empty) {
|
|
print $" [ok] DNS: ($domain) already absent \(nothing to delete\)"
|
|
return
|
|
}
|
|
let resp = (do -i { http delete --headers $hdr $"($base)/zones/($zone_id)/dns_records/($record_id)" } | default {})
|
|
if (($resp | get -o success | default false) == true) {
|
|
print $" [ok] DNS: ($domain) A record deleted"
|
|
} else {
|
|
let err = ($resp | get -o errors.0.message | default "unknown")
|
|
print $" [warn] DNS delete failed for ($domain): ($err) \(continuing\)"
|
|
}
|
|
}
|
|
|
|
# Upsert many A records — for multi-site components (e.g. rev_proxy, one record
|
|
# per proxied site, all pointing at the shared FIP). Reads a JSON file of
|
|
# [{token, domain, target_ip, zone}] so per-zone tokens never land in argv.
|
|
export def cf-dns-upsert-many [records_file: string]: nothing -> nothing {
|
|
if ($records_file | is-empty) or (not ($records_file | path exists)) {
|
|
print " [skip] DNS: no records file — skipping"
|
|
return
|
|
}
|
|
let records = (do -i { open $records_file } | default [])
|
|
for r in $records {
|
|
cf-dns-upsert (
|
|
$r | get -o token | default ""
|
|
) (
|
|
$r | get -o domain | default ""
|
|
) (
|
|
$r | get -o target_ip | default ""
|
|
) (
|
|
$r | get -o zone | default ""
|
|
)
|
|
}
|
|
}
|
|
|
|
# CLI entry — invoked as `nu cloudflare-dns.nu <domain> <target_ip> [zone]` with
|
|
# the token supplied out-of-band via $env.CF_API_TOKEN so it never lands in argv
|
|
# (and thus never in `ps`). Used by the provisioning orchestrator post-install.
|
|
def main [
|
|
domain: string
|
|
target_ip: string
|
|
zone: string = ""
|
|
]: nothing -> nothing {
|
|
let token = ($env.CF_API_TOKEN? | default "")
|
|
cf-dns-upsert $token $domain $target_ip $zone
|
|
# Verification check — cf-dns-upsert is do -i throughout and returns no error,
|
|
# so a failed publish is otherwise silent. Confirm the record actually landed
|
|
# and make a miss LOUD. exit 1 is swallowed by the caller's `do -i` (deploy is
|
|
# not broken), but the [ERROR] line surfaces and the non-zero is observable to
|
|
# any caller that switches from `do -i` to `complete`.
|
|
if ($domain | is-not-empty) and ($target_ip | is-not-empty) {
|
|
if (cf-dns-verify $token $domain $target_ip $zone) {
|
|
print $" [ok] DNS verified: ($domain) → ($target_ip)"
|
|
} else {
|
|
print $" [ERROR] DNS NOT confirmed: ($domain) → ($target_ip) absent after reconcile — publish manually or re-run"
|
|
exit 1
|
|
}
|
|
}
|
|
}
|
|
|
|
# `nu cloudflare-dns.nu many <records.json>` — multi-site entry.
|
|
def "main many" [records_file: string]: nothing -> nothing {
|
|
cf-dns-upsert-many $records_file
|
|
}
|
|
|
|
# `nu cloudflare-dns.nu delete <domain> [zone]` — delete-phase entry. Removes the
|
|
# A record and confirms its absence, exiting non-zero if it survives (so the
|
|
# caller's `complete` surfaces an unconfirmed delete). Inverse of `main`.
|
|
def "main delete" [domain: string, zone: string = ""]: nothing -> nothing {
|
|
let token = ($env.CF_API_TOKEN? | default "")
|
|
cf-dns-delete $token $domain $zone
|
|
if ($domain | is-not-empty) {
|
|
if (cf-dns-exists $token $domain $zone) {
|
|
print $" [ERROR] DNS delete NOT confirmed: ($domain) still present after delete — remove it manually"
|
|
exit 1
|
|
} else {
|
|
print $" [ok] DNS delete verified: ($domain) absent"
|
|
}
|
|
}
|
|
}
|