mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 16:24:45 +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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
+37
-1
@@ -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.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user