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:
|
- fromEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: radarr
|
app.kubernetes.io/name: radarr
|
||||||
|
k8s:io.kubernetes.pod.namespace: radarr
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '9117'
|
- port: '9117'
|
||||||
@@ -142,6 +143,7 @@ spec:
|
|||||||
- fromEndpoints:
|
- fromEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: sonarr
|
app.kubernetes.io/name: sonarr
|
||||||
|
k8s:io.kubernetes.pod.namespace: sonarr
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '9117'
|
- port: '9117'
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ spec:
|
|||||||
- fromEndpoints:
|
- fromEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: seerr
|
app.kubernetes.io/name: seerr
|
||||||
|
k8s:io.kubernetes.pod.namespace: seerr
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '32400'
|
- port: '32400'
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ spec:
|
|||||||
- fromEndpoints:
|
- fromEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: radarr
|
app.kubernetes.io/name: radarr
|
||||||
|
k8s:io.kubernetes.pod.namespace: radarr
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '8080'
|
- port: '8080'
|
||||||
@@ -142,6 +143,7 @@ spec:
|
|||||||
- fromEndpoints:
|
- fromEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: sonarr
|
app.kubernetes.io/name: sonarr
|
||||||
|
k8s:io.kubernetes.pod.namespace: sonarr
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '8080'
|
- port: '8080'
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ spec:
|
|||||||
- toEndpoints:
|
- toEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: qbittorrent
|
app.kubernetes.io/name: qbittorrent
|
||||||
|
k8s:io.kubernetes.pod.namespace: qbittorrent
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '8080'
|
- port: '8080'
|
||||||
@@ -142,6 +143,7 @@ spec:
|
|||||||
- toEndpoints:
|
- toEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: jackett
|
app.kubernetes.io/name: jackett
|
||||||
|
k8s:io.kubernetes.pod.namespace: jackett
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '9117'
|
- port: '9117'
|
||||||
@@ -163,6 +165,7 @@ spec:
|
|||||||
- fromEndpoints:
|
- fromEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: seerr
|
app.kubernetes.io/name: seerr
|
||||||
|
k8s:io.kubernetes.pod.namespace: seerr
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '7878'
|
- port: '7878'
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ spec:
|
|||||||
- toEndpoints:
|
- toEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: sonarr
|
app.kubernetes.io/name: sonarr
|
||||||
|
k8s:io.kubernetes.pod.namespace: sonarr
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '8989'
|
- port: '8989'
|
||||||
@@ -139,6 +140,7 @@ spec:
|
|||||||
- toEndpoints:
|
- toEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: radarr
|
app.kubernetes.io/name: radarr
|
||||||
|
k8s:io.kubernetes.pod.namespace: radarr
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '7878'
|
- port: '7878'
|
||||||
@@ -160,6 +162,7 @@ spec:
|
|||||||
- toEndpoints:
|
- toEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: plex
|
app.kubernetes.io/name: plex
|
||||||
|
k8s:io.kubernetes.pod.namespace: plex
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '32400'
|
- port: '32400'
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ spec:
|
|||||||
- toEndpoints:
|
- toEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: qbittorrent
|
app.kubernetes.io/name: qbittorrent
|
||||||
|
k8s:io.kubernetes.pod.namespace: qbittorrent
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '8080'
|
- port: '8080'
|
||||||
@@ -142,6 +143,7 @@ spec:
|
|||||||
- toEndpoints:
|
- toEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: jackett
|
app.kubernetes.io/name: jackett
|
||||||
|
k8s:io.kubernetes.pod.namespace: jackett
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '9117'
|
- port: '9117'
|
||||||
@@ -163,6 +165,7 @@ spec:
|
|||||||
- fromEndpoints:
|
- fromEndpoints:
|
||||||
- matchLabels:
|
- matchLabels:
|
||||||
app.kubernetes.io/name: seerr
|
app.kubernetes.io/name: seerr
|
||||||
|
k8s:io.kubernetes.pod.namespace: seerr
|
||||||
toPorts:
|
toPorts:
|
||||||
- ports:
|
- ports:
|
||||||
- port: '8989'
|
- 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).
|
"""One CiliumNetworkPolicy egress allow per dependency, on top of default-deny (R-NET-02).
|
||||||
|
|
||||||
Everything undeclared stays denied; empty dependencies => reaches only DNS.
|
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 = []
|
out = []
|
||||||
for dep in s["dependencies"]:
|
for dep in s["dependencies"]:
|
||||||
@@ -309,7 +318,12 @@ def r_dependency_policies(s: dict) -> list:
|
|||||||
"egress": [
|
"egress": [
|
||||||
{
|
{
|
||||||
"toEndpoints": [
|
"toEndpoints": [
|
||||||
{"matchLabels": {"app.kubernetes.io/name": dep["to"]}}
|
{
|
||||||
|
"matchLabels": {
|
||||||
|
"app.kubernetes.io/name": dep["to"],
|
||||||
|
"k8s:io.kubernetes.pod.namespace": dep["to"],
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"toPorts": [
|
"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
|
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
|
ingress allow on top of default-deny, mirroring the source-side egress allow
|
||||||
(r_dependency_policies) exactly but inverted: `fromEndpoints` matching the SOURCE's
|
(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
|
identity on the declared port/protocol. As with the egress half, the source lives in a
|
||||||
declared port/protocol. Same L3/L4 granularity and cross-namespace selector semantics as
|
DIFFERENT namespace, and a CiliumNetworkPolicy's `fromEndpoints` selector is scoped to the
|
||||||
the egress half, so the two halves compose into a working east-west path. Without this,
|
POLICY's own namespace unless it carries the reserved `k8s:io.kubernetes.pod.namespace`
|
||||||
the destination's default-deny-ingress drops the source's traffic and the declared
|
label — so the source's namespace (== edge["source"]) is pinned alongside its app label.
|
||||||
dependency does not actually work (docs/03 R-NET-01/02).
|
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 = []
|
out = []
|
||||||
for edge in inbound or []:
|
for edge in inbound or []:
|
||||||
@@ -394,7 +410,12 @@ def r_reverse_dependency_policies(s: dict, inbound: list) -> list:
|
|||||||
"ingress": [
|
"ingress": [
|
||||||
{
|
{
|
||||||
"fromEndpoints": [
|
"fromEndpoints": [
|
||||||
{"matchLabels": {"app.kubernetes.io/name": edge["source"]}}
|
{
|
||||||
|
"matchLabels": {
|
||||||
|
"app.kubernetes.io/name": edge["source"],
|
||||||
|
"k8s:io.kubernetes.pod.namespace": edge["source"],
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"toPorts": [
|
"toPorts": [
|
||||||
{
|
{
|
||||||
@@ -959,6 +980,13 @@ def _selftest() -> int:
|
|||||||
cnps = [d[1] for d in dep_docs if d[1]["kind"] == "CiliumNetworkPolicy"]
|
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"]
|
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")
|
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(
|
check(
|
||||||
any(c["metadata"]["name"] == "y-allow-internet-egress" for c in cnps),
|
any(c["metadata"]["name"] == "y-allow-internet-egress" for c in cnps),
|
||||||
"every app (egress class) must render a world/internet egress allow",
|
"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")
|
check(len(rev_cnps) == 1, "destination must render one reciprocal ingress allow")
|
||||||
if rev_cnps:
|
if rev_cnps:
|
||||||
ing = rev_cnps[0]["spec"]["ingress"]
|
ing = rev_cnps[0]["spec"]["ingress"]
|
||||||
|
from_ep = ing[0]["fromEndpoints"][0]["matchLabels"]
|
||||||
check(
|
check(
|
||||||
ing[0]["fromEndpoints"] == [{"matchLabels": {"app.kubernetes.io/name": "src"}}],
|
from_ep.get("app.kubernetes.io/name") == "src"
|
||||||
"reciprocal ingress must be from the SOURCE's identity",
|
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"]}
|
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")
|
check(allowed == {"8080"}, "reciprocal ingress must open only the declared port")
|
||||||
|
|||||||
Reference in new issue
Block a user