fipsctl probe <npub|hostname> answers, for one target, where it sits in the spanning tree relative to us and whether we can actually reach it. It reports our coordinates, the target's, the walk between them and the next hop we would select, then opens an FSP session, waits for one MMP receiver report to yield a round-trip time, and tears down what it opened. Nothing here changes the wire format. The probe is built entirely from messages that already exist, and the control socket carries the new request triplet. The work runs as five stages that report separately: bloom, discovery, path, session and rtt. One verdict covering several findings is what makes an operator read source, and the distinctions are real ones. "No peer's filter claims this address" says the mesh has never heard of the target; "a filter claimed it and nothing answered" says the opposite, that somebody believes the address is reachable and the lookup went unanswered anyway. Bloom emits the lookup and settles on the gate's answer, where a miss, a backoff suppression or a zero fanout ends the probe; discovery waits for the coordinates and owns the ladder timeout. "Lookup never resolved" and "resolved but the handshake never completed" part the same way further down. Each stage keeps the reasons it owns, so no discriminator is lost and none sits on a stage that cannot produce it. The path is computed from coordinates, not observed. The output says so in those words: nothing traverses the mesh to confirm the hops, and a route display that reads like traceroute output would be believed as one. A real per-hop trace needs a new wire message, so it is not on this branch. The probe is a daemon-side job advanced on the tick, not a blocking control call. The control socket has a five second timeout and its dispatch is awaited inline in the rx loop, so a handler that waits for a handshake would stall the data plane. Start, poll and cancel each return immediately and fipsctl hides the polling. The job is stepped once at the end of admission rather than left for the next tick, which admission can do because the probe commands take the command path and therefore already run on the rx loop; otherwise every probe spent up to a full tick period of its own budget before a single message left the node, which against a one-second tick meant the first three polls of an already-cached target showed nothing happening. It cleans up after itself, and that is the part built to be defended rather than assumed. A session that existed before the probe started is never torn down, ownership is decided at the moment of action rather than once at the beginning, re-checked before teardown, and dropped if our entry is replaced or adopted by traffic underneath us. Removing the ownership guard reds eleven tests. The client renders each poll rather than waiting for the end. The daemon was already progressive, returning the whole report on every poll with each stage carrying its own verdict as it reaches one, so a client that waited for `state == "done"` made a probe spending seventeen seconds in a lookup ladder look identical to one that was hung. On a terminal the stage block is redrawn in place with a spinner and a running elapsed on whichever stage is working. Piped or redirected there is no cursor to move, so each row prints once, at the moment it settles, and the transcript ends up the same block a terminal leaves behind. `--json` is untouched and still emits exactly one document at the end, so a script parsing the report does not have to skip past progress output. Four things the rendering has to get right, none of them automatic: - A running stage may only report what the daemon has observed, and must never preview an outcome. Every settled text keys on `reason`, which is null while a stage runs, so the success arm renders for a stage that has not succeeded and a running session row would claim the handshake completed. - The elapsed column comes from the daemon's clock throughout, the running stage's figure being the report's elapsed less the stages already accounted for, so the numbers a viewer watches are the ones the final report prints. - A frame shorter than the last one blanks the rows it no longer covers and walks the cursor back over them, or the previous frame's tail stays on screen under a report that has stopped mentioning it. - The discovery ladder is read from the report rather than assumed, since it is configuration and a node may not be using the default. One line per request sent, with the timeout that attempt was given and whether it drew a reply, the last animating while it is in flight. Below the block, the tree walk is one line: self, up through the least common ancestor, down to the target, with the ancestor emphasised on a terminal and left plain in a pipe or a file. Naming the ancestor alone left the reader to assemble the route from it and the two coordinate lines above. Where the target is itself the ancestor there is no descent and the line ends on the emphasised address. Stages that were never attempted print no row. A failure marks everything behind it not reached, and saying that three more times adds nothing to the failed row that already said it. The rule keys on `not_reached` rather than on the position of the failure, because those are not the same set: a failed path stage does not stop the probe, since the preview touches nothing and the session can still succeed where it named no next hop, so the rows behind that one describe work that really happened. A skip keeps its row for the same reason, being a result naming why a stage was unnecessary rather than an absence. A probe that fails before the path stage prints no path section, which had been restating the failure as "no coords" and "no next hop". Two counts the discovery stage gets right that are easy to get wrong. It marks itself running while it waits, where publishing `pending` throughout would read to a poller as a stage that has not started. And the first attempt is counted when the request is sent rather than when the pending table is next observed, since a lookup answered inside one tick never appears in that table and the fastest case would report no attempts at all. Two honest gaps: the HopNotSendReady branch is not reached by any test, and the concurrent-probe cap counts only unfinished jobs without a test covering that filter. Adds 63 tests across 32 files. One changelog entry under Added, describing the released state: the five stages and why they are separate, the session the probe opens and the one it must not tear down, the path being computed rather than observed, the three control commands and why they cannot block, and the two rendering modes. It says in as many words that the wire format is unchanged.
15 KiB
fipsctl
Command-line client for the FIPS daemon's control socket.
Synopsis
fipsctl [-s SOCKET] <subcommand> [args...]
Description
fipsctl connects to a running daemon over its control socket
(Unix domain socket on Linux/macOS, TCP loopback on Windows), sends
one JSON request, and pretty-prints the response. Exits with a
non-zero status if the socket cannot be reached, the daemon returns an
error, or the request times out.
fipsctl keygen is a special case: it does not contact the daemon and
operates purely on local files.
For the line-delimited JSON wire protocol, see control-socket.md. For the YAML configuration that defines the socket location, see configuration.md.
Global Options
| Flag | Argument | Description |
|---|---|---|
-s, --socket |
PATH |
Override the control-socket path (Linux/macOS) or TCP port (Windows). |
-V, --version |
— | Print the short version. |
--version |
— | Print the long version. |
-h, --help |
— | Print usage and exit. Per-subcommand help via fipsctl <subcommand> --help. |
Subcommands
show <what>
Read-only queries against the daemon. Each subcommand maps 1:1 to a
control-socket query (see control-socket.md) and
prints the response's data object as pretty JSON.
| Subcommand | Control-socket command | Returns |
|---|---|---|
show status |
show_status |
Node-level status: identity, version, peer/link/session counts, TUN state, recent sparklines. |
show peers |
show_peers |
Authenticated peer list with link IDs, transport addresses, MMP metrics, Noise/rekey state. |
show links |
show_links |
Active links (one per FMP-authenticated peer): direction, state, byte counters. |
show tree |
show_tree |
Spanning-tree state: root, my coordinates, parent, peer declarations. |
show sessions |
show_sessions |
End-to-end FSP sessions: state, traffic counters, session-MMP metrics, path MTU. |
show bloom |
show_bloom |
Bloom-filter state: own filter sequence, leaf dependents, per-peer filter summaries. |
show mmp |
show_mmp |
MMP metrics summary: per-peer link-layer metrics and per-session session-layer metrics. |
show cache |
show_cache |
Coordinate cache: TTL, fill ratio, per-destination coords and path MTU. |
show connections |
show_connections |
Pending handshake connections: state, idle time, resend count. |
show transports |
show_transports |
Transport instances: type, state, MTU, local address, per-transport stats. |
show routing |
show_routing |
Routing summary: pending lookups, retry state, forwarding/discovery/error/congestion counters. |
show identity-cache |
show_identity_cache |
Cached (node_addr → npub) entries with last-seen timestamps. |
acl <what>
| Subcommand | Control-socket command | Returns |
|---|---|---|
acl show |
show_acl |
Loaded peer-ACL state: allow/deny files, effective mode, default decision, entry counts. |
stats <what>
Time-series metrics from the in-process history rings.
| Subcommand | Control-socket command | Description |
|---|---|---|
stats list |
show_stats_list |
Enumerate available metrics, their units, and the per-ring retention windows. |
stats metrics |
show_metrics |
Dump current counter values for every protocol metric family (forwarding, discovery, tree, bloom, congestion, errors). |
stats peers |
show_stats_peers |
List peers tracked in stats history (active or recently active). |
stats history <metric> [options] |
show_stats_history |
Fetch a time-series window for one metric. |
stats history options:
| Flag | Argument | Default | Description |
|---|---|---|---|
--peer |
npub or hostname |
(none) | Required for per-peer metrics; resolves through /etc/fips/hosts if not an npub. |
--window |
<N>s / <N>m / <N>h |
10m |
Window duration. |
--granularity |
1s or 1m |
1s |
Ring resolution. 1s uses the fast ring; 1m uses the slow ring. |
--plot |
— | off | Render a Unicode-block sparkline to stdout instead of JSON. |
keygen [options]
Generate a new FIPS identity keypair locally. Does not contact the daemon.
| Flag | Argument | Default | Description |
|---|---|---|---|
-d, --dir |
DIR |
/usr/local/etc/fips (macOS), /etc/fips (other Unix), %APPDATA%\fips (Windows) |
Output directory for fips.key and fips.pub. Matches the directory the platform's packaging installs config into, which is where the daemon derives the key paths from. |
-f, --force |
— | off | Overwrite an existing fips.key. |
-s, --stdout |
— | off | Print nsec then npub to stdout instead of writing files. |
fips.key is written with mode 0600 and fips.pub with mode 0644
on Unix. After running keygen, set node.identity.persistent: true
in fips.yaml or the daemon will overwrite the keys on next start.
connect <peer> <address> <transport>
Tell the daemon to dial a peer over a specific transport.
| Argument | Description |
|---|---|
peer |
npub (bech32) or hostname from /etc/fips/hosts. |
address |
Transport endpoint, e.g. 192.168.1.10:2121, [2001:db8::1]:2121, or a Tor onion. FIPS-mesh ULAs (fd00::/8) are rejected for the IP-based transports (udp, tcp, ethernet). |
transport |
One of udp, tcp, tor, nym, ethernet. The named transport must be configured and running. |
disconnect <peer>
Tell the daemon to drop a peer link.
| Argument | Description |
|---|---|
peer |
npub (bech32) or hostname from /etc/fips/hosts. |
probe <target>
Diagnose whether a mesh endpoint is reachable, in five stages, and report a per-stage verdict so a partial failure localizes itself.
| Argument | Description |
|---|---|
target |
npub (bech32) or hostname from /etc/fips/hosts. |
--json |
Emit the report as JSON instead of human-readable text. |
--timeout <secs> |
Client-side ceiling. Defaults to the budget the daemon computed, which scales with its tick interval. |
The stages are:
- bloom — does any peer's announced filter claim the target, and did a LookupRequest therefore go out? A miss ends the probe here and is a statement about the mesh's own knowledge: nobody has heard of this address. Skipped when the coordinates are already cached, and when the target is a directly connected peer.
- discovery — waiting for a LookupResponse to answer with coordinates. Each request the node sends gets its own line under the stage, with the timeout that attempt was given and whether it drew a reply. Failing here is the opposite finding to a bloom miss: a peer's filter did claim the address, and nothing answered for it.
- path — the least-common-ancestor walk between the two
coordinates, plus the next hop this node would select. This is a
local computation, not a traceroute: no hop beyond the first is
contacted, and the tree distance is an upper bound on the real hop
count because a crosslink cut-through can deliver in fewer hops.
When no coordinates were available the walk is not computed at all:
path.coords_knownis false and every tree field is null, rather than a default that would read as a finding about the spanning tree. - session — a full Noise XK handshake over FSP. Completing it is genuine end-to-end evidence: our route reached them, their route reached us, and the remote holds the expected static key.
- rtt — one MMP sender/receiver report exchange, for a real round-trip time.
Exit status is 0 only for an overall verdict of ok; partial,
failed and cancelled all exit 1.
The stage block fills in as the probe runs. Each stage reports its verdict at the moment it reaches one, rather than the whole report arriving at the end, so a slow stage is visible as the stage that is slow. On a terminal the block is redrawn in place, with a spinner and a running elapsed on whichever stage is working; piped or redirected, each row is printed once, when it settles, and the transcript ends up the same block. A running stage reports only what the daemon has observed — which requests have gone unanswered so far, whether the handshake is in flight, how many receiver reports have arrived — and never previews an outcome it does not have yet. The per-request lines under the discovery stage carry the configured timeout of each attempt in parentheses, which is the node's own ladder rather than a measurement.
The elapsed column comes from the daemon's clock throughout: a finished stage carries its own tick-quantized figure, and the stage still running carries the report's elapsed less the stages already accounted for. Nothing in that column is measured client-side.
Stages that were never attempted get no row. A failure marks everything behind it as not reached, and the report says that once, in the failed row, rather than three more times. Two cases deliberately keep their rows: a skipped stage, because a skip is a result naming why that stage was unnecessary, and everything after a failed path stage, because the path preview touches nothing and the session can still succeed where the preview named no next hop.
Below the stage block, the path: line renders the whole tree walk on
one line, from this node to the target, through the least common
ancestor, which is emphasised on a terminal. It is the same computed
walk the ours, theirs and tree walk lines describe, read in one
piece.
--json is unaffected and still emits exactly one document, when the
probe ends, so a script parsing the report does not have to skip past
progress output.
What the probe leaves behind. It tears down a session it opened itself and never touches one that already existed. Three residues are deliberate and worth knowing about:
- The coordinate-cache and identity-cache entries a lookup produced are not evicted. They are TTL-bounded shared read caches, and evicting them could strand an unrelated flow mid-route.
- The remote's half of a probe-created session persists until its own idle timeout (default 90s). There is no teardown wire message. In the window between our removal and its idle purge, any session frame the remote sends lands here as one unknown-session reject.
- To obtain a round-trip time the probe sends a
CoordsWarmup, which starts MMP reporting on the session. On a session the probe does not own, that reporting continues until the idle purge — the same traffic any single data packet would cause, and bounded, but a real change to a session the probe did not create. The report names it undercleanup.warmups_sent.
Running a probe against a production node is safe: the job carries its own deadline daemon-side, so it cleans up whether or not the client is still there.
profile tick <on|off|status>
Reading the output. Step durations are wall clock measured across
awaitpoints, not CPU time: a step that waits on I/O accrues that wait, and other tasks may run inside the span. That is the intended measure for head-of-line delay, and it means a large step is not necessarily an expensive one.arm_starvationis measured directly as the entry time minus the deadline the interval scheduled that tick for. It is not derived fromtick_entry_gap, which carries no starvation signal on its own: under a steady delay every gap is exactly one tick period.
Start, stop and inspect a capture of the rx-loop tick body. Present
only when both fipsctl and the daemon are built with
--features profiling; the feature is off by default, so a stock
package does not carry this subcommand and a stock daemon reports
profile_tick_* as an unknown command.
| Subcommand | Control-socket command | Description |
|---|---|---|
profile tick on |
profile_tick_on |
Create the capture file and start recording. Fails if a capture is already running (naming the active file) or if the directory cannot be written. |
profile tick off |
profile_tick_off |
Stop the capture. The writer is woken immediately, drains once more and is joined, so the command returns promptly. Succeeds, reporting nothing active, when no capture is running. |
profile tick status |
profile_tick_status |
Report idle, running, stopped_by_cap or stopped_by_error, plus the active path, bytes written, flush interval and byte cap. |
profile tick on options:
| Flag | Argument | Default | Description |
|---|---|---|---|
--dir |
directory path | /var/log/fips |
Where to write the capture. Created if absent. Use it to profile a non-root cargo run, or on a platform whose log root differs. |
One file is written per capture, named profile-<UTC timestamp>.tsv.
It opens with a #-prefixed header block (node npub, build version,
platform, configured tick period, flush interval, byte cap, start
time), then a tab-separated column header, then one row per measured
step per flush interval:
ts_unix kind domain name count max total unit
kind is step for a timed span and gauge for a sampled scalar, so
a gauge value never lands under a duration column; unit names the
unit of max and total for that row. Every step present in the build
gets a row every interval, including zero-count rows. Gauges cover
ticks per interval, peer count, the wall gap between successive
tick-arm entries, and the arm-starvation delay, which is measured
against the deadline the tick was scheduled for rather than derived
from the gap.
A capture stops itself on reaching 32 MB, appending a # line saying
so; profile tick status then reports stopped_by_cap until the next
on or off clears it.
Exit Codes
| Code | Meaning |
|---|---|
0 |
Daemon returned {"status":"ok",...}. |
1 |
Argument parse failure, control-socket connection failure, daemon returned {"status":"error",...}, or local I/O failure (keygen). The error message is printed to stderr. |
Environment
| Variable | Description |
|---|---|
XDG_RUNTIME_DIR |
Used to derive the default control-socket path when /run/fips is absent. |
fipsctl does not consume RUST_LOG; logging is for the daemon.
Files
| Path | Purpose |
|---|---|
/etc/fips/hosts |
Maps hostnames to npubs for the connect, disconnect, and --peer arguments. See configuration.md. |
| Control socket (default) | Same resolution as the daemon: /run/fips/control.sock if present; then /var/run/fips/control.sock on macOS/FreeBSD if present; then $XDG_RUNTIME_DIR/fips/control.sock; finally /tmp/fips-control.sock (Unix). A privileged macOS daemon bootstraps the private /var/run/fips directory. Windows uses TCP localhost:21210. |
If you get Permission denied connecting to the socket on Linux,
add your user to the fips group (sudo usermod -aG fips $USER)
and log out and back in.
See also
fips— the daemon.fipstop— live-status TUI.- control-socket.md — wire protocol.
- configuration.md — YAML reference.