Skip to content

Commit c8e5a62

Browse files
edimarlnxclaude
andcommitted
nftables phase 2: router-shaped matches and a staged atomic apply
Close the seven UI gaps the phase-1 lab run surfaced, and add the connectivity-safe batch apply. Filter rules gain interface (iifname/oifname), connection-state (ct state established,related) and ICMP (ip protocol icmp / ip6 nexthdr ipv6-icmp, with an optional type) matches, each built for the table's family. Masquerade takes an optional source network. A rule that matches an alias reads its family off the set's element type instead of demanding the Family field. The actions menu can delete a chain of the tool's own table and drop the whole table, guarded so it never removes someone else's. Pressing a in the NAT and alias views opens the actions those views add through instead of a refusal. Staging (internal/nftables/staging) collects mutations into a pending set, applies them as one `nft -f` transaction — all-or-nothing — after a single preview and confirm, snapshots the ruleset first, and rolls it back on its own if the operator does not confirm they still have access within a keep window (default 60s). Driven from the TUI with s/S/k, and reported by --check and --report. Covered by table-driven tests, staging unit tests with a hand-driven timer, two new fuzz targets plus ct-state/icmp parser seeds (which caught a whitespace-only ct state), and a real-nft run in a `unshare -rn` namespace that applies a forward policy drop with its accept atomically and restores the snapshot on a missed keep. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q
1 parent 62d3084 commit c8e5a62

23 files changed

Lines changed: 2624 additions & 56 deletions

‎cmd/tui-firewall/app.go‎

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
tea "github.com/charmbracelet/bubbletea"
1010
"github.com/tui-tools/tui-firewall/internal/firewall"
11+
"github.com/tui-tools/tui-firewall/internal/nftables/staging"
1112
"github.com/tui-tools/tui-kit/compat"
1213
"github.com/tui-tools/tui-kit/theme"
1314
"github.com/tui-tools/tui-kit/ui"
@@ -39,6 +40,8 @@ const (
3940
pickerGroup
4041
pickerExtra
4142
pickerExtraStep
43+
pickerStagingAction
44+
pickerStagingDrop
4245
)
4346

4447
// app is the tui-firewall Bubble Tea model.
@@ -76,6 +79,25 @@ type app struct {
7679
// matched back to the action it names.
7780
extras []firewall.Extra
7881

82+
// staging holds the pending-changes set and the atomic-apply lifecycle. It
83+
// is only reachable on a backend that can snapshot its ruleset (nftables);
84+
// snap is that backend, or nil.
85+
staging *staging.Session
86+
snap snapshotter
87+
stagingOn bool
88+
// awaitingKeep reports that a staged batch was applied and is waiting for
89+
// the operator to confirm they still have access before it rolls back.
90+
awaitingKeep bool
91+
// keepToken guards the rollback timer against a stale tick: only the tick
92+
// carrying the current token may fire the rollback.
93+
keepToken int
94+
// pendingApply marks the change now at the confirm dialog as the staged
95+
// batch, so a yes arms the keep timer instead of just reporting success.
96+
pendingApply bool
97+
// keepTickPending asks the next load to start the rollback countdown, so
98+
// the applied batch is on screen before its keep window begins.
99+
keepTickPending bool
100+
79101
status string
80102
statusKind ui.StatusKind
81103
loading bool
@@ -125,6 +147,13 @@ func newApp(backend firewall.Backend, th theme.Theme,
125147
if !backendCompat.Caps().Has("rule-comments") {
126148
a.caps.SupportsComments = false
127149
}
150+
// Staging is offered only where a rollback is possible: a backend that can
151+
// snapshot its own ruleset. That is the nftables backend and its demo; ufw
152+
// and firewalld apply through their own daemons and have no such snapshot.
153+
if snap, ok := backend.(snapshotter); ok {
154+
a.snap = snap
155+
a.staging = staging.New(0)
156+
}
128157
if th.Warning != "" {
129158
a.setStatus(ui.StatusWarn, th.Warning)
130159
}
@@ -188,14 +217,37 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
188217
a.group = a.model.Groups[0].Name
189218
}
190219
a.applyFilter()
220+
// A batch that just applied starts its keep window now, with the applied
221+
// ruleset already on screen, so the operator sees what they are keeping.
222+
if a.keepTickPending && a.staging != nil {
223+
a.keepTickPending = false
224+
token := a.keepToken
225+
timeout := a.staging.Timeout()
226+
return a, tea.Tick(timeout, func(time.Time) tea.Msg {
227+
return keepExpiredMsg{token: token}
228+
})
229+
}
191230
return a, nil
192231

193232
case ranMsg:
194233
a.busy = false
234+
wasApply := a.pendingApply
235+
a.pendingApply = false
195236
if msg.err != nil {
237+
if wasApply {
238+
// The batch was atomic: nft rejected it, so nothing changed and
239+
// there is nothing to keep or roll back.
240+
a.setStatusf(ui.StatusError,
241+
"the staged batch was rejected and nothing changed: %s",
242+
firstLine(msg.err.Error()))
243+
return a, a.load()
244+
}
196245
a.setStatus(ui.StatusError, msg.err.Error())
197246
return a, a.load()
198247
}
248+
if wasApply {
249+
return a, a.armKeep()
250+
}
199251
summary := strings.TrimSpace(msg.output)
200252
if summary == "" {
201253
summary = "done"
@@ -204,6 +256,17 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
204256
a.loading = true
205257
return a, a.load()
206258

259+
case keepExpiredMsg:
260+
return a, a.keepExpired(msg.token)
261+
262+
case applyReadyMsg:
263+
if msg.err != nil {
264+
a.setStatus(ui.StatusError, msg.err.Error())
265+
return a, nil
266+
}
267+
a.openApplyConfirm(msg.change)
268+
return a, nil
269+
207270
case tea.KeyMsg:
208271
return a.handleKey(msg)
209272
}
@@ -260,6 +323,8 @@ func (a *app) handleConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
260323
change, ok := a.confirm.Payload.(firewall.Change)
261324
a.confirm = ui.Confirm{}
262325
if !confirmed || !ok {
326+
// A cancelled apply must not leave the next run looking like the batch.
327+
a.pendingApply = false
263328
a.setStatus(ui.StatusInfo, "cancelled")
264329
return a, nil
265330
}
@@ -358,6 +423,13 @@ func (a *app) handlePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
358423
return a, a.startExtra(choice)
359424
case pickerExtraStep:
360425
return a, a.answerExtra(choice)
426+
case pickerStagingAction:
427+
a.mode = modeTable
428+
return a, a.stagingAction(choice)
429+
case pickerStagingDrop:
430+
a.mode = modeTable
431+
a.dropStaged(choice)
432+
return a, nil
361433
default:
362434
a.mode = modeTable
363435
return a, nil
@@ -413,7 +485,7 @@ func (a *app) submitForm() tea.Cmd {
413485
a.setStatus(ui.StatusError, err.Error())
414486
return nil
415487
}
416-
a.openConfirm(change.Description, "The firewall will be changed as follows.", change)
488+
a.stageOrConfirm(change.Description, "The firewall will be changed as follows.", change)
417489
return nil
418490
}
419491

@@ -427,6 +499,11 @@ func (a *app) handleTableKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
427499
case "j", "down":
428500
a.moveCursor(1)
429501
case "k", "up":
502+
// k keeps an applied batch when one is waiting; otherwise it is the
503+
// vi-style move up.
504+
if msg.String() == "k" && a.awaitingKeep {
505+
return a, a.keepStaged()
506+
}
430507
a.moveCursor(-1)
431508
case "g", "home":
432509
a.cursor, a.offset = 0, 0
@@ -445,8 +522,11 @@ func (a *app) handleTableKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
445522
a.loading = true
446523
return a, a.load()
447524
case "a":
448-
a.form = newRuleForm(a.caps, a.model.Services)
449-
a.mode = modeForm
525+
return a, a.startAdd()
526+
case "s":
527+
a.toggleStaging()
528+
case "S":
529+
return a, a.openStagingMenu()
450530
case "d":
451531
return a, a.confirmDelete()
452532
case "e":
@@ -469,6 +549,22 @@ func (a *app) handleTableKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
469549
return a, nil
470550
}
471551

552+
// startAdd begins adding an entry to the view on screen. A rule view opens the
553+
// add-rule form; the NAT and alias views hold no plain rules, so a there opens
554+
// the actions menu those views add through — the masquerade and port-forward
555+
// actions for NAT, the create-alias action for the aliases — instead of a
556+
// refusal that only tells the user to press x.
557+
func (a *app) startAdd() tea.Cmd {
558+
switch a.currentView() {
559+
case firewall.ViewNAT, firewall.ViewAliases:
560+
return a.openExtrasMenu()
561+
default:
562+
a.form = newRuleForm(a.caps, a.model.Services)
563+
a.mode = modeForm
564+
return nil
565+
}
566+
}
567+
472568
// buildAndConfirm runs a command builder and opens the confirm dialog, or
473569
// reports the builder's error in the status line.
474570
func (a *app) buildAndConfirm(title string,
@@ -478,7 +574,7 @@ func (a *app) buildAndConfirm(title string,
478574
a.setStatus(ui.StatusError, err.Error())
479575
return nil
480576
}
481-
a.openConfirm(title, change.Description+".", change)
577+
a.stageOrConfirm(title, change.Description+".", change)
482578
return nil
483579
}
484580

@@ -512,7 +608,7 @@ func (a *app) confirmDelete() tea.Cmd {
512608
return nil
513609
}
514610
change.Destructive = true
515-
a.openConfirm(change.Description, "Rule: "+describeRule(rule), change)
611+
a.stageOrConfirm(change.Description, "Rule: "+describeRule(rule), change)
516612
return nil
517613
}
518614

@@ -695,7 +791,7 @@ func (a *app) confirmExtra() tea.Cmd {
695791
if extra.Warning != "" {
696792
body += "\n" + extra.Warning
697793
}
698-
a.openConfirm(extra.Label, body, change)
794+
a.stageOrConfirm(extra.Label, body, change)
699795
return nil
700796
}
701797

‎cmd/tui-firewall/check.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/tui-tools/tui-firewall/internal/backends"
1111
"github.com/tui-tools/tui-firewall/internal/firewall"
1212
"github.com/tui-tools/tui-firewall/internal/nftables"
13+
"github.com/tui-tools/tui-firewall/internal/nftables/staging"
1314
"github.com/tui-tools/tui-kit/compat"
1415
)
1516

@@ -84,6 +85,23 @@ type nftablesFacts struct {
8485
// Writable lists the groups this backend would accept a rule in, which is
8586
// the mutation guard reported rather than described.
8687
Writable []string `json:"writable"`
88+
// Staging reports the connectivity-safe apply: whether this backend offers
89+
// it, and — in an interactive session — whether a batch is active.
90+
Staging stagingFacts `json:"staging"`
91+
}
92+
93+
// stagingFacts is the staging block of --check: staging is a mode of the
94+
// interactive session, so a non-interactive check reports the capability and a
95+
// zero batch, which is the honest answer for a path that never stages.
96+
type stagingFacts struct {
97+
// Supported reports whether this backend can stage and roll back at all.
98+
Supported bool `json:"supported"`
99+
// Active reports whether a staging batch is open, and Pending how many
100+
// changes it holds. Both are zero outside the interactive UI.
101+
Active bool `json:"active"`
102+
Pending int `json:"pending"`
103+
// TimeoutSeconds is the keep-confirmation window a batch would apply under.
104+
TimeoutSeconds int `json:"timeoutSeconds"`
87105
}
88106

89107
// rulesetSource is the part of the nftables backends --check reads its extra
@@ -129,6 +147,14 @@ func collectNftablesFacts(backend firewall.Backend, model firewall.Model) *nftab
129147
}
130148
facts.Writable = append(facts.Writable, group.Name)
131149
}
150+
// Staging is a property of the interactive session, which --check never
151+
// starts; what it can report is whether this backend supports it and under
152+
// what timeout a batch would apply.
153+
_, supported := backend.(snapshotter)
154+
facts.Staging = stagingFacts{
155+
Supported: supported,
156+
TimeoutSeconds: int(staging.DefaultTimeout.Seconds()),
157+
}
132158
return facts
133159
}
134160

‎cmd/tui-firewall/form.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@ import (
1515
// noneOption is the "leave empty" entry of every optional choice field.
1616
const noneOption = "(none)"
1717

18+
// ctStateOptions are the connection-state presets the form offers. They are
19+
// the combinations a stateful firewall is actually built from; the field is a
20+
// single pick because a rule almost never wants an arbitrary subset, and the
21+
// one it usually wants — established,related — leads the list.
22+
var ctStateOptions = []string{
23+
noneOption,
24+
"established,related",
25+
"established,related,new",
26+
"new",
27+
"invalid",
28+
"established",
29+
"related",
30+
}
31+
1832
// fieldKind tells a cycled choice from a free-text field.
1933
type fieldKind int
2034

@@ -79,6 +93,9 @@ func newRuleForm(caps firewall.Capabilities, services []string) ruleForm {
7993
directions = append(directions, string(d))
8094
}
8195
protos := []string{noneOption, "tcp", "udp"}
96+
if caps.SupportsICMP {
97+
protos = append(protos, "icmp", "icmpv6")
98+
}
8299
serviceOptions := append([]string{noneOption}, services...)
83100

84101
fields := []formField{
@@ -102,6 +119,29 @@ func newRuleForm(caps firewall.Capabilities, services []string) ruleForm {
102119
input: text("any, 10.0.0.0/8, fd00::/8")},
103120
{key: "to", label: "To", kind: fieldText, input: text("any, 192.168.1.10")},
104121
}...)
122+
if caps.SupportsICMP {
123+
fields = append(fields, formField{key: "icmptype", label: "ICMP type",
124+
kind: fieldText, input: text("echo-request, echo-reply (icmp only)"),
125+
help: "Only used when the protocol is icmp or icmpv6."})
126+
}
127+
// Interface matches are what make a router rule a router rule: "SSH from
128+
// the LAN side only" is an iifname match. They exist only where the backend
129+
// can express them.
130+
if caps.SupportsInterfaces {
131+
fields = append(fields,
132+
formField{key: "iif", label: "In iface", kind: fieldText,
133+
input: text("lan0, wan0 — the interface it arrives on")},
134+
formField{key: "oif", label: "Out iface", kind: fieldText,
135+
input: text("wan0 — the interface it leaves on")})
136+
}
137+
// The connection-state match is what makes a rule stateful. The presets are
138+
// the combinations a router actually uses; "established,related" is the one
139+
// every stateful firewall opens with.
140+
if caps.SupportsConntrack {
141+
fields = append(fields, formField{key: "ctstate", label: "Conn. state",
142+
kind: fieldChoice, options: ctStateOptions,
143+
help: "established,related is the stateful default."})
144+
}
105145
if caps.SupportsFamily {
106146
fields = append(fields, formField{key: "family", label: "Family",
107147
kind: fieldChoice, options: []string{noneOption, "v4", "v6"},
@@ -222,11 +262,17 @@ func (f ruleForm) spec() (firewall.RuleSpec, error) {
222262
Proto: f.get("proto"),
223263
From: f.get("from"),
224264
To: f.get("to"),
265+
InIface: f.get("iif"),
266+
OutIface: f.get("oif"),
267+
ICMPType: f.get("icmptype"),
225268
Comment: f.get("comment"),
226269
Family: firewall.Family(f.get("family")),
227270
Log: f.get("log") == "yes",
228271
Routed: f.get("routed") == "yes",
229272
}
273+
if states := f.get("ctstate"); states != "" {
274+
spec.CTStates = strings.Split(states, ",")
275+
}
230276
if position := f.get("position"); position != "" {
231277
n, err := strconv.Atoi(position)
232278
if err != nil || n <= 0 {

0 commit comments

Comments
 (0)