Skip to content

nftui v0.9.0

Choose a tag to compare

@github-actions github-actions released this 19 Jun 18:06
· 90 commits to main since this release

The release-infrastructure milestone: integration test harness for the live netlink path, GitHub Actions CI, a reproducible Goreleaser release pipeline, Nix flake packaging, and virtualized rule-list rendering for large chains. (Originally targeted as v1.0.0; renamed after a strict audit surfaced enough hardening items that bumping straight to a 1.0 stable was premature — v1.0.0 picks from the post-v0.9.0 candidate pool.)

Added

  • Integration test harness under the integration build tag (nft/integration_test.go). Skips when not root, applies a uniquely-named inet table via nft -f, reads it back through ListTables / ListChainsOfTable / ListRulesOfChain, and asserts on per-rule comment text (UserData TLV) plus rendered tokens. The table is torn down in t.Cleanup so a failing assertion still leaves the host clean. New README "Integration tests" subsection covers the invocation (sudo -E go test -tags=integration ./nft/ -v). The first-run output uncovered the anonymous-set renderer bug fixed below.
  • GitHub Actions CI workflow (.github/workflows/ci.yml). Two jobs run on every push / PR to main and develop: build-and-test (gofmt check, go vet for the default and integration build tag sets, go build, go test -race ./..., timeout-minutes: 15) and integration-test (apt-installs the nftables package, then sudo -E env PATH=$PATH go test -tags=integration -v ./nft/ so the harness gets CAP_NET_ADMIN and the elevated process can still resolve the setup-go-provisioned toolchain, timeout-minutes: 20). The integration job is gated by needs: build-and-test so it doesn't burn runner minutes on a commit that fails the cheap checks. actions/setup-go@v5 reads the Go version from go.mod (go-version-file), keeping CI and the module's declared toolchain in lockstep. A concurrency group with cancel-in-progress: true retires superseded runs on the same ref. README's new "Continuous integration" subsection documents what the workflow runs.
  • Goreleaser configuration (.goreleaser.yaml) and matching release pipeline (.github/workflows/release.yml). Builds reproducible Linux amd64 / arm64 binaries (CGO_ENABLED=0, -trimpath, -ldflags='-s -w', mod_timestamp pinned to the commit time so byte-identical rebuilds are possible given the same Go toolchain version), bundles each binary with LICENSE, README.md, CHANGELOG.md, and man/nftui.1 into a tar.gz, and emits a SHA-256 checksums.txt. The release workflow fires on v* tags (timeout-minutes: 30), extracts the matching ## [X.Y.Z] section from CHANGELOG.md via a literal-match awk one-liner (index($0, header) == 1, never a regex — so . in 1.0.0 can't match an arbitrary character), and passes it to Goreleaser via --release-notes so the published GitHub Release body is the curated changelog text (fallback placeholder uses ${GITHUB_REPOSITORY} so the URL is correct on any fork). The pre-build hook is go mod verify (read-only checksum assertion) — go mod tidy was deliberately rejected because it could rewrite go.sum mid-release and silently diverge the published binary from committed source state. release.github.{owner,name} is omitted so Goreleaser auto-detects upstream from the git remote; forks can release without editing the config. goreleaser-action is pinned to version: '~> v2' (tracks v2.x, immune to a v3 breaking change ambushing release day), and the config uses the current archives.formats / archives.ids schema (migrated from the deprecated singular format / builds after the second pre-tag audit; validated with goreleaser check against v2.16.0 — the same major series the workflow pulls). dist/ added to .gitignore so snapshot builds don't pollute the working tree. README's new "Release process" subsection documents the tag → publish flow and the local goreleaser check / goreleaser release --snapshot --clean --skip=publish validation commands.
  • Nix flake (flake.nix) packaging nftui for Nix users. packages.default is a buildGoModule derivation for x86_64-linux and aarch64-linux matching the Goreleaser shape (CGO_ENABLED=0 by default, -s -w), runs unit tests via doCheck = true, and installs man/nftui.1 to $out/share/man/man1/ so man nftui works after nix profile install. Version derivation is timestamp-based (0-YYYYMMDD-shortRev from self.lastModifiedDate + self.shortRev) so the Nix store path is at least date-informative; falls back to 0-00000000-dirty when neither field is set. A proper semver source (VERSION file or --argstr version) is tracked as a post-v0.9.0 candidate. apps.default exposes nix run. devShells.default mirrors the CI toolchain — go, gopls, goreleaser, nftables (for integration tests), mandoc (for mandoc -Tlint man/nftui.1). vendorHash is lib.fakeHash on first commit per the standard pattern: the initial nix build fails with the real sha256-… to paste in; the Nix path is intentionally independent of the Goreleaser release pipeline, so an outdated vendorHash never blocks publishing. Pinned to nixpkgs/nixos-unstable for current Go toolchain; users on stable channels can --override-input nixpkgs <ref>. README's "Installation" section gains a "Nix flake" subsection covering nix build / nix run / nix develop and the first-build hash-pinning step.
  • Test-coverage pass ahead of tagging — overall unit-statement coverage lifted from 16.0% to 45.5%. Four pieces: (1) nft/nftserializer gained its first test file (ruleset_test.go, 0% → 38.6%): SerializeRule was split into a netlink-free serializeRuleExprs core (sets injected by the caller) so the expression dispatch is unit-testable, with table-driven cases for chain/set declaration rendering, the common expression classes, lookup fallbacks, comment TLV append, and the unknown-expr marker. (2) A generic FieldEditor contract harness (ui/field_editor_harness_test.go) drives every editor registered in the rule editor's tabs (~140) through the full FocusSlots / Focus / Update / View / Blur / Changed / Save interface, constructed from both a populated and an empty rule (ui 6.6% → 42.3%), plus a whole-editor walk (F6 tab cycling, focus wrap-around, F2 save validation — the kernel-apply cmd is returned, never executed) and a value-pinning test for the verdict editor. (3) The CI integration job now writes a coverage profile (-coverpkg=./nft/...) and prints the total in the job log — the live netlink path is invisible to the unit profile, and measured 35.6% over the nft tree in a local root run. (4) Update state-machine tests for the chain create / edit dialogs (ui/chain_dialog_test.go, following the set-dialog test pattern): focus cycling with wrap-around, the kind toggle growing/shrinking the slot count, hook-option resync on chain-type change (including the invalid-hook fallback being flagged as a change), empty-name rejection, the no-op-save short-circuit that skips the kernel, and spec building for base vs regular chains — chain_create.go 0% → 95.4%, chain_edit.go 1.9% → 90.6%. The kernel-touching create/update cmds are returned by the dialogs but never executed in tests.
  • Second test-coverage wave on top of the first — total lifted to 56.5%. Rule-view render tests (ui/rule_view_test.go: all four tabs, every action type, the CT value-type switch, network payload/meta/exthdr/SCTP blocks, the reject/log/verdict/set-action/objref/set-lookup helpers — rule_view.go 2.3% → 90.1%). Table-tree state-machine tests (ui/table_tree_test.go: navigation, expand/collapse, scroll-follow, incremental search incl. match cycling, read-only guards, delete-confirm flow, row-selection messages — main_window_table_list.go 17.7% → 74.7%). MainWindow router tests (ui/main_window_route_test.go: view-switch messages, reload-batch messages, tree-refresh fan-out with --table filter preservation, quit-confirm overlay). chainView Update/View tests (ui/chain_view_update_test.go) through a new netlink-free newChainViewWithRules seam — chain_view.go 24.0% → 80.3%. RuleToHumanReadable gained the same injected-sets seam (ruleToHumanReadableWithSets) plus render tests; the pure parse helpers (natToAction / redirToAction / rejectToAction / queueToAction / extractValueFromCt / exthdrProtoToType) and the CT string converters (events / state / status / label-bit masks) got direct table tests; SerializeRedirect / SerializeReject covered in-package — nft/rule.go 55.4% → 73.8%, nft/expr/ct.go 53.4% → 63.5%.
  • Third test-coverage wave — total lifted to 61.8% (nft/expr 58.2% → 73.5%, nft 51.7% → 57.2%, ui 57.8% → 61.7%). The 22 zero-coverage pure serializers in nft/expr/ (dup / notrack / tproxy / flow_offload / hash / secmark / target / connlimit / match / socket / immediate / synproxy / queue / dynset / rt / numgen / quota / exthdr / fib / bitwise / nat / log, plus formatElement and the objref pair) got exact-output or token tests (serializers_misc_test.go, objref_test.go) — writing them surfaced the connlimit polarity bug fixed below. The table-create dialog got the same state-machine treatment as the chain dialogs (ui/table_create_test.go: focus cycle, name validation, family mapping, input routing — table_create.go 0% → 85.7%). RejectField got targeted tests beyond the generic harness (type-change code-rebuild with round-trip code restore, all four Save variants against the wire constants, family option/code mappings — field_reject.go 38.3% → 95.8%; field_ct_helpers.go 0% → 100%). setView gained a netlink-free newSetViewWithElements seam (same pattern as newChainViewWithRules) and a full Update/View suite (ui/set_view_test.go: add-prompt loop incl. parse errors and the map key/value tab flow, delete confirm, read-only guards, element/flag/hint formatting, interval-range rendering — set_view.go 27.6% → 78.9%). The pure nft helpers got direct tables: chain hook/policy/type string conversions and the per-family chain-type/hook validity matrices (chain.go 30.0% → 96.6%), and the set-element parsers (ParseSetElementKey branch sweep, ParseSetElementVal, KeyTypeFromString, the supported-type lists — set.go 47.6% → 68.7%). The remaining cold spots are the netlink cmd closures (nft_linux.go, main_window_stats.go) — integration-lane territory by design.
  • Fourth test-coverage wave — total lifted to 73.9% (nft/expr 73.5% → 92.2%, nft/nftserializer 38.6% → 71.5%, ui 61.7% → 73.8%, nft 57.2% → 62.0%). serializeRuleExprs got 25 more dispatch-arm cases (objref / redirect / nat / quota / dynset / match / target / connlimit / flow-offload / hash / synproxy / secmark / fib / numgen / rt / dup / notrack / tproxy / socket / bitwise / immediate / range), lifting it 34.7% → 90.9%. The CT converters and decoders got exhaustive branch sweeps (ct_decode_test.go, ct_expr_test.go): DecodeCTValue across every key, formatCtValue, FormatDuration, ExprCtToCt's Cmp/Bitwise/Lookup/Range arms, and fillCtField over all ~30 key/type combinations. The nft parse helpers got direct tables (masqToAction port-range, dynsetOpToString, exthdrFieldName, regValueFieldLabel, formatMACBytes) and ruleToHumanReadableWithSets gained 13 more render cases (CIDR-via-bitwise, set-lookup qualifier, log, dynset, connlimit, range). The biggest single win: a Save-path harness (ui/field_save_harness_test.go) that builds a rule carrying every CT expression group, forces a net change on each editor, then Saves into it — exercising the ~30 field-editor Save bodies' expr-finding loops that the contract harness left cold (most CT Save methods jumped from <15% to 50–94%). One incomplete-code finding flagged (not fixed): nftserializer.SerializeRuleset is unreferenced dead code that hard-codes empty sets/rules slices and needs a live *nftables.Conn, so it stays untested.
  • Fifth test-coverage wave, focused on the nft package — 62.0% → 73.5% (overall aggregate 74.2% → 75.7%). The pure string mappers in nft_linux.go got exhaustive switch tables (logLevelToString / payloadBaseToString / verdictKindToString / KeyTypeToString / Icmpv6TypeToStringnft_linux_helpers_test.go). identifyPayloadField got a one-row-per-cell table across every payload base and l4proto dispatch (ARP / IPv4 / IPv6 / ICMP / ICMPv6 / SCTP / DCCP / AH / ESP / IPComp / TCP / UDP / Ethernet — identify_payload_test.go, → 100%). The low-level set parsers got direct error-path tables (parseIP4 / parseIP6 / parseInetService / parseUintBE / cidrToRange / dashRangeToBytesset_parsers_test.go) and ParseSetElementKey gained an error-branch + valid/oversized-MAC sweep (47.6→95.7%). The big dispatcher NftablesToRuleDefinition got a kitchen-sink rule driving every type-switch arm in one pass plus targeted assertions (→ 98.8%), and the compareToCondition family was finished off via direct calls: payloadCompareToCondition's bit-packed refinements (version / hdrlength / dscp / flowlabel / doff / vlan id-cfi-pcp / dccp-type + CIDR / prefix address forms — payload_compare_test.go, → 97.6%), rangeToCondition's CT and unsupported arms, ctCompareToCondition's Pattern-B normalization and reply direction, and sctpChunkCompareToCondition's presence / known-field / unknown-field arms (all → 100%). ruleToHumanReadableWithSets gained the ip-protocol / icmp-type / iif special-cased Cmp arms plus a todo-arm sweep over the no-op render cases (78→95%), and decodePayloadValue got a full per-field-class table (→ 100%). The remaining cold spots are netlink-bound (RuleToHumanReadable's set-fetch wrapper, GetSets, the 0%-covered nft_linux.go cmd functions) or value-collision dead branches (ChainHookNumToString ingress/egress, parseIP6's unreachable not-IPv6 guard) — integration-lane territory by design.
  • Sixth test-coverage wave, across nft/nftserializer and ui (aggregate 75.7% → 76.7%). nftserializer 71.5% → 76.8%: serializeRuleExprs reached 100% by adding its missing dispatch arms (Ct, standalone Cmp with no pending register, Exthdr) plus the empty-Immediate skip and an anonymous-set-matched-by-ID lookup fallback (ruleset_test.go). ui 73.8% → 75.1%: the pure code↔name mappers and formatters got exhaustive tables (dccpTypeCodeToName / icmpTypeCodeToName / icmpv6TypeCodeToName / formatICMP / formatICMPv6 / arphrdCodeToName / nfprotoCodeToName / pkttypeCodeToName / parseEtherTypeTextfield_mappers_test.go); the TCP-flags byte↔names round-trip (tcpFlagsByteToNames / tcpFlagsNamesToByte); every dialog/view keymap's ShortHelp + FullHelp driven through the help.KeyMap interface from zero-value instances (keymap_help_test.go, 11 keymaps); the pure parseMAC helper with all error branches plus EtherAddrField.ValidateForSave driven through a constructed field (field_ether_test.go); and EtherTypeField.ValidateForSave (incl. the parse-error branch reachable only when the field originally held a value), MetaUintField.isCleared, and the NewUdpSportField / NewUdpDportField constructor wrappers (field_validate_test.go). No production bugs surfaced (pure additive tests). nftserializer.SerializeRuleset stays untested — confirmed still unreferenced, incomplete dead code (hard-codes empty sets/rules, needs a live *nftables.Conn); the remaining ui cold spots are the per-field Save bodies that require Select-widget manipulation to flip Changed(), and the netlink/event-loop closures.
  • Seventh test-coverage wave, across the root main package and ui (aggregate 76.7% → 78.0%). Root nftui 44.9% → 60.3%: applyStartupOptions reached 100% (empty-options no-op, missing---config propagation, the --table validation branch) and validateTableFilter gained its error path (main_test.go) — the unprivileged unit run hits the ListTables EPERM branch with the capability-advice message; the not-found branch and main itself stay integration/entry-point territory. ui 75.1% → 76.6%: the previously-noted Select-driven blocker was lifted by Select.SetValue + the embedded NumberInput's SetValue, unlocking ValidateForSave bodies for the verdict / queue / nat / quota / meta-select / meta-iftype editors (field_save_validate_test.go — jump-without-chain, queue range/fanout, NAT empty/garbage/family-mismatch, zero-quota) plus the verdictKindToExpr mapper. The biggest single win was direct branch coverage for applyIPAddrSave / applyIP6AddrSave (3.4% / 3.5% → high) — every Payload→[Bitwise→]Cmp rewrite shape (in-place Cmp, Bitwise insert/remove, prepend-new, the dangling-Payload early returns) driven by calling the package-level functions with crafted rules (field_ip_addr_save_test.go). No production bugs surfaced (pure additive tests). Remaining cold spots: the per-field Save bodies (e.g. field_ct_count, field_log, field_nat) and the netlink/TUI-event-loop closures.
  • Eighth test-coverage wave — the per-field Save() bodies in ui (aggregate 78.0% → 80.7%; ui 76.6% → 80.3%, crossing 80%). The contract harness drives every editor but undoes its edits, so each Save short-circuited on !Changed(); field_save_test.go instead sets widget state directly (now possible via Select.SetValue / NumberInput.SetValue / textinput.SetValue / MultiSelect.SetValues) and Saves into rules shaped to hit each branch — insert-fresh, overwrite-in-place, and remove. Covered: CtCountField (connlimit insert-before-verdict / update), CtProtocolField & CtL3ProtoField (Ct+Cmp insert / update), MetaUintField (append / overwrite / clear-removes), QuotaField (enabled-add / disabled-strip), the CT counters CtBytes/CtPkts/CtAvgpkt (the original/reply/none direction encodings), IcmpType/IcmpCode/Icmpv6Type/Icmpv6Code (l4proto-prefix insertion + payload pair, clear), TcpFlagsField & TransportUintField & TcpDoffField (payload[+bitwise]+cmp shapes), EtherAddrField & EtherTypeField (LL payload append / update), MetaIifField/MetaIifnameField/MetaOifnameField/MetaIftypeField (meta+cmp insert / overwrite), CtExpirationField (single / range / set duration forms), IP6DscpField & IP6FlowlabelField (the split-byte bitwise encodings), DccpTypeField (dccp type bit-packed match), ExthdrField (IPv6 ext-header field match), plus extractMasqState and the remaining LogField / MetaIifField ValidateForSave error branches. No production bugs surfaced (pure additive tests). The residual uncovered code is now almost entirely the two structural blockers — the netlink/CLI I/O layer (nft_linux.go, the set/named-object mutators, the ui netlink command closures) and the Bubble Tea TUI event loop (main_window.Update, main()) — both covered by the integration suite (run as root) and manual testing by design.
  • Ninth test-coverage wave — extended the integration suite (nft/integration_test.go) from the read-only readback path to the full netlink mutation path, lifting the nft package's coverage under the integration suite from 75.1% to 86.9% (the unit-only profile is unchanged — these tests are root- and nft-binary-gated behind the integration build tag). The previously 0%-covered mutators now run 60–90%. Four new tests, each creating a uniquely-named inet table torn down in t.Cleanup (host verified clean afterward): TestIntegration_TableLifecycle (CreateTableRenameTableDeleteTable, plus the same-name-rename no-op and nftCLIFamily's inet branch); TestIntegration_ChainLifecycle (CreateChain regular + base, all three UpdateChain paths — CLI rename, the minimal-netlink policy-only update, and the recreateBaseChain dump/rewrite path forced by a priority change — DeleteChain, and the Table-less-chain guard); TestIntegration_RuleLifecycle (AddNewRuleToChain incl. the returned kernel handle, InsertNewRuleBefore for both the idx==0 InsertRule and idx>0 Position+AddRule paths, MoveRuleUp/MoveRuleDown incl. the out-of-range no-op guards, DeleteRule); and TestIntegration_BulkReadersAndLoadConfig (LoadConfig's nft -f wrapper applied to a fixture that only creates the test's own table, then ListRulesOfTable, getAllRules via GetAllRulesWithAccept/GetAllRulesWithDrop, and CountRulesByType). FlushRules (nft flush ruleset, would wipe the host firewall) and LoadExamples (bundled relative-path ruleset the test cannot reliably clean up) are deliberately left uncovered, documented in the test file's header. No production bugs surfaced (pure additive tests).
  • CI security scanning (audit E-7): a dedicated govulncheck job in .github/workflows/ci.yml scans the module and the Go standard library against the vulnerability database on every push / PR — its own parallel check, failing only on a vulnerability reachable from nftui's own call graph (informational findings in unused dependency code don't break the build). A new .github/dependabot.yml opens weekly dependency- and GitHub-Actions-update PRs as upstream releases and security fixes land, with github.com/google/nftables excluded to honour its intentional pin. README's "Continuous integration" subsection documents both.
  • Release supply-chain attestation (audit E-6): the Goreleaser pipeline now signs checksums.txt with cosign (keyless — bound to the release workflow's OIDC identity via Fulcio/Rekor, no stored private key), emits a Syft SBOM per archive, and records a SLSA build-provenance attestation (GitHub-native actions/attest-build-provenance) for the archives and checksums. .goreleaser.yaml gains signs: + sboms: blocks; release.yml gains id-token: write + attestations: write and installs cosign + syft. Validated with goreleaser check. README's "Release process" subsection documents verification with cosign verify-blob and gh attestation verify.
  • Security policy (SECURITY.md, audit E-8): a vulnerability-disclosure policy covering private reporting via GitHub's "Report a vulnerability" (no public issue, no personal email to harvest), the supported-version policy (pre-1.0 — fixes target the most recent release), and nftui's security model. Because nftui has no authentication of its own and relies on CAP_NET_ADMIN, the policy spells out scope: ruleset-injection and parser crashes on untrusted data are in scope; needing root to change the firewall, and a setcap'd binary being runnable by any local user, are out of scope (deployment-side concerns).
  • Optional mutation audit log (audit E-9): setting the NFTUI_AUDIT_LOG environment variable to a writable path makes nftui append one JSON object per applied ruleset change (create / delete / rename table, chain and set; add / insert / move / delete / edit rule; add / delete set element; delete / reset named object; --config load; ruleset flush). Each record carries the UTC timestamp, effective UID + user, the human operator behind sudo (SUDO_USER), the operation, the target object, and the outcome (result ok/error, with the kernel's message on failure — rejected attempts are logged too) — covering the SOC 2 / PCI-DSS change-management expectation. The log is append-only (no rotation/truncation, never read back), created 0600, and fails open (a misconfigured path prints one warning and disables auditing rather than blocking firewall management). Unset/empty leaves the mutation path untouched. Implemented as a netlink-free nft.auditEvent seam wired into every mutator at its kernel-commit point (pure marshalLine + file-sink unit tests, plus an end-to-end integration test asserting real CreateTable/DeleteTable records). New README "Audit logging" section documents NFTUI_AUDIT_LOG.
  • Project governance docs (audit E-10): a CONTRIBUTING.md (development setup, the CI gate — gofmt, go vet on both build-tag sets, go test -race; one-change-per-PR; tests-first; English-only; CHANGELOG append-only; the nftables netlink-encoding correctness rule) and a CODE_OF_CONDUCT.md adopting the Contributor Covenant v2.1, with the enforcement contact set to the maintainer's public GitHub handle (no personal email, consistent with SECURITY.md). CONTRIBUTING.md links only to tracked repo files (CODE_OF_CONDUCT.md, SECURITY.md, LICENSE, README.md).
  • Deployment-hardening documentation (audit E-11): a new README "Privilege model & deployment hardening" section spells out that nftui has no access control of its own and relies on CAP_NET_ADMIN, then documents the two recommended ways to grant that privilege safely — sudo with a group-restricted sudoers rule (absolute path, password-logged invocations, a --read-only rule for browse-only roles, SUDO_USER captured in the audit log) and a group-restricted setcap binary (chmod 750 + dedicated group, with an explicit warning against setcap on a world-executable binary), plus defense-in-depth notes (audit log, --read-only, PAM-layer re-auth/MFA). This closes the "no RBAC / deployment guidance" governance gap by documenting the OS-layer controls rather than building authz into the tool. SECURITY.md's out-of-scope note now points to the section.

Changed

  • Refreshed dependencies to their latest minor/patch releases: golang.org/x/sys 0.44.0 → 0.46.0, golang.org/x/net 0.54.0 → 0.56.0, golang.org/x/text 0.37.0 → 0.38.0, golang.org/x/sync 0.20.0 → 0.21.0, github.com/mattn/go-runewidth 0.0.23 → 0.0.24, github.com/mdlayher/socket 0.6.0 → 0.6.1. github.com/google/nftables is intentionally left at its pinned snapshot (see the comment on its require line in go.mod), and the Bubble Tea libraries were already current. Validated against the full -race suite and the root integration run.

Fixed

  • Deleting or moving a rule now re-reads the chain and verifies the rule still exists before mutating, instead of acting blindly on the handle from a possibly-stale view. If the ruleset changed underneath the TUI (a concurrent edit, an external nft command) the operation fails with a clear "rule no longer exists — the ruleset changed; refresh and retry" message rather than a cryptic netlink error, and a move now re-plans against the current neighbour. Because nftables handles are never reused, this can't silently hit the wrong rule; the kernel transaction stays atomic, so a change in the remaining tiny race window fails cleanly without corrupting the ruleset. Surfaced by the enterprise-readiness audit (finding R3).
  • A netlink read failure during the initial table-tree load or when opening a chain no longer crashes the TUI. initialTableTreeModel and newChainView called panic(err) on any enumeration error; at startup (the tree load runs before the Bubble Tea program) this printed a raw Go stack trace instead of the styled "needs CAP_NET_ADMIN — run with sudo / setcap" advice, and opening a chain mid-run crashed the program. Both now return an error: a failed initial load is captured into the main view and rendered through the existing permission-advice path, and a failed chain open keeps the user on the tree with the error shown. The ui and nft packages now contain no panic/log.Fatal in non-test code. Surfaced by the enterprise-readiness audit (finding R2).
  • Table / chain / set names entered in the TUI dialogs are now validated against a strict identifier allowlist (nft.ValidateIdentifier: must start with a letter, then only letters, digits, _, ., -, max 255 bytes) before they reach the kernel or an nft -f script. Previously the dialogs only rejected an empty name, so a name containing nft-script metacharacters ({, }, ;, newlines, …) could inject statements into the privileged nft -f - transaction the table-rename / base-chain-recreate paths build, or silently corrupt the ruleset. Wired into all five name-entry dialogs (table create, chain create, chain edit, table rename, set create). Surfaced by the enterprise-readiness audit (finding S1).
  • Opening or refreshing a set no longer risks crashing the whole TUI on a transient netlink error. nft.GetSetElements called log.Fatal on any read failure, which terminated the process via os.Exit(1) and skipped Bubble Tea's terminal restore — leaving the terminal in raw mode (the user had to run reset). It now returns ([]nftables.SetElement, error): newSetView treats an error as an empty element list (the documented best-effort fetch for unreferenced anonymous sets) and RefreshElements keeps the previously-loaded elements while showing the error in the status line. The netlink-free post-processing (vmap VerdictData decode + interval pairing) was split out into a pure decodeSetElements seam and unit-tested directly. Surfaced by the enterprise-readiness audit (finding R1).
  • ct count polarity was inverted across all three layers — the TUI wrote the opposite rule to the kernel from what it displayed. nft CLI encoding (verified live via nft --debug=netlink): ct count over 5 sets NFT_CONNLIMIT_F_INV (matches when the connection count exceeds N), plain ct count 7 leaves flags 0 (matches while count ≤ N). The codebase used the reverse convention consistently in nftexpr.SerializeConnlimit (rule-list rendering), rule_view.go's CT tab, and CtCountField (both the initial read and Save — so editing a rule re-saved it with the opposite kernel semantics while the UI kept displaying the user's intent, masking the bug). All three sites flipped, the two tests that pinned the wrong convention corrected, and the live-verified encoding documented at each site. Found by the third-wave serializer tests.
  • chainView.View() no longer re-fetches the chain's entire rule list over netlink on every render. The "N rules" stat box called a helper that did a synchronous ListRulesOfChain per keystroke — and panicked the whole TUI on a transient netlink error. The count now comes from the already-fetched c.rules (kept current by RefreshRules after every mutation); the panicking helper is gone. Found by the second-wave View() render test.
  • RuleToHumanReadable now renders masquerade — the expr.Masq case was an empty TODO, so a masquerade rule showed nothing for the masq statement in the chain's rule list. Routed through the already-tested nftexpr.SerializeMasq.
  • nftserializer.SerializeRule could loop forever on a rule whose set lookup failed to resolve: the expr.Lookup branch re-fetched the set over netlink and continued without advancing the expression index on error, and its element fetch went through a helper that log.Fatals on failure — killing the whole TUI process. Rewritten to resolve the lookup against the table's already-fetched sets slice with an @<setName> fallback (mirroring the SerializeLookup fallback fixed below) and a plain error-checked element fetch. Found while writing the package's first unit tests; regression-pinned by TestSerializeRuleExprs_LookupTerminates.
  • RuleToHumanReadable now renders expr.Lookup (set lookups) instead of silently dropping them. The previous case body was commented out, so tcp dport { 80, 443 } accept came back as just tcp accept. New behavior: standalone Lookup reads the source-register description from regMap and runs it through nftexpr.SerializeLookup; the Payload case for saddr/daddr gained a Lookup lookahead so ip saddr @blocklist keeps the ip qualifier (the standalone branch would emit a bare saddr, since the ip prefix is added inline in the Payload case for IPv4 network-header reads). The integration test fixture was upgraded from scalar to anonymous-set form (tcp dport { 80, 443 }) to pin the fix and prevent regression. Unit coverage added in nft/expr/lookup_test.go.
  • nftexpr.SerializeLookupWithKey (the CT-typed sibling of SerializeLookup, used for ct state @setname style lookups) received the same two fallback fixes as SerializeLookup below — it was missed when the original fix landed: a set absent from the caller's sets slice now renders as @<setName> instead of a register with trailing whitespace, and the empty-SetName fallback lost its stray leading @ (@set_Nset_N, so the post-format @ prefix doesn't double up). Surfaced by the second pre-tag audit; pinned by three new unit tests in nft/expr/lookup_test.go.
  • Second pre-tag audit cleanup, docs and comments: README's "Release history" section no longer lists v0.8.0 as "in progress" (tagged 2026-05-30) and describes the renamed v0.9.0 milestone instead of the stale v1.0.0 entry; Hungarian comments translated to English as they surfaced (nft/expr/lookup.go, nft/expr/bitwise.go, three more in nft/rule.go); two misleading comments corrected — .goreleaser.yaml (the changelog: block is bypassed whenever --release-notes is passed; the archive's CHANGELOG.md comes from files:, not from that block) and flake.nix (the devShell's pkgs.go is the nixpkgs-unstable pin, not the exact go.mod toolchain version CI resolves).
  • nftexpr.SerializeLookup no longer returns a register with trailing whitespace when the looked-up set isn't in the caller's sets slice. The previous code wrote elementsString only on a successful match — a miss left it empty, and fmt.Sprintf("%s %s", register, elementsString) produced "tcp dport ". New behavior: if no match was found and no elements were resolved, fall back to @<setName> so the rendered form is always syntactically nft-CLI shaped. The setName fallback for empty Lookup.SetName also lost its stray leading @ (was @set_N, now set_N) so the post-format "@" + setName produces a clean @set_N.
  • Virtualized rule-list rendering in chainView. Replaces the off-by-many maxHeight := c.height - 20 (which assumed 1 line per rule and let the cursor scroll off-screen with the rest of the entries below it clipped by the content box) with a dynamic maxVisibleRules() that divides the available content-box height by the actual ruleEntryLines = 4 and subtracts a headerLines() count computed from the chain's optional hook / priority / policy fields and the optional filter-prompt block. The render loop, the Down handler, and the filter-mode Down handler all route through maxVisibleRules() so a 1000-rule chain iterates the same ~5–10 entries as a 10-rule one. New matchCache map[uint64]string on chainView memoizes the lowercase RuleToHumanReadable + ExtractComment haystack per rule.Handle; ruleMatchesFilter reads it on every keystroke and only re-serializes on a cache miss. RefreshRules clears the cache (handles survive a rule edit but the rendered text behind them does not). Manual-test fixture: examples/example-nftables-01.conf section 48 (table inet large_chain_demochain many_rules) carries 60 hookless rules with rule NN — ... comments for scroll and filter exercises. Unit tests pin the maxVisibleRules / headerLines math and the cache hit / no-cache-on-empty-query behavior.

Security

  • Bumped github.com/mdlayher/netlink off the upstream-retracted v1.11.1 to the suggested v1.11.2. A retracted version is one the author flagged "do not use"; the bump is the only go.mod change (the netlink transport sits under every kernel read/write, so the change was validated against the full -race suite and the root integration run). Surfaced by the enterprise-readiness audit (supply-chain, finding E-13).
  • Defense-in-depth identifier validation inside the nft package (audit E-12). CreateTable, RenameTable, CreateChain, UpdateChain, and CreateSet now call nft.ValidateIdentifier on the caller-supplied name as their first step — returning an error rather than touching the kernel or building an nft -f - script when the name carries script metacharacters. This complements the E-2 dialog-level validation: the guard now also covers any future caller (a new dialog, a config-import path, a programmatic API consumer) that reaches the name-interpolating mutators without going through a validated input field. Existing kernel-sourced names are not re-validated (they may be legitimately quoted identifiers); only the new, caller-chosen name is checked. Unit tests drive each mutator with an injection identifier and assert it is rejected pre-kernel; the root integration suite (valid names) stays green.