From b871877c1ba7d793faf2a90e1afd12c3c1fec665 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Wed, 26 Aug 2026 01:01:56 -0500 Subject: [PATCH] fix(render): namespace-pin east-west selectors so declared deps actually connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the reciprocal-ingress fix: in-cluster verification (Hubble) revealed the east-west path was STILL fully blocked — and that the pre-existing EGRESS half was also broken, contrary to the original diagnosis. Root cause: a CiliumNetworkPolicy `toEndpoints`/`fromEndpoints` selector is scoped to the POLICY's OWN namespace unless it carries the reserved `k8s:io.kubernetes.pod.namespace` label. Every service lives in its own namespace (== its name) and every dependency crosses a namespace boundary, so an app-label-only peer selector matched nothing and Cilium silently denied the flow (Hubble: sonarr->qbittorrent = "EGRESS DENIED ... Policy denied DROPPED"). Pin the peer namespace on BOTH halves — egress toEndpoints (destination ns == dep.to) and the reciprocal ingress fromEndpoints (source ns == source name). This is a strict TIGHTENING (more precise, still L3/L4), not a weakening. Verified end-to-end in-cluster: sonarr->qbittorrent:8080, sonarr->jackett:9117, seerr->{sonarr:8989,radarr:7878, plex:32400} now CONNECT (were DROPPED); undeclared sonarr->plex:32400 and seerr->qbittorrent:8080 stay BLOCKED (default-deny holds). Selftest + L0 (17/17) + L1 (3/3, goldens byte-identical) + lint all pass. NOTE: this necessarily touches the egress toEndpoints selector, which the task scoped as ingress-only; the premise that egress already worked was empirically false. Co-Authored-By: Claude Opus 4.8 --- apps/jackett.yaml | 2 ++ apps/plex.yaml | 1 + apps/qbittorrent.yaml | 2 ++ apps/radarr.yaml | 3 +++ apps/seerr.yaml | 3 +++ apps/sonarr.yaml | 3 +++ catalog/render.py | 48 +++++++++++++++++++++++++++++++++++-------- 7 files changed, 53 insertions(+), 9 deletions(-) diff --git a/apps/jackett.yaml b/apps/jackett.yaml index eaa0863..e90d27d 100644 --- a/apps/jackett.yaml +++ b/apps/jackett.yaml @@ -121,6 +121,7 @@ spec: - fromEndpoints: - matchLabels: app.kubernetes.io/name: radarr + k8s:io.kubernetes.pod.namespace: radarr toPorts: - ports: - port: '9117' @@ -142,6 +143,7 @@ spec: - fromEndpoints: - matchLabels: app.kubernetes.io/name: sonarr + k8s:io.kubernetes.pod.namespace: sonarr toPorts: - ports: - port: '9117' diff --git a/apps/plex.yaml b/apps/plex.yaml index 120d128..dfffcb2 100644 --- a/apps/plex.yaml +++ b/apps/plex.yaml @@ -118,6 +118,7 @@ spec: - fromEndpoints: - matchLabels: app.kubernetes.io/name: seerr + k8s:io.kubernetes.pod.namespace: seerr toPorts: - ports: - port: '32400' diff --git a/apps/qbittorrent.yaml b/apps/qbittorrent.yaml index 40489ee..b587b31 100644 --- a/apps/qbittorrent.yaml +++ b/apps/qbittorrent.yaml @@ -121,6 +121,7 @@ spec: - fromEndpoints: - matchLabels: app.kubernetes.io/name: radarr + k8s:io.kubernetes.pod.namespace: radarr toPorts: - ports: - port: '8080' @@ -142,6 +143,7 @@ spec: - fromEndpoints: - matchLabels: app.kubernetes.io/name: sonarr + k8s:io.kubernetes.pod.namespace: sonarr toPorts: - ports: - port: '8080' diff --git a/apps/radarr.yaml b/apps/radarr.yaml index 54bf1f3..f1e3795 100644 --- a/apps/radarr.yaml +++ b/apps/radarr.yaml @@ -121,6 +121,7 @@ spec: - toEndpoints: - matchLabels: app.kubernetes.io/name: qbittorrent + k8s:io.kubernetes.pod.namespace: qbittorrent toPorts: - ports: - port: '8080' @@ -142,6 +143,7 @@ spec: - toEndpoints: - matchLabels: app.kubernetes.io/name: jackett + k8s:io.kubernetes.pod.namespace: jackett toPorts: - ports: - port: '9117' @@ -163,6 +165,7 @@ spec: - fromEndpoints: - matchLabels: app.kubernetes.io/name: seerr + k8s:io.kubernetes.pod.namespace: seerr toPorts: - ports: - port: '7878' diff --git a/apps/seerr.yaml b/apps/seerr.yaml index 16a27f6..8cf7049 100644 --- a/apps/seerr.yaml +++ b/apps/seerr.yaml @@ -118,6 +118,7 @@ spec: - toEndpoints: - matchLabels: app.kubernetes.io/name: sonarr + k8s:io.kubernetes.pod.namespace: sonarr toPorts: - ports: - port: '8989' @@ -139,6 +140,7 @@ spec: - toEndpoints: - matchLabels: app.kubernetes.io/name: radarr + k8s:io.kubernetes.pod.namespace: radarr toPorts: - ports: - port: '7878' @@ -160,6 +162,7 @@ spec: - toEndpoints: - matchLabels: app.kubernetes.io/name: plex + k8s:io.kubernetes.pod.namespace: plex toPorts: - ports: - port: '32400' diff --git a/apps/sonarr.yaml b/apps/sonarr.yaml index 572b2e0..dcd04bb 100644 --- a/apps/sonarr.yaml +++ b/apps/sonarr.yaml @@ -121,6 +121,7 @@ spec: - toEndpoints: - matchLabels: app.kubernetes.io/name: qbittorrent + k8s:io.kubernetes.pod.namespace: qbittorrent toPorts: - ports: - port: '8080' @@ -142,6 +143,7 @@ spec: - toEndpoints: - matchLabels: app.kubernetes.io/name: jackett + k8s:io.kubernetes.pod.namespace: jackett toPorts: - ports: - port: '9117' @@ -163,6 +165,7 @@ spec: - fromEndpoints: - matchLabels: app.kubernetes.io/name: seerr + k8s:io.kubernetes.pod.namespace: seerr toPorts: - ports: - port: '8989' diff --git a/catalog/render.py b/catalog/render.py index e84f0f2..26f17ae 100644 --- a/catalog/render.py +++ b/catalog/render.py @@ -289,6 +289,15 @@ def r_dependency_policies(s: dict) -> list: """One CiliumNetworkPolicy egress allow per dependency, on top of default-deny (R-NET-02). Everything undeclared stays denied; empty dependencies => reaches only DNS. + + Cross-namespace note: a CiliumNetworkPolicy's `toEndpoints` selector is scoped to the + POLICY's own namespace unless it carries the reserved `k8s:io.kubernetes.pod.namespace` + label. Every service lives in its own namespace named after it (r_namespace), and a + dependency always targets a DIFFERENT service/namespace, so the destination's namespace + (== dep["to"]) MUST be pinned here — otherwise Cilium looks for the destination's app + label inside the SOURCE namespace, finds nothing, and silently denies the egress (proven + in-cluster via Hubble: `EGRESS DENIED`). This pins it so the declared dependency's egress + half actually reaches the destination (verified: EGRESS ALLOWED / FORWARDED). """ out = [] for dep in s["dependencies"]: @@ -309,7 +318,12 @@ def r_dependency_policies(s: dict) -> list: "egress": [ { "toEndpoints": [ - {"matchLabels": {"app.kubernetes.io/name": dep["to"]}} + { + "matchLabels": { + "app.kubernetes.io/name": dep["to"], + "k8s:io.kubernetes.pod.namespace": dep["to"], + } + } ], "toPorts": [ { @@ -369,11 +383,13 @@ def r_reverse_dependency_policies(s: dict, inbound: list) -> list: the list of sources that declared a dependency ON this service. Each becomes an ADDITIVE ingress allow on top of default-deny, mirroring the source-side egress allow (r_dependency_policies) exactly but inverted: `fromEndpoints` matching the SOURCE's - identity (its app.kubernetes.io/name — globally unique here, one app per namespace) on the - declared port/protocol. Same L3/L4 granularity and cross-namespace selector semantics as - the egress half, so the two halves compose into a working east-west path. Without this, - the destination's default-deny-ingress drops the source's traffic and the declared - dependency does not actually work (docs/03 R-NET-01/02). + identity on the declared port/protocol. As with the egress half, the source lives in a + DIFFERENT namespace, and a CiliumNetworkPolicy's `fromEndpoints` selector is scoped to the + POLICY's own namespace unless it carries the reserved `k8s:io.kubernetes.pod.namespace` + label — so the source's namespace (== edge["source"]) is pinned alongside its app label. + Without both halves so pinned the destination's default-deny-ingress drops the source's + traffic and the declared dependency does not actually work (docs/03 R-NET-01/02; proven + in-cluster via Hubble: pinned => FORWARDED, unpinned => Policy denied DROPPED). """ out = [] for edge in inbound or []: @@ -394,7 +410,12 @@ def r_reverse_dependency_policies(s: dict, inbound: list) -> list: "ingress": [ { "fromEndpoints": [ - {"matchLabels": {"app.kubernetes.io/name": edge["source"]}} + { + "matchLabels": { + "app.kubernetes.io/name": edge["source"], + "k8s:io.kubernetes.pod.namespace": edge["source"], + } + } ], "toPorts": [ { @@ -959,6 +980,13 @@ def _selftest() -> int: cnps = [d[1] for d in dep_docs if d[1]["kind"] == "CiliumNetworkPolicy"] dep_cnps = [c for c in cnps if c["metadata"]["name"] == "y-allow-db-5432"] check(len(dep_cnps) == 1, "one dependency must render one CiliumNetworkPolicy allow") + if dep_cnps: + to_ep = dep_cnps[0]["spec"]["egress"][0]["toEndpoints"][0]["matchLabels"] + check( + to_ep.get("k8s:io.kubernetes.pod.namespace") == "db" + and to_ep.get("app.kubernetes.io/name") == "db", + "egress allow must pin the destination namespace (cross-ns Cilium selector)", + ) check( any(c["metadata"]["name"] == "y-allow-internet-egress" for c in cnps), "every app (egress class) must render a world/internet egress allow", @@ -989,9 +1017,11 @@ def _selftest() -> int: check(len(rev_cnps) == 1, "destination must render one reciprocal ingress allow") if rev_cnps: ing = rev_cnps[0]["spec"]["ingress"] + from_ep = ing[0]["fromEndpoints"][0]["matchLabels"] check( - ing[0]["fromEndpoints"] == [{"matchLabels": {"app.kubernetes.io/name": "src"}}], - "reciprocal ingress must be from the SOURCE's identity", + from_ep.get("app.kubernetes.io/name") == "src" + and from_ep.get("k8s:io.kubernetes.pod.namespace") == "src", + "reciprocal ingress must be from the SOURCE's identity, namespace-pinned (cross-ns)", ) allowed = {p["port"] for r in ing for tp in r["toPorts"] for p in tp["ports"]} check(allowed == {"8080"}, "reciprocal ingress must open only the declared port")