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]>
150 lines
6.4 KiB
YAML
150 lines
6.4 KiB
YAML
---
|
|
# 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
|