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
+23 -5
View File
@@ -187,6 +187,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`fipsctl stats` subcommands, and a `fipstop` Graphs tab with `fipsctl stats` subcommands, and a `fipstop` Graphs tab with
btop-style sparklines btop-style sparklines
([#64](https://github.com/jmcorgan/fips/pull/64)). ([#64](https://github.com/jmcorgan/fips/pull/64)).
- `fipstop` Node tab now carries a "Listening on fips0" panel
(right-half of the Traffic block) that lists local IPv6 listening
sockets reachable from the mesh interface, paired with the
`inet fips` baseline filter classification for each (proto, port).
Rows render in default White (`OPEN` — the chain has a canonical
unrestricted accept rule), DarkGray (`filt` — chain falls through
to `counter drop`), or DarkGray with a `?` State suffix (`filt?`
the chain references the port but with matchers the panel cannot
fully decompose, e.g. saddr filters or jumps). When the
`fips-firewall.service` is not active, the panel renders a yellow
banner reminding the operator that all listeners are
mesh-exposed. Wildcard binds (`local_addr == ::`) carry a `*`
suffix in the Process column. Powered by a new
`show_listening_sockets` control query (Linux-only).
#### Packaging and Deployment #### Packaging and Deployment
@@ -321,11 +335,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`fipstop` could connect to a socket the daemon never bound (notably `fipstop` could connect to a socket the daemon never bound (notably
on dev runs with `XDG_RUNTIME_DIR` set, or after a prior packaged on dev runs with `XDG_RUNTIME_DIR` set, or after a prior packaged
install left a root-owned `/run/fips` behind). Canonical order is install left a root-owned `/run/fips` behind). Canonical order is
`/run/fips``$XDG_RUNTIME_DIR/fips/``/tmp/fips-<name>`, with `/run/fips``$XDG_RUNTIME_DIR/fips/``/tmp/fips-<name>`. The
writability of `/run/fips` probed via tempfile create (ACL- and `/run/fips` arm is selected by directory existence; the kernel
group-aware) and `XDG_RUNTIME_DIR` validated as an existing enforces actual access at `connect(2)` time, surfacing a clear
directory before being used. The deployed fleet is unaffected: `EACCES` for users not yet in the `fips` group rather than silently
packaged configs set `node.control.socket_path` explicitly. steering them to a path the daemon never bound. `XDG_RUNTIME_DIR` is
validated as an existing directory before being used so stale
post-logout values are treated as missing. The deployed fleet is
unaffected: packaged configs set `node.control.socket_path`
explicitly.
- UDP transport with `advertise_on_nostr: true` + `public: true` + - UDP transport with `advertise_on_nostr: true` + `public: true` +
a wildcard `bind_addr` (e.g. `0.0.0.0:2121`) is now advertised a wildcard `bind_addr` (e.g. `0.0.0.0:2121`) is now advertised
with its STUN-discovered public IPv4 instead of being silently with its STUN-discovered public IPv4 instead of being silently
Generated
+22
View File
@@ -1067,6 +1067,7 @@ dependencies = [
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"portable-atomic", "portable-atomic",
"procfs",
"rand 0.10.1", "rand 0.10.1",
"ratatui", "ratatui",
"rtnetlink", "rtnetlink",
@@ -2365,6 +2366,27 @@ dependencies = [
"yansi", "yansi",
] ]
[[package]]
name = "procfs"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7"
dependencies = [
"bitflags 2.11.1",
"procfs-core",
"rustix",
]
[[package]]
name = "procfs-core"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405"
dependencies = [
"bitflags 2.11.1",
"hex",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
+1
View File
@@ -42,6 +42,7 @@ libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
rtnetlink = "0.21.0" rtnetlink = "0.21.0"
rustables = "0.8.7" rustables = "0.8.7"
procfs = { version = "0.18", default-features = false }
# bluer/BlueZ needs glibc — see build.rs `bluer_available` cfg gate. # bluer/BlueZ needs glibc — see build.rs `bluer_available` cfg gate.
[target.'cfg(all(target_os = "linux", not(target_env = "musl")))'.dependencies] [target.'cfg(all(target_os = "linux", not(target_env = "musl")))'.dependencies]
+34
View File
@@ -113,6 +113,40 @@ ip6 saddr {
Set syntax keeps multi-node rules readable and is more efficient than a Set syntax keeps multi-node rules readable and is more efficient than a
chain of individual rules. 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 ## Inspect drops
The baseline counter increments on every dropped packet. Inspect it: The baseline counter increments on every dropped packet. Inspect it:
+33 -1
View File
@@ -36,7 +36,7 @@ query on its first activation and on every refresh tick while active.
| Tab | Query | Shows | | 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. | | **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. | | **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. | | **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 and Cache tabs are not in the cycle but are fetched as cross-references
to populate Peers, Transports, and Routing detail views. 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 ## Keybindings
### Global ### Global
+1
View File
@@ -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_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_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_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_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_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). | | `show_stats_all_history` | `peer` (optional npub), `window`, `granularity` | `granularity_seconds`, `window_seconds`, `peer`, `series[]` (one per metric). |
+41 -1
View File
@@ -189,6 +189,16 @@ server.
> verified is reachability *from another mesh node*. That is > verified is reachability *from another mesh node*. That is
> the next concern. > 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 ## Step 4: Reachability from a mesh node
Any mesh node — a direct peer, or a node several hops away — 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 > TCP SYN dropped before it can reach your server. From the
> remote end the connection times out. > 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 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 for our purpose: we *want* mesh nodes to reach port 8080. The
next step opens that one port. 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 `inbound` chain → matches `tcp dport 8080 accept` → delivered
to the HTTP server. 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 If you only want to expose the service to a *specific* node
or set of nodes, source-filter the rule. The address filter or set of nodes, source-filter the rule. The address filter
applies to the mesh-source address as it arrives on `fips0`, 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 opts in to one audience; binding to wildcard
(`0.0.0.0` / `[::]`) opts in to *all* of them, including (`0.0.0.0` / `[::]`) opts in to *all* of them, including
ones you forgot you had. For mesh-only exposure, bind to 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 - **Same-host loopback is misleading.** A local curl to your
own `fd97:...` address goes via the loopback path, not own `fd97:...` address goes via the loopback path, not
through `fips0` ingress. To actually verify mesh-side 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, mesh node attempts to reach a port you have not opened,
`sudo nft list table inet fips` will show the counter `sudo nft list table inet fips` will show the counter
packet count rising. 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 ## What's next
+4
View File
@@ -210,6 +210,9 @@ pub struct App {
pub gateway_running: bool, pub gateway_running: bool,
/// Mappings data fetched from the gateway (separate from summary). /// Mappings data fetched from the gateway (separate from summary).
pub gateway_mappings: Option<serde_json::Value>, pub gateway_mappings: Option<serde_json::Value>,
/// `show_listening_sockets` payload for the Node-tab "Listening on
/// fips0" panel; refreshed each tick alongside `show_status`.
pub listening_sockets: Option<serde_json::Value>,
/// Scroll offset (rows) for the stacked Graphs tab. /// Scroll offset (rows) for the stacked Graphs tab.
pub graphs_scroll: u16, pub graphs_scroll: u16,
/// Selected (window, granularity) index for the Graphs tab. /// Selected (window, granularity) index for the Graphs tab.
@@ -243,6 +246,7 @@ impl App {
selected_tree_item: SelectedTreeItem::None, selected_tree_item: SelectedTreeItem::None,
gateway_running: false, gateway_running: false,
gateway_mappings: None, gateway_mappings: None,
listening_sockets: None,
graphs_scroll: 0, graphs_scroll: 0,
graphs_window_idx: 1, // default 10m graphs_window_idx: 1, // default 10m
graphs_mode: GraphsMode::Node, graphs_mode: GraphsMode::Node,
+11
View File
@@ -65,6 +65,17 @@ fn fetch_data(
} }
} }
// Listening-on-fips0 panel — fetched only while the Node tab is
// active (it's the only place the data is rendered). Errors are
// non-fatal: an old daemon without the query just leaves the
// payload at None and the panel hides.
if app.active_tab == Tab::Node {
match rt.block_on(client.query("show_listening_sockets")) {
Ok(data) => app.listening_sockets = Some(data),
Err(_) => app.listening_sockets = None,
}
}
// Gateway tab uses a separate socket // Gateway tab uses a separate socket
if app.active_tab == Tab::Gateway { if app.active_tab == Tab::Gateway {
match rt.block_on(gateway_client.query("show_gateway")) { match rt.block_on(gateway_client.query("show_gateway")) {
+11 -2
View File
@@ -7,6 +7,7 @@ use ratatui::widgets::{Block, Borders, Paragraph};
use crate::app::App; use crate::app::App;
use super::helpers; use super::helpers;
use super::listening;
pub fn draw(frame: &mut Frame, app: &App, area: Rect) { pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&crate::app::Tab::Node) { let data = match app.data.get(&crate::app::Tab::Node) {
@@ -23,7 +24,7 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
Constraint::Length(7), // Runtime Constraint::Length(7), // Runtime
Constraint::Length(7), // Identity Constraint::Length(7), // Identity
Constraint::Length(6), // State (sparkline row adds one line) Constraint::Length(6), // State (sparkline row adds one line)
Constraint::Length(9), // Traffic Constraint::Length(9), // Traffic + Listening on fips0 (side-by-side)
Constraint::Min(0), // remaining Constraint::Min(0), // remaining
]) ])
.split(area); .split(area);
@@ -31,7 +32,15 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
draw_runtime(frame, data, chunks[0]); draw_runtime(frame, data, chunks[0]);
draw_identity(frame, data, chunks[1]); draw_identity(frame, data, chunks[1]);
draw_state(frame, data, chunks[2]); draw_state(frame, data, chunks[2]);
draw_node_stats(frame, data, chunks[3]);
// Traffic on the left, listening-on-fips0 on the right. The split
// is 50/50 with a sane minimum width for each half so very narrow
// terminals still produce readable columns.
let traffic_chunks =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[3]);
draw_node_stats(frame, data, traffic_chunks[0]);
listening::draw(frame, app.listening_sockets.as_ref(), traffic_chunks[1]);
} }
fn draw_runtime(frame: &mut Frame, data: &serde_json::Value, area: Rect) { fn draw_runtime(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
+161
View File
@@ -0,0 +1,161 @@
//! "Listening on fips0" panel — right-half of the Node tab's Traffic block.
//!
//! Renders the daemon's `show_listening_sockets` payload as a table:
//!
//! ```text
//! ┌─ Listening on fips0 ──────────┐
//! │ Proto Port Process State │
//! │ tcp 22 sshd OPEN │
//! │ tcp 8443 fips OPEN │
//! │ tcp 9100 prometheus filt │
//! │ udp 5353 systemd-r* filt │
//! └───────────────────────────────┘
//! ```
//!
//! Style rules per row's `filter` value:
//! - `accept` → default White (mesh-reachable).
//! - `drop` → DarkGray (less prominent).
//! - `unknown` → DarkGray with `?` suffix in State.
//! - `no_firewall` → default White; a yellow banner is rendered above
//! the table in place of the title to alert the operator.
//!
//! A `*` after the process name marks wildcard binds (`local_addr ==
//! ::`) — the bind is not fips0-specific, so the operator sees it
//! exposed to the mesh perhaps unintentionally.
use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table};
use serde_json::Value;
/// Render the listening-sockets panel into `area`. `payload` is the
/// raw `show_listening_sockets` response, or `None` if the daemon
/// couldn't be queried (old daemon, or the panel is rendering before
/// the first fetch).
pub fn draw(frame: &mut Frame, payload: Option<&Value>, area: Rect) {
let payload = match payload {
Some(p) => p,
None => {
let block = Block::default()
.borders(Borders::ALL)
.title(" Listening on fips0 ");
let inner = block.inner(area);
frame.render_widget(block, area);
let msg = Paragraph::new(Span::styled(
" loading...",
Style::default().fg(Color::DarkGray),
));
frame.render_widget(msg, inner);
return;
}
};
let firewall_active = payload
.get("firewall_active")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let sockets = payload
.get("sockets")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
// Title — yellow banner replaces the plain title when the
// baseline filter is not active.
let title: Span<'static> = if firewall_active {
Span::raw(" Listening on fips0 ")
} else {
Span::styled(
" Listening on fips0 fips-firewall.service inactive — all listeners exposed ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
};
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
if sockets.is_empty() {
let msg = Paragraph::new(Span::styled(
" no listeners reachable from fips0",
Style::default().fg(Color::DarkGray),
));
frame.render_widget(msg, inner);
return;
}
let header = Row::new(vec![
Cell::from("Proto"),
Cell::from("Port"),
Cell::from("Process"),
Cell::from("State"),
])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = sockets.iter().map(|s| build_row(s)).collect();
let table = Table::new(
rows,
[
Constraint::Length(5), // Proto
Constraint::Length(6), // Port
Constraint::Min(8), // Process (variable)
Constraint::Length(11), // State (e.g., "filt? *" pad)
],
)
.header(header)
.column_spacing(1);
frame.render_widget(table, inner);
}
fn build_row(s: &Value) -> Row<'static> {
let proto = s
.get("proto")
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let port = s.get("port").and_then(|v| v.as_u64()).unwrap_or(0);
let pid = s.get("pid").and_then(|v| v.as_u64());
let process_name = s.get("process").and_then(|v| v.as_str());
let wildcard = s
.get("wildcard_bind")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let filter = s.get("filter").and_then(|v| v.as_str()).unwrap_or("drop");
let process_label = match (pid, process_name) {
(Some(p), Some(n)) => format!("{n}({p})"),
(Some(p), None) => format!("?({p})"),
_ => "?".to_string(),
};
let process_label = if wildcard && pid.is_some() {
format!("{process_label} *")
} else {
process_label
};
let (state_text, row_style): (String, Style) = match filter {
"accept" => ("OPEN".into(), Style::default()),
"drop" => ("filt".into(), Style::default().fg(Color::DarkGray)),
"unknown" => ("filt?".into(), Style::default().fg(Color::DarkGray)),
"no_firewall" => ("OPEN".into(), Style::default()),
_ => ("?".into(), Style::default().fg(Color::DarkGray)),
};
Row::new(vec![
Cell::from(format!(" {proto}")),
Cell::from(port.to_string()),
Cell::from(process_label),
Cell::from(state_text),
])
.style(row_style)
}
+1
View File
@@ -3,6 +3,7 @@ mod dashboard;
mod gateway; mod gateway;
mod graphs; mod graphs;
mod helpers; mod helpers;
pub(crate) mod listening;
mod mmp; mod mmp;
mod peers; mod peers;
mod routing; mod routing;
+29 -42
View File
@@ -93,33 +93,30 @@ pub fn pub_file_path(config_path: &Path) -> PathBuf {
/// Resolve a default Unix-socket path under the canonical order: /// Resolve a default Unix-socket path under the canonical order:
/// `/run/fips/<filename>` → `$XDG_RUNTIME_DIR/fips/<filename>` → `/tmp/fips-<filename>`. /// `/run/fips/<filename>` → `$XDG_RUNTIME_DIR/fips/<filename>` → `/tmp/fips-<filename>`.
/// ///
/// `/run/fips` is the packaged convention (`root:fips 0770` directory created /// `/run/fips` is the packaged convention (`root:fips 0770` directory
/// by the daemon at bind time). `XDG_RUNTIME_DIR` covers non-root dev runs /// created by the daemon at bind time, or by the postinst script).
/// where `/run/fips` does not exist or is not writable. `/tmp` is the /// `XDG_RUNTIME_DIR` covers dev runs where `/run/fips` does not exist.
/// last-resort fallback. /// `/tmp` is the last-resort fallback.
/// ///
/// Hardening notes: /// Selection is by *existence*, not writability. A fips-group member
/// - `/run/fips` is accepted only if the directory exists and is writable by /// whose shell session has not picked up the supplementary group (no
/// the current process. `create_dir_all` reporting `Ok(())` is *not* /// re-login after `usermod -aG fips`) cannot tempfile-probe a
/// sufficient: it returns `Ok` for an existing root-owned dir that we /// `root:fips 0770` directory but can still connect to a socket inside
/// cannot write to, which would silently steer a non-root daemon onto a /// it once the kernel checks the actual group at `connect(2)` time —
/// path that fails at bind time. Writability is probed via tempfile create /// and even where the user genuinely cannot connect, surfacing an
/// rather than mode bits so ACLs and group membership (the dir is /// `EACCES` from the socket call is clearer than silently steering
/// `root:fips 0770`) are honored. /// fipstop / fipsctl to a path the daemon never bound. The daemon's
/// - `XDG_RUNTIME_DIR` is validated as an existing directory before being /// own bind code (`ControlSocket::bind`) creates `/run/fips` if it is
/// used; a stale post-logout value (after `pam_systemd` reaps the dir) is /// missing, so the resolver does not need to materialize the directory
/// treated as missing. /// itself.
///
/// `XDG_RUNTIME_DIR` is validated as an existing directory before being
/// used; a stale post-logout value (after `pam_systemd` reaps the dir)
/// is treated as missing.
#[cfg(unix)] #[cfg(unix)]
pub(crate) fn resolve_default_socket(filename: &str) -> String { pub(crate) fn resolve_default_socket(filename: &str) -> String {
// 1. /run/fips — accept only if the directory exists and is writable. // 1. /run/fips — preferred whenever the directory exists.
let run_fips = Path::new("/run/fips"); if Path::new("/run/fips").is_dir() {
if run_fips.is_dir() && is_writable_dir(run_fips) {
return format!("/run/fips/{filename}");
}
// Also accept /run/fips if we can create it (covers the first-boot
// daemon-as-root case before the directory has been materialized). The
// actual chown happens at bind time.
if std::fs::create_dir_all(run_fips).is_ok() && is_writable_dir(run_fips) {
return format!("/run/fips/{filename}"); return format!("/run/fips/{filename}");
} }
@@ -137,21 +134,6 @@ pub(crate) fn resolve_default_socket(filename: &str) -> String {
format!("/tmp/fips-{filename}") format!("/tmp/fips-{filename}")
} }
#[cfg(unix)]
fn is_writable_dir(path: &Path) -> bool {
// Probe via tempfile creation rather than mode bits: mode-bit checks miss
// ACLs and group-membership effects (the /run/fips dir is `root:fips
// 0770` and the daemon may run as a user that's in the `fips` group).
let probe = path.join(format!(".fips-write-probe-{}", std::process::id()));
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
/// Default control socket path for fipsctl / fipstop. /// Default control socket path for fipsctl / fipstop.
/// ///
/// On Unix, delegates to [`resolve_default_socket`] for the canonical /// On Unix, delegates to [`resolve_default_socket`] for the canonical
@@ -1547,8 +1529,11 @@ peers:
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn test_resolve_default_socket_xdg_when_no_run_fips() { fn test_resolve_default_socket_xdg_when_no_run_fips() {
// With /run/fips unwritable (non-root tests) and XDG_RUNTIME_DIR // With /run/fips absent and XDG_RUNTIME_DIR pointing at an
// pointing at an existing directory, the resolver picks XDG. // existing directory, the resolver picks XDG. On test hosts where
// /run/fips happens to exist (a real fips deployment), the
// resolver legitimately picks /run/fips and skips XDG entirely;
// both outcomes are accepted below.
let _g = ENV_MUTEX.lock().unwrap(); let _g = ENV_MUTEX.lock().unwrap();
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
@@ -1584,7 +1569,9 @@ peers:
#[test] #[test]
fn test_resolve_default_socket_tmp_when_xdg_invalid() { fn test_resolve_default_socket_tmp_when_xdg_invalid() {
// With XDG_RUNTIME_DIR pointing at a non-existent directory and // With XDG_RUNTIME_DIR pointing at a non-existent directory and
// /run/fips unwritable, the resolver falls through to /tmp. // /run/fips absent, the resolver falls through to /tmp. On hosts
// where /run/fips exists, the resolver legitimately picks it
// first; both outcomes are accepted below.
let _g = ENV_MUTEX.lock().unwrap(); let _g = ENV_MUTEX.lock().unwrap();
let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok(); let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
+657
View File
@@ -0,0 +1,657 @@
//! Read-side classifier for the `inet fips` baseline filter.
//!
//! For the fipstop "Listening on fips0" panel, we need to tell the
//! operator whether a given (proto, port) listener is actually
//! reachable on fips0 or whether it would be silently dropped by the
//! shipped baseline. We answer that question by shelling out to
//! `nft -j list table inet fips` (stable JSON output) and walking the
//! `inbound` chain's rules.
//!
//! Linux-only. Non-Linux callers (the daemon doesn't ship the
//! firewall on macOS / Windows) get [`FilterClassifier::no_firewall`].
//!
//! Three terminal states per (proto, port) pair:
//!
//! - [`FilterState::NoFirewall`] — `inet fips` table does not exist
//! (the operator hasn't enabled `fips-firewall.service`). The UI
//! surfaces this via a yellow banner above the panel rather than
//! per-row.
//! - [`FilterState::Accept`] — the chain has a canonical-shape rule
//! that accepts traffic to (proto, port) without any source or
//! other restriction.
//! - [`FilterState::Drop`] — no rule matches; the chain falls through
//! to its trailing `counter drop`.
//! - [`FilterState::Unknown`] — at least one rule references the
//! (proto, port) pair but uses matchers we don't fully interpret
//! (saddr filters, daddr filters, set/range right-hand sides we
//! can't decompose, jumps to other chains). The operator should
//! `nft list table inet fips` to confirm; the UI dims and tags `?`.
//!
//! Conntrack-related accepts (`ct state established,related accept`)
//! and the ICMPv6-echo-request accept are not classified — they
//! don't pertain to listening TCP/UDP ports the operator binds.
use serde_json::Value;
use crate::control::listening::Proto;
/// Classification of a (proto, port) pair against the inbound chain.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterState {
NoFirewall,
Accept,
Drop,
Unknown,
}
impl FilterState {
pub fn as_str(self) -> &'static str {
match self {
FilterState::NoFirewall => "no_firewall",
FilterState::Accept => "accept",
FilterState::Drop => "drop",
FilterState::Unknown => "unknown",
}
}
}
/// Cached snapshot of the `inet fips` inbound chain at the moment
/// the panel was queried. Build once per `show_listening_sockets`
/// call and consult per row.
pub struct FilterClassifier {
/// Parsed rule list for the `inbound` chain, in order. `None`
/// when the table does not exist (`fips-firewall.service` not
/// active) — every classification call returns `NoFirewall`.
rules: Option<Vec<Rule>>,
}
#[derive(Debug, Clone)]
struct Rule {
/// All `match` expressions in order, plus a single terminal verdict.
matches: Vec<MatchExpr>,
verdict: Verdict,
}
/// Subset of `match` expressions we recognize. Anything we don't
/// recognize forces the rule into the [`Verdict::Unknown`] bucket
/// when classifying.
#[derive(Debug, Clone)]
enum MatchExpr {
/// `meta iifname == "fips0"` / `!= "fips0"`.
/// The shipped baseline returns immediately when iifname is not
/// fips0; rules after that line apply only to fips0 traffic, so we
/// don't need to model this. We just recognize the shape so we
/// don't bucket these lines into [`MatchExpr::Unrecognized`].
Iifname,
/// `meta l4proto == tcp/udp`.
L4Proto(Proto),
/// `tcp dport == N` / `udp dport == N` / dport in set / dport in range.
Dport(Proto, PortMatch),
/// Any other match expression we don't decompose (saddr, daddr,
/// ct state we don't care about, complex right-hand sides).
Unrecognized,
}
#[derive(Debug, Clone)]
enum PortMatch {
/// `dport == 22`
Single(u16),
/// `dport { 22, 80, 443 }`
Set(Vec<u16>),
/// `dport 22-25`
Range(u16, u16),
}
impl PortMatch {
fn matches(&self, port: u16) -> bool {
match self {
PortMatch::Single(p) => *p == port,
PortMatch::Set(ps) => ps.contains(&port),
PortMatch::Range(lo, hi) => *lo <= port && port <= *hi,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Verdict {
Accept,
Drop,
/// `return`, `continue`, `jump`, `goto`, `reject`, `queue`, etc.
/// We don't follow control-flow verdicts — anything that isn't a
/// terminal accept/drop forces classification to [`FilterState::Unknown`]
/// when the rule otherwise references the port.
Other,
}
impl FilterClassifier {
/// No-firewall classifier (used on non-Linux targets).
pub fn no_firewall() -> Self {
Self { rules: None }
}
/// Build a classifier by querying the running kernel for the
/// current `inet fips` inbound chain. Returns a [`Self::no_firewall`]
/// classifier when the table is absent.
#[cfg(target_os = "linux")]
pub fn query() -> Self {
let json = match run_nft_list() {
Some(j) => j,
None => return Self::no_firewall(),
};
let rules = parse_inbound_rules(&json);
Self { rules: Some(rules) }
}
#[cfg(not(target_os = "linux"))]
pub fn query() -> Self {
Self::no_firewall()
}
/// True iff the `inet fips` table is currently loaded — i.e.
/// `fips-firewall.service` is active.
pub fn is_active(&self) -> bool {
self.rules.is_some()
}
/// Classify a single (proto, port) pair.
pub fn classify(&self, proto: Proto, port: u16) -> FilterState {
let rules = match &self.rules {
None => return FilterState::NoFirewall,
Some(r) => r,
};
let mut saw_unknown_for_port = false;
for rule in rules {
// Does this rule reference our (proto, port)?
let mut references_port = false;
let mut canonical_for_port = true;
let mut has_proto_match = None;
for m in &rule.matches {
match m {
MatchExpr::Iifname => {
// The `iifname != "fips0" return` rule is
// structurally the table's iif scoping. Skip
// it — it shouldn't affect classification of
// rules that come after.
}
MatchExpr::L4Proto(p) => {
has_proto_match = Some(*p);
if *p != proto {
canonical_for_port = false;
}
}
MatchExpr::Dport(p, pm) => {
if *p == proto && pm.matches(port) {
references_port = true;
} else if pm.matches(port) {
// dport match for a different proto —
// the rule references our port number but
// not under our proto.
} else {
canonical_for_port = false;
}
}
MatchExpr::Unrecognized => {
// Source filters, daddr filters, anything
// else — rule is not the canonical
// unrestricted accept.
if rule_might_reference_port(rule, proto, port) {
saw_unknown_for_port = true;
}
canonical_for_port = false;
}
}
}
if !references_port {
continue;
}
// Rule references our (proto, port). Decide based on
// verdict and whether the rule had any unrecognized matches.
if !canonical_for_port {
saw_unknown_for_port = true;
continue;
}
// Optional l4proto match must agree with our proto
// (already checked above) or be absent.
if let Some(p) = has_proto_match
&& p != proto
{
continue;
}
match rule.verdict {
Verdict::Accept => return FilterState::Accept,
Verdict::Drop => {
// Explicit drop — clearly Drop, no need to keep
// looking. Operator wrote a deny.
return FilterState::Drop;
}
Verdict::Other => {
saw_unknown_for_port = true;
}
}
}
if saw_unknown_for_port {
FilterState::Unknown
} else {
FilterState::Drop
}
}
}
/// Heuristic: does this rule, taken as a whole, reference our port?
/// Used to decide whether unrecognized matches warrant Unknown vs.
/// being ignored. Avoids flagging every rule with an unrecognized
/// matcher as Unknown for every port in the system.
fn rule_might_reference_port(rule: &Rule, proto: Proto, port: u16) -> bool {
rule.matches.iter().any(|m| match m {
MatchExpr::Dport(p, pm) => *p == proto && pm.matches(port),
_ => false,
})
}
// ---------- nft -j shell-out + JSON parsing ----------
#[cfg(target_os = "linux")]
fn run_nft_list() -> Option<Value> {
use std::process::Command;
let output = Command::new("nft")
.args(["-j", "list", "table", "inet", "fips"])
.output()
.ok()?;
if !output.status.success() {
// Common case: table doesn't exist (fips-firewall.service not
// active) → exit code 1, stderr "Error: No such file or directory".
// Less common: nft binary missing (we already returned None
// above). Either way, no firewall data to classify against.
return None;
}
serde_json::from_slice::<Value>(&output.stdout).ok()
}
fn parse_inbound_rules(json: &Value) -> Vec<Rule> {
let arr = match json.get("nftables").and_then(|v| v.as_array()) {
Some(a) => a,
None => return Vec::new(),
};
arr.iter()
.filter_map(|entry| entry.get("rule"))
.filter(|rule| {
rule.get("chain").and_then(|v| v.as_str()) == Some("inbound")
&& rule.get("table").and_then(|v| v.as_str()) == Some("fips")
})
.map(parse_rule)
.collect()
}
fn parse_rule(rule: &Value) -> Rule {
let exprs = rule
.get("expr")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut matches = Vec::new();
let mut verdict = Verdict::Other;
for e in &exprs {
if let Some(m) = e.get("match") {
matches.push(parse_match(m));
} else if e.get("accept").is_some() {
verdict = Verdict::Accept;
} else if e.get("drop").is_some() {
verdict = Verdict::Drop;
} else if e.get("counter").is_some() {
// Bare counter is observational; preserve any earlier
// verdict (the counter usually precedes the verdict).
// The trailing `counter drop` rule has only `counter` +
// `drop` exprs, which is handled above.
} else if e.get("return").is_some()
|| e.get("jump").is_some()
|| e.get("goto").is_some()
|| e.get("continue").is_some()
|| e.get("reject").is_some()
|| e.get("queue").is_some()
{
verdict = Verdict::Other;
}
// Unknown expression types fall through silently — they don't
// affect verdict, but parse_match already pushes Unrecognized
// for unknown match shapes.
}
Rule { matches, verdict }
}
fn parse_match(m: &Value) -> MatchExpr {
let op = m.get("op").and_then(|v| v.as_str()).unwrap_or("==");
let left = m.get("left").cloned().unwrap_or(Value::Null);
let right = m.get("right").cloned().unwrap_or(Value::Null);
// meta iifname
if let Some(meta) = left.get("meta")
&& meta.get("key").and_then(|v| v.as_str()) == Some("iifname")
&& right.as_str().is_some()
{
let _ = op; // op is informational here; we don't use negation.
return MatchExpr::Iifname;
}
// meta l4proto
if let Some(meta) = left.get("meta")
&& meta.get("key").and_then(|v| v.as_str()) == Some("l4proto")
&& let Some(proto_str) = right.as_str()
&& let Some(proto) = parse_proto(proto_str)
&& op == "=="
{
return MatchExpr::L4Proto(proto);
}
// tcp/udp dport
if let Some(payload) = left.get("payload")
&& payload.get("field").and_then(|v| v.as_str()) == Some("dport")
&& let Some(proto_str) = payload.get("protocol").and_then(|v| v.as_str())
&& let Some(proto) = parse_proto(proto_str)
&& op == "=="
{
if let Some(p) = right.as_u64() {
return MatchExpr::Dport(proto, PortMatch::Single(p as u16));
}
if let Some(set) = right.get("set").and_then(|v| v.as_array()) {
let ports: Vec<u16> = set
.iter()
.filter_map(|v| v.as_u64().map(|n| n as u16))
.collect();
// Bail if the set contained anything we couldn't read as
// a plain integer (e.g. a named-set reference or nested
// range/prefix).
if ports.len() == set.len() {
return MatchExpr::Dport(proto, PortMatch::Set(ports));
}
}
if let Some(range) = right.get("range").and_then(|v| v.as_array())
&& range.len() == 2
&& let (Some(lo), Some(hi)) = (range[0].as_u64(), range[1].as_u64())
{
return MatchExpr::Dport(proto, PortMatch::Range(lo as u16, hi as u16));
}
}
MatchExpr::Unrecognized
}
fn parse_proto(s: &str) -> Option<Proto> {
match s {
"tcp" => Some(Proto::Tcp),
"udp" => Some(Proto::Udp),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_classifier(rules_json: Value) -> FilterClassifier {
let nft_json = json!({
"nftables": rules_json
.as_array()
.unwrap()
.iter()
.map(|r| json!({"rule": {
"family": "inet",
"table": "fips",
"chain": "inbound",
"expr": r,
}}))
.collect::<Vec<_>>(),
});
FilterClassifier {
rules: Some(parse_inbound_rules(&nft_json)),
}
}
#[test]
fn no_firewall_means_no_firewall() {
let c = FilterClassifier::no_firewall();
assert_eq!(c.classify(Proto::Tcp, 22), FilterState::NoFirewall);
assert_eq!(c.classify(Proto::Udp, 5353), FilterState::NoFirewall);
}
#[test]
fn empty_chain_drops_everything() {
let c = make_classifier(json!([]));
assert_eq!(c.classify(Proto::Tcp, 22), FilterState::Drop);
assert_eq!(c.classify(Proto::Udp, 5353), FilterState::Drop);
}
#[test]
fn canonical_tcp_dport_accept() {
// tcp dport 22 accept
let c = make_classifier(json!([
[
{"match": {
"op": "==",
"left": {"payload": {"protocol": "tcp", "field": "dport"}},
"right": 22
}},
{"accept": null}
]
]));
assert_eq!(c.classify(Proto::Tcp, 22), FilterState::Accept);
assert_eq!(c.classify(Proto::Tcp, 80), FilterState::Drop);
assert_eq!(c.classify(Proto::Udp, 22), FilterState::Drop);
}
#[test]
fn canonical_udp_dport_accept() {
// udp dport 5353 accept
let c = make_classifier(json!([
[
{"match": {
"op": "==",
"left": {"payload": {"protocol": "udp", "field": "dport"}},
"right": 5353
}},
{"accept": null}
]
]));
assert_eq!(c.classify(Proto::Udp, 5353), FilterState::Accept);
assert_eq!(c.classify(Proto::Tcp, 5353), FilterState::Drop);
}
#[test]
fn dport_set_accept() {
// tcp dport { 22, 80, 443 } accept
let c = make_classifier(json!([
[
{"match": {
"op": "==",
"left": {"payload": {"protocol": "tcp", "field": "dport"}},
"right": {"set": [22, 80, 443]}
}},
{"accept": null}
]
]));
assert_eq!(c.classify(Proto::Tcp, 22), FilterState::Accept);
assert_eq!(c.classify(Proto::Tcp, 80), FilterState::Accept);
assert_eq!(c.classify(Proto::Tcp, 443), FilterState::Accept);
assert_eq!(c.classify(Proto::Tcp, 25), FilterState::Drop);
}
#[test]
fn dport_range_accept() {
// tcp dport 22-25 accept
let c = make_classifier(json!([
[
{"match": {
"op": "==",
"left": {"payload": {"protocol": "tcp", "field": "dport"}},
"right": {"range": [22, 25]}
}},
{"accept": null}
]
]));
assert_eq!(c.classify(Proto::Tcp, 22), FilterState::Accept);
assert_eq!(c.classify(Proto::Tcp, 25), FilterState::Accept);
assert_eq!(c.classify(Proto::Tcp, 26), FilterState::Drop);
}
#[test]
fn saddr_restricted_is_unknown() {
// ip6 saddr fd97::/64 tcp dport 22 accept — the saddr filter
// means we can't tell from the rule alone whether mesh peers
// can reach the port.
let c = make_classifier(json!([
[
{"match": {
"op": "==",
"left": {"payload": {"protocol": "ip6", "field": "saddr"}},
"right": {"prefix": {"addr": "fd97::", "len": 64}}
}},
{"match": {
"op": "==",
"left": {"payload": {"protocol": "tcp", "field": "dport"}},
"right": 22
}},
{"accept": null}
]
]));
assert_eq!(c.classify(Proto::Tcp, 22), FilterState::Unknown);
// Other ports unaffected.
assert_eq!(c.classify(Proto::Tcp, 80), FilterState::Drop);
}
#[test]
fn jump_verdict_is_unknown() {
// tcp dport 22 jump some_chain — we don't follow chains, so
// surface to operator.
let c = make_classifier(json!([
[
{"match": {
"op": "==",
"left": {"payload": {"protocol": "tcp", "field": "dport"}},
"right": 22
}},
{"jump": {"target": "some_chain"}}
]
]));
assert_eq!(c.classify(Proto::Tcp, 22), FilterState::Unknown);
}
#[test]
fn explicit_drop_classifies_as_drop() {
// tcp dport 22 drop — operator explicitly denying.
let c = make_classifier(json!([
[
{"match": {
"op": "==",
"left": {"payload": {"protocol": "tcp", "field": "dport"}},
"right": 22
}},
{"drop": null}
]
]));
assert_eq!(c.classify(Proto::Tcp, 22), FilterState::Drop);
}
#[test]
fn unrelated_rules_dont_affect_port() {
// Common shipped baseline rules: iifname-scoping, ct state,
// icmpv6 echo. Should not affect (tcp, 22) classification.
let c = make_classifier(json!([
// iifname != "fips0" return
[
{"match": {
"op": "!=",
"left": {"meta": {"key": "iifname"}},
"right": "fips0"
}},
{"return": null}
],
// ct state {established, related} accept
[
{"match": {
"op": "in",
"left": {"ct": {"key": "state"}},
"right": ["established", "related"]
}},
{"accept": null}
],
// icmpv6 type echo-request accept
[
{"match": {
"op": "==",
"left": {"payload": {"protocol": "icmpv6", "field": "type"}},
"right": "echo-request"
}},
{"accept": null}
],
]));
assert_eq!(c.classify(Proto::Tcp, 22), FilterState::Drop);
assert_eq!(c.classify(Proto::Udp, 5353), FilterState::Drop);
}
#[test]
fn l4proto_then_dport_accept() {
// meta l4proto tcp tcp dport 22 accept — rare but valid
let c = make_classifier(json!([
[
{"match": {
"op": "==",
"left": {"meta": {"key": "l4proto"}},
"right": "tcp"
}},
{"match": {
"op": "==",
"left": {"payload": {"protocol": "tcp", "field": "dport"}},
"right": 22
}},
{"accept": null}
]
]));
assert_eq!(c.classify(Proto::Tcp, 22), FilterState::Accept);
assert_eq!(c.classify(Proto::Udp, 22), FilterState::Drop);
}
#[test]
fn first_accept_match_wins() {
// If both an accept and a Unknown rule reference the same
// port, the explicit accept wins (operator wanted it open).
let c = make_classifier(json!([
[
{"match": {
"op": "==",
"left": {"payload": {"protocol": "tcp", "field": "dport"}},
"right": 22
}},
{"accept": null}
],
[
{"match": {
"op": "==",
"left": {"payload": {"protocol": "ip6", "field": "saddr"}},
"right": "fd00::1"
}},
{"match": {
"op": "==",
"left": {"payload": {"protocol": "tcp", "field": "dport"}},
"right": 22
}},
{"drop": null}
]
]));
assert_eq!(c.classify(Proto::Tcp, 22), FilterState::Accept);
}
}
+274
View File
@@ -0,0 +1,274 @@
//! Listening-socket enumeration for the fipstop "Listening on fips0" panel.
//!
//! Walks `/proc/net/tcp6` and `/proc/net/udp6` and pairs each entry with
//! the owning PID/process name (resolved by walking `/proc/<pid>/fd/`).
//! Results are filtered to entries reachable from the fips0 interface —
//! sockets bound to the IPv6 wildcard `::` or to the node's own
//! fd00::/8 address. IPv4 listeners are not enumerated; fips0 is
//! IPv6-only.
//!
//! Linux-only. Non-Linux callers receive an empty vector.
//!
//! See [`crate::control::firewall_state`] for the per-port nftables
//! filter classification that pairs with this enumeration.
//!
//! See `docs/design/fips-security.md` for the operator-side narrative
//! that motivates the panel.
use std::net::{IpAddr, Ipv6Addr};
/// Transport protocol of a listening socket.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Proto {
Tcp,
Udp,
}
impl Proto {
pub fn as_str(self) -> &'static str {
match self {
Proto::Tcp => "tcp",
Proto::Udp => "udp",
}
}
}
/// One listening socket reachable from fips0.
#[derive(Debug, Clone)]
pub struct ListeningSocket {
pub proto: Proto,
pub local_addr: Ipv6Addr,
pub port: u16,
pub pid: Option<u32>,
pub process: Option<String>,
/// True when bound to `::` rather than the fips0 address — a hint
/// that the bind is not fips0-specific (the operator may not have
/// intended to expose the service over the mesh).
pub wildcard_bind: bool,
}
/// Enumerate listening IPv6 sockets reachable from fips0.
///
/// On non-Linux targets (where `/proc` does not exist), returns an
/// empty vector.
#[cfg(target_os = "linux")]
pub fn enumerate(fips0_addr: Ipv6Addr) -> Vec<ListeningSocket> {
use procfs::net::TcpState;
let inode_to_pid = build_inode_to_pid_map();
let mut out: Vec<ListeningSocket> = Vec::new();
if let Ok(entries) = procfs::net::tcp6() {
for e in entries {
if e.state != TcpState::Listen {
continue;
}
if let Some(sock) = build_entry(
Proto::Tcp,
e.local_address.ip(),
e.local_address.port(),
e.inode,
&inode_to_pid,
fips0_addr,
) {
out.push(sock);
}
}
}
if let Ok(entries) = procfs::net::udp6() {
for e in entries {
// /proc/net/udp has no dedicated LISTEN state; treat any
// socket with a wildcard remote as a listener. Connected
// UDP sockets (the kernel after a connect(2)) carry a
// non-wildcard remote and are excluded.
if !e.remote_address.ip().is_unspecified() {
continue;
}
if let Some(sock) = build_entry(
Proto::Udp,
e.local_address.ip(),
e.local_address.port(),
e.inode,
&inode_to_pid,
fips0_addr,
) {
out.push(sock);
}
}
}
// Stable order: proto, then port, then PID. Helps the UI panel
// not flicker as kernel re-orders entries between ticks.
out.sort_by(|a, b| {
a.proto
.as_str()
.cmp(b.proto.as_str())
.then(a.port.cmp(&b.port))
.then(a.pid.cmp(&b.pid))
});
out
}
#[cfg(not(target_os = "linux"))]
pub fn enumerate(_fips0_addr: Ipv6Addr) -> Vec<ListeningSocket> {
Vec::new()
}
/// Decide whether a listening socket is reachable from fips0 and, if
/// so, build a [`ListeningSocket`] row for it.
#[cfg(target_os = "linux")]
fn build_entry(
proto: Proto,
local: IpAddr,
port: u16,
inode: u64,
inode_to_pid: &std::collections::HashMap<u64, (u32, String)>,
fips0_addr: Ipv6Addr,
) -> Option<ListeningSocket> {
let v6 = match local {
IpAddr::V6(a) => a,
// procfs emits v4-mapped addresses for AF_INET6 dual-stack
// sockets bound to 0.0.0.0; treat the prefix as v6 wildcard.
IpAddr::V4(_) => return None,
};
let is_wildcard = v6.is_unspecified();
let is_fips0_addr = v6 == fips0_addr;
if !is_wildcard && !is_fips0_addr {
// Bound to ::1 or to some non-fips0 specific address —
// not reachable over the mesh.
return None;
}
let (pid, process) = match inode_to_pid.get(&inode) {
Some((p, c)) => (Some(*p), Some(c.clone())),
None => (None, None),
};
Some(ListeningSocket {
proto,
local_addr: v6,
port,
pid,
process,
wildcard_bind: is_wildcard,
})
}
/// Build a map of socket inode → (pid, comm) by walking `/proc/<pid>/fd/`.
///
/// Best-effort: processes the daemon cannot read (permission, vanished
/// between listing and stat) are silently skipped, leaving those
/// sockets in the output with `pid: None`.
#[cfg(target_os = "linux")]
fn build_inode_to_pid_map() -> std::collections::HashMap<u64, (u32, String)> {
use procfs::process::FDTarget;
let mut map = std::collections::HashMap::new();
let procs = match procfs::process::all_processes() {
Ok(p) => p,
Err(_) => return map,
};
for proc_res in procs {
let process = match proc_res {
Ok(p) => p,
Err(_) => continue,
};
let stat = match process.stat() {
Ok(s) => s,
Err(_) => continue,
};
let fds = match process.fd() {
Ok(f) => f,
Err(_) => continue,
};
for fd_res in fds {
let fd = match fd_res {
Ok(f) => f,
Err(_) => continue,
};
if let FDTarget::Socket(inode) = fd.target {
map.insert(inode, (stat.pid as u32, stat.comm.clone()));
}
}
}
map
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn proto_as_str() {
assert_eq!(Proto::Tcp.as_str(), "tcp");
assert_eq!(Proto::Udp.as_str(), "udp");
}
#[cfg(target_os = "linux")]
#[test]
fn enumerate_runs_without_panicking() {
// The daemon's own listening sockets (control socket is unix,
// not v6, so it doesn't show up; transports may or may not).
// Just confirm the call returns and produces a valid (possibly
// empty) vector.
let _ = enumerate(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1));
}
#[cfg(target_os = "linux")]
#[test]
fn build_entry_filters_non_fips0_binds() {
let inode_map = std::collections::HashMap::new();
let fips0 = Ipv6Addr::new(0xfd97, 0, 0, 0, 0, 0, 0, 1);
// Wildcard — accepted.
let r = build_entry(
Proto::Tcp,
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
22,
0,
&inode_map,
fips0,
);
assert!(r.is_some());
assert!(r.unwrap().wildcard_bind);
// fips0 address — accepted.
let r = build_entry(Proto::Tcp, IpAddr::V6(fips0), 22, 0, &inode_map, fips0);
assert!(r.is_some());
assert!(!r.unwrap().wildcard_bind);
// Loopback — rejected.
let r = build_entry(
Proto::Tcp,
IpAddr::V6(Ipv6Addr::LOCALHOST),
22,
0,
&inode_map,
fips0,
);
assert!(r.is_none());
// Different specific address — rejected.
let other = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1);
let r = build_entry(Proto::Tcp, IpAddr::V6(other), 22, 0, &inode_map, fips0);
assert!(r.is_none());
// IPv4 — rejected (fips0 is IPv6-only).
let r = build_entry(
Proto::Tcp,
IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
22,
0,
&inode_map,
fips0,
);
assert!(r.is_none());
}
}
+2
View File
@@ -9,6 +9,8 @@
//! - Windows: Uses a TCP socket on localhost (see commit 3) //! - Windows: Uses a TCP socket on localhost (see commit 3)
pub mod commands; pub mod commands;
pub mod firewall_state;
pub mod listening;
pub mod protocol; pub mod protocol;
pub mod queries; pub mod queries;
+37 -1
View File
@@ -1104,6 +1104,40 @@ pub fn show_stats_history_all_peers(
})) }))
} }
/// `show_listening_sockets` — IPv6 listeners reachable from fips0,
/// each annotated with its current `inet fips` filter classification.
///
/// Powers the fipstop "Listening on fips0" panel. See
/// [`crate::control::listening`] and [`crate::control::firewall_state`]
/// for the per-half implementations.
pub fn show_listening_sockets(node: &Node) -> Value {
let fips0 = crate::FipsAddress::from_node_addr(node.identity().node_addr()).to_ipv6();
let sockets = super::listening::enumerate(fips0);
let classifier = super::firewall_state::FilterClassifier::query();
let rows: Vec<Value> = sockets
.iter()
.map(|s| {
let filter = classifier.classify(s.proto, s.port);
json!({
"proto": s.proto.as_str(),
"local_addr": s.local_addr.to_string(),
"port": s.port,
"pid": s.pid,
"process": s.process,
"filter": filter.as_str(),
"wildcard_bind": s.wildcard_bind,
})
})
.collect();
json!({
"fips0_addr": fips0.to_string(),
"firewall_active": classifier.is_active(),
"sockets": rows,
})
}
/// Dispatch a command string to the appropriate query function. /// Dispatch a command string to the appropriate query function.
pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::protocol::Response { pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::protocol::Response {
match command { match command {
@@ -1120,6 +1154,7 @@ pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::pr
"show_transports" => super::protocol::Response::ok(show_transports(node)), "show_transports" => super::protocol::Response::ok(show_transports(node)),
"show_routing" => super::protocol::Response::ok(show_routing(node)), "show_routing" => super::protocol::Response::ok(show_routing(node)),
"show_identity_cache" => super::protocol::Response::ok(show_identity_cache(node)), "show_identity_cache" => super::protocol::Response::ok(show_identity_cache(node)),
"show_listening_sockets" => super::protocol::Response::ok(show_listening_sockets(node)),
"show_stats_list" => super::protocol::Response::ok(show_stats_list()), "show_stats_list" => super::protocol::Response::ok(show_stats_list()),
"show_stats_history" => show_stats_history(node, params), "show_stats_history" => show_stats_history(node, params),
"show_stats_all_history" => show_stats_all_history(node, params), "show_stats_all_history" => show_stats_all_history(node, params),
@@ -1468,13 +1503,14 @@ mod tests {
"show_transports", "show_transports",
"show_routing", "show_routing",
"show_identity_cache", "show_identity_cache",
"show_listening_sockets",
"show_stats_list", "show_stats_list",
"show_stats_history", "show_stats_history",
"show_stats_all_history", "show_stats_all_history",
"show_stats_peers", "show_stats_peers",
"show_stats_history_all_peers", "show_stats_history_all_peers",
]; ];
assert_eq!(expected.len(), 18, "expected exactly 18 query handlers"); assert_eq!(expected.len(), 19, "expected exactly 19 query handlers");
let node = build_test_node(); let node = build_test_node();
for cmd in expected { for cmd in expected {
// Each must dispatch successfully (status == "ok") with // Each must dispatch successfully (status == "ok") with