fix(network/policy): handle overlapping CIDRs in workload policy address sets - #1031
Merged
Conversation
…ess sets
Workload-policy address sets are declared `flags interval` without
`auto-merge`, so nft refuses any membership write that puts a CIDR into a set
alongside one that covers it:
Error: conflicting intervals specified
set bn-publisher { type ipv4_addr; flags interval;
elements = { 10.0.0.0/24, 10.0.0.5/32 }; }
Reachable from `network policy create`, `add` and `set`, and from the
traffic-shaper daemon's poll loop.
`auto-merge` is deliberately NOT enabled. Unlike the host firewall
(#1002/#1004), which re-renders from an authoritative persisted YAML config
and never reads membership back from the kernel, policy set membership is
never persisted and the kernel is the only copy -- so folding would leave
`delete element` with no exact element to remove and make Manager.Create's
snapshot/restore lossy. Measured against nftables v1.0.6, auto-merge also
merges adjacency into range form (10.0.0.1/32 + 10.0.0.2/32 becomes
10.0.0.1-10.0.0.2), which is not CIDR syntax: netip.ParsePrefix,
sanity.ValidateCIDR and parseElement all reject it, so DiffElements would
report a permanent add and re-apply every set on every poll tick.
Containment is handled in Go instead. Because CIDR prefixes form a tree,
containment is the only possible overlap and the only thing nft rejects, so no
interval arithmetic is needed:
- operator paths reject, naming both prefixes and what to do about it
- the daemon path drops covered prefixes, on both the apply side and the
desired side -- pruning only on apply would leave the reconciler's desired
list permanently different from live, re-applying the set every tick
`network policy remove` also stops leaking nft's bare "element does not exist"
for a covered CIDR, an unrelated non-member, or a batch containing one -- the
last of which removes nothing at all, since the transaction is atomic.
The test fake now enforces nft's real containment and missing-element rules,
so a path that skips these checks fails in unit tests instead of only failing
on a host.
Signed-off-by: alex-au <alex.w.aus@gmail.com>
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
4 tasks
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes nftables flags interval set update failures caused by overlapping (strictly containing) CIDR prefixes in workload-policy address sets, by detecting/pruning containment conflicts in Go rather than relying on nft auto-merge semantics that would introduce lossy range folding and perpetual reconcile churn.
Changes:
- Added CIDR containment detection utilities to reject overlapping operator-authored updates and prune redundant daemon-derived membership.
- Updated policy Manager paths (
Create/Add/Set/Remove, daemon apply paths) to enforce the new containment/missing-member behavior while preserving atomicity expectations. - Updated traffic-shaper desired membership computation to prune covered prefixes so diffing settles (no per-tick re-apply).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| internal/network/policy/cidrset.go | New containment/pruning helpers for CIDR interval-set semantics and improved operator-facing errors. |
| internal/network/policy/cidrset_test.go | New unit + manager-level tests covering containment detection, pruning, and operator/daemon behavior. |
| internal/network/policy/manager.go | Enforces containment checks for operator paths, prunes on daemon paths, and adds live membership prechecks for safer mutations. |
| internal/network/policy/policy_test.go | Enhances the fake nft runner to mirror kernel containment and missing-element atomic failure semantics. |
| internal/network/policy/render.go | Documents why auto-merge is intentionally not enabled for workload-policy sets. |
| internal/blocknode/shaper/policy_map.go | Prunes covered desired endpoints so reconciler diffs settle and digest reflects applied kernel membership. |
| internal/blocknode/shaper/policy_map_test.go | Adds regression tests for no-churn settling, digest stability, and compound-set non-pruning behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Workload-policy address sets are declared
flags intervalwith noauto-merge, so nft refuses any membership write that puts a CIDR into a set alongside one that covers it:Reachable from
network policy create,addandset, and from the traffic-shaper daemon's poll loop.Containment is now handled in Go rather than by the kernel. Because CIDR prefixes form a tree, containment is the only possible overlap between two prefixes and the only thing nft rejects — so no interval arithmetic is needed, only a containment filter. Operator-authored paths reject and name both prefixes; the daemon's derived path drops covered prefixes, because it fully replaces each set every tick and never deletes an individual element.
network policy removealso stops leaking nft's bareelement does not exist.Why not just add
auto-merge?This is the obvious fix and it is deliberately not taken. Measured against nftables v1.0.6:
auto-mergedoes resolve the example above — but it also merges adjacency, and merged adjacency is usually not CIDR syntax at all:Two consecutive peer IPs is the daemon's most ordinary input from statusz. That range form breaks the Go side:
So
auto-mergewould introduce per-tick churn for input that is perfectly valid and non-overlapping. It also breaksdelete elementoutright —delete { 10.0.0.5/32 }after a fold returnsError: element does not exist; nft does not split the interval.The host firewall gets away with
auto-merge(#1002/#1004) for a structural reason: there the kernel is write-only.firewall.Runnerhas no element-level operations,Manager.loadreads the authoritative YAML (falling back to the.nftfile we rendered, notnft list), and the only kernel content read isrunner.ListinsideShow, which is printed and never parsed. The policy plane is the opposite — membership is never persisted, the kernel is the only copy, andListElementsfeeds snapshot/restore, the daemon's diff, andshow.Measured nft behaviour
The design rests on these, verified in a privileged container rather than inferred:
flags interval, noauto-merge/25+/25/24+/32conflicting intervals specifieddelete elementof a non-membernft -fwith any errorThe last row is why no
nft -cdry-run was added: unlike the firewall (which persisted its boot artifact before applying, the actual bug in #1002), the policy plane already applies before persisting, so a dry-run would be dead code.Files changed
internal/network/policy/cidrset.gocovers,containmentPair,PruneContainedCIDRs,rejectContainment,rejectMissingMembersinternal/network/policy/cidrset_test.gointernal/network/policy/manager.goCreate/Addreject;Removepre-checks membership;applySetgainsmembershipSource; newliveMembershipinternal/network/policy/render.goauto-mergeis absent here (no behaviour change)internal/network/policy/policy_test.gointernal/blocknode/shaper/policy_map.godesiredElementsprunes, so the reconciler's diff settlesinternal/blocknode/shaper/policy_map_test.goReview guide
Worth a close look:
cidrset.go—covers()is strict containment (outer.Bits() < inner.Bits()). An exact duplicate must not be a conflict, or the idempotent re-add that works today would start failing.cidrset.go— thesortCIDRElemsdoc comment argues why comparing each candidate against only the last retained element is sufficient. That is the one non-obvious algorithmic step.cidrset.go—parseCIDRElemsdrops compound<ip> . <port>and unparseable tokens, so neither path can touch them (AC#4).PruneContainedCIDRsreturns a subset in input order — never rewrites, merges, or invents a prefix. This is what keepsListElementsround-tripping (AC#2).manager.goCreate— rejection happens beforewithLockand before any kernel or disk write.manager.goRemove— the pre-check runs before the firstDeleteElements, so a rejected batch removes nothing.policy_map.godesiredElements— pruning here is what makes the diff settle; removing it reintroduces a per-tick re-apply..nftfixtures are unchanged.Note: the policy package still uses plain
errorxrather than the newererrx+ reason-code contract in CLAUDE.md. The package has zeroerrxusage today, so the new errors match their surroundings; adoptingerrxpackage-wide is a separate cleanup.Tests:
Mutation-checked, so the suite demonstrably bites: forcing
covers()tofalsefails 21 tests; reverting thedesiredElementsprune fails the 2 churn/digest tests; forcingrejectMissingMemberstonilfails the 5Removetests.Manual UAT
Prereq: a host with the workload-policy table live (after
block node install, ornetwork policy create).1. Covered CIDR rejected, both prefixes named
Expected — not nft's text:
2. Reverse direction rejected, with different advice
3. Exact re-add and adjacency still work (must NOT be rejected)
4. Overlapping list in one invocation rejected, prior membership intact
5.
removegives a legible error6. A batch containing one absent entry removes nothing
7. Removing an exact member still works (AC#3)
8. Daemon neither wedges nor churns
9. Cleanup — restore the membership recorded in step 0 with
network policy set.Risks
firewall add --cidrfolds an overlap silently (fix(network/firewall): merge overlapping CIDRs and never persist a rejected ruleset #1004) whilepolicy addrejects it. Deliberate, for the structural reason above; the error text explains it. Worth revisiting if feat(daemon): persist statusz-derived nft set membership so it survives a reboot #990 gives the policy plane a persisted membership record.auto-mergeis only safe while nothing compares live table content against the persisted config. Adding firewall drift detection would hit this same range-form problem.internal/network/policyplus one function ininternal/blocknode/shaper.Related Issues