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]>
85 lines
4.7 KiB
YAML
85 lines
4.7 KiB
YAML
---
|
|
# 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: REPLACE-ME-TUNNEL-UUID
|
|
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
|