2026-09-21 11:55:20 +01:00
|
|
|
//! The `tsunagi` command line agent.
|
|
|
|
|
//!
|
|
|
|
|
//! This binary owns everything the library deliberately refuses to do: it
|
|
|
|
|
//! starts the tokio runtime, installs a logging subscriber and handles
|
|
|
|
|
//! Ctrl-C. The library itself does none of that.
|
|
|
|
|
|
|
|
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
|
|
|
|
|
|
use std::net::SocketAddr;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
|
|
|
|
use tsunagi::agent::Event;
|
|
|
|
|
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
|
|
|
|
use tsunagi::dataplane::IpPlugin;
|
|
|
|
|
use tsunagi::dataplane::wireguard::{
|
2026-09-21 13:43:01 +01:00
|
|
|
MemoryTunFactory, TunFactory, WireguardConfig, WireguardPlugin,
|
2026-09-21 11:55:20 +01:00
|
|
|
};
|
|
|
|
|
use tsunagi::discovery::{CompositeDiscovery, NetworkDiscovery, StaticBootstrap};
|
|
|
|
|
use tsunagi::identity::{NetworkName, NetworkSecret};
|
|
|
|
|
use tsunagi::iroh_types::EndpointAddr;
|
2026-09-21 13:43:01 +01:00
|
|
|
use tsunagi::state::Ipv4Range;
|
2026-09-21 11:55:20 +01:00
|
|
|
use tsunagi::{Agent, NetworkId};
|
|
|
|
|
|
|
|
|
|
/// A small agent for private mesh networks.
|
|
|
|
|
#[derive(Debug, Parser)]
|
|
|
|
|
#[command(name = "tsunagi", version, about, long_about = None)]
|
|
|
|
|
struct Cli {
|
|
|
|
|
/// Log filter, for example `info` or `tsunagi=debug`.
|
|
|
|
|
#[arg(long, global = true, env = "TSUNAGI_LOG", default_value = "warn")]
|
|
|
|
|
log: String,
|
|
|
|
|
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
command: Command,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
enum Command {
|
|
|
|
|
/// Generates a fresh network secret and prints it.
|
|
|
|
|
Secret,
|
|
|
|
|
/// Reports what this machine can and cannot do.
|
|
|
|
|
Doctor(PathArgs),
|
|
|
|
|
/// Shows this device's identity without joining anything.
|
|
|
|
|
Id(PathArgs),
|
|
|
|
|
/// Joins a network and runs until interrupted.
|
|
|
|
|
Up(UpArgs),
|
2026-09-21 12:53:04 +01:00
|
|
|
/// Asks a running agent what it is doing.
|
|
|
|
|
Status(StatusArgs),
|
2026-09-21 12:23:37 +01:00
|
|
|
/// Prints the one-time privileged setup for the overlay interface.
|
|
|
|
|
///
|
|
|
|
|
/// Run its output once as root, then run `tsunagi up` as an ordinary
|
|
|
|
|
/// user: the agent attaches to the prepared interface and needs no
|
|
|
|
|
/// privileges of its own.
|
|
|
|
|
TunSetup(TunSetupArgs),
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 12:53:04 +01:00
|
|
|
#[derive(Debug, Args)]
|
|
|
|
|
struct StatusArgs {
|
|
|
|
|
#[command(flatten)]
|
|
|
|
|
paths: PathArgs,
|
|
|
|
|
|
|
|
|
|
/// Control socket to talk to. Derived from the state directory by default.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
control_socket: Option<PathBuf>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 12:23:37 +01:00
|
|
|
#[derive(Debug, Args)]
|
|
|
|
|
struct TunSetupArgs {
|
|
|
|
|
#[command(flatten)]
|
|
|
|
|
paths: PathArgs,
|
|
|
|
|
|
|
|
|
|
/// Network name, exactly as passed to `tsunagi up`.
|
|
|
|
|
#[arg(long, short = 'n')]
|
|
|
|
|
network: String,
|
|
|
|
|
|
|
|
|
|
/// The shared secret.
|
|
|
|
|
#[arg(
|
|
|
|
|
long,
|
|
|
|
|
short = 's',
|
|
|
|
|
env = "TSUNAGI_SECRET",
|
|
|
|
|
conflicts_with = "secret_file"
|
|
|
|
|
)]
|
|
|
|
|
secret: Option<String>,
|
|
|
|
|
|
|
|
|
|
/// Read the shared secret from a file instead of the command line.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
secret_file: Option<PathBuf>,
|
|
|
|
|
|
|
|
|
|
/// The user that should own the interface. Defaults to the current one.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
user: Option<String>,
|
|
|
|
|
|
|
|
|
|
/// Interface name prefix, matching `tsunagi up --wg-prefix`.
|
|
|
|
|
#[arg(long, default_value = "tsun")]
|
|
|
|
|
wg_prefix: String,
|
|
|
|
|
|
2026-09-21 12:41:57 +01:00
|
|
|
/// Interface MTU, matching `tsunagi up --wg-mtu`. At least 1280.
|
2026-09-21 12:23:37 +01:00
|
|
|
#[arg(long)]
|
|
|
|
|
wg_mtu: Option<u32>,
|
2026-09-21 13:00:35 +01:00
|
|
|
|
|
|
|
|
/// Match `tsunagi up --ipv4-range`.
|
2026-09-21 13:11:09 +01:00
|
|
|
#[arg(long, value_name = "CIDR")]
|
2026-09-21 13:00:35 +01:00
|
|
|
ipv4_range: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 13:11:09 +01:00
|
|
|
/// Resolves the IPv4 overlay range from the flag.
|
2026-09-21 13:43:01 +01:00
|
|
|
///
|
|
|
|
|
/// Absent means the built-in default. A network that already settled on
|
|
|
|
|
/// another range wins over both.
|
2026-09-21 13:00:35 +01:00
|
|
|
fn resolve_ipv4_range(
|
|
|
|
|
range: Option<&String>,
|
2026-09-21 13:11:09 +01:00
|
|
|
) -> Result<Option<Ipv4Range>, Box<dyn std::error::Error>> {
|
2026-09-21 13:00:35 +01:00
|
|
|
match range {
|
2026-09-21 13:43:01 +01:00
|
|
|
Some(text) if text.eq_ignore_ascii_case("none") => Ok(None),
|
|
|
|
|
Some(text) => Ok(Some(
|
|
|
|
|
text.parse::<Ipv4Range>()
|
|
|
|
|
.map_err(|err| format!("--ipv4-range {text}: {err}"))?,
|
|
|
|
|
)),
|
|
|
|
|
None => Ok(Some(tsunagi::state::DEFAULT_IPV4_RANGE)),
|
2026-09-21 13:00:35 +01:00
|
|
|
}
|
2026-09-21 11:55:20 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Args, Clone)]
|
|
|
|
|
struct PathArgs {
|
|
|
|
|
/// Directory for the mandatory state. Defaults to the platform location.
|
|
|
|
|
#[arg(long, env = "TSUNAGI_STATE_DIR")]
|
|
|
|
|
state_dir: Option<PathBuf>,
|
|
|
|
|
/// Directory for the disposable cache. Defaults to the platform location.
|
|
|
|
|
#[arg(long, env = "TSUNAGI_CACHE_DIR")]
|
|
|
|
|
cache_dir: Option<PathBuf>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PathArgs {
|
|
|
|
|
fn resolve(&self) -> Result<StoragePaths, tsunagi::Error> {
|
|
|
|
|
let mut paths = StoragePaths::user_default()?;
|
|
|
|
|
if let Some(dir) = &self.state_dir {
|
|
|
|
|
paths.state_dir = dir.clone();
|
|
|
|
|
}
|
|
|
|
|
if let Some(dir) = &self.cache_dir {
|
|
|
|
|
paths.cache_dir = dir.clone();
|
|
|
|
|
}
|
|
|
|
|
Ok(paths)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// How much external connectivity machinery the endpoint may use.
|
2026-09-21 12:08:48 +01:00
|
|
|
///
|
2026-09-21 12:17:01 +01:00
|
|
|
/// `direct` and `relay` publish this endpoint's addresses, keyed by its
|
|
|
|
|
/// endpoint id, to the public lookup service run by Number 0 — the company
|
|
|
|
|
/// behind iroh — at `dns.iroh.link`, and resolve peers through it. That is
|
|
|
|
|
/// what makes `--peer <endpoint-id>` work without an address.
|
2026-09-21 11:55:20 +01:00
|
|
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
|
|
|
enum Transport {
|
2026-09-21 12:08:48 +01:00
|
|
|
/// Loopback and the local network only. Publishes nothing.
|
2026-09-21 11:55:20 +01:00
|
|
|
Local,
|
2026-09-21 12:17:01 +01:00
|
|
|
/// Public address lookup, direct paths only, no relays.
|
2026-09-21 11:55:20 +01:00
|
|
|
Direct,
|
2026-09-21 12:17:01 +01:00
|
|
|
/// Public address lookup plus public relay fallback. The default.
|
|
|
|
|
#[value(alias = "n0")]
|
|
|
|
|
Relay,
|
2026-09-21 11:55:20 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<Transport> for TransportPolicy {
|
|
|
|
|
fn from(value: Transport) -> Self {
|
|
|
|
|
match value {
|
|
|
|
|
Transport::Local => TransportPolicy::LocalOnly,
|
|
|
|
|
Transport::Direct => TransportPolicy::DirectOnly,
|
2026-09-21 12:17:01 +01:00
|
|
|
Transport::Relay => TransportPolicy::N0Defaults,
|
2026-09-21 11:55:20 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Args)]
|
|
|
|
|
struct UpArgs {
|
|
|
|
|
#[command(flatten)]
|
|
|
|
|
paths: PathArgs,
|
|
|
|
|
|
|
|
|
|
/// Network name. Must be identical on every participant.
|
|
|
|
|
#[arg(long, short = 'n')]
|
|
|
|
|
network: String,
|
|
|
|
|
|
|
|
|
|
/// The shared secret, as printed by `tsunagi secret`.
|
|
|
|
|
#[arg(
|
|
|
|
|
long,
|
|
|
|
|
short = 's',
|
|
|
|
|
env = "TSUNAGI_SECRET",
|
|
|
|
|
conflicts_with = "secret_file"
|
|
|
|
|
)]
|
|
|
|
|
secret: Option<String>,
|
|
|
|
|
|
|
|
|
|
/// Read the shared secret from a file instead of the command line.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
secret_file: Option<PathBuf>,
|
|
|
|
|
|
|
|
|
|
/// Hostname to announce. Defaults to the machine's.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
hostname: Option<String>,
|
|
|
|
|
|
|
|
|
|
/// How much external connectivity to use.
|
2026-09-21 12:17:01 +01:00
|
|
|
#[arg(long, value_enum, default_value_t = Transport::Relay)]
|
2026-09-21 11:55:20 +01:00
|
|
|
transport: Transport,
|
|
|
|
|
|
|
|
|
|
/// A peer to contact, as `<endpoint-id>` or `<endpoint-id>@<ip:port>,...`.
|
|
|
|
|
///
|
|
|
|
|
/// One agent needs to know another to begin with. Repeat for several.
|
|
|
|
|
#[arg(long = "peer", value_name = "PEER")]
|
|
|
|
|
peers: Vec<String>,
|
|
|
|
|
|
|
|
|
|
/// Local address to bind. Repeat for several; defaults to iroh's choice.
|
|
|
|
|
#[arg(long = "bind", value_name = "ADDR")]
|
|
|
|
|
binds: Vec<SocketAddr>,
|
|
|
|
|
|
|
|
|
|
/// Run the WireGuard data plane.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
wireguard: bool,
|
|
|
|
|
|
|
|
|
|
/// Do not create a real network interface.
|
|
|
|
|
///
|
|
|
|
|
/// The WireGuard tunnels still run and handshake, so the mesh can be
|
|
|
|
|
/// verified with no privileges; traffic just does not reach the
|
|
|
|
|
/// operating system.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
no_tun: bool,
|
|
|
|
|
|
2026-09-21 14:33:19 +01:00
|
|
|
/// How the overlay interface is obtained.
|
|
|
|
|
///
|
|
|
|
|
/// `managed` has the agent create and configure it itself, which needs
|
|
|
|
|
/// CAP_NET_ADMIN and cleans up on exit. `attach` opens an interface that
|
|
|
|
|
/// was prepared beforehand (see `tsunagi tun-setup`) and needs no
|
|
|
|
|
/// privileges. `auto` manages it when it can and attaches when it cannot.
|
|
|
|
|
#[arg(long, value_enum, default_value_t = InterfaceMode::Auto)]
|
|
|
|
|
interface: InterfaceMode,
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
/// Interface name prefix for the WireGuard data plane.
|
|
|
|
|
#[arg(long, default_value = "tsun")]
|
|
|
|
|
wg_prefix: String,
|
|
|
|
|
|
|
|
|
|
/// Interface MTU for the WireGuard data plane.
|
2026-09-21 12:41:57 +01:00
|
|
|
///
|
|
|
|
|
/// Must be at least 1280, the minimum IPv6 requires.
|
2026-09-21 11:55:20 +01:00
|
|
|
#[arg(long)]
|
|
|
|
|
wg_mtu: Option<u32>,
|
|
|
|
|
|
2026-09-21 13:43:01 +01:00
|
|
|
/// IPv4 overlay range, as `address/prefix`, or `none` to disable IPv4.
|
2026-09-21 13:11:09 +01:00
|
|
|
///
|
2026-09-21 13:43:01 +01:00
|
|
|
/// Defaults to 10.13.37.0/24. Only the first member to join decides:
|
|
|
|
|
/// a network that has already settled on a range wins, and a joining
|
|
|
|
|
/// agent adopts what it finds. Addresses are allocated from it and
|
|
|
|
|
/// recorded in signed state, so each member keeps its own across
|
|
|
|
|
/// restarts and long absences.
|
2026-09-21 13:11:09 +01:00
|
|
|
#[arg(long, value_name = "CIDR")]
|
2026-09-21 13:00:35 +01:00
|
|
|
ipv4_range: Option<String>,
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
/// How often to print a status summary, in seconds. Zero disables it.
|
|
|
|
|
#[arg(long, default_value_t = 15)]
|
|
|
|
|
status_interval: u64,
|
2026-09-21 12:53:04 +01:00
|
|
|
|
|
|
|
|
/// Control socket to serve. Derived from the state directory by default.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
control_socket: Option<PathBuf>,
|
2026-09-21 11:55:20 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-21 12:23:37 +01:00
|
|
|
/// Reads the shared secret from an argument or a file.
|
|
|
|
|
fn load_secret(
|
|
|
|
|
secret: Option<&str>,
|
|
|
|
|
secret_file: Option<&std::path::Path>,
|
|
|
|
|
) -> Result<NetworkSecret, Box<dyn std::error::Error>> {
|
|
|
|
|
let text = match (secret, secret_file) {
|
|
|
|
|
(Some(secret), _) => secret.to_string(),
|
|
|
|
|
(None, Some(path)) => std::fs::read_to_string(path)?,
|
|
|
|
|
(None, None) => {
|
|
|
|
|
return Err("provide --secret, --secret-file or TSUNAGI_SECRET".into());
|
2026-09-21 11:55:20 +01:00
|
|
|
}
|
2026-09-21 12:23:37 +01:00
|
|
|
};
|
|
|
|
|
let text = text.trim();
|
|
|
|
|
// The canonical form is preferred, but a raw high-entropy value is
|
|
|
|
|
// accepted so an existing secret can be reused.
|
|
|
|
|
match NetworkSecret::decode(text) {
|
|
|
|
|
Ok(secret) => Ok(secret),
|
|
|
|
|
Err(_) => Ok(NetworkSecret::from_bytes(text.as_bytes().to_vec())?),
|
2026-09-21 11:55:20 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parses `<endpoint-id>` or `<endpoint-id>@<ip:port>,<ip:port>`.
|
|
|
|
|
fn parse_peer(text: &str) -> Result<EndpointAddr, String> {
|
|
|
|
|
let (id_text, addr_text) = match text.split_once('@') {
|
|
|
|
|
Some((id, addrs)) => (id, Some(addrs)),
|
|
|
|
|
None => (text, None),
|
|
|
|
|
};
|
|
|
|
|
let id: tsunagi::iroh_types::EndpointId = id_text
|
|
|
|
|
.parse()
|
|
|
|
|
.map_err(|err| format!("`{id_text}` is not an endpoint id: {err}"))?;
|
|
|
|
|
let mut addr = EndpointAddr::new(id);
|
|
|
|
|
if let Some(addrs) = addr_text {
|
|
|
|
|
for entry in addrs.split(',') {
|
|
|
|
|
let socket: SocketAddr = entry
|
|
|
|
|
.trim()
|
|
|
|
|
.parse()
|
|
|
|
|
.map_err(|err| format!("`{entry}` is not an address: {err}"))?;
|
|
|
|
|
addr = addr.with_ip_addr(socket);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(addr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn main() -> std::process::ExitCode {
|
|
|
|
|
let cli = Cli::parse();
|
|
|
|
|
|
|
|
|
|
tracing_subscriber::fmt()
|
|
|
|
|
.with_env_filter(tracing_subscriber::EnvFilter::new(&cli.log))
|
|
|
|
|
.with_writer(std::io::stderr)
|
|
|
|
|
.init();
|
|
|
|
|
|
|
|
|
|
// The library never starts a runtime; this binary owns it.
|
|
|
|
|
let runtime = match tokio::runtime::Builder::new_multi_thread()
|
|
|
|
|
.enable_all()
|
|
|
|
|
.build()
|
|
|
|
|
{
|
|
|
|
|
Ok(runtime) => runtime,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
eprintln!("cannot start the async runtime: {err}");
|
|
|
|
|
return std::process::ExitCode::FAILURE;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match runtime.block_on(run(cli.command)) {
|
|
|
|
|
Ok(()) => std::process::ExitCode::SUCCESS,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
eprintln!("error: {err}");
|
|
|
|
|
std::process::ExitCode::FAILURE
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
match command {
|
|
|
|
|
Command::Secret => {
|
|
|
|
|
let secret = NetworkSecret::generate();
|
|
|
|
|
println!("{}", secret.encode().as_str());
|
|
|
|
|
eprintln!(
|
|
|
|
|
"\nShare this with every participant, over a channel you trust.\n\
|
|
|
|
|
Anyone who has it can join the network."
|
|
|
|
|
);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
Command::Doctor(paths) => doctor(paths).await,
|
|
|
|
|
Command::Id(paths) => show_id(paths).await,
|
|
|
|
|
Command::Up(args) => up(args).await,
|
2026-09-21 12:23:37 +01:00
|
|
|
Command::TunSetup(args) => tun_setup(args).await,
|
2026-09-21 12:53:04 +01:00
|
|
|
Command::Status(args) => status(args).await,
|
2026-09-21 11:55:20 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 14:04:44 +01:00
|
|
|
/// The IPv4 address this agent has already been allocated, if any.
|
|
|
|
|
///
|
|
|
|
|
/// Read straight from the mandatory state store. Opening it for reading does
|
|
|
|
|
/// not take the directory lock, so this works while the agent is running.
|
|
|
|
|
/// Records are verified here too: the database is not a trust boundary.
|
|
|
|
|
fn allocated_ipv4(
|
|
|
|
|
paths: &StoragePaths,
|
|
|
|
|
network: tsunagi::NetworkId,
|
|
|
|
|
) -> Result<Option<(std::net::Ipv4Addr, u8)>, Box<dyn std::error::Error>> {
|
|
|
|
|
use tsunagi::state::RecordBody;
|
|
|
|
|
use tsunagi::storage::StateStore;
|
|
|
|
|
|
|
|
|
|
let store = StateStore::open(paths.state_db())?;
|
|
|
|
|
let author = store.load_or_create_device_identity()?.endpoint_id();
|
|
|
|
|
for record in store.signed_records(network)? {
|
|
|
|
|
if record.author != *author.as_bytes() || record.verify(network).is_err() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let RecordBody::Ipv4Claim { address, range } = record.body {
|
|
|
|
|
return Ok(Some((address, range.prefix_len)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 12:53:04 +01:00
|
|
|
/// Path of the local control socket for a state directory.
|
|
|
|
|
fn control_socket(paths: &StoragePaths, override_path: Option<&PathBuf>) -> PathBuf {
|
|
|
|
|
match override_path {
|
|
|
|
|
Some(path) => path.clone(),
|
|
|
|
|
None => tsunagi::ipc::control_socket_path(&paths.state_dir),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
let paths = args.paths.resolve()?;
|
|
|
|
|
let socket = control_socket(&paths, args.control_socket.as_ref());
|
|
|
|
|
if !socket.exists() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"no agent is running for {} (no control socket at {})",
|
|
|
|
|
paths.state_dir.display(),
|
|
|
|
|
socket.display()
|
|
|
|
|
)
|
|
|
|
|
.into());
|
|
|
|
|
}
|
|
|
|
|
let report = tsunagi::ipc::unix::request_status(&socket)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|err| format!("cannot reach the agent at {}: {err}", socket.display()))?;
|
|
|
|
|
print!("{}", report.render());
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 12:23:37 +01:00
|
|
|
/// Works out the interface name and overlay address, then prints the
|
|
|
|
|
/// privileged commands that prepare it.
|
|
|
|
|
///
|
|
|
|
|
/// The address depends on this agent's WireGuard key for the network, so the
|
|
|
|
|
/// key store is opened (and the key created on first use) to compute it.
|
|
|
|
|
async fn tun_setup(args: TunSetupArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
use tsunagi::dataplane::wireguard::{
|
|
|
|
|
DEFAULT_MTU, OVERLAY_PREFIX_LEN, WgKeyStore, interface_name, overlay_address,
|
|
|
|
|
};
|
|
|
|
|
use tsunagi::identity::NetworkKeys;
|
|
|
|
|
|
|
|
|
|
let name = NetworkName::new(args.network.clone())?;
|
|
|
|
|
let secret = load_secret(args.secret.as_deref(), args.secret_file.as_deref())?;
|
|
|
|
|
let paths = args.paths.resolve()?;
|
|
|
|
|
let network = NetworkKeys::derive(&name, &secret).network_id();
|
|
|
|
|
|
|
|
|
|
let store_path = paths.state_dir.join("wireguard").join("wireguard.sqlite");
|
|
|
|
|
let store = tokio::task::spawn_blocking({
|
|
|
|
|
let store_path = store_path.clone();
|
|
|
|
|
move || WgKeyStore::open(store_path)
|
|
|
|
|
})
|
|
|
|
|
.await??;
|
|
|
|
|
let key = tokio::task::spawn_blocking(move || store.load_or_create(network)).await??;
|
|
|
|
|
|
|
|
|
|
let interface = interface_name(&args.wg_prefix, network)?;
|
|
|
|
|
let address = overlay_address(network, &key.public());
|
2026-09-21 13:11:09 +01:00
|
|
|
let ipv4_range = resolve_ipv4_range(args.ipv4_range.as_ref())?;
|
2026-09-21 12:23:37 +01:00
|
|
|
let mtu = args.wg_mtu.unwrap_or(DEFAULT_MTU);
|
|
|
|
|
let user = args.user.unwrap_or_else(|| {
|
|
|
|
|
std::env::var("SUDO_USER")
|
|
|
|
|
.or_else(|_| std::env::var("USER"))
|
|
|
|
|
.unwrap_or_else(|_| "$USER".to_string())
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
println!("# Network {name} ({network})");
|
|
|
|
|
println!("# Interface {interface}, address {address}/{OVERLAY_PREFIX_LEN}, mtu {mtu}");
|
2026-09-21 14:04:44 +01:00
|
|
|
// The IPv4 address is allocated at run time, so it can only be shown once
|
|
|
|
|
// the agent has one. Reading the state store does not disturb a running
|
|
|
|
|
// agent: the directory lock belongs to the agent, not to this reader.
|
|
|
|
|
let allocated_v4 = ipv4_range.and_then(|_| allocated_ipv4(&paths, network).ok().flatten());
|
|
|
|
|
match (ipv4_range, allocated_v4) {
|
|
|
|
|
(Some(_), Some((address, prefix_len))) => {
|
|
|
|
|
println!("# IPv4 overlay address {address}/{prefix_len}, allocated and signed");
|
|
|
|
|
}
|
|
|
|
|
(Some(_), None) => {
|
|
|
|
|
println!(
|
|
|
|
|
"# IPv4 is allocated once the agent runs and agrees with its peers, so\n\
|
|
|
|
|
# there is nothing to print yet. Start `tsunagi up`: it prints the exact\n\
|
|
|
|
|
# `ip address add` command for the address it was given, and this\n\
|
|
|
|
|
# command will include it from then on."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
(None, _) => {}
|
2026-09-21 13:00:35 +01:00
|
|
|
}
|
2026-09-21 12:36:16 +01:00
|
|
|
println!("# Run once as root; then run `tsunagi up` as {user}.");
|
|
|
|
|
println!(
|
|
|
|
|
"#\n\
|
|
|
|
|
# keep_addr_on_down matters: a persistent TUN interface has no carrier\n\
|
|
|
|
|
# until a process attaches, and Linux flushes IPv6 addresses from an\n\
|
|
|
|
|
# interface that loses carrier unless it is set. `nodad` matters for the\n\
|
|
|
|
|
# same reason: duplicate address detection can never finish without a\n\
|
|
|
|
|
# carrier, leaving the address tentative and unusable.\n"
|
|
|
|
|
);
|
2026-09-21 12:23:37 +01:00
|
|
|
println!("sudo ip tuntap add dev {interface} mode tun user {user}");
|
|
|
|
|
println!("sudo ip link set dev {interface} mtu {mtu} up");
|
2026-09-21 12:36:16 +01:00
|
|
|
println!("sudo sysctl -qw net.ipv6.conf.{interface}.keep_addr_on_down=1");
|
|
|
|
|
println!("sudo ip -6 address add {address}/{OVERLAY_PREFIX_LEN} dev {interface} nodad");
|
2026-09-21 14:04:44 +01:00
|
|
|
if let Some((address, prefix_len)) = allocated_v4 {
|
|
|
|
|
// IPv4 addresses are not flushed when an interface loses carrier, so
|
|
|
|
|
// this one needs none of the treatment IPv6 does.
|
|
|
|
|
println!("sudo ip address add {address}/{prefix_len} dev {interface}");
|
|
|
|
|
}
|
2026-09-21 13:53:42 +01:00
|
|
|
|
2026-09-21 12:36:16 +01:00
|
|
|
println!("\n# To check it afterwards:");
|
2026-09-21 14:04:44 +01:00
|
|
|
println!("ip addr show dev {interface}");
|
2026-09-21 12:23:37 +01:00
|
|
|
println!("\n# To remove it again:");
|
|
|
|
|
println!("sudo ip link del dev {interface}");
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
async fn show_id(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
let paths = paths.resolve()?;
|
|
|
|
|
println!("state directory {}", paths.state_dir.display());
|
|
|
|
|
println!("cache directory {}", paths.cache_dir.display());
|
|
|
|
|
|
|
|
|
|
let agent =
|
|
|
|
|
Agent::spawn(AgentConfig::new(paths).with_transport(TransportPolicy::LocalOnly)).await?;
|
|
|
|
|
println!("endpoint id {}", agent.endpoint_id());
|
|
|
|
|
println!("hostname {}", agent.hostname());
|
|
|
|
|
for network in agent.list_networks().await? {
|
|
|
|
|
println!(
|
|
|
|
|
"network {} ({}) auto-start={}",
|
|
|
|
|
network.name, network.network_id, network.auto_start
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
agent.shutdown().await;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn doctor(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
let paths = paths.resolve()?;
|
|
|
|
|
println!("tsunagi doctor\n");
|
|
|
|
|
|
|
|
|
|
println!("state directory {}", paths.state_dir.display());
|
|
|
|
|
println!("cache directory {}", paths.cache_dir.display());
|
|
|
|
|
match std::fs::create_dir_all(&paths.state_dir) {
|
|
|
|
|
Ok(()) => println!(" writable yes"),
|
|
|
|
|
Err(err) => println!(" writable NO ({err})"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
println!("\ncontrol plane");
|
|
|
|
|
println!(" needs outbound UDP; no privileges");
|
|
|
|
|
println!(" status always available");
|
|
|
|
|
|
|
|
|
|
println!("\ndata plane (WireGuard)");
|
|
|
|
|
println!(" implementation userspace (boringtun); no kernel module needed");
|
|
|
|
|
#[cfg(feature = "tun-device")]
|
|
|
|
|
{
|
|
|
|
|
let tun_path = std::path::Path::new("/dev/net/tun");
|
|
|
|
|
if cfg!(target_os = "linux") {
|
|
|
|
|
if tun_path.exists() {
|
|
|
|
|
match std::fs::OpenOptions::new()
|
|
|
|
|
.read(true)
|
|
|
|
|
.write(true)
|
|
|
|
|
.open(tun_path)
|
|
|
|
|
{
|
|
|
|
|
Ok(_) => println!(" /dev/net/tun openable"),
|
|
|
|
|
Err(err) => println!(" /dev/net/tun present but not openable ({err})"),
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
println!(" /dev/net/tun missing (load the `tun` module)");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
println!(" interfaces supported on this build");
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(feature = "tun-device"))]
|
|
|
|
|
println!(" interfaces not built in (enable the `tun-device` feature)");
|
|
|
|
|
|
2026-09-21 14:33:19 +01:00
|
|
|
#[cfg(feature = "tun-device")]
|
2026-09-21 11:55:20 +01:00
|
|
|
{
|
2026-09-21 14:33:19 +01:00
|
|
|
use tsunagi::dataplane::wireguard::{Privilege, probe_net_admin};
|
|
|
|
|
match probe_net_admin() {
|
|
|
|
|
Privilege::Available => {
|
|
|
|
|
println!(" privileges CAP_NET_ADMIN held");
|
|
|
|
|
println!(
|
|
|
|
|
" interface managed by the agent: created on start, \
|
|
|
|
|
removed on exit"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Privilege::Missing(reason) => {
|
|
|
|
|
println!(" privileges no CAP_NET_ADMIN ({reason})");
|
|
|
|
|
println!(" interface must be prepared first; run `tsunagi tun-setup`");
|
|
|
|
|
println!(
|
|
|
|
|
" to manage it {}",
|
|
|
|
|
Privilege::how_to_grant(&program_path())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Privilege::Unsupported => {
|
|
|
|
|
println!(
|
|
|
|
|
" privileges managing interfaces is not implemented on {} yet",
|
|
|
|
|
std::env::consts::OS
|
|
|
|
|
);
|
|
|
|
|
println!(" interface must be prepared first; run `tsunagi tun-setup`");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-21 11:55:20 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
println!("\nlocal addresses");
|
|
|
|
|
let state = netwatch_addresses().await;
|
|
|
|
|
if state.is_empty() {
|
|
|
|
|
println!(" none found");
|
|
|
|
|
}
|
|
|
|
|
for addr in state {
|
|
|
|
|
println!(" {addr}");
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 14:33:19 +01:00
|
|
|
/// This program's path, for an instruction the user can paste.
|
|
|
|
|
fn program_path() -> String {
|
|
|
|
|
std::env::current_exe()
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|path| path.to_str().map(str::to_string))
|
|
|
|
|
.unwrap_or_else(|| "tsunagi".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
async fn netwatch_addresses() -> Vec<std::net::IpAddr> {
|
|
|
|
|
// Best effort; used for diagnostics only.
|
|
|
|
|
let state = netwatch::interfaces::State::new().await;
|
|
|
|
|
let mut addresses = state.local_addresses.regular;
|
|
|
|
|
addresses.sort();
|
|
|
|
|
addresses.dedup();
|
|
|
|
|
addresses
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
let name = NetworkName::new(args.network.clone())?;
|
2026-09-21 12:23:37 +01:00
|
|
|
let secret = load_secret(args.secret.as_deref(), args.secret_file.as_deref())?;
|
2026-09-21 11:55:20 +01:00
|
|
|
let paths = args.paths.resolve()?;
|
|
|
|
|
|
2026-09-21 13:11:09 +01:00
|
|
|
// Parsed up front so a typo is reported immediately, and so the option is
|
|
|
|
|
// never silently ignored when the data plane is off.
|
|
|
|
|
let ipv4_range = resolve_ipv4_range(args.ipv4_range.as_ref())?;
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
let mut bootstrap: Vec<EndpointAddr> = Vec::new();
|
|
|
|
|
for peer in &args.peers {
|
|
|
|
|
bootstrap.push(parse_peer(peer)?);
|
|
|
|
|
}
|
|
|
|
|
let discovery: Arc<dyn NetworkDiscovery> =
|
|
|
|
|
Arc::new(CompositeDiscovery::new([
|
|
|
|
|
Arc::new(StaticBootstrap::new(bootstrap)) as Arc<dyn NetworkDiscovery>,
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
let mut config = AgentConfig::new(paths.clone())
|
2026-09-21 13:43:01 +01:00
|
|
|
.with_overlay_ipv4_range(ipv4_range)
|
2026-09-21 11:55:20 +01:00
|
|
|
.with_transport(args.transport.into())
|
|
|
|
|
.with_discovery(discovery)
|
|
|
|
|
.with_discovery_interval(Duration::from_secs(5));
|
|
|
|
|
if let Some(hostname) = &args.hostname {
|
|
|
|
|
config = config.with_hostname(hostname.clone());
|
|
|
|
|
}
|
|
|
|
|
if !args.binds.is_empty() {
|
|
|
|
|
config = config.with_bind_addrs(args.binds.clone());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The data plane is optional and never required for the control plane.
|
|
|
|
|
let wireguard = if args.wireguard {
|
|
|
|
|
let tun_factory: Arc<dyn TunFactory> = if args.no_tun {
|
|
|
|
|
Arc::new(MemoryTunFactory::new())
|
|
|
|
|
} else {
|
2026-09-21 14:33:19 +01:00
|
|
|
system_tun_factory(args.interface)?
|
2026-09-21 11:55:20 +01:00
|
|
|
};
|
|
|
|
|
let mut wg = WireguardConfig::new(paths.state_dir.join("wireguard"))
|
2026-09-21 13:43:01 +01:00
|
|
|
.with_interface_prefix(args.wg_prefix.clone());
|
2026-09-21 11:55:20 +01:00
|
|
|
if let Some(mtu) = args.wg_mtu {
|
|
|
|
|
wg = wg.with_mtu(mtu);
|
|
|
|
|
}
|
|
|
|
|
let plugin = WireguardPlugin::open(wg, tun_factory).await?;
|
|
|
|
|
config = config.with_plugin(plugin.clone() as Arc<dyn IpPlugin>);
|
|
|
|
|
Some(plugin)
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let agent = Agent::spawn(config).await?;
|
2026-09-21 12:08:48 +01:00
|
|
|
// From here on every exit goes through `agent.shutdown()`, so the endpoint
|
|
|
|
|
// is never dropped without being closed.
|
2026-09-21 11:55:20 +01:00
|
|
|
let mut events = agent.subscribe();
|
2026-09-21 12:08:48 +01:00
|
|
|
let network = match agent.join_network(&name, &secret).await {
|
|
|
|
|
Ok(network) => network,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
agent.shutdown().await;
|
|
|
|
|
return Err(err.into());
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-09-21 11:55:20 +01:00
|
|
|
|
|
|
|
|
println!("tsunagi is up");
|
|
|
|
|
println!(" endpoint id {}", agent.endpoint_id());
|
|
|
|
|
println!(" hostname {}", agent.hostname());
|
|
|
|
|
println!(" network {name} ({network})");
|
|
|
|
|
println!(" state {}", paths.state_dir.display());
|
|
|
|
|
if args.peers.is_empty() {
|
|
|
|
|
println!(
|
|
|
|
|
"\nNo --peer was given, so this agent waits to be contacted.\n\
|
|
|
|
|
On the other machine run:\n\n tsunagi up --network {name} --secret <secret> \\\n --peer {}\n",
|
|
|
|
|
agent.endpoint_id()
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-21 12:53:04 +01:00
|
|
|
// Serve `tsunagi status` for as long as this agent runs. Failing to bind
|
|
|
|
|
// is not fatal: the agent itself works fine without it.
|
|
|
|
|
let control = {
|
|
|
|
|
let agent = agent.clone();
|
|
|
|
|
let plugin = wireguard.clone();
|
|
|
|
|
let source: Arc<dyn tsunagi::ipc::unix::ReportSource> = Arc::new(
|
|
|
|
|
move || -> tsunagi::BoxFuture<'static, tsunagi::ipc::StatusReport> {
|
|
|
|
|
let agent = agent.clone();
|
|
|
|
|
let plugin = plugin.clone();
|
|
|
|
|
Box::pin(async move { build_report(&agent, plugin.as_deref()).await })
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
let path = control_socket(&paths, args.control_socket.as_ref());
|
|
|
|
|
match tsunagi::ipc::unix::ControlSocket::bind(path, source).await {
|
|
|
|
|
Ok(socket) => {
|
|
|
|
|
println!(" control {}", socket.path().display());
|
|
|
|
|
Some(socket)
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
eprintln!("warning: `tsunagi status` will not work: {err}");
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
println!("Press Ctrl-C to stop.\n");
|
|
|
|
|
|
|
|
|
|
let status_every =
|
|
|
|
|
(args.status_interval > 0).then(|| Duration::from_secs(args.status_interval));
|
|
|
|
|
let mut ticker = status_every.map(tokio::time::interval);
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
tokio::select! {
|
2026-09-21 12:08:48 +01:00
|
|
|
reason = stop_signal() => {
|
|
|
|
|
println!("\nstopping ({reason})...");
|
2026-09-21 11:55:20 +01:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
event = events.recv() => match event {
|
|
|
|
|
Ok(event) => print_event(&event),
|
|
|
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
|
|
|
|
println!(" (missed {skipped} events)");
|
|
|
|
|
}
|
|
|
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
|
|
|
},
|
|
|
|
|
_ = async {
|
|
|
|
|
match ticker.as_mut() {
|
|
|
|
|
Some(ticker) => { ticker.tick().await; }
|
|
|
|
|
None => std::future::pending::<()>().await,
|
|
|
|
|
}
|
|
|
|
|
}, if ticker.is_some() => {
|
|
|
|
|
print_status(&agent, network, wireguard.as_deref()).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 12:53:04 +01:00
|
|
|
if let Some(control) = control {
|
|
|
|
|
control.shutdown().await;
|
|
|
|
|
}
|
2026-09-21 11:55:20 +01:00
|
|
|
agent.shutdown().await;
|
|
|
|
|
println!("stopped.");
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 12:53:04 +01:00
|
|
|
/// Collects a status report from the agent and, when present, the WireGuard
|
|
|
|
|
/// plugin. The two are combined here because only this binary knows about
|
|
|
|
|
/// both.
|
|
|
|
|
async fn build_report(
|
|
|
|
|
agent: &Agent,
|
|
|
|
|
wireguard: Option<&WireguardPlugin>,
|
|
|
|
|
) -> tsunagi::ipc::StatusReport {
|
|
|
|
|
use tsunagi::ipc::{NetworkReport, OverlayPeerReport, OverlayReport, PeerReport, StatusReport};
|
|
|
|
|
|
|
|
|
|
let Ok(status) = agent.status().await else {
|
|
|
|
|
return StatusReport::default();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let networks = status
|
|
|
|
|
.networks
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|network| {
|
|
|
|
|
let overlay = wireguard
|
|
|
|
|
.and_then(|plugin| plugin.overview(network.network_id))
|
|
|
|
|
.map(|view| OverlayReport {
|
|
|
|
|
interface: view.interface.clone(),
|
|
|
|
|
mtu: view.mtu,
|
|
|
|
|
address: view.overlay_address.to_string(),
|
2026-09-21 13:00:35 +01:00
|
|
|
address_v4: view.overlay_address_v4.map(|addr| addr.to_string()),
|
2026-09-21 12:53:04 +01:00
|
|
|
prefix: view.overlay_prefix.to_string(),
|
|
|
|
|
prefix_len: view.overlay_prefix_len,
|
|
|
|
|
peers: view
|
|
|
|
|
.peers
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|peer| OverlayPeerReport {
|
|
|
|
|
public_key: peer.public_key.to_string(),
|
|
|
|
|
address: peer.overlay_address.to_string(),
|
2026-09-21 13:00:35 +01:00
|
|
|
address_v4: peer.overlay_address_v4.map(|addr| addr.to_string()),
|
2026-09-21 12:53:04 +01:00
|
|
|
handshake_secs_ago: peer
|
|
|
|
|
.tunnel
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|tunnel| tunnel.health.since_handshake)
|
|
|
|
|
.map(|since| since.as_secs()),
|
|
|
|
|
tx_packets: peer
|
|
|
|
|
.tunnel
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map_or(0, |tunnel| tunnel.stats.tx_packets),
|
|
|
|
|
rx_packets: peer
|
|
|
|
|
.tunnel
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map_or(0, |tunnel| tunnel.stats.rx_packets),
|
|
|
|
|
dropped: peer.tunnel.as_ref().map_or(0, |tunnel| {
|
|
|
|
|
tunnel.stats.dropped_wrong_source + tunnel.stats.dropped_oversize
|
|
|
|
|
}),
|
|
|
|
|
protocol_errors: peer
|
|
|
|
|
.tunnel
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map_or(0, |tunnel| tunnel.stats.protocol_errors),
|
|
|
|
|
path: peer
|
|
|
|
|
.tunnel
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|tunnel| tunnel.path.clone())
|
|
|
|
|
.unwrap_or_else(|| "no data link".into()),
|
|
|
|
|
})
|
|
|
|
|
.collect(),
|
|
|
|
|
unroutable_packets: view.unroutable_packets,
|
|
|
|
|
multicast_packets: view.multicast_packets,
|
2026-09-21 13:53:42 +01:00
|
|
|
unroutable_sample: view.unroutable_sample.map(|address| address.to_string()),
|
2026-09-21 12:53:04 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
NetworkReport {
|
|
|
|
|
name: network.name.to_string(),
|
|
|
|
|
network_id: network.network_id.to_string(),
|
|
|
|
|
active: matches!(network.state, tsunagi::agent::NetworkState::Active),
|
|
|
|
|
peers: network
|
|
|
|
|
.peers
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|peer| PeerReport {
|
|
|
|
|
endpoint_id: peer.endpoint_id.to_string(),
|
|
|
|
|
hostname: peer.hostname.clone(),
|
|
|
|
|
transport: format!("{:?}", peer.transport),
|
|
|
|
|
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
|
|
|
|
|
})
|
|
|
|
|
.collect(),
|
|
|
|
|
dial_failures: network.metrics.dial_failures,
|
|
|
|
|
handshake_failures: network.metrics.handshake_failures,
|
|
|
|
|
control_messages: (
|
|
|
|
|
network.metrics.control_messages_sent,
|
|
|
|
|
network.metrics.control_messages_received,
|
|
|
|
|
),
|
|
|
|
|
overlay,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
StatusReport {
|
|
|
|
|
endpoint_id: status.endpoint_id.to_string(),
|
|
|
|
|
hostname: status.hostname.clone(),
|
|
|
|
|
bound_sockets: status
|
|
|
|
|
.bound_sockets
|
|
|
|
|
.iter()
|
|
|
|
|
.map(ToString::to_string)
|
|
|
|
|
.collect(),
|
|
|
|
|
cache_healthy: status.cache_healthy,
|
|
|
|
|
networks,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 12:08:48 +01:00
|
|
|
/// Resolves when the process is asked to stop.
|
|
|
|
|
///
|
|
|
|
|
/// Both Ctrl-C and `SIGTERM` are handled, so a service manager stopping the
|
|
|
|
|
/// agent gets the same clean shutdown an interactive user does.
|
|
|
|
|
async fn stop_signal() -> &'static str {
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
{
|
|
|
|
|
use tokio::signal::unix::{SignalKind, signal};
|
|
|
|
|
let mut terminate = match signal(SignalKind::terminate()) {
|
|
|
|
|
Ok(stream) => stream,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
eprintln!("cannot listen for SIGTERM: {err}");
|
|
|
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
|
|
|
return "interrupted";
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
tokio::select! {
|
|
|
|
|
_ = tokio::signal::ctrl_c() => "interrupted",
|
|
|
|
|
_ = terminate.recv() => "terminated",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(unix))]
|
|
|
|
|
{
|
|
|
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
|
|
|
"interrupted"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 14:33:19 +01:00
|
|
|
/// How the overlay interface is obtained.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
|
|
|
|
|
enum InterfaceMode {
|
|
|
|
|
/// Manage it when possible, attach to a prepared one otherwise.
|
|
|
|
|
Auto,
|
|
|
|
|
/// Create and configure it in process. Needs CAP_NET_ADMIN.
|
|
|
|
|
Managed,
|
|
|
|
|
/// Open an interface prepared beforehand. Needs no privileges.
|
|
|
|
|
Attach,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds the interface factory for the chosen mode.
|
|
|
|
|
///
|
|
|
|
|
/// The managed path is preferred because it is the one that cleans up after
|
|
|
|
|
/// itself: the interface is tied to an open file descriptor, so it goes away
|
|
|
|
|
/// when the agent does, however the agent goes away.
|
2026-09-21 11:55:20 +01:00
|
|
|
#[cfg(feature = "tun-device")]
|
2026-09-21 14:33:19 +01:00
|
|
|
fn system_tun_factory(
|
|
|
|
|
mode: InterfaceMode,
|
|
|
|
|
) -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
|
2026-09-21 11:55:20 +01:00
|
|
|
use tsunagi::dataplane::wireguard::SystemTunFactory;
|
2026-09-21 14:33:19 +01:00
|
|
|
|
|
|
|
|
if mode == InterfaceMode::Attach {
|
|
|
|
|
return Ok(Arc::new(SystemTunFactory::new()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match managed_tun_factory() {
|
|
|
|
|
Ok(factory) => Ok(factory),
|
|
|
|
|
Err(err) if mode == InterfaceMode::Managed => Err(err),
|
|
|
|
|
Err(err) => {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
"{err} Falling back to attaching to a prepared interface; \
|
|
|
|
|
`tsunagi tun-setup` prints how to make one."
|
|
|
|
|
);
|
|
|
|
|
Ok(Arc::new(SystemTunFactory::new()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
|
|
|
|
fn managed_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
|
|
|
|
|
use tsunagi::dataplane::wireguard::{ManagedTunFactory, NetlinkProvisioner};
|
|
|
|
|
let provisioner = NetlinkProvisioner::new()?;
|
|
|
|
|
Ok(Arc::new(ManagedTunFactory::new(Arc::new(provisioner))))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// There is no provisioner for this platform yet.
|
|
|
|
|
///
|
|
|
|
|
/// Refused here rather than at the first packet, so `auto` falls back to
|
|
|
|
|
/// attaching and `--interface managed` says plainly why it cannot.
|
|
|
|
|
#[cfg(all(feature = "tun-device", not(target_os = "linux")))]
|
|
|
|
|
fn managed_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
|
|
|
|
|
Err(format!(
|
|
|
|
|
"managing the overlay interface is not implemented on {} yet.",
|
|
|
|
|
std::env::consts::OS
|
|
|
|
|
)
|
|
|
|
|
.into())
|
2026-09-21 11:55:20 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(feature = "tun-device"))]
|
2026-09-21 14:33:19 +01:00
|
|
|
fn system_tun_factory(
|
|
|
|
|
_mode: InterfaceMode,
|
|
|
|
|
) -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
|
2026-09-21 11:55:20 +01:00
|
|
|
Err("this build has no interface support; rebuild with the `tun-device` feature or pass --no-tun".into())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_event(event: &Event) {
|
|
|
|
|
match event {
|
|
|
|
|
Event::PeerConnected {
|
|
|
|
|
peer,
|
|
|
|
|
transport,
|
|
|
|
|
rtt,
|
|
|
|
|
..
|
|
|
|
|
} => println!(
|
|
|
|
|
" + peer {} connected over {transport:?} rtt={rtt:?}",
|
|
|
|
|
peer.fmt_short()
|
|
|
|
|
),
|
|
|
|
|
Event::PeerDisconnected { peer, reason, .. } => {
|
|
|
|
|
println!(" - peer {} gone: {reason}", peer.fmt_short())
|
|
|
|
|
}
|
|
|
|
|
Event::DataLinkUp {
|
|
|
|
|
peer,
|
|
|
|
|
protocol,
|
|
|
|
|
path,
|
|
|
|
|
max_datagram,
|
|
|
|
|
..
|
|
|
|
|
} => println!(
|
|
|
|
|
" + data link to {} for {protocol}: {path}, datagram {max_datagram}",
|
|
|
|
|
peer.fmt_short()
|
|
|
|
|
),
|
|
|
|
|
Event::DataLinkDown {
|
|
|
|
|
peer,
|
|
|
|
|
protocol,
|
|
|
|
|
reason,
|
|
|
|
|
..
|
|
|
|
|
} => println!(
|
|
|
|
|
" - data link to {} for {protocol}: {reason}",
|
|
|
|
|
peer.fmt_short()
|
|
|
|
|
),
|
|
|
|
|
Event::HandshakeRejected { peer, reason, .. } => println!(
|
|
|
|
|
" ! rejected {}: {reason}",
|
|
|
|
|
peer.map(|peer| peer.fmt_short().to_string())
|
|
|
|
|
.unwrap_or_else(|| "a caller".into())
|
|
|
|
|
),
|
|
|
|
|
Event::PluginError {
|
|
|
|
|
protocol, reason, ..
|
|
|
|
|
} => println!(" ! {protocol}: {reason}"),
|
|
|
|
|
Event::CacheReset { reason } => println!(" ! cache was reset: {reason}"),
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn print_status(agent: &Agent, network: NetworkId, wireguard: Option<&WireguardPlugin>) {
|
|
|
|
|
let Ok(status) = agent.network_status(network).await else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
println!("\n--- status ---");
|
|
|
|
|
println!(
|
|
|
|
|
"control: {} peer(s), {} dial failure(s), {} handshake failure(s)",
|
|
|
|
|
status.peers.len(),
|
|
|
|
|
status.metrics.dial_failures,
|
|
|
|
|
status.metrics.handshake_failures
|
|
|
|
|
);
|
|
|
|
|
for peer in &status.peers {
|
|
|
|
|
println!(
|
|
|
|
|
" {} {} {:?} rtt={:?}",
|
|
|
|
|
peer.endpoint_id.fmt_short(),
|
|
|
|
|
peer.hostname.as_deref().unwrap_or("?"),
|
|
|
|
|
peer.transport,
|
|
|
|
|
peer.rtt
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(plugin) = wireguard
|
|
|
|
|
&& let Some(view) = plugin.overview(network)
|
|
|
|
|
{
|
|
|
|
|
println!(
|
|
|
|
|
"wireguard: {} on {}/{} mtu {}, {}/{} tunnel(s) established",
|
|
|
|
|
view.interface,
|
|
|
|
|
view.overlay_address,
|
|
|
|
|
view.overlay_prefix_len,
|
|
|
|
|
view.mtu,
|
|
|
|
|
view.established_peers(),
|
|
|
|
|
view.peers.len()
|
|
|
|
|
);
|
|
|
|
|
for peer in &view.peers {
|
|
|
|
|
match &peer.tunnel {
|
|
|
|
|
Some(tunnel) => println!(
|
|
|
|
|
" {} {} {} tx={} rx={} dropped={} path={}",
|
|
|
|
|
peer.public_key.fmt_short(),
|
|
|
|
|
peer.overlay_address,
|
|
|
|
|
match tunnel.health.since_handshake {
|
|
|
|
|
Some(since) => format!("handshake {}s ago", since.as_secs()),
|
|
|
|
|
None => "NOT HANDSHAKEN".to_string(),
|
|
|
|
|
},
|
|
|
|
|
tunnel.stats.tx_packets,
|
|
|
|
|
tunnel.stats.rx_packets,
|
|
|
|
|
tunnel.stats.dropped_wrong_source + tunnel.stats.dropped_oversize,
|
|
|
|
|
tunnel.path
|
|
|
|
|
),
|
|
|
|
|
None => println!(
|
|
|
|
|
" {} {} waiting for a data link",
|
|
|
|
|
peer.public_key.fmt_short(),
|
|
|
|
|
peer.overlay_address
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if view.unroutable_packets > 0 {
|
|
|
|
|
println!(
|
|
|
|
|
" {} packet(s) for unknown addresses",
|
|
|
|
|
view.unroutable_packets
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
println!();
|
|
|
|
|
}
|