Files
tsunagi/crates/tsunagi-core/src/ipc/mod.rs
T
Aleksandr BogomiakovandClaude 9966b50407 Rename the CLI package to tsunagi and its executable to tsng
The command line package is now `tsunagi` and builds the `tsng` executable,
which the packages, the unit, the installers and the workflows install and
run. The core library package becomes `tsunagi-core`, keeping the library name
`tsunagi`, so no code changes `use tsunagi::...`. Directories follow
(crates/tsunagi-core, crates/tsunagi). Every command shown to a person is now
`tsng ...`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 02:14:43 +01:00

1120 lines
43 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;
#[cfg(windows)]
pub mod windows;
// The one transport this build serves on. The two adapters expose the same
// items, so the rest of the crate — and the client wrappers below — name the
// transport through this alias and never a platform directly.
#[cfg(unix)]
use unix as transport;
#[cfg(windows)]
use windows as transport;
/// Serves the local control interface on this platform's transport.
///
/// A Unix socket on Unix, a named pipe on Windows; the same API either way.
#[cfg(any(unix, windows))]
pub use transport::ControlSocket;
/// Who may open the local control socket.
///
/// The default is owner-only. A system service shares its socket with a GUI or
/// CLI running as another user by granting a group; the agent sets that on the
/// socket when it binds it, so the access is correct from creation rather than
/// patched afterwards.
#[derive(Debug, Clone)]
pub enum ControlSocketAccess {
/// Owner only — mode `0600`. The per-user agent's default.
Private,
/// The owner and members of this group — the socket's group is set to this
/// gid and the mode to `0660`. The caller resolves a group name to its gid.
Group(u32),
}
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use crate::BoxFuture;
use crate::error::{Error, Result};
/// Largest accepted local control message.
pub const MAX_MESSAGE_LEN: usize = 1024 * 1024;
/// A string the control protocol must carry but that must never appear in a
/// log: it serialises transparently (the text crosses the socket intact) but
/// its `Debug` is redacted. Used for a network secret handed back to a local
/// client that asked to copy it.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RedactedText(String);
impl RedactedText {
/// Wraps a string.
pub fn new(text: impl Into<String>) -> Self {
Self(text.into())
}
/// Unwraps to the plain string.
pub fn into_string(self) -> String {
self.0
}
}
impl std::fmt::Debug for RedactedText {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("<redacted>")
}
}
/// 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")
}
/// Where the default system control socket lives on this platform.
///
/// On Windows, the system-wide `%ProgramData%\tsunagi\agent.sock` is used so that
/// any local user, tray application, or CLI tool can manage the running agent
/// without needing to know which user account or service started it.
/// On macOS the agent runs as a root LaunchDaemon, so a fixed system path under
/// `/var/run` is used for the same reason, and it stays short enough for the
/// Unix socket path limit. On other Unix it derives from the default per-user
/// state directory.
pub fn default_control_socket_path() -> PathBuf {
#[cfg(windows)]
{
if let Some(program_data) = std::env::var_os("ProgramData") {
let pd = PathBuf::from(program_data);
if pd.is_absolute() {
return pd.join("tsunagi").join("agent.sock");
}
}
PathBuf::from(r"C:\ProgramData\tsunagi\agent.sock")
}
#[cfg(target_os = "macos")]
{
PathBuf::from("/var/run/tsunagi/agent.sock")
}
#[cfg(all(unix, not(target_os = "macos")))]
{
// Prefer a running system service's socket when it is present, so a
// GUI or CLI reaches the packaged daemon with no configuration; fall
// back to the per-user location otherwise.
let system = PathBuf::from("/run/tsunagi/agent.sock");
if system.exists() {
return system;
}
if let Ok(paths) = crate::config::StoragePaths::user_default() {
control_socket_path(&paths.state_dir)
} else {
system
}
}
}
/// What a client asks for.
///
/// `Debug` is written by hand rather than derived: one of these carries a
/// network secret, and a derived one would put it in any log line that
/// printed a request.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Request {
/// Report what the agent is doing.
Status,
/// Set local broadcast participation in one configured network.
SetBroadcast {
/// Public network identifier.
network_id: String,
/// Whether to originate and accept broadcasts.
enabled: bool,
},
/// 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),
/// Leave a network: give up what was claimed, then forget it.
///
/// Asked of the running agent rather than done behind its back, because
/// only it can publish the release, and only while its sessions are up.
/// The network is named by its id, in the text form `status` prints.
Leave(String),
/// Stop serving a network, or start serving it again.
///
/// Not leaving: the configuration, the secret and the signed state all
/// stay, so it can be resumed exactly where it was. The network is
/// named by its id, in the text form `status` prints.
SetActive {
/// Which network.
network_id: String,
/// Whether it should be running.
active: bool,
},
/// Turn the local resolver on or off, now and for future starts.
Dns {
/// Whether it should be serving.
enable: bool,
/// The port to serve on. Keeps the stored one when absent.
port: Option<u16>,
},
/// Join a network, or start one that is configured and not running.
///
/// Asked of the running agent because that is the only way to add a
/// network to an agent that is already up: the state directory belongs
/// to one live agent, so a second `tsng up` cannot.
Join {
/// The network name.
name: String,
/// The shared secret in its `tsn1…` text form.
///
/// It travels over a socket only its owner can open, to the agent
/// that stores it anyway, and never appears in `Debug`.
secret: String,
/// Explicit choice, or preserve the saved setting.
#[serde(default)]
broadcast: Option<bool>,
},
/// Ask for a configured network's shared secret, in its `tsn1…` text form,
/// so a local GUI or script can copy it to re-share. The reply travels only
/// over the owner/group socket and the secret is never logged.
Secret {
/// Which network, by the id text `status` prints.
network_id: String,
},
/// Offer this agent as an exit node in one network, or stop. Off unless
/// asked.
SetExitOffer {
/// Public network identifier.
network_id: String,
/// Whether members may send their internet traffic through this agent.
enabled: bool,
},
/// Send all this device's internet traffic through a member of one
/// network, or back the ordinary way.
SetExitNode {
/// Public network identifier.
network_id: String,
/// The member's endpoint id, or `None` to stop using an exit node.
peer: Option<String>,
},
}
impl std::fmt::Debug for Request {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Request::Status => f.write_str("Status"),
Request::SetBroadcast {
network_id,
enabled,
} => write!(f, "SetBroadcast {{ {network_id}, enabled: {enabled} }}"),
Request::SetHostname(name) => write!(f, "SetHostname({name})"),
Request::Leave(network) => write!(f, "Leave({network})"),
Request::SetActive { network_id, active } => {
write!(f, "SetActive {{ {network_id}, active: {active} }}")
}
// The name is not a secret; the secret is.
Request::Dns { enable, port } => {
write!(f, "Dns {{ enable: {enable}, port: {port:?} }}")
}
Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: <redacted> }}"),
Request::Secret { network_id } => write!(f, "Secret {{ {network_id} }}"),
Request::SetExitOffer {
network_id,
enabled,
} => write!(f, "SetExitOffer {{ {network_id}, enabled: {enabled} }}"),
Request::SetExitNode { network_id, peer } => {
write!(f, "SetExitNode {{ {network_id}, peer: {peer:?} }}")
}
}
}
}
/// What the agent answers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Response {
/// A status report.
Status(Box<StatusReport>),
/// Accepted local broadcast participation.
Broadcast(bool),
/// The name the agent now answers to, after reducing it to canonical form.
Hostname(String),
/// A network was left.
Left(LeftReport),
/// A network was joined, or was already there and is now running.
Joined(JoinedReport),
/// A network was stopped or started.
Active(ActiveReport),
/// What the local resolver is doing, after being changed or asked.
///
/// `None` means it is not serving at all.
Dns(Option<DnsReport>),
/// A network's shared secret, redacted in `Debug`.
Secret(RedactedText),
/// Whether this agent now offers itself as an exit node.
ExitOffer(bool),
/// The member this device now sends its internet traffic through.
ExitNode(Option<String>),
/// The request could not be served.
Error(String),
}
/// What happened when a network was stopped or started.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActiveReport {
/// The network's name, for the message the user reads.
pub name: String,
/// Whether it is running now.
pub active: bool,
/// Whether this request is what changed it.
///
/// Stopping something already stopped is not an error — it is the state
/// asked for — but it is worth saying which happened.
pub changed: bool,
}
/// What happened when a network was joined.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct JoinedReport {
/// The network's name, as given.
pub name: String,
/// The derived network id, which is what tells two same-named networks
/// apart.
pub network_id: String,
/// Whether this device was already configured for exactly this network.
///
/// Joining is idempotent, so this is the difference between "added" and
/// "it was already there and is now running".
pub already_configured: bool,
/// Another configured network with the same name but a different
/// secret, if there is one.
///
/// Almost always a mistyped secret, and the one thing that makes two
/// sections of a report look like one network.
pub name_shared_with: Option<String>,
}
/// What happened when a network was left.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeftReport {
/// The network's name, for the message the user reads.
pub name: String,
/// Whether a signed release was published.
///
/// `false` when the network was not running: nothing could sign or send
/// it, so this was a local removal and the others keep the old claim.
pub announced: bool,
/// How many connected peers it was handed to.
///
/// They pass it on, so this is not the number of members that will
/// learn of it — but zero means none of them will.
pub peers_told: u32,
}
/// 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>,
/// The local DNS service, when one was asked for.
pub dns: Option<DnsReport>,
/// The state directory the agent is actually using.
///
/// A client that was not told one would look in its own default, which is
/// not the agent's when the agent runs as a service under another user.
pub state_dir: String,
/// The cache directory the agent is actually using.
pub cache_dir: String,
/// The executable the agent runs, which is what needs the capability.
pub program: String,
/// Whether the agent itself can manage the overlay interface.
///
/// It is the agent that creates the interface, so the client's own
/// capabilities say nothing about it.
pub privilege: PrivilegeReport,
/// The version of the agent's software.
pub version: String,
/// The data plane protocols it has, each as `name vN` (wire version).
pub protocols: Vec<String>,
}
/// What the agent found when it checked whether it may manage an interface.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum PrivilegeReport {
/// Not checked.
#[default]
Unknown,
/// It can.
Available,
/// It cannot, with what was found.
Missing(String),
/// This platform has no provisioner yet.
Unsupported,
}
/// The local DNS service.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DnsReport {
/// The zones it answers for: one per network, named after it.
pub zones: Vec<String>,
/// Every address it is listening on, one per family where it could.
///
/// Empty means it answers nowhere, and `bind_error` says why.
pub listening: Vec<String>,
/// Why it could not bind, if it did not.
pub bind_error: Option<String>,
/// Why the system resolver was not told, if it was not.
///
/// `None` means it was told. The server answers either way, so this is a
/// degraded overlay rather than a broken one.
pub publish_error: Option<String>,
/// What to do about that, when there is something.
pub publish_remedy: Option<String>,
/// Anything worth saying about those names, one line each.
pub zone_warnings: Vec<String>,
/// How many names it answers for.
pub names: u32,
}
/// One network.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetworkReport {
/// Whether this agent participates in LAN broadcasts in this network.
#[serde(default)]
pub broadcast: bool,
/// 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>,
/// Unverified candidates this network currently knows of.
///
/// Not peers: somewhere to try. Zero of them, with nobody connected,
/// is the difference between "nobody has joined yet" and "this agent
/// has no way to reach anybody" — which look identical in a report
/// that counts only members.
pub candidates: u32,
/// Datagrams this agent passed on between two other peers.
pub relay_forwarded: u64,
/// Datagrams this agent sent to a peer through somebody else.
pub relay_sent_via: u64,
/// Datagrams that reached this agent through somebody else.
pub relay_received_via: u64,
/// 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 range this network uses, once it has one.
pub range: Option<String>,
/// The range it could not have, because another network on this agent
/// already holds it.
pub range_conflict: Option<String>,
/// The overlay, when a protocol is running one.
pub overlay: Option<OverlayReport>,
/// Exit-node settings: whether this agent offers one, and whether it
/// uses one.
pub exit: ExitReport,
}
/// 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,
/// The last hostname this agent saw it announce, kept while it is away.
pub hostname: Option<String>,
}
/// 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 peer offers itself as an exit node in this network.
pub exit_node: bool,
}
/// The WireGuard overlay of one network.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayReport {
/// Packet interface name.
pub interface: String,
/// Whether that interface exists on the host.
///
/// `false` under `--no-tun`: tunnels run and packets move between
/// agents, but the operating system has no interface, no address and no
/// route, so nothing local reaches the overlay.
pub on_host: bool,
/// Interface MTU.
pub mtu: u32,
/// This agent's overlay address, once the network has agreed one.
pub address: Option<String>,
/// Prefix length of the overlay range every member shares.
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>,
/// The broadcast route and firewall allowance on this host, when the
/// agent installed them. `None` means none are installed.
pub broadcast_rules: Option<HostRulesReport>,
}
/// How one set of exit-node host rules went.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuleSetReport {
/// Whether every step took.
pub ok: bool,
/// One line: what is in place, or what is missing and why.
pub detail: String,
}
/// One network's exit-node settings and what they are doing.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExitReport {
/// This agent offers itself as an exit node in this network.
pub offering: bool,
/// Masquerading and forwarding rules for members' traffic, when offering.
pub offer_rules: Option<RuleSetReport>,
/// Whether the kernel forwards packets for the overlay interface.
/// `Some(false)` means the rules are in place and nothing passes until it
/// is turned on. `None` when unknown.
pub forwarding: Option<bool>,
/// The member this device sends its internet traffic through, as an
/// endpoint id.
pub via: Option<String>,
/// Whether that member is connected and still offering to be one.
pub via_online: bool,
/// The routes that send this device's traffic through it.
pub client_rules: Option<RuleSetReport>,
}
/// What the agent installed on the host so LAN broadcast reaches the overlay.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostRulesReport {
/// Whether both the route and the firewall allowance are in place.
pub ok: bool,
/// One line: `ok (route + firewall)`, or what is missing and why.
pub detail: String,
/// The overlay address the `255.255.255.255` route is bound to. With
/// several networks on one interface only one address can be its source.
pub source: 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,
/// Protocol carrying overlay traffic for this peer (e.g. "tcp-tls", "wg-quic").
pub protocol: String,
/// The peer's WireGuard public key (if applicable).
pub public_key: String,
/// The overlay address the network agreed it holds.
pub address: 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,
/// Plaintext bytes encrypted and sent to this peer.
pub tx_bytes: u64,
/// Plaintext bytes decrypted from this peer.
pub rx_bytes: 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()
}
}
// ---------------------------------------------------------------------------
// The transport-agnostic half of the local control interface.
//
// Everything below is the same on every platform: the request/response
// framing, the dispatch of a request onto a [`ReportSource`], and the client
// wrappers that ask one question and read one answer. Only the listener and
// the stream differ, and those live in the per-platform `unix` and `windows`
// adapters. Each adapter drives the shared [`serve_connection`] for its
// accepted streams and offers a `connect`/`is_serving` for the client side,
// so the logic here is written once.
// ---------------------------------------------------------------------------
/// Builds the report that answers a status request, and applies the changes a
/// client asks for.
///
/// Supplied by the caller, because only the caller knows which plugins are
/// running and what they can report or change. That is what keeps this module
/// free of any knowledge of them.
pub trait ReportSource: Send + Sync + 'static {
/// Produces a fresh report.
fn report(&self) -> BoxFuture<'_, StatusReport>;
/// Changes the name the agent answers to, returning the accepted form.
///
/// Defaulted to a refusal so that a source which only reports — the
/// closure impl below, and every test that uses it — stays valid and
/// says plainly that it cannot do this, rather than appearing to.
fn set_hostname(
&self,
_hostname: String,
) -> BoxFuture<'_, std::result::Result<String, String>> {
Box::pin(async move { Err("this agent cannot change its hostname".to_string()) })
}
/// Leaves a network, publishing a release first.
///
/// Defaulted to a refusal for the same reason as the above: a source
/// that only reports says so plainly rather than appearing to do it.
fn leave(&self, _network_id: String) -> BoxFuture<'_, std::result::Result<LeftReport, String>> {
Box::pin(async move { Err("this agent cannot leave a network".to_string()) })
}
/// Joins a network, or starts one that is configured and not running.
///
/// Defaulted to a refusal, like the others: a source that only reports
/// says so rather than appearing to have done it.
fn join(
&self,
_name: String,
_secret: String,
_broadcast: Option<bool>,
) -> BoxFuture<'_, std::result::Result<JoinedReport, String>> {
Box::pin(async move { Err("this agent cannot join a network".to_string()) })
}
/// Persists and applies local broadcast participation.
fn set_broadcast(
&self,
_network_id: String,
_enabled: bool,
) -> BoxFuture<'_, std::result::Result<bool, String>> {
Box::pin(async move { Err("this agent cannot change broadcast participation".into()) })
}
/// Stops serving a network, or starts serving it again.
///
/// Defaulted to a refusal, like the others.
fn set_active(
&self,
_network_id: String,
_active: bool,
) -> BoxFuture<'_, std::result::Result<ActiveReport, String>> {
Box::pin(async move { Err("this agent cannot stop or start a network".to_string()) })
}
/// Turns the local resolver on or off while the agent runs.
///
/// Defaulted to a refusal, like the others.
fn set_dns(
&self,
_enable: bool,
_port: Option<u16>,
) -> BoxFuture<'_, std::result::Result<Option<DnsReport>, String>> {
Box::pin(async move { Err("this agent cannot serve DNS".to_string()) })
}
/// Returns a configured network's shared secret in its `tsn1…` text form.
///
/// Defaulted to a refusal, like the others. An implementor reads it from
/// local state; it must never be logged.
fn network_secret(
&self,
_network_id: String,
) -> BoxFuture<'_, std::result::Result<String, String>> {
Box::pin(async move { Err("this agent cannot read secrets".to_string()) })
}
/// Offers this agent as an exit node in a network, or stops.
///
/// Defaulted to a refusal, like the others.
fn set_exit_offer(
&self,
_network_id: String,
_enabled: bool,
) -> BoxFuture<'_, std::result::Result<bool, String>> {
Box::pin(async move { Err("this agent cannot be an exit node".to_string()) })
}
/// Sends this device's internet traffic through a member, or stops.
///
/// Defaulted to a refusal, like the others.
fn set_exit_node(
&self,
_network_id: String,
_peer: Option<String>,
) -> BoxFuture<'_, std::result::Result<Option<String>, String>> {
Box::pin(async move { Err("this agent cannot use an exit node".to_string()) })
}
}
impl<F> ReportSource for F
where
F: Fn() -> BoxFuture<'static, StatusReport> + Send + Sync + 'static,
{
fn report(&self) -> BoxFuture<'_, StatusReport> {
(self)()
}
}
/// How long either end waits for the other.
///
/// A local answer comes from memory, so anything this slow means the agent is
/// wedged rather than busy. Saying so beats waiting: unbounded, one wedged
/// runtime leaves `tsng status` hanging with nothing on screen and no way
/// out but Ctrl-C.
pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
/// Marks the wire format of the local control interface.
///
/// `b"TSN"` followed by the version, so a mismatch is recognised as one
/// instead of being read as a length. The encoding is postcard, which is not
/// self-describing: adding a field to a report changes how the bytes parse,
/// and without this a client one build ahead of its agent reports something
/// like "Found an Option discriminant that wasn't 0 or 1" — which says
/// nothing about the actual problem, that the two are different builds.
///
/// Bump it whenever [`Request`], [`Response`] or anything they contain
/// changes shape.
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 17]);
/// Reads one request off an accepted stream, answers it, writes the response.
///
/// The per-platform adapters call this for every connection they accept, so
/// the request handling is identical on every transport.
pub(crate) async fn serve_connection<S>(mut stream: S, source: Arc<dyn ReportSource>) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin,
{
// Bounded, so a connection that sends nothing cannot hold a task open.
// Only the wait for the request: building the report afterwards takes as
// long as it takes, and cutting it off would answer a live client with a
// closed socket.
let request: Request =
match tokio::time::timeout(EXCHANGE_TIMEOUT, read_message(&mut stream)).await {
Ok(request) => request?,
Err(_) => {
return Err(Error::Timeout {
what: "a local control connection".to_string(),
});
}
};
let response = match request {
Request::Status => Response::Status(Box::new(source.report().await)),
Request::SetBroadcast {
network_id,
enabled,
} => match source.set_broadcast(network_id, enabled).await {
Ok(enabled) => Response::Broadcast(enabled),
Err(error) => Response::Error(error),
},
Request::SetHostname(hostname) => match source.set_hostname(hostname).await {
Ok(accepted) => Response::Hostname(accepted),
Err(reason) => Response::Error(reason),
},
Request::Leave(network_id) => match source.leave(network_id).await {
Ok(report) => Response::Left(report),
Err(reason) => Response::Error(reason),
},
Request::Join {
name,
secret,
broadcast,
} => match source.join(name, secret, broadcast).await {
Ok(report) => Response::Joined(report),
Err(reason) => Response::Error(reason),
},
Request::SetActive { network_id, active } => {
match source.set_active(network_id, active).await {
Ok(report) => Response::Active(report),
Err(reason) => Response::Error(reason),
}
}
Request::Dns { enable, port } => match source.set_dns(enable, port).await {
Ok(report) => Response::Dns(report),
Err(reason) => Response::Error(reason),
},
Request::Secret { network_id } => match source.network_secret(network_id).await {
Ok(secret) => Response::Secret(RedactedText::new(secret)),
Err(reason) => Response::Error(reason),
},
Request::SetExitOffer {
network_id,
enabled,
} => match source.set_exit_offer(network_id, enabled).await {
Ok(enabled) => Response::ExitOffer(enabled),
Err(reason) => Response::Error(reason),
},
Request::SetExitNode { network_id, peer } => {
match source.set_exit_node(network_id, peer).await {
Ok(peer) => Response::ExitNode(peer),
Err(reason) => Response::Error(reason),
}
}
};
write_message(&mut stream, &response).await
}
/// Connects, sends one request and reads the answer, all within `within`.
///
/// The bound covers the whole exchange rather than each read: an agent that
/// answers the header and then stops is as stuck as one that never answers.
async fn exchange(path: &Path, request: &Request, within: Duration) -> Result<Response> {
let attempt = async {
let mut stream = transport::connect(path).await?;
write_message(&mut stream, request).await?;
read_message::<Response, _>(&mut stream).await
};
match tokio::time::timeout(within, attempt).await {
Ok(result) => result,
Err(_) => Err(Error::Timeout {
what: format!("the agent at {}", path.display()),
}),
}
}
/// Whether an agent is serving the control interface at `path`.
///
/// Used to tell a running agent from a leftover: on Unix a socket file with
/// nothing behind it, on Windows nothing at all.
/// A Windows pipe that denies access still counts as present; the subsequent
/// request reports the permission error instead of attempting an offline edit.
pub async fn is_serving(path: impl AsRef<Path>) -> bool {
transport::probe(path.as_ref()).await
}
/// Asks a running agent for its status.
pub async fn request_status(path: impl AsRef<Path>) -> Result<StatusReport> {
let path = path.as_ref();
match exchange(path, &Request::Status, EXCHANGE_TIMEOUT).await? {
Response::Status(report) => Ok(*report),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Asks a running agent to answer to a different name.
///
/// Returns the name it accepted, which is the canonical form of what was
/// asked for and may differ from it.
pub async fn set_hostname(path: impl AsRef<Path>, hostname: &str) -> Result<String> {
let path = path.as_ref();
let request = Request::SetHostname(hostname.to_string());
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
Response::Hostname(accepted) => Ok(accepted),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Asks a running agent to leave a network.
///
/// The agent publishes the release and removes the network; this only
/// carries the request and the outcome.
pub async fn leave_network(path: impl AsRef<Path>, network_id: &str) -> Result<LeftReport> {
let path = path.as_ref();
let request = Request::Leave(network_id.to_string());
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
Response::Left(report) => Ok(report),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Asks a running agent to join a network.
///
/// The one way to add a network to an agent that is already up: the state
/// directory belongs to one live agent, so a second `up` cannot.
pub async fn join_network(
path: impl AsRef<Path>,
name: &str,
secret: &str,
) -> Result<JoinedReport> {
let path = path.as_ref();
join_network_with_broadcast(path, name, secret, None).await
}
/// Joins with an explicit per-network broadcast choice.
pub async fn join_network_with_broadcast(
path: impl AsRef<Path>,
name: &str,
secret: &str,
broadcast: Option<bool>,
) -> Result<JoinedReport> {
let path = path.as_ref();
let request = Request::Join {
broadcast,
name: name.to_string(),
secret: secret.to_string(),
};
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
Response::Joined(report) => Ok(report),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Changes one network's broadcast participation while the agent runs.
pub async fn set_broadcast(
path: impl AsRef<Path>,
network_id: &str,
enabled: bool,
) -> Result<bool> {
let path = path.as_ref();
let request = Request::SetBroadcast {
network_id: network_id.to_owned(),
enabled,
};
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
Response::Broadcast(enabled) => Ok(enabled),
Response::Error(message) => Err(Error::Storage(message)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Asks a running agent to stop serving a network, or to serve it again.
pub async fn set_active(
path: impl AsRef<Path>,
network_id: &str,
active: bool,
) -> Result<ActiveReport> {
let path = path.as_ref();
let request = Request::SetActive {
network_id: network_id.to_string(),
active,
};
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
Response::Active(report) => Ok(report),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Turns the running agent's local resolver on or off.
pub async fn set_dns(
path: impl AsRef<Path>,
enable: bool,
port: Option<u16>,
) -> Result<Option<DnsReport>> {
let path = path.as_ref();
match exchange(path, &Request::Dns { enable, port }, EXCHANGE_TIMEOUT).await? {
Response::Dns(report) => Ok(report),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Offers the running agent as an exit node in one network, or stops.
pub async fn set_exit_offer(
path: impl AsRef<Path>,
network_id: &str,
enabled: bool,
) -> Result<bool> {
let request = Request::SetExitOffer {
network_id: network_id.to_owned(),
enabled,
};
match exchange(path.as_ref(), &request, EXCHANGE_TIMEOUT).await? {
Response::ExitOffer(enabled) => Ok(enabled),
Response::Error(message) => Err(Error::Storage(message)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Sends the running agent's internet traffic through a member of one
/// network, or back the ordinary way with `None`.
pub async fn set_exit_node(
path: impl AsRef<Path>,
network_id: &str,
peer: Option<&str>,
) -> Result<Option<String>> {
let request = Request::SetExitNode {
network_id: network_id.to_owned(),
peer: peer.map(str::to_owned),
};
match exchange(path.as_ref(), &request, EXCHANGE_TIMEOUT).await? {
Response::ExitNode(peer) => Ok(peer),
Response::Error(message) => Err(Error::Storage(message)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Asks a running agent for a configured network's shared secret, in its
/// `tsn1…` text form, so a local client can copy it. The secret is never
/// logged by this path.
pub async fn network_secret(path: impl AsRef<Path>, network_id: &str) -> Result<String> {
let path = path.as_ref();
let request = Request::Secret {
network_id: network_id.to_string(),
};
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
Response::Secret(secret) => Ok(secret.into_string()),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
pub(crate) async fn write_message<S, T>(stream: &mut S, value: &T) -> Result<()>
where
S: AsyncWrite + Unpin,
T: serde::Serialize,
{
let encoded = postcard::to_stdvec(value)
.map_err(|err| Error::Storage(format!("cannot encode a control message: {err}")))?;
if encoded.len() > MAX_MESSAGE_LEN {
return Err(Error::Storage("control message is too large".into()));
}
let len = encoded.len() as u32;
stream
.write_all(&CONTROL_PROTOCOL.to_be_bytes())
.await
.map_err(io_error)?;
stream
.write_all(&len.to_be_bytes())
.await
.map_err(io_error)?;
stream.write_all(&encoded).await.map_err(io_error)?;
stream.flush().await.map_err(io_error)
}
pub(crate) async fn read_message<T, S>(stream: &mut S) -> Result<T>
where
T: for<'de> serde::Deserialize<'de>,
S: AsyncRead + Unpin,
{
let mut header = [0u8; 4];
stream.read_exact(&mut header).await.map_err(io_error)?;
let version = u32::from_be_bytes(header);
if version != CONTROL_PROTOCOL {
return Err(Error::Storage(format!(
"the other end speaks control protocol {version:#010x} and this build speaks \
{CONTROL_PROTOCOL:#010x}; they are different builds of tsunagi, so restart the \
agent with the binary you are running now"
)));
}
stream.read_exact(&mut header).await.map_err(io_error)?;
let len = u32::from_be_bytes(header) as usize;
// Checked before allocating, exactly as on the network.
if len > MAX_MESSAGE_LEN {
return Err(Error::Storage(format!(
"control message of {len} bytes exceeds the {MAX_MESSAGE_LEN} byte limit"
)));
}
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload).await.map_err(io_error)?;
postcard::from_bytes(&payload)
.map_err(|err| Error::Storage(format!("cannot decode a control message: {err}")))
}
pub(crate) fn io_error(source: std::io::Error) -> Error {
Error::Io {
path: PathBuf::from("<local control socket>"),
source,
}
}