fix(render): namespace-pin east-west selectors so declared deps actually connect
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 <[email protected]>
This commit is contained in:
1 parent
14beb85b65
commit
b871877c1b
7 files changed
+53
-9
No files matched your search
@@ -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'
|
||||
|
||||
@@ -118,6 +118,7 @@ spec:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
app.kubernetes.io/name: seerr
|
||||
k8s:io.kubernetes.pod.namespace: seerr
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '32400'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
+39
-9
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user