Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ tidy:
## nftables one for the router screens — the flow rule list, the NAT and alias
## views, the phase-2 add form and the staged atomic apply — and the iptables
## one for the cloud-image screens: the insert before the catch-all REJECT and
## the persist diff.
## the persist diff. The last run is the --open hand-off: the add form opened
## prefilled (the rest of the comment is typed, since --args splits on spaces).
screenshots: build
python3 $(KIT)/tools/render-screenshots.py \
--bin $(BIN)/$(TOOL) --name $(TOOL) --out docs/screenshots \
Expand All @@ -94,6 +95,10 @@ screenshots: build
--screen iptables= \
--screen 'iptables-add=a\t51820\t\rudp\r\t\r' \
--screen 'iptables-persist=a\t51820\t\rudp\r\t\ryW'
python3 $(KIT)/tools/render-screenshots.py \
--bin $(BIN)/$(TOOL) --name $(TOOL) --out docs/screenshots \
--args="--demo --open=19443/tcp,41641/udp --comment=headscale" \
--screen 'open= control (tailnet)'

## readme: regenerate the generated README sections from tool.json.
readme:
Expand Down
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Upgrades then arrive with the rest of your system updates.
### Any distribution, static binary

```sh
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
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
sudo install -m0755 tui-firewall /usr/local/bin/tui-firewall
```

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

### `--open`, a prefilled add form for a hand-off

```sh
tui-firewall --open 19443/tcp,41641/udp --comment 'headscale control (tailnet)'
tui-firewall --open 19443/tcp --open 41641/udp # the same list, repeated
tui-firewall --demo --open 19443/tcp --comment test # try it on the demo
```

`--open` starts the UI straight in the add-rule form, filled in to allow the
first port: action ALLOW, the port, the protocol and the `--comment`. Enter
shows the preview and `y` applies it, exactly as a rule added by hand with `a`;
nothing is applied without that confirm. Then the form for the next port opens,
one form and one confirm per port, in the order given. `esc` skips a port and
`n` at the confirm declines it; either way the sequence moves on. Every other
field of the form is still there to change before Enter.

It is the entry point for another tool that has just found a port closed, such
as [tui-tailscale](https://github.com/tui-tools/tui-tailscale) handing over the
ports a self-hosted control plane needs, so the user does not have to retype
what that tool already knew.

Where the rule goes depends on the backend:

| Backend | Group the rule is added to |
|---|---|
| ufw | the rule list (`ufw allow 19443/tcp comment 'headscale control (tailnet)'`) |
| firewalld | the default zone (`--add-port=19443/tcp`); firewalld has no rule comments, so `--comment` is not used and the status line says so |
| 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` |
| iptables | the filter `INPUT` chain of each family, so a port gets one form for `iptables` and one for `ip6tables` |

The value is checked before anything is read: each entry is a single port from
1 to 65535 and `tcp` or `udp`, a port may not be given twice, and the comment is
one line of at most 128 characters. A wrong value exits with the reason instead
of opening a half-filled form. `--comment` without `--open`, and `--open` with
`--check` or `--report`, are refused the same way. `--check` itself is
unchanged.

Every preview is shell-quoted, so a comment with a space or a parenthesis reads
as one argument, and the line pasted into a shell runs the same argv:

```console
$ /usr/bin/sudo -n ufw allow 19443/tcp comment 'headscale control (tailnet)'
```

![A prefilled --open form](docs/screenshots/tui-firewall-open.png)

### `--report`, for bug reports

`--report` prints, in one block, everything a maintainer has to ask for
Expand Down
17 changes: 16 additions & 1 deletion cmd/tui-firewall/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ type app struct {
loadFailed bool
// busy blocks input while a command runs.
busy bool
// loaded reports that a Load has succeeded at least once.
loaded bool

// open is the --open sequence still to show, or nil.
open *openQueue
}

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

// Update is the main event loop.
// Update is the main event loop. After every message it gives the --open
// sequence a chance to show its next form, which only happens once the screen
// is idle again.
func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
model, cmd := a.update(msg)
a.advanceOpen()
return model, cmd
}

// update handles one message.
func (a *app) update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
a.width, a.height = msg.Width, msg.Height
Expand All @@ -244,6 +258,7 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
a.loadFailed = false
a.loaded = true
a.model = msg.model
if a.persistHintPending {
a.persistHintPending = false
Expand Down
4 changes: 2 additions & 2 deletions cmd/tui-firewall/logview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ func TestToggleLogKeyPreviewsReplace(t *testing.T) {
if a.mode != modeConfirm {
t.Fatalf("l should preview a confirm, mode = %v", a.mode)
}
want := `replace rule inet tui input handle 14 iifname "wan0" tcp dport 22 ` +
`log prefix "tui:input drop " counter drop`
want := `replace rule inet tui input handle 14 iifname '"wan0"' tcp dport 22 ` +
`log prefix '"tui:input drop "' counter drop`
if !strings.Contains(a.confirm.Command, want) {
t.Errorf("the preview is not the log toggle:\n%s", a.confirm.Command)
}
Expand Down
14 changes: 12 additions & 2 deletions cmd/tui-firewall/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ type options struct {
themePath string
sudo string
showVersion bool
// open and comment are the --open hand-off: ports to prefill the add
// form with, one form per port, and the comment for those rules.
open openFlag
comment string
// sudoSet records whether -sudo was passed, so `--sudo ""` can disable
// escalation instead of reading as "not given".
sudoSet bool
Expand All @@ -113,6 +117,8 @@ func parseFlags(args []string, out *os.File) (options, error) {
"path to an Omarchy-style colors.toml (overrides the config file)")
fs.StringVar(&opts.sudo, "sudo", "",
"privilege escalation prefix, e.g. \"sudo -n\" or \"\" to disable")
fs.Var(&opts.open, "open", openUsage)
fs.StringVar(&opts.comment, "comment", "", commentUsage)
fs.BoolVar(&opts.showVersion, "version", false, "print the version and exit")
fs.Usage = func() {
_, _ = fmt.Fprintf(out, "tui-firewall — a terminal UI for the system firewall\n\n"+
Expand Down Expand Up @@ -154,6 +160,9 @@ func run(args []string) error {
fmt.Println("tui-firewall", version)
return nil
}
if err := validateOpenOptions(opts); err != nil {
return err
}

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

program := tea.NewProgram(newApp(backend, theme.New(), backendCompat),
tea.WithAltScreen())
a := newApp(backend, theme.New(), backendCompat)
a.queueOpen(opts.open.ports, opts.comment)
program := tea.NewProgram(a, tea.WithAltScreen())
_, err = program.Run()
return err
}
Expand Down
13 changes: 7 additions & 6 deletions cmd/tui-firewall/nftables_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,9 @@ func TestNftablesAddRulePreview(t *testing.T) {
a.form.setFieldForTest("ports", "8443")
send(t, a, "enter")

// The preview is shell-quoted: pasted, it reaches nft as the same words.
want := `nft add rule inet tui input tcp dport 8443 counter accept ` +
`comment "the new service"`
`comment '"the new service"'`
if got := previewOf(t, a); got != want {
t.Errorf("preview =\n %s\nwant\n %s", got, want)
}
Expand Down Expand Up @@ -195,7 +196,7 @@ func TestNftablesPolicyPreview(t *testing.T) {
if !selectOption(t, a, string(firewall.PolicyAllow)) {
t.Fatal("the policy picker should offer allow")
}
want := "nft chain inet tui input { policy accept ; }"
want := "nft chain inet tui input '{' policy accept ';' '}'"
if got := previewOf(t, a); got != want {
t.Errorf("preview = %q, want %q", got, want)
}
Expand All @@ -214,8 +215,8 @@ func TestNftablesActionsMenuBuildsAPortForward(t *testing.T) {
answerStep(t, a, answer)
}

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

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