Skip to content

Commit ae73a87

Browse files
edimarlnxclaude
andauthored
0.6.0: quoted previews; --open prefilled add form (#26) (#27)
Closes #26. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbCfX3T8khyd9hsrN5kMo3
1 parent ee682ff commit ae73a87

34 files changed

Lines changed: 805 additions & 28 deletions

‎Makefile‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ tidy:
6767
## nftables one for the router screens — the flow rule list, the NAT and alias
6868
## views, the phase-2 add form and the staged atomic apply — and the iptables
6969
## one for the cloud-image screens: the insert before the catch-all REJECT and
70-
## the persist diff.
70+
## the persist diff. The last run is the --open hand-off: the add form opened
71+
## prefilled (the rest of the comment is typed, since --args splits on spaces).
7172
screenshots: build
7273
python3 $(KIT)/tools/render-screenshots.py \
7374
--bin $(BIN)/$(TOOL) --name $(TOOL) --out docs/screenshots \
@@ -94,6 +95,10 @@ screenshots: build
9495
--screen iptables= \
9596
--screen 'iptables-add=a\t51820\t\rudp\r\t\r' \
9697
--screen 'iptables-persist=a\t51820\t\rudp\r\t\ryW'
98+
python3 $(KIT)/tools/render-screenshots.py \
99+
--bin $(BIN)/$(TOOL) --name $(TOOL) --out docs/screenshots \
100+
--args="--demo --open=19443/tcp,41641/udp --comment=headscale" \
101+
--screen 'open= control (tailnet)'
97102

98103
## readme: regenerate the generated README sections from tool.json.
99104
readme:

‎README.md‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ Upgrades then arrive with the rest of your system updates.
135135
### Any distribution, static binary
136136

137137
```sh
138-
curl -fsSL https://github.com/tui-tools/tui-firewall/releases/download/v0.5.0/tui-firewall_0.5.0_linux_amd64.tar.gz | tar -xz tui-firewall
138+
curl -fsSL https://github.com/tui-tools/tui-firewall/releases/download/v0.6.0/tui-firewall_0.6.0_linux_amd64.tar.gz | tar -xz tui-firewall
139139
sudo install -m0755 tui-firewall /usr/local/bin/tui-firewall
140140
```
141141

@@ -222,6 +222,8 @@ tui-firewall --demo=iptables # sample cloud-image iptables
222222
tui-firewall --demo=nftables # sample router ruleset
223223
tui-firewall --check # read the firewall, print JSON, exit
224224
tui-firewall --report # print what a bug report needs, exit
225+
tui-firewall --open 19443/tcp,41641/udp --comment 'headscale control (tailnet)'
226+
# open the add form prefilled, per port
225227
tui-firewall --backend ufw # skip autodetection
226228
tui-firewall --backend firewalld
227229
tui-firewall --backend iptables
@@ -265,6 +267,52 @@ would run a binary nobody asked this tool to touch.
265267
against real firewalls on Ubuntu, Fedora and Omarchy Server; the assertions live
266268
in [`test/smoke.sh`](test/smoke.sh).
267269

270+
### `--open`, a prefilled add form for a hand-off
271+
272+
```sh
273+
tui-firewall --open 19443/tcp,41641/udp --comment 'headscale control (tailnet)'
274+
tui-firewall --open 19443/tcp --open 41641/udp # the same list, repeated
275+
tui-firewall --demo --open 19443/tcp --comment test # try it on the demo
276+
```
277+
278+
`--open` starts the UI straight in the add-rule form, filled in to allow the
279+
first port: action ALLOW, the port, the protocol and the `--comment`. Enter
280+
shows the preview and `y` applies it, exactly as a rule added by hand with `a`;
281+
nothing is applied without that confirm. Then the form for the next port opens,
282+
one form and one confirm per port, in the order given. `esc` skips a port and
283+
`n` at the confirm declines it; either way the sequence moves on. Every other
284+
field of the form is still there to change before Enter.
285+
286+
It is the entry point for another tool that has just found a port closed, such
287+
as [tui-tailscale](https://github.com/tui-tools/tui-tailscale) handing over the
288+
ports a self-hosted control plane needs, so the user does not have to retype
289+
what that tool already knew.
290+
291+
Where the rule goes depends on the backend:
292+
293+
| Backend | Group the rule is added to |
294+
|---|---|
295+
| ufw | the rule list (`ufw allow 19443/tcp comment 'headscale control (tailnet)'`) |
296+
| firewalld | the default zone (`--add-port=19443/tcp`); firewalld has no rule comments, so `--comment` is not used and the status line says so |
297+
| nftables | the `input` chain of the `inet tui` table, or else the first chain hooked on input; on an empty ruleset the status line says so and the forms open once the chains are created with `x` |
298+
| iptables | the filter `INPUT` chain of each family, so a port gets one form for `iptables` and one for `ip6tables` |
299+
300+
The value is checked before anything is read: each entry is a single port from
301+
1 to 65535 and `tcp` or `udp`, a port may not be given twice, and the comment is
302+
one line of at most 128 characters. A wrong value exits with the reason instead
303+
of opening a half-filled form. `--comment` without `--open`, and `--open` with
304+
`--check` or `--report`, are refused the same way. `--check` itself is
305+
unchanged.
306+
307+
Every preview is shell-quoted, so a comment with a space or a parenthesis reads
308+
as one argument, and the line pasted into a shell runs the same argv:
309+
310+
```console
311+
$ /usr/bin/sudo -n ufw allow 19443/tcp comment 'headscale control (tailnet)'
312+
```
313+
314+
![A prefilled --open form](docs/screenshots/tui-firewall-open.png)
315+
268316
### `--report`, for bug reports
269317

270318
`--report` prints, in one block, everything a maintainer has to ask for

‎cmd/tui-firewall/app.go‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ type app struct {
132132
loadFailed bool
133133
// busy blocks input while a command runs.
134134
busy bool
135+
// loaded reports that a Load has succeeded at least once.
136+
loaded bool
137+
138+
// open is the --open sequence still to show, or nil.
139+
open *openQueue
135140
}
136141

137142
// loadedMsg carries the result of a Load.
@@ -228,8 +233,17 @@ func (a *app) setStatusf(kind ui.StatusKind, format string, args ...any) {
228233
a.setStatus(kind, fmt.Sprintf(format, args...))
229234
}
230235

231-
// Update is the main event loop.
236+
// Update is the main event loop. After every message it gives the --open
237+
// sequence a chance to show its next form, which only happens once the screen
238+
// is idle again.
232239
func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
240+
model, cmd := a.update(msg)
241+
a.advanceOpen()
242+
return model, cmd
243+
}
244+
245+
// update handles one message.
246+
func (a *app) update(msg tea.Msg) (tea.Model, tea.Cmd) {
233247
switch msg := msg.(type) {
234248
case tea.WindowSizeMsg:
235249
a.width, a.height = msg.Width, msg.Height
@@ -244,6 +258,7 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
244258
return a, nil
245259
}
246260
a.loadFailed = false
261+
a.loaded = true
247262
a.model = msg.model
248263
if a.persistHintPending {
249264
a.persistHintPending = false

‎cmd/tui-firewall/logview_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ func TestToggleLogKeyPreviewsReplace(t *testing.T) {
2828
if a.mode != modeConfirm {
2929
t.Fatalf("l should preview a confirm, mode = %v", a.mode)
3030
}
31-
want := `replace rule inet tui input handle 14 iifname "wan0" tcp dport 22 ` +
32-
`log prefix "tui:input drop " counter drop`
31+
want := `replace rule inet tui input handle 14 iifname '"wan0"' tcp dport 22 ` +
32+
`log prefix '"tui:input drop "' counter drop`
3333
if !strings.Contains(a.confirm.Command, want) {
3434
t.Errorf("the preview is not the log toggle:\n%s", a.confirm.Command)
3535
}

‎cmd/tui-firewall/main.go‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ type options struct {
8888
themePath string
8989
sudo string
9090
showVersion bool
91+
// open and comment are the --open hand-off: ports to prefill the add
92+
// form with, one form per port, and the comment for those rules.
93+
open openFlag
94+
comment string
9195
// sudoSet records whether -sudo was passed, so `--sudo ""` can disable
9296
// escalation instead of reading as "not given".
9397
sudoSet bool
@@ -113,6 +117,8 @@ func parseFlags(args []string, out *os.File) (options, error) {
113117
"path to an Omarchy-style colors.toml (overrides the config file)")
114118
fs.StringVar(&opts.sudo, "sudo", "",
115119
"privilege escalation prefix, e.g. \"sudo -n\" or \"\" to disable")
120+
fs.Var(&opts.open, "open", openUsage)
121+
fs.StringVar(&opts.comment, "comment", "", commentUsage)
116122
fs.BoolVar(&opts.showVersion, "version", false, "print the version and exit")
117123
fs.Usage = func() {
118124
_, _ = fmt.Fprintf(out, "tui-firewall — a terminal UI for the system firewall\n\n"+
@@ -154,6 +160,9 @@ func run(args []string) error {
154160
fmt.Println("tui-firewall", version)
155161
return nil
156162
}
163+
if err := validateOpenOptions(opts); err != nil {
164+
return err
165+
}
157166

158167
cfg, err := config.Load(config.Options{Tool: toolName, Defaults: defaults()})
159168
if err != nil {
@@ -201,8 +210,9 @@ func run(args []string) error {
201210
selectionDetail(cfg, opts), os.Stdout)
202211
}
203212

204-
program := tea.NewProgram(newApp(backend, theme.New(), backendCompat),
205-
tea.WithAltScreen())
213+
a := newApp(backend, theme.New(), backendCompat)
214+
a.queueOpen(opts.open.ports, opts.comment)
215+
program := tea.NewProgram(a, tea.WithAltScreen())
206216
_, err = program.Run()
207217
return err
208218
}

‎cmd/tui-firewall/nftables_test.go‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,9 @@ func TestNftablesAddRulePreview(t *testing.T) {
162162
a.form.setFieldForTest("ports", "8443")
163163
send(t, a, "enter")
164164

165+
// The preview is shell-quoted: pasted, it reaches nft as the same words.
165166
want := `nft add rule inet tui input tcp dport 8443 counter accept ` +
166-
`comment "the new service"`
167+
`comment '"the new service"'`
167168
if got := previewOf(t, a); got != want {
168169
t.Errorf("preview =\n %s\nwant\n %s", got, want)
169170
}
@@ -195,7 +196,7 @@ func TestNftablesPolicyPreview(t *testing.T) {
195196
if !selectOption(t, a, string(firewall.PolicyAllow)) {
196197
t.Fatal("the policy picker should offer allow")
197198
}
198-
want := "nft chain inet tui input { policy accept ; }"
199+
want := "nft chain inet tui input '{' policy accept ';' '}'"
199200
if got := previewOf(t, a); got != want {
200201
t.Errorf("preview = %q, want %q", got, want)
201202
}
@@ -214,8 +215,8 @@ func TestNftablesActionsMenuBuildsAPortForward(t *testing.T) {
214215
answerStep(t, a, answer)
215216
}
216217

217-
want := `nft add rule inet tui prerouting iifname "wan0" tcp dport 2222 ` +
218-
`counter dnat ip to 10.10.0.7:22 comment "tcp 2222 to 10.10.0.7:22"`
218+
want := `nft add rule inet tui prerouting iifname '"wan0"' tcp dport 2222 ` +
219+
`counter dnat ip to 10.10.0.7:22 comment '"tcp 2222 to 10.10.0.7:22"'`
219220
if got := previewOf(t, a); got != want {
220221
t.Errorf("preview =\n %s\nwant\n %s", got, want)
221222
}
@@ -236,8 +237,8 @@ func TestNftablesActionsMenuBuildsAnAlias(t *testing.T) {
236237
answerStep(t, a, "yes")
237238
answerStep(t, a, "peers allowed in")
238239

239-
want := `nft add set inet tui vpn_peers { type ipv4_addr ; flags interval ; ` +
240-
`comment "peers allowed in" ; }`
240+
want := `nft add set inet tui vpn_peers '{' type ipv4_addr ';' flags interval ';' ` +
241+
`comment '"peers allowed in"' ';' '}'`
241242
if got := previewOf(t, a); got != want {
242243
t.Errorf("preview =\n %s\nwant\n %s", got, want)
243244
}

0 commit comments

Comments
 (0)