diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec6aa6..31390fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 btop-style sparklines ([#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 @@ -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 on dev runs with `XDG_RUNTIME_DIR` set, or after a prior packaged install left a root-owned `/run/fips` behind). Canonical order is - `/run/fips` → `$XDG_RUNTIME_DIR/fips/` → `/tmp/fips-`, with - writability of `/run/fips` probed via tempfile create (ACL- and - group-aware) and `XDG_RUNTIME_DIR` validated as an existing - directory before being used. The deployed fleet is unaffected: - packaged configs set `node.control.socket_path` explicitly. + `/run/fips` → `$XDG_RUNTIME_DIR/fips/` → `/tmp/fips-`. The + `/run/fips` arm is selected by directory existence; the kernel + enforces actual access at `connect(2)` time, surfacing a clear + `EACCES` for users not yet in the `fips` group rather than silently + 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` + a wildcard `bind_addr` (e.g. `0.0.0.0:2121`) is now advertised with its STUN-discovered public IPv4 instead of being silently diff --git a/Cargo.lock b/Cargo.lock index e3f1448..7fbd3c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1067,6 +1067,7 @@ dependencies = [ "nostr", "nostr-sdk", "portable-atomic", + "procfs", "rand 0.10.1", "ratatui", "rtnetlink", @@ -2365,6 +2366,27 @@ dependencies = [ "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]] name = "quote" version = "1.0.45" diff --git a/Cargo.toml b/Cargo.toml index 3eea986..26e5c59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ libc = "0.2" [target.'cfg(target_os = "linux")'.dependencies] rtnetlink = "0.21.0" rustables = "0.8.7" +procfs = { version = "0.18", default-features = false } # bluer/BlueZ needs glibc — see build.rs `bluer_available` cfg gate. [target.'cfg(all(target_os = "linux", not(target_env = "musl")))'.dependencies] diff --git a/docs/how-to/enable-mesh-firewall.md b/docs/how-to/enable-mesh-firewall.md index 93cf665..512f8ba 100644 --- a/docs/how-to/enable-mesh-firewall.md +++ b/docs/how-to/enable-mesh-firewall.md @@ -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: diff --git a/docs/reference/cli-fipstop.md b/docs/reference/cli-fipstop.md index 7ea8a99..6b759ef 100644 --- a/docs/reference/cli-fipstop.md +++ b/docs/reference/cli-fipstop.md @@ -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//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//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 diff --git a/docs/reference/control-socket.md b/docs/reference/control-socket.md index daa2df2..f59feb6 100644 --- a/docs/reference/control-socket.md +++ b/docs/reference/control-socket.md @@ -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` (`s` / `m` / `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). | diff --git a/docs/tutorials/host-a-service.md b/docs/tutorials/host-a-service.md index 8bb1b10..92e0edd 100644 --- a/docs/tutorials/host-a-service.md +++ b/docs/tutorials/host-a-service.md @@ -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()` 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()` 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()` 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 diff --git a/src/bin/fipstop/app.rs b/src/bin/fipstop/app.rs index 7d0f906..b69eeea 100644 --- a/src/bin/fipstop/app.rs +++ b/src/bin/fipstop/app.rs @@ -210,6 +210,9 @@ pub struct App { pub gateway_running: bool, /// Mappings data fetched from the gateway (separate from summary). pub gateway_mappings: Option, + /// `show_listening_sockets` payload for the Node-tab "Listening on + /// fips0" panel; refreshed each tick alongside `show_status`. + pub listening_sockets: Option, /// Scroll offset (rows) for the stacked Graphs tab. pub graphs_scroll: u16, /// Selected (window, granularity) index for the Graphs tab. @@ -243,6 +246,7 @@ impl App { selected_tree_item: SelectedTreeItem::None, gateway_running: false, gateway_mappings: None, + listening_sockets: None, graphs_scroll: 0, graphs_window_idx: 1, // default 10m graphs_mode: GraphsMode::Node, diff --git a/src/bin/fipstop/main.rs b/src/bin/fipstop/main.rs index 434cd43..77b47c2 100644 --- a/src/bin/fipstop/main.rs +++ b/src/bin/fipstop/main.rs @@ -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 if app.active_tab == Tab::Gateway { match rt.block_on(gateway_client.query("show_gateway")) { diff --git a/src/bin/fipstop/ui/dashboard.rs b/src/bin/fipstop/ui/dashboard.rs index b723d35..f4082c5 100644 --- a/src/bin/fipstop/ui/dashboard.rs +++ b/src/bin/fipstop/ui/dashboard.rs @@ -7,6 +7,7 @@ use ratatui::widgets::{Block, Borders, Paragraph}; use crate::app::App; use super::helpers; +use super::listening; pub fn draw(frame: &mut Frame, app: &App, area: Rect) { 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), // Identity 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 ]) .split(area); @@ -31,7 +32,15 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) { draw_runtime(frame, data, chunks[0]); draw_identity(frame, data, chunks[1]); 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) { diff --git a/src/bin/fipstop/ui/listening.rs b/src/bin/fipstop/ui/listening.rs new file mode 100644 index 0000000..a4f4f67 --- /dev/null +++ b/src/bin/fipstop/ui/listening.rs @@ -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 = 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) +} diff --git a/src/bin/fipstop/ui/mod.rs b/src/bin/fipstop/ui/mod.rs index 1b5bf53..31c6763 100644 --- a/src/bin/fipstop/ui/mod.rs +++ b/src/bin/fipstop/ui/mod.rs @@ -3,6 +3,7 @@ mod dashboard; mod gateway; mod graphs; mod helpers; +pub(crate) mod listening; mod mmp; mod peers; mod routing; diff --git a/src/config/mod.rs b/src/config/mod.rs index ec563ee..4e2dde2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -93,33 +93,30 @@ pub fn pub_file_path(config_path: &Path) -> PathBuf { /// Resolve a default Unix-socket path under the canonical order: /// `/run/fips/` → `$XDG_RUNTIME_DIR/fips/` → `/tmp/fips-`. /// -/// `/run/fips` is the packaged convention (`root:fips 0770` directory created -/// by the daemon at bind time). `XDG_RUNTIME_DIR` covers non-root dev runs -/// where `/run/fips` does not exist or is not writable. `/tmp` is the -/// last-resort fallback. +/// `/run/fips` is the packaged convention (`root:fips 0770` directory +/// created by the daemon at bind time, or by the postinst script). +/// `XDG_RUNTIME_DIR` covers dev runs where `/run/fips` does not exist. +/// `/tmp` is the last-resort fallback. /// -/// Hardening notes: -/// - `/run/fips` is accepted only if the directory exists and is writable by -/// the current process. `create_dir_all` reporting `Ok(())` is *not* -/// sufficient: it returns `Ok` for an existing root-owned dir that we -/// cannot write to, which would silently steer a non-root daemon onto a -/// path that fails at bind time. Writability is probed via tempfile create -/// rather than mode bits so ACLs and group membership (the dir is -/// `root:fips 0770`) are honored. -/// - `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. +/// Selection is by *existence*, not writability. A fips-group member +/// whose shell session has not picked up the supplementary group (no +/// re-login after `usermod -aG fips`) cannot tempfile-probe a +/// `root:fips 0770` directory but can still connect to a socket inside +/// it once the kernel checks the actual group at `connect(2)` time — +/// and even where the user genuinely cannot connect, surfacing an +/// `EACCES` from the socket call is clearer than silently steering +/// fipstop / fipsctl to a path the daemon never bound. The daemon's +/// own bind code (`ControlSocket::bind`) creates `/run/fips` if it is +/// missing, so the resolver does not need to materialize the directory +/// 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)] pub(crate) fn resolve_default_socket(filename: &str) -> String { - // 1. /run/fips — accept only if the directory exists and is writable. - let run_fips = Path::new("/run/fips"); - 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) { + // 1. /run/fips — preferred whenever the directory exists. + if Path::new("/run/fips").is_dir() { return format!("/run/fips/{filename}"); } @@ -137,21 +134,6 @@ pub(crate) fn resolve_default_socket(filename: &str) -> String { 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. /// /// On Unix, delegates to [`resolve_default_socket`] for the canonical @@ -1547,8 +1529,11 @@ peers: #[cfg(unix)] #[test] fn test_resolve_default_socket_xdg_when_no_run_fips() { - // With /run/fips unwritable (non-root tests) and XDG_RUNTIME_DIR - // pointing at an existing directory, the resolver picks XDG. + // With /run/fips absent and XDG_RUNTIME_DIR pointing at an + // 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 temp_dir = TempDir::new().unwrap(); @@ -1584,7 +1569,9 @@ peers: #[test] fn test_resolve_default_socket_tmp_when_xdg_invalid() { // 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 prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok(); diff --git a/src/control/firewall_state.rs b/src/control/firewall_state.rs new file mode 100644 index 0000000..10f7c3d --- /dev/null +++ b/src/control/firewall_state.rs @@ -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>, +} + +#[derive(Debug, Clone)] +struct Rule { + /// All `match` expressions in order, plus a single terminal verdict. + matches: Vec, + 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), + /// `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 { + 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::(&output.stdout).ok() +} + +fn parse_inbound_rules(json: &Value) -> Vec { + 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 = 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 { + 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::>(), + }); + 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); + } +} diff --git a/src/control/listening.rs b/src/control/listening.rs new file mode 100644 index 0000000..6b00ac6 --- /dev/null +++ b/src/control/listening.rs @@ -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//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, + pub process: Option, + /// 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 { + use procfs::net::TcpState; + + let inode_to_pid = build_inode_to_pid_map(); + + let mut out: Vec = 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 { + 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, + fips0_addr: Ipv6Addr, +) -> Option { + 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//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 { + 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()); + } +} diff --git a/src/control/mod.rs b/src/control/mod.rs index 9a052e7..4e7a1e9 100644 --- a/src/control/mod.rs +++ b/src/control/mod.rs @@ -9,6 +9,8 @@ //! - Windows: Uses a TCP socket on localhost (see commit 3) pub mod commands; +pub mod firewall_state; +pub mod listening; pub mod protocol; pub mod queries; diff --git a/src/control/queries.rs b/src/control/queries.rs index b7d3575..2b0b699 100644 --- a/src/control/queries.rs +++ b/src/control/queries.rs @@ -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 = 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. pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::protocol::Response { 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_routing" => super::protocol::Response::ok(show_routing(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_history" => show_stats_history(node, params), "show_stats_all_history" => show_stats_all_history(node, params), @@ -1468,13 +1503,14 @@ mod tests { "show_transports", "show_routing", "show_identity_cache", + "show_listening_sockets", "show_stats_list", "show_stats_history", "show_stats_all_history", "show_stats_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(); for cmd in expected { // Each must dispatch successfully (status == "ok") with