Compare commits
3 Commits
91733d3f02
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1261e238aa | |||
| b7605de60c | |||
| e6c4c894e4 |
@@ -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,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
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: vaultwarden
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: vaultwarden
|
||||
namespace: vaultwarden
|
||||
spec:
|
||||
selector:
|
||||
app: vaultwarden
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
@@ -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