From c13ef69e7199d50e718ffb598bac5226fe9cbb2d Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 24 Aug 2026 23:40:52 -0500 Subject: [PATCH] feat(net): renderer emits gateway-ingress allow for exposed services (R-NET-01/02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposed apps got a default-deny NetworkPolicy with ingress: [] (deny all), so the Cilium Gateway (Envoy, reserved identity `ingress`) could not reach the backend — an Accepted HTTPRoute was still unreachable. render.py now emits an ADDITIVE, least- privilege CiliumNetworkPolicy for any ServiceDefinition that renders an HTTPRoute: ingress: - fromEntities: [ingress] # the shared Cilium Gateway/Envoy proxy identity toPorts: [ TCP] Verified against the live cluster (Cilium 1.16.5): with default-deny in place, Hubble shows the Gateway proxy reaching pihole as source identity `(ingress)` and the flow `Policy denied DROPPED` on :80 — so `fromEntities: [ingress]` is exactly the source the Gateway uses, scoped to the service targetPort. Default-deny stays intact (ingress: [] unchanged); the allow only opens the north-south path (docs/09), east-west stays denied. - render.py: add r_gateway_ingress_policy(); emit it after dependency policies; selftest asserts the allow is additive (default-deny ingress stays []), least-privilege (only the exposed port, only the ingress identity), and absent for non-exposed services. - L1 goldens updated for the two exposed fixtures (container-workload, egress-class); default-deny-baseline golden unchanged (no exposure). --selftest + run.sh --layer l1 green. - apps/pihole.yaml re-rendered (--no-image-automation) to carry the allow. Co-Authored-By: Claude Opus 4.8 --- apps/pihole.yaml | 20 ++++ catalog/render.py | 103 ++++++++++++++++++ .../golden/expected.yaml | 20 ++++ .../golden/egress-internet.expected.yaml | 20 ++++ .../golden/egress-vpn.expected.yaml | 20 ++++ 5 files changed, 183 insertions(+) diff --git a/apps/pihole.yaml b/apps/pihole.yaml index af993bf..66366ec 100644 --- a/apps/pihole.yaml +++ b/apps/pihole.yaml @@ -103,6 +103,26 @@ spec: port: 80 targetPort: 80 --- +# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): pihole-allow-gateway-ingress +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: pihole-allow-gateway-ingress + namespace: pihole + labels: + app.kubernetes.io/name: pihole +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: pihole + ingress: + - fromEntities: + - ingress + toPorts: + - ports: + - port: '80' + protocol: TCP +--- # HTTPRoute pihole-80 (R-TLS/R-DNS) apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute diff --git a/catalog/render.py b/catalog/render.py index 4824f8d..60ee7ed 100644 --- a/catalog/render.py +++ b/catalog/render.py @@ -315,6 +315,54 @@ def r_dependency_policies(s: dict) -> list: return out +def r_gateway_ingress_policy(s: dict): + """North-south allow: let the shared Cilium Gateway reach an exposed service's port(s). + + Any service that renders an HTTPRoute (non-empty ingressRoutes) is reachable through the + shared Gateway, but the namespace default-deny has `ingress: []` (deny all east-west and + north-south). This CiliumNetworkPolicy is the ADDITIVE, least-privilege allow that opens + ONLY the north-south path (docs/09 "Ingress opens north-south only — it does not relax + east-west default-deny"): ingress from Cilium's reserved `ingress` identity (the Gateway/ + Ingress Envoy proxy, `reserved:ingress`, numeric identity 8 in Cilium 1.16.5) to ONLY the + exposed service port(s). Everything else stays denied; undeclared ports stay denied. + + Verified empirically against the cluster: with default-deny in place, Hubble shows the + Gateway proxy reaching the backend as source identity `(ingress)` and the flow + `Policy denied DROPPED` on the service port — so `fromEntities: [ingress]` is exactly the + source the Cilium Gateway uses, and the port is the service targetPort (R-NET-01/02). + """ + if not s["ingressRoutes"]: + return None + ports = _service_ports(s) + return { + "apiVersion": "cilium.io/v2", + "kind": "CiliumNetworkPolicy", + "metadata": { + "name": f"{s['name']}-allow-gateway-ingress", + "namespace": s["name"], + "labels": {"app.kubernetes.io/name": s["name"]}, + }, + "spec": { + "endpointSelector": { + "matchLabels": {"app.kubernetes.io/name": s["name"]} + }, + "ingress": [ + { + "fromEntities": ["ingress"], + "toPorts": [ + { + "ports": [ + {"port": str(p["port"]), "protocol": "TCP"} + for p in ports + ] + } + ], + } + ], + }, + } + + def _route_meta(s: dict, route: dict) -> dict: meta = { "name": f"{s['name']}-{route['port']}", @@ -616,6 +664,16 @@ def render(sd: dict, image_automation: bool = True) -> list: for cnp in r_dependency_policies(s): docs.append((f"CiliumNetworkPolicy egress allow (R-NET-02): {cnp['metadata']['name']}", cnp)) + gw_allow = r_gateway_ingress_policy(s) + if gw_allow: + docs.append( + ( + "CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): " + f"{gw_allow['metadata']['name']}", + gw_allow, + ) + ) + for hr in r_httproutes(s): docs.append((f"HTTPRoute {hr['metadata']['name']} (R-TLS/R-DNS)", hr)) @@ -725,6 +783,51 @@ def _selftest() -> int: cnps = [d[1] for d in dep_docs if d[1]["kind"] == "CiliumNetworkPolicy"] check(len(cnps) == 1, "one dependency must render one CiliumNetworkPolicy allow") + # 4b) exposure (ingressRoutes) -> an ADDITIVE gateway-ingress CiliumNetworkPolicy that + # allows ONLY the reserved `ingress` (Gateway proxy) identity to the exposed port, + # while default-deny still denies all other ingress (R-NET-01/02). + exp_docs = render( + { + "name": "z", + "source": {"image": "a:1"}, + "ingressRoutes": [{"host": "z.home.keithjackson.dev", "port": 80}], + } + ) + gw = [ + d[1] + for d in exp_docs + if d[1]["kind"] == "CiliumNetworkPolicy" + and d[1]["metadata"]["name"].endswith("-allow-gateway-ingress") + ] + check(len(gw) == 1, "an exposed service must render one gateway-ingress allow (R-NET-01)") + if gw: + ing = gw[0]["spec"]["ingress"] + check( + ing[0]["fromEntities"] == ["ingress"], + "gateway-ingress allow must be from the reserved `ingress` (Gateway) identity", + ) + allowed_ports = {p["port"] for r in ing for tp in r["toPorts"] for p in tp["ports"]} + check( + allowed_ports == {"80"}, + "gateway-ingress allow must be least-privilege (only the exposed port)", + ) + # default-deny stays deny-all-ingress even when an exposed service adds the allow. + exp_dd = next(d[1] for d in exp_docs if d[1]["kind"] == "NetworkPolicy") + check( + exp_dd["spec"]["ingress"] == [], + "gateway-ingress allow must be ADDITIVE — default-deny ingress stays [] (R-NET-01)", + ) + # an internal (no ingressRoutes) service must NOT get a gateway-ingress allow. + internal_docs = render({"name": "int", "source": {"image": "a:1"}}) + check( + not any( + d[1]["kind"] == "CiliumNetworkPolicy" + and d[1]["metadata"]["name"].endswith("-allow-gateway-ingress") + for d in internal_docs + ), + "a non-exposed service must not render a gateway-ingress allow (least privilege)", + ) + # 5) vm kind -> VirtualMachine + standalone DataVolume. vm_docs = render( { diff --git a/conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml b/conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml index 96f9d06..510cfc5 100644 --- a/conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml +++ b/conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml @@ -78,6 +78,26 @@ spec: port: 8080 targetPort: 8080 --- +# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): paas-demo-allow-gateway-ingress +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: paas-demo-allow-gateway-ingress + namespace: paas-demo + labels: + app.kubernetes.io/name: paas-demo +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: paas-demo + ingress: + - fromEntities: + - ingress + toPorts: + - ports: + - port: '8080' + protocol: TCP +--- # HTTPRoute paas-demo-8080 (R-TLS/R-DNS) apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute diff --git a/conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml b/conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml index efec1b0..313a3f7 100644 --- a/conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml +++ b/conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml @@ -103,6 +103,26 @@ spec: port: 8989 targetPort: 8989 --- +# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): sonarr-allow-gateway-ingress +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: sonarr-allow-gateway-ingress + namespace: sonarr + labels: + app.kubernetes.io/name: sonarr +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: sonarr + ingress: + - fromEntities: + - ingress + toPorts: + - ports: + - port: '8989' + protocol: TCP +--- # HTTPRoute sonarr-8989 (R-TLS/R-DNS) apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute diff --git a/conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml b/conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml index 4caaa7f..36eed96 100644 --- a/conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml +++ b/conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml @@ -103,6 +103,26 @@ spec: port: 8989 targetPort: 8989 --- +# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): sonarr-allow-gateway-ingress +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: sonarr-allow-gateway-ingress + namespace: sonarr + labels: + app.kubernetes.io/name: sonarr +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: sonarr + ingress: + - fromEntities: + - ingress + toPorts: + - ports: + - port: '8989' + protocol: TCP +--- # HTTPRoute sonarr-8989 (R-TLS/R-DNS) apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute