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