Skip to content

feat(network/firewall): declare a named allow rule from the CLI without a config file - #1015

Merged
alex-au merged 1 commit into
mainfrom
01009-cli-declare-allow-rule
Aug 18, 2026
Merged

feat(network/firewall): declare a named allow rule from the CLI without a config file#1015
alex-au merged 1 commit into
mainfrom
01009-cli-declare-allow-rule

Conversation

@alex-au

@alex-au alex-au commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Description

network firewall create --from-file was the only way to bring a named allow rule into
existence, as its own help text stated. Everything else about the host firewall is reachable
from the CLI — the three reserved blocks via create/set/add, and membership inside an
existing named rule via add/remove/set --name. Only the act of declaring a rule had no
CLI form.

That made the file mandatory for a case it was not designed for. An operator who simply wants
to admit their own monitoring host had to learn a schema and author a file that states the
whole table: --from-file is fully declarative, so a partial file silently removes rules
and can fall back to an empty management allowlist under the default-drop policy. It also
blocked the interactive path — a prompted flow cannot offer “add an allow rule” when the
underlying capability only accepts a file.

This adds network firewall create-allow-rule, so the whole lifecycle is CLI-reachable:

# 1. the three reserved blocks
sudo solo-provisioner network firewall create \
  --mgmt-cidrs 10.0.0.0/8 --ssh-port 22

# 2. declare a named group, then fill it
sudo solo-provisioner network firewall create-allow-rule --name rudder_server --proto tcp --icmp-echo
sudo solo-provisioner network firewall add --name rudder_server \
  --cidr 200.201.203.205/32 --port 5309,8443

# 3. install or reconfigure, without touching firewall settings
sudo solo-provisioner block node reconfigure

--cidr and --port already accepted lists applied in one transaction, so step 2 needs one
add rather than one per element — which is why the declare verb deliberately takes no
--cidr/--port of its own. A real rule has too many of both to fold into the declare line.

create --from-file remains valid and unchanged.

The invariant this changes

Rule.Validate rejected an allow rule with no CIDRs (“delete the rule rather than emptying
it”) and one with no ports and no icmp_echo. A declared-but-unpopulated rule could not exist
under those checks, so declare-then-populate was impossible in any order. Both checks are
dropped for allow rules and replaced by a warning on apply.

This is fail-closed rather than fail-open: the template gates every emission on the address
and port sets being non-empty, so an incomplete rule renders no accept rule at all. That
was verified against a real kernel rather than assumed — a table carrying all four incomplete
shapes (declared-empty, cidrs-only, ports-only, echo-without-cidrs) plus one complete rule
produced empty set declarations only, passed nft -c -f, and loaded live with accepts emitted
solely for the complete rule.

Reserved blocks keep every existing restriction; the relaxation is scoped to the default:
branch of the switch r.Name.

Two bugs found while building this

  • in_cluster accepted proto and icmp_echo and silently ignored both — the template
    hardcodes TCP for all three reserved blocks and gives them no echo accept. Newly rejected,
    which matters now that set --proto can reach them.
  • create-allow-rule --name mgmt initially reported “already exists” and succeeded, because
    the reserved blocks always exist and hit the create-if-missing branch. The reserved-name
    check now runs first. Caught by a test, not by review.

Related Issues

Changes

File Change
internal/network/firewall/rule.go Allow rules may be incomplete; in_cluster now rejects proto/icmp_echo; new incomplete() predicate
internal/network/firewall/table.go IncompleteAllowRules() for the apply-time warning
internal/network/firewall/manager.go CreateRule; Update gains Proto/ICMPEcho pointers; warning in applyAndPersist; unknown-name errors name the new verb
internal/network/firewall/parse.go Doc comment no longer says allow rules are re-applied from a file
cmd/.../firewall/create_allow_rule.go New — the create-allow-rule verb
cmd/.../firewall/firewall.go Register the verb; new shared flag vars; package/group docs
cmd/.../firewall/set.go --proto / --icmp-echo
cmd/.../firewall/create.go --from-file help no longer claims to be the only way
docs/quickstart.md, docs/dev/traffic-shaper.md Document the verb, the new set flags, and the list form

Review guide

Key invariants to check:

  • CreateRule rejects reserved names before the exists check (manager.go). Reversing
    these two is the bug called out above.
  • CreateRule is built on withLock, not mutate. mutate always calls
    applyAndPersist, so the already-exists no-op would restart the nft unit for nothing. A test
    asserts the apply count does not move.
  • Update.Proto / .ICMPEcho are pointers. "" is a real proto setting and false a real
    echo setting, so only “flag was given” distinguishes them from “leave alone”. Existing callers
    pass nil and are unaffected.
  • --proto/--icmp-echo are read only when Changed(). Leaving Proto empty keeps it out
    of show --output yaml, so the showcreate --from-file round-trip stays byte-stable.
  • The relaxation is scoped to allow rules. mgmt/blocked/in_cluster restrictions are
    unchanged or tightened.
  • resetFlagState covers the two new vars in the CLI tests, or state leaks between cases.

Test plan

These packages import the Linux-only internal/mount and do not build on macOS:

task mocks
docker run --rm -v "$PWD":/src -v "$(go env GOMODCACHE)":/go/pkg/mod -w /src \
  -e GOFLAGS=-mod=mod golang:1.26.5 go test \
  ./internal/network/firewall/... ./cmd/cli/commands/network/firewall/...
task lint

Full ./internal/... ./cmd/... ./pkg/... suite passes; golangci-lint reports 0 issues.

New tests: TestTable_IncompleteAllowRulesAreLegalAndRenderNothing,
TestRender_CLIDeclaredMatchesFileDeclared, TestManager_CreateRule,
TestManager_SetProtoAndICMPEcho, TestManager_UnknownRuleNameStillFails,
TestCreateAllowRuleCmd, TestCreateAllowRuleCmd_Rejections,
TestCreateAllowRuleCmd_ForceRedeclares, TestSetCmd_ProtoAndICMPEcho,
TestUnknownRuleNameNeverDeclares.

Manual UAT

Run on the BN VM as root, from a console you did not open through a rule you are about to
change
. Several cases render a default-drop input chain; keep a second non-SSH console open.
Replace 192.168.50.0/24 with the subnet your host reaches the VM on, or you will lock
yourself out.

task build && task vm:start && task vm:ssh
sudo /mnt/solo-weaver/bin/solo-provisioner-linux-arm64 install

UAT-1 — declare, populate, delete with no file (the headline AC)

sudo solo-provisioner network firewall create --mgmt-cidrs 192.168.50.0/24 --force
sudo solo-provisioner network firewall create-allow-rule --name rudder_server --proto tcp --icmp-echo
sudo nft list table inet weaver-host-firewall

Expect: sets rudder_server / rudder_server6 declared and empty, no
saddr @rudder_server accept anywhere, and a warning that the rule renders nothing yet.

sudo solo-provisioner network firewall add --name rudder_server \
  --cidr 200.201.203.205/32,10.1.0.0/16 --port 5309,8443,9000-9100
sudo nft list table inet weaver-host-firewall

Expect, from the single add:

ip saddr @rudder_server tcp dport @rudder_server_ports accept
ip saddr @rudder_server icmp type echo-request accept
set rudder_server_ports { ... elements = { 5309, 8443, 9000-9100 } }

The range must survive as one element, and the echo accept must sit above the
limit rate over 10/second drop meter (nft -a list chain inet weaver-host-firewall input_icmp_ipv4
— compare handle numbers).

sudo solo-provisioner network firewall delete --name rudder_server   # no new verb needed

UAT-2 — populate in any order; nothing renders until complete

sudo solo-provisioner network firewall create-allow-rule --name partial
sudo solo-provisioner network firewall add --name partial --port 9999      # ports first
sudo nft list table inet weaver-host-firewall | grep -c 'saddr @partial'   # expect 0
sudo solo-provisioner network firewall add --name partial --cidr 203.0.113.5/32
sudo nft list table inet weaver-host-firewall | grep 'saddr @partial'      # now renders

Both orders must work. Before this PR the first add failed outright.

UAT-3 — a typo never declares (AC#3)

sudo solo-provisioner network firewall add    --name rudder_sever --cidr 10.0.0.1/32   # expect error
sudo solo-provisioner network firewall remove --name rudder_sever --cidr 10.0.0.1/32   # expect error
sudo solo-provisioner network firewall set    --name rudder_sever --cidrs 10.0.0.1/32  # expect error
sudo nft list table inet weaver-host-firewall | grep -c rudder_sever                   # expect 0

Each error should list the known rules and point at create-allow-rule.

UAT-4 — CLI-declared renders identically to file-declared (AC#4)

sudo solo-provisioner network firewall create-allow-rule --name admin --icmp-echo
sudo solo-provisioner network firewall add --name admin --cidr 203.0.113.5/32,2001:db8:5e5::/64 --port 22
sudo nft list table inet weaver-host-firewall > /tmp/before.nft
sudo solo-provisioner network firewall show --output yaml > /tmp/shown.yaml
sudo solo-provisioner network firewall create --from-file /tmp/shown.yaml --force
sudo nft list table inet weaver-host-firewall > /tmp/after.nft
diff /tmp/before.nft /tmp/after.nft && echo ROUND-TRIP-OK

Expect no diff, and the one admin rule split across families — @admin and @admin6.

UAT-5 — create-if-missing and --force

sudo solo-provisioner network firewall create-allow-rule --name admin --proto udp
sudo nft list set inet weaver-host-firewall admin          # membership intact, still tcp
sudo solo-provisioner network firewall create-allow-rule --name admin --proto udp --force
sudo nft list set inet weaver-host-firewall admin          # now empty

UAT-6 — --proto / --icmp-echo on set, and reserved-block rejection

sudo solo-provisioner network firewall set --name admin --proto udp
sudo solo-provisioner network firewall set --name admin --icmp-echo=false
sudo solo-provisioner network firewall set --name mgmt       --proto udp     # expect error
sudo solo-provisioner network firewall set --name in_cluster --icmp-echo     # expect error (new)
sudo solo-provisioner network firewall set --name blocked    --proto tcp     # expect error
sudo solo-provisioner network firewall create-allow-rule --name mgmt         # expect: reserved name
sudo solo-provisioner network firewall create-allow-rule --name mgmt_addrs   # expect: set-name collision

UAT-7 — no regressions in the pre-existing surface

Re-run UAT-2 (reserved blocks required) and UAT-6 (backward compatibility) from the #999
runbook unchanged. create --from-file must behave exactly as before, and every pre---name
invocation must still work:

sudo solo-provisioner network firewall add --mgmt-cidr 10.1.0.0/16
sudo solo-provisioner network firewall add --in-cluster-port 9100
sudo solo-provisioner network firewall set --mgmt-cidrs 192.168.50.0/24 --in-cluster-ports 6443,4244

UAT-8 — boot persistence

sudo reboot
sudo nft list table inet weaver-host-firewall | grep admin

Deferred

The final acceptance case — the full sequence ending in sudo solo-provisioner block node reconfigure, confirming every rule survives — depends on #1003 / PR #1005, which is green
but not yet merged. Standalone network firewall verbs write no machine state, so today
reconfigure deletes the firewall it finds. To be run after rebasing onto main once #1005
lands.

Risks

  • The Rule.Validate relaxation is the only behaviour change outside the new verb. It
    removes two loud errors that also caught typo'd config files. --from-file now warns rather
    than errors on a half-written allow rule, and remove-ing a rule's last CIDR no longer fails
    — the rule goes dormant instead. Both are fail-closed. The first disappears entirely under
    refactor(network/firewall): remove --from-file and make the CLI the only host-firewall input #1010. Rollback is a one-hunk revert in rule.go.
  • in_cluster newly rejects proto/icmp_echo. A hand-written config that set either on
    in_cluster was previously accepted and ignored, and will now fail to load. That config was
    already not doing what it said.
  • No template or on-disk schema change, so there is no existing-cluster upgrade path to trace.
    Reverting the PR restores --from-file-only declaration with no migration.

🤖 Generated with Claude Code

@alex-au
alex-au requested a review from a team as a code owner August 15, 2026 02:34
@alex-au
alex-au requested a review from JeffreyDallas August 15, 2026 02:34
@swirlds-automation

swirlds-automation commented Aug 15, 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 extends the host-firewall CLI so operators can declare a named allow rule without authoring a full declarative YAML file, enabling a safe declare-then-populate workflow that renders no accept rules until a rule is complete.

Changes:

  • Add network firewall create-allow-rule to declare an (initially empty) named allow rule from the CLI.
  • Relax allow-rule validation to permit declared-but-incomplete rules, and warn at apply-time when allow rules render nothing.
  • Add --proto / --icmp-echo support to network firewall set, and update docs/tests to cover the new lifecycle.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
internal/network/firewall/rule.go Adds allow-rule “incomplete” predicate and relaxes validation for allow rules; tightens validation for reserved blocks.
internal/network/firewall/table.go Adds IncompleteAllowRules() helper to surface apply-time warnings for declared-but-empty allow rules.
internal/network/firewall/manager.go Introduces CreateRule, extends Update with Proto/ICMPEcho pointers, and warns on incomplete allow rules during apply.
internal/network/firewall/parse.go Updates Parse docs to reflect the CLI-declaration path for named allow rules.
internal/network/firewall/allow_test.go Adds model/manager rendering + lifecycle tests covering incomplete rules and CLI-vs-file equivalence.
cmd/cli/commands/network/firewall/create_allow_rule.go New Cobra command implementing create-allow-rule.
cmd/cli/commands/network/firewall/firewall.go Registers the new verb and adds shared flag vars for proto/icmp-echo.
cmd/cli/commands/network/firewall/set.go Adds --proto / --icmp-echo flag handling via pointer semantics in fw.Update.
cmd/cli/commands/network/firewall/create.go Updates --from-file help text to no longer claim it’s the only declaration path.
cmd/cli/commands/network/firewall/firewall_test.go Adds end-to-end CLI tests for declare/redeclare, proto/icmp-echo on set, and typo safety.
docs/quickstart.md Documents create-allow-rule, new set flags, and list/range forms.
docs/dev/traffic-shaper.md Updates developer docs to include the new declaration workflow and invariants.

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

Comment thread internal/network/firewall/rule.go
Comment thread internal/network/firewall/manager.go Outdated
Comment thread docs/quickstart.md Outdated
Comment thread internal/network/firewall/manager.go
Comment thread internal/network/firewall/parse.go
@alex-au

alex-au commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Manual UAT — executed

Ran the full runbook from the PR description on a freshly reset UTM VM (Debian, arm64),
against a binary built from this branch at 2c4d027
(solo-provisioner versionCommit: 2c4d027df86860b0e38af185f84d172099609873).

VM at 192.168.50.22/24; management allowlist set to 192.168.50.0/24 throughout, and every
command ran over a new SSH connection, so each step also implicitly re-verified that the
management allow rule was intact under the default-drop policy.

# Case Result
1 Declare → populate → delete, no file ✅ pass
2 Populate in any order (ports before CIDRs) ✅ pass
3 Typo'd --name never declares ✅ pass
4 CLI-declared renders identically to file-declared ✅ pass
5 create-if-missing + --force ✅ pass
6 set --proto/--icmp-echo, reserved-block rejections ✅ pass
7 No regressions in the pre-existing surface ✅ pass
8 Boot persistence ✅ pass
Bonus: config-file-lost recovery message ✅ pass
Full sequence ending in block node reconfigure ⏸️ deferred — #1005 still open

The parts worth showing

UAT-1 — an incomplete rule really does render nothing. After create-allow-rule --name rudder_server --proto tcp --icmp-echo:

WRN allow rule(s) render nothing yet: each needs at least one CIDR and either a port or
    icmp_echo — populate with `network firewall add --name <rule> --cidr <cidr> --port <port>`
    rules=["rudder_server"]
INF allow rule declared; populate it with `network firewall add --name rudder_server ...`

--- sets declared ---        --- accept rules referencing it ---
set rudder_server {          0
set rudder_server6 {

Sets exist and are empty; zero accept rules. Then one add carrying both lists:

ip saddr @rudder_server icmp type echo-request accept          # handle 33
icmp type echo-request limit rate over 10/second ... drop      # handle 34
ip saddr @rudder_server tcp dport @rudder_server_ports accept

set rudder_server       { elements = { 10.1.0.0/16, 200.201.203.205 } }
set rudder_server_ports { elements = { 5309, 8443, 9000-9100 } }

The range survived as one element, ports came out sorted, and the echo accept (handle 33) sits
above the rate meter (handle 34) — the ordering claim, checked on the live kernel.

UAT-2 — the order that was impossible before. add --port on an empty rule now succeeds
(it errored outright before this PR), still renders nothing, and goes live only once the CIDR
arrives:

declared, accepts:                 0
after PORTS-first add, accepts:    0
after cidr add:  ip saddr @partial tcp dport @partial_ports accept

UAT-3 — the typo guard, with a nice touch. All three verbs fail and list the real rule
right next to the typo, so the mistake is obvious:

no rule named "partail"; known rules are mgmt, blocked, in_cluster, partial.
Declare a new allow rule with `network firewall create-allow-rule --name partail`

Nothing was created (grep -c partail → 0).

UAT-4 — AC#4, byte-identical. Built admin (dual-stack, icmp_echo) purely through the
CLI, dumped show --output yaml, fed that back through create --from-file, diffed the two
nft list outputs:

=== DIFF before(CLI-declared) vs after(file-declared) ===
ROUND-TRIP-OK: byte-identical

The emitted YAML also confirms the "only persist what was set" design — admin carries
icmp_echo: true and no proto: key, so the round-trip stays stable.

UAT-6 — every rejection, including both bugs this PR fixes:

set --name mgmt --proto udp          -> does not take proto: management access is TCP
set --name in_cluster --icmp-echo    -> does not take icmp_echo: ...          <-- was silently ignored
set --name in_cluster --proto udp    -> does not take proto: ...              <-- was silently ignored
set --name blocked --proto tcp       -> does not take proto: the block list drops ...
create-allow-rule --name mgmt        -> reserved name and cannot be used for an allow rule
                                                                              <-- was "already exists"
create-allow-rule --name mgmt_addrs  -> derive the nft set name "mgmt_addrs"
create-allow-rule                    -> --name is required
set --name admin                     -> at least one of --cidrs, --cidrs-file, --ports, --proto
                                        or --icmp-echo is required

UAT-7 — no regressions. All five pre---name shorthand invocations still OK; mixing the
two flag forms still rejected. The #999 reserved-block gate is intact:

missing-mgmt         exit=1  config is missing the required "mgmt" block: ...
missing-in_cluster   exit=1  config is missing the required "in_cluster" block: ...
mgmt-without-cidrs   exit=1
in_cluster present, cidrs omitted -> exit=0, "could not auto-detect pod CIDR"  (deliberate exception)

Each rejection left the live table unchanged (diff against a pre-capture: UNCHANGED).

UAT-8 — boot persistence. A dual-stack UDP + icmp_echo rule declared entirely through the
CLI, then reboot:

ip  saddr @persist_check  icmp   type echo-request accept
ip6 saddr @persist_check6 icmpv6 type echo-request accept
ip  saddr @persist_check  udp dport @persist_check_ports accept
ip6 saddr @persist_check6 udp dport @persist_check_ports accept
set persist_check_ports { elements = { 7777, 8000-8010 } }

Fully restored by solo-provisioner-network-nft.service, range included.

Bonus — the recovery path #1010 will depend on. Deleted
/etc/solo-provisioner/network-weaver-host-firewall.yaml, then show --output yaml:

recovering the reserved blocks from the rendered ruleset
  (any named allow rules must be re-declared with `network firewall create-allow-rule`)

mgmt:
  cidrs: [192.168.50.0/24]

Reworded message is correct, and management access is recovered from the .nft artifact rather
than erroring — which is what makes removing the file safe in #1010.

Still deferred

The sequence ending in block node reconfigure needs #1003 / #1005, which is still open. Will
run after rebasing onto main once that lands.

@alex-au
alex-au force-pushed the 01009-cli-declare-allow-rule branch from 2c4d027 to d4a1aed Compare August 18, 2026 02:31
Comment thread internal/network/firewall/manager.go

@brunodam brunodam 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.

Great work, Alex!

`create --from-file` was the only way to bring a named allow rule into
existence, so admitting one monitoring host meant learning a schema and
authoring a file that states the whole table -- which is fully declarative,
removes any rule it omits, and can fall back to an empty management
allowlist under the default-drop policy.

Add `network firewall create-allow-rule --name <rule>`, which declares a
rule that `add` then populates. Both `--cidr` and `--port` already take
lists applied in one transaction, so a rule goes live in a single follow-up
command.

Expose the two Rule fields that had no flag, `--proto` and `--icmp-echo`,
on the new verb and on `set`, so every field of every rule is now reachable
from the CLI rather than only from a file.

Declaring stays a separate verb from `add`: an unknown --name on
add/remove/set still fails, so a typo edits nothing instead of quietly
creating a second rule alongside the intended one. Re-declaring an existing
name is create-if-missing, matching `create`.

This required relaxing Rule.Validate, which rejected an allow rule with no
CIDRs, or with no ports and no icmp_echo. A declared-but-unpopulated rule
has to be representable for the sequence to work in any order. It is
fail-closed: the template gates every emission on the address and port sets
being non-empty, so an incomplete rule renders no accept rule at all --
verified against a live kernel. applyAndPersist warns about them instead.

Also reject --proto/--icmp-echo on the reserved blocks, which previously
accepted and silently ignored them: in_cluster took both, and mgmt and
in_cluster accepted proto=tcp because they only rejected a mismatching
value. The renderer fixes all three to TCP and the config schema carries no
proto field for them, so any value accepted here reported a change that
never happened. And fix create-allow-rule --name mgmt reporting "already
exists" rather than naming the reserved block -- the reserved-name check
has to run ahead of the create-if-missing branch, since those blocks always
exist.

Refs #1009

Signed-off-by: alex-au <alex.w.aus@gmail.com>
@alex-au
alex-au force-pushed the 01009-cli-declare-allow-rule branch from d4a1aed to acbd22f Compare August 18, 2026 02:48
@alex-au
alex-au merged commit 79fa0fc into main Aug 18, 2026
20 checks passed
@alex-au
alex-au deleted the 01009-cli-declare-allow-rule branch August 18, 2026 04:05
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.

feat(network/firewall): declare a named allow rule from the CLI without a config file

4 participants