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,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
|
||||
@@ -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"}'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user