Skip to content

Commit 34abbdb

Browse files
committed
Show which ufw version is being driven
ufw is not one program: 0.35 has no rule comments, anything before 0.36 lists rules without the app profile column, and until now the tool behaved as if every machine ran the version the author had. The manifest now declares the ufw backend — the version command, the 0.36 minimum, the features and the caveats per range — and the binary embeds its own tool.json and probes it once at startup. The header carries the result: `ufw 0.36.2` on a version the lab has run against, `(untested)` in warning colour on one it has not, `(below minimum 0.36)` in the error colour on one older than we claim to support. The tool still runs in every case; a probe that could stop it starting would be worse than no probe. The add form now drops the comment field on ufw older than 0.35 by asking caps.Has("rule-comments") rather than by comparing versions in the UI. --check reports the probe, which is where the smoke test reads it back from before appending a line to compat/results.jsonl: the two lines committed here are from a real lab run on Ubuntu 24.04 and Omarchy 4.0.1, both ufw 0.36.2, and they are what `tested` in the manifest is generated from. Fedora's firewalld has nothing to probe and records nothing, which is the honest result for a backend that is still a stub. CI gained the exec boundary check tui-kit now ships, so the promise that only internal/ufw starts a process is asserted rather than remembered.
1 parent 2fa4c9a commit 34abbdb

16 files changed

Lines changed: 420 additions & 32 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,26 @@ jobs:
3737
- name: go vet
3838
run: go vet ./...
3939

40+
# The family's central promise — the command line you were shown is the
41+
# one that ran — only holds while a single package can start a process.
42+
# tui-kit ships the check, so every tool asserts the same boundary.
43+
- name: exec boundary
44+
run: |
45+
go mod download github.com/tui-tools/tui-kit
46+
kit=$(go list -m -f '{{.Dir}}' github.com/tui-tools/tui-kit)
47+
bash "$kit/tools/check-exec.sh" .
48+
49+
# `tested` in the manifest is generated from compat/results.jsonl, and
50+
# the README's Compatibility section is generated from the manifest. A
51+
# claim that outran its evidence fails here.
52+
- name: compatibility claims match the evidence
53+
run: |
54+
kit=$(go list -m -f '{{.Dir}}' github.com/tui-tools/tui-kit)
55+
python3 "$kit/tools/compat-sync.py" --manifest tool.json \
56+
--results compat/results.jsonl --check
57+
python3 "$kit/tools/render-compat.py" --manifest tool.json \
58+
--readme README.md --check
59+
4060
- name: go test
4161
run: go test -race ./...
4262

‎Makefile‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ LDFLAGS := -s -w -X main.version=$(VERSION)
99
# kit, which is already a dependency: ask the module cache where it landed.
1010
KIT = $(shell $(GO) list -m -f '{{.Dir}}' github.com/tui-tools/tui-kit)
1111

12-
.PHONY: manifest readme all build test vet fmt fmt-check lint check demo clean tidy install screenshots
12+
.PHONY: manifest readme compat check-exec all build test vet fmt fmt-check lint check demo clean tidy install screenshots
1313

1414
all: check build
1515

@@ -38,8 +38,8 @@ fmt-check:
3838
echo "these files need gofmt:"; echo "$$out"; exit 1; \
3939
fi
4040

41-
## lint: fmt-check plus vet. golangci-lint is used when installed.
42-
lint: fmt-check vet
41+
## lint: fmt-check, vet and the exec boundary. golangci-lint when installed.
42+
lint: fmt-check vet check-exec
4343
@if command -v golangci-lint >/dev/null 2>&1; then \
4444
golangci-lint run ./...; \
4545
else \
@@ -68,9 +68,22 @@ screenshots: build
6868
--screen main= --screen add=a --screen delete=d \
6969
--screen policies=p --screen help=?
7070

71-
## readme: regenerate the Install section of the README from tool.json.
71+
## readme: regenerate the generated README sections from tool.json.
7272
readme:
7373
python3 $(KIT)/tools/render-install.py --manifest tool.json --readme README.md
74+
python3 $(KIT)/tools/render-compat.py --manifest tool.json --readme README.md
75+
76+
## compat: harvest the lab logs into compat/results.jsonl and regenerate the
77+
## tested versions in tool.json. Run it after `lab.sh test $(TOOL)`.
78+
compat:
79+
python3 $(KIT)/tools/compat-sync.py --manifest tool.json \
80+
--results compat/results.jsonl \
81+
--from-log $(wildcard ../tui-lab/out/results/*-$(TOOL)/*.log)
82+
83+
## check-exec: assert that only a backend package starts a process.
84+
check-exec:
85+
bash $(KIT)/tools/check-exec.sh .
86+
7487

7588
## manifest: validate tool.json against the family schema in tui-kit.
7689
manifest:

‎README.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,36 @@ text field, `esc` cancels.
223223
- **No live log tail**, no packet counters, no nft view.
224224
- Rules are ordered by ufw's own numbering; there is no reordering key.
225225

226+
## Compatibility
227+
228+
<!-- compat:start -->
229+
<!-- Generated by tui-kit/tools/render-compat.py from tool.json. -->
230+
<!-- Edit the manifest, then run `make readme`. -->
231+
232+
`tui-firewall` probes its backend once at startup and shows the version in the
233+
header. A version nobody has tested is marked `(untested)` there rather than
234+
hidden; one below the minimum is marked as such and the tool still runs.
235+
236+
### ufw
237+
238+
| | |
239+
| --- | --- |
240+
| Binary | `ufw` |
241+
| Version read with | `ufw --version` |
242+
| Minimum | 0.36 |
243+
| Tested | `0.36.2` |
244+
| Version-gated features | `rule-comments` (since 0.35) |
245+
246+
| Versions | What changes |
247+
| --- | --- |
248+
| `<0.36` | `ufw status numbered` has no app profile column, so a rule added from a profile is shown by its ports and cannot be edited as a profile |
249+
| `0.36.x` | `status numbered` indexes IPv4 and IPv6 halves of one rule separately, so deleting by number renumbers the rest and the list is re-read after every delete |
250+
251+
The tested versions are generated from `compat/results.jsonl`, which the tool's
252+
own smoke test appends to when it runs against a real machine in
253+
[tui-lab](https://github.com/tui-tools/tui-lab).
254+
<!-- compat:end -->
255+
226256
## Configuration
227257

228258
`/etc/tui-firewall/config.toml`, then `~/.config/tui-firewall/config.toml` (the

‎cmd/tui-firewall/app.go‎

Lines changed: 19 additions & 7 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-kit/compat"
1112
"github.com/tui-tools/tui-kit/theme"
1213
"github.com/tui-tools/tui-kit/ui"
1314
)
@@ -42,6 +43,8 @@ type app struct {
4243
backend firewall.Backend
4344
theme theme.Theme
4445
caps firewall.Capabilities
46+
// backendCompat is what the version probe found, rendered in the header.
47+
backendCompat compat.Result
4548

4649
model firewall.Model
4750
// group is the name of the group currently shown.
@@ -89,14 +92,23 @@ type ranMsg struct {
8992
}
9093

9194
// newApp builds the model around a backend.
92-
func newApp(backend firewall.Backend, th theme.Theme) *app {
95+
func newApp(backend firewall.Backend, th theme.Theme,
96+
backendCompat compat.Result) *app {
9397
a := &app{
94-
backend: backend,
95-
theme: th,
96-
caps: backend.Capabilities(),
97-
width: 80,
98-
height: 24,
99-
loading: true,
98+
backend: backend,
99+
theme: th,
100+
caps: backend.Capabilities(),
101+
backendCompat: backendCompat,
102+
width: 80,
103+
height: 24,
104+
loading: true,
105+
}
106+
// ufw grew rule comments in 0.35. On an older one the backend would build
107+
// a command the firewall refuses, so the field is dropped from the add
108+
// form instead — and which version that is stays in the manifest, not in
109+
// a comparison written here.
110+
if !backendCompat.Caps().Has("rule-comments") {
111+
a.caps.SupportsComments = false
100112
}
101113
if th.Warning != "" {
102114
a.setStatus(ui.StatusWarn, th.Warning)

‎cmd/tui-firewall/app_test.go‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,44 @@
11
package main
22

33
import (
4+
"context"
45
"strings"
56
"testing"
67
"time"
78

89
tea "github.com/charmbracelet/bubbletea"
10+
tuifirewall "github.com/tui-tools/tui-firewall"
911
"github.com/tui-tools/tui-firewall/internal/firewall"
1012
"github.com/tui-tools/tui-firewall/internal/ufw"
13+
"github.com/tui-tools/tui-kit/compat"
14+
"github.com/tui-tools/tui-kit/manifest"
1115
"github.com/tui-tools/tui-kit/theme"
1216
)
1317

18+
// testCompat probes the manifest's real ufw backend against a canned
19+
// `ufw --version` output, so the header is exercised with the same block the
20+
// binary ships rather than with a hand-written fixture.
21+
func testCompat(t *testing.T, versionOutput string) compat.Result {
22+
t.Helper()
23+
m, err := manifest.Load(tuifirewall.ManifestJSON)
24+
if err != nil {
25+
t.Fatalf("manifest: %v", err)
26+
}
27+
backend, ok := m.Backend("ufw")
28+
if !ok {
29+
t.Fatal("the manifest declares no ufw backend")
30+
}
31+
return compat.ProbeWith(context.Background(), backend,
32+
func(context.Context, []string) (string, error) { return versionOutput, nil })
33+
}
34+
1435
// newTestApp builds the demo app at a fixed size, with colors disabled so
1536
// assertions can look for plain text.
1637
func newTestApp(t *testing.T, width, height int) (*app, *ufw.Fake) {
1738
t.Helper()
1839
t.Setenv("NO_COLOR", "1")
1940
fake := ufw.NewFake()
20-
a := newApp(fake, theme.FromPalette(theme.TokyoNight()))
41+
a := newApp(fake, theme.FromPalette(theme.TokyoNight()), testCompat(t, "ufw 0.36.2"))
2142
a.width, a.height = width, height
2243
// Drain Init's load command synchronously: the fake never blocks.
2344
msg := a.Init()()

‎cmd/tui-firewall/check.go‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"time"
99

1010
"github.com/tui-tools/tui-firewall/internal/firewall"
11+
"github.com/tui-tools/tui-kit/compat"
1112
)
1213

1314
// checkTimeout bounds the read. Loading the rule set shells out to the
@@ -33,6 +34,10 @@ type checkReport struct {
3334
// Groups and Rules are the totals across the model.
3435
Groups int `json:"groups"`
3536
Rules int `json:"rules"`
37+
// Compat is what the backend version probe found. It is reported rather
38+
// than asserted: an untested version is a fact about the machine, not a
39+
// failure of the read path.
40+
Compat compat.Result `json:"compat"`
3641
// Model is the parsed state in full.
3742
Model firewall.Model `json:"model"`
3843
}
@@ -46,7 +51,8 @@ type checkReport struct {
4651
// the correct result: the tool cannot read that machine's firewall, and a
4752
// smoke test asserting "firewalld is still a stub" asserts exactly this
4853
// failure.
49-
func runCheck(backend firewall.Backend, out io.Writer) error {
54+
func runCheck(backend firewall.Backend, backendCompat compat.Result,
55+
out io.Writer) error {
5056
ctx, cancel := context.WithTimeout(context.Background(), checkTimeout)
5157
defer cancel()
5258

@@ -63,6 +69,7 @@ func runCheck(backend firewall.Backend, out io.Writer) error {
6369
Enabled: model.Enabled,
6470
Logging: model.Logging,
6571
Groups: len(model.Groups),
72+
Compat: backendCompat,
6673
Model: model,
6774
}
6875
for _, group := range model.Groups {

‎cmd/tui-firewall/compat.go‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package main
2+
3+
import (
4+
"context"
5+
6+
tuifirewall "github.com/tui-tools/tui-firewall"
7+
"github.com/tui-tools/tui-kit/compat"
8+
"github.com/tui-tools/tui-kit/manifest"
9+
)
10+
11+
// probeCompat reads the version of the backend the tool is about to drive.
12+
//
13+
// The facts it is judged against — the minimum version, the versions the lab
14+
// has actually run against, the caveats that apply to a range — come from the
15+
// repository's own tool.json, embedded in the binary, so there is no second
16+
// copy of them in the code.
17+
//
18+
// It never fails: a backend the manifest does not describe, a manifest that
19+
// cannot be parsed and a missing binary all produce the zero Result, which the
20+
// header renders as nothing at all.
21+
func probeCompat(ctx context.Context, backendName string, demo bool) compat.Result {
22+
// --demo drives an in-memory firewall; probing the real ufw on the host
23+
// would report a version that has nothing to do with what is on screen.
24+
if demo {
25+
return compat.Result{}
26+
}
27+
m, err := manifest.Load(tuifirewall.ManifestJSON)
28+
if err != nil {
29+
return compat.Result{}
30+
}
31+
backend, ok := m.Backend(backendName)
32+
if !ok {
33+
return compat.Result{}
34+
}
35+
return compat.Probe(ctx, backend)
36+
}

‎cmd/tui-firewall/compat_test.go‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
tuifirewall "github.com/tui-tools/tui-firewall"
9+
"github.com/tui-tools/tui-firewall/internal/ufw"
10+
"github.com/tui-tools/tui-kit/compat"
11+
"github.com/tui-tools/tui-kit/manifest"
12+
"github.com/tui-tools/tui-kit/theme"
13+
)
14+
15+
// The embedded manifest is the source the header reads, so it has to parse and
16+
// describe the backend the tool actually drives.
17+
func TestEmbeddedManifestDeclaresUfw(t *testing.T) {
18+
m, err := manifest.Load(tuifirewall.ManifestJSON)
19+
if err != nil {
20+
t.Fatalf("the embedded tool.json does not parse: %v", err)
21+
}
22+
if m.Name != toolName {
23+
t.Errorf("manifest name = %q, want %q", m.Name, toolName)
24+
}
25+
backend, ok := m.Backend("ufw")
26+
if !ok {
27+
t.Fatal("no ufw backend in the manifest")
28+
}
29+
if len(backend.VersionCommand) == 0 || backend.Minimum == "" {
30+
t.Errorf("ufw backend is incomplete: %+v", backend)
31+
}
32+
}
33+
34+
// --demo must never probe the host: the version on screen would describe a
35+
// machine the demo is not driving.
36+
func TestProbeCompatSkipsDemo(t *testing.T) {
37+
got := probeCompat(context.Background(), "ufw", true)
38+
if got.Backend != "" || got.Version != "" {
39+
t.Errorf("demo probe = %+v, want the zero result", got)
40+
}
41+
}
42+
43+
// A backend the manifest does not describe (firewalld today) is not an error,
44+
// it is simply nothing to show.
45+
func TestProbeCompatUnknownBackend(t *testing.T) {
46+
got := probeCompat(context.Background(), "nftables", false)
47+
if got.Backend != "" {
48+
t.Errorf("unknown backend = %+v, want the zero result", got)
49+
}
50+
}
51+
52+
// The whole point of the feature: the version reaches the header.
53+
func TestHeaderShowsTheBackendVersion(t *testing.T) {
54+
a, _ := newTestApp(t, 100, 30)
55+
if got := a.View(); !strings.Contains(got, "ufw 0.36.2") {
56+
t.Errorf("the header should carry the probed version, got:\n%s", got)
57+
}
58+
59+
a.backendCompat = testCompat(t, "ufw 0.37.0")
60+
if got := a.View(); !strings.Contains(got, "ufw 0.37.0 (untested)") {
61+
t.Errorf("an untested version should say so, got:\n%s", got)
62+
}
63+
64+
a.backendCompat = testCompat(t, "ufw 0.35")
65+
if got := a.View(); !strings.Contains(got, "below minimum 0.36") {
66+
t.Errorf("a version below the minimum should say so, got:\n%s", got)
67+
}
68+
}
69+
70+
// A tool that could not read the version still renders a header.
71+
func TestHeaderWithoutAProbe(t *testing.T) {
72+
a, _ := newTestApp(t, 100, 30)
73+
a.backendCompat = compat.Result{}
74+
_ = theme.TokyoNight()
75+
if got := a.View(); strings.Contains(got, "backend:") {
76+
t.Errorf("no probe means no backend fact, got:\n%s", got)
77+
}
78+
}
79+
80+
// Rule comments are a ufw 0.35 feature. The form must drop the field on an
81+
// older machine rather than build a command ufw refuses.
82+
func TestRuleCommentsAreGatedOnTheUfwVersion(t *testing.T) {
83+
a, _ := newTestApp(t, 100, 30)
84+
if !a.caps.SupportsComments {
85+
t.Error("0.36.2 supports rule comments")
86+
}
87+
88+
old := newApp(ufw.NewFake(), theme.FromPalette(theme.TokyoNight()),
89+
testCompat(t, "ufw 0.34"))
90+
if old.caps.SupportsComments {
91+
t.Error("0.34 has no rule comments, so the form must not offer them")
92+
}
93+
}

0 commit comments

Comments
 (0)