Skip to content

feat(network/firewall): support named allow rules and a declarative config file - #999

Merged
alex-au merged 2 commits into
00996-collapse-icmp-path-health-acceptsfrom
00998-firewall-named-allow-rules
Aug 13, 2026
Merged

feat(network/firewall): support named allow rules and a declarative config file#999
alex-au merged 2 commits into
00996-collapse-icmp-path-health-acceptsfrom
00998-firewall-named-allow-rules

Conversation

@brunodam

@brunodam brunodam commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Description

The host firewall rendered exactly three hardcoded bindings. internal/network/firewall/table.go
contained no udp, no interval and no auto-merge, so there was no UDP, no port ranges, one
management group on a single scalar port, and no way to grant unmetered ICMP echo to a named set of
sources. That is not enough to express a complete host ruleset, which weaver now has to do on
operator-managed hardware where no external configuration management supplies one.

This generalises the table to named allow rules over three reserved blocks. mgmt, blocked and
in_cluster stay first-class because weaver derives or defaults their content and omitting one is
dangerous — an empty mgmt list locks the operator out, an absent in-cluster list breaks the cluster,
and the block list renders on three hooks rather than one. Everything else is an operator-authored
source list x port list x protocol accept:

ip  saddr @k8s-node tcp dport @k8s-node_ports accept
ip6 saddr @admin6   tcp dport @admin_ports    accept

Structure is file-only; membership is CLI-mutable. Adding a rule is a reviewed change, while
unblocking an operator is sometimes urgent. The same YAML is the --from-file input, the persisted
state, and the output of show --output yaml, so the round-trip is exact by construction rather than
by test.

Config file

version: 1
mgmt:
  cidrs: ["192.168.68.0/24"]
  ports: ["22"]                                # now a list; --ssh-port is sugar for one element
blocked:
  cidrs: []
in_cluster:                                    # both fields optional
  cidrs: ["10.4.0.0/14"]                       # omitted -> auto-detected
  ports: ["4244", "6443", "7472", "10250"]     # omitted -> defaults
allow:
  - name: k8s-node
    cidrs: ["10.0.0.0/24"]
    ports: ["6443", "2379-2380", "10250", "10256-10259"]
  - name: cilium-vxlan
    cidrs: ["10.0.0.0/24"]
    ports: ["8472"]
    proto: udp
  - name: admin
    cidrs: ["203.0.113.5/32", "2001:db8:5e5::/64"]
    ports: ["22"]
    icmp_echo: true

Deliberate decisions a reviewer should weigh

allow: is declarative; the reserved blocks are required. An allow entry absent from an applied
file is deleted. A reserved block absent from it is an error, not a silent default — the earlier
revision of this PR defaulted it, and for mgmt that default is an empty address list under the
input chain's default drop, i.e. exactly the lockout the asymmetry was meant to prevent. cidrs is
required inside mgmt and blocked for the same reason. Omitting in_cluster.cidrs still means
"auto-detect this node's pod CIDR" and in_cluster: {cidrs: []} still means "render no rule", which
is why nil-vs-empty on a decoded slice is load-bearing and has its own test. Raised by @brunodam in
review.

The YAML config replaces the rendered ruleset as the source of truth for the mutating verbs.
auto-merge is required for port ranges and also collapses adjacent entries, so the kernel can read
back differently from what was written — re-deriving intent from the ruleset would be lossy. Parse
stays as a fallback that recovers the reserved blocks from a pre-existing artifact (both the old and
new renderings), so a host that lost its config file still yields its management allowlist rather than
an error leaving the operator no way back in. Named allow rules are deliberately not
reverse-engineered from nft syntax: losing them is recoverable, losing management access is not.

One state file, not one JSON per record — a deviation from what #998 suggested. A change to the
management allowlist must be all-or-nothing, and a partial write across several files could leave a
host reachable by nobody. Happy to split it if you prefer the policy registry shape.

create --from-file follows create-if-missing, like every other create in this repo, so
re-applying an edited file needs --force. The existing warning names the flag explicitly, so this is
friction rather than silence — but it is the one call here I could see going the other way.

icmp_echo renders above the rate meter. Post-#997 the meter is limit rate over … drop, so an
accept placed below it would never be reached under a flood — exactly when an operator needs their own
ping to work. Pinned by an ordering test in both families.

Reserved blocks keep their shipped set names (mgmt_addrs, in_cluster_ports) while allow rules
use the bare name, matching the workload plane's @bn-publisher. All three derivations go through one
function, and Table.Validate rejects collisions — an allow rule named mgmt_addrs would claim the
mgmt block's set, and one named k8s6 would claim the v6 set of k8s. nft accepts a duplicate set
declaration silently and merges the membership, so this has to be caught before render.

Backward compatibility

Every pre-existing invocation behaves identically. The per-block flags are retained as shorthands that
name their reserved block implicitly, set still accepts several of them in one call (as one nft
transaction), and bare delete still means --all. TestBackwardCompatibleInvocations drives each
older form end to end.

One behaviour change worth flagging: delete --all now asks for confirmation in an interactive
session. Non-interactive callers are unaffected (prompt.ShouldPrompt is false), and --force skips it.

Files changed

File Change
internal/network/firewall/rule.go New. Rule, Proto, reserved names, port-spec parsing/ordering, derived set naming
internal/network/firewall/config.go New. FileConfig schema, strict YAML load, table conversion both ways
internal/network/firewall/table.go Table becomes three reserved Rules plus Allow; name lookup, upsert/delete, collision check
internal/network/firewall/render.go Per-rule flattening, family scoping
internal/network/firewall/manager.go Config-file store, generic Add/Remove/Set/SetMany/DeleteRule/Apply/Table
internal/network/firewall/parse.go Reserved-block recovery from both renderings
internal/templates/files/.../network-weaver-host-firewall.nft.tmpl Ranged set decls, per-family allow rules, icmp_echo above the meter
cmd/cli/commands/network/firewall/*.go --name, --from-file, --output yaml, --all, --cidrs-file
internal/workflows/steps/step_network_firewall.go Carries named allow rules across a reconcile re-render
docs/quickstart.md, docs/dev/traffic-shaper.md CLI surface and the design rationale

Review guide

Start with rule.go and table.go (the model), then the template, then manager.go's load/
applyAndPersist ordering. The CLI is mechanical by comparison.

The bug worth checking I actually fixed: step_network_firewall.go force re-renders on
block node reconfigure, and config.yaml has no field for allow rules — so a reconfigure would have
wiped every operator-authored k8s/Cilium/admin rule while reporting success. The step now carries them
across. TestNetworkFirewallCreate_PreservesNamedAllowRules covers it.

Real-kernel verification — string tests cannot tell you whether nft accepts the document:

cd internal/network/firewall/testdata
docker run --rm --privileged -v "$PWD:/t:ro" debian:stable-slim sh -c '
  apt-get update -qq && apt-get install -y -qq nftables &&
  for f in network-weaver-host-firewall.golden.nft network-weaver-host-firewall-allow.golden.nft; do
    nft -c -f /t/$f && nft -f /t/$f && nft -f /t/$f; done &&
  nft list set inet weaver-host-firewall k8s-node_ports'

Both goldens load and re-apply idempotently on nftables 1.1.3, dual-stack, with the range preserved as
a single element:

elements = { 2379-2380, 6443, 10250, 10256-10259 }

The full unit suite passes on Linux (go test ./internal/... ./pkg/... ./cmd/... after task mocks).
The network firewall CLI package is Linux-only via internal/mount, so it does not run on macOS.

Manual UAT on the VM harness:

task network:all && task network:ip
sudo solo-provisioner network firewall create --from-file rules.yaml --force
sudo solo-provisioner network firewall show --output yaml | diff - rules.yaml   # expect no drift
sudo solo-provisioner network firewall add --name k8s-node --cidr 10.0.0.9/32
sudo nft list chain inet weaver-host-firewall input_ipv4
ping <host>   # from an admin CIDR, under a concurrent ping flood from elsewhere

Stacking note

Based on 00996-collapse-icmp-path-health-accepts (#997), not main: that PR inverts the ICMP rate
meter and rewrites the chains this one inserts into, and the icmp_echo placement only makes sense
against the inverted form. Merge #997 first, then retarget this to main.

Out of scope

Called out in #998 and unchanged here: block-node service ports (that traffic is forwarded, so an
input rule for it never matches), confirm-or-roll-back apply, migration of externally-managed hosts,
cluster-derived k8s/Cilium rules, and groups: address sugar.

Of those, confirm-or-roll-back apply is the one that should land before this is relied on as the only
host firewall
on hardware with no console — a bad render is otherwise a truck roll.

Related Issues

…onfig file

The host firewall rendered exactly three hardcoded bindings, with no UDP, no
port ranges, one management group on a single scalar port, and no way to grant
unmetered ICMP echo to a named set of sources. That is not enough to express a
complete host ruleset, which weaver now has to do on operator-managed hardware
where no external configuration management supplies one.

Generalise to named allow rules over three reserved blocks. `mgmt`, `blocked`
and `in_cluster` stay first-class because weaver derives or defaults their
content and omitting one is dangerous; everything else is an operator-authored
source list x port list x protocol accept, rendered per family as
`<family> saddr @<name> <proto> dport @<name>_ports accept`. Port sets gain
`flags interval` + `auto-merge` so a range is a single element, and `mgmt.ports`
becomes a list with `--ssh-port` as sugar for a one-element one.

Structure is declared in a YAML config file; membership is mutable from the CLI
via `--name`, which reaches a reserved block and an allow rule alike. That file
is also the persisted state and the output of `show --output yaml`, so the
round-trip is exact by construction rather than by test. It replaces the
rendered ruleset as the source of truth for the mutating verbs, since
`auto-merge` means the kernel can read back merged differently from what was
written; `Parse` stays as a fallback that recovers the reserved blocks from a
pre-existing artifact, so a host that lost its config never loses management
access.

Every pre-existing invocation keeps working: the per-block flags are retained as
shorthands that name their reserved block implicitly, bare `delete` still means
`--all`, and a regression test drives each older form end to end.

Two asymmetries are deliberate and worth stating. `allow:` is declarative — an
entry absent from an applied file is deleted — while a reserved block absent
from it is defaulted rather than removed, so a partial file cannot silently drop
management access. And an `icmp_echo` rule renders above the rate meter, because
the meter drops over-budget echo outright and an accept below it would never be
reached under a flood.

Both goldens were verified against nftables 1.1.3: they load, re-apply
idempotently, and preserve `2379-2380` as a single range element.

Signed-off-by: Bruno Marques <bruno.marques@hashgraph.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Bruno Marques <bruno.marques@swirldslabs.com>
@brunodam
brunodam requested a review from a team as a code owner August 12, 2026 09:20
@brunodam
brunodam requested review from boris-bonin and a lite review from Copilot August 12, 2026 09:20
@swirlds-automation

swirlds-automation commented Aug 12, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR generalizes the node-level host firewall (internal/network/firewall) from a few hardcoded bindings into a model that supports (1) three reserved blocks (mgmt, blocked, in_cluster) plus (2) operator-declared, named allow rules, with a strict declarative YAML config as the source of truth for mutations and round-trippable show --output yaml.

Changes:

  • Introduces a new firewall rule/table model with protocol + port-range support and named allow rules, plus strict YAML config load/round-trip support.
  • Updates rendering/template + goldens to emit per-rule sets (including interval/auto-merge port sets) and icmp_echo accepts above the ICMP rate meter.
  • Extends the CLI and workflow wiring to manage rules by name and persist/load the YAML config, including tests to preserve allow rules across re-renders.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
internal/workflows/steps/step_network_firewall.go Preserves existing named allow rules across workflow-driven re-renders.
internal/workflows/steps/step_network_firewall_test.go Adds coverage to ensure named allow rules survive reconcile re-render.
internal/templates/files/network/network-weaver-host-firewall.nft.tmpl Renders per-rule address/port sets, allow rules, and icmp_echo ordering.
internal/network/firewall/testdata/network-weaver-host-firewall.golden.nft Updates golden for new reserved-block set layout and mgmt ports set.
internal/network/firewall/testdata/network-weaver-host-firewall-allow.golden.nft Adds golden covering named allow rules, UDP, port ranges, and icmp_echo.
internal/network/firewall/table.go Refactors Table to reserved Rule blocks + Allow list; adds validation and collisions checks.
internal/network/firewall/rule.go New Rule/Proto model, reserved names, port-spec parsing/sorting, and per-rule validation.
internal/network/firewall/render.go Flattens rules for template rendering (per-family split, per-rule sets, allow list).
internal/network/firewall/paths.go Adds HostConfigPath for persisted declarative YAML config.
internal/network/firewall/parse.go Updates fallback parsing to recover reserved blocks from legacy/current nft artifacts.
internal/network/firewall/manager.go Adds YAML config persistence/load as source of truth; exposes Config/Table; adds name-based mutators.
internal/network/firewall/firewall_test.go Updates existing tests for new model and adds manager-level behavior tests.
internal/network/firewall/config.go New strict YAML schema, versioning, config↔table conversion, and marshaling helpers.
internal/network/firewall/allow_test.go New tests for allow-rule rendering, ordering, config round-trip, and legacy fallback behaviors.
docs/quickstart.md Documents new declarative config workflow, --from-file, --name, and YAML output.
docs/dev/traffic-shaper.md Documents new host-firewall config source of truth and named allow rules behavior.
cmd/cli/commands/network/firewall/show.go Adds --output yaml (config view) and --name (single-rule view).
cmd/cli/commands/network/firewall/set.go Reworks set to operate by --name (or legacy per-block flags) and adds --cidrs-file.
cmd/cli/commands/network/firewall/remove.go Reworks remove to operate by --name + --cidr/--port (plus legacy shorthands).
cmd/cli/commands/network/firewall/firewall.go Adds shared name-addressed flag plumbing and target-resolution helper logic.
cmd/cli/commands/network/firewall/firewall_test.go Adds end-to-end CLI tests for backward compatibility, from-file, yaml round-trip, and targeting.
cmd/cli/commands/network/firewall/delete.go Adds delete-by-name for allow rules and interactive confirmation for delete-all.
cmd/cli/commands/network/firewall/create.go Adds --from-file path and refactors build logic + podCIDR auto-detection behavior.
cmd/cli/commands/network/firewall/add.go Reworks add to operate by --name + --cidr/--port (plus legacy shorthands).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +104 to +111
// Named allow rules are not part of this step's input: they are
// declared with `network firewall create --from-file`, and config.yaml
// has no field for them. Carry any that already exist across, or a
// reconfigure (which force re-renders) would silently drop the
// operator's k8s/Cilium/admin rules while appearing to succeed.
if existing, err := mgr.Table(ctx); err == nil {
t.Allow = existing.Allow
}
Comment on lines +229 to +233
for _, r := range t.rules() {
for _, setName := range []string{addrSetName(r.Name), v6SetName(r.Name), portsSetName(r.Name)} {
if err := claim(setName, r.Name); err != nil {
return err
}
Comment on lines +320 to 324
nft, err := os.ReadFile(m.nftPath)
if err != nil {
if os.IsNotExist(err) {
return nil, errorx.IllegalState.New("inet weaver-host-firewall firewall not found at %s; run `solo-provisioner network firewall create` first", m.nftPath)
return nil, errorx.IllegalState.New("inet weaver-host-firewall firewall not found at %s; run `solo-provisioner network firewall create` first", m.configPath)
}
Comment thread docs/quickstart.md
- name: admin
cidrs: ["203.0.113.5/32", "2001:db8:5e5::/64"]
ports: ["22"]
icmp_echo: true

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure icmp_echo should be false by default.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

confirmed, it is false by default. Rule.ICMPEcho is a plain bool (rule.go:67) with yaml:"icmp_echo,omitempty", so it is false unless an allow rule opts in

Comment thread docs/quickstart.md Outdated
```yaml
version: 1

mgmt:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when this yaml file is specified, mgmt, blocked and in_cluster should be mandatory. Is that the case?
In other words, when using the file, everything should be sourced from it instead of trying to merge existing values (which only applies to set)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, I'll implement this feature to make "mgmt, blocked and in_cluster" mandatory

Comment thread docs/quickstart.md
# Remove the table and /etc/solo-provisioner/network-weaver-host-firewall.nft
sudo solo-provisioner network firewall delete
# Show the declarative config the ruleset was rendered from
sudo solo-provisioner network firewall show --output yaml

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is show displaying the same format that was passed via --from-file or the contents of the nftable? I believe it should be the format of the --from-file which would help when we want to export those values to apply them again later. Let's check if it's worth the complexity.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

confirmed show --output yaml already prints exactly the --from-file schema

Comment thread docs/quickstart.md
sudo solo-provisioner network firewall delete --all
```

`show --output yaml` prints exactly the schema `create --from-file` accepts, so it round-trips:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if the --output is not provided? Does it print the nftable format? If not, should we have an output type for that?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default is nft (show.go:87), which prints the live kernel ruleset via nft list table inet weaver-host-firewall

…config

`--from-file` states the whole table — nothing is inherited from the host's
current firewall — but an omitted reserved block still fell back to a
compiled-in default. For `mgmt` that default is an empty address list under
the input chain's default drop, so a file that simply forgot the block
rendered a host with no management allowlist and reported success, with only
a log warning to say otherwise.

Require all three reserved blocks (`mgmt`, `blocked`, `in_cluster`) to be
present, and `cidrs` to be present inside `mgmt` and `blocked`. Omitting
`in_cluster.cidrs` still means "auto-detect this node's pod CIDR" — the one
address list weaver can legitimately derive, and one whose absence costs a
rule rather than access to the host.

The check runs in `ParseConfig`, so it covers the persisted config at
/etc/solo-provisioner/network-weaver-host-firewall.yaml as well as
`--from-file`. `FileConfigFromTable` always writes all three blocks, so a
file weaver wrote passes by construction; one that fails has been truncated
or hand-edited, and failing loudly beats loading it with a defaulted
management allowlist.

Raised by @brunodam in review of #999.

Signed-off-by: alex-au <alex.w.aus@gmail.com>
@alex-au

alex-au commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Manual UAT on a local VM

Ran the host firewall end to end on a Debian 13 / arm64 UTM VM (nftables v1.1.3, kernel
6.12.57) against 42a52e6, driving the real CLI, the real kernel and the real systemd loader
unit — not the fake runner the unit tests use.

11 of 13 planned cases pass. 1 pre-existing bug found and filed as #1002.

# Case Result
1 Render every rule kind from a file ✅ 6/6
2 Reserved blocks are required ✅ 9/9
3 show --output yaml round-trips ✅ 6/6
4 allow: is declarative, reserved blocks are not ✅ 4/4
5 Membership verbs by --name ✅ 11/11
6 Backward compatibility ✅ 7/7
7 create-if-missing ✅ 2/2
8 Delete semantics ✅ 8/8
9 icmp_echo above the rate meter skipped
10 Set-name collisions refused before render ✅ 3/3
11 block node reconfigure preserves allow rules not run — needs a provisioned BN
12 Recovery when the config file is lost ✅ 4/4
13 Boot persistence

What each case actually confirmed

1 — every rule kind renders correctly. One admin rule with a mixed v4/v6 CIDR list split
into ip saddr @admin and ip6 saddr @admin6; cilium-vxlan rendered udp while its
neighbours rendered tcp; icmp_echo: true produced echo-request accepts in both families. The
range survived the kernel round-trip as a single element:

set k8s-node_ports { type inet_service; flags interval; auto-merge;
  elements = { 2379-2380, 6443, 10250, 10256-10259 } }

Re-applying the identical file produced a byte-identical nft list (diff clean).

2 — the required-block rule added in 42a52e6. Each of these is rejected with the live table
left untouched (verified by diffing nft list before and after): missing mgmt, missing
blocked, missing in_cluster, mgmt: with a null body, mgmt without cidrs, blocked: {}.
The deliberate exception still works — in_cluster present with cidrs omitted is accepted and
auto-detects, warning and omitting the rule when no cluster is reachable.

3 — round-trip. show --output yaml piped back through create --from-file --force produced
an identical ruleset. show with no --output prints the live nft; show --name k8s-node prints
just that rule and nothing about mgmt; show --name nope errors.

8 — delete. --name removes one allow rule and leaves the table up; --name mgmt is refused
as reserved; --name X --all is refused as mutually exclusive; --all --force removes the table
and both artifacts. Not covered: the new interactive confirmation prompt — I only exercised the
--force and non-interactive paths.

12 — recovery. Deleting the persisted YAML and running show --output yaml still returns the
management allowlist, recovered from the rendered .nft (named allow rules correctly absent, as
documented). A truncated config — as opposed to a missing one — now fails loudly rather than
loading with a defaulted mgmt block.

13 — boot persistence. Rebooted the VM; nft list table inet weaver-host-firewall hashed
identically before and after, all six allow-rule lines present, loader unit active.

Bug found: #1002 (pre-existing, not introduced here)

Adding a CIDR already covered by an existing member of the same rule breaks the apply:

$ sudo solo-provisioner network firewall add --name k8s-node --cidr 10.0.0.5/32
  Error: service solo-provisioner-network-nft.service start failed

# journalctl:
network-weaver-host-firewall.nft:24: Error: conflicting intervals specified
        set k8s-node { type ipv4_addr; flags interval; elements = { 10.0.0.0/24, 10.0.0.5/32 }; }

The address sets are declared flags interval without auto-merge; the port sets in the same
template have it, which is why --port never hits this and --cidr does. That predates this PR
(the address sets have looked like this since fd3a73b), so #1002 is filed separately rather than
fixed here
.

Two things about it are worth a reviewer's attention anyway:

  1. This PR makes it reachable by following the docs. The example in docs/quickstart.md and in
    this PR's own description is add --name k8s-node --cidr 10.0.0.5/32 against a k8s-node rule
    holding 10.0.0.0/24 — precisely the failing input. Named allow rules also multiply the number
    of address sets an operator can add --cidr into.
  2. The persisted artifact is poisoned. applyAndPersist writes the .yaml and .nft before
    applying, so a rejected ruleset still becomes the boot artifact. The live table is fine (the unit
    has no ExecStop, so nothing is flushed) — but nft -c -f on the persisted file then fails, and
    the host reboots with no weaver firewall at all. This is the part that turns a clear CLI error
    into a silent loss of the firewall.

Also worth knowing while testing: the repeated failed starts trip systemd's start-limit, after
which every network firewall mutation fails with the same opaque error until
systemctl reset-failed solo-provisioner-network-nft.service. My first UAT run reported 18
failures for this reason; re-running with a reset between cases showed one real bug and everything
else green.

Why the existing tests missed it

The unit suite asserts on rendered strings, and elements = { 10.0.0.0/24, 10.0.0.5/32 } is a
valid string — only nftables' parser rejects it. The real-kernel check in this PR's description
loads the goldens, which are create output with disjoint CIDRs; reproducing this needs a
create followed by an add, which no golden captures. And no fixture anywhere uses an
overlapping CIDR pair.

Still open

  • UAT-11 (block node reconfigure preserves named allow rules) is the one case needing a
    provisioned block node, so it did not run on this 3 CPU / 3 GB VM. It is the behaviour the
    step_network_firewall.go fix in this PR is for, and it is covered by
    TestNetworkFirewallCreate_PreservesNamedAllowRules at the unit level. Deciding how to cover it
    end to end.

@alex-au

alex-au commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

UAT-11 now covered end to end — the reconfigure fix works

Followed up the earlier UAT by actually provisioning a block node on the VM, so the one case that
needed a real cluster is no longer outstanding. -p local fits the 3 CPU / 3 GB VM (the profile
asks for 3 CPU / 1 GB), so no VM resizing was needed.

sudo solo-provisioner kube cluster install -p local --non-interactive     # 5m1s, node Ready, 12 pods Running
sudo solo-provisioner block node install -p local --non-interactive      # Successfully installed Hedera Block Node
sudo solo-provisioner network firewall create --from-file rules.yaml --force
sudo solo-provisioner block node reconfigure -p local --non-interactive \
  --firewall-enabled --mgmt-cidrs 192.168.50.0/24

Result: all three named allow rules survived the force re-render.

ip  saddr @admin        icmp   type echo-request accept
ip6 saddr @admin6       icmpv6 type echo-request accept
ip  saddr @admin        tcp dport @admin_ports        accept
ip  saddr @cilium-vxlan udp dport @cilium-vxlan_ports accept
ip  saddr @k8s-node     tcp dport @k8s-node_ports     accept
ip6 saddr @admin6       tcp dport @admin_ports        accept

k8s-node_ports  elements = { 2379-2380, 6443, 10250, 10256-10259 }
mgmt_addrs      elements = { 192.168.50.0/24 }

6/6 rule lines, port ranges intact, mgmt correctly re-rendered from the flag, and the persisted
config still lists all 3 allow rules. This is the behaviour step_network_firewall.go:104-111 was
added for — before it, reconfigure re-rendered from config.yaml (which has no field for allow
rules) and would have dropped every one of them while reporting success.

UAT status is now 12 of 13. Only UAT-9 (the icmp_echo-under-flood functional check) is
unrun, by choice — it needs a second source host. The kernel-side half of that claim (accept
renders above the rate meter) is already pinned by the ordering test in both families.

Second bug found on the way: #1003

The first reconfigure attempt — without --firewall-enabled — deleted the host firewall
outright:

INF Removing host firewall (inet weaver-host-firewall)   step_id=network-firewall-delete
DBG Persisted host-firewall configuration into runtime state  disabled=true managementCidrs=0
INF Successfully reconfigured Hedera Block Node

Table, both artifacts and the management allowlist gone, exit 0. The cause is that
ResolveHostFirewallConfig seeds the enable/disable decision from MachineState.Firewall
(deliberately, per host_firewall.go:132-139), but the standalone network firewall verbs never
write that state — so a firewall created with network firewall create is invisible to the seed
and gets torn down.

Filed separately as #1003 since it predates this PR. It is worth flagging here anyway, because
this PR makes create --from-file the only way to declare named allow rules — so the richer
the hand-authored ruleset an operator builds through the path this PR adds, the more a stray
reconfigure destroys, with no way to rebuild it from config.yaml.

The two findings are complementary: #1003 is the enable decision flipping silently, while this PR
fixes the allow rules being dropped whenever it stays enabled.

One correction to my earlier comment: I said the interactive reconfigure prompt is seeded from
IsActive ground truth. It is not — IsActive's doc comment describes that, but its only caller
is the rollback guard in step_network_firewall.go:119. The actual seed is MachineState.Firewall
on both paths, which is why --non-interactive is not special here; an interactive run defaults
the same prompt to No.

@alex-au alex-au left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UAT tested and this PR itself looks great, found two existing defects and raised issue #1002 and #1003 accordingly.

@alex-au
alex-au merged commit 2cbfbdf into 00996-collapse-icmp-path-health-accepts Aug 13, 2026
20 checks passed
@alex-au
alex-au deleted the 00998-firewall-named-allow-rules branch August 13, 2026 23:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants