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]>
140 lines
9.8 KiB
Markdown
140 lines
9.8 KiB
Markdown
---
|
|
date: 2026-11-08
|
|
branch: feat/cloudflare-tunnel
|
|
title: Cloudflare Tunnel connector, Phase 1 — the component only, isolated, with zero change to live traffic
|
|
---
|
|
|
|
## What changed
|
|
|
|
Ingress is moving to a Cloudflare Tunnel because the GFiber router cannot hairpin and hands LAN
|
|
clients Google's IPv6 DNS, so split DNS keeps breaking. This is **Phase 1: the connector only.**
|
|
external-dns, the Gateway annotations, Pi-hole and CoreDNS are untouched. A later change, by a
|
|
different agent, moves DNS.
|
|
|
|
**Operator decisions (2026-11-08), recorded here as the source for the guard:**
|
|
|
|
1. Public hostnames stay `*.home.keithjackson.dev` and the apex `home.keithjackson.dev`. The
|
|
operator enables Cloudflare ACM (ordered edge cert or Total TLS) for edge certificates.
|
|
2. **Plex stays OFF the tunnel.** No plex hostname goes through cloudflared (`plex`, `plex-vip` → 404).
|
|
3. **jackett, pihole and semaphore stay LAN/VPN-only**, never via the tunnel. The same goes for the
|
|
non-HTTP L4 VIPs **unifi** and **crafty-mc**. After the PM's read-only audit, the other three
|
|
catalog `exposure: internal` apps, **qbittorrent, radarr and sonarr**, are on the same list. All
|
|
six carry the external-dns `controller: none` route annotation live.
|
|
4. **The Kubernetes API stays local-only.** It must never be reachable through the tunnel.
|
|
5. Pi-hole keeps serving the LAN. Split DNS gets trimmed later, by another agent.
|
|
6. **Headlamp is LAN/VPN-only** (operator decision, relayed by the PM later the same day). It is
|
|
permanently in the 404 list and excluded from the egress backends. Its catalog/route exposure is
|
|
deliberately **not** changed here; the Phase 2 DNS-flip PR does that.
|
|
|
|
**Files:**
|
|
|
|
- `platform/cloudflared/` (new):
|
|
- `config.yaml` is the locally-managed tunnel config, projected through a content-hashed
|
|
`configMapGenerator`, so edits roll the pods. It has explicit `http_status:404` rules for
|
|
plex, plex-vip, jackett, pihole, pihole-dns, semaphore, qbittorrent, radarr, sonarr, headlamp,
|
|
unifi and crafty-mc. Then the apex and `*.${BASE_DOMAIN}` go to
|
|
`https://cilium-gateway-shared.gateway.svc.cluster.local:443` with `matchSNItoHost: true`. Last
|
|
comes a catch-all 404. The tunnel UUID is a **literal** in this file (`REPLACE-ME-TUNNEL-UUID`
|
|
until enrolment), not a cluster-var, so the Phase 2 PR can match it without touching `clusters/**`.
|
|
- `cloudflared-credentials.sops.yaml` is SOPS-encrypted and holds `REPLACE-ME` placeholders, with
|
|
a `.example` hand-off sibling. I encrypted the placeholder (not a credential) in the toolbox
|
|
with the root `.sops.yaml` recipient.
|
|
- `deployment.yaml` runs 2 replicas with a hard `topologySpreadConstraints` across nodes. The image
|
|
is `cloudflare/cloudflared:2026.10.7@sha256:4955524a…` (tag + digest, Renovate `kubernetes`
|
|
manager). It runs as non-root 65532, with a read-only root fs, all capabilities dropped and no
|
|
SA token. Requests are 10m/64Mi and limits 500m/256Mi. Readiness uses `/ready`. Liveness uses
|
|
`/healthcheck`, so an edge outage cannot restart-loop the pods. The pods are labelled
|
|
`egress-class: internet`. A PDB sets `minAvailable: 1`.
|
|
- `networkpolicy.yaml`:
|
|
- A namespace `default-deny` with DNS only. It has a **single owner**: the namespace is not
|
|
`app-namespace=true`, so Kyverno's generate rule never writes it.
|
|
- A Prometheus scrape allow on :2000.
|
|
- The egress CNP allows world tcp/udp 7844 and tcp 443, plus the `ingress` entity on tcp 443.
|
|
It also allows **Gateway backends by `io.cilium.k8s.namespace.labels.gateway-access=shared`,
|
|
minus the LAN-only namespaces.**
|
|
- `servicemonitor.yaml` (headless metrics Service + ServiceMonitor) and `alerts.yaml` (`CloudflaredTunnelDown`, `CloudflaredReplicasDegraded`)
|
|
ship with `alerts.promtool-test.yaml`, and `README.md` documents the component.
|
|
- `clusters/base/cloudflared.yaml` (new) is its own Flux Kustomization: `dependsOn: platform-config`,
|
|
**`wait: false`**, prune, and postBuild `cluster-vars`. It is listed in `clusters/base/kustomization.yaml`.
|
|
- `clusters/{dev,prod}/kustomization.yaml` each gain a SOPS `decryption` patch for `cloudflared`.
|
|
- `conformance/tests/l0-static/cloudflared-ingress-rules/` (new, whole-repo scope) asserts several
|
|
things:
|
|
- Every rule targets either 404 or exactly the Gateway Service. The Service name is derived from
|
|
`platform/gateway/gateway.yaml`, and API markers are named explicitly.
|
|
- The excluded hosts 404 before the first Gateway rule, the Gateway rules use `matchSNItoHost`,
|
|
there is no `noTLSVerify`, and a catch-all closes the list.
|
|
- The CNP has no kube-apiserver, host, remote-node, cluster or `all` entity, no CIDR, and the full
|
|
NotIn list. The pods carry egress-class internet and have no SA token.
|
|
- The Flux tier is isolated: wait:false, not in `platform/kustomization.yaml`, nothing
|
|
`dependsOn: cloudflared`, and both overlays decrypt it.
|
|
- **Mutation self-test:** an added API rule, plex moved below the wildcard, and a dropped
|
|
catch-all must each be flagged.
|
|
- `conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh` gains an additive
|
|
`cloudflared_` scrape owner, with a networkpolicy companion, in the same shape as the kured entry.
|
|
- `runbooks/cloudflare-tunnel.md` (new) is registered in `runbooks/kustomization.yaml` and the
|
|
README index.
|
|
|
|
**Design choices:**
|
|
|
|
- **Isolation (the merge-before-credentials question).** The PR is safe to merge with placeholders.
|
|
The Deployment CrashLoops, but in its own `wait:false` Kustomization that nothing depends on. Its
|
|
Ready condition tracks the apply, not pod health, so neither `platform`, `platform-config` nor any
|
|
apps wave can stall. There is no Certificate or HelmRelease in the path for health checks to hang
|
|
on. Health is reported by the two alerts instead. `CloudflaredTunnelDown` **will fire from merge
|
|
until enrolment**. That is honest, and it is `warning` because nothing routes through the tunnel
|
|
yet. Raise it to critical in the DNS-flip change.
|
|
- **Egress to backends, not `{}`.** Under Cilium 1.20 (CVE-2025-30162), the Gateway's Envoy
|
|
re-checks the source pod's egress against the resolved backend. An egress rule that names only
|
|
the Gateway would therefore make every tunnelled request `403 Access denied`
|
|
(decisions/2026-10-30-gitea-admit-buildkit.md; catalog/render.py, `r_internet_egress`). The
|
|
gitea-actions `- {}` pattern would also work, but it admits kube-apiserver and host, which
|
|
decision 4 rules out. The namespace-label selector names every Gateway backend and nothing else.
|
|
**Backends need no ingress change.** Envoy reaches them as `reserved:ingress`, and the 2026-10-30
|
|
measurement showed the identity check sits on the source's egress: gitea-actions is in none of
|
|
Gitea's ingress allows and still reaches it. So no app's `inClusterClients` was touched.
|
|
- **The alerts cannot self-silence.** Both expressions use `or on() vector(0)`, so absent series
|
|
(a broken scrape, zero pods, KSM gone) fire. The promtool tests pin the absent case and the
|
|
working-then-stale re-fire case.
|
|
|
|
## What I verified
|
|
|
|
- **The Gateway Service, measured live (read-only, `k3s-prod`, 2026-11-08).** It is
|
|
`gateway/cilium-gateway-shared`: type LoadBalancer, ClusterIP `10.43.225.79`, EXTERNAL-IP
|
|
`192.168.2.20`, ports 80/443, no selector, labelled `gateway.networking.k8s.io/gateway-name=shared`.
|
|
It belongs to Gateway `gateway/shared` (class `cilium`).
|
|
- **The namespace selector, measured live.** Cilium identities carry the label
|
|
`k8s:io.cilium.k8s.namespace.labels.gateway-access` (read from a gitea identity). Every namespace
|
|
with an HTTPRoute (crafty, gitea, headlamp, homepage, jackett, keycloak, monitoring, nextcloud,
|
|
oauth2-proxy, pihole, plex, qbittorrent, radarr, runbooks, seerr, semaphore, sonarr) is
|
|
`gateway-access=shared`.
|
|
- **Nothing exists yet.** Namespace `cloudflared` does not exist on prod. **cloudflared is not in
|
|
the pinned toolbox**, so the runbook runs the pinned image under podman instead.
|
|
- The image tag and digest came from Docker Hub's API: `2026.10.7` = `latest` =
|
|
`sha256:4955524a…c9b9c5ba`, published 2026-10-30.
|
|
- The credentials Secret is encrypted: `stringData.credentials.json` is `ENC[…]`, with the age
|
|
recipient from the root `.sops.yaml`.
|
|
- The new L0 check passes on the host (47 assertions, mutation self-test included). The toolbox
|
|
gate result is reported in the PR description. I ran it after committing.
|
|
|
|
## What I did NOT verify
|
|
|
|
- **UNVERIFIED: nothing ran in a cluster.** No pod has started, and no tunnel exists. I have not
|
|
observed placeholder CrashLoop behaviour itself. I expect cloudflared to treat
|
|
`REPLACE-ME-TUNNEL-UUID` as a tunnel name, find no origin cert, and exit, but whatever it does,
|
|
the pods are not Ready and the tier is non-gating.
|
|
- **UNVERIFIED: the CVE-2025-30162 path for cloudflared specifically.** The selector form was
|
|
checked against live identity labels, but no tunnelled request has been made. The runbook's
|
|
step 6 `kubectl debug` probe exists to prove "real backend, no 403" from the pod's own identity.
|
|
- **UNVERIFIED: whether the `ingress` entity rule's `toPorts: 443` restriction holds at Envoy.**
|
|
buildkit's working rule has no port restriction. If the canary shows drops, removing the port
|
|
restriction there is the first thing to try.
|
|
- The metric name `cloudflared_tunnel_ha_connections` and the `/ready` / `/healthcheck` endpoints
|
|
come from my knowledge of cloudflared, **not measured** against 2026.10.7.
|
|
- The Cloudflare edge egress is `world` + ports, not Cloudflare CIDRs. I did not narrow it blind.
|
|
- Egress `world:443` "for the API" follows the brief. I have not confirmed that a credentials-file
|
|
tunnel needs it at all.
|
|
- The Cloudflare-side settings (ACM, Total TLS / ordered cert, Bot Fight Mode, BIC/WAF, WebSockets)
|
|
are documented for the operator but not inspected. I have no Cloudflare access.
|
|
- The headlamp, qbittorrent, radarr and sonarr catalog exposures are deliberately unchanged
|
|
(Phase 2).
|