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:
jakman85andClaude Opus 5.5 committed 2026-11-08 12:38:22 -06:00
1 parent eb62c13c6b
commit aabc8c60b6
22 files changed
+1579

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
# platform/cloudflared
**Cloudflare Tunnel connector**: inbound HTTPS from the Cloudflare edge to the shared Gateway,
outbound-only from the cluster (no router port forward, no hairpin, no split-DNS dependency).
- **Flux tier:** `clusters/base/cloudflared.yaml` — its own Kustomization, `dependsOn: [platform-config]`,
`wait: false`, and nothing depends on it. A missing or invalid credential can affect only the tunnel.
- **Runbook (enrolment, canary, alerts, rollback):** `runbooks/cloudflare-tunnel.md`
- **Design record:** `decisions/2026-11-08-cloudflare-tunnel.md`
- **Guard:** `conformance/tests/l0-static/cloudflared-ingress-rules`
| File | What it is |
|---|---|
| `config.yaml` | The locally-managed tunnel: literal tunnel UUID + ingress rules. LAN/VPN-only hosts → 404, apex + wildcard → `https://cilium-gateway-shared.gateway.svc.cluster.local:443` with `matchSNItoHost`, catch-all 404. Never the Kubernetes API. |
| `cloudflared-credentials.sops.yaml` | SOPS-encrypted tunnel credentials JSON. Ships with `REPLACE-ME`; the operator fills it. |
| `deployment.yaml` | 2 replicas, hard spread across nodes, non-root, read-only root fs, pinned digest; PDB `minAvailable: 1`. |
| `networkpolicy.yaml` | default-deny (single owner — not an app namespace) + Prometheus scrape allow + egress CNP: world 7844 tcp/udp and 443, the Gateway (`ingress` entity), and Gateway backends by `gateway-access=shared` minus the LAN-only namespaces. |
| `podmonitor.yaml`, `alerts.yaml` | Scrape of :2000 and the two alerts, both written to fire (not go quiet) when their series vanish. |
**Phase 1 only.** This component changes no live traffic: Cloudflare sends a hostname through the
tunnel only when its DNS record is a CNAME to `<UUID>.cfargotunnel.com`, and nothing here creates
one. Moving DNS (external-dns, Gateway annotations, Pi-hole/CoreDNS split horizon) is a later change.
@@ -0,0 +1,128 @@
---
# promtool unit tests for platform/cloudflared/alerts.yaml.
#
# Found by platform/monitoring/tests/run.sh through the COMPANION convention
# (`<manifest>.promtool-test.yaml` beside `<manifest>.yaml`). Run just this file with
# platform/monitoring/tests/run.sh platform/cloudflared/alerts.promtool-test.yaml
# The harness flattens the PrometheusRule's `.spec.groups` into alerts.rules.yaml. Assertions query
# the synthetic ALERTS series (not exp_alerts), so annotation wording is not pinned here.
#
# Each rule is pinned BOTH ways, plus the two self-silencing traps:
# * series ABSENT (nothing scraped) must FIRE, not read as healthy;
# * working-then-broken must RE-FIRE, not latch cleared.
rule_files:
- alerts.rules.yaml
evaluation_interval: 1m
tests:
- interval: 1m
name: healthy - two replicas, four edge connections each, both rules quiet
input_series:
- series: 'cloudflared_tunnel_ha_connections{namespace="cloudflared",pod="cloudflared-a"}'
values: '4x60'
- series: 'cloudflared_tunnel_ha_connections{namespace="cloudflared",pod="cloudflared-b"}'
values: '4x60'
- series: 'kube_deployment_status_replicas_available{namespace="cloudflared",deployment="cloudflared"}'
values: '2x60'
promql_expr_test:
- expr: 'count(ALERTS{alertname=~"Cloudflared.*"})'
eval_time: 50m
exp_samples: []
- interval: 1m
name: nothing scraped at all - TunnelDown still fires (no self-silencing on absence)
input_series:
# kube-state-metrics says both pods are Available, but no cloudflared series exists — the
# broken-PodMonitor / dropped-scrape case. The rule must not read this as healthy.
- series: 'kube_deployment_status_replicas_available{namespace="cloudflared",deployment="cloudflared"}'
values: '2x60'
promql_expr_test:
- expr: 'ALERTS{alertname="CloudflaredTunnelDown"}'
eval_time: 5m
exp_samples:
- labels: 'ALERTS{alertname="CloudflaredTunnelDown", alertstate="pending", severity="warning"}'
value: 1
- expr: 'ALERTS{alertname="CloudflaredTunnelDown", alertstate="firing"}'
eval_time: 12m
exp_samples:
- labels: 'ALERTS{alertname="CloudflaredTunnelDown", alertstate="firing", severity="warning"}'
value: 1
- expr: 'ALERTS{alertname="CloudflaredReplicasDegraded"}'
eval_time: 30m
exp_samples: []
- interval: 1m
name: scraped but zero edge connections - TunnelDown fires after 10m
input_series:
- series: 'cloudflared_tunnel_ha_connections{namespace="cloudflared",pod="cloudflared-a"}'
values: '0x60'
- series: 'cloudflared_tunnel_ha_connections{namespace="cloudflared",pod="cloudflared-b"}'
values: '0x60'
- series: 'kube_deployment_status_replicas_available{namespace="cloudflared",deployment="cloudflared"}'
values: '0x60'
promql_expr_test:
- expr: 'ALERTS{alertname="CloudflaredTunnelDown", alertstate="firing"}'
eval_time: 9m
exp_samples: []
- expr: 'ALERTS{alertname="CloudflaredTunnelDown", alertstate="firing"}'
eval_time: 12m
exp_samples:
- labels: 'ALERTS{alertname="CloudflaredTunnelDown", alertstate="firing", severity="warning"}'
value: 1
- expr: 'ALERTS{alertname="CloudflaredReplicasDegraded", alertstate="firing"}'
eval_time: 17m
exp_samples:
- labels: 'ALERTS{alertname="CloudflaredReplicasDegraded", alertstate="firing", severity="warning"}'
value: 1
- interval: 1m
name: working then the series vanish - TunnelDown re-fires instead of latching cleared
input_series:
# 20 healthy samples, then the series stop (stale) — the scrape broke or the pods died.
- series: 'cloudflared_tunnel_ha_connections{namespace="cloudflared",pod="cloudflared-a"}'
values: '4x20 stale'
- series: 'cloudflared_tunnel_ha_connections{namespace="cloudflared",pod="cloudflared-b"}'
values: '4x20 stale'
- series: 'kube_deployment_status_replicas_available{namespace="cloudflared",deployment="cloudflared"}'
values: '2x60'
promql_expr_test:
- expr: 'ALERTS{alertname="CloudflaredTunnelDown"}'
eval_time: 15m
exp_samples: []
- expr: 'ALERTS{alertname="CloudflaredTunnelDown", alertstate="firing"}'
eval_time: 35m
exp_samples:
- labels: 'ALERTS{alertname="CloudflaredTunnelDown", alertstate="firing", severity="warning"}'
value: 1
- interval: 1m
name: one replica lost - ReplicasDegraded fires after 15m, tunnel itself still up
input_series:
- series: 'cloudflared_tunnel_ha_connections{namespace="cloudflared",pod="cloudflared-a"}'
values: '4x60'
- series: 'kube_deployment_status_replicas_available{namespace="cloudflared",deployment="cloudflared"}'
values: '1x60'
promql_expr_test:
- expr: 'ALERTS{alertname="CloudflaredReplicasDegraded", alertstate="firing"}'
eval_time: 14m
exp_samples: []
- expr: 'ALERTS{alertname="CloudflaredReplicasDegraded", alertstate="firing"}'
eval_time: 17m
exp_samples:
- labels: 'ALERTS{alertname="CloudflaredReplicasDegraded", alertstate="firing", severity="warning"}'
value: 1
- expr: 'ALERTS{alertname="CloudflaredTunnelDown"}'
eval_time: 30m
exp_samples: []
- interval: 1m
name: kube-state-metrics series absent - ReplicasDegraded fires rather than going quiet
input_series:
- series: 'cloudflared_tunnel_ha_connections{namespace="cloudflared",pod="cloudflared-a"}'
values: '4x60'
promql_expr_test:
- expr: 'ALERTS{alertname="CloudflaredReplicasDegraded", alertstate="firing"}'
eval_time: 17m
exp_samples:
- labels: 'ALERTS{alertname="CloudflaredReplicasDegraded", alertstate="firing", severity="warning"}'
value: 1
+64
View File
@@ -0,0 +1,64 @@
---
# Alert rules for the Cloudflare Tunnel connector.
#
# THE DESIGN CONSTRAINT: NEITHER RULE MAY GO QUIET WHEN ITS OWN DATA DISAPPEARS. The repo has
# shipped self-silencing rules before (a bare `up{}` that clears exactly when scraping breaks), so:
#
# * CloudflaredTunnelDown reads `cloudflared_tunnel_ha_connections` — the per-replica gauge of
# live edge connections — and falls back to `vector(0)` when NO such series exists. A dead
# tunnel, zero pods, a broken PodMonitor, or a NetworkPolicy that drops the scrape all make the
# series absent, and all of them FIRE rather than read as healthy.
# * CloudflaredReplicasDegraded reads kube-state-metrics, which does not depend on cloudflared
# being scrapable at all, and has the same `vector(0)` fallback.
#
# Both directions are pinned by the companion alerts.promtool-test.yaml (healthy is quiet; absent
# series fires; working-then-broken re-fires).
#
# NOTE FOR PHASE 1: until the operator completes runbooks/cloudflare-tunnel.md steps 1-5 the
# Deployment cannot connect and CloudflaredTunnelDown fires. That is the honest state — the tunnel
# is not serving — and nothing routes through it yet, which is why both rules are `warning`.
# Raise TunnelDown to `critical` in the change that points public DNS at the tunnel.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cloudflared
namespace: cloudflared
labels:
app.kubernetes.io/name: cloudflared
spec:
groups:
- name: platform-health.cloudflare-tunnel
rules:
- alert: CloudflaredTunnelDown
# Sum of live edge connections across every replica. `or on() vector(0)` turns an EMPTY
# result (nothing scraped) into 0 so it fires instead of evaluating to no series.
expr: (sum(cloudflared_tunnel_ha_connections{namespace="cloudflared"}) or on() vector(0)) < 1
# 10m rides out a rolling update and a Cloudflare edge reconnect, not a real outage.
for: 10m
labels:
severity: warning
annotations:
summary: Cloudflare Tunnel has no healthy edge connections
description: >-
No cloudflared replica reports a live connection to the Cloudflare edge (or none is
being scraped at all). Every hostname routed through the tunnel is unreachable from
the internet. Before the operator has enrolled the tunnel this is expected.
runbook_url: "https://${BASE_DOMAIN}/runbooks/cloudflare-tunnel"
- alert: CloudflaredReplicasDegraded
# Fewer than the 2 replicas deployment.yaml asks for are Available — the tunnel has lost
# its HA, and the next node drain or reboot takes it down. Keep `2` in step with
# spec.replicas. Absent series (kube-state-metrics gone, Deployment deleted) -> 0 -> fires.
expr: >-
(max(kube_deployment_status_replicas_available{namespace="cloudflared",deployment="cloudflared"})
or on() vector(0)) < 2
for: 15m
labels:
severity: warning
annotations:
summary: cloudflared has fewer than 2 available replicas
description: >-
The cloudflared Deployment has fewer than two Available replicas, so the tunnel has
no redundancy (or is down entirely). Check for unenrolled credentials, a node that
cannot schedule the hard-spread pod, or an image pull failure.
runbook_url: "https://${BASE_DOMAIN}/runbooks/cloudflare-tunnel"
@@ -0,0 +1,32 @@
# Cloudflare Tunnel credentials for platform/cloudflared — SOPS-ENCRYPTED (R-SEC-01, spec docs/07).
# Keys stay cleartext for reviewable diffs; only `stringData` values are encrypted (.sops.yaml
# `encrypted_regex`). Plaintext template + operator hand-off: cloudflared-credentials.sops.yaml.example.
# Shipped with REPLACE-ME placeholders; runbooks/cloudflare-tunnel.md step 4 fills it.
apiVersion: v1
kind: Secret
metadata:
name: cloudflared-credentials
namespace: cloudflared
type: Opaque
stringData:
credentials.json: ENC[AES256_GCM,data:PM5pgIW/JEUMLuhApkPhgMOkJCL0nrlgLBb3tG+pHdm8JWdNK2yYCZUWM3USY7Uy2IYphZh35RwPOrWSUCyBlSq+TeEKBkzTTgJhp4gqaQ==,iv:Sr0Z6d9LmHvGN5oebIf+YBbFyLIdNOi80d6WwOF3PtU=,tag:Enb5/9v7H1nu2kvh4zEjXg==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUanIxelFEUDZtWU5CMHd5
VHl5MkJnM1JDUWhkM2daY0dtRUlFUmNUMDJrClM5UFBsdjh2Q0NIWmxpM1djYXUy
OFlZYVdBbURLanRRNzV1RVNmbkxWR28KLS0tIFE3d1Z1MjFZMTlaTFFIWm5ndVpN
emhHaFpEZWpMN0JXL2NVaW5PU1ZwVW8KkCDX7ED039X12BCmiSqHr0fmZKSZmfjP
DIiR6DvWWw1TcB3CjD5q29nIthCDf9zlrpuMfQ+9ODAlRr5t6zP+cg==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-11-08T18:28:24Z"
mac: ENC[AES256_GCM,data:RGiftdP4Bd5Qwyfx/pXEyZTBzTvr5Tvc7k0foEzTQGiSg6p0meurwtmCe/gMjKUZLhwf/0xqi1DtPQGS6UY8m8YFq1v1nXtohUQWLPXgTukMT915tHmohZeG91wYdyG9kA5E03udn+QNdOiI8OtjKoqAtifBoymCYd/EDOzWLQU=,iv:0vDIoDJ4QGECq7YjUPIlsU1j/AHfr1W32/+RjEOfpWo=,tag:BWT7nxiUxkVhEDh0JiE1Eg==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
@@ -0,0 +1,34 @@
# =============================================================================
# TEMPLATE — NOT APPLIED, NOT ENCRYPTED (.yaml.example is ignored by kustomize and never
# auto-encrypted by SOPS). Reference scaffold + OPERATOR HAND-OFF.
#
# WHAT THIS IS: the credentials JSON of the locally-managed Cloudflare Tunnel that
# platform/cloudflared runs. `cloudflared tunnel create` writes it; it holds the account tag, the
# tunnel UUID and the tunnel SECRET. Anyone holding it can run a connector for this tunnel and
# receive its traffic, so it is a real credential: SOPS-encrypted in git, never printed.
#
# *** THE REAL FILE IS COMMITTED WITH REPLACE-ME PLACEHOLDERS, ALREADY ENCRYPTED. ***
# So the wiring is complete before the tunnel exists: the resource is listed in kustomization.yaml
# and both overlays already carry the `cloudflared` decryption patch. Until you fill it, the
# Deployment CrashLoops inside its own NON-GATING Flux Kustomization (clusters/base/cloudflared.yaml,
# wait:false) and CloudflaredTunnelDown fires. Nothing else in the cluster is affected.
#
# HAND-OFF (operator — an agent must not mint a long-lived credential, CLAUDE.md §6).
# The full procedure, with verification and rollback, is runbooks/cloudflare-tunnel.md. In short:
# 1. `cloudflared tunnel login` + `cloudflared tunnel create --credentials-file ... home-k3s`
# (pinned cloudflared image under podman; the toolbox does not ship cloudflared).
# 2. Write the JSON STRAIGHT INTO the encrypted file with `sops set`, inside the toolbox — the
# plaintext never reaches a terminal or the repo tree.
# 3. Put the tunnel UUID (not secret) into platform/cloudflared/config.yaml `tunnel:`.
# 4. Verify without printing (`sops -d --extract ... | jq -e ...`), then PR it.
# =============================================================================
apiVersion: v1
kind: Secret
metadata:
name: cloudflared-credentials
namespace: cloudflared
type: Opaque
stringData:
# One line of JSON, exactly as `cloudflared tunnel create` wrote it (compacted with `jq -c`).
# TunnelID MUST equal the `tunnel:` UUID in config.yaml.
credentials.json: '{"AccountTag":"REPLACE-ME","TunnelSecret":"REPLACE-ME","TunnelID":"REPLACE-ME"}'
+84
View File
@@ -0,0 +1,84 @@
---
# 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
+140
View File
@@ -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
+35
View File
@@ -0,0 +1,35 @@
---
# Cloudflare Tunnel connector. Reconciled by its OWN non-gating Flux Kustomization
# (clusters/base/cloudflared.yaml: dependsOn platform-config, wait:false) — NOT a line in
# platform/kustomization.yaml. See that file for why; in one line: this Deployment cannot become
# Ready until the operator enrols the tunnel, and in a gating tier that would stall every app wave.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# REQUIRED for the configMapGenerator: a generated ConfigMap inherits no namespace, and kustomize
# will not rewrite the Deployment's reference to its hashed name across a namespace boundary
# (measured on platform/vpn-ingress).
namespace: cloudflared
resources:
- namespace.yaml
- deployment.yaml # Deployment (2 replicas, hard node spread) + PodDisruptionBudget
# Namespace default-deny + the Prometheus scrape allow + the egress CiliumNetworkPolicy. Keep
# them in ONE file: a baseline without its scrape allow silently empties the TSDB.
- networkpolicy.yaml
- podmonitor.yaml
- alerts.yaml
# The tunnel credentials JSON — the ONE secret here. SOPS-encrypted, decrypted per-env by the
# `cloudflared` Flux Kustomization (both overlays patch it), referenced by name only. Its
# plaintext template sibling is deliberately NOT named in this file (no-plaintext-secrets).
# Ships with REPLACE-ME placeholders; runbooks/cloudflare-tunnel.md fills it.
- cloudflared-credentials.sops.yaml
# config.yaml becomes the CONTENT-HASHED ConfigMap `cloudflared-config`, so an edit to the ingress
# rules or the tunnel UUID changes the pod template and rolls the Deployment. Do NOT set
# disableNameSuffixHash: the hash is the rollout mechanism.
configMapGenerator:
- name: cloudflared-config
files:
- config.yaml
options:
labels:
app.kubernetes.io/name: cloudflared
+18
View File
@@ -0,0 +1,18 @@
---
# Cloudflare Tunnel connector (north-south INGRESS from the Cloudflare edge). See README.md.
#
# NOT homelab.keithjackson.dev/app-namespace=true. This is platform network infrastructure (like
# vpn-ingress / vpn-egress), not an application, so Kyverno's app-scoped policies — including the
# `require-default-deny-baseline` GENERATE rule — do not select this namespace. That is why
# networkpolicy.yaml ships its own `default-deny` here, and why doing so creates NO second owner
# of that object: Kyverno never generates one in a namespace without the app-namespace label
# (the drift loop recorded in the 2026-09-30 handoff only exists where both write the same name).
#
# NO `gateway-access` label. Nothing here attaches an HTTPRoute to the shared Gateway; cloudflared
# is a CLIENT of the Gateway, not a backend behind it.
apiVersion: v1
kind: Namespace
metadata:
name: cloudflared
labels:
app.kubernetes.io/name: cloudflared
+149
View File
@@ -0,0 +1,149 @@
---
# Namespace baseline: deny everything both ways, except DNS to kube-system.
#
# SINGLE OWNER. This namespace is not labelled app-namespace=true, so Kyverno's
# require-default-deny-baseline generate rule does not select it and never writes this object;
# Flux is its only writer (the same posture as kured / crafty / headlamp / homepage). Do NOT add
# the app-namespace label to namespace.yaml without deleting this object in the same change, or
# the two controllers fight over it every reconcile (the 2026-09-30 drift-loop root cause).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: cloudflared
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# The ONLY ingress: Prometheus scraping :2000. Shipped in the same file as the baseline on purpose
# (the Longhorn/kured lesson): a deny-all without this allow leaves the PodMonitor's targets
# DISCOVERED and every scrape timing out, which is the state the CloudflaredTunnelDown rule is
# written to fire on rather than go quiet in.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: cloudflared-allow-prometheus-scrape
namespace: cloudflared
labels:
app.kubernetes.io/name: cloudflared
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: cloudflared
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- protocol: TCP
port: 2000
---
# Egress: the Cloudflare edge, the shared Gateway, and the Gateway's backends — nothing else.
#
# 1. world tcp+udp/7844 — the tunnel itself (QUIC on udp, HTTP/2 fallback on tcp).
# world tcp/443 — Cloudflare's API / update endpoints that cloudflared calls at start.
# Expressed as `world` + port rather than Cloudflare CIDRs: the edge ranges are Cloudflare's to
# change, and a stale CIDR list would fail as a silent outage. Narrowing to toCIDR is a
# possible later hardening; it is not done blind.
#
# 2. `ingress` entity tcp/443 — the shared Gateway's Envoy. Traffic to the
# cilium-gateway-shared Service is handed to Envoy, whose identity is reserved:ingress.
#
# 3. The Gateway's BACKENDS, by namespace. NOT optional: Cilium >= 1.16.8 (CVE-2025-30162; this
# cluster runs 1.20.x) makes the Gateway's Envoy re-evaluate THIS pod's egress policy against
# the identity of the backend it routes the request to. With only rule 2, Envoy accepts the TCP
# connection and then answers every request `403 Access denied` — measured for buildkit ->
# gitea on 2026-10-30, see platform/buildkit/networkpolicy.yaml. So the backends are named:
# * any namespace opted in to the shared Gateway (gateway-access=shared) — which is the set
# of namespaces that can serve a route at all, so this tracks new apps automatically;
# * MINUS the LAN/VPN-only services (operator decisions 2026-11-08): plex, jackett, pihole,
# semaphore, qbittorrent, radarr, sonarr, headlamp. config.yaml already 404s their
# hostnames; this is the second, independent layer: even a request that got past the
# ingress rules would be refused by Envoy for these backends.
# unifi and crafty-mc are not here because they are L4 LoadBalancer VIPs, not Gateway
# backends; the crafty WEB UI (crafty.<domain>, an HTTPRoute) is deliberately reachable.
#
# WHY NOT the `- {}` allow-all egress gitea-actions uses (apps/wave-1/gitea-actions.yaml)?
# It would satisfy the L7 re-check just as well, but `{}` admits EVERY identity, including
# kube-apiserver and host — which operator decision 4 (the Kubernetes API is never reachable
# through the tunnel) rules out for the one pod that carries internet traffic in. The
# namespace-label selector is the narrowest rule that still names every possible backend.
# Verified 2026-11-08: Cilium identities carry `k8s:io.cilium.k8s.namespace.labels.
# gateway-access`, and every namespace with an HTTPRoute on the shared Gateway is labelled
# gateway-access=shared.
#
# BACKENDS NEED NO INGRESS CHANGE. Envoy forwards to a backend as reserved:ingress, which every
# backend already admits; the source-identity check happens on THIS pod's egress, not on the
# destination's ingress. Measured 2026-10-30 (decisions/2026-10-30-gitea-admit-buildkit.md):
# gitea-actions is in none of Gitea's ingress allows and its requests through the Gateway reach
# Gitea, while Gitea's endpoint shows only a `reserved:ingress` allow row. So, unlike a
# ClusterIP client, cloudflared is not added to any app's inClusterClients. That also means
# this rule does not open a direct cloudflared -> app pod path into any app namespace whose
# ingress admits only reserved:ingress and named peers.
#
# DELIBERATELY ABSENT: kube-apiserver, host, remote-node, cluster, all. The Kubernetes API stays
# local-only (operator decision 2026-11-08); conformance/tests/l0-static/cloudflared-ingress-rules
# fails the gate if any of those entities appears here.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: cloudflared-allow-egress
namespace: cloudflared
labels:
app.kubernetes.io/name: cloudflared
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: cloudflared
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "7844"
protocol: TCP
- port: "7844"
protocol: UDP
- port: "443"
protocol: TCP
- toEntities:
- ingress
toPorts:
- ports:
- port: "443"
protocol: TCP
- toEndpoints:
- matchExpressions:
- key: io.cilium.k8s.namespace.labels.gateway-access
operator: In
values:
- shared
- key: io.kubernetes.pod.namespace
operator: NotIn
values:
- plex
- jackett
- pihole
- semaphore
- qbittorrent
- radarr
- sonarr
- headlamp
+22
View File
@@ -0,0 +1,22 @@
---
# Scrapes cloudflared's own metrics server (:2000, `metrics:` in config.yaml) on every replica.
# kube-prometheus-stack runs with podMonitorSelectorNilUsesHelmValues: false, so Prometheus selects
# every PodMonitor in every namespace; the `release` label is kept for consistency with the
# platform's other monitors. The ingress allow that lets this scrape through the namespace's
# default-deny is cloudflared-allow-prometheus-scrape in networkpolicy.yaml.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: cloudflared
namespace: cloudflared
labels:
app.kubernetes.io/name: cloudflared
release: kube-prometheus-stack
spec:
selector:
matchLabels:
app.kubernetes.io/name: cloudflared
podMetricsEndpoints:
- port: metrics
path: /metrics
interval: 30s