Skip to content

Commit 6ae7fcf

Browse files
authored
pkg/netpol: explain one policy's effect on one connection (#1747)
## What this is, in one paragraph When Radar says "this NetworkPolicy blocks that connection", it has to be right — a wrong answer sends someone editing the wrong YAML. Kubernetes policies are unusual: they only ever *allow*; a policy never blocks anything by itself. A pod is blocked when *some* policy selects it and *none* of them allow the connection. So the honest question to ask of a single policy is not "does it block?" but "does it allow this connection, or not, or can't we tell?". This PR adds that question to `pkg/netpol` as `Explain`, and rebuilds the existing service-level verdict (`Evaluate`) on top of it so the two can never disagree. ## What a reviewer without NetworkPolicy expertise can check - **Every answer is one of four words**: `NotApplicable` (this policy doesn't concern this pod or direction), `Admits` (a rule allows it — the reason names the rule), `DoesNotAdmit` (no rule allows it), `Undecidable` (Radar can't tell — the reason says what it couldn't check). There is deliberately no `Denies`. - **"Undecidable" is never used as a hedge.** It appears only when a specific fact is missing: the peer's namespace labels aren't readable, the peer pod no longer exists, a selector is malformed, the connection's port is unknown, or the address a rule would be checked against might have been rewritten by the network (a real Kubernetes behaviour — see the two `ipBlock` rules below). Each case has its own test. - **The reasons are user-facing text** — they appear verbatim under a dropped flow in the Traffic view — so they're written as an operator reads a flow: *"rule 1 allows incoming TCP/80 from pods labeled app=client in namespace demo"*, *"requires incoming traffic to be explicitly allowed, but has no allow rules"*, *"the network may change the source address before this rule sees it, so Radar can't tell whether 10.0.0.12 falls in this range"*. ## The rules worth knowing (and where the old evaluator got them wrong) | Kubernetes behaviour | What `Explain` does | Old `/evaluate` handler | |---|---|---| | A policy with no `policyTypes` still isolates ingress | Treats it as isolating ingress | Ignored it → false "allowed" | | Rule ports matter; a named port resolves on the destination pod | Compares protocol + port; named port needs the pod | Ignored ports entirely | | `namespaceSelector` matches the *Namespace object's labels* | Needs the Namespace; without it ⇒ undecidable | Compared namespace *names* → cross-namespace never matched | | `ipBlock` on **ingress**: a hit is decisive, a miss isn't (source may be SNATed) | Hit ⇒ admits; miss ⇒ undecidable | Never matched | | `ipBlock` on **egress to a pod**: the plugin may see the Service IP, not the pod's | Undecidable either way | Never matched | | `ipBlock` on **egress to outside the cluster**: nothing rewrites it | Hit admits; miss is decisive | Never matched | | Node / host-network endpoints: plugin-defined | Undecidable | Treated as pods | These were confirmed live on an enforcing EKS cluster before this PR: the old handler said "allowed" for a connection that timed out, and "allowed for the wrong reason" for one that worked. ## What changed in code - `pkg/netpol/explain.go` — `Explain(policy, direction, selectedPod, peer, port, protocol) → {Effect, Reason}`. One rule walk; peers described in words; three-valued folding so an open question never becomes a "no". - `pkg/netpol/evaluate.go` — `Evaluate` now folds `Explain` per policy. `Peer` gains `External` and `IP` (an observed address wins over a pod's address set). Verdicts unchanged. - Tests: 25 `Explain` cases + a fold-consistency test that checks `Evaluate` and `Explain` agree on every fixture; the 33 existing `Evaluate` cases still pass. `internal/trace` (Service reachability) and `internal/prometheus` (why Prometheus is unreachable) consume the package and pass unchanged. ## Testing `go test ./pkg/netpol ./internal/trace ./internal/prometheus` ✓. Live: the same fixture pods and policies on `us-east-1-nonprod`, all six `kubectl exec curl` outcomes matched by `Explain` with the correct rule credited; causality proven by removing and restoring the isolating policy.
1 parent 9f742a4 commit 6ae7fcf

3 files changed

Lines changed: 744 additions & 96 deletions

File tree

‎pkg/netpol/evaluate.go‎

Lines changed: 43 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ import (
1717
type Peer struct {
1818
Pod *corev1.Pod
1919
Namespace *corev1.Namespace
20+
// External marks an endpoint that is not a pod at all — a node, the host
21+
// network, the world outside the cluster. Selectors can never match it;
22+
// an ipBlock is matched against IP. A Peer with a nil Pod that is not
23+
// External is a pod the caller could not resolve, which is a different
24+
// thing: nothing about it is decidable.
25+
External bool
26+
// IP is the address the caller actually observed for this endpoint, when
27+
// it has one (a flow record). It takes precedence over the pod's own
28+
// address set for ipBlock matching.
29+
IP string
2030
}
2131

2232
// Backend is a destination pod together with the port and protocol the
@@ -121,7 +131,7 @@ func Evaluate(src Peer, dst []Backend, policies []*networkingv1.NetworkPolicy) V
121131
egressAll, ingressAll = triYes, triYes
122132
continue
123133
}
124-
egress := egressAdmits(egressPolicies, b)
134+
egress := egressAdmits(egressPolicies, src, b)
125135
selecting, ok := selectingPolicies(b.Pod, policies, func(t PolicyTypes) bool { return t.Ingress })
126136
if !ok {
127137
return Verdict{Kind: Unknown}
@@ -151,20 +161,13 @@ func Evaluate(src Peer, dst []Backend, policies []*networkingv1.NetworkPolicy) V
151161

152162
// egressAdmits answers for one backend against the policies that isolate the
153163
// source's egress; none means the source is unrestricted.
154-
func egressAdmits(selecting []*networkingv1.NetworkPolicy, b Backend) tri {
164+
func egressAdmits(selecting []*networkingv1.NetworkPolicy, src Peer, b Backend) tri {
155165
if len(selecting) == 0 {
156166
return triYes
157167
}
158168
verdict := triNo
159169
for _, np := range selecting {
160-
for i := range np.Spec.Egress {
161-
rule := &np.Spec.Egress[i]
162-
// Named ports in an egress rule refer to the destination pod.
163-
if !RuleMatchesPort(rule.Ports, b.Port, b.Protocol, b.Pod) {
164-
continue
165-
}
166-
verdict = fold(verdict, peersAdmit(rule.To, b.Peer, np.Namespace, DirectionEgress))
167-
}
170+
verdict = fold(verdict, effectTri(Explain(np, DirectionEgress, src.Pod, b.Peer, b.Port, b.Protocol).Effect))
168171
}
169172
return verdict
170173
}
@@ -178,17 +181,24 @@ func ingressAdmits(selecting []*networkingv1.NetworkPolicy, src Peer, b Backend)
178181
}
179182
verdict := triNo
180183
for _, np := range selecting {
181-
for i := range np.Spec.Ingress {
182-
rule := &np.Spec.Ingress[i]
183-
if !RuleMatchesPort(rule.Ports, b.Port, b.Protocol, b.Pod) {
184-
continue
185-
}
186-
verdict = fold(verdict, peersAdmit(rule.From, src, np.Namespace, DirectionIngress))
187-
}
184+
verdict = fold(verdict, effectTri(Explain(np, DirectionIngress, b.Pod, src, b.Port, b.Protocol).Effect))
188185
}
189186
return verdict
190187
}
191188

189+
// effectTri maps a policy's Effect onto the fold. A policy that does not
190+
// apply must not count as a refusal, so it folds as neither.
191+
func effectTri(e Effect) tri {
192+
switch e {
193+
case Admits:
194+
return triYes
195+
case Undecidable:
196+
return triUnknown
197+
default:
198+
return triNo
199+
}
200+
}
201+
192202
// both merges the two legs of one path: the path works only if both do, is
193203
// denied as soon as one is, and is undecidable otherwise.
194204
func both(a, b tri) tri {
@@ -202,19 +212,6 @@ func both(a, b tri) tri {
202212
}
203213
}
204214

205-
// peersAdmit is a rule's from/to list as one answer: empty admits everyone,
206-
// otherwise one admitting entry is enough.
207-
func peersAdmit(peers []networkingv1.NetworkPolicyPeer, target Peer, policyNs string, dir Direction) tri {
208-
if len(peers) == 0 {
209-
return triYes
210-
}
211-
out := triNo
212-
for i := range peers {
213-
out = fold(out, peerAdmits(&peers[i], target, policyNs, dir))
214-
}
215-
return out
216-
}
217-
218215
// fold merges answers that are alternatives to each other (rules of one
219216
// policy, entries of one rule, backends of one Service): admitted as soon as
220217
// one says yes, denied only when all say no, undecidable otherwise.
@@ -262,77 +259,27 @@ func selectingPolicies(pod *corev1.Pod, policies []*networkingv1.NetworkPolicy,
262259
return out, true
263260
}
264261

265-
// peerAdmits decides whether one from/to entry admits target. policyNs is the
266-
// namespace the rule lives in — a podSelector without a namespaceSelector
267-
// only ever matches pods there.
268-
func peerAdmits(peer *networkingv1.NetworkPolicyPeer, target Peer, policyNs string, dir Direction) tri {
269-
if peer.IPBlock != nil {
270-
if dir == DirectionEgress {
271-
return triUnknown
272-
}
273-
// A match admits. A miss proves nothing: whether the policy sees the
274-
// pod's address or a rewritten one (kube-proxy masquerade, a mesh
275-
// sidecar) is up to the network plugin.
276-
if ipBlockAdmits(peer.IPBlock, podIPs(target.Pod)) == triYes {
277-
return triYes
278-
}
279-
return triUnknown
280-
}
281-
if peer.NamespaceSelector == nil && peer.PodSelector == nil {
282-
return triUnknown
283-
}
284-
285-
ns := triYes
286-
if peer.NamespaceSelector == nil {
287-
if target.Pod.Namespace != policyNs {
288-
ns = triNo
289-
}
290-
} else {
291-
if target.Namespace == nil {
292-
ns = triUnknown
293-
} else {
294-
sel, err := metav1.LabelSelectorAsSelector(peer.NamespaceSelector)
295-
if err != nil {
296-
ns = triUnknown
297-
} else if !sel.Matches(labels.Set(target.Namespace.Labels)) {
298-
ns = triNo
299-
}
300-
}
301-
}
302-
if ns == triNo {
303-
return triNo
304-
}
305-
306-
pod := triYes
307-
if peer.PodSelector != nil {
308-
sel, err := metav1.LabelSelectorAsSelector(peer.PodSelector)
309-
if err != nil {
310-
pod = triUnknown
311-
} else if !sel.Matches(labels.Set(target.Pod.Labels)) {
312-
pod = triNo
313-
}
314-
}
315-
if pod == triNo {
316-
return triNo
317-
}
318-
if ns == triUnknown || pod == triUnknown {
319-
return triUnknown
320-
}
321-
return triYes
322-
}
323-
324-
// ipBlockAdmits reports whether one of the pod's addresses of the block's own
325-
// family is inside it and outside every exception — a dual-stack pod reaches
326-
// an IPv6 backend from its IPv6 address, which an IPv4 block says nothing
327-
// about.
328-
func ipBlockAdmits(block *networkingv1.IPBlock, ips []net.IP) tri {
262+
// ipBlockMatch compares the block against the addresses of its own family —
263+
// a dual-stack pod reaches an IPv6 backend from its IPv6 address, which an
264+
// IPv4 block says nothing about. Yes: an address is inside the block and
265+
// outside every exception. No: an address of the family exists and none
266+
// qualifies. Unknown: no address of the family, or the block is malformed.
267+
// Whether a "no" is decisive is the caller's question, not this one's.
268+
func ipBlockMatch(block *networkingv1.IPBlock, ips []net.IP) tri {
329269
_, cidr, err := net.ParseCIDR(block.CIDR)
330270
if err != nil {
331271
return triUnknown
332272
}
333273
v4 := cidr.IP.To4() != nil
274+
out := triUnknown
334275
for _, ip := range ips {
335-
if (ip.To4() != nil) != v4 || !cidr.Contains(ip) {
276+
if (ip.To4() != nil) != v4 {
277+
continue
278+
}
279+
if out == triUnknown {
280+
out = triNo
281+
}
282+
if !cidr.Contains(ip) {
336283
continue
337284
}
338285
excepted := false
@@ -351,7 +298,7 @@ func ipBlockAdmits(block *networkingv1.IPBlock, ips []net.IP) tri {
351298
}
352299
return triYes
353300
}
354-
return triUnknown
301+
return out
355302
}
356303

357304
func podIPs(pod *corev1.Pod) []net.IP {

0 commit comments

Comments
 (0)