Cilium Post-Migration Hardening — Design
Date: 2026-05-12 Author: Sean Brandt (with Claude Opus 4.7) Status: Draft — pending implementation plan Predecessor: 2026-05-10-calico-to-cilium-design.md
Context
Section titled “Context”Calico → Cilium migration completed 2026-05-12 (PR4 #1018 production cutover; PR5 #1019 legacy
Calico NetworkPolicy sweep). All 8 nodes are Cilium-managed; policyEnforcementMode=default is
live; the cross-CNI NetworkPolicy gap that motivated PRs #1015/#1016 is moot.
Three loose-end concerns surfaced post-cutover:
- The
cf-ssh-bastionworkload — Cloudflared-tunneled SSH-to-nodes — is vestigial; the Cloudflared path it relied on has been retired. - NetworkPolicy coverage is extremely sparse. After bastion teardown, the cluster will have zero
CiliumNetworkPolicies. With
policyEnforcementMode=default, every namespace is allow-all. - Hubble UI is deployed (
hubble-uiClusterIP svc inkube-system) but not exposed. Operationally it is reachable only via port-forward.
- Delete the bastion cleanly with no orphaned resources.
- Adopt a “crown-jewels” NetworkPolicy posture: default-deny on namespaces that hold credentials, identity state, or persistent data. Leave application namespaces open.
- Bake label-keyed identity into all new CiliumNetworkPolicies (Strategy A from brainstorming).
Avoid bare
endpointSelector: {}so future pods landing in a crown-jewel namespace do not silently inherit privileges. - Expose Hubble UI on
hubble.fzymgc.housebehind Authentik forward-auth. - Use Hubble flow observation as the empirical verification ritual for each policy PR.
Non-Goals
Section titled “Non-Goals”- Cilium Mutual Authentication (SPIFFE/SPIRE). Tracked as a follow-up bead; out of scope for this initiative.
- Application-namespace NetworkPolicy coverage beyond the crown jewels.
- Migration of cert-manager Vault routing from
vault-internaltovault-active. Surfaced during this audit; tracked as a separate parallel bead because it’s independent of the NP work and shares the routing-anti-pattern with ESO (memory:cilium-pr3-complete).
Architecture
Section titled “Architecture”Sequencing
Section titled “Sequencing”Five PRs land in order. Each PR is independently revertable.
| PR | Title | Risk | Soak |
|---|---|---|---|
| PR1 | Remove cf-ssh-bastion + tunnel route | Low | Immediate |
| PR2 | Expose Hubble UI on hubble.fzymgc.house | Low | Immediate |
| PR3 | Enable Cilium L7 DNS proxy (prerequisite for toFQDNs egress rules) | Medium | 24h |
| PR4 | Crown-jewel CNPs: dolt → cert-manager → external-secrets | Medium | 24h per ns |
| PR5 | Crown-jewel CNPs: cnpg-system → authentik → vault | High | 48h on vault |
PR3 is a prerequisite to PR4/PR5 because cert-manager’s egress to Let’s Encrypt and the Cloudflare
API will be expressed as toFQDNs rules.
PR4 and PR5 split the crown-jewel rollout by coupling complexity: dolt/cert-manager/ESO have narrower traffic surface; cnpg/authentik/vault have HA-internal flows (raft, replication) that demand more care.
Components
Section titled “Components”PR1: Bastion Teardown
Section titled “PR1: Bastion Teardown”Repo changes:
- Delete
argocd/app-configs/cf-ssh-bastion/(entire directory). - Delete
argocd/cluster-app/templates/cf-ssh-bastion.yaml. This Application carriesmetadata.finalizers: [resources-finalizer.argocd.argoproj.io], so deletion cascades to all child resources including the namespace. - Audit Cloudflared tunnel config (location TBD — likely
argocd/app-configs/cloudflared/or a Cloudflare-side Terraform module undertf/) and remove the ingress route:2222 → cf-ssh-bastion.cf-ssh-bastion.svc. If a Cloudflare DNS record for the SSH host is managed via Terraform, remove it too. - The Vault path that backed the bastion ExternalSecret (host keys, SSH config) — leave intact for now; tracked in PR1 verification step as a “document as orphaned, defer removal” item.
Verification:
kubectl get application cf-ssh-bastion -n argocd→ NotFound.kubectl get ns cf-ssh-bastion→ NotFound.kubectl get pod,cnp -A | grep -i bastionreturns no rows.- Cloudflared tunnel reload succeeded; no
:2222route remains.
Bead hygiene: close hl-7h0 (PR4 Calico decommission) as stale — its work landed in #1018/#1019.
PR2: Hubble UI on hubble.fzymgc.house
Section titled “PR2: Hubble UI on hubble.fzymgc.house”New argocd/app-configs/hubble-ui/ directory:
namespace.yaml— newhubblenamespace.certificate.yaml— cert-manager Certificate (secretName: hubble-tls,issuerRef: vault-issuerClusterIssuer,dnsNames: [hubble.fzymgc.house]). Matches longhorn-dashboard pattern.traefik-middleware.yaml— TraefikMiddleware/modern-authinhubblenamespace.forwardAuthtohttp://authentik-server.authentik.svc/outpost.goauthentik.io/auth/traefikwith the longhorn/traefik-dashboard header allowlist (Remote-User,Remote-Groups,Remote-Email,Remote-Name, plusX-authentik-*).ingress-route.yaml— TraefikIngressRouteinhubblenamespace. Hosthubble.fzymgc.house, middleware ref to localmodern-auth, service ref cross-namespace toname: hubble-ui, namespace: kube-system, port: 80. TLSsecretName: hubble-tls. Carriesrouter-hosts.fzymgc.house/enabled: "true"annotation.kustomization.yaml— bundle.
New argocd/cluster-app/templates/hubble-ui.yaml:
- ArgoCD
Applicationfor the directory above. MUST includemetadata.finalizers: [resources-finalizer.argocd.argoproj.io]so future teardown cascades cleanly (memory:argocd-application-deletion). - This PR ships no ExternalSecret, so the standard
ignoreDifferencesblock for ESO remote-ref drift is not required here. Add it later only if/when an ExternalSecret is introduced (memory:argocd-app-externalsecret-ignoredifferences).
Terraform changes (tf/authentik/):
- New
authentik_provider_proxyresource forhubble.fzymgc.house(forward-auth domain-level mode). - New
authentik_applicationbinding the provider. - Group restriction: align with longhorn-dashboard’s Authentik app (probably an admin/operator group). Confirm exact group at implementation time.
- Outpost assignment: add to the existing embedded outpost.
Verification:
- DNS:
dig hubble.fzymgc.houseresolves via Traefik (therouter-hostscontroller propagates the host). - TLS: certificate
hubble-tlsreaches Ready in Hubble namespace. - Anonymous request:
curl -sI https://hubble.fzymgc.house/returns 302 to authentik login. - Authenticated browser request: Hubble UI loads with live flow data; UI can switch namespaces.
PR3: Cilium L7 DNS Proxy Enablement
Section titled “PR3: Cilium L7 DNS Proxy Enablement”Prerequisite for PR4/PR5 cert-manager toFQDNs rules.
Repo changes (ansible/roles/cilium/templates/values.yaml.j2):
- Confirm/enable
enableL7Proxy: true(likely already on by default in 1.19.x but verify). - Enable DNS visibility / capture via the appropriate chart value. Specific value to be confirmed
against Cilium 1.19.x chart docs at spec-implementation time; candidates are
dnsPolicy.enabled/proxy.dns.enable/ a permissiveCiliumNetworkPolicywithtoFQDNs: ["*"]andtoPorts: dns-rules.
Apply: re-run ansible/roles/cilium against control-plane to helm upgrade. Watch
cilium-agent pod restart-rolls per node.
Verification:
hubble observe --to-fqdn '*' --from-namespace kube-system --last 100shows FQDN labels on flows (today, this query returns nothing — that’s the symptom).- No application-level regression: ESO refreshes succeed, traefik continues to route correctly.
PR4: Crown-Jewel CNPs (dolt, cert-manager, external-secrets)
Section titled “PR4: Crown-Jewel CNPs (dolt, cert-manager, external-secrets)”Per-namespace pattern. Each namespace gets a small set of CNPs (default-deny + explicit
allows). Selectors use app.kubernetes.io/name and app.kubernetes.io/component labels — never
bare endpointSelector: {}, never namespaceSelector alone.
dolt CNPs:
default-deny:endpointSelector: {matchLabels: {app.kubernetes.io/name: dolt}}, no ingress/egress.- Ingress: from
traefiknamespace pods to SQL port (3306) and remote API (50051). Match byapp.kubernetes.io/name: traefik. - Ingress: from
monitoringnamespace Prometheus to dolt metrics (port 9418, per memory:dolt-deployment). - Egress: kube-dns (UDP/TCP 53 to
k8s-app=kube-dnsinkube-system). - Egress: kube-apiserver entity.
cert-manager CNPs:
default-denyfor each cert-manager component (app.kubernetes.io/name: cert-managerandcomponent: controller|webhook|cainjector).- Ingress: Prometheus →
:9402on all three components. - Ingress: kube-apiserver → cert-manager-webhook for admission webhook.
- Egress: kube-dns, kube-apiserver entity.
- Egress:
vault-internal.vault.svc:8200— match by labelapp.kubernetes.io/name: vault,component: serverin namespacevault. (NB: cert-manager today points atvault-internalheadless service; the routing-fix bead may move this tovault-activebefore this NP lands. If so, no policy change needed since both resolve to the same Vault pods.) - Egress (toFQDNs, PR3-enabled):
acme-v02.api.letsencrypt.org:443,api.cloudflare.com:443, plus any subdomains LE redirects to.
external-secrets CNPs:
default-denyonapp.kubernetes.io/name: external-secrets,component: controller|webhook|cert-controller.- Ingress: Prometheus → controller metrics endpoint.
- Ingress: kube-apiserver → ESO webhook.
- Egress: kube-dns, kube-apiserver entity.
- Egress:
vault-active.vault.svc:8200— match by labelapp.kubernetes.io/name: vault,component: server(the namespace-and-label combo, not raw service name). Confirms intent matches the memory note about leader-only routing.
Per-policy verification (Hubble flow-driven ritual):
- Apply policy via ArgoCD.
- Wait for Cilium identity allocation.
- Trigger the workload’s outbound activity explicitly:
- dolt: run
bd push/pullfrom operator machine, query via traefik. - cert-manager: annotate a Certificate for force-renewal
(
cert-manager.io/issue-temporary-certificate). - external-secrets: annotate an ExternalSecret for force-refresh
(
force-sync: <timestamp>).
- dolt: run
kubectl exec -n kube-system ds/cilium -- hubble observe --namespace <ns> --last 200for 60–120s. Confirm:- No
DROPPEDflows for traffic the policy should permit. - All
FORWARDEDflows match expected egress destinations.
- No
- 24h soak with monitoring alerts watched. If clean, proceed to next namespace.
PR5: Crown-Jewel CNPs (cnpg-system, authentik, vault)
Section titled “PR5: Crown-Jewel CNPs (cnpg-system, authentik, vault)”These three have HA-internal flows that demand extra care.
cnpg-system CNPs:
- Operator and per-cluster pods need policy. Selectors include
cnpg.io/clusterlabel for postgres pods (operator labels areapp.kubernetes.io/name: cloudnative-pg). - Ingress: operator API from kube-apiserver (webhook), Prometheus → operator metrics.
- Ingress (per-postgres-cluster ns): traffic from owning app pods only, by label
(
postgresport 5432). - Egress: kube-dns, kube-apiserver, intra-namespace replication (port 5432 between pods sharing
cnpg.io/clusterlabel). - CRITICAL: validate streaming replication remains unbroken. Memory note
cnpg-cpu-formatrecalls how subtle config drift can break CNPG; replication is on the same risk surface here.
authentik CNPs:
default-denyonapp.kubernetes.io/name: authentik(separate forcomponent: server | worker | outpost).- Ingress: from
traefikto authentik-server port 9000. - Ingress (intra-namespace): outpost ↔ server.
- Egress: own postgres (
cnpg.io/cluster: authentikin same ns or external — confirm topology), redis if present (check chart values), kube-dns, kube-apiserver.
vault CNPs (last, most coupled):
default-denyonapp.kubernetes.io/name: vault,component: server.- Ingress: from
traefik(port 8200), fromexternal-secretscontrollers (port 8200), cluster-internal from agent-injector webhook callers (port 8443 on injector pod). Match by labels. - Ingress: from
cert-manager(port 8200). - Ingress: kube-apiserver → vault-agent-injector mutating webhook.
- Egress: kube-dns, kube-apiserver, k8s-API for SA TokenReview.
- CRITICAL — intra-namespace raft heartbeat (port 8201) between vault server pods. Memory
note
cilium-pr3-completeflags this as the highest-risk failure mode if missed. Allow rule:endpointSelector: app.kubernetes.io/name=vault, component=serverto itself on port 8201.
Verification ritual: same Hubble flow-driven pattern as PR4, plus:
- Authentik: log in via SSO, verify all downstream apps (longhorn, traefik dashboard) still forward-auth correctly. 48h soak before considering complete.
- Vault: validate ESO refresh cycle, cert-manager cert issuance, vault-active leader election (kill leader pod, confirm new leader within 30s).
Data Flow
Section titled “Data Flow”Per-PR flows are described in the per-PR sections above. Cross-PR data flow during rollout:
Operator (workstation) ↓ kubectl apply (via ArgoCD GitOps)ArgoCD Application Controller ↓ reconciles CiliumNetworkPolicy CRDsCilium Operator + cilium-agent (per node) ↓ allocates identity, programs eBPFWorkload pods (now bound by policy) ↓ trafficHubble (relay → CLI on workstation) ↓ flow observationOperator (verification feedback loop)Error Handling and Rollback
Section titled “Error Handling and Rollback”Per-PR rollback strategy:
- PR1: revert the ArgoCD
Applicationdeletion commit; namespace and resources recreate. - PR2: revert the new
hubble-uiApplication + directory; ArgoCD prunes. - PR3: revert ansible role change, re-run ansible to roll back Cilium chart values. Cilium agents restart-roll.
- PR4/PR5: each per-namespace CNP set is independently revertable. Roll back by deleting the
CNPs from the relevant
argocd/app-configs/<ns>/directory. Critical: revert applies immediately; traffic begins flowing again the moment the CNP is removed.
Failure modes to watch:
- Vault raft heartbeat dropped (PR5): symptom is split-brain or all-followers state, Vault
API returns HTTP 474. Per memory, read full
/v1/sys/healthJSON body — don’t trust the status code alone. - ESO refresh failures (PR4): ExternalSecret
SecretSyncedErrorevents. - Cert-manager renewal failures (PR4): Certificate
Ready: False, CertificateRequest events showing connection refused to vault or ACME. - Authentik forward-auth 502 (PR5): any Authentik-protected app returns 502 — likely an
ingress block on authentik-server. The traefik forward-auth is in the
authentiknamespace and is reached from many namespaces; verify the policy allows ingress from all currently-using namespaces.
Testing Strategy
Section titled “Testing Strategy”Per-PR functional tests:
- PR1: bastion namespace gone, no orphaned resources.
- PR2: anonymous → 302, authenticated → Hubble UI loads.
- PR3:
hubble observe --to-fqdn '*'shows FQDN labels. - PR4/PR5: Hubble flow-driven ritual described above. 24h soak per namespace (48h for vault).
Regression watch:
- Grafana dashboards for cluster traffic, error rates, certificate expiry, ESO sync success.
- ArgoCD app sync status: all apps remain
SyncedandHealthy.
Quality gates:
yamllint argocd/clean.helm template argocd/cluster-app --name-template cluster-app --namespace argocdrenders (memory:cluster-app-helm-template-trap).rumdl fmt docs/clean if touching docs.mkdocs buildwarning-free if touching docs (memory:mkdocs-nav-curated).
Documentation Pointers
Section titled “Documentation Pointers”New runbooks to add (each in its respective PR):
docs/operations/hubble-ui.md— how to access, how to read flows, common debugging recipes.docs/operations/cilium-network-policies.md— crown-jewel policy inventory, label conventions, verification ritual, rollback recipes.- Update
mkdocs.ymlnav:to list the new docs (memory:mkdocs-nav-curated).
Open Questions for Implementation Time
Section titled “Open Questions for Implementation Time”- Exact Cilium chart values for L7 DNS visibility on 1.19.x — confirm against upstream docs.
- Cloudflared tunnel config location for the bastion route — needs grep at PR1 implementation time.
- Authentik group restriction for
hubble.fzymgc.house— align with longhorn dashboard’s existing app config. - CNPG postgres clusters that need per-cluster policy in PR5 — enumerate live:
kubectl get cluster.postgresql.cnpg.io -A.
Bead Plan
Section titled “Bead Plan”- Close:
hl-7h0(PR4 Calico decommission) — stale, work merged. - New beads to file:
- “Re-point cert-manager
vault-issuerfrom vault-internal to vault-active” (P2, parallel track, not blocking this initiative). - “Spike Cilium Mutual Auth + SPIRE for crown-jewel namespaces” (P3, depends on PR5 complete).
- “Audit
app.kubernetes.io/*label hygiene across all workloads” (P3, useful beyond NP work). - One bead per PR (PR1..PR5) to track status, soak, blockers.
- “Re-point cert-manager