From e6c4c894e456fce92efd39c1ee0507c8731ecbc0 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 16 Jul 2026 18:53:18 -0700 Subject: [PATCH] feat(cloudflare-ddns): keep aleshym.co pointed at the current WAN IP The WAN is on DHCP with no DDNS anywhere, so a lease change would break public DNS silently -- certs, Plex and Gitea SSH all resolve through aleshym.co. cert-manager's Cloudflare token only ever writes the _acme-challenge TXT records and never touches the A record. Reuses that same token, resealed for this namespace: its Zone:DNS:Edit scope already covers A records (verified against the live API). The updater only PATCHes when the record is actually stale, and PATCHes content alone so the proxied flag and TTL stay as configured. It exits non-zero on any failure so a broken run surfaces as a Failed job rather than dying quietly -- silent failure is the thing this exists to prevent. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cloudflare-ddns/ddns-configmap.yaml | 100 ++++++++++++++++++++ apps/cloudflare-ddns/ddns-cronjob.yaml | 56 +++++++++++ apps/cloudflare-ddns/ddns-sealedsecret.yaml | 13 +++ apps/cloudflare-ddns/kustomization.yaml | 8 ++ apps/cloudflare-ddns/namespace.yaml | 4 + argocd/cloudflare-ddns.yaml | 20 ++++ 6 files changed, 201 insertions(+) create mode 100644 apps/cloudflare-ddns/ddns-configmap.yaml create mode 100644 apps/cloudflare-ddns/ddns-cronjob.yaml create mode 100644 apps/cloudflare-ddns/ddns-sealedsecret.yaml create mode 100644 apps/cloudflare-ddns/kustomization.yaml create mode 100644 apps/cloudflare-ddns/namespace.yaml create mode 100644 argocd/cloudflare-ddns.yaml diff --git a/apps/cloudflare-ddns/ddns-configmap.yaml b/apps/cloudflare-ddns/ddns-configmap.yaml new file mode 100644 index 0000000..37e722e --- /dev/null +++ b/apps/cloudflare-ddns/ddns-configmap.yaml @@ -0,0 +1,100 @@ +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() diff --git a/apps/cloudflare-ddns/ddns-cronjob.yaml b/apps/cloudflare-ddns/ddns-cronjob.yaml new file mode 100644 index 0000000..62813a4 --- /dev/null +++ b/apps/cloudflare-ddns/ddns-cronjob.yaml @@ -0,0 +1,56 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: cloudflare-ddns + namespace: cloudflare-ddns +spec: + schedule: "*/5 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + startingDeadlineSeconds: 300 + jobTemplate: + spec: + backoffLimit: 2 + template: + spec: + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: ddns + image: python:3.12-alpine + command: ["python3", "/script/ddns.py"] + env: + - name: CF_ZONE + value: aleshym.co + - name: CF_RECORD + value: aleshym.co + - name: CF_API_TOKEN + valueFrom: + secretKeyRef: + name: cloudflare-ddns-token + key: api-token + volumeMounts: + - name: script + mountPath: /script + readOnly: true + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 64Mi + volumes: + - name: script + configMap: + name: cloudflare-ddns-script diff --git a/apps/cloudflare-ddns/ddns-sealedsecret.yaml b/apps/cloudflare-ddns/ddns-sealedsecret.yaml new file mode 100644 index 0000000..14f804d --- /dev/null +++ b/apps/cloudflare-ddns/ddns-sealedsecret.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: cloudflare-ddns-token + namespace: cloudflare-ddns +spec: + encryptedData: + api-token: AgB5PIMwLsFW8PojFN++6VyFUV+7oq3Q56iQGTLH0iAF35HhmPNhrBAVHbdlm/qgjLruOpkUNeadNZXobtoa8N4PZtuEea99kwRt4VNB4ohyWezGRS8Bc4QjXFup6TU35fajq3Y7kqEMIbFP3KOCL+tuDktB67yInRnOpk13/5JfE9EU0FzPQOX7dq3YihZ+yt9PTYeUlGInK7w2PvoeaKgPPobLsaDgBoLIcRm+DFL8SrSFYXDps8MQMjAMA7vyHdGmiOpr8YHNI5IHXB1VqiFiiWwaovMTz9zRZgEnrEAAdejrrJYbCnR4sJe62BPerCk02bxqXwQ4Ab3HAxtVTJ6gWyzt/jgxmoRMUJvCvscAnfABIpQCcpc8MVgvNcrGMVZbA+1UL1qC656VQ+Po1otjfxZe1Kn6BD6tMC3CovUAVzJ8ZmANJoHUQMQFDaqOfcN2Af2DC9hyJSUSdTldmvv7mzX0q5JWaKh544tM0JvN42KtllmuD38Qu23j8qE9n7qAblEh5DcGQ7WYleCLq5xyEUofX2mFIjSmZC65CuSYmmmDCuYlG32XjkP9wJnrWeHNErWDFJaK6oNAu7Qa+K1YghMx01D6rDvM9KC+NomPsVklaznfTRwsyMT2ZgQ8DkdeGzM4l+ZCVpL87FSyIyOelKqrR3D+F48SAPVB/l/s0YZ7mMhrcM3thnzpwCcGTvxVJGDqwOeMFh/p5kMwOqqAvCbiGpWwHQV1JzxYuRV71zxQxh9+MbXTxpep3OoScyM60cMLxQ== + template: + metadata: + name: cloudflare-ddns-token + namespace: cloudflare-ddns diff --git a/apps/cloudflare-ddns/kustomization.yaml b/apps/cloudflare-ddns/kustomization.yaml new file mode 100644 index 0000000..3716a79 --- /dev/null +++ b/apps/cloudflare-ddns/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml + - ddns-sealedsecret.yaml + - ddns-configmap.yaml + - ddns-cronjob.yaml diff --git a/apps/cloudflare-ddns/namespace.yaml b/apps/cloudflare-ddns/namespace.yaml new file mode 100644 index 0000000..9c39552 --- /dev/null +++ b/apps/cloudflare-ddns/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: cloudflare-ddns diff --git a/argocd/cloudflare-ddns.yaml b/argocd/cloudflare-ddns.yaml new file mode 100644 index 0000000..b50942e --- /dev/null +++ b/argocd/cloudflare-ddns.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: cloudflare-ddns + namespace: argocd +spec: + project: default + source: + repoURL: https://git.aleshym.co/funman300/k3s-homelab.git + targetRevision: main + path: apps/cloudflare-ddns + destination: + server: https://kubernetes.default.svc + namespace: cloudflare-ddns + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true