|
1 | | -{{- if .Values.webhookPolicy.enabled }} |
| 1 | +{{- $policy := .Values.webhookPolicy | default dict -}} |
| 2 | +{{- if and (dig "enabled" true $policy) (.Capabilities.APIVersions.Has "cilium.io/v2/CiliumClusterwideNetworkPolicy") }} |
| 3 | +{{- $policyHash := printf "%s/%s" .Release.Namespace .Release.Name | sha256sum | trunc 12 -}} |
2 | 4 | --- |
3 | 5 | {{/* |
4 | | -The /mutate-pods handler answers whoever asks it with namespace-derived |
5 | | -annotations, and only the API server has any business calling it |
6 | | -(GHSA-g883-q79m-8225). Client-certificate verification cannot close this on |
7 | | -its own: enabling --client-ca-file requires the API server to *present* a |
8 | | -certificate, which it does only when its AdmissionConfiguration names a |
9 | | -kubeConfigFile for the webhook plugins. A stock Cozystack control plane ships |
10 | | -a PodSecurity plugin block and nothing else, so no certificate is offered and |
11 | | -RequireAndVerifyClientCert would reject the API server itself -- with |
12 | | -failurePolicy: Fail that stops pod creation cluster-wide. Until that changes, |
13 | | -the network is the only place the caller can actually be constrained. |
| 6 | +The /mutate-pods handler returns namespace-derived annotations to any caller, |
| 7 | +and only the API server has any business calling it (GHSA-g883-q79m-8225). |
| 8 | +Client-certificate verification cannot enforce that on the stock control plane |
| 9 | +because the API server presents no client certificate to this webhook. |
14 | 10 |
|
15 | | -This is a CiliumNetworkPolicy, not a core NetworkPolicy, and the distinction |
16 | | -is the whole reason it works: packages/system/kubeovn/values.yaml sets |
| 11 | +This is a CiliumClusterwideNetworkPolicy, not a core NetworkPolicy, and the |
| 12 | +distinction is why it works: packages/system/kubeovn/values.yaml sets |
17 | 13 | ENABLE_NP: false, so kube-ovn programs no ACLs and a core NetworkPolicy would |
18 | | -be created and never enforced. Cilium enforces its own policies regardless, |
19 | | -which is why the platform already relies on them elsewhere. |
| 14 | +be created and never enforced. Cilium enforces its own policies regardless. |
20 | 15 |
|
21 | | -Deny-only, with default-deny explicitly disabled. A Cilium rule carrying an |
22 | | -ingress section puts the selected endpoints behind a default-deny, which here |
23 | | -would mean enumerating every port these pods serve and silently breaking |
24 | | -traffic on the first one forgotten. Subtracting one port leaves everything |
25 | | -else exactly as it was. |
| 16 | +Deny-only, and expressed from both ends because each end fails in a direction |
| 17 | +the other does not. The source-side spec is an egressDeny on the caller's own |
| 18 | +endpoint, so it holds while this webhook's endpoint policy is being recomputed |
| 19 | +or has overflowed its map, but it relies on Cilium performing the service DNAT |
| 20 | +before egress policy runs. The target-side spec is an ingressDeny on this |
| 21 | +webhook's endpoint, evaluated after whatever DNAT already happened, so it is |
| 22 | +independent of the service-LB implementation, but denying pods there needs a |
| 23 | +cluster-spanning peer enumerated per identity on a fail-closed endpoint. |
26 | 24 |
|
27 | | -The API server needs no carve-out, and this was confirmed on a live kube-ovn |
28 | | -cluster rather than reasoned about: cilium monitor on the webhook's endpoint |
29 | | -shows the API server's connection arriving from the node InternalIP with |
30 | | -identity "host" when it originates on the same node and "kube-apiserver" when |
31 | | -it originates on another, never from the kube-ovn join subnet and never as |
32 | | -"world". Neither identity is a Cilium endpoint, so fromEndpoints cannot match |
33 | | -it and the world deny does not apply to it. Unlike the ingress-nginx admission |
34 | | -webhook, this component exists only on the management cluster, so there is no |
35 | | -konnectivity-tunnelled caller to exempt either. |
| 25 | +Be exact about what that means: the source-side spec was added to get a |
| 26 | +cluster-spanning selector OFF this endpoint, and the target-side spec puts one |
| 27 | +back. Shipping both does not eliminate the namespace-churn map writes here; it |
| 28 | +makes them non-load-bearing, because whichever end is degraded the other still |
| 29 | +denies, and the writes are writes rather than an outage (see the cost paragraph |
| 30 | +on the second spec). The alternative -- dropping the target-side rule -- buys |
| 31 | +the coupling back at the price of a silent fail-open wherever Cilium does not |
| 32 | +own the DNAT. |
| 33 | + |
| 34 | +That coupling was not reproduced in isolation as the trigger of #3983, and the |
| 35 | +errno the issue reports cannot come from a policy denial; see the PR |
| 36 | +description. The world deny below, the other candidate that issue named, is |
| 37 | +retained on the evidence quoted further down. enableDefaultDeny stays false, so |
| 38 | +the policy subtracts one destination and port and leaves everything else |
| 39 | +exactly as it was. |
| 40 | + |
| 41 | +Only the BACKEND port is denied, and that is sufficient. Cilium translates a |
| 42 | +service to its backends BEFORE egress policy is enforced, so a pod dialling |
| 43 | +this webhook's ClusterIP:443 is matched against the backend identity on the |
| 44 | +port below. Do not add a `toServices` rule to cover the ClusterIP explicitly: |
| 45 | +Cilium resolves `toServices` only inside ALLOW rules |
| 46 | +(pkg/policy/k8s/service.go walks spec.Egress, never spec.EgressDeny), and an |
| 47 | +egressDeny whose only destination is an unresolved `toServices` reaches the |
| 48 | +datapath with a non-nil-but-empty L3 selector, which Cilium reads as a |
| 49 | +WILDCARD when the rule carries toPorts (pkg/policy/types/policyentry.go). That |
| 50 | +denies EVERY destination on that port for every selected endpoint -- a |
| 51 | +cluster-wide egress outage rather than a tighter deny. |
| 52 | + |
| 53 | +That ordering is Cilium's own and it is conditional: it holds while Cilium owns |
| 54 | +service load-balancing, which on every cozystack variant it does because |
| 55 | +packages/system/cilium/values.yaml sets kubeProxyReplacement: true against an |
| 56 | +upstream chart default of "false". Set that false and the ClusterIP DNAT moves |
| 57 | +downstream of Cilium's egress hook, the packet reaches policy_can_egress4 with |
| 58 | +dst = ClusterIP, matches no endpoint identity, and -- default-deny being off -- |
| 59 | +is allowed, restoring pod access to /mutate-pods with nothing to look at. |
| 60 | +packages/system/cilium/tests/kube_proxy_replacement_test.yaml pins that value |
| 61 | +and packages/system/cilium/templates/kube-proxy-replacement-beacon.yaml renders |
| 62 | +a status ConfigMap whenever it is not true, which is the only signal an operator |
| 63 | +override in the field can produce. The second spec below then keeps enforcing |
| 64 | +whoever performs the DNAT. |
| 65 | + |
| 66 | +The API server and host-network workloads are not selected: a rule carrying an |
| 67 | +endpointSelector can never match the host endpoint, because Cilium routes host |
| 68 | +policy through nodeSelector instead (pkg/policy/rule.go, matchesSubject). This |
| 69 | +component exists only on the management cluster, so there is no |
| 70 | +konnectivity-tunnelled caller to exempt. |
| 71 | + |
| 72 | +The second spec is target-side, on the webhook endpoint, and carries two denies |
| 73 | +kept for different reasons. |
| 74 | + |
| 75 | +Its pod deny is deliberate belt and braces. Being on the destination's ingress |
| 76 | +path it is evaluated after whatever DNAT has already happened, so it holds even |
| 77 | +where Cilium is not the service load balancer -- exactly the exposure the |
| 78 | +paragraph above describes. It cannot catch the API server: for a clusterwide |
| 79 | +policy Cilium rewrites every fromEndpoints selector to also require |
| 80 | +k8s:io.kubernetes.pod.namespace to Exist, plus the cluster label (Cilium |
| 81 | +v1.19.5, pkg/k8s/apis/cilium.io/utils/utils.go:120-154 getEndpointSelector, |
| 82 | +whose own comment at :141 says that even a wildcard must not become a truly |
| 83 | +empty allow-all selector), and reserved identities carry neither label. What |
| 84 | +WOULD catch it is `fromEntities: [all]` or an omitted peer, so the shape is |
| 85 | +pinned in tests/networkpolicy_test.yaml rather than left to review. |
| 86 | + |
| 87 | +Its cost, stated rather than denied: a cluster-spanning fromEndpoints peer is |
| 88 | +enumerated per identity, so this rule adds policy-map entries on the webhook |
| 89 | +endpoint in proportion to the number of pod identities in the cluster, and a |
| 90 | +map write on every namespace add or delete. It does not cost a drop window -- |
| 91 | +pkg/endpoint/bpf.go:1168-1177 adds entries before deleting them expressly to |
| 92 | +avoid transient drops, and inverts that order only when the addition would |
| 93 | +overflow the map, warning when it does (:1180-1182). The residual risk is |
| 94 | +therefore cardinality rather than churn: an endpoint whose policy map |
| 95 | +approaches MaxEntries gets the inverted order and a possible transient drop, |
| 96 | +and cozystack leaves endpointLockdownOnMapOverflow at false |
| 97 | +(charts/cilium/values.yaml:1250), so an overflow degrades rather than locking |
| 98 | +the endpoint down. |
| 99 | + |
| 100 | +Its world deny is retained rather than relied upon for the API server: |
| 101 | +`cilium monitor` on this webhook's endpoint on a live kube-ovn cluster showed |
| 102 | +the API server arriving from the node InternalIP with identity `host` when it |
| 103 | +originates on the same node and `kube-apiserver` when it originates on |
| 104 | +another, never from the kube-ovn join subnet and never as `world`. |
36 | 105 |
|
37 | 106 | Note the scope this actually has: it removes in-cluster pods and external |
38 | 107 | traffic, not every possible caller. Any host-network workload on the |
39 | 108 | management cluster carries the same host / remote-node identity that exempts |
40 | 109 | the API server and keeps its reach. |
41 | 110 | */}} |
42 | 111 | apiVersion: cilium.io/v2 |
43 | | -kind: CiliumNetworkPolicy |
| 112 | +kind: CiliumClusterwideNetworkPolicy |
44 | 113 | metadata: |
45 | | - name: {{ include "namespace-annotation-webhook.name" . }} |
46 | | - namespace: {{ .Release.Namespace }} |
47 | | -spec: |
48 | | - endpointSelector: |
| 114 | + name: kube-ovn-webhook-{{ $policyHash }} |
| 115 | +specs: |
| 116 | +- endpointSelector: {} |
| 117 | + enableDefaultDeny: |
| 118 | + ingress: false |
| 119 | + egress: false |
| 120 | + egressDeny: |
| 121 | + - toEndpoints: |
| 122 | + - matchLabels: |
| 123 | + "k8s:io.kubernetes.pod.namespace": {{ .Release.Namespace | quote }} |
| 124 | + "k8s:app.kubernetes.io/name": {{ include "namespace-annotation-webhook.name" . | quote }} |
| 125 | + "k8s:app.kubernetes.io/instance": {{ .Release.Name | quote }} |
| 126 | + toPorts: |
| 127 | + - ports: |
| 128 | + - port: {{ include "namespace-annotation-webhook.port" . | quote }} |
| 129 | + protocol: TCP |
| 130 | +- endpointSelector: |
49 | 131 | matchLabels: |
50 | | - app.kubernetes.io/name: {{ include "namespace-annotation-webhook.name" . }} |
51 | | - app.kubernetes.io/instance: {{ .Release.Name }} |
| 132 | + "k8s:io.kubernetes.pod.namespace": {{ .Release.Namespace | quote }} |
| 133 | + "k8s:app.kubernetes.io/name": {{ include "namespace-annotation-webhook.name" . | quote }} |
| 134 | + "k8s:app.kubernetes.io/instance": {{ .Release.Name | quote }} |
52 | 135 | enableDefaultDeny: |
53 | 136 | ingress: false |
| 137 | + egress: false |
54 | 138 | ingressDeny: |
55 | | -{{- /* |
56 | | -Every pod in the cluster. The namespace key is matched with Exists so the rule |
57 | | -spans all namespaces: a plain fromEndpoints in a namespaced |
58 | | -CiliumNetworkPolicy would only ever select pods sharing this policy's |
59 | | -namespace, which would leave the webhook open to every other one. |
60 | | -*/}} |
61 | 139 | - fromEndpoints: |
| 140 | + # Every pod in the cluster. The namespace key is matched with Exists so the |
| 141 | + # rule spans all namespaces rather than only this policy's own, and so the |
| 142 | + # selector is explicit about the requirement Cilium would otherwise add for |
| 143 | + # it (see the comment above). |
62 | 144 | - matchExpressions: |
63 | 145 | - key: k8s:io.kubernetes.pod.namespace |
64 | 146 | operator: Exists |
|
0 commit comments