Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7605de60c | |||
| e6c4c894e4 | |||
| 91733d3f02 |
@@ -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