3 Commits

Author SHA1 Message Date
Claude 1261e238aa Add vaultwarden app (migrated from docker on 10.10.0.5)
- Pinned to 1.37.0 so the migration is not also an upgrade
- ADMIN_TOKEN rotated to an Argon2id PHC string, stored as a SealedSecret.
  The old token was plaintext AND set in config.json, which overrides env --
  both that key and signups_allowed were removed during migration so the
  Deployment is the source of truth. signups had been silently enabled.
- /admin keeps Authentik forward-auth at priority 1000 (Traefik's default
  priority is rule length; the old docker router used 10 vs an implicit 24
  and was therefore bypassed).
- vault.aleshym.co stays private: absent from the k3s public catchall and
  from the Docker Traefik wan entrypoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:44:05 -07:00
funman300 b7605de60c Merge 'feat/cloudflare-ddns': keep aleshym.co pointed at the current WAN IP
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:56:13 -07:00
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
15 changed files with 407 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
+11
View File
@@ -0,0 +1,11 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- vaultwarden-sealedsecret.yaml
- vaultwarden-pvc.yaml
- vaultwarden-deployment.yaml
- vaultwarden-service.yaml
- vaultwarden-ingress.yaml
- vaultwarden-admin.yaml
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: vaultwarden
+47
View File
@@ -0,0 +1,47 @@
# /admin gets Authentik forward-auth on top of the ADMIN_TOKEN.
#
# Bitwarden clients (browser extension, phone) use /api, /identity and
# /notifications/hub and CANNOT follow an SSO redirect, so this is deliberately
# path-scoped -- never blanket-auth this host.
#
# Priority must EXCEED the Ingress route's default, which Traefik derives from
# the rule's character length. Setting a low-but-nonzero number is the trap that
# left this same /admin panel unprotected under Docker (priority=10 vs an
# implicit 24). 1000 is unambiguous.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: authentik
namespace: vaultwarden
spec:
forwardAuth:
# Resolves to 10.10.0.5 only because coredns-custom forwards aleshym.co to
# Pi-hole; the node's own resolver (1.1.1.1) returns the public IP.
address: https://auth.aleshym.co/outpost.goauthentik.io/auth/traefik
trustForwardHeader: true
authResponseHeaders:
- X-authentik-username
- X-authentik-groups
- X-authentik-email
- X-authentik-name
- X-authentik-uid
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: vaultwarden-admin
namespace: vaultwarden
spec:
entryPoints:
- websecure
routes:
- kind: Rule
priority: 1000
match: Host(`vault.aleshym.co`) && PathPrefix(`/admin`)
middlewares:
- name: authentik
services:
- name: vaultwarden
port: 80
tls:
secretName: vaultwarden-tls
@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: vaultwarden
namespace: vaultwarden
spec:
replicas: 1
strategy:
# SQLite on a RWO volume - never run two pods against it.
type: Recreate
selector:
matchLabels:
app: vaultwarden
template:
metadata:
labels:
app: vaultwarden
spec:
containers:
- name: vaultwarden
# Pinned deliberately: this migration should not also be an upgrade.
image: vaultwarden/server:1.37.0
ports:
- name: http
containerPort: 80
env:
- name: DOMAIN
value: https://vault.aleshym.co
# config.json used to override these; both keys were removed from it
# during the migration so the Deployment is the source of truth.
- name: SIGNUPS_ALLOWED
value: "false"
- name: INVITATIONS_ALLOWED
value: "false"
- name: ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: vaultwarden-secret
key: ADMIN_TOKEN
volumeMounts:
- name: data
mountPath: /data
livenessProbe:
httpGet:
path: /alive
port: http
initialDelaySeconds: 20
periodSeconds: 30
readinessProbe:
httpGet:
path: /alive
port: http
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 25m
memory: 96Mi
limits:
memory: 512Mi
volumes:
- name: data
persistentVolumeClaim:
claimName: vaultwarden-data
+29
View File
@@ -0,0 +1,29 @@
# vault.aleshym.co is PRIVATE. It is deliberately absent from the k3s
# `catchall-to-docker-apps` public host list and from the Docker Traefik `wan`
# entrypoint, so the internet has no route to it. Pi-hole resolves the name to
# 10.10.0.100 for LAN/VPN clients.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vaultwarden
namespace: vaultwarden
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
rules:
- host: vault.aleshym.co
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vaultwarden
port:
name: http
tls:
- hosts:
- vault.aleshym.co
secretName: vaultwarden-tls
+13
View File
@@ -0,0 +1,13 @@
# local-path has reclaimPolicy: Delete -- deleting this PVC destroys the vault.
# Data is tiny (12Mi today); 2Gi is headroom for attachments/sends.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: vaultwarden-data
namespace: vaultwarden
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
@@ -0,0 +1,13 @@
---
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: vaultwarden-secret
namespace: vaultwarden
spec:
encryptedData:
ADMIN_TOKEN: AgA6mXlIUSyYrbKTJ0y5sW+ihtQoSg2B6XYoTadlp7JvHEojnsF11mlX5JuiZgqlWlb0o9WakC4HnXCo04t4sX98ZZaEvumkC8ALIiTCG9R9K7/b3GP5h2kmEY0kpZDRVbhex4wBEYdjlirqGMpubT4slwlwGeoNnMugC95+IE8CAjFJW87IaQR03J4zumFNG9E4AFqgprm5HCxDi9UHeY77gECDcOko0TVXSvMmdnwtymjgSWKG7t8Ry3dkkS+j/cS1v1IoJkvqINi2b9+oP1C3M0dV+YZyyE+1KAlgwVnR+RxTUWFmZ6rWWK/C1/a79hqcGNjXzL5YWCUewzvIBcsERRNMMQqb63Q0KvCXNPheC5z+aNz6PPEaToyrNbmHgx6973SwNrtIeaEj8FAkAoNHZFwk9kJ+bjhVyrMZ+A/V/xw5STMPn3B4dbJYv/PfjXOuCySIZKtdFzM3DWAMK3aGzS2cvDefTofLc1HpoXr7ZFvUnNGrs9kjpbcnKt3HImhx4b/moddywOeaicn0kQMYCXoq5TmJLyma10bQl37JV19sFyya054OMGT5mOyqCKLmm7tZxC3T5TuCDyRSj7uM9suGcW7RSpn7gxa10ismSRzSoqSO8Af+Ilo69AYdiwW341P6h5GQOLhUbUA9G+ho8Q5rgEh7tkp9qpz0QYEKqsmtd4vlzg4jn2c0c1fSK29I9shMyKusyVdADkk/sunkYdqRG6kdAGGmS71DocuSvN8faP8DIEqw49tlI59TBCSmPqF1kEg5xBErkq/9+HUx5SffxEUn1SEdlftnQWQ9ECiPZNw7LhVl9Ritv99UuvtL
template:
metadata:
name: vaultwarden-secret
namespace: vaultwarden
@@ -0,0 +1,13 @@
# Template only -- the real values live in vaultwarden-sealedsecret.yaml.
# ADMIN_TOKEN is an Argon2id PHC string (vaultwarden hash --preset owasp),
# NOT a plaintext token. Generate a replacement with:
# docker run --rm python:3.12-alpine sh -c \
# 'pip install -q argon2-cffi && python -c "..."'
apiVersion: v1
kind: Secret
metadata:
name: vaultwarden-secret
namespace: vaultwarden
type: Opaque
stringData:
ADMIN_TOKEN: '$argon2id$v=19$m=19456,t=2,p=1$REPLACE$REPLACE'
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: vaultwarden
namespace: vaultwarden
spec:
selector:
app: vaultwarden
ports:
- name: http
port: 80
targetPort: http
+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