mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +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:
+23
-5
@@ -187,6 +187,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
`fipsctl stats` subcommands, and a `fipstop` Graphs tab with
|
`fipsctl stats` subcommands, and a `fipstop` Graphs tab with
|
||||||
btop-style sparklines
|
btop-style sparklines
|
||||||
([#64](https://github.com/jmcorgan/fips/pull/64)).
|
([#64](https://github.com/jmcorgan/fips/pull/64)).
|
||||||
|
- `fipstop` Node tab now carries a "Listening on fips0" panel
|
||||||
|
(right-half of the Traffic block) that lists local IPv6 listening
|
||||||
|
sockets reachable from the mesh interface, paired with the
|
||||||
|
`inet fips` baseline filter classification for each (proto, port).
|
||||||
|
Rows render in default White (`OPEN` — the chain has a canonical
|
||||||
|
unrestricted accept rule), DarkGray (`filt` — chain falls through
|
||||||
|
to `counter drop`), or DarkGray with a `?` State suffix (`filt?` —
|
||||||
|
the chain references the port but with matchers the panel cannot
|
||||||
|
fully decompose, e.g. saddr filters or jumps). When the
|
||||||
|
`fips-firewall.service` is not active, the panel renders a yellow
|
||||||
|
banner reminding the operator that all listeners are
|
||||||
|
mesh-exposed. Wildcard binds (`local_addr == ::`) carry a `*`
|
||||||
|
suffix in the Process column. Powered by a new
|
||||||
|
`show_listening_sockets` control query (Linux-only).
|
||||||
|
|
||||||
#### Packaging and Deployment
|
#### Packaging and Deployment
|
||||||
|
|
||||||
@@ -321,11 +335,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
`fipstop` could connect to a socket the daemon never bound (notably
|
`fipstop` could connect to a socket the daemon never bound (notably
|
||||||
on dev runs with `XDG_RUNTIME_DIR` set, or after a prior packaged
|
on dev runs with `XDG_RUNTIME_DIR` set, or after a prior packaged
|
||||||
install left a root-owned `/run/fips` behind). Canonical order is
|
install left a root-owned `/run/fips` behind). Canonical order is
|
||||||
`/run/fips` → `$XDG_RUNTIME_DIR/fips/` → `/tmp/fips-<name>`, with
|
`/run/fips` → `$XDG_RUNTIME_DIR/fips/` → `/tmp/fips-<name>`. The
|
||||||
writability of `/run/fips` probed via tempfile create (ACL- and
|
`/run/fips` arm is selected by directory existence; the kernel
|
||||||
group-aware) and `XDG_RUNTIME_DIR` validated as an existing
|
enforces actual access at `connect(2)` time, surfacing a clear
|
||||||
directory before being used. The deployed fleet is unaffected:
|
`EACCES` for users not yet in the `fips` group rather than silently
|
||||||
packaged configs set `node.control.socket_path` explicitly.
|
steering them to a path the daemon never bound. `XDG_RUNTIME_DIR` is
|
||||||
|
validated as an existing directory before being used so stale
|
||||||
|
post-logout values are treated as missing. The deployed fleet is
|
||||||
|
unaffected: packaged configs set `node.control.socket_path`
|
||||||
|
explicitly.
|
||||||
- UDP transport with `advertise_on_nostr: true` + `public: true` +
|
- UDP transport with `advertise_on_nostr: true` + `public: true` +
|
||||||
a wildcard `bind_addr` (e.g. `0.0.0.0:2121`) is now advertised
|
a wildcard `bind_addr` (e.g. `0.0.0.0:2121`) is now advertised
|
||||||
with its STUN-discovered public IPv4 instead of being silently
|
with its STUN-discovered public IPv4 instead of being silently
|
||||||
|
|||||||
Generated
+22
@@ -1067,6 +1067,7 @@ dependencies = [
|
|||||||
"nostr",
|
"nostr",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
|
"procfs",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"rtnetlink",
|
"rtnetlink",
|
||||||
@@ -2365,6 +2366,27 @@ dependencies = [
|
|||||||
"yansi",
|
"yansi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "procfs"
|
||||||
|
version = "0.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"procfs-core",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "procfs-core"
|
||||||
|
version = "0.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"hex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ libc = "0.2"
|
|||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
rtnetlink = "0.21.0"
|
rtnetlink = "0.21.0"
|
||||||
rustables = "0.8.7"
|
rustables = "0.8.7"
|
||||||
|
procfs = { version = "0.18", default-features = false }
|
||||||
|
|
||||||
# bluer/BlueZ needs glibc — see build.rs `bluer_available` cfg gate.
|
# bluer/BlueZ needs glibc — see build.rs `bluer_available` cfg gate.
|
||||||
[target.'cfg(all(target_os = "linux", not(target_env = "musl")))'.dependencies]
|
[target.'cfg(all(target_os = "linux", not(target_env = "musl")))'.dependencies]
|
||||||
|
|||||||
@@ -113,6 +113,40 @@ ip6 saddr {
|
|||||||
Set syntax keeps multi-node rules readable and is more efficient than a
|
Set syntax keeps multi-node rules readable and is more efficient than a
|
||||||
chain of individual rules.
|
chain of individual rules.
|
||||||
|
|
||||||
|
## Verify with fipstop
|
||||||
|
|
||||||
|
`fipstop`'s Node tab carries a **Listening on fips0** panel
|
||||||
|
(right-half of the Traffic block) that pairs each local IPv6
|
||||||
|
listener with its current baseline-filter classification. After
|
||||||
|
adding or editing a drop-in and reloading, this is the fastest
|
||||||
|
way to confirm the rule landed correctly without manually
|
||||||
|
parsing `nft list table inet fips`.
|
||||||
|
|
||||||
|
| Panel state | Reading |
|
||||||
|
| ----------- | ------- |
|
||||||
|
| Service row in **default White** with `OPEN` in the State column | The chain has a canonical, unrestricted accept rule for this (proto, port). The service is reachable from any mesh node. |
|
||||||
|
| Service row in **DarkGray** with `filt` | No matching accept rule; the chain falls through to `counter drop`. The service is not reachable from the mesh. |
|
||||||
|
| Service row in **DarkGray** with `filt?` | A rule references the port but uses matchers the panel cannot fully decompose (saddr filter, jump, daddr filter). The intent is operator-defined; inspect with `sudo nft list table inet fips` to see the actual rule. |
|
||||||
|
| **Yellow banner** above the panel: "fips-firewall.service inactive — all listeners exposed" | The `inet fips` table is not loaded. Every listener is mesh-reachable (subject only to whatever ACL you have at the peer layer). |
|
||||||
|
|
||||||
|
A common workflow when extending the baseline is to keep `fipstop`
|
||||||
|
open on the Node tab in one terminal while editing
|
||||||
|
`/etc/fips/fips.d/` in another. After each
|
||||||
|
`sudo systemctl reload-or-restart fips-firewall.service`, the panel
|
||||||
|
re-classifies on the next poll tick and the affected row's State
|
||||||
|
column flips. A row staying `filt` after you expected `OPEN`
|
||||||
|
usually means the drop-in failed to load (syntax error in any file
|
||||||
|
under `/etc/fips/fips.d/` aborts the whole reload) or carries a
|
||||||
|
saddr filter that triggers `filt?` rather than `OPEN`.
|
||||||
|
|
||||||
|
The classifier is conservative: it recognizes only the canonical
|
||||||
|
unrestricted shapes (`tcp dport N accept`, `udp dport N accept`,
|
||||||
|
`dport { ... } accept`, `dport A-B accept`). Source-restricted
|
||||||
|
accepts intentionally render as `filt?` rather than `OPEN` —
|
||||||
|
the panel is a security screen, and any rule that varies by
|
||||||
|
source is an operator decision the panel will not silently bless
|
||||||
|
as fully open.
|
||||||
|
|
||||||
## Inspect drops
|
## Inspect drops
|
||||||
|
|
||||||
The baseline counter increments on every dropped packet. Inspect it:
|
The baseline counter increments on every dropped packet. Inspect it:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ query on its first activation and on every refresh tick while active.
|
|||||||
|
|
||||||
| Tab | Query | Shows |
|
| Tab | Query | Shows |
|
||||||
| --- | ----- | ----- |
|
| --- | ----- | ----- |
|
||||||
| **Node** | `show_status` | Identity, version, uptime, peer/link/session counts, sparklines for mesh size, tree depth, peer count, bytes, loss. |
|
| **Node** | `show_status` (+ `show_listening_sockets`) | Identity, version, uptime, peer/link/session counts, sparklines for mesh size, tree depth, peer count, bytes, loss. The Traffic block on this tab is split: TUN counters on the left, the **Listening on fips0** panel on the right (see below). |
|
||||||
| **Peers** | `show_peers` (+ `show_links`, `show_transports` cross-refs) | Authenticated peers in a table. Selecting a row and pressing Enter opens a detail view. |
|
| **Peers** | `show_peers` (+ `show_links`, `show_transports` cross-refs) | Authenticated peers in a table. Selecting a row and pressing Enter opens a detail view. |
|
||||||
| **Transports** | `show_transports` (+ `show_links`, `show_peers` cross-refs) | Tree of transport instances with per-link children when expanded. |
|
| **Transports** | `show_transports` (+ `show_links`, `show_peers` cross-refs) | Tree of transport instances with per-link children when expanded. |
|
||||||
| **Sessions** | `show_sessions` | End-to-end FSP sessions. |
|
| **Sessions** | `show_sessions` | End-to-end FSP sessions. |
|
||||||
@@ -52,6 +52,38 @@ Tree → Filters → Performance → Routing → Graphs → Gateway. The Links
|
|||||||
and Cache tabs are not in the cycle but are fetched as cross-references
|
and Cache tabs are not in the cycle but are fetched as cross-references
|
||||||
to populate Peers, Transports, and Routing detail views.
|
to populate Peers, Transports, and Routing detail views.
|
||||||
|
|
||||||
|
## Listening on fips0 panel (Node tab)
|
||||||
|
|
||||||
|
The right half of the Node tab's Traffic block lists local IPv6
|
||||||
|
listening sockets reachable from `fips0`, paired with the current
|
||||||
|
`inet fips` baseline filter classification for each (proto, port).
|
||||||
|
The panel exists to remind the operator which local services are
|
||||||
|
exposed to the mesh and which of those are admitted by the
|
||||||
|
default-deny firewall.
|
||||||
|
|
||||||
|
| Column | Meaning |
|
||||||
|
| ------ | ------- |
|
||||||
|
| **Proto** | `tcp` or `udp`. IPv4 listeners are not enumerated; `fips0` is IPv6-only. |
|
||||||
|
| **Port** | Listening port number. |
|
||||||
|
| **Process** | `comm(pid)` resolved by walking `/proc/<pid>/fd/`. A trailing `*` marks wildcard binds (`local_addr == ::`) — the bind is not fips0-specific, so the operator sees that the service is exposed across every interface, not just the mesh. |
|
||||||
|
| **State** | `OPEN` (default White) — the baseline filter has a canonical accept rule for this (proto, port). `filt` (DarkGray) — chain falls through to `counter drop`. `filt?` (DarkGray) — a rule references the port but uses matchers (saddr filter, jump, daddr) the panel cannot fully decompose; operator should `nft list table inet fips` to confirm. |
|
||||||
|
|
||||||
|
When `fips-firewall.service` is **not** active, the `inet fips`
|
||||||
|
table is absent. The panel renders every row in default White and
|
||||||
|
replaces the title with a yellow banner reading
|
||||||
|
"`Listening on fips0 fips-firewall.service inactive — all listeners exposed`".
|
||||||
|
|
||||||
|
The panel is read-only and unselectable. It refreshes on the same
|
||||||
|
poll tick as the rest of the Node tab. Sockets owned by other users
|
||||||
|
that the daemon could not resolve to a PID render as `?` in the
|
||||||
|
Process column; this only happens if the daemon itself is running
|
||||||
|
without root privileges (an unusual dev setup), since walking
|
||||||
|
`/proc/<pid>/fd/` for processes the daemon does not own requires
|
||||||
|
elevated capabilities.
|
||||||
|
|
||||||
|
The panel is Linux-only; on non-Linux daemons the query returns an
|
||||||
|
empty list and the panel hides.
|
||||||
|
|
||||||
## Keybindings
|
## Keybindings
|
||||||
|
|
||||||
### Global
|
### Global
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ table below lists every command currently registered.
|
|||||||
| `show_transports` | — | `transports[]` — `transport_id`, `type`, `state`, `mtu`, `name`, `local_addr`, optional `tor_mode`, `onion_address`, `tor_monitoring`, `stats`. |
|
| `show_transports` | — | `transports[]` — `transport_id`, `type`, `state`, `mtu`, `name`, `local_addr`, optional `tor_mode`, `onion_address`, `tor_monitoring`, `stats`. |
|
||||||
| `show_routing` | — | `coord_cache_entries`, `identity_cache_entries`, `pending_lookups[]`, `pending_tun_destinations`, `pending_tun_packets`, `recent_requests`, `retries[]`, `forwarding`, `discovery`, `error_signals`, `congestion`. |
|
| `show_routing` | — | `coord_cache_entries`, `identity_cache_entries`, `pending_lookups[]`, `pending_tun_destinations`, `pending_tun_packets`, `recent_requests`, `retries[]`, `forwarding`, `discovery`, `error_signals`, `congestion`. |
|
||||||
| `show_identity_cache` | — | `entries[]`, `count`, `max_entries`. Each entry: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `last_seen_ms`, `age_ms`. |
|
| `show_identity_cache` | — | `entries[]`, `count`, `max_entries`. Each entry: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `last_seen_ms`, `age_ms`. |
|
||||||
|
| `show_listening_sockets` | — | `fips0_addr`, `firewall_active` (bool — `inet fips` table loaded), `sockets[]`. Each entry: `proto` (`tcp` / `udp`), `local_addr` (`::` or the node's fd00::/8 address), `port`, `pid` (nullable), `process` (nullable), `wildcard_bind` (bool — `local_addr == ::`), `filter` (`accept` / `drop` / `unknown` / `no_firewall`). Linux-only; returns an empty `sockets[]` on other platforms. |
|
||||||
| `show_stats_list` | — | `metrics[]` (each with `name`, `unit`, `scope`), `fast_ring_seconds`, `slow_ring_minutes`, `peer_retention_seconds`. |
|
| `show_stats_list` | — | `metrics[]` (each with `name`, `unit`, `scope`), `fast_ring_seconds`, `slow_ring_minutes`, `peer_retention_seconds`. |
|
||||||
| `show_stats_history` | `metric` (req), `peer` (req for per-peer metrics), `window` (`<N>s` / `<N>m` / `<N>h`, default `10m`), `granularity` (`1s` / `1m`, default `1s`) | A single `Series`: `metric`, `unit`, `granularity_seconds`, `values[]`. |
|
| `show_stats_history` | `metric` (req), `peer` (req for per-peer metrics), `window` (`<N>s` / `<N>m` / `<N>h`, default `10m`), `granularity` (`1s` / `1m`, default `1s`) | A single `Series`: `metric`, `unit`, `granularity_seconds`, `values[]`. |
|
||||||
| `show_stats_all_history` | `peer` (optional npub), `window`, `granularity` | `granularity_seconds`, `window_seconds`, `peer`, `series[]` (one per metric). |
|
| `show_stats_all_history` | `peer` (optional npub), `window`, `granularity` | `granularity_seconds`, `window_seconds`, `peer`, `series[]` (one per metric). |
|
||||||
|
|||||||
@@ -189,6 +189,16 @@ server.
|
|||||||
> verified is reachability *from another mesh node*. That is
|
> verified is reachability *from another mesh node*. That is
|
||||||
> the next concern.
|
> the next concern.
|
||||||
|
|
||||||
|
If you have `fipstop` available, open it now in another terminal
|
||||||
|
and switch to the **Node** tab. The right-half of the Traffic
|
||||||
|
block — the **Listening on fips0** panel — should list a `tcp`
|
||||||
|
row at port 8080 with a `python(<pid>)` Process column. The State
|
||||||
|
column reads `OPEN` because the firewall has not been turned on
|
||||||
|
yet; everything bound to `fips0` is currently mesh-reachable. The
|
||||||
|
yellow banner above the panel says
|
||||||
|
"fips-firewall.service inactive — all listeners exposed". Both
|
||||||
|
signals will flip in the next two steps.
|
||||||
|
|
||||||
## Step 4: Reachability from a mesh node
|
## Step 4: Reachability from a mesh node
|
||||||
|
|
||||||
Any mesh node — a direct peer, or a node several hops away —
|
Any mesh node — a direct peer, or a node several hops away —
|
||||||
@@ -277,6 +287,13 @@ inbound on `fips0` hits the final `counter ... drop`.
|
|||||||
> TCP SYN dropped before it can reach your server. From the
|
> TCP SYN dropped before it can reach your server. From the
|
||||||
> remote end the connection times out.
|
> remote end the connection times out.
|
||||||
|
|
||||||
|
The fipstop panel reflects the change immediately: the yellow
|
||||||
|
"firewall inactive" banner disappears, the panel title becomes a
|
||||||
|
plain "Listening on fips0", and your `tcp 8080 python(<pid>)` row
|
||||||
|
flips to **DarkGray** with `filt` in the State column. Every
|
||||||
|
other row also goes DarkGray — none of them have an explicit
|
||||||
|
accept rule yet, and the chain falls through to `counter drop`.
|
||||||
|
|
||||||
So the firewall is in the right shape but in the wrong state
|
So the firewall is in the right shape but in the wrong state
|
||||||
for our purpose: we *want* mesh nodes to reach port 8080. The
|
for our purpose: we *want* mesh nodes to reach port 8080. The
|
||||||
next step opens that one port.
|
next step opens that one port.
|
||||||
@@ -317,6 +334,18 @@ mesh → your direct peer's link to you → `fips0` ingress →
|
|||||||
`inbound` chain → matches `tcp dport 8080 accept` → delivered
|
`inbound` chain → matches `tcp dport 8080 accept` → delivered
|
||||||
to the HTTP server.
|
to the HTTP server.
|
||||||
|
|
||||||
|
In the fipstop panel, your `tcp 8080 python(<pid>)` row flips
|
||||||
|
back to **default White** with `OPEN` in the State column on the
|
||||||
|
next poll tick. No other row changes — they remain DarkGray
|
||||||
|
`filt` because you have only opened this one port. The panel
|
||||||
|
doubles as a security screen for the rest of the tutorial: any
|
||||||
|
service whose row reads `OPEN` is mesh-reachable, anything
|
||||||
|
DarkGray is filtered. If you later add a saddr-restricted
|
||||||
|
drop-in (covered just below), the row will land at `filt?`
|
||||||
|
rather than `OPEN`, signalling that the rule exists but is
|
||||||
|
source-scoped — the panel deliberately does not classify
|
||||||
|
restricted accepts as fully open.
|
||||||
|
|
||||||
If you only want to expose the service to a *specific* node
|
If you only want to expose the service to a *specific* node
|
||||||
or set of nodes, source-filter the rule. The address filter
|
or set of nodes, source-filter the rule. The address filter
|
||||||
applies to the mesh-source address as it arrives on `fips0`,
|
applies to the mesh-source address as it arrives on `fips0`,
|
||||||
@@ -386,7 +415,10 @@ sudo systemctl disable --now fips-firewall.service
|
|||||||
opts in to one audience; binding to wildcard
|
opts in to one audience; binding to wildcard
|
||||||
(`0.0.0.0` / `[::]`) opts in to *all* of them, including
|
(`0.0.0.0` / `[::]`) opts in to *all* of them, including
|
||||||
ones you forgot you had. For mesh-only exposure, bind to
|
ones you forgot you had. For mesh-only exposure, bind to
|
||||||
your `fd97:...` address.
|
your `fd97:...` address. The fipstop **Listening on fips0**
|
||||||
|
panel marks wildcard binds with a trailing `*` after the
|
||||||
|
process name as a reminder that the bind is not
|
||||||
|
fips0-specific.
|
||||||
- **Same-host loopback is misleading.** A local curl to your
|
- **Same-host loopback is misleading.** A local curl to your
|
||||||
own `fd97:...` address goes via the loopback path, not
|
own `fd97:...` address goes via the loopback path, not
|
||||||
through `fips0` ingress. To actually verify mesh-side
|
through `fips0` ingress. To actually verify mesh-side
|
||||||
@@ -442,6 +474,14 @@ its own port — same `--bind` rule, same drop-in shape.
|
|||||||
mesh node attempts to reach a port you have not opened,
|
mesh node attempts to reach a port you have not opened,
|
||||||
`sudo nft list table inet fips` will show the counter
|
`sudo nft list table inet fips` will show the counter
|
||||||
packet count rising.
|
packet count rising.
|
||||||
|
- **Use fipstop to spot-check listener and filter state.** The
|
||||||
|
Listening on fips0 panel on the Node tab shows every
|
||||||
|
fips0-reachable listener and its current filter state. A row
|
||||||
|
staying `filt` after you expected `OPEN` usually means the
|
||||||
|
drop-in failed to load (a syntax error in any file under
|
||||||
|
`/etc/fips/fips.d/` aborts the whole reload, leaving the
|
||||||
|
previous ruleset in place) or the drop-in carries a source
|
||||||
|
filter and now reads `filt?` rather than `OPEN`.
|
||||||
|
|
||||||
## What's next
|
## What's next
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,9 @@ pub struct App {
|
|||||||
pub gateway_running: bool,
|
pub gateway_running: bool,
|
||||||
/// Mappings data fetched from the gateway (separate from summary).
|
/// Mappings data fetched from the gateway (separate from summary).
|
||||||
pub gateway_mappings: Option<serde_json::Value>,
|
pub gateway_mappings: Option<serde_json::Value>,
|
||||||
|
/// `show_listening_sockets` payload for the Node-tab "Listening on
|
||||||
|
/// fips0" panel; refreshed each tick alongside `show_status`.
|
||||||
|
pub listening_sockets: Option<serde_json::Value>,
|
||||||
/// Scroll offset (rows) for the stacked Graphs tab.
|
/// Scroll offset (rows) for the stacked Graphs tab.
|
||||||
pub graphs_scroll: u16,
|
pub graphs_scroll: u16,
|
||||||
/// Selected (window, granularity) index for the Graphs tab.
|
/// Selected (window, granularity) index for the Graphs tab.
|
||||||
@@ -243,6 +246,7 @@ impl App {
|
|||||||
selected_tree_item: SelectedTreeItem::None,
|
selected_tree_item: SelectedTreeItem::None,
|
||||||
gateway_running: false,
|
gateway_running: false,
|
||||||
gateway_mappings: None,
|
gateway_mappings: None,
|
||||||
|
listening_sockets: None,
|
||||||
graphs_scroll: 0,
|
graphs_scroll: 0,
|
||||||
graphs_window_idx: 1, // default 10m
|
graphs_window_idx: 1, // default 10m
|
||||||
graphs_mode: GraphsMode::Node,
|
graphs_mode: GraphsMode::Node,
|
||||||
|
|||||||
@@ -65,6 +65,17 @@ fn fetch_data(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Listening-on-fips0 panel — fetched only while the Node tab is
|
||||||
|
// active (it's the only place the data is rendered). Errors are
|
||||||
|
// non-fatal: an old daemon without the query just leaves the
|
||||||
|
// payload at None and the panel hides.
|
||||||
|
if app.active_tab == Tab::Node {
|
||||||
|
match rt.block_on(client.query("show_listening_sockets")) {
|
||||||
|
Ok(data) => app.listening_sockets = Some(data),
|
||||||
|
Err(_) => app.listening_sockets = None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Gateway tab uses a separate socket
|
// Gateway tab uses a separate socket
|
||||||
if app.active_tab == Tab::Gateway {
|
if app.active_tab == Tab::Gateway {
|
||||||
match rt.block_on(gateway_client.query("show_gateway")) {
|
match rt.block_on(gateway_client.query("show_gateway")) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use ratatui::widgets::{Block, Borders, Paragraph};
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
|
||||||
use super::helpers;
|
use super::helpers;
|
||||||
|
use super::listening;
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let data = match app.data.get(&crate::app::Tab::Node) {
|
let data = match app.data.get(&crate::app::Tab::Node) {
|
||||||
@@ -23,7 +24,7 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
Constraint::Length(7), // Runtime
|
Constraint::Length(7), // Runtime
|
||||||
Constraint::Length(7), // Identity
|
Constraint::Length(7), // Identity
|
||||||
Constraint::Length(6), // State (sparkline row adds one line)
|
Constraint::Length(6), // State (sparkline row adds one line)
|
||||||
Constraint::Length(9), // Traffic
|
Constraint::Length(9), // Traffic + Listening on fips0 (side-by-side)
|
||||||
Constraint::Min(0), // remaining
|
Constraint::Min(0), // remaining
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
@@ -31,7 +32,15 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
draw_runtime(frame, data, chunks[0]);
|
draw_runtime(frame, data, chunks[0]);
|
||||||
draw_identity(frame, data, chunks[1]);
|
draw_identity(frame, data, chunks[1]);
|
||||||
draw_state(frame, data, chunks[2]);
|
draw_state(frame, data, chunks[2]);
|
||||||
draw_node_stats(frame, data, chunks[3]);
|
|
||||||
|
// Traffic on the left, listening-on-fips0 on the right. The split
|
||||||
|
// is 50/50 with a sane minimum width for each half so very narrow
|
||||||
|
// terminals still produce readable columns.
|
||||||
|
let traffic_chunks =
|
||||||
|
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
|
.split(chunks[3]);
|
||||||
|
draw_node_stats(frame, data, traffic_chunks[0]);
|
||||||
|
listening::draw(frame, app.listening_sockets.as_ref(), traffic_chunks[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_runtime(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
fn draw_runtime(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||||
|
|||||||
@@ -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 gateway;
|
||||||
mod graphs;
|
mod graphs;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
|
pub(crate) mod listening;
|
||||||
mod mmp;
|
mod mmp;
|
||||||
mod peers;
|
mod peers;
|
||||||
mod routing;
|
mod routing;
|
||||||
|
|||||||
+29
-42
@@ -93,33 +93,30 @@ pub fn pub_file_path(config_path: &Path) -> PathBuf {
|
|||||||
/// Resolve a default Unix-socket path under the canonical order:
|
/// Resolve a default Unix-socket path under the canonical order:
|
||||||
/// `/run/fips/<filename>` → `$XDG_RUNTIME_DIR/fips/<filename>` → `/tmp/fips-<filename>`.
|
/// `/run/fips/<filename>` → `$XDG_RUNTIME_DIR/fips/<filename>` → `/tmp/fips-<filename>`.
|
||||||
///
|
///
|
||||||
/// `/run/fips` is the packaged convention (`root:fips 0770` directory created
|
/// `/run/fips` is the packaged convention (`root:fips 0770` directory
|
||||||
/// by the daemon at bind time). `XDG_RUNTIME_DIR` covers non-root dev runs
|
/// created by the daemon at bind time, or by the postinst script).
|
||||||
/// where `/run/fips` does not exist or is not writable. `/tmp` is the
|
/// `XDG_RUNTIME_DIR` covers dev runs where `/run/fips` does not exist.
|
||||||
/// last-resort fallback.
|
/// `/tmp` is the last-resort fallback.
|
||||||
///
|
///
|
||||||
/// Hardening notes:
|
/// Selection is by *existence*, not writability. A fips-group member
|
||||||
/// - `/run/fips` is accepted only if the directory exists and is writable by
|
/// whose shell session has not picked up the supplementary group (no
|
||||||
/// the current process. `create_dir_all` reporting `Ok(())` is *not*
|
/// re-login after `usermod -aG fips`) cannot tempfile-probe a
|
||||||
/// sufficient: it returns `Ok` for an existing root-owned dir that we
|
/// `root:fips 0770` directory but can still connect to a socket inside
|
||||||
/// cannot write to, which would silently steer a non-root daemon onto a
|
/// it once the kernel checks the actual group at `connect(2)` time —
|
||||||
/// path that fails at bind time. Writability is probed via tempfile create
|
/// and even where the user genuinely cannot connect, surfacing an
|
||||||
/// rather than mode bits so ACLs and group membership (the dir is
|
/// `EACCES` from the socket call is clearer than silently steering
|
||||||
/// `root:fips 0770`) are honored.
|
/// fipstop / fipsctl to a path the daemon never bound. The daemon's
|
||||||
/// - `XDG_RUNTIME_DIR` is validated as an existing directory before being
|
/// own bind code (`ControlSocket::bind`) creates `/run/fips` if it is
|
||||||
/// used; a stale post-logout value (after `pam_systemd` reaps the dir) is
|
/// missing, so the resolver does not need to materialize the directory
|
||||||
/// treated as missing.
|
/// itself.
|
||||||
|
///
|
||||||
|
/// `XDG_RUNTIME_DIR` is validated as an existing directory before being
|
||||||
|
/// used; a stale post-logout value (after `pam_systemd` reaps the dir)
|
||||||
|
/// is treated as missing.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub(crate) fn resolve_default_socket(filename: &str) -> String {
|
pub(crate) fn resolve_default_socket(filename: &str) -> String {
|
||||||
// 1. /run/fips — accept only if the directory exists and is writable.
|
// 1. /run/fips — preferred whenever the directory exists.
|
||||||
let run_fips = Path::new("/run/fips");
|
if Path::new("/run/fips").is_dir() {
|
||||||
if run_fips.is_dir() && is_writable_dir(run_fips) {
|
|
||||||
return format!("/run/fips/{filename}");
|
|
||||||
}
|
|
||||||
// Also accept /run/fips if we can create it (covers the first-boot
|
|
||||||
// daemon-as-root case before the directory has been materialized). The
|
|
||||||
// actual chown happens at bind time.
|
|
||||||
if std::fs::create_dir_all(run_fips).is_ok() && is_writable_dir(run_fips) {
|
|
||||||
return format!("/run/fips/{filename}");
|
return format!("/run/fips/{filename}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,21 +134,6 @@ pub(crate) fn resolve_default_socket(filename: &str) -> String {
|
|||||||
format!("/tmp/fips-{filename}")
|
format!("/tmp/fips-{filename}")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn is_writable_dir(path: &Path) -> bool {
|
|
||||||
// Probe via tempfile creation rather than mode bits: mode-bit checks miss
|
|
||||||
// ACLs and group-membership effects (the /run/fips dir is `root:fips
|
|
||||||
// 0770` and the daemon may run as a user that's in the `fips` group).
|
|
||||||
let probe = path.join(format!(".fips-write-probe-{}", std::process::id()));
|
|
||||||
match std::fs::File::create(&probe) {
|
|
||||||
Ok(_) => {
|
|
||||||
let _ = std::fs::remove_file(&probe);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default control socket path for fipsctl / fipstop.
|
/// Default control socket path for fipsctl / fipstop.
|
||||||
///
|
///
|
||||||
/// On Unix, delegates to [`resolve_default_socket`] for the canonical
|
/// On Unix, delegates to [`resolve_default_socket`] for the canonical
|
||||||
@@ -1547,8 +1529,11 @@ peers:
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_default_socket_xdg_when_no_run_fips() {
|
fn test_resolve_default_socket_xdg_when_no_run_fips() {
|
||||||
// With /run/fips unwritable (non-root tests) and XDG_RUNTIME_DIR
|
// With /run/fips absent and XDG_RUNTIME_DIR pointing at an
|
||||||
// pointing at an existing directory, the resolver picks XDG.
|
// existing directory, the resolver picks XDG. On test hosts where
|
||||||
|
// /run/fips happens to exist (a real fips deployment), the
|
||||||
|
// resolver legitimately picks /run/fips and skips XDG entirely;
|
||||||
|
// both outcomes are accepted below.
|
||||||
let _g = ENV_MUTEX.lock().unwrap();
|
let _g = ENV_MUTEX.lock().unwrap();
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
@@ -1584,7 +1569,9 @@ peers:
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_default_socket_tmp_when_xdg_invalid() {
|
fn test_resolve_default_socket_tmp_when_xdg_invalid() {
|
||||||
// With XDG_RUNTIME_DIR pointing at a non-existent directory and
|
// With XDG_RUNTIME_DIR pointing at a non-existent directory and
|
||||||
// /run/fips unwritable, the resolver falls through to /tmp.
|
// /run/fips absent, the resolver falls through to /tmp. On hosts
|
||||||
|
// where /run/fips exists, the resolver legitimately picks it
|
||||||
|
// first; both outcomes are accepted below.
|
||||||
let _g = ENV_MUTEX.lock().unwrap();
|
let _g = ENV_MUTEX.lock().unwrap();
|
||||||
|
|
||||||
let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
|
let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
|
||||||
|
|||||||
@@ -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)
|
//! - Windows: Uses a TCP socket on localhost (see commit 3)
|
||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
pub mod firewall_state;
|
||||||
|
pub mod listening;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
|
||||||
|
|||||||
+37
-1
@@ -1104,6 +1104,40 @@ pub fn show_stats_history_all_peers(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `show_listening_sockets` — IPv6 listeners reachable from fips0,
|
||||||
|
/// each annotated with its current `inet fips` filter classification.
|
||||||
|
///
|
||||||
|
/// Powers the fipstop "Listening on fips0" panel. See
|
||||||
|
/// [`crate::control::listening`] and [`crate::control::firewall_state`]
|
||||||
|
/// for the per-half implementations.
|
||||||
|
pub fn show_listening_sockets(node: &Node) -> Value {
|
||||||
|
let fips0 = crate::FipsAddress::from_node_addr(node.identity().node_addr()).to_ipv6();
|
||||||
|
let sockets = super::listening::enumerate(fips0);
|
||||||
|
let classifier = super::firewall_state::FilterClassifier::query();
|
||||||
|
|
||||||
|
let rows: Vec<Value> = sockets
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let filter = classifier.classify(s.proto, s.port);
|
||||||
|
json!({
|
||||||
|
"proto": s.proto.as_str(),
|
||||||
|
"local_addr": s.local_addr.to_string(),
|
||||||
|
"port": s.port,
|
||||||
|
"pid": s.pid,
|
||||||
|
"process": s.process,
|
||||||
|
"filter": filter.as_str(),
|
||||||
|
"wildcard_bind": s.wildcard_bind,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"fips0_addr": fips0.to_string(),
|
||||||
|
"firewall_active": classifier.is_active(),
|
||||||
|
"sockets": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Dispatch a command string to the appropriate query function.
|
/// Dispatch a command string to the appropriate query function.
|
||||||
pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::protocol::Response {
|
pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::protocol::Response {
|
||||||
match command {
|
match command {
|
||||||
@@ -1120,6 +1154,7 @@ pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::pr
|
|||||||
"show_transports" => super::protocol::Response::ok(show_transports(node)),
|
"show_transports" => super::protocol::Response::ok(show_transports(node)),
|
||||||
"show_routing" => super::protocol::Response::ok(show_routing(node)),
|
"show_routing" => super::protocol::Response::ok(show_routing(node)),
|
||||||
"show_identity_cache" => super::protocol::Response::ok(show_identity_cache(node)),
|
"show_identity_cache" => super::protocol::Response::ok(show_identity_cache(node)),
|
||||||
|
"show_listening_sockets" => super::protocol::Response::ok(show_listening_sockets(node)),
|
||||||
"show_stats_list" => super::protocol::Response::ok(show_stats_list()),
|
"show_stats_list" => super::protocol::Response::ok(show_stats_list()),
|
||||||
"show_stats_history" => show_stats_history(node, params),
|
"show_stats_history" => show_stats_history(node, params),
|
||||||
"show_stats_all_history" => show_stats_all_history(node, params),
|
"show_stats_all_history" => show_stats_all_history(node, params),
|
||||||
@@ -1468,13 +1503,14 @@ mod tests {
|
|||||||
"show_transports",
|
"show_transports",
|
||||||
"show_routing",
|
"show_routing",
|
||||||
"show_identity_cache",
|
"show_identity_cache",
|
||||||
|
"show_listening_sockets",
|
||||||
"show_stats_list",
|
"show_stats_list",
|
||||||
"show_stats_history",
|
"show_stats_history",
|
||||||
"show_stats_all_history",
|
"show_stats_all_history",
|
||||||
"show_stats_peers",
|
"show_stats_peers",
|
||||||
"show_stats_history_all_peers",
|
"show_stats_history_all_peers",
|
||||||
];
|
];
|
||||||
assert_eq!(expected.len(), 18, "expected exactly 18 query handlers");
|
assert_eq!(expected.len(), 19, "expected exactly 19 query handlers");
|
||||||
let node = build_test_node();
|
let node = build_test_node();
|
||||||
for cmd in expected {
|
for cmd in expected {
|
||||||
// Each must dispatch successfully (status == "ok") with
|
// Each must dispatch successfully (status == "ok") with
|
||||||
|
|||||||
Reference in New Issue
Block a user