fix(blocknode): keep a hand-created host firewall across block node reconfigure - #1005
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. |
a0dc0b1 to
499d50b
Compare
…econfigure A host firewall created with `network firewall create` was silently deleted by the next `block node reconfigure` -- table, both on-disk artifacts, and the management allowlist -- while the command reported success. `reconfigure` seeds its enable/disable choice from MachineState.Firewall, which only the block-node workflow ever wrote. The standalone verbs stopped at the manager, so a hand-created firewall left the record nil -- indistinguishable from "explicitly disabled" -- and the no-flag seed resolved to disabled, scheduling step_network_firewall_delete. Make the two paths agree: - `network firewall create` / `delete --all` record the enable decision into MachineState.Firewall. Best-effort: an nft ruleset already applied to the kernel must not be reported as a failure because a bookkeeping write missed. The delete side matters just as much as create -- a record still saying "enabled" would make the next reconfigure re-create a table just removed. - reconfigure's seed consults the live table via Manager.IsActive, which that method's own doc comment already claimed was wired up. A live table always seeds enabled, so removing an active host firewall now requires an explicit --firewall-enabled=false rather than being the default outcome of an unrelated reconfigure. - The firewall's own config file becomes a content precedence tier above machine state (flag > --config > live firewall > state > default), so a reconfigure's force re-render no longer reverts an urgent `add --name mgmt --cidr ...` back to the allowlist captured at install time. The projection leaves out what HostConfig cannot hold -- IPv6 members and inclusive port ranges -- with a warning rather than silently, or in the IPv6 case failing HostConfig.Validate and aborting the whole reconfigure. Closes #1003 Signed-off-by: alex-au <alex.w.aus@gmail.com>
499d50b to
93d2fa4
Compare
There was a problem hiding this comment.
Pull request overview
This PR fixes a block-node operational footgun where a host firewall created via the standalone solo-provisioner network firewall create command could be silently torn down by a later block node reconfigure due to missing/unsynced persisted “enablement decision” state. It aligns the standalone firewall verbs with the block-node workflow by recording the enable/disable decision, and it makes reconfigure seeding consult live kernel ground truth so an active firewall is never removed by default.
Changes:
- Persist the host-firewall enable/disable decision from
network firewall createandnetwork firewall delete --allinto runtime state. - Seed
block node reconfigure’s firewall gate from a newResolveFirewallSeedthat prefers “live table exists ⇒ enabled” over an absent/disabled recorded decision. - Add a “live firewall config” precedence tier so urgent operator edits in
/etc/solo-provisioner/network-weaver-host-firewall.yamlaren’t reverted by reconfigure.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| cmd/cli/commands/network/firewall/state.go | New best-effort runtime-state decision writer for standalone firewall verbs. |
| cmd/cli/commands/network/firewall/state_test.go | Tests for decision persistence behavior using an in-memory state.Manager. |
| cmd/cli/commands/network/firewall/create.go | Record disabled=false after successful create (including no-op create). |
| cmd/cli/commands/network/firewall/delete.go | Record disabled=true after successful --all teardown. |
| cmd/cli/commands/network/firewall/firewall_test.go | Ensure unit tests stub state so tests never touch host runtime state. |
| cmd/cli/commands/common/host_firewall.go | Add ResolveFirewallSeed, live firewall merge tier, and table→HostConfig projection. |
| cmd/cli/commands/common/host_firewall_test.go | Seed truth table tests + precedence tests + lossy-projection guard. |
| cmd/cli/commands/block/node/reconfigure.go | Use ResolveFirewallSeed instead of only persisted state for firewall seeding. |
| internal/network/firewall/manager.go | Update IsActive doc comment to reflect real callers. |
| docs/quickstart.md | Document new seeding/precedence behavior and decision recording. |
| docs/dev/traffic-shaper.md | Document decision/content split between machine state and firewall config file. |
Suppressed comments (1)
cmd/cli/commands/common/host_firewall.go:439
- This warning recommends
network firewall set --ports, butsetrequires--namewhen using--cidrs/--ports. Including--name <rule>makes the remediation actionable.
logx.As().Warn().Strs("ports", skipped).Msg(
"the live host firewall's " + desc + " block holds port ranges, which the flag-shaped config cannot " +
"express; they are not seeded and a re-render would drop them — re-apply them afterwards with " +
"`network firewall set --ports`")
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Alex Wang <alex.w.aus@gmail.com>
Description
A host firewall created with the standalone
network firewall createverb was silently deleted by the nextblock node reconfigure— table, both on-disk artifacts, and the management allowlist — while the command reported success.Two sources of truth that never synced.
reconfigureseeds its enable/disable choice fromMachineState.Firewall(cmd/cli/commands/block/node/reconfigure.go:61), which only the block-node workflow ever wrote —patchMachineFirewallFromConfigruns inside the intent handlers. The standalone verbs stopped at the manager, so a hand-created firewall left the recordnil, which is indistinguishable from "explicitly disabled". The no-flag seed resolved to disabled andNetworkPlaneStepstook the teardown branch.On a remote host with no console, tearing down a management allowlist is a truck roll — so this PR makes the two paths agree, and additionally makes removal of an active firewall require saying so.
What changed
The standalone verbs record the decision.
network firewall createanddelete --allnow writemachineState.firewall.disabled, the same field the block-node workflow writes. Best-effort by design:network firewall createis node-agnostic and may run on a host with no state file, and an nft ruleset already applied to the kernel must not be reported as a failure because a bookkeeping write missed. Thedeleteside matters as much ascreate— a record still saying "enabled" would make the next reconfigure re-create a table the operator just removed.The seed consults kernel ground truth.
common.ResolveFirewallSeedfalls back toManager.IsActive, which that method's own doc comment already claimed was wired up (internal/network/firewall/manager.go:194-201) but which had no caller outside the rollback guard. A live table always seeds enabled, whatever state records, so removing an active host firewall is only reachable through an explicit--firewall-enabled=falseor an interactive decline. The converse is untouched: with no live table the recorded decision stands, so a reconfigure still re-asserts a firewall state says should be there.The live firewall becomes a content precedence tier. Content resolution is now
flag > --config > live firewall > MachineState.Firewall > default./etc/solo-provisioner/network-weaver-host-firewall.yamlis always at least as fresh as machine state — every path that writes machine state also re-renders the firewall, but the standalone verbs write only the firewall. Without this tier a reconfigure's force re-render would revert an urgentnetwork firewall add --name mgmt --cidr …back to the allowlist captured at install time.Why the decision is mirrored but the content is not
models.HostConfigis flag-shaped and strictly narrower than afirewall.Table: its CIDR fields are IPv4-only and its port fields are[]int, where aRuleholds port specs that may be inclusive ranges (2379-2380). Mirroring reserved-block content into machine state would therefore have to drop exactly the kind of operator data this issue is about. Reading the firewall's own config file instead is lossless for the path that matters, andhostConfigFromTableleaves out whatHostConfigcannot hold — with a warning naming the skipped values, rather than silently, or in the IPv6 case failingHostConfig.Validateand aborting the whole reconfigure.Base
Targets
main. This was originally opened against00996-collapse-icmp-path-health-accepts(#997), because the declarative YAML config and named allow rules it builds on landed via #999, which had merged into00996rather thanmain. Now that #997 is inmain(7facfc1), the branch has been rebased with--ontoso only this PR's own commit is replayed — a plain rebase would have re-applied commits already folded into that squash merge.Files changed
cmd/cli/commands/network/firewall/state.gorecordHostFirewallDecision, the best-effort decision writecmd/cli/commands/network/firewall/state_test.gostate.Managercmd/cli/commands/network/firewall/create.godisabled: falseafter a successfulCreate, no-op includedcmd/cli/commands/network/firewall/delete.godisabled: trueafter a successful--allteardowncmd/cli/commands/network/firewall/firewall_test.gostubManagerso no unit test can touch the host's real statecmd/cli/commands/common/host_firewall.goResolveFirewallSeed,mergeLiveHostFirewall, and theTable→HostConfigprojectioncmd/cli/commands/common/host_firewall_test.gocmd/cli/commands/block/node/reconfigure.goResolveFirewallSeedinternal/network/firewall/manager.goIsActivedoc comment now names its real callersdocs/quickstart.mdnetwork firewallnote on the recorded decisiondocs/dev/traffic-shaper.mdReview guide
Code-review checklist
ResolveFirewallSeed(cmd/cli/commands/common/host_firewall.go) — verify the direction of the override is one-way: a live table forces enabled, but an absent table never forces disabled over a recordeddisabled: false. The probe error path must fall back to the recorded decision (pre-existing behaviour), not to a guess.hostConfigFromTable— every field it fills must surviveHostConfig.Validate(). This is asserted directly inTestHostConfigFromTable_SkipsWhatHostConfigCannotHold; an IPv6 mgmt CIDR reachingHostConfigwould turn a working reconfigure into a hard error, which is worse than not seeding the field.ResolveHostFirewallConfig—mergeLiveHostFirewallmust run beforemergeHostFirewallFromState; both only fill empty fields, so the order is what encodes the precedence.recordHostFirewallDecision— confirm it can never return an error to the verb. The nft ruleset is already live at that point.NetworkPlaneSteps(internal/workflows/network_setup.go:85) is deliberately unchanged: once the seed is ground-truth-backed,Host.Disabled == truewith a live table can only come from an explicit flag or prompt, so threading an "explicit opt-out" boolean throughNetworkPlaneOptionswould buy nothing.Test commands
These packages transitively import the Linux-only
internal/mount, so they do not build on macOS — run them in the VM (task vm:test:unit) or a Linux container.Manual UAT (not yet run — Debian 13 VM, the issue's exact reproducer)
Risks / rollback
networkcommand tree, on a host that may have no state file. Mitigated by making the write best-effort and never failing the verb.machineState.firewallblock written by a standalone verb is exactly the shape the block-node path already writes.Related Issues