Skip to content

Per-rule disable/enable with a persisted spec - #17

Merged
edimarlnx merged 3 commits into
mainfrom
rule-disable
Sep 1, 2026
Merged

edimarlnx merged 3 commits into
mainfrom
rule-disable

Conversation

@edimarlnx

Copy link
Copy Markdown
Contributor

nftables has no per-rule off switch: a rule is in the ruleset or it is not. So
D disables a rule by deleting it and remembering enough to put it back
exactly where it was — and that memory has to survive a reboot, or the feature
is a delete with a friendly name.

The format

The record goes in the file W already writes, as comment lines nft -f
ignores and this tool parses:

# tui-firewall:spec v2
table inet tui {
	chain input {
		type filter hook input priority 0; policy drop;
		iifname "wan0" tcp dport 22 counter drop comment "no ssh from the wan"
	}
}
# tui-firewall:disabled {"index":2,"expr":["iifname","\"wan0\"","udp","dport","53","counter","drop"],"family":"v4","rule":{…}}
  • The live half stays nft's own listing, byte for byte, so the boot path is
    untouched and a machine that loses this tool still boots the rules it had.
  • A file with no marker is v1 and loads as "nothing disabled".
  • A ruleset with nothing disabled still writes that byte-identical v1 file,
    so nobody who does not press D sees the format at all, or a diff for it.
  • A marker naming a version this build does not read is an error, and the save
    is refused while it stands: overwriting a file whose disabled rules could not
    be read would delete them.

A sidecar file was the other option and was rejected — two files drift, the
boot path would have to learn about the second one, and copying
tui-firewall.nft to another machine would silently leave half the
configuration behind. DESIGN.md carries the reasoning in full, and replaces
the "not implemented" note it used to hold.

Behaviour

  • D disables; D on the greyed-out row enables, replaying the captured
    statement with nft insert rule … index N (or add, when the chain has
    become shorter since). Fresh handle, counter at zero, and the preview says so.
  • The statement is rebuilt from the rule's own modelled match by the same
    machinery a move uses, so the refusal set is the one E already makes: an
    unmodelled match, a NAT translation, a verdict other than accept/drop/reject,
    a log statement carrying a level or an nflog group. Refused before anything
    is deleted.
  • The disabled rule stays a row, greyed, with a STATE column that appears
    only once something is switched off. It is drawn from the spec, not from the
    kernel.
  • Disabling never joins a staged batch: the ruleset change and the record of it
    have to agree, and a batch defers the one past the other. It applies on its
    own as one nft command, refused while staging collects or a batch awaits its
    keep, and then offers the save — with rules: 1 disabled, unsaved (W) in the
    header until that save happens.
  • d, E, K/J and l on a disabled row say so instead of failing on a
    handle that does not exist.

Verification

make check green (vet, exec boundary, golangci-lint 0 issues, tests),
make manifest and make readme clean. New tests: spec round trip and v1/v2
loading, disable→enable restoring the position, the append fallback on a
shrunken chain, every refusal, the injection guards on what comes off disk, and
the full --demo gesture through the UI (greyed row, header fact, auto-offered
save, staging refusal, ufw not offering the key). Two fuzz targets —
FuzzParseSpec on the guards and FuzzSaveFileRoundTrip on write/read
agreement — run clean at 25s and 20s locally on top of the seed corpus.

Not done: the spec is not reconciled against a ruleset something else changed
behind this tool's back. The position past the end of a shorter chain is
handled; the rest is written up as open in DESIGN.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q

edimarlnx and others added 2 commits September 1, 2026 19:07
nftables has no disabled state: a rule is in the ruleset or it is not. So
disabling can only mean deleting the rule while remembering enough to put it
back exactly where it was, and that memory has to survive a reboot or the
feature is a delete with a friendly name.

The memory goes in the file the Save action already writes, as comment lines
`nft -f` ignores and this tool parses. The live half stays nft's own listing,
byte for byte, so the boot path is untouched and a machine that loses this
tool still boots the rules it had; a file written before this existed still
loads, and a ruleset with nothing disabled still writes that same v1 file, so
nobody who does not press D sees the format at all.

D disables the selected rule and D again puts it back at the recorded index.
The statement replayed on the way back is captured from the live match when
the rule was disabled, by the same rebuild a move uses, so the refusals are
the ones E already makes: a rule this tool does not hold in full is refused
before anything is deleted. The disabled rule stays on screen as a greyed-out
row drawn from the spec rather than from the kernel.

Disabling does not join a staged batch. The ruleset change and the record of
it have to agree, and a batch defers the one past the other; so it applies on
its own and then offers the save, with the header saying `1 disabled, unsaved`
until that save happens.

Everything read off disk is guarded before it becomes an nft statement or a
table cell, with two fuzz targets on the invariants.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q
The Save preview diffs the capture against the file at the save path, and only
one of those two is this tool's own output. The other is whatever is on disk,
and the diff is a quadratic longest-common-subsequence over lines: a file that
is not a table listing — a log somebody redirected there, a ruleset a hundred
times this tool's own — asks for a table quadratic in its size before a
character is drawn. CodeQL flags the allocation, and it is right to.

Past the bound the diff degrades to "the whole file is replaced", which is
what installing over it does anyway.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q
Comment thread internal/nftables/diff.go Fixed
CodeQL follows the line count of a file this tool did not write into the
allocation and does not read the product of two int64s as a bound on either.
A plain per-side limit says the same thing and is the same fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q
@edimarlnx
edimarlnx merged commit 8ebbc93 into main Sep 1, 2026
8 checks passed
@edimarlnx
edimarlnx deleted the rule-disable branch September 1, 2026 22:45
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.

2 participants