`id` now shows what this device is — the key it signs with, the name it
answers to, the secret of every network it has joined — and changes all
of it. One shape throughout: name a thing to see it, name it with a value
to change it. `secret` folded in as `id secret generate`, and the path
flags became global so they work either side of a subcommand.
There is no separate signing certificate to show: the endpoint key is
what signs records, and the report says so rather than leaving it to be
guessed.
Secrets appear in `id`, which is where you go to ask for one, and stay
out of `status`, logs, `Debug` and anything sent to a peer.
The hostname is now a signed claim, which is what makes changing it a
revocation. Records are one per author, so a new version replaces the
whole claim and no replica can keep the old name standing. RecordBody
generalised to Claim { address, range, hostname } + Release for that,
with the signing domain bumped; a name is bounded and canonicalised, and
a non-canonical one is rejected rather than repaired, because a repaired
version is not what its author signed. Two members claiming one name
resolve it like an address: lowest id wins, computed identically
everywhere. A member with only a name now has a record too, so an
IPv6-only network finally has a durable roster and an absent member can
be named rather than shown as a bare id.
Replacing the signing key is allowed and does not break the store. The
outgoing key signs a release for every network first, so the address and
name it held are freed rather than reserved forever to a key nobody has
— nothing can sign for a retired author, and by design no authority
could overrule one. Identity and releases commit together: a crash
between them would leave the old key gone and unable to sign what it
owed. It refuses while an agent holds the directory, rather than failing
on the lock with a message that says nothing about what to do.
The version counter is keyed by author as well as network, so a
replacement key starts its own sequence. The migration drops records
written under the previous signing domain instead of carrying rows that
every read must reject and that look exactly like corruption.
The hostname defaults to the machine's own name. Also fixed a
pre-existing flaky test: 40 random authors in a /24 collide by the
birthday problem often enough that its threshold failed about one run in
six, so the authors are fixed now and it tests a property rather than a
coin flip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
219 lines
7.9 KiB
Rust
219 lines
7.9 KiB
Rust
//! 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,
|
|
/// Answer to a different name from now on.
|
|
///
|
|
/// Applied by the running agent rather than written behind its back, so
|
|
/// the change takes effect and reaches peers immediately instead of
|
|
/// waiting for a restart.
|
|
SetHostname(String),
|
|
}
|
|
|
|
/// What the agent answers.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[non_exhaustive]
|
|
pub enum Response {
|
|
/// A status report.
|
|
Status(Box<StatusReport>),
|
|
/// The name the agent now answers to, after reducing it to canonical form.
|
|
Hostname(String),
|
|
/// 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>,
|
|
/// Members the signed state knows about, connected or not.
|
|
///
|
|
/// This is what makes "offline" sayable. Without it a member that is away
|
|
/// is indistinguishable from one that never existed, and the only thing
|
|
/// left to report is a dial-failure counter — which describes the symptom
|
|
/// and not the cause.
|
|
pub members: Vec<MemberReport>,
|
|
/// 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 member of the network, from signed state.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct MemberReport {
|
|
/// The member's device identity.
|
|
pub endpoint_id: String,
|
|
/// The IPv4 overlay address it claimed and signed for.
|
|
pub overlay_address_v4: Option<String>,
|
|
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
|
pub failed_dials: u32,
|
|
}
|
|
|
|
/// 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>,
|
|
/// How the connection reaches the peer: `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,
|
|
/// This agent's IPv4 overlay address, when the overlay is dual stack.
|
|
pub address_v4: Option<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 destination nobody owned, if there was one.
|
|
pub unroutable_sample: Option<String>,
|
|
}
|
|
|
|
/// One overlay peer.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct OverlayPeerReport {
|
|
/// The peer's control plane identity, so a tunnel can be matched to the
|
|
/// session and the member it belongs to.
|
|
pub endpoint_id: String,
|
|
/// The peer's WireGuard public key.
|
|
pub public_key: String,
|
|
/// Its overlay address.
|
|
pub address: String,
|
|
/// Its IPv4 overlay address, when it has one.
|
|
pub address_v4: Option<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()
|
|
}
|
|
}
|