fix(network/firewall): merge overlapping CIDRs and never persist a rejected ruleset - #1004
Conversation
✅ 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. |
There was a problem hiding this comment.
Pull request overview
Fixes the host-firewall failure mode where adding an overlapping CIDR causes nft to reject the ruleset and (previously) persisted an unloadable artifact, potentially leaving the host without the weaver firewall on next boot. The PR also hardens the apply path by validating rulesets before persisting and prevents systemd start-limit wedging for the shared oneshot unit.
Changes:
- Add
auto-mergeto all host-firewall address sets so overlapping CIDRs are accepted by nftables. - Dry-run (
nft -c -f) the rendered ruleset before persisting config/artifacts and restarting the loader unit. - Disable systemd start limiting for the shared oneshot unit and ensure the unit file is rewritten on content drift; update tests and docs accordingly.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| internal/templates/files/network/network-weaver-host-firewall.nft.tmpl | Add auto-merge to address set declarations and expand rationale comment. |
| internal/templates/files/network/solo-provisioner-network-nft.service | Disable start rate limiting (StartLimitIntervalSec=0) with rationale. |
| internal/network/firewall/nft.go | Extend Runner with Check and implement nft -c -f dry-run. |
| internal/network/firewall/manager.go | Validate rendered ruleset before writing config/artifact and restarting unit. |
| internal/network/firewall/service_linux.go | Rewrite shared unit on drift (compare embedded vs on-disk). |
| internal/network/policy/service_linux.go | Apply the same unit drift reconciliation from the policy plane. |
| internal/network/firewall/firewall_test.go | Add fake Check support and new tests covering overlap + dry-run semantics. |
| internal/network/firewall/allow_test.go | Update assertions for new set declaration shape (auto-merge). |
| internal/network/firewall/testdata/network-weaver-host-firewall.golden.nft | Regenerate golden output to include auto-merge and updated comments. |
| internal/network/firewall/testdata/network-weaver-host-firewall-allow.golden.nft | Regenerate golden output for allow-rules rendering with auto-merge. |
| cmd/cli/commands/network/firewall/firewall_test.go | Add Check to test runner and avoid writing config to real /etc in tests. |
| internal/workflows/steps/step_network_firewall_test.go | Add Check stub to step test runner. |
| docs/quickstart.md | Document overlapping CIDR behavior and “reject-before-write” guarantee. |
| docs/dev/traffic-shaper.md | Document auto-merge semantics, dry-run-before-persist, and start-limit change. |
Suppressed comments (2)
internal/network/firewall/service_linux.go:66
- EnsureNetworkNftUnit can now rewrite the existing systemd unit on drift, but writeEmbedded uses os.WriteFile (truncate+write) which is not atomic. A crash or concurrent writers (firewall vs policy plane) could leave a torn unit file under /usr/lib/systemd/system. Other unit installers in this repo use atomicWriteFile to avoid this (e.g. internal/network/shape/service_linux.go). Consider using the package's atomicWriteFile here as well.
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return errorx.ExternalError.Wrap(err, "failed to create %s", filepath.Dir(destPath))
}
if err := os.WriteFile(destPath, content, 0o644); err != nil {
return errorx.ExternalError.Wrap(err, "failed to write %s", destPath)
internal/network/policy/service_linux.go:45
- defaultEnsureService can now rewrite the shared network-nft unit on content drift, but it writes the unit with os.WriteFile (truncate+write), which is not atomic. A crash or concurrent writers can leave a torn unit file. The policy package already has an atomicWriteFile helper (used for persisted artifacts) that can be reused here to make unit updates durable.
if err := os.MkdirAll(filepath.Dir(NetworkNftServiceUnitPath), 0o755); err != nil {
return errorx.ExternalError.Wrap(err, "failed to create %s", filepath.Dir(NetworkNftServiceUnitPath))
}
if err := os.WriteFile(NetworkNftServiceUnitPath, content, 0o644); err != nil {
return errorx.ExternalError.Wrap(err, "failed to write %s", NetworkNftServiceUnitPath)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…jected ruleset
Adding a CIDR covered by one already in a rule — the documented
`add --name k8s-node --cidr 10.0.0.5/32` against a rule holding
10.0.0.0/24 — made the nft apply fail and left the host with a
persisted ruleset that no longer loads at boot.
Three coupled fixes:
Address sets now carry `auto-merge`, matching the port sets. Without
it nft refuses two overlapping elements outright ("conflicting
intervals specified"); with it the narrower prefix folds into the one
that covers it. The config keeps both entries, so removing the wider
prefix later leaves the narrower one in force. The cost is that the
kernel read-back is now lossy for addresses as it already was for
ports — the persisted YAML config was already the source of truth.
Every mutation now dry-runs the rendered document through `nft -c -f`
before either artifact is written, via a new Check on the existing
Runner seam. applyAndPersist wrote both files before asking systemd to
load them, and the unit has no ExecStop — so a rejected ruleset left
the live table intact and the failure looked harmless, while the
persisted artifact that replays at boot had become unloadable. The
host then came up with no weaver firewall at all: no management
allowlist, no block list.
The shared network-nft unit sets StartLimitIntervalSec=0. It is
restarted on every firewall and policy mutation, so systemd's default
start rate limit turned one bad apply into an opaque start-limit-hit
on every later command until someone ran `systemctl reset-failed` — in
UAT, one real failure became 18. EnsureNetworkNftUnit (and its policy
counterpart) now compares the on-disk unit against the embedded copy
instead of stat-and-skip, so already-provisioned hosts converge on it.
Verified against nftables v1.1.3 on Debian 13: the pre-fix rendering
reproduces the reported error and the post-fix rendering passes
`nft -c -f`, with the kernel reading the pair back as 10.0.0.0/24.
Fixes #1002
Signed-off-by: alex-au <alex.w.aus@gmail.com>
Signed-off-by: alex-au <alex.w.aus@gmail.com>
3796d64 to
e5349ec
Compare
…, not exit code nft -c -f exits non-zero for two unrelated reasons: a ruleset it parsed and refused, and an environment failure (no CAP_NET_ADMIN, a netlink error, a missing binary). Check treated every *exec.ExitError as IllegalFormat, so a privilege problem was surfaced as malformed input and pointed the operator at their ruleset instead of their permissions. Only a rejected ruleset reports a source position, so the <path>:<line>:<col> prefix — for the exact file we handed nft — is what now selects IllegalFormat; everything else returns ExternalError with the cause and stderr attached, which it previously dropped. Matching the position rather than the message keeps this independent of nft's wording across versions, and requiring our own path stops a diagnostic about an included file being read as a verdict on ours. Covered by table-driven cases built from verbatim nft 1.1.3 output: the conflicting-intervals and syntax-error diagnostics, against Operation not permitted, a netlink failure, another file's position, and our path named without a position. Signed-off-by: alex-au <alex.w.aus@gmail.com>
Description
Adding a CIDR to a host-firewall rule that already holds one covering it — the documented
add --name k8s-node --cidr 10.0.0.5/32against a rule holding10.0.0.0/24— made the nftapply fail, and left the persisted artifacts holding a document that no longer loads at boot:
The loud part is the CLI error. The quiet part is the damage: the artifacts were written
before the apply, the unit has no
ExecStopso the live table stayed intact, and the hostthen came up at next boot with no weaver firewall at all — no management allowlist, no block
list. Three coupled fixes, all three of which the issue asks for.
1. Address sets carry
auto-mergeThe address sets were declared
flags intervalwithoutauto-merge, so nft refuses twooverlapping elements outright. The port sets in the same template already carry it, which is
why
--portnever hit this and--cidrdid. Withauto-mergethe narrower prefix folds intothe one that covers it.
The declarative config keeps both entries —
10.0.0.0/24and10.0.0.5/32— so removingthe wider prefix later correctly leaves the narrower one in force. Only the kernel folds them.
That makes the kernel read-back lossy for addresses the way it already was for ports:
firewall showdumps the kernel and prints the folded form, whilefirewall show --output yamlreads the config and prints what the operator authored.2. The ruleset is dry-run before anything is persisted
applyAndPersistrendered, wrotenetwork-weaver-host-firewall.yaml, wrote.nft, and thenrestarted the unit. It now renders, dry-runs the document through
nft -c -f, and only writesif that passes. A rejected ruleset therefore never reaches disk, and the artifact that replays
at boot is always one that loads.
The dry run goes through a new
Check(ctx, path)on the existingRunnerseam innft.go,alongside
ListandDelete— tests already substitute a fake Runner, so the package stillbuilds and unit-tests on macOS. Past the dry run the config is still written before the nft
artifact, preserving the existing invariant that a crash between the two writes loses the
kernel state rather than the operator's intent.
This is deliberately not apply-then-persist: inverting the order would have lost the intent
while leaving the ruleset live, with nothing left to re-derive it from.
3. One bad apply no longer wedges every later command
The shared unit is restarted on every firewall and policy mutation, so a run of failed applies
tripped systemd's default start rate limit and left every subsequent
network firewallcommand failing with the same opaque error until someone ran
systemctl reset-failed. In theUAT that found this, one real failure became 18. The unit now sets
StartLimitIntervalSec=0—there is nothing to rate-limit, it is a oneshot that loads a file and exits.
EnsureNetworkNftUnitstat-and-skipped the unit file, which would have stranded everyalready-provisioned host on the unit that shipped when it was first provisioned. It now
compares the on-disk unit against the embedded copy and rewrites on drift. The same change is
in
internal/network/policy/service_linux.go, which writes the same shared path, so whicheverplane runs first converges the host rather than the two fighting.
Files changed
internal/templates/files/network/network-weaver-host-firewall.nft.tmplauto-mergeon all eight address set declarations; rewritten rationale comment covering addresses as well as portsinternal/templates/files/network/solo-provisioner-network-nft.serviceStartLimitIntervalSec=0internal/network/firewall/nft.goCheck(ctx, path)added toRunner;execRunnerrunsnft -c -fand distinguishes a refused ruleset from a broken hostinternal/network/firewall/manager.goapplyAndPersistdry-runs before either write; newcheckhelper stages the document beside the real artifactinternal/network/firewall/service_linux.goEnsureNetworkNftUnitrewrites on content drift instead of stat-and-skipinternal/network/policy/service_linux.gointernal/network/firewall/firewall_test.goCheckonfakeRunner(with a rejection hook); five new testsinternal/network/firewall/allow_test.gointernal/network/firewall/testdata/*.golden.nftcmd/cli/commands/network/firewall/firewall_test.go,internal/workflows/steps/step_network_firewall_test.goCheckon their fake Runnersdocs/dev/traffic-shaper.md,docs/quickstart.mdDeliberately out of scope
internal/network/policy/render.go:221-222declares the workload-policy address sets with theidentical missing
auto-merge. It is not fixed here: those sets are mutated incrementallyby the daemon (
add element/delete element), and auto-merge would make a later per-elementdelete of a folded prefix fail. Worse, policy set membership is never persisted, and
Manager.Createsnapshots it viaListElementsbefore its destructive delete/recreate — withauto-merge that snapshot reads back folded, so the restore would permanently overwrite the
authored membership. The host firewall is safe to auto-merge precisely because the opposite is
true of it: the YAML config is authoritative, and every mutation re-renders and reloads the whole
table.
Same symptom, genuinely different fix. Filed as #1006.
Also not done: rejecting overlapping CIDRs up front in
Rule.AddCIDRs. The issue allows eitherbehaviour; folding is the friendlier one and keeps the config declarative.
Review guide
Worth a close look:
manager.goapplyAndPersist— the dry run must come before both writes. If it movedbelow the config write, a rejected ruleset would still clobber the operator's intent.
manager.gocheck— the document is staged in the nft artifact's own directory, not thesystem temp dir, so the check runs on the same filesystem and permissions the real load sees.
nft.goCheck— a non-zero exit isIllegalFormat(the ruleset is wrong); anything else isExternalErrorwith the cause attached (the host is wrong). Getting these backwards wouldmis-style the error in the doctor layer.
allow_test.go— the inlinelegacyfixture inTestManager_LoadsFromLegacyNftWhenConfigMissingintentionally keeps the pre-
auto-mergerendering.Parsemust handle both, andTestRoundTrip_RenderParseRendercovers the new one.Test plan
New unit coverage in
internal/network/firewall:TestRender_OverlappingCIDRsShareAnAutoMergeSetauto-merge, allow rules includedTestManager_AddCoveredCIDRTestManager_RejectedRulesetIsNeverPersistedTestManager_ChecksTheDocumentItPersistsTestNetworkNftUnit_HasNoStartLimitStartLimitIntervalSec=0survives future template editsManual UAT
Case 1 — the reported repro now succeeds
Expected: the
addsucceeds. Before this PR it failed withservice ... start failed.Case 2 — the persisted artifact still loads
Expected:
exit=0, no output. Before this PR this failed withconflicting intervals specified, which is what made the next boot come up bare.Case 3 — config keeps intent, kernel shows the fold
Expected: the YAML holds both
10.0.0.0/24and10.0.0.5/32; the kernel set reads backas
elements = { 10.0.0.0/24 }alone. This asymmetry is the intended trade — verify both halves.Then confirm the narrower prefix is real rather than swallowed, by removing the wider one:
Expected: the kernel set is now
elements = { 10.0.0.5/32 }. Restore it withadd --name k8s-node --cidr 10.0.0.0/24before continuing.Case 4 — the firewall survives a reboot
Expected: the table is present and the unit is
active (exited). Before this PR the tablewas gone — this is the failure the whole PR exists to prevent.
Case 5 — a rejected ruleset leaves disk untouched
Force a document nft will refuse, by hand-editing the config to something invalid, then
snapshotting the artifacts around a mutation:
Expected: the command errors, the error text names the nft complaint and says nothing was
written,
diffreports no change, andnft -c -fstill exits 0. If the config validator rejectsthe hand-edit before the render is reached, that is also an acceptable pass — the point is that
the on-disk
.nftnever degrades.Case 6 — no start-limit wedge
Expected:
StartLimitIntervalSec=0. On a host provisioned by an older build, confirm theunit converges rather than staying stale — check it before running any mutation, then again
after:
Expected: absent before, present after — this is the upgrade path that stat-and-skip would
have blocked.
Verification already done
nft -c -fagainst nftables v1.1.3 on Debian 13 (arm64), on both renderings of the sameoverlapping ruleset:
And the kernel read-back, loaded as a standalone set-only probe table (no hooked chains, so the
VM's own SSH was never at risk):
The full
create --from-file→add --cidr→ reboot walkthrough above has not been runend-to-end — it needs a VM whose
mgmt.cidrscovers its own SSH source, which the available VMdid not have. Cases 1–6 are written to be run by a reviewer on such a host.
Risks
nft -c -fon every mutation adds an exec to a path that previously had none. A hostmissing the nft binary now fails the mutation outright instead of half-succeeding — arguably
correct, but it is a new failure mode. The error names the binary and the check.
nft listand expects to recover the authored CIDR list will now see folded prefixes.parse.gois unaffected — it parses the artifact we render, not kernel output — andshow --output yamlreads the config.sites get the same logic here, so they converge; an unchanged unit is still a no-op with no
write and no
daemon-reload.Stacking note
Based on
00996-collapse-icmp-path-health-accepts(#997), notmain— the code this fixes(the declarative config, named allow rules, the current
applyAndPersist) lives on that branchvia #999 and has not reached
mainyet. This PR targets #997's branch and will retarget tomainautomatically when #997 merges.The bug itself is older than #999 — the address sets have lacked
auto-mergesincefd3a73b—but named allow rules make it far easier to reach, since operators get many more sets to
add --cidrinto.Related Issues