mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 08:14:42 +00:00
fipstop: add "Listening on fips0" panel to Node tab
Surfaces local services reachable from the mesh, paired with their
current `inet fips` baseline filter classification. Lands to the
right of the existing TUN section in the Traffic block.
A new daemon control query `show_listening_sockets` returns IPv6
listeners bound to either `::` (wildcard) or the node's fd00::/8
address, each classified as Accept / Drop / Unknown / NoFirewall
against the running inbound chain. fipstop renders the result as a
table beside the Traffic counters: Accept rows in default White,
Drop / Unknown in DarkGray, a yellow banner above the table when
`fips-firewall.service` is inactive, and a trailing `*` on
wildcard binds to remind the operator the bind is not
fips0-specific.
Daemon side:
- `src/control/listening.rs` walks `/proc/net/tcp6` and
`/proc/net/udp6` via the procfs crate (LISTEN state for TCP,
wildcard remote for UDP), filters to fips0-reachable binds, and
resolves inodes to PID / comm via `/proc/<pid>/fd`.
- `src/control/firewall_state.rs` shells out to
`nft -j list table inet fips` and walks the inbound chain.
Recognises canonical accepts (`tcp/udp dport N accept`,
`dport { ... } accept`, `dport A-B accept`), the iifname-scoping
line, conntrack and icmpv6 lines (skipped). Any rule with
unrecognised matchers (saddr filters, jumps, daddr filters) or
non-terminal verdicts forces Unknown classification for the
ports it references. Eleven unit tests cover the classification
logic; the listening enumerator carries a /proc-parsing test of
its own.
- `show_listening_sockets` emits
`{fips0_addr, firewall_active, sockets[]}` with per-row
`{proto, local_addr, port, pid, process, filter, wildcard_bind}`.
fipstop side:
- `src/bin/fipstop/ui/dashboard.rs` splits the Traffic block into
a 50/50 horizontal layout; the existing TUN + Forwarded panel
occupies the left half.
- `src/bin/fipstop/ui/listening.rs` renders the right half.
- `main.rs` fetches the new query each tick when the Node tab is
active. Errors are non-fatal: an old daemon without the query
leaves the payload at None and the panel renders "loading...".
`Cargo.toml` gains `procfs = "0.18"` on the Linux target. IPv4
listeners are not enumerated — fips0 is IPv6-only.
Folded in: revert the default-socket lookup from writability-probe
back to existence-based selection. The previous tempfile-probe on
`/run/fips` silently steered fipstop / fipsctl onto an XDG path
the daemon never bound for any user in the `fips` group whose
shell session had not yet picked up the supplementary group (no
re-login after `usermod -aG`). `XDG_RUNTIME_DIR` is set on every
modern systemd-managed user session, so this hit the common case.
The kernel checks actual group membership at `connect(2)`, so a
user who genuinely cannot connect now gets a clear `EACCES`
rather than a silent path mismatch. Drops the now-unused
`is_writable_dir` helper. `XDG_RUNTIME_DIR` existence validation
is preserved.
Documentation:
- `docs/reference/cli-fipstop.md` — Node-tab row updated, new
"Listening on fips0 panel" section.
- `docs/reference/control-socket.md` — `show_listening_sockets`
added to the read-only queries table.
- `docs/how-to/enable-mesh-firewall.md` — new "Verify with
fipstop" section.
- `docs/tutorials/host-a-service.md` — fipstop callouts at
Steps 3, 5, 6 + Troubleshooting bullet + wildcard-bind reminder
under "What you've learned".
- `CHANGELOG.md` — new bullet under `Added / Operator Tooling`,
resolver `Fixed` entry rewritten to describe the
existence-based final shape.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod dashboard;
|
||||
mod gateway;
|
||||
mod graphs;
|
||||
mod helpers;
|
||||
pub(crate) mod listening;
|
||||
mod mmp;
|
||||
mod peers;
|
||||
mod routing;
|
||||
|
||||
Reference in New Issue
Block a user