mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
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:
@@ -113,6 +113,40 @@ ip6 saddr {
|
||||
Set syntax keeps multi-node rules readable and is more efficient than a
|
||||
chain of individual rules.
|
||||
|
||||
## Verify with fipstop
|
||||
|
||||
`fipstop`'s Node tab carries a **Listening on fips0** panel
|
||||
(right-half of the Traffic block) that pairs each local IPv6
|
||||
listener with its current baseline-filter classification. After
|
||||
adding or editing a drop-in and reloading, this is the fastest
|
||||
way to confirm the rule landed correctly without manually
|
||||
parsing `nft list table inet fips`.
|
||||
|
||||
| Panel state | Reading |
|
||||
| ----------- | ------- |
|
||||
| Service row in **default White** with `OPEN` in the State column | The chain has a canonical, unrestricted accept rule for this (proto, port). The service is reachable from any mesh node. |
|
||||
| Service row in **DarkGray** with `filt` | No matching accept rule; the chain falls through to `counter drop`. The service is not reachable from the mesh. |
|
||||
| Service row in **DarkGray** with `filt?` | A rule references the port but uses matchers the panel cannot fully decompose (saddr filter, jump, daddr filter). The intent is operator-defined; inspect with `sudo nft list table inet fips` to see the actual rule. |
|
||||
| **Yellow banner** above the panel: "fips-firewall.service inactive — all listeners exposed" | The `inet fips` table is not loaded. Every listener is mesh-reachable (subject only to whatever ACL you have at the peer layer). |
|
||||
|
||||
A common workflow when extending the baseline is to keep `fipstop`
|
||||
open on the Node tab in one terminal while editing
|
||||
`/etc/fips/fips.d/` in another. After each
|
||||
`sudo systemctl reload-or-restart fips-firewall.service`, the panel
|
||||
re-classifies on the next poll tick and the affected row's State
|
||||
column flips. A row staying `filt` after you expected `OPEN`
|
||||
usually means the drop-in failed to load (syntax error in any file
|
||||
under `/etc/fips/fips.d/` aborts the whole reload) or carries a
|
||||
saddr filter that triggers `filt?` rather than `OPEN`.
|
||||
|
||||
The classifier is conservative: it recognizes only the canonical
|
||||
unrestricted shapes (`tcp dport N accept`, `udp dport N accept`,
|
||||
`dport { ... } accept`, `dport A-B accept`). Source-restricted
|
||||
accepts intentionally render as `filt?` rather than `OPEN` —
|
||||
the panel is a security screen, and any rule that varies by
|
||||
source is an operator decision the panel will not silently bless
|
||||
as fully open.
|
||||
|
||||
## Inspect drops
|
||||
|
||||
The baseline counter increments on every dropped packet. Inspect it:
|
||||
|
||||
@@ -36,7 +36,7 @@ query on its first activation and on every refresh tick while active.
|
||||
|
||||
| Tab | Query | Shows |
|
||||
| --- | ----- | ----- |
|
||||
| **Node** | `show_status` | Identity, version, uptime, peer/link/session counts, sparklines for mesh size, tree depth, peer count, bytes, loss. |
|
||||
| **Node** | `show_status` (+ `show_listening_sockets`) | Identity, version, uptime, peer/link/session counts, sparklines for mesh size, tree depth, peer count, bytes, loss. The Traffic block on this tab is split: TUN counters on the left, the **Listening on fips0** panel on the right (see below). |
|
||||
| **Peers** | `show_peers` (+ `show_links`, `show_transports` cross-refs) | Authenticated peers in a table. Selecting a row and pressing Enter opens a detail view. |
|
||||
| **Transports** | `show_transports` (+ `show_links`, `show_peers` cross-refs) | Tree of transport instances with per-link children when expanded. |
|
||||
| **Sessions** | `show_sessions` | End-to-end FSP sessions. |
|
||||
@@ -52,6 +52,38 @@ Tree → Filters → Performance → Routing → Graphs → Gateway. The Links
|
||||
and Cache tabs are not in the cycle but are fetched as cross-references
|
||||
to populate Peers, Transports, and Routing detail views.
|
||||
|
||||
## Listening on fips0 panel (Node tab)
|
||||
|
||||
The right half of the Node tab's Traffic block lists local IPv6
|
||||
listening sockets reachable from `fips0`, paired with the current
|
||||
`inet fips` baseline filter classification for each (proto, port).
|
||||
The panel exists to remind the operator which local services are
|
||||
exposed to the mesh and which of those are admitted by the
|
||||
default-deny firewall.
|
||||
|
||||
| Column | Meaning |
|
||||
| ------ | ------- |
|
||||
| **Proto** | `tcp` or `udp`. IPv4 listeners are not enumerated; `fips0` is IPv6-only. |
|
||||
| **Port** | Listening port number. |
|
||||
| **Process** | `comm(pid)` resolved by walking `/proc/<pid>/fd/`. A trailing `*` marks wildcard binds (`local_addr == ::`) — the bind is not fips0-specific, so the operator sees that the service is exposed across every interface, not just the mesh. |
|
||||
| **State** | `OPEN` (default White) — the baseline filter has a canonical accept rule for this (proto, port). `filt` (DarkGray) — chain falls through to `counter drop`. `filt?` (DarkGray) — a rule references the port but uses matchers (saddr filter, jump, daddr) the panel cannot fully decompose; operator should `nft list table inet fips` to confirm. |
|
||||
|
||||
When `fips-firewall.service` is **not** active, the `inet fips`
|
||||
table is absent. The panel renders every row in default White and
|
||||
replaces the title with a yellow banner reading
|
||||
"`Listening on fips0 fips-firewall.service inactive — all listeners exposed`".
|
||||
|
||||
The panel is read-only and unselectable. It refreshes on the same
|
||||
poll tick as the rest of the Node tab. Sockets owned by other users
|
||||
that the daemon could not resolve to a PID render as `?` in the
|
||||
Process column; this only happens if the daemon itself is running
|
||||
without root privileges (an unusual dev setup), since walking
|
||||
`/proc/<pid>/fd/` for processes the daemon does not own requires
|
||||
elevated capabilities.
|
||||
|
||||
The panel is Linux-only; on non-Linux daemons the query returns an
|
||||
empty list and the panel hides.
|
||||
|
||||
## Keybindings
|
||||
|
||||
### Global
|
||||
|
||||
@@ -113,6 +113,7 @@ table below lists every command currently registered.
|
||||
| `show_transports` | — | `transports[]` — `transport_id`, `type`, `state`, `mtu`, `name`, `local_addr`, optional `tor_mode`, `onion_address`, `tor_monitoring`, `stats`. |
|
||||
| `show_routing` | — | `coord_cache_entries`, `identity_cache_entries`, `pending_lookups[]`, `pending_tun_destinations`, `pending_tun_packets`, `recent_requests`, `retries[]`, `forwarding`, `discovery`, `error_signals`, `congestion`. |
|
||||
| `show_identity_cache` | — | `entries[]`, `count`, `max_entries`. Each entry: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `last_seen_ms`, `age_ms`. |
|
||||
| `show_listening_sockets` | — | `fips0_addr`, `firewall_active` (bool — `inet fips` table loaded), `sockets[]`. Each entry: `proto` (`tcp` / `udp`), `local_addr` (`::` or the node's fd00::/8 address), `port`, `pid` (nullable), `process` (nullable), `wildcard_bind` (bool — `local_addr == ::`), `filter` (`accept` / `drop` / `unknown` / `no_firewall`). Linux-only; returns an empty `sockets[]` on other platforms. |
|
||||
| `show_stats_list` | — | `metrics[]` (each with `name`, `unit`, `scope`), `fast_ring_seconds`, `slow_ring_minutes`, `peer_retention_seconds`. |
|
||||
| `show_stats_history` | `metric` (req), `peer` (req for per-peer metrics), `window` (`<N>s` / `<N>m` / `<N>h`, default `10m`), `granularity` (`1s` / `1m`, default `1s`) | A single `Series`: `metric`, `unit`, `granularity_seconds`, `values[]`. |
|
||||
| `show_stats_all_history` | `peer` (optional npub), `window`, `granularity` | `granularity_seconds`, `window_seconds`, `peer`, `series[]` (one per metric). |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user