Skip to content

Commit e3ccd27

Browse files
edimarlnxclaude
andauthored
Add --report, and a bug form that asks for it (#10)
Every bug report from a machine nobody here owns starts with the same three questions: which version, against which backend, on what. --report answers all of them in one block, so a reporter pastes rather than remembers. The block comes from the kit's new report package, so the whole family answers in the same shape. What this tool adds is its own half: the backend that was selected, the version the same probe --check uses read off it, and what the detector saw of the backend it did not pick, because "firewalld was chosen" and "ufw is not installed" are different reports. It reads nothing privileged and it runs before the backend is required. A user who cannot escalate, or a machine with no firewall at all, still produces a report — the missing backend is one of its lines. In --demo it says demo on the backend line and names the backend the fake imitates. The block is written to be published: no hostname, no user name, no home path, no address. The smoke test asserts that on a real guest, next to the checks that the report agrees with the backend the machine should be driving. Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 91b4038 commit e3ccd27

8 files changed

Lines changed: 319 additions & 27 deletions

File tree

‎.github/ISSUE_TEMPLATE/bug_report.yml‎

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,79 @@
1+
# Bug report form for tui-firewall.
2+
#
3+
# Rendered from tui-kit/templates/ISSUE_TEMPLATE/bug_report.yml — every tool in
4+
# the family asks the same questions in the same order, so edit it there and
5+
# render again rather than editing this copy.
6+
#
7+
# The first field is the whole point: `tui-firewall --report` prints the tool
8+
# and kit versions, the backend and its version, the distribution, the kernel,
9+
# the terminal and where the binary came from, in one block that is safe to
10+
# publish. Asking for it once, in a fixed shape, is what stops a report from
11+
# turning into three rounds of "which version, on what?".
112
name: Bug report
213
description: Something the tool got wrong on a real machine
314
labels: ["bug"]
415
body:
516
- type: markdown
617
attributes:
718
value: |
8-
Thanks for the report. The most useful bug reports here quote the
9-
output the tool showed and the output of the command it drives.
19+
Thanks for the report. The two things that make one useful here are the
20+
`--report` block below and the exact steps that reproduce it.
21+
- type: textarea
22+
id: report
23+
attributes:
24+
label: Output of `tui-firewall --report`
25+
description: |
26+
Run `tui-firewall --report` and paste the output verbatim, without editing
27+
it. It names the versions, the backend, the distribution, the kernel
28+
and the terminal — and it deliberately carries no hostname, user name,
29+
home path or address, so it is safe to publish as it is.
30+
render: shell
31+
validations:
32+
required: true
1033
- type: textarea
1134
id: what
1235
attributes:
1336
label: What happened
14-
description: What you did, what you expected, what you got instead.
37+
description: What the tool did, in your own words.
1538
validations:
1639
required: true
1740
- type: textarea
18-
id: version
41+
id: steps
1942
attributes:
20-
label: Versions
43+
label: Steps to reproduce
2144
description: |
22-
Output of `tui-firewall --version`, your distribution and release, and the
23-
version of the command the tool drives.
24-
render: shell
45+
The keystrokes or the command line, from starting the tool to the
46+
moment it went wrong. If the state of the machine matters, say what it
47+
was.
48+
placeholder: |
49+
1. Start `tui-firewall`
50+
2. …
51+
3. …
2552
validations:
2653
required: true
54+
- type: input
55+
id: expected
56+
attributes:
57+
label: What you expected
58+
description: "One line: what should have happened instead."
59+
validations:
60+
required: true
61+
- type: checkboxes
62+
id: demo
63+
attributes:
64+
label: Does it reproduce in demo mode?
65+
description: |
66+
`--demo` runs against sample data and never touches the system, so a
67+
bug that shows up there is one anybody can reproduce on any machine.
68+
options:
69+
- label: I tried `tui-firewall --demo`, and it reproduces there too
70+
- label: I tried `tui-firewall --demo`, and it does not reproduce there
2771
- type: textarea
2872
id: output
2973
attributes:
3074
label: Relevant output
3175
description: |
3276
Anything the tool printed, plus the raw output of the underlying
33-
command if a parse looks wrong. Redact addresses you would rather not
34-
publish.
77+
command if a parse looks wrong. Redact anything you would rather not
78+
publish — addresses, host names, rule comments.
3579
render: shell
36-
- type: checkboxes
37-
id: demo
38-
attributes:
39-
label: Does it reproduce in demo mode?
40-
options:
41-
- label: I tried `tui-firewall --demo`

‎README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ tui-firewall # drive the real firewall
208208
tui-firewall --demo # sample ufw data, no privileges needed
209209
tui-firewall --demo=firewalld # sample firewalld data
210210
tui-firewall --check # read the firewall, print JSON, exit
211+
tui-firewall --report # print what a bug report needs, exit
211212
tui-firewall --backend ufw # skip autodetection
212213
tui-firewall --backend firewalld
213214
tui-firewall --theme ~/mytheme/colors.toml
@@ -249,6 +250,42 @@ would run a binary nobody asked this tool to touch.
249250
against real firewalls on Ubuntu, Fedora and Omarchy Server; the assertions live
250251
in [`test/smoke.sh`](test/smoke.sh).
251252

253+
### `--report`, for bug reports
254+
255+
`--report` prints, in one block, everything a maintainer has to ask for
256+
otherwise: the tool and kit versions, the backend and the version probed off
257+
it, what the detector saw of the backend it did not choose, the distribution,
258+
the kernel, the terminal, the theme, the escalation prefix, and whether the
259+
running binary came from a package. It needs no privileges and reads no
260+
firewall, so it works on the machine where the bug is — including one where no
261+
backend can be selected at all, which is itself a thing worth reporting.
262+
263+
```console
264+
$ tui-firewall --report
265+
tui-firewall 0.2.2 (kit v0.2.8)
266+
backend: firewalld 2.3.2
267+
mode: live
268+
distro: fedora 42 (Fedora Linux 42 (Workstation Edition))
269+
kernel: 6.19.14-108.fc42.x86_64
270+
arch: x86_64
271+
locale: en_US.UTF-8
272+
term: xterm-256color
273+
theme: tokyo-night
274+
sudo: sudo -n
275+
root: no
276+
binary: /usr/bin/tui-firewall (packaged)
277+
backends: ufw absent, firewalld active
278+
```
279+
280+
The block is written to be published as it is: it carries no hostname, user
281+
name, home path or address, and no environment variable beyond `LANG`,
282+
`LC_ALL`, `TERM` and `TERM_PROGRAM`. A binary living under your home directory
283+
is reported as being there without naming the path. `--report` works with
284+
`--demo` too, where it says so on the `mode` line.
285+
286+
The bug form asks for this block first — see
287+
[`.github/ISSUE_TEMPLATE/bug_report.yml`](.github/ISSUE_TEMPLATE/bug_report.yml).
288+
252289
`tui-firewall` needs root to change anything, and to read anything on ufw. When
253290
it is not running as root it uses `sudo -n`, which never prompts: run `sudo -v`
254291
in another terminal first, or start `tui-firewall` with sudo. If neither

‎cmd/tui-firewall/main.go‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ func (d *demoFlag) IsBoolFlag() bool { return true }
7979
type options struct {
8080
demo demoFlag
8181
check bool
82+
report bool
8283
backend string
8384
themePath string
8485
sudo string
@@ -99,6 +100,7 @@ func parseFlags(args []string, out *os.File) (options, error) {
99100
fs.BoolVar(&opts.check, "check", false,
100101
"read the firewall and print the parsed model as JSON, then exit "+
101102
"(no UI, no changes); exit 1 if the backend cannot be read")
103+
fs.BoolVar(&opts.report, "report", false, reportUsage)
102104
fs.StringVar(&opts.backend, "backend", "",
103105
"firewall backend: auto, ufw or firewalld (overrides the config file)")
104106
fs.StringVar(&opts.themePath, "theme", "",
@@ -156,6 +158,25 @@ func run(args []string) error {
156158
return err
157159
}
158160

161+
// The configured theme is handed to the kit through the same variable the
162+
// user could set by hand, so precedence stays in one place. It is set
163+
// before the backend is built so --report can name the theme the UI would
164+
// have used even on a machine where no backend can be.
165+
if path := cfg.Theme(); path != "" {
166+
if err := os.Setenv("TUI_THEME", path); err != nil {
167+
return err
168+
}
169+
}
170+
171+
// --report is the non-interactive path that must work everywhere. It
172+
// reads nothing privileged and it survives a machine with no firewall at
173+
// all, because "there is no backend here" is one of the things a bug
174+
// report has to be able to say. So it comes before the backend is
175+
// required.
176+
if opts.report {
177+
return runReport(cfg, opts, os.Stdout)
178+
}
179+
159180
backend, err := pickBackend(cfg, opts)
160181
if err != nil {
161182
return err
@@ -174,14 +195,6 @@ func run(args []string) error {
174195
os.Stdout)
175196
}
176197

177-
// The configured theme is handed to the kit through the same variable the
178-
// user could set by hand, so precedence stays in one place.
179-
if path := cfg.Theme(); path != "" {
180-
if err := os.Setenv("TUI_THEME", path); err != nil {
181-
return err
182-
}
183-
}
184-
185198
program := tea.NewProgram(newApp(backend, theme.New(), backendCompat),
186199
tea.WithAltScreen())
187200
_, err = program.Run()

‎cmd/tui-firewall/report.go‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"strings"
8+
9+
"github.com/tui-tools/tui-firewall/internal/backends"
10+
"github.com/tui-tools/tui-kit/config"
11+
"github.com/tui-tools/tui-kit/report"
12+
"github.com/tui-tools/tui-kit/theme"
13+
)
14+
15+
// runReport prints the block a bug report needs and exits. Everything generic
16+
// — the kit version, the distribution, the kernel, the terminal, where the
17+
// binary came from — is collected by the kit, so the whole family answers
18+
// --report in the same shape. What this function adds is the part only
19+
// tui-firewall knows: which backend was selected, what the compat probe read
20+
// off it, and what the detector saw of the backend it did not pick.
21+
//
22+
// It never reads the firewall. --check is the flag that does that, and it needs
23+
// privileges; a report has to work for a user who cannot get them, because the
24+
// missing privilege may be the bug. For the same reason a machine with no
25+
// firewall at all still gets a report, with the selection error as one of its
26+
// lines: "there is nothing here to drive" is a bug report, not a refusal.
27+
func runReport(cfg config.Config, opts options, out io.Writer) error {
28+
palette, _ := theme.ResolvePalette()
29+
30+
var backendName, selectError string
31+
if backend, err := pickBackend(cfg, opts); err != nil {
32+
selectError = err.Error()
33+
} else {
34+
backendName = backend.Name()
35+
}
36+
37+
// The same probe --check and the header use. There is one version probe in
38+
// this tool and this is it.
39+
backendCompat := probeCompat(context.Background(), backendName, opts.demo.on)
40+
41+
info := report.Info{
42+
Tool: toolName,
43+
Version: version,
44+
Backend: backendName,
45+
BackendVersion: backendCompat.Version,
46+
BackendDetail: backendCompat.Detail,
47+
Demo: opts.demo.on,
48+
Sudo: cfg.String(config.KeySudo, ""),
49+
Theme: palette.Name,
50+
}
51+
if opts.demo.on {
52+
// The fake imitates one of the real backends, and which one decides
53+
// which command builders and which parser the session exercised. It is
54+
// taken from the flag rather than from the fake's own name, because a
55+
// fake is free to call itself "demo".
56+
info.Backend = "demo"
57+
info.Extra = append(info.Extra, report.Field{
58+
Key: "demo backend", Value: opts.demo.backend,
59+
})
60+
}
61+
info.Extra = append(info.Extra, report.Field{
62+
Key: "backends", Value: describeBackends(backends.Inspect(backendName)),
63+
})
64+
if selectError != "" {
65+
info.Extra = append(info.Extra, report.Field{
66+
Key: "backend error", Value: selectError,
67+
})
68+
}
69+
70+
_, err := io.WriteString(out, report.Render(info))
71+
return err
72+
}
73+
74+
// describeBackends renders the detector's view of every backend the tool knows
75+
// as one line. A report that says only "firewalld" leaves the reader guessing
76+
// whether ufw was absent or merely stopped, and that difference is most of the
77+
// backend selection bugs.
78+
func describeBackends(states []backends.State) string {
79+
parts := make([]string, 0, len(states))
80+
for _, s := range states {
81+
switch {
82+
case !s.Installed:
83+
parts = append(parts, s.Name+" absent")
84+
case s.Active:
85+
parts = append(parts, s.Name+" active")
86+
default:
87+
parts = append(parts, s.Name+" installed")
88+
}
89+
}
90+
if len(parts) == 0 {
91+
return "none"
92+
}
93+
return strings.Join(parts, ", ")
94+
}
95+
96+
// reportUsage is the flag's one-line help, kept here next to what it prints.
97+
var reportUsage = fmt.Sprintf(
98+
"print the versions and machine facts a bug report needs, then exit "+
99+
"(no UI, no privileges, nothing about you: paste it into a %s issue)",
100+
toolName)

‎cmd/tui-firewall/report_test.go‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package main
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/tui-tools/tui-firewall/internal/backends"
8+
)
9+
10+
// TestRunReportDemo checks the half of the block this tool owns. The kit's own
11+
// tests cover the machine facts and the scrubbing; what has to be right here is
12+
// that --demo says demo, that the imitated backend is the one the flag asked
13+
// for rather than whatever the fake calls itself, and that no firewall was
14+
// read to produce any of it.
15+
func TestRunReportDemo(t *testing.T) {
16+
var out strings.Builder
17+
opts := options{demo: demoFlag{on: true, backend: backends.BackendFirewalld},
18+
report: true}
19+
if err := runReport(baseConfig(), opts, &out); err != nil {
20+
t.Fatalf("runReport: %v", err)
21+
}
22+
23+
got := out.String()
24+
for _, want := range []string{
25+
"backend: demo\n",
26+
"mode: demo (sample data, the system was not read)\n",
27+
"demo backend: firewalld\n",
28+
} {
29+
if !strings.Contains(got, want) {
30+
t.Errorf("report is missing %q:\n%s", want, got)
31+
}
32+
}
33+
if !strings.HasPrefix(got, toolName+" ") {
34+
t.Errorf("report should start with the tool name:\n%s", got)
35+
}
36+
}
37+
38+
// TestDescribeBackends renders the detector's verdict for every backend, which
39+
// is what tells "firewalld was chosen" from "ufw is not here".
40+
func TestDescribeBackends(t *testing.T) {
41+
tests := []struct {
42+
name string
43+
states []backends.State
44+
want string
45+
}{
46+
{
47+
name: "one active, one absent",
48+
states: []backends.State{
49+
{Name: "ufw", Installed: false},
50+
{Name: "firewalld", Installed: true, Active: true, Selected: true},
51+
},
52+
want: "ufw absent, firewalld active",
53+
},
54+
{
55+
name: "installed but stopped is not the same as absent",
56+
states: []backends.State{
57+
{Name: "ufw", Installed: true},
58+
{Name: "firewalld", Installed: true, Active: true},
59+
},
60+
want: "ufw installed, firewalld active",
61+
},
62+
{
63+
name: "a machine with no firewall at all",
64+
states: nil,
65+
want: "none",
66+
},
67+
}
68+
69+
for _, tc := range tests {
70+
t.Run(tc.name, func(t *testing.T) {
71+
if got := describeBackends(tc.states); got != tc.want {
72+
t.Errorf("describeBackends = %q, want %q", got, tc.want)
73+
}
74+
})
75+
}
76+
}

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
github.com/charmbracelet/bubbles v1.0.0
77
github.com/charmbracelet/bubbletea v1.3.10
88
github.com/charmbracelet/lipgloss v1.1.0
9-
github.com/tui-tools/tui-kit v0.2.8
9+
github.com/tui-tools/tui-kit v0.2.9-0.20260830192312-beb5e3579095
1010
)
1111

1212
require (

0 commit comments

Comments
 (0)