fipstop: add "Listening on fips0" panel to Node tab

Surfaces local services reachable from the mesh, paired with their
current `inet fips` baseline filter classification. Lands to the
right of the existing TUN section in the Traffic block.

A new daemon control query `show_listening_sockets` returns IPv6
listeners bound to either `::` (wildcard) or the node's fd00::/8
address, each classified as Accept / Drop / Unknown / NoFirewall
against the running inbound chain. fipstop renders the result as a
table beside the Traffic counters: Accept rows in default White,
Drop / Unknown in DarkGray, a yellow banner above the table when
`fips-firewall.service` is inactive, and a trailing `*` on
wildcard binds to remind the operator the bind is not
fips0-specific.

Daemon side:

- `src/control/listening.rs` walks `/proc/net/tcp6` and
  `/proc/net/udp6` via the procfs crate (LISTEN state for TCP,
  wildcard remote for UDP), filters to fips0-reachable binds, and
  resolves inodes to PID / comm via `/proc/<pid>/fd`.

- `src/control/firewall_state.rs` shells out to
  `nft -j list table inet fips` and walks the inbound chain.
  Recognises canonical accepts (`tcp/udp dport N accept`,
  `dport { ... } accept`, `dport A-B accept`), the iifname-scoping
  line, conntrack and icmpv6 lines (skipped). Any rule with
  unrecognised matchers (saddr filters, jumps, daddr filters) or
  non-terminal verdicts forces Unknown classification for the
  ports it references. Eleven unit tests cover the classification
  logic; the listening enumerator carries a /proc-parsing test of
  its own.

- `show_listening_sockets` emits
  `{fips0_addr, firewall_active, sockets[]}` with per-row
  `{proto, local_addr, port, pid, process, filter, wildcard_bind}`.

fipstop side:

- `src/bin/fipstop/ui/dashboard.rs` splits the Traffic block into
  a 50/50 horizontal layout; the existing TUN + Forwarded panel
  occupies the left half.

- `src/bin/fipstop/ui/listening.rs` renders the right half.

- `main.rs` fetches the new query each tick when the Node tab is
  active. Errors are non-fatal: an old daemon without the query
  leaves the payload at None and the panel renders "loading...".

`Cargo.toml` gains `procfs = "0.18"` on the Linux target. IPv4
listeners are not enumerated — fips0 is IPv6-only.

Folded in: revert the default-socket lookup from writability-probe
back to existence-based selection. The previous tempfile-probe on
`/run/fips` silently steered fipstop / fipsctl onto an XDG path
the daemon never bound for any user in the `fips` group whose
shell session had not yet picked up the supplementary group (no
re-login after `usermod -aG`). `XDG_RUNTIME_DIR` is set on every
modern systemd-managed user session, so this hit the common case.
The kernel checks actual group membership at `connect(2)`, so a
user who genuinely cannot connect now gets a clear `EACCES`
rather than a silent path mismatch. Drops the now-unused
`is_writable_dir` helper. `XDG_RUNTIME_DIR` existence validation
is preserved.

Documentation:

- `docs/reference/cli-fipstop.md` — Node-tab row updated, new
  "Listening on fips0 panel" section.
- `docs/reference/control-socket.md` — `show_listening_sockets`
  added to the read-only queries table.
- `docs/how-to/enable-mesh-firewall.md` — new "Verify with
  fipstop" section.
- `docs/tutorials/host-a-service.md` — fipstop callouts at
  Steps 3, 5, 6 + Troubleshooting bullet + wildcard-bind reminder
  under "What you've learned".
- `CHANGELOG.md` — new bullet under `Added / Operator Tooling`,
  resolver `Fixed` entry rewritten to describe the
  existence-based final shape.
This commit is contained in:
Johnathan Corgan
2026-05-08 21:36:42 +00:00
parent b3a1fb464f
commit 53ad528f7d
17 changed files with 1342 additions and 52 deletions

View File

@@ -189,6 +189,16 @@ server.
> verified is reachability *from another mesh node*. That is
> the next concern.
If you have `fipstop` available, open it now in another terminal
and switch to the **Node** tab. The right-half of the Traffic
block — the **Listening on fips0** panel — should list a `tcp`
row at port 8080 with a `python(<pid>)` Process column. The State
column reads `OPEN` because the firewall has not been turned on
yet; everything bound to `fips0` is currently mesh-reachable. The
yellow banner above the panel says
"fips-firewall.service inactive — all listeners exposed". Both
signals will flip in the next two steps.
## Step 4: Reachability from a mesh node
Any mesh node — a direct peer, or a node several hops away —
@@ -277,6 +287,13 @@ inbound on `fips0` hits the final `counter ... drop`.
> TCP SYN dropped before it can reach your server. From the
> remote end the connection times out.
The fipstop panel reflects the change immediately: the yellow
"firewall inactive" banner disappears, the panel title becomes a
plain "Listening on fips0", and your `tcp 8080 python(<pid>)` row
flips to **DarkGray** with `filt` in the State column. Every
other row also goes DarkGray — none of them have an explicit
accept rule yet, and the chain falls through to `counter drop`.
So the firewall is in the right shape but in the wrong state
for our purpose: we *want* mesh nodes to reach port 8080. The
next step opens that one port.
@@ -317,6 +334,18 @@ mesh → your direct peer's link to you → `fips0` ingress →
`inbound` chain → matches `tcp dport 8080 accept` → delivered
to the HTTP server.
In the fipstop panel, your `tcp 8080 python(<pid>)` row flips
back to **default White** with `OPEN` in the State column on the
next poll tick. No other row changes — they remain DarkGray
`filt` because you have only opened this one port. The panel
doubles as a security screen for the rest of the tutorial: any
service whose row reads `OPEN` is mesh-reachable, anything
DarkGray is filtered. If you later add a saddr-restricted
drop-in (covered just below), the row will land at `filt?`
rather than `OPEN`, signalling that the rule exists but is
source-scoped — the panel deliberately does not classify
restricted accepts as fully open.
If you only want to expose the service to a *specific* node
or set of nodes, source-filter the rule. The address filter
applies to the mesh-source address as it arrives on `fips0`,
@@ -386,7 +415,10 @@ sudo systemctl disable --now fips-firewall.service
opts in to one audience; binding to wildcard
(`0.0.0.0` / `[::]`) opts in to *all* of them, including
ones you forgot you had. For mesh-only exposure, bind to
your `fd97:...` address.
your `fd97:...` address. The fipstop **Listening on fips0**
panel marks wildcard binds with a trailing `*` after the
process name as a reminder that the bind is not
fips0-specific.
- **Same-host loopback is misleading.** A local curl to your
own `fd97:...` address goes via the loopback path, not
through `fips0` ingress. To actually verify mesh-side
@@ -442,6 +474,14 @@ its own port — same `--bind` rule, same drop-in shape.
mesh node attempts to reach a port you have not opened,
`sudo nft list table inet fips` will show the counter
packet count rising.
- **Use fipstop to spot-check listener and filter state.** The
Listening on fips0 panel on the Node tab shows every
fips0-reachable listener and its current filter state. A row
staying `filt` after you expected `OPEN` usually means the
drop-in failed to load (a syntax error in any file under
`/etc/fips/fips.d/` aborts the whole reload, leaving the
previous ruleset in place) or the drop-in carries a source
filter and now reads `filt?` rather than `OPEN`.
## What's next