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")