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
+4
View File
@@ -210,6 +210,9 @@ pub struct App {
pub gateway_running: bool,
/// Mappings data fetched from the gateway (separate from summary).
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.
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,
+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
if app.active_tab == Tab::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 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) {
+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 graphs;
mod helpers;
pub(crate) mod listening;
mod mmp;
mod peers;
mod routing;