apiVersion: v1 kind: ConfigMap metadata: name: cloudflare-ddns-script namespace: cloudflare-ddns data: ddns.py: | #!/usr/bin/env python3 """Point a Cloudflare A record at this network's current public IP. Only PATCHes when the record is actually stale, so a stable WAN IP costs two reads per run and no writes. Exits non-zero on any failure so the Job is marked Failed and shows up in `kubectl get jobs` rather than dying quietly -- silent failure is the exact thing this exists to prevent. """ import json import os import sys import urllib.error import urllib.request API = "https://api.cloudflare.com/client/v4" TOKEN = os.environ["CF_API_TOKEN"] ZONE = os.environ["CF_ZONE"] RECORD = os.environ["CF_RECORD"] # cloudflare first: same vendor as the API we're about to call, so if it's # unreachable the run was doomed anyway. ipify is an independent fallback. IP_SOURCES = [ ("https://cloudflare.com/cdn-cgi/trace", lambda b: next( l.split("=", 1)[1] for l in b.splitlines() if l.startswith("ip="))), ("https://api.ipify.org", lambda b: b.strip()), ] def fail(msg): print(f"ERROR: {msg}", file=sys.stderr) sys.exit(1) def cf(path, method="GET", body=None): req = urllib.request.Request( API + path, method=method, headers={ "Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json", }, data=json.dumps(body).encode() if body is not None else None, ) try: with urllib.request.urlopen(req, timeout=20) as r: payload = json.load(r) except urllib.error.HTTPError as e: fail(f"{method} {path} -> HTTP {e.code}: {e.read().decode()[:300]}") except Exception as e: fail(f"{method} {path} -> {e}") if not payload.get("success"): fail(f"{method} {path} -> cloudflare returned {payload.get('errors')}") return payload["result"] def public_ip(): for url, parse in IP_SOURCES: try: with urllib.request.urlopen(url, timeout=15) as r: ip = parse(r.read().decode()) if ip: return ip except Exception as e: print(f"warn: {url} failed ({e}), trying next", file=sys.stderr) fail("could not determine public IP from any source") def main(): ip = public_ip() zones = cf(f"/zones?name={ZONE}") if not zones: fail(f"zone {ZONE!r} not visible to this token") zone_id = zones[0]["id"] records = cf(f"/zones/{zone_id}/dns_records?type=A&name={RECORD}") if not records: fail(f"no A record named {RECORD!r} in zone {ZONE!r}") if len(records) > 1: fail(f"{len(records)} A records named {RECORD!r}; refusing to guess") record = records[0] if record["content"] == ip: print(f"ok: {RECORD} already points at {ip}") return # content only -- leaves ttl and the proxied flag exactly as configured. cf(f"/zones/{zone_id}/dns_records/{record['id']}", "PATCH", {"content": ip}) print(f"UPDATED: {RECORD} {record['content']} -> {ip}") if __name__ == "__main__": main()