feat(cloudflared): Cloudflare Tunnel connector, Phase 1 (isolated, no traffic change) #152

Merged
jakman85 merged 2 commits from feat/cloudflare-tunnel into main 2026-11-08 18:43:43 +00:00
Owner

Summary

Phase 1 of moving ingress to a Cloudflare Tunnel: this adds the connector component and nothing else. It changes no live traffic. Cloudflare only sends a hostname through a tunnel when its DNS record is a CNAME to <uuid>.cfargotunnel.com, and this PR creates no such record. external-dns, the Gateway annotations, Pi-hole and CoreDNS are untouched.

  • platform/cloudflared/ is a locally-managed tunnel, with its ingress rules in git (config.yaml, a content-hashed ConfigMap).

    • The http_status:404 rules come first: plex, plex-vip, jackett, pihole, pihole-dns, semaphore, qbittorrent, radarr, sonarr, headlamp, unifi and crafty-mc.
    • The apex and *.home.keithjackson.dev then go to https://cilium-gateway-shared.gateway.svc.cluster.local:443 with matchSNItoHost: true, so the Gateway's existing LE wildcard cert validates.
    • A catch-all 404 closes the list. No rule reaches the Kubernetes API.
  • The Deployment runs 2 replicas, hard-spread across nodes, non-root with a read-only root fs and no SA token. The image is pinned as cloudflare/cloudflared:2026.10.7@sha256:4955524a…. Readiness uses /ready and liveness uses /healthcheck. A PDB sets minAvailable: 1, and the pods are labelled egress-class: internet.

  • The NetworkPolicy has three parts:

    • A default-deny with a single owner (this is not an app namespace, so Kyverno does not generate one here).
    • A Prometheus scrape allow.
    • An egress CNP that allows world 7844 tcp/udp and 443, the Gateway (ingress entity), and Gateway backends selected by gateway-access=shared, minus the LAN-only namespaces.

    The backend rule is required by Cilium 1.20's CVE-2025-30162 L7LB egress re-check. Without it every tunnelled request would get a 403 Access denied. I did not use gitea-actions' - {}, because that admits kube-apiserver/host. Backends' ingress needs no change: the check is on the source's egress, per decisions/2026-10-30-gitea-admit-buildkit.md.

  • A headless metrics Service and ServiceMonitor feed two alerts, CloudflaredTunnelDown and CloudflaredReplicasDegraded. Both use or on() vector(0), so they fire, rather than go quiet, when their series vanish. promtool tests pin the absent case and the working-then-stale case.

  • Flux isolation: the component has its own Kustomization clusters/base/cloudflared.yaml (dependsOn platform-config, wait: false), and nothing depends on it. Both overlays add its SOPS decryption patch.

  • New L0 check cloudflared-ingress-rules (whole-repo) covers:

    • only-404-or-Gateway targets, with the Gateway Service name derived from platform/gateway/gateway.yaml;
    • excluded hosts 404 before the wildcard;
    • no API or node entity and no CIDR in the egress CNP;
    • Flux isolation;
    • a mutation self-test.

    alert-metrics-have-scrape-targets also gains an additive cloudflared_ owner.

  • runbooks/cloudflare-tunnel.md holds the operator steps. decisions/2026-11-08-cloudflare-tunnel.md records the operator decisions, including Headlamp being LAN/VPN-only.

Safe to merge before credentials exist

Yes. The Secret ships SOPS-encrypted with REPLACE-ME placeholders, and tunnel: is REPLACE-ME-TUNNEL-UUID. On merge the Deployment CrashLoops inside its own wait:false tier, so platform, platform-config and every apps wave are unaffected. CloudflaredTunnelDown (warning) fires from merge until the operator enrols the tunnel. That alert is the honest signal.

Sensitive paths: needs an operator /approve-override

This PR touches clusters/** (Flux wiring), conformance/** (the new check plus the scrape-owner entry) and **/*.sops.yaml (the placeholder Secret). approval-policy will block it until the operator posts /approve-override <reason> as a PR review of type Comment. The agent did not, and will not, post it.

Gate (pinned toolbox, from the worktree, after committing)

./ci/gate.sh returned rc 3. gitea-settings is the only stage not run (it needs SETTINGS_API_TOKEN, CI-only). spec-immutability passed (spec repo mounted). decisions-log, lint, l0, render-golden, l1 and approval-policy (N/A offline) all passed. L2/L3 conformance was skipped as usual (no cluster). Relevant L0 checks that passed: cloudflared-ingress-rules, alert-metrics-have-scrape-targets, alert-rules-unit-tested, alert-runbooks-resolvable, servicemonitor-ports-resolve, no-plaintext-secrets and flux-postbuild-substitution.

Not verified

Nothing has run in a cluster. Specifically unverified:

  • the CVE-2025-30162 path for cloudflared (the runbook's kubectl debug probe exists to prove it);
  • the /ready and /healthcheck endpoints and the cloudflared_tunnel_ha_connections metric on 2026.10.7;
  • whether world:443 is needed at all;
  • the Cloudflare-side settings.

Details are in the decisions entry.

🤖 Generated with Claude Code

## Summary Phase 1 of moving ingress to a **Cloudflare Tunnel**: this adds the connector component and nothing else. **It changes no live traffic.** Cloudflare only sends a hostname through a tunnel when its DNS record is a CNAME to `<uuid>.cfargotunnel.com`, and this PR creates no such record. external-dns, the Gateway annotations, Pi-hole and CoreDNS are untouched. - `platform/cloudflared/` is a **locally-managed** tunnel, with its ingress rules in git (`config.yaml`, a content-hashed ConfigMap). - The `http_status:404` rules come first: plex, plex-vip, jackett, pihole, pihole-dns, semaphore, qbittorrent, radarr, sonarr, headlamp, unifi and crafty-mc. - The apex and `*.home.keithjackson.dev` then go to `https://cilium-gateway-shared.gateway.svc.cluster.local:443` with `matchSNItoHost: true`, so the Gateway's existing LE wildcard cert validates. - A catch-all 404 closes the list. **No rule reaches the Kubernetes API.** - The Deployment runs 2 replicas, hard-spread across nodes, non-root with a read-only root fs and no SA token. The image is pinned as `cloudflare/cloudflared:2026.10.7@sha256:4955524a…`. Readiness uses `/ready` and liveness uses `/healthcheck`. A PDB sets `minAvailable: 1`, and the pods are labelled `egress-class: internet`. - The NetworkPolicy has three parts: - A default-deny with a single owner (this is not an app namespace, so Kyverno does not generate one here). - A Prometheus scrape allow. - An egress CNP that allows world 7844 tcp/udp and 443, the Gateway (`ingress` entity), and **Gateway backends selected by `gateway-access=shared`, minus the LAN-only namespaces.** The backend rule is required by Cilium 1.20's CVE-2025-30162 L7LB egress re-check. Without it every tunnelled request would get a `403 Access denied`. I did not use gitea-actions' `- {}`, because that admits kube-apiserver/host. Backends' ingress needs no change: the check is on the source's egress, per `decisions/2026-10-30-gitea-admit-buildkit.md`. - A headless metrics Service and ServiceMonitor feed two alerts, `CloudflaredTunnelDown` and `CloudflaredReplicasDegraded`. Both use `or on() vector(0)`, so they **fire, rather than go quiet, when their series vanish**. promtool tests pin the absent case and the working-then-stale case. - **Flux isolation:** the component has its own Kustomization `clusters/base/cloudflared.yaml` (dependsOn `platform-config`, **`wait: false`**), and nothing depends on it. Both overlays add its SOPS decryption patch. - New L0 check `cloudflared-ingress-rules` (whole-repo) covers: - only-404-or-Gateway targets, with the Gateway Service name derived from `platform/gateway/gateway.yaml`; - excluded hosts 404 before the wildcard; - no API or node entity and no CIDR in the egress CNP; - Flux isolation; - a mutation self-test. `alert-metrics-have-scrape-targets` also gains an additive `cloudflared_` owner. - `runbooks/cloudflare-tunnel.md` holds the operator steps. `decisions/2026-11-08-cloudflare-tunnel.md` records the operator decisions, including Headlamp being LAN/VPN-only. ## Safe to merge before credentials exist Yes. The Secret ships SOPS-encrypted with `REPLACE-ME` placeholders, and `tunnel:` is `REPLACE-ME-TUNNEL-UUID`. On merge the Deployment CrashLoops inside its own `wait:false` tier, so `platform`, `platform-config` and every apps wave are unaffected. **`CloudflaredTunnelDown` (warning) fires from merge until the operator enrols the tunnel.** That alert is the honest signal. ## Sensitive paths: needs an operator `/approve-override` This PR touches `clusters/**` (Flux wiring), `conformance/**` (the new check plus the scrape-owner entry) and `**/*.sops.yaml` (the placeholder Secret). `approval-policy` will block it until the operator posts `/approve-override <reason>` **as a PR review of type Comment**. The agent did not, and will not, post it. ## Gate (pinned toolbox, from the worktree, after committing) `./ci/gate.sh` returned **rc 3**. `gitea-settings` is the only stage not run (it needs `SETTINGS_API_TOKEN`, CI-only). `spec-immutability` **passed** (spec repo mounted). decisions-log, lint, l0, render-golden, l1 and approval-policy (N/A offline) all passed. L2/L3 conformance was skipped as usual (no cluster). Relevant L0 checks that passed: cloudflared-ingress-rules, alert-metrics-have-scrape-targets, alert-rules-unit-tested, alert-runbooks-resolvable, servicemonitor-ports-resolve, no-plaintext-secrets and flux-postbuild-substitution. ## Not verified Nothing has run in a cluster. Specifically unverified: - the CVE-2025-30162 path for cloudflared (the runbook's `kubectl debug` probe exists to prove it); - the `/ready` and `/healthcheck` endpoints and the `cloudflared_tunnel_ha_connections` metric on 2026.10.7; - whether world:443 is needed at all; - the Cloudflare-side settings. Details are in the decisions entry. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gitea-admin added 2 commits 2026-11-08 18:41:17 +00:00
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]>
fix(cloudflared): auditable Service+ServiceMonitor scrape; placeholder-only template value
gate / gate (pull_request) Successful in 42s
gate / conformance (pull_request) Skipped
09f219fe7f
servicemonitor-ports-resolve could not audit a PodMonitor with no in-repo
Service, and no-plaintext-secrets requires a template's secret value to be a
bare placeholder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jakman85 approved these changes 2026-11-08 18:42:54 +00:00
jakman85 scheduled this pull request to auto merge when all checks succeed 2026-11-08 18:43:15 +00:00
jakman85 merged commit 83e0ac194f into main 2026-11-08 18:43:43 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Home-Network/home-network-k3s-impl#152