Add tsunagi status over a local control socket

There was no way to ask a running agent what it was doing; the only status
came from the periodic print of the `up` process itself.

The new ipc module is an adapter over the public API: nothing in the agent
core knows it exists, so a Windows named pipe or an authenticated loopback
socket can be added beside it. It is also a different interface from the
peer-to-peer protocol — between processes on one machine, authorised by
filesystem permissions rather than the network secret. The socket is 0600
inside an owner-only directory, the wire format is length-prefixed postcard
with the same bounds the network protocol uses, and the report types are
their own stable format rather than the crate's internals.

The socket path is derived from the state directory into XDG_RUNTIME_DIR
when there is one. A Unix socket address is limited to about 100 bytes, and
a deeply nested state directory overflows it — which is exactly what
happened on the first attempt.

Two presentation fixes while here. Multicast is counted separately from
unroutable traffic, because Linux emits multicast on every IPv6 interface
and it was showing up as "packets for unknown addresses" on a healthy
agent. And WireGuard protocol errors are no longer added into the dropped
counter: a few are normal while both ends start a handshake at once, and a
working tunnel was reporting "dropped 3" with no traffic at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 12:53:04 +01:00
co-authored by Claude Opus 5
parent d2e336f2f9
commit be459e5bd0
7 changed files with 900 additions and 3 deletions
+271
View File
@@ -0,0 +1,271 @@
//! The local control interface.
//!
//! This is how a command line tool asks a running agent what it is doing. It
//! is deliberately **an adapter over the public API, not part of the core**:
//! nothing in [`crate::agent`] knows this module exists, so a Windows named
//! pipe or an authenticated loopback socket can be added beside it without
//! touching anything else.
//!
//! It is also a different interface from the peer-to-peer control protocol in
//! [`crate::proto`]. That one is between machines and is authenticated by the
//! network secret; this one is between processes on one machine and is
//! authorised by filesystem permissions.
//!
//! # Access
//!
//! The socket lives inside the agent's state directory, which is owner-only,
//! and the socket itself is created with mode `0600`. There is no
//! unauthenticated listener reachable by other local users, and nothing is
//! exposed on the network.
//!
//! # Wire format
//!
//! Length-prefixed postcard, with the same frame bounds the network protocol
//! uses. The report types here are a stable data transfer format of their own
//! rather than the crate's internal structures, so internal refactors do not
//! silently change what a client sees.
#[cfg(unix)]
pub mod unix;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// Largest accepted local control message.
pub const MAX_MESSAGE_LEN: usize = 1024 * 1024;
/// Where the control socket for a state directory lives.
///
/// A Unix socket path is limited to around 100 bytes, which a state directory
/// nested deeply enough will exceed. So the runtime directory is preferred
/// when the platform provides one — which is also where a runtime socket
/// belongs — with a short name derived from the state directory so that two
/// agents with different state never share a socket. The state directory
/// itself is the fallback.
///
/// Both the agent and the client compute this the same way, so neither has to
/// be told where the other put it.
pub fn control_socket_path(state_dir: &Path) -> PathBuf {
let digest = Sha256::digest(state_dir.as_os_str().as_encoded_bytes());
let tag = hex::encode(&digest[..8]);
if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
let runtime = PathBuf::from(runtime);
if runtime.is_absolute() {
return runtime.join("tsunagi").join(format!("{tag}.sock"));
}
}
state_dir.join("agent.sock")
}
/// What a client asks for.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Request {
/// Report what the agent is doing.
Status,
}
/// What the agent answers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Response {
/// A status report.
Status(Box<StatusReport>),
/// The request could not be served.
Error(String),
}
/// Everything the agent is doing, in one snapshot.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusReport {
/// This device's persistent endpoint id.
pub endpoint_id: String,
/// Hostname announced to peers.
pub hostname: String,
/// Sockets the endpoint is bound to.
pub bound_sockets: Vec<String>,
/// Whether the disposable cache is usable.
pub cache_healthy: bool,
/// One entry per configured network.
pub networks: Vec<NetworkReport>,
}
/// One network.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetworkReport {
/// Network name.
pub name: String,
/// Public network identifier.
pub network_id: String,
/// Whether the network is running locally.
pub active: bool,
/// Authenticated control plane peers.
pub peers: Vec<PeerReport>,
/// Outbound dials that failed.
pub dial_failures: u64,
/// Handshakes rejected in either direction.
pub handshake_failures: u64,
/// Control messages sent and received.
pub control_messages: (u64, u64),
/// The overlay, when an IP plugin is running one.
pub overlay: Option<OverlayReport>,
}
/// One control plane peer.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerReport {
/// The peer's endpoint id.
pub endpoint_id: String,
/// Hostname it announced, if any.
pub hostname: Option<String>,
/// `Direct`, `Relay` or `Unknown`, as the transport reports it.
pub transport: String,
/// Round-trip time in milliseconds, when a path is selected.
pub rtt_ms: Option<u64>,
}
/// The WireGuard overlay of one network.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayReport {
/// Packet interface name.
pub interface: String,
/// Interface MTU.
pub mtu: u32,
/// This agent's overlay address.
pub address: String,
/// The subnet every member shares.
pub prefix: String,
/// Prefix length of that subnet.
pub prefix_len: u8,
/// One entry per overlay peer.
pub peers: Vec<OverlayPeerReport>,
/// Unicast packets sent to an address no peer owns.
pub unroutable_packets: u64,
/// Multicast packets dropped. Expected, not a fault.
pub multicast_packets: u64,
}
/// One overlay peer.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayPeerReport {
/// The peer's WireGuard public key.
pub public_key: String,
/// Its overlay address.
pub address: String,
/// Seconds since the last WireGuard handshake.
///
/// `None` means the tunnel has never handshaken and cannot carry traffic.
pub handshake_secs_ago: Option<u64>,
/// Packets encrypted and sent to this peer.
pub tx_packets: u64,
/// Packets decrypted from this peer.
pub rx_packets: u64,
/// Data packets dropped: wrong source address, or too large for the path.
pub dropped: u64,
/// WireGuard protocol errors.
///
/// A few are normal while a tunnel is being set up, because both ends
/// start a handshake at once and one of the two is discarded.
pub protocol_errors: u64,
/// What the transport reports about the path in use.
pub path: String,
}
impl OverlayPeerReport {
/// Whether the tunnel has handshaken and can carry traffic.
pub fn is_up(&self) -> bool {
self.handshake_secs_ago.is_some()
}
}
impl StatusReport {
/// Renders the report the way the command line prints it.
pub fn render(&self) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "endpoint {}", self.endpoint_id);
let _ = writeln!(out, "hostname {}", self.hostname);
let _ = writeln!(out, "bound {}", self.bound_sockets.join(", "));
if !self.cache_healthy {
let _ = writeln!(out, "cache UNAVAILABLE");
}
for network in &self.networks {
let _ = writeln!(
out,
"\nnetwork {} ({}) {}",
network.name,
network.network_id,
if network.active { "active" } else { "inactive" }
);
if network.peers.is_empty() {
let _ = writeln!(out, " no peers");
}
for peer in &network.peers {
let _ = writeln!(
out,
" peer {} {} {}{}",
&peer.endpoint_id[..10.min(peer.endpoint_id.len())],
peer.hostname.as_deref().unwrap_or("?"),
peer.transport,
match peer.rtt_ms {
Some(rtt) => format!(" rtt {rtt}ms"),
None => String::new(),
}
);
}
if network.dial_failures > 0 || network.handshake_failures > 0 {
let _ = writeln!(
out,
" {} dial failure(s), {} handshake failure(s)",
network.dial_failures, network.handshake_failures
);
}
if let Some(overlay) = &network.overlay {
let up = overlay.peers.iter().filter(|peer| peer.is_up()).count();
let _ = writeln!(
out,
" overlay {} {}/{} mtu {} {}/{} tunnel(s) up",
overlay.interface,
overlay.address,
overlay.prefix_len,
overlay.mtu,
up,
overlay.peers.len()
);
for peer in &overlay.peers {
let _ = writeln!(
out,
" {} {} {} tx {} rx {}{} {}",
&peer.public_key[..8.min(peer.public_key.len())],
peer.address,
match peer.handshake_secs_ago {
Some(secs) => format!("handshake {secs}s ago"),
None => "NOT HANDSHAKEN".to_string(),
},
peer.tx_packets,
peer.rx_packets,
if peer.dropped > 0 {
format!(" DROPPED {}", peer.dropped)
} else {
String::new()
},
peer.path
);
}
if overlay.unroutable_packets > 0 {
let _ = writeln!(
out,
" {} packet(s) to addresses nobody owns",
overlay.unroutable_packets
);
}
}
}
out
}
}