--- # cloudflared configuration for the LOCALLY-MANAGED tunnel (the ingress rules live here, in git, # not in the Cloudflare dashboard). Projected into the pods by the configMapGenerator in # kustomization.yaml as the content-hashed ConfigMap `cloudflared-config`, so editing this file # changes the pod template and ROLLS the Deployment (the vpn-ingress peers.conf lesson: a # fixed-name ConfigMap updates in place and the running process never re-reads it). # # `${BASE_DOMAIN}` is substituted by Flux postBuild (clusters/base/cloudflared.yaml). It is the ONLY # token in this file; do not add a bare dollar sign anywhere here. # # INVARIANTS, enforced by conformance/tests/l0-static/cloudflared-ingress-rules: # * every rule's `service` is either `http_status:404` or EXACTLY the shared Gateway's in-cluster # Service — nothing else, and in particular NEVER the Kubernetes API (operator decision # 2026-11-08: the API stays local-only and must never be reachable through the tunnel); # * the LAN/VPN-only hostnames below map to `http_status:404` and come BEFORE the wildcard; # * `tunnel:` is a LITERAL UUID here, not a cluster-var: the Phase 2 DNS-flip change matches # records against it, and keeping it out of clusters/** keeps that change off a sensitive path; # * the last rule is a hostname-less `http_status:404` catch-all. # # OPERATOR STEP: replace REPLACE-ME-TUNNEL-UUID with the UUID printed by `cloudflared tunnel create` # (runbooks/cloudflare-tunnel.md step 3). Until then cloudflared treats the value as a tunnel NAME, # finds no origin certificate to resolve it with, and exits — a CrashLoop in a non-gating tier. tunnel: f26f1052-1bc0-4d9d-800b-4da0377472a3 credentials-file: /etc/cloudflared/creds/credentials.json # Serves /metrics (Prometheus), /ready (200 only with >=1 live edge connection) and /healthcheck. metrics: 0.0.0.0:2000 no-autoupdate: true ingress: # ── NEVER THROUGH THE TUNNEL (operator decisions 2026-11-08) ───────────────────────────────── # Plex stays off the tunnel; it keeps its own remote-access path (runbooks/plex-remote-access.md). - hostname: plex.${BASE_DOMAIN} service: http_status:404 - hostname: plex-vip.${BASE_DOMAIN} service: http_status:404 # LAN/VPN-only services. The six catalog `exposure: internal` apps (each carries the external-dns # `controller: none` route annotation live, so none has a public record today) ... - hostname: jackett.${BASE_DOMAIN} service: http_status:404 - hostname: pihole.${BASE_DOMAIN} service: http_status:404 - hostname: pihole-dns.${BASE_DOMAIN} service: http_status:404 - hostname: semaphore.${BASE_DOMAIN} service: http_status:404 - hostname: qbittorrent.${BASE_DOMAIN} service: http_status:404 - hostname: radarr.${BASE_DOMAIN} service: http_status:404 - hostname: sonarr.${BASE_DOMAIN} service: http_status:404 # ... and Headlamp, the cluster dashboard (operator decision 2026-11-08: LAN/VPN-only). It holds a # route to the Kubernetes API on the user's behalf, which is one more reason it stays off the # tunnel alongside decision 4. - hostname: headlamp.${BASE_DOMAIN} service: http_status:404 # Non-HTTP L4 VIPs (their own LoadBalancer addresses, not the Gateway). Listed so a DNS flip # that CNAMEs them at the tunnel by mistake gets a 404, not the Gateway's catch-all. - hostname: unifi.${BASE_DOMAIN} service: http_status:404 - hostname: crafty-mc.${BASE_DOMAIN} service: http_status:404 # ── EVERYTHING ELSE ON THE ZONE -> THE SHARED GATEWAY, OVER TLS ────────────────────────────── # cilium-gateway-shared is the LoadBalancer Service Cilium creates for Gateway gateway/shared # (platform/gateway/gateway.yaml); its ClusterIP frontend is redirected to the Gateway's Envoy # exactly like the VIP. Addressed by cluster DNS, not by a ${BASE_DOMAIN} name, so the tunnel # does not depend on split-horizon DNS at all. # # matchSNItoHost: cloudflared sends the request's Host as the TLS SNI, so the Gateway selects the # right listener and presents the existing Let's Encrypt `wildcard-home-tls` certificate, which # cloudflared validates against the public roots for that same name. No TLS verification is # disabled anywhere. - hostname: ${BASE_DOMAIN} service: https://cilium-gateway-shared.gateway.svc.cluster.local:443 originRequest: matchSNItoHost: true - hostname: "*.${BASE_DOMAIN}" service: https://cilium-gateway-shared.gateway.svc.cluster.local:443 originRequest: matchSNItoHost: true # ── CATCH-ALL (cloudflared requires the last rule to have no hostname) ─────────────────────── - service: http_status:404