feat(net): renderer emits gateway-ingress allow for exposed services (R-NET-01/02)
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: [<exposed service port(s)> 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 <[email protected]>
This commit is contained in:
1 parent
e022a2aea4
commit
c13ef69e71
5 files changed
+183
No files matched your search
@@ -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(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user