2 Commits

Author SHA1 Message Date
funman300 e6c4c894e4 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>
2026-07-16 18:53:18 -07:00
Alex 91733d3f02 Merge 'feat/notesnook-k8s': Notesnook on k3s + sealed secrets
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 10:13:37 -07:00
6 changed files with 201 additions and 0 deletions
+100
View File
@@ -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()
+56
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- ddns-sealedsecret.yaml
- ddns-configmap.yaml
- ddns-cronjob.yaml
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: cloudflare-ddns
+20
View File
@@ -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