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) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- ddns-sealedsecret.yaml
|
||||
- ddns-configmap.yaml
|
||||
- ddns-cronjob.yaml
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: cloudflare-ddns
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user