feat(cloudflared): Cloudflare Tunnel connector, Phase 1 (isolated, no traffic change)
Adds platform/cloudflared: a locally-managed tunnel whose ingress rules live in git. LAN/VPN-only hosts (plex, plex-vip, jackett, pihole, pihole-dns, semaphore, qbittorrent, radarr, sonarr, headlamp, unifi, crafty-mc) map to 404. The apex and *.home go to cilium-gateway-shared.gateway over TLS with matchSNItoHost. Last comes a catch-all 404. Nothing routes to the Kubernetes API. - 2-replica Deployment, hard node spread, non-root, pinned digest, PDB - default-deny plus an egress CNP: Cloudflare edge 7844/443, the Gateway, and Gateway backends by gateway-access=shared minus the LAN-only namespaces (needed because of the CVE-2025-30162 L7LB egress re-check) - PodMonitor plus two alerts that fire on absent series, with promtool tests - its own Flux Kustomization (dependsOn platform-config, wait:false, nothing depends on it), plus SOPS decryption patches in both overlays - placeholder credentials (SOPS-encrypted) so it can merge before enrolment - L0 cloudflared-ingress-rules, and a cloudflared_ scrape owner - runbooks/cloudflare-tunnel.md and decisions/2026-11-08-cloudflare-tunnel.md Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
eb62c13c6b
commit
aabc8c60b6
22 files changed
+1579
No files matched your search
@@ -0,0 +1,140 @@
|
||||
---
|
||||
# cloudflared — the Cloudflare Tunnel connector. Outbound-only: it dials the Cloudflare edge on
|
||||
# 7844 (QUIC, falling back to HTTP/2 over TCP) and carries requests for the tunnel's hostnames
|
||||
# back to the shared Gateway. Nothing listens on the LAN and no router port is opened.
|
||||
#
|
||||
# HA: 2 replicas, hard-spread across nodes. Each replica registers its own set of edge
|
||||
# connections for the SAME tunnel, so Cloudflare load-balances between them and losing one node
|
||||
# (a kured reboot, a drain) loses no traffic. The PDB in this directory keeps a drain from taking
|
||||
# both at once.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: cloudflared
|
||||
namespace: cloudflared
|
||||
labels:
|
||||
app.kubernetes.io/name: cloudflared
|
||||
spec:
|
||||
replicas: 2
|
||||
revisionHistoryLimit: 3
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
# Never drop below the running count during a rollout; with 3 nodes and a hard spread the
|
||||
# surge pod always has a node to land on.
|
||||
maxUnavailable: 0
|
||||
maxSurge: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: cloudflared
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: cloudflared
|
||||
# internet, NEVER vpn. The vpn class steers egress through the PIA tunnel (ADR-0015), which
|
||||
# would put the edge connections behind a third party's exit and its kill-switch — the
|
||||
# tunnel would drop whenever PIA does. This namespace is outside the app-namespace scope
|
||||
# of require-egress-class-label, so the label is declared for the steering, not to pass
|
||||
# admission; the L0 check cloudflared-ingress-rules pins it.
|
||||
homelab.keithjackson.dev/egress-class: internet
|
||||
spec:
|
||||
# cloudflared never talks to the Kubernetes API. No token mounted, and the egress policy
|
||||
# does not admit the kube-apiserver entity either.
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
# The image is distroless `nonroot` (65532).
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
whenUnsatisfiable: DoNotSchedule
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: cloudflared
|
||||
# cloudflared drains in-flight requests for up to its own 30s grace-period on SIGTERM.
|
||||
terminationGracePeriodSeconds: 45
|
||||
containers:
|
||||
- name: cloudflared
|
||||
# Tag + digest: Renovate's `kubernetes` manager (renovate.json5, platform/**) bumps both.
|
||||
image: docker.io/cloudflare/cloudflared:2026.10.7@sha256:4955524aeaa379e154023d5a027c250f7b35041a2e7e950cf13f144bc9b9c5ba
|
||||
# The image ENTRYPOINT is `cloudflared --no-autoupdate`; these replace its CMD. The tunnel
|
||||
# UUID, credentials path, metrics address and ingress rules all come from the file.
|
||||
args:
|
||||
- tunnel
|
||||
- --config
|
||||
- /etc/cloudflared/config/config.yaml
|
||||
- run
|
||||
ports:
|
||||
- name: metrics
|
||||
containerPort: 2000
|
||||
protocol: TCP
|
||||
# /ready is 200 only while this replica holds at least one live edge connection, so an
|
||||
# unenrolled or edge-isolated replica is NotReady and the Deployment says so.
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: metrics
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
# Liveness is deliberately NOT /ready: an edge outage would otherwise restart-loop every
|
||||
# replica, which reconnects nothing faster and churns the pods. /healthcheck answers
|
||||
# whenever the process is serving.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthcheck
|
||||
port: metrics
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/cloudflared/config
|
||||
readOnly: true
|
||||
- name: creds
|
||||
mountPath: /etc/cloudflared/creds
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
# Rewritten to the content-hashed name by kustomize (configMapGenerator).
|
||||
name: cloudflared-config
|
||||
- name: creds
|
||||
secret:
|
||||
# cloudflared-credentials.sops.yaml — the tunnel credentials JSON, by name only.
|
||||
secretName: cloudflared-credentials
|
||||
defaultMode: 0440
|
||||
---
|
||||
# One replica may be disrupted at a time — enough for kured's one-node-at-a-time drain to
|
||||
# proceed, never enough to take the tunnel down. minAvailable 1 (not 2) on purpose: a 0-disruption
|
||||
# PDB wedges a drain (platform/kured/alerts.yaml, KuredRebootPending).
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: cloudflared
|
||||
namespace: cloudflared
|
||||
labels:
|
||||
app.kubernetes.io/name: cloudflared
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: cloudflared
|
||||
Reference in new issue
Block a user