When a peer belongs to multiple overlay networks served by one agent (e.g.
a VM participating in both 'hexor' and 'ultradesu'), packets received on
the shared WireGuard UDP socket could not be differentiated by source IP/port
alone. Previously, `WireguardTransport::accept_loop` picked the first
matching link found in iteration order, starving other networks for that peer
and causing rekey timeouts (`WARN HANDSHAKE(REKEY_TIMEOUT)`,
`ERROR CONNECTION_EXPIRED(REKEY_ATTEMPT_TIME)`) and packet loss on the
starved network.
Additionally:
- When a peer had multiple candidate endpoints (such as LAN, VM bridge, and
Tailscale CGNAT addresses), `WireguardLink` sent handshake initiations
only to `addrs[0]`. If the first address was unreachable from the remote
side (e.g. host's Tailscale IP unreachable from inside a local VM), the
handshake never completed.
- In `build_report`, peers known to the `wg-quic` plugin were added with
empty tunnels even if an active `wg` (pure WireGuard) tunnel existed,
causing `tsunagi status` to falsely report 'no WireGuard handshake yet'
for connected and pingable peers.
- `local_ip_candidates` previously included IPv6 addresses which caused
OS error 22 (EINVAL) when passed to the IPv4-bound UDP socket.
Key fixes:
- In `crates/tsunagi-wireguard/src/transport.rs`:
- Store `peer_networks` as `HashSet<NetworkId>` per peer.
- In `WireguardLink`, maintain a list of candidate addresses and a
`confirmed` flag. Before a handshake response confirms the active path,
transmit datagrams (handshake initiations) to all candidate addresses.
Upon receiving an inbound datagram, lock onto the confirmed address.
- In `accept_loop`, deliver inbound datagrams to all active links
associated with the sender's endpoint ID across all networks. Boringtun's
cryptographic index and AEAD verification naturally demultiplexes and
accepts the packet in the correct network while discarding it in others.
- Filter candidate addresses to IPv4 to match the bound socket family.
- In `crates/tsunagi-wireguard/src/plugin.rs`:
- Only collect IPv4 interfaces in `local_ip_candidates`.
- In `crates/tsunagi-cli/src/main.rs`:
- Refactor `build_report` to evaluate all protocols per peer and prioritize
whichever protocol has an active tunnel (`wg` > `tcp-tls` > `wg-quic`).
- In `crates/tsunagi-wireguard/tests/plugin_tests.rs`:
- Add `test_wireguard_two_networks_same_peers` verifying simultaneous
bidirectional packet transmission between two peers sharing two networks
over a single `WireguardTransport`.
Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (350+ tests passed).
4961 lines
184 KiB
Rust
4961 lines
184 KiB
Rust
//! 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::discovery::{
|
|
CompositeDiscovery, MainlineDiscovery, NetworkDiscovery, StaticBootstrap,
|
|
};
|
|
use tsunagi::identity::{NetworkName, NetworkSecret};
|
|
use tsunagi::iroh_types::{EndpointAddr, EndpointId};
|
|
use tsunagi::overlay::{MemoryTunFactory, TunFactory};
|
|
use tsunagi::state::Ipv4Range;
|
|
use tsunagi::{Agent, NetworkId};
|
|
use tsunagi_tcp_tls::{
|
|
TCP_TLS_PROTOCOL, TCP_TLS_VERSION, TcpTlsCodec, TcpTlsConfig, TcpTlsPlugin, TcpTlsTransport,
|
|
};
|
|
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
|
|
use tsunagi_wireguard::{
|
|
WG_PROTOCOL, WG_WIRE_VERSION, WireguardConfig as PureWgConfig, WireguardPlugin as PureWgPlugin,
|
|
WireguardTransport as PureWgTransport,
|
|
};
|
|
|
|
/// 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`.
|
|
///
|
|
/// Can also be set via the `TSUNAGI_LOG` or `RUST_LOG` environment variables.
|
|
#[arg(long, global = true)]
|
|
log: Option<String>,
|
|
|
|
#[command(subcommand)]
|
|
command: Command,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum Command {
|
|
/// Shows this device's identity, and changes it.
|
|
///
|
|
/// Every item follows the same shape: name it to see it, name it with a
|
|
/// value to change it.
|
|
Id(IdArgs),
|
|
/// Runs the agent until interrupted: the device's one process.
|
|
///
|
|
/// It serves every network this device has joined, answers `status`,
|
|
/// and is what `join`, `leave`, `stop` and `dns` talk to. Which
|
|
/// networks it is in is decided separately, and can change while it
|
|
/// runs.
|
|
// Boxed: it is much larger than the other variants, and every command
|
|
// but this one would otherwise pay for its size. A `//` comment, not a
|
|
// `///` one, or clap would print it as help.
|
|
Up(Box<UpArgs>),
|
|
/// Reports this device, what the agent is doing, and what this host can do.
|
|
Status(StatusArgs),
|
|
/// Shows the protocols this build can carry packets with.
|
|
Protocols,
|
|
/// Makes a network, or joins one — the command that does the work.
|
|
///
|
|
/// The same as `tsunagi network join`, at the top level because it is
|
|
/// what gets typed: `up` runs the agent, this decides what it is in.
|
|
Join(JoinArgs),
|
|
/// Shows the networks this device belongs to, and leaves them.
|
|
Network(NetworkArgs),
|
|
/// Shows the local resolver, and turns it on or off.
|
|
Dns(DnsArgs),
|
|
/// Removes everything this device has stored and starts over.
|
|
Wipe(WipeArgs),
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct DnsArgs {
|
|
#[command(flatten)]
|
|
paths: PathArgs,
|
|
|
|
/// Control socket to talk to. Derived from the state directory by default.
|
|
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET", global = true)]
|
|
control_socket: Option<PathBuf>,
|
|
|
|
#[command(subcommand)]
|
|
action: Option<DnsAction>,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum DnsAction {
|
|
/// Starts serving, now and after every restart.
|
|
On {
|
|
/// Port to listen on, on loopback of both families.
|
|
#[arg(long, value_name = "PORT")]
|
|
port: Option<u16>,
|
|
},
|
|
/// Stops serving, now and after every restart.
|
|
Off,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct NetworkArgs {
|
|
#[command(flatten)]
|
|
paths: PathArgs,
|
|
|
|
/// Control socket to talk to. Derived from the state directory by default.
|
|
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET", global = true)]
|
|
control_socket: Option<PathBuf>,
|
|
|
|
#[command(subcommand)]
|
|
action: Option<NetworkAction>,
|
|
}
|
|
|
|
/// Making or joining one network.
|
|
///
|
|
/// The name alone is enough: this device's network of that name if it has
|
|
/// one, and otherwise a new network with a secret invented here and printed
|
|
/// so it can be passed on.
|
|
#[derive(Debug, Args)]
|
|
struct JoinArgs {
|
|
/// Enable LAN UDP broadcast relay for this network (default for new networks).
|
|
#[arg(long, conflicts_with = "no_broadcast")]
|
|
broadcast: bool,
|
|
/// Disable LAN UDP broadcast relay for this network, including after restart.
|
|
#[arg(long, conflicts_with = "broadcast")]
|
|
no_broadcast: bool,
|
|
|
|
#[command(flatten)]
|
|
paths: PathArgs,
|
|
|
|
/// Control socket to talk to. Derived from the state directory by default.
|
|
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET")]
|
|
control_socket: Option<PathBuf>,
|
|
|
|
/// Network name. Must be identical on every participant.
|
|
#[arg(long, short = 'n')]
|
|
network: String,
|
|
|
|
/// The shared secret, as printed when a network is made.
|
|
#[arg(long, short = 's', env = "TSUNAGI_SECRET")]
|
|
secret: Option<String>,
|
|
|
|
/// Read the shared secret from a file instead of the command line.
|
|
#[arg(long, conflicts_with = "secret")]
|
|
secret_file: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum NetworkAction {
|
|
/// Shows or changes this network's local LAN broadcast participation.
|
|
Broadcast {
|
|
/// Exact network name, full id or unique id prefix.
|
|
network: String,
|
|
/// New choice; omit to show the stored choice.
|
|
#[arg(value_enum)]
|
|
choice: Option<BroadcastChoice>,
|
|
},
|
|
/// Makes a network, or joins one, in the agent that is already running.
|
|
///
|
|
/// The state directory belongs to one live agent, so this is how a
|
|
/// network is added to it, and it takes effect at once. With no agent
|
|
/// running it is configured and starts with the next `tsunagi up`.
|
|
/// `tsunagi join` is the same command, spelled shorter.
|
|
Join(JoinArgs),
|
|
/// Stops serving a network, keeping everything so it can be resumed.
|
|
///
|
|
/// Not leaving: the configuration, the secret, the address and the
|
|
/// signed state all stay. Sessions close and the address comes off the
|
|
/// interface, and nothing is announced — to the others this device is
|
|
/// simply away, as if it had been switched off. It stays stopped
|
|
/// across restarts until `tsunagi network start`.
|
|
Stop {
|
|
/// Exact network name, full id or unique id prefix.
|
|
network: String,
|
|
},
|
|
/// Serves a stopped network again, from where it left off.
|
|
Start {
|
|
/// Exact network name, full id or unique id prefix.
|
|
network: String,
|
|
},
|
|
/// Gives up this device's address and name in a network, and forgets it.
|
|
///
|
|
/// A signed release goes out first, so the address and name are freed
|
|
/// for the others rather than staying reserved to a member that has
|
|
/// gone. That needs the agent running; without it nothing can be sent.
|
|
///
|
|
/// Everything local goes: the configuration, the secret, this
|
|
/// network's signed records, its cached hints and the protocol key it
|
|
/// used. `stop` is the one that keeps them.
|
|
Leave {
|
|
/// Exact network name, full id or unique id prefix.
|
|
/// If the name is shared, use the id to select one network.
|
|
network: String,
|
|
|
|
/// Remove it without telling anybody.
|
|
///
|
|
/// For a network nobody else is in, or one joined with a mistyped
|
|
/// secret. The others keep whatever this device claimed.
|
|
#[arg(long)]
|
|
offline: bool,
|
|
},
|
|
/// Shows the secret of a network, which is half of its identity.
|
|
///
|
|
/// Printed only when asked for, never as part of an overview: these
|
|
/// reports get pasted into chats and issue trackers.
|
|
Secret {
|
|
/// Exact network name, full id or unique id prefix. All of them if omitted.
|
|
network: Option<String>,
|
|
|
|
#[command(subcommand)]
|
|
action: Option<SecretAction>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
|
enum BroadcastChoice {
|
|
On,
|
|
Off,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum SecretAction {
|
|
/// Prints a fresh random secret, for a network that does not exist yet.
|
|
Generate,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct WipeArgs {
|
|
#[command(flatten)]
|
|
paths: PathArgs,
|
|
|
|
/// Control socket to check for a running agent.
|
|
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET")]
|
|
control_socket: Option<PathBuf>,
|
|
|
|
/// Actually remove it. Without this the command only says what it would.
|
|
#[arg(long)]
|
|
yes: bool,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct IdArgs {
|
|
#[command(flatten)]
|
|
paths: PathArgs,
|
|
|
|
/// Control socket to talk to. Derived from the state directory by default.
|
|
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET", global = true)]
|
|
control_socket: Option<PathBuf>,
|
|
|
|
#[command(subcommand)]
|
|
action: Option<IdAction>,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum IdAction {
|
|
/// Shows the name this device answers to, or changes it.
|
|
Hostname {
|
|
/// The new name. Omit it to see the current one.
|
|
name: Option<String>,
|
|
},
|
|
/// Shows the key this device signs with.
|
|
Key {
|
|
#[command(subcommand)]
|
|
action: Option<KeyAction>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum KeyAction {
|
|
/// Replaces the signing key with a fresh one.
|
|
///
|
|
/// This device becomes a different member. The outgoing key gives up the
|
|
/// addresses and names it held on the way out, so they are freed rather
|
|
/// than reserved to a key nobody has. Requires the agent to be stopped.
|
|
Rotate,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct StatusArgs {
|
|
#[command(flatten)]
|
|
paths: PathArgs,
|
|
|
|
/// Control socket to talk to. Derived from the state directory by default.
|
|
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET")]
|
|
control_socket: Option<PathBuf>,
|
|
}
|
|
|
|
/// Resolves the IPv4 overlay range from the flag.
|
|
///
|
|
/// Absent means the built-in default. A network that already settled on
|
|
/// another range wins over both.
|
|
fn resolve_ipv4_range(
|
|
range: Option<&String>,
|
|
) -> Result<Option<Ipv4Range>, Box<dyn std::error::Error>> {
|
|
match range {
|
|
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)),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Args, Clone)]
|
|
struct PathArgs {
|
|
// Global, so they may be written before or after a subcommand. A
|
|
// sub-subcommand that silently rejected the flag its parent accepts is
|
|
// the kind of inconsistency that makes a tool feel arbitrary.
|
|
/// Directory for the mandatory state. Defaults to the platform location.
|
|
#[arg(long, env = "TSUNAGI_STATE_DIR", global = true)]
|
|
state_dir: Option<PathBuf>,
|
|
/// Directory for the disposable cache. Defaults to the platform location.
|
|
#[arg(long, env = "TSUNAGI_CACHE_DIR", global = true)]
|
|
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.
|
|
///
|
|
/// `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.
|
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
enum Reach {
|
|
/// Loopback and the local network only. Publishes nothing.
|
|
Local,
|
|
/// Public address lookup, direct paths only, no relays.
|
|
Direct,
|
|
/// Public address lookup plus public relay fallback. The default.
|
|
#[value(alias = "n0")]
|
|
Relay,
|
|
}
|
|
|
|
impl From<Reach> for TransportPolicy {
|
|
fn from(value: Reach) -> Self {
|
|
match value {
|
|
Reach::Local => TransportPolicy::LocalOnly,
|
|
Reach::Direct => TransportPolicy::DirectOnly,
|
|
Reach::Relay => TransportPolicy::N0Defaults,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct UpArgs {
|
|
#[command(flatten)]
|
|
paths: PathArgs,
|
|
|
|
/// Hostname to announce. Defaults to the machine's.
|
|
#[arg(long, help_heading = "System")]
|
|
hostname: Option<String>,
|
|
|
|
/// How much of iroh's reachability to use.
|
|
///
|
|
/// About how the *control plane* finds peers, not about which protocol
|
|
/// carries packets — that is `--protocol`.
|
|
#[arg(long, value_enum, default_value_t = Reach::Relay, help_heading = "System")]
|
|
reach: Reach,
|
|
|
|
/// Enable Mainline DHT rendezvous (already enabled by default).
|
|
#[arg(long, conflicts_with = "no_dht", help_heading = "System")]
|
|
dht: bool,
|
|
|
|
/// Disable Mainline DHT lookup and publication. --reach local also disables it.
|
|
#[arg(long, conflicts_with = "dht", help_heading = "System")]
|
|
no_dht: bool,
|
|
|
|
/// A peer to contact, as `<endpoint-id>` or `<endpoint-id>@<ip:port>,...`.
|
|
///
|
|
/// Optional alongside DHT discovery. Repeat for several.
|
|
#[arg(long = "peer", value_name = "PEER", help_heading = "System")]
|
|
peers: Vec<String>,
|
|
|
|
/// Local address to bind. Repeat for several; defaults to iroh's choice.
|
|
#[arg(long = "bind", value_name = "ADDR", help_heading = "System")]
|
|
binds: Vec<SocketAddr>,
|
|
|
|
/// Name of the overlay interface. One agent has one, whatever carries it.
|
|
#[arg(
|
|
long,
|
|
default_value = "tsun0",
|
|
value_name = "NAME",
|
|
help_heading = "System"
|
|
)]
|
|
interface: String,
|
|
|
|
/// Largest packet the overlay carries, at least 576.
|
|
#[arg(long, value_name = "BYTES", help_heading = "System")]
|
|
mtu: Option<u32>,
|
|
|
|
/// Do not create a real network interface.
|
|
///
|
|
/// Tunnels still run and handshake, so a mesh can be verified with no
|
|
/// privileges; traffic just does not reach the operating system.
|
|
#[arg(long, help_heading = "System")]
|
|
no_tun: bool,
|
|
|
|
/// Protocols to carry packets with, best first.
|
|
///
|
|
/// A pair of peers uses one they both have at the same wire version. A
|
|
/// peer with none in common keeps its control plane and gets no data
|
|
/// plane. `none` runs the control plane alone.
|
|
#[arg(
|
|
long = "protocol",
|
|
value_name = "LIST",
|
|
value_delimiter = ',',
|
|
default_value = "wg,tcp-tls,wg-quic",
|
|
help_heading = "Transport"
|
|
)]
|
|
protocols: Vec<String>,
|
|
|
|
/// A protocol setting, as `key=value` or `protocol:key=value`.
|
|
///
|
|
/// Repeat for several. `tsunagi protocols` lists what each one takes.
|
|
#[arg(
|
|
short = 'o',
|
|
long = "protocol-option",
|
|
value_name = "KEY=VALUE",
|
|
help_heading = "Transport"
|
|
)]
|
|
protocol_options: Vec<String>,
|
|
|
|
/// IPv4 overlay range, as `address/prefix`, or `none` to disable IPv4.
|
|
///
|
|
/// 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.
|
|
#[arg(long, value_name = "CIDR", help_heading = "System")]
|
|
ipv4_range: Option<String>,
|
|
|
|
/// Enable local DNS for every network (on by default).
|
|
///
|
|
/// Each network becomes a zone named after it, and its members resolve
|
|
/// as `<hostname>.<network>` from signed state, so a member that is
|
|
/// switched off still resolves. Answers are the overlay's IPv4
|
|
/// addresses; questions are taken on loopback of both families, over
|
|
/// UDP and TCP.
|
|
///
|
|
/// Overrides a remembered opt-out. Use --no-dns or `tsunagi dns off` to disable.
|
|
#[arg(long, conflicts_with = "no_dns", help_heading = "System")]
|
|
dns: bool,
|
|
|
|
/// Disable local DNS and remember this choice for future starts.
|
|
///
|
|
/// Use --dns or `tsunagi dns on` to enable it again.
|
|
#[arg(long, conflicts_with = "dns", help_heading = "System")]
|
|
no_dns: bool,
|
|
|
|
/// Port for the local DNS server, on loopback of both families.
|
|
///
|
|
/// Remembered with the setting, so it needs giving only when changing.
|
|
#[arg(long, value_name = "PORT", help_heading = "System")]
|
|
dns_port: Option<u16>,
|
|
|
|
/// How often to log a periodic status summary in debug mode, in seconds. Zero disables it.
|
|
#[arg(long, default_value_t = 0)]
|
|
status_interval: u64,
|
|
|
|
/// Control socket to serve. Derived from the state directory by default.
|
|
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET")]
|
|
control_socket: Option<PathBuf>,
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
};
|
|
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())?),
|
|
}
|
|
}
|
|
|
|
/// Port the local resolver listens on unless told otherwise.
|
|
///
|
|
/// On Windows the default is 53, because the Windows DNS client only ever asks
|
|
/// on 53 and cannot be pointed at another port — a resolver on anything else
|
|
/// cannot be wired into the system at all. This is the same choice Tailscale
|
|
/// makes: a fixed, system-usable port plus an NRPT rule for the network's
|
|
/// suffix. An explicit `--dns-port` still wins (so two agents on one host, and
|
|
/// the tests, can each take a port of their own); this only decides what a
|
|
/// plain `--dns` picks. Elsewhere the default is a high port that needs no
|
|
/// privilege, since systemd-resolved can be pointed at any port.
|
|
const DEFAULT_DNS_PORT: u16 = if cfg!(target_os = "windows") {
|
|
53
|
|
} else {
|
|
5354
|
|
};
|
|
|
|
/// Settings key: whether the local resolver is wanted.
|
|
const DNS_ENABLED: &str = "dns.enabled";
|
|
/// Settings key: which port it listens on.
|
|
const DNS_PORT: &str = "dns.port";
|
|
|
|
/// Whether the local resolver is on, and on which port.
|
|
///
|
|
/// Stored with the device rather than passed on every start: a resolver
|
|
/// that quietly goes away when a command line is retyped is worse than no
|
|
/// resolver at all, because the names simply stop working.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct DnsSetting {
|
|
enabled: bool,
|
|
port: u16,
|
|
}
|
|
|
|
impl Default for DnsSetting {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
port: DEFAULT_DNS_PORT,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reads the stored resolver setting. Read-only, so it needs no lock.
|
|
fn dns_setting(paths: &StoragePaths) -> DnsSetting {
|
|
let Ok(store) = tsunagi::storage::StateStore::open(paths.state_db()) else {
|
|
return DnsSetting::default();
|
|
};
|
|
DnsSetting {
|
|
enabled: !matches!(
|
|
store.get_setting(DNS_ENABLED).ok().flatten().as_deref(),
|
|
Some("0")
|
|
),
|
|
port: store
|
|
.get_setting(DNS_PORT)
|
|
.ok()
|
|
.flatten()
|
|
.and_then(|port| port.parse().ok())
|
|
.unwrap_or(DEFAULT_DNS_PORT),
|
|
}
|
|
}
|
|
|
|
/// Writes the resolver setting, for the next start and for this one.
|
|
fn store_dns_setting(
|
|
paths: &StoragePaths,
|
|
setting: DnsSetting,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let store = tsunagi::storage::StateStore::open(paths.state_db())?;
|
|
store.set_setting(DNS_ENABLED, if setting.enabled { "1" } else { "0" })?;
|
|
store.set_setting(DNS_PORT, &setting.port.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
/// What this device already knows about the network a command names.
|
|
///
|
|
/// Two things, and both are only knowable *before* joining: whether this
|
|
/// network was already configured here, and whether another one answers to
|
|
/// the same name. A name is a label and an id is the identity, so those two
|
|
/// are different networks that share nothing — almost always a mistyped
|
|
/// secret, and the one mistake that makes a report unreadable. Said at the
|
|
/// moment it happens, it is obvious; discovered later in a status report,
|
|
/// it is a mystery.
|
|
fn network_context(
|
|
configured: &[tsunagi::storage::StoredNetwork],
|
|
name: &NetworkName,
|
|
network_id: tsunagi::NetworkId,
|
|
) -> (NetworkStanding, Option<String>) {
|
|
let known = configured
|
|
.iter()
|
|
.find(|other| other.network_id == network_id)
|
|
.map(|other| {
|
|
if other.auto_start {
|
|
NetworkStanding::Known
|
|
} else {
|
|
NetworkStanding::Stopped
|
|
}
|
|
})
|
|
.unwrap_or(NetworkStanding::New);
|
|
let shared = configured
|
|
.iter()
|
|
.find(|other| other.name == *name && other.network_id != network_id)
|
|
.map(|other| other.network_id.to_string());
|
|
(known, shared)
|
|
}
|
|
|
|
/// How a network the command line names stood before the command ran.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum NetworkStanding {
|
|
/// Not configured here at all: this command makes it.
|
|
New,
|
|
/// Configured and meant to run.
|
|
Known,
|
|
/// Configured and deliberately stopped — which this command undoes,
|
|
/// because a command line that names a network says to run it. Said
|
|
/// out loud, or a `stop` quietly comes back at the next restart.
|
|
Stopped,
|
|
}
|
|
|
|
/// Where the secret a command is about to use came from.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum SecretOrigin {
|
|
/// Given on the command line, in a file or in the environment.
|
|
Given,
|
|
/// Read from the one network of that name this device already has.
|
|
Stored,
|
|
/// Invented here, because there was nothing to go on.
|
|
Generated,
|
|
}
|
|
|
|
/// Works out which secret a network name means.
|
|
///
|
|
/// Three cases, and they are what make the name alone a usable command:
|
|
///
|
|
/// * given — use it, whatever is stored;
|
|
/// * not given and this device is already in exactly one network of that
|
|
/// name — that one, so `join --network lab` resumes rather than making a
|
|
/// stranger with the same name;
|
|
/// * not given and there is none — invent one, because an ad-hoc network is
|
|
/// a thing people want and "generate a secret first" is a step with no
|
|
/// purpose. The caller prints it: a secret nobody can read is no use.
|
|
///
|
|
/// Two networks of one name and no secret is the one case with no answer,
|
|
/// and it says so rather than choosing.
|
|
fn resolve_secret(
|
|
paths: &StoragePaths,
|
|
name: &NetworkName,
|
|
secret: Option<&str>,
|
|
secret_file: Option<&std::path::Path>,
|
|
) -> Result<(NetworkSecret, SecretOrigin), Box<dyn std::error::Error>> {
|
|
if secret.is_some() || secret_file.is_some() {
|
|
return Ok((load_secret(secret, secret_file)?, SecretOrigin::Given));
|
|
}
|
|
|
|
let known: Vec<tsunagi::storage::StoredNetwork> = stored_networks(paths)
|
|
.into_iter()
|
|
.filter(|network| network.name == *name)
|
|
.collect();
|
|
match known.as_slice() {
|
|
[] => Ok((NetworkSecret::generate(), SecretOrigin::Generated)),
|
|
[one] => Ok((one.secret.clone(), SecretOrigin::Stored)),
|
|
several => Err(format!(
|
|
"this device is in {} networks called `{name}`, so the name alone does not say \
|
|
which. Give --secret, or `tsunagi network` lists them with their ids.",
|
|
several.len()
|
|
)
|
|
.into()),
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
|
|
let log_filter = cli
|
|
.log
|
|
.or_else(|| std::env::var("TSUNAGI_LOG").ok())
|
|
.or_else(|| std::env::var("RUST_LOG").ok())
|
|
.unwrap_or_else(|| {
|
|
"info,iroh=warn,quinn=warn,rustls=warn,boringtun=warn,mainline=off".to_string()
|
|
});
|
|
|
|
let is_debug = log_filter.contains("debug") || log_filter.contains("trace");
|
|
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(tracing_subscriber::EnvFilter::new(&log_filter))
|
|
.with_target(is_debug)
|
|
.compact()
|
|
.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::Id(args) => id(args).await,
|
|
Command::Up(args) => up(*args).await,
|
|
Command::Status(args) => status(args).await,
|
|
Command::Protocols => show_protocols(),
|
|
Command::Join(args) => join_command(args).await,
|
|
Command::Network(args) => network_command(args).await,
|
|
Command::Dns(args) => dns_command(args).await,
|
|
Command::Wipe(args) => wipe(args).await,
|
|
}
|
|
}
|
|
|
|
/// Path of the local control socket for a state directory.
|
|
fn control_socket(
|
|
paths: &StoragePaths,
|
|
override_path: Option<&PathBuf>,
|
|
_is_custom_state_dir: bool,
|
|
) -> PathBuf {
|
|
if let Some(path) = override_path {
|
|
return path.clone();
|
|
}
|
|
#[cfg(windows)]
|
|
if !_is_custom_state_dir {
|
|
return tsunagi::ipc::default_control_socket_path();
|
|
}
|
|
tsunagi::ipc::control_socket_path(&paths.state_dir)
|
|
}
|
|
|
|
/// What could be learned about this device, and from where.
|
|
///
|
|
/// A running agent is authoritative and live, so it is asked first. With no
|
|
/// agent there is still plenty to say: the mandatory state store holds the
|
|
/// identity and the configured networks, and reading it takes no directory
|
|
/// lock — so asking who this device is never collides with the agent that is
|
|
/// being asked about, and never needs one to be running.
|
|
enum Observed {
|
|
/// A running agent answered over the control socket.
|
|
Agent(Box<tsunagi::ipc::StatusReport>),
|
|
/// Read from the state store, because the agent could not be asked.
|
|
Stored {
|
|
endpoint_id: Option<String>,
|
|
hostname: Option<String>,
|
|
/// Why the agent could not be asked.
|
|
why: String,
|
|
/// Whether a socket was there at all.
|
|
///
|
|
/// Nothing running is an ordinary state and gets said plainly. A
|
|
/// socket that is there and will not answer is a fault.
|
|
socket_present: bool,
|
|
},
|
|
}
|
|
|
|
/// Asks the agent, and falls back to the state store.
|
|
async fn observe(paths: &StoragePaths, socket: &std::path::Path) -> Observed {
|
|
let socket_present = tsunagi::ipc::is_serving(socket).await;
|
|
let why = if socket_present {
|
|
match tsunagi::ipc::request_status(socket).await {
|
|
Ok(report) => return Observed::Agent(Box::new(report)),
|
|
Err(err) => format!("{err}"),
|
|
}
|
|
} else {
|
|
"no agent is serving this state directory".to_string()
|
|
};
|
|
|
|
// Read-only, and deliberately tolerant: a state directory that has never
|
|
// been used is not an error, it just has nothing to report yet.
|
|
let (endpoint_id, hostname) = match tsunagi::storage::StateStore::open(paths.state_db()) {
|
|
Ok(store) => (
|
|
store
|
|
.device_identity()
|
|
.ok()
|
|
.flatten()
|
|
.map(|identity| identity.endpoint_id().to_string()),
|
|
store.hostname().ok().flatten(),
|
|
),
|
|
Err(_) => (None, None),
|
|
};
|
|
Observed::Stored {
|
|
endpoint_id,
|
|
hostname,
|
|
why,
|
|
socket_present,
|
|
}
|
|
}
|
|
|
|
/// The `device` section: who this is and where it keeps things.
|
|
fn device_section(paths: &StoragePaths, observed: &Observed) -> report::Section {
|
|
use report::{Health, Row, Section};
|
|
|
|
let mut device = Section::new("device");
|
|
let (endpoint_id, hostname) = match observed {
|
|
Observed::Agent(report) => (
|
|
Some(report.endpoint_id.clone()),
|
|
Some(report.hostname.clone()),
|
|
),
|
|
Observed::Stored {
|
|
endpoint_id,
|
|
hostname,
|
|
..
|
|
} => (endpoint_id.clone(), hostname.clone()),
|
|
};
|
|
|
|
device.push(match endpoint_id {
|
|
Some(id) => Row::new(Health::Info, "endpoint id", id),
|
|
None => Row::new(Health::Info, "endpoint id", "not created yet")
|
|
.with_note("generated the first time an agent starts here"),
|
|
});
|
|
if let Some(hostname) = hostname {
|
|
device.push(Row::new(Health::Info, "hostname", hostname));
|
|
}
|
|
device.push(Row::new(
|
|
Health::Info,
|
|
"state directory",
|
|
paths.state_dir.display().to_string(),
|
|
));
|
|
device.push(Row::new(
|
|
Health::Info,
|
|
"cache directory",
|
|
paths.cache_dir.display().to_string(),
|
|
));
|
|
device
|
|
}
|
|
|
|
/// The networks this device belongs to, named but not described.
|
|
///
|
|
/// No secrets: this is part of `status`, and a status report is somewhere a
|
|
/// secret must never appear. `tsunagi network secret` is the place that shows one,
|
|
/// because asking for it there is deliberate.
|
|
fn configured_networks_section(paths: &StoragePaths) -> report::Section {
|
|
use report::{Health, Row, Section};
|
|
|
|
let mut section = Section::new("networks");
|
|
let networks = stored_networks(paths);
|
|
if networks.is_empty() {
|
|
section.push(Row::new(Health::Info, "none", "no network has been joined"));
|
|
}
|
|
for network in &networks {
|
|
section.push(Row::new(
|
|
Health::Info,
|
|
network.name.as_str(),
|
|
format!(
|
|
"{}{}",
|
|
network.network_id,
|
|
if network.auto_start {
|
|
" (auto-start)"
|
|
} else {
|
|
""
|
|
}
|
|
),
|
|
));
|
|
}
|
|
section
|
|
}
|
|
|
|
/// What the local DNS service is doing, for `status` to report.
|
|
#[derive(Debug, Clone, Default)]
|
|
struct DnsState {
|
|
/// One zone per network, named after it.
|
|
zones: Vec<String>,
|
|
/// Anything worth saying about those names, one line each.
|
|
zone_warnings: Vec<String>,
|
|
listening: Vec<SocketAddr>,
|
|
bind_error: Option<String>,
|
|
publish_error: Option<String>,
|
|
publish_remedy: Option<String>,
|
|
names: u32,
|
|
}
|
|
|
|
/// The local DNS service: a server, and an attempt to tell the OS about it.
|
|
///
|
|
/// The two are deliberately independent. The server comes up whether or not
|
|
/// the resolver can be configured, because a resolver the user can point at
|
|
/// by hand is worth more than nothing, and the reason it was not configured
|
|
/// is reported rather than swallowed.
|
|
///
|
|
/// One service for the agent, not one per network: an agent has one
|
|
/// identity and as many networks as it likes, each of them a zone named
|
|
/// after it, and they all arrive at the same socket.
|
|
struct DnsService {
|
|
state: Arc<std::sync::Mutex<DnsState>>,
|
|
publisher: Arc<dyn tsunagi::dns::DnsPublisher>,
|
|
port: u16,
|
|
task: tokio::task::JoinHandle<()>,
|
|
}
|
|
|
|
impl DnsService {
|
|
/// Stops answering and undoes what was told to the resolver.
|
|
async fn shutdown(self) {
|
|
self.task.abort();
|
|
if let Err(err) = self.publisher.revert().await {
|
|
tracing::debug!(%err, "cannot undo the resolver setting");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What the control socket reports about the resolver.
|
|
fn dns_report(dns: DnsState) -> tsunagi::ipc::DnsReport {
|
|
tsunagi::ipc::DnsReport {
|
|
zones: dns.zones,
|
|
listening: dns
|
|
.listening
|
|
.iter()
|
|
.map(|address| address.to_string())
|
|
.collect(),
|
|
bind_error: dns.bind_error,
|
|
publish_error: dns.publish_error,
|
|
publish_remedy: dns.publish_remedy,
|
|
zone_warnings: dns.zone_warnings,
|
|
names: dns.names,
|
|
}
|
|
}
|
|
|
|
/// Picks the publisher for this platform.
|
|
fn dns_publisher() -> Arc<dyn tsunagi::dns::DnsPublisher> {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
Arc::new(tsunagi::dns::publish::ResolvedPublisher::new())
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
Arc::new(tsunagi::dns::publish::NrptPublisher::new())
|
|
}
|
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
|
{
|
|
Arc::new(tsunagi::dns::publish::UnsupportedPublisher::new())
|
|
}
|
|
}
|
|
|
|
/// Starts the DNS service and keeps it in step with the agent's state.
|
|
///
|
|
/// Every network the agent is in becomes a zone named after it, so joining
|
|
/// or leaving one changes what resolves without restarting anything.
|
|
fn spawn_dns(agent: Agent, port: u16) -> DnsService {
|
|
use tsunagi::dns::{DnsServer, SharedZone, Zone, ZoneName, Zones, listen_plan};
|
|
|
|
let state = Arc::new(std::sync::Mutex::new(DnsState::default()));
|
|
let publisher = dns_publisher();
|
|
|
|
let task = {
|
|
let state = Arc::clone(&state);
|
|
let publisher = Arc::clone(&publisher);
|
|
tokio::spawn(async move {
|
|
let shared = SharedZone::default();
|
|
// Held for their `Drop`, which stops each server: the values
|
|
// are never read, but letting them go is what closes the
|
|
// sockets. One per address family, so a question is answered
|
|
// over whichever the resolver uses.
|
|
let mut _servers: Vec<DnsServer> = Vec::new();
|
|
let mut bound: Vec<SocketAddr> = Vec::new();
|
|
let mut published: Option<tsunagi::dns::Published> = None;
|
|
// A condition that persists is worth saying once, not every
|
|
// pass; and a refusal will not lift without somebody acting, so
|
|
// hammering at it two seconds apart is pure noise.
|
|
let mut reported: Option<String> = None;
|
|
let mut retry_after: Option<tokio::time::Instant> = None;
|
|
let mut recipe_shown = false;
|
|
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(2));
|
|
|
|
// The port does not change while the service runs; turning DNS
|
|
// off and on again is what changes it, and that is a new
|
|
// service. So this is bound once and kept.
|
|
let wanted = listen_plan(port);
|
|
let mut last: Option<std::io::Error> = None;
|
|
for family in wanted.families() {
|
|
for candidate in family {
|
|
match DnsServer::bind(*candidate, shared.clone()).await {
|
|
Ok(fresh) => {
|
|
tracing::info!(address = %fresh.local_addr(), "dns listening");
|
|
bound.push(fresh.local_addr());
|
|
_servers.push(fresh);
|
|
break;
|
|
}
|
|
Err(err) => last = Some(err),
|
|
}
|
|
}
|
|
}
|
|
let bind_error = bound.is_empty().then(|| {
|
|
last.map_or_else(
|
|
|| "no address to listen on".to_string(),
|
|
|err| err.to_string(),
|
|
)
|
|
});
|
|
{
|
|
let listening = bound.clone();
|
|
update(&state, |state| {
|
|
state.listening = listening;
|
|
state.bind_error = bind_error;
|
|
});
|
|
}
|
|
|
|
loop {
|
|
ticker.tick().await;
|
|
let Ok(status) = agent.status().await else {
|
|
continue;
|
|
};
|
|
|
|
// One zone per network, named after it. A name that cannot
|
|
// be a zone is said once and skipped: the network works,
|
|
// it just has no names.
|
|
let mut zones = Vec::new();
|
|
let mut labels = Vec::new();
|
|
let mut warnings = Vec::new();
|
|
for network in &status.networks {
|
|
let zone = match ZoneName::new(network.name.as_str()) {
|
|
Ok(zone) => zone,
|
|
Err(err) => {
|
|
warnings.push(format!(
|
|
"`{}` cannot be a zone, so its members have no names: {err}",
|
|
network.name
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
if let Some(warning) = zone.collision() {
|
|
warnings.push(warning);
|
|
}
|
|
labels.push(zone.as_str().to_string());
|
|
// Names come from signed state, so a member that is
|
|
// away is in here too.
|
|
let members = network.members.iter().filter_map(|member| {
|
|
Some((member.hostname.clone()?, member.overlay_address_v4?))
|
|
});
|
|
zones.push(Zone::new(zone, members));
|
|
}
|
|
let zones = Zones::new(zones);
|
|
let names = zones.names() as u32;
|
|
shared.set(zones);
|
|
update(&state, |state| {
|
|
state.zones = labels.clone();
|
|
state.zone_warnings = warnings;
|
|
state.names = names;
|
|
});
|
|
|
|
if bound.is_empty() {
|
|
continue;
|
|
}
|
|
// The interface belongs to the agent, so the resolver
|
|
// setting attaches to that one and not to a protocol's.
|
|
// Only one that is really on the host: an in-memory
|
|
// interface has a name and nothing else, and telling the
|
|
// operating system about that name would configure
|
|
// whatever else happens to be called it.
|
|
let interface = agent
|
|
.overlay()
|
|
.filter(|overlay| overlay.on_host)
|
|
.map(|overlay| overlay.interface)
|
|
.filter(|name| !name.is_empty());
|
|
let Some(interface) = interface else {
|
|
update(&state, |state| {
|
|
state.publish_error = Some(
|
|
"there is no overlay interface on this host to attach the resolver \
|
|
setting to"
|
|
.to_string(),
|
|
);
|
|
state.publish_remedy = None;
|
|
});
|
|
continue;
|
|
};
|
|
if labels.is_empty() {
|
|
// Nothing to route here yet. Whatever was published is
|
|
// now wrong, and saying nothing is the honest setting.
|
|
if published.take().is_some() {
|
|
let _ = publisher.revert().await;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let want_published = tsunagi::dns::Published {
|
|
interface,
|
|
servers: bound.clone(),
|
|
domains: labels.clone(),
|
|
};
|
|
let due = retry_after.is_none_or(|at| tokio::time::Instant::now() >= at);
|
|
if published.as_ref() != Some(&want_published) && due {
|
|
match publisher.apply(&want_published).await {
|
|
Ok(()) => {
|
|
tracing::info!(
|
|
zones = %labels.join(", "),
|
|
interface = %want_published.interface,
|
|
"the system resolver was told where to ask"
|
|
);
|
|
published = Some(want_published);
|
|
reported = None;
|
|
retry_after = None;
|
|
update(&state, |state| {
|
|
state.publish_error = None;
|
|
state.publish_remedy = None;
|
|
});
|
|
}
|
|
Err(err) => {
|
|
// Not fatal, by design: the server keeps
|
|
// answering and the user is told what is missing.
|
|
let text = err.to_string();
|
|
if reported.as_deref() != Some(text.as_str()) {
|
|
tracing::warn!("cannot configure the system resolver: {text}");
|
|
if err.needs_a_human() && !recipe_shown {
|
|
recipe_shown = true;
|
|
tracing::warn!(
|
|
"systemd-resolved asks polkit, and polkit decides by \
|
|
user rather than by capability, so this cannot be done \
|
|
from inside the agent. To grant it once:\n\n{}\n",
|
|
tsunagi::dns::publish::polkit_recipe(¤t_user())
|
|
);
|
|
}
|
|
reported = Some(text.clone());
|
|
}
|
|
// Backed off, and further for something only a
|
|
// person can change.
|
|
let wait = if err.needs_a_human() { 300 } else { 15 };
|
|
retry_after = Some(
|
|
tokio::time::Instant::now() + std::time::Duration::from_secs(wait),
|
|
);
|
|
let remedy = err.remedy().map(str::to_string);
|
|
update(&state, |state| {
|
|
state.publish_error = Some(text);
|
|
state.publish_remedy = remedy;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
};
|
|
|
|
DnsService {
|
|
state,
|
|
publisher,
|
|
port,
|
|
task,
|
|
}
|
|
}
|
|
|
|
fn update(state: &Arc<std::sync::Mutex<DnsState>>, edit: impl FnOnce(&mut DnsState)) {
|
|
match state.lock() {
|
|
Ok(mut guard) => edit(&mut guard),
|
|
Err(poisoned) => edit(&mut poisoned.into_inner()),
|
|
}
|
|
}
|
|
|
|
/// What a protocol is called, what it speaks, and what it takes.
|
|
///
|
|
/// A registry rather than a lookup on the plugins themselves, because
|
|
/// `tsunagi protocols` has to answer before anything is constructed, and
|
|
/// because this is the list `--protocol` resolves against.
|
|
struct ProtocolSpec {
|
|
/// The name on the wire, which is what peers compare.
|
|
name: &'static str,
|
|
/// The wire version. Not the software version: two peers on different
|
|
/// builds carry traffic for each other as long as this matches.
|
|
version: u16,
|
|
/// One line about what it is.
|
|
summary: &'static str,
|
|
/// The settings it accepts.
|
|
options: &'static [tsunagi::dataplane::ProtocolOption],
|
|
}
|
|
|
|
/// Every protocol this build has.
|
|
const PROTOCOLS: &[ProtocolSpec] = &[
|
|
ProtocolSpec {
|
|
name: WG_PROTOCOL,
|
|
version: WG_WIRE_VERSION,
|
|
summary: "Pure WireGuard direct UDP transport for uncensored networks and maximum performance",
|
|
options: PureWgPlugin::OPTIONS,
|
|
},
|
|
ProtocolSpec {
|
|
name: TCP_TLS_PROTOCOL,
|
|
version: TCP_TLS_VERSION,
|
|
summary: "Noise E2EE carried over TCP TLS 1.3 (port 443) disguised as HTTPS traffic",
|
|
options: TcpTlsCodec::OPTIONS,
|
|
},
|
|
ProtocolSpec {
|
|
name: tsunagi_wg_quic::WIREGUARD_PROTOCOL,
|
|
version: tsunagi_wg_quic::ANNOUNCEMENT_VERSION,
|
|
summary: "WireGuard's cryptography carried in iroh's QUIC datagrams, so it \
|
|
crosses NAT and survives where plain WireGuard is blocked",
|
|
options: WireguardPlugin::OPTIONS,
|
|
},
|
|
];
|
|
|
|
/// One `-o` setting, and the protocol it was aimed at.
|
|
struct Setting {
|
|
/// `Some` when written as `protocol:key=value`.
|
|
protocol: Option<String>,
|
|
key: String,
|
|
value: String,
|
|
}
|
|
|
|
/// Parses `-o` settings, which are `key=value` or `protocol:key=value`.
|
|
fn parse_settings(raw: &[String]) -> Result<Vec<Setting>, Box<dyn std::error::Error>> {
|
|
raw.iter()
|
|
.map(|entry| {
|
|
let (left, value) = entry
|
|
.split_once('=')
|
|
.ok_or_else(|| format!("`{entry}` is not a setting; write it as key=value"))?;
|
|
let (protocol, key) = match left.split_once(':') {
|
|
Some((protocol, key)) => (Some(protocol.to_string()), key),
|
|
None => (None, left),
|
|
};
|
|
if key.is_empty() {
|
|
return Err(format!("`{entry}` has no key").into());
|
|
}
|
|
Ok(Setting {
|
|
protocol,
|
|
key: key.to_string(),
|
|
value: value.to_string(),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The settings meant for one protocol, refusing any that fit nowhere.
|
|
fn settings_for(spec: &ProtocolSpec, settings: &[Setting]) -> Vec<(String, String)> {
|
|
let mut taken = Vec::new();
|
|
for setting in settings {
|
|
let aimed_here = match &setting.protocol {
|
|
Some(name) => name == spec.name,
|
|
// Unqualified settings go to whichever protocol declares the
|
|
// key. With one selected that is the obvious reading; with
|
|
// several, write `protocol:key=value`.
|
|
None => spec.options.iter().any(|option| option.key == setting.key),
|
|
};
|
|
if aimed_here {
|
|
taken.push((setting.key.clone(), setting.value.clone()));
|
|
}
|
|
}
|
|
taken
|
|
}
|
|
|
|
/// Shows the protocols this build has, and what each one takes.
|
|
fn show_protocols() -> Result<(), Box<dyn std::error::Error>> {
|
|
use report::{Health, Report, Row, Section};
|
|
|
|
let mut out = Report::new();
|
|
for spec in PROTOCOLS {
|
|
let mut section = Section::new(format!("{} (wire version {})", spec.name, spec.version));
|
|
section.push(Row::new(Health::Info, "what", spec.summary));
|
|
if spec.options.is_empty() {
|
|
section.push(Row::new(Health::Info, "settings", "none"));
|
|
}
|
|
for option in spec.options {
|
|
section.push(
|
|
Row::new(
|
|
Health::Info,
|
|
format!("-o {}={}", option.key, option.value),
|
|
option.help,
|
|
)
|
|
.with_note(match option.default {
|
|
Some(default) => format!("default {default}"),
|
|
None => "no default".to_string(),
|
|
}),
|
|
);
|
|
}
|
|
out.push(section);
|
|
}
|
|
print_report("tsunagi protocols", &out)
|
|
}
|
|
|
|
/// Serves the local control socket from the running agent.
|
|
///
|
|
/// A struct rather than a closure because this end both answers questions and
|
|
/// accepts changes, and a change has to reach the agent itself: writing one
|
|
/// into the store behind its back would be overwritten by the next thing it
|
|
/// published.
|
|
struct AgentControl {
|
|
agent: Agent,
|
|
pure_wg: Option<Arc<PureWgPlugin>>,
|
|
plugin: Option<Arc<WireguardPlugin>>,
|
|
tcp_tls: Option<Arc<TcpTlsPlugin>>,
|
|
/// The resolver, which this owns so it can be switched while running.
|
|
dns: Arc<tokio::sync::Mutex<Option<DnsService>>>,
|
|
/// Where the setting is remembered, so it survives a restart.
|
|
paths: StoragePaths,
|
|
}
|
|
|
|
impl tsunagi::ipc::ReportSource for AgentControl {
|
|
fn report(&self) -> tsunagi::BoxFuture<'_, tsunagi::ipc::StatusReport> {
|
|
Box::pin(async move {
|
|
let dns = self.dns_state().await;
|
|
build_report(
|
|
&self.agent,
|
|
self.pure_wg.as_deref(),
|
|
self.plugin.as_deref(),
|
|
self.tcp_tls.as_deref(),
|
|
dns,
|
|
)
|
|
.await
|
|
})
|
|
}
|
|
|
|
fn set_dns(
|
|
&self,
|
|
enable: bool,
|
|
port: Option<u16>,
|
|
) -> tsunagi::BoxFuture<'_, Result<Option<tsunagi::ipc::DnsReport>, String>> {
|
|
Box::pin(async move {
|
|
let mut service = self.dns.lock().await;
|
|
let port = port
|
|
.or_else(|| service.as_ref().map(|service| service.port))
|
|
.unwrap_or_else(|| dns_setting(&self.paths).port);
|
|
|
|
// Remembered first: what the agent is doing and what it will do
|
|
// after a restart must not drift apart, and a failure to store
|
|
// it is exactly the kind of drift.
|
|
store_dns_setting(
|
|
&self.paths,
|
|
DnsSetting {
|
|
enabled: enable,
|
|
port,
|
|
},
|
|
)
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
match (enable, service.take()) {
|
|
// Already serving on that port: nothing to restart.
|
|
(true, Some(running)) if running.port == port => {
|
|
*service = Some(running);
|
|
}
|
|
// A different port means a different socket.
|
|
(true, previous) => {
|
|
if let Some(previous) = previous {
|
|
previous.shutdown().await;
|
|
}
|
|
*service = Some(spawn_dns(self.agent.clone(), port));
|
|
}
|
|
(false, Some(running)) => running.shutdown().await,
|
|
(false, None) => {}
|
|
}
|
|
let handle = service.as_ref().map(|service| Arc::clone(&service.state));
|
|
// The lock goes before the wait: nothing else should queue
|
|
// behind a sleep, and the state is shared by an `Arc` anyway.
|
|
drop(service);
|
|
|
|
let state = match handle {
|
|
// Freshly started: let it bind and collect a zone or two
|
|
// before answering, so the report is the state and not a
|
|
// snapshot of nothing.
|
|
Some(state) => {
|
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
|
Some(match state.lock() {
|
|
Ok(guard) => guard.clone(),
|
|
Err(poisoned) => poisoned.into_inner().clone(),
|
|
})
|
|
}
|
|
None => None,
|
|
};
|
|
Ok(state.map(dns_report))
|
|
})
|
|
}
|
|
|
|
fn set_hostname(&self, hostname: String) -> tsunagi::BoxFuture<'_, Result<String, String>> {
|
|
Box::pin(async move {
|
|
self.agent
|
|
.set_hostname(&hostname)
|
|
.await
|
|
.map_err(|err| err.to_string())
|
|
})
|
|
}
|
|
|
|
fn join(
|
|
&self,
|
|
name: String,
|
|
secret: String,
|
|
broadcast: Option<bool>,
|
|
) -> tsunagi::BoxFuture<'_, Result<tsunagi::ipc::JoinedReport, String>> {
|
|
Box::pin(async move {
|
|
let name = NetworkName::new(&name).map_err(|err| err.to_string())?;
|
|
let secret = NetworkSecret::decode(&secret).map_err(|err| err.to_string())?;
|
|
let keys = tsunagi::identity::NetworkKeys::derive(&name, &secret);
|
|
|
|
// Read before joining: afterwards "already configured" is true
|
|
// of everything, and the difference is what the user is told.
|
|
let before = self
|
|
.agent
|
|
.list_networks()
|
|
.await
|
|
.map_err(|err| err.to_string())?;
|
|
let already = before
|
|
.iter()
|
|
.any(|other| other.network_id == keys.network_id());
|
|
let shared = before
|
|
.iter()
|
|
.find(|other| other.name == name && other.network_id != keys.network_id())
|
|
.map(|other| other.network_id.to_string());
|
|
|
|
let network_id = self
|
|
.agent
|
|
.join_network_with_broadcast(&name, &secret, broadcast)
|
|
.await
|
|
.map_err(|err| err.to_string())?;
|
|
Ok(tsunagi::ipc::JoinedReport {
|
|
name: name.as_str().to_string(),
|
|
network_id: network_id.to_string(),
|
|
already_configured: already,
|
|
name_shared_with: shared,
|
|
})
|
|
})
|
|
}
|
|
|
|
fn set_broadcast(
|
|
&self,
|
|
network_id: String,
|
|
enabled: bool,
|
|
) -> tsunagi::BoxFuture<'_, Result<bool, String>> {
|
|
Box::pin(async move {
|
|
let id = network_id
|
|
.parse()
|
|
.map_err(|err| format!("invalid network id: {err}"))?;
|
|
self.agent
|
|
.set_broadcast(id, enabled)
|
|
.await
|
|
.map_err(|err| err.to_string())?;
|
|
Ok(enabled)
|
|
})
|
|
}
|
|
|
|
fn set_active(
|
|
&self,
|
|
network_id: String,
|
|
active: bool,
|
|
) -> tsunagi::BoxFuture<'_, Result<tsunagi::ipc::ActiveReport, String>> {
|
|
Box::pin(async move {
|
|
let wanted: tsunagi::NetworkId = network_id
|
|
.parse()
|
|
.map_err(|err| format!("`{network_id}` is not a network id: {err}"))?;
|
|
let name = self
|
|
.agent
|
|
.list_networks()
|
|
.await
|
|
.map_err(|err| err.to_string())?
|
|
.into_iter()
|
|
.find(|network| network.network_id == wanted)
|
|
.map(|network| network.name.as_str().to_string())
|
|
.ok_or_else(|| format!("this agent is not in {wanted}"))?;
|
|
|
|
let was = self.agent.is_active(wanted).await;
|
|
if was != active {
|
|
// Both of these also remember the answer, so a restart
|
|
// does what the last instruction said.
|
|
if active {
|
|
self.agent
|
|
.activate_network(wanted)
|
|
.await
|
|
.map_err(|err| err.to_string())?;
|
|
} else {
|
|
self.agent
|
|
.deactivate_network(wanted)
|
|
.await
|
|
.map_err(|err| err.to_string())?;
|
|
}
|
|
}
|
|
Ok(tsunagi::ipc::ActiveReport {
|
|
name,
|
|
active,
|
|
changed: was != active,
|
|
})
|
|
})
|
|
}
|
|
|
|
fn leave(
|
|
&self,
|
|
network_id: String,
|
|
) -> tsunagi::BoxFuture<'_, Result<tsunagi::ipc::LeftReport, String>> {
|
|
Box::pin(async move {
|
|
let wanted: tsunagi::NetworkId = network_id
|
|
.parse()
|
|
.map_err(|err| format!("`{network_id}` is not a network id: {err}"))?;
|
|
// The name is for the message the user reads, and it is only
|
|
// available while the network is still configured.
|
|
let name = self
|
|
.agent
|
|
.list_networks()
|
|
.await
|
|
.map_err(|err| err.to_string())?
|
|
.into_iter()
|
|
.find(|network| network.network_id == wanted)
|
|
.map(|network| network.name.as_str().to_string())
|
|
.ok_or_else(|| format!("this agent is not in {wanted}"))?;
|
|
|
|
let outcome = self
|
|
.agent
|
|
.leave_network(wanted)
|
|
.await
|
|
.map_err(|err| err.to_string())?;
|
|
Ok(tsunagi::ipc::LeftReport {
|
|
name,
|
|
announced: outcome.announced,
|
|
peers_told: outcome.peers_told as u32,
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
impl AgentControl {
|
|
/// The resolver's state, when there is one.
|
|
async fn dns_state(&self) -> Option<DnsState> {
|
|
let service = self.dns.lock().await;
|
|
service.as_ref().map(|service| match service.state.lock() {
|
|
Ok(guard) => guard.clone(),
|
|
Err(poisoned) => poisoned.into_inner().clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The networks this device has joined, read straight from the store.
|
|
///
|
|
/// Secrets live only in the mandatory state, never in a status report and
|
|
/// never on the control socket, so they are read here rather than asked for.
|
|
fn stored_networks(paths: &StoragePaths) -> Vec<tsunagi::storage::StoredNetwork> {
|
|
tsunagi::storage::StateStore::open(paths.state_db())
|
|
.and_then(|store| store.list_networks())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Resolves a stored network by exact name, full id or unique id prefix.
|
|
///
|
|
/// Full ids are definitive. Otherwise name and prefix matches must identify
|
|
/// exactly one network: a shared name or a name colliding with another id's
|
|
/// prefix must never select an arbitrary network for a destructive command.
|
|
fn resolve_network<'a>(
|
|
networks: &'a [tsunagi::storage::StoredNetwork],
|
|
wanted: &str,
|
|
) -> Result<&'a tsunagi::storage::StoredNetwork, String> {
|
|
let wanted = wanted.trim_matches(|c: char| c.is_ascii_whitespace());
|
|
if wanted.is_empty() {
|
|
return Err("select a network by name or id; `tsunagi network` lists them".to_string());
|
|
}
|
|
if let Some(network) = networks
|
|
.iter()
|
|
.find(|network| network.network_id.to_string() == wanted)
|
|
{
|
|
return Ok(network);
|
|
}
|
|
// Only id prefixes lose a copied report ellipsis; names are used verbatim.
|
|
let prefix = wanted.trim_end_matches('…');
|
|
let matched: Vec<&tsunagi::storage::StoredNetwork> = networks
|
|
.iter()
|
|
.filter(|network| {
|
|
network.name.as_str() == wanted
|
|
|| (!prefix.is_empty() && network.network_id.to_string().starts_with(prefix))
|
|
})
|
|
.collect();
|
|
match matched.as_slice() {
|
|
[one] => Ok(one),
|
|
[] => Err(format!(
|
|
"no configured network matches name or id `{wanted}`; \
|
|
`tsunagi network` lists them"
|
|
)),
|
|
several => {
|
|
let candidates = several
|
|
.iter()
|
|
.map(|network| format!(" `{}` {}", network.name, network.network_id))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
Err(format!(
|
|
"`{wanted}` matches {} networks; use the full id or a unique id prefix:\n{candidates}",
|
|
several.len()
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `tsunagi network`: what this device belongs to, and leaving it.
|
|
async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
let paths = args.paths.resolve()?;
|
|
let is_custom_state = args.paths.state_dir.is_some();
|
|
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
|
match args.action {
|
|
None => show_networks(&paths, &socket).await,
|
|
Some(NetworkAction::Broadcast { network, choice }) => {
|
|
broadcast_command(&paths, &socket, &network, choice).await
|
|
}
|
|
Some(NetworkAction::Join(args)) => join_command(args).await,
|
|
Some(NetworkAction::Stop { network }) => set_active(&paths, &socket, &network, false).await,
|
|
Some(NetworkAction::Start { network }) => set_active(&paths, &socket, &network, true).await,
|
|
Some(NetworkAction::Leave { network, offline }) => {
|
|
leave_network(&paths, &socket, &network, offline).await
|
|
}
|
|
Some(NetworkAction::Secret {
|
|
network,
|
|
action: None,
|
|
}) => show_secrets(&paths, network.as_deref()),
|
|
Some(NetworkAction::Secret {
|
|
action: Some(SecretAction::Generate),
|
|
..
|
|
}) => {
|
|
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(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `tsunagi join`: make a network or join one.
|
|
async fn join_command(args: JoinArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
let paths = args.paths.resolve()?;
|
|
let is_custom_state = args.paths.state_dir.is_some();
|
|
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
|
let name = NetworkName::new(args.network)?;
|
|
// A name this device already has means that network; a name nobody
|
|
// has means a new one, and no secret is needed to make a network with
|
|
// somebody in a hurry.
|
|
let (secret, origin) = resolve_secret(
|
|
&paths,
|
|
&name,
|
|
args.secret.as_deref(),
|
|
args.secret_file.as_deref(),
|
|
)?;
|
|
// How it stood before, read now because afterwards everything is
|
|
// configured and the difference is what the user needs to see.
|
|
let network_id = tsunagi::identity::NetworkKeys::derive(&name, &secret).network_id();
|
|
let (standing, _) = network_context(&stored_networks(&paths), &name, network_id);
|
|
let broadcast = if args.no_broadcast {
|
|
Some(false)
|
|
} else if args.broadcast {
|
|
Some(true)
|
|
} else {
|
|
None
|
|
};
|
|
join_network(&paths, &socket, &name, secret, origin, standing, broadcast).await
|
|
}
|
|
|
|
/// Joins a network: into the running agent if there is one.
|
|
async fn join_network(
|
|
paths: &StoragePaths,
|
|
socket: &std::path::Path,
|
|
name: &NetworkName,
|
|
secret: NetworkSecret,
|
|
origin: SecretOrigin,
|
|
standing: NetworkStanding,
|
|
broadcast: Option<bool>,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
// The running agent, because a second `up` cannot have the directory
|
|
// and because this way the network starts at once instead of at the
|
|
// next restart.
|
|
if tsunagi::ipc::is_serving(socket).await {
|
|
let report = tsunagi::ipc::join_network_with_broadcast(
|
|
socket,
|
|
name.as_str(),
|
|
secret.encode().as_str(),
|
|
broadcast,
|
|
)
|
|
.await?;
|
|
// The id in full either way: it is what every other command takes,
|
|
// and the shortened form in a report is for reading, not copying.
|
|
match standing {
|
|
NetworkStanding::New => {
|
|
println!("joined `{}` ({})", report.name, report.network_id)
|
|
}
|
|
NetworkStanding::Known => println!(
|
|
"`{}` ({}) was already here; it is running",
|
|
report.name, report.network_id
|
|
),
|
|
// Joining is an instruction to run it, so it undoes a stop —
|
|
// said out loud, because a pause that ends without a word is
|
|
// a pause nobody can rely on.
|
|
NetworkStanding::Stopped => println!(
|
|
"`{}` ({}) was stopped; it is running again",
|
|
report.name, report.network_id
|
|
),
|
|
}
|
|
if let Some(other) = &report.name_shared_with {
|
|
eprintln!(
|
|
"\nwarning: `{}` is also configured with a different secret, as {}.\n\
|
|
A network is its name *and* its secret, so these two share nothing.\n\
|
|
If that was a mistyped secret, `tsunagi network leave` removes one.",
|
|
report.name,
|
|
short(other, 10)
|
|
);
|
|
}
|
|
if origin == SecretOrigin::Generated {
|
|
invite(socket, name, &secret).await;
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// No agent: configure it, and say when it will take effect rather than
|
|
// leaving the impression that it is running.
|
|
let keys = tsunagi::identity::NetworkKeys::derive(name, &secret);
|
|
let storage = tsunagi::storage::Storage::open(paths)?;
|
|
let shared = storage
|
|
.list_networks()
|
|
.await
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.find(|other| other.name == *name && other.network_id != keys.network_id())
|
|
.map(|other| other.network_id.to_string());
|
|
storage
|
|
.upsert_network(keys.network_id(), name.clone(), secret.clone(), true)
|
|
.await?;
|
|
if let Some(enabled) = broadcast {
|
|
storage.set_broadcast(keys.network_id(), enabled).await?;
|
|
}
|
|
storage.release_ownership_lock();
|
|
|
|
match standing {
|
|
NetworkStanding::New => println!("joined `{name}` ({})", keys.network_id()),
|
|
NetworkStanding::Known => {
|
|
println!("`{name}` ({}) was already here", keys.network_id())
|
|
}
|
|
NetworkStanding::Stopped => {
|
|
println!(
|
|
"`{name}` ({}) was stopped; it will start",
|
|
keys.network_id()
|
|
)
|
|
}
|
|
}
|
|
if let Some(other) = shared {
|
|
eprintln!(
|
|
"\nwarning: `{name}` is also configured with a different secret, as {}.\n\
|
|
A network is its name *and* its secret, so these two share nothing.",
|
|
short(&other, 10)
|
|
);
|
|
}
|
|
if origin == SecretOrigin::Generated {
|
|
println!(" secret {}", secret.encode().as_str());
|
|
}
|
|
eprintln!("\nNo agent is running here, so it starts with the next `tsunagi up`.");
|
|
Ok(())
|
|
}
|
|
|
|
/// Prints the one line that gets somebody else into this network.
|
|
///
|
|
/// Only when the secret was invented here: there is nowhere else to read it
|
|
/// from, and the whole point of a network made in a hurry is that the
|
|
/// command can be pasted to the other person as it stands. The endpoint id
|
|
/// comes from the running agent, because without a peer to contact the
|
|
/// other side has nothing to go on.
|
|
async fn invite(socket: &std::path::Path, name: &NetworkName, secret: &NetworkSecret) {
|
|
let endpoint = tsunagi::ipc::request_status(socket)
|
|
.await
|
|
.ok()
|
|
.map(|report| report.endpoint_id)
|
|
.filter(|id| !id.is_empty());
|
|
println!(" secret {}", secret.encode().as_str());
|
|
println!(
|
|
"\nRun this on the other machine:\n\n \
|
|
tsunagi join --network {name} --secret {}",
|
|
secret.encode().as_str()
|
|
);
|
|
match endpoint {
|
|
Some(endpoint) => println!(
|
|
"\nStart its agent with `tsunagi up`; DHT discovery is enabled by default.\n\n \
|
|
Optional manual bootstrap: tsunagi up --peer {endpoint}"
|
|
),
|
|
None => println!("\nIts agent has to be running: `tsunagi up`."),
|
|
}
|
|
}
|
|
|
|
/// Every configured network, live where an agent can say so.
|
|
async fn show_networks(
|
|
paths: &StoragePaths,
|
|
socket: &std::path::Path,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
use report::{Health, Report, Row, Section};
|
|
|
|
let stored = stored_networks(paths);
|
|
if stored.is_empty() {
|
|
eprintln!("no network has been joined");
|
|
return Ok(());
|
|
}
|
|
|
|
let observed = observe(paths, socket).await;
|
|
let live = match &observed {
|
|
Observed::Agent(report) => report.networks.clone(),
|
|
Observed::Stored { .. } => Vec::new(),
|
|
};
|
|
|
|
let mut out = Report::new();
|
|
let mut section = Section::new("networks");
|
|
for network in &stored {
|
|
let id = network.network_id.to_string();
|
|
let running = live.iter().find(|other| other.network_id == id);
|
|
// Three states, and the difference matters: running, stopped on
|
|
// purpose and kept, or configured and waiting for an agent.
|
|
let (state, note) = match running {
|
|
Some(live) if live.active => (
|
|
match live.overlay.as_ref().and_then(|o| o.address.clone()) {
|
|
Some(address) => format!("running · {address}"),
|
|
None => "running · no address agreed yet".to_string(),
|
|
},
|
|
format!(
|
|
"`tsunagi network stop {}` pauses it, `leave` gives it up",
|
|
short(&id, 10)
|
|
),
|
|
),
|
|
Some(_) => (
|
|
"stopped".to_string(),
|
|
format!(
|
|
"kept as it was; `tsunagi network start {}` resumes it",
|
|
short(&id, 10)
|
|
),
|
|
),
|
|
None if network.auto_start => (
|
|
"configured · starts with the agent".to_string(),
|
|
format!(
|
|
"`tsunagi network stop {}` keeps it from starting",
|
|
short(&id, 10)
|
|
),
|
|
),
|
|
None => (
|
|
"stopped".to_string(),
|
|
format!(
|
|
"kept as it was; `tsunagi network start {}` resumes it",
|
|
short(&id, 10)
|
|
),
|
|
),
|
|
};
|
|
section.push(
|
|
Row::new(
|
|
Health::Info,
|
|
network.name.as_str().to_string(),
|
|
format!("{id} · {state}"),
|
|
)
|
|
.with_note(note),
|
|
);
|
|
}
|
|
out.push(section);
|
|
print_report("tsunagi networks", &out)
|
|
}
|
|
|
|
/// Stops serving a network, or starts serving it again.
|
|
///
|
|
/// Deliberately not a signed anything: stopping is this device being away,
|
|
/// which is an ordinary condition the others already handle, and the whole
|
|
/// point is that everything is still here when it comes back.
|
|
async fn broadcast_command(
|
|
paths: &StoragePaths,
|
|
socket: &std::path::Path,
|
|
wanted: &str,
|
|
choice: Option<BroadcastChoice>,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let configured = stored_networks(paths);
|
|
let network = resolve_network(&configured, wanted)?;
|
|
let mut enabled = network.broadcast;
|
|
if let Some(choice) = choice {
|
|
enabled = matches!(choice, BroadcastChoice::On);
|
|
if tsunagi::ipc::is_serving(socket).await {
|
|
tsunagi::ipc::set_broadcast(socket, &network.network_id.to_string(), enabled).await?;
|
|
} else {
|
|
let storage = tsunagi::storage::Storage::open(paths)?;
|
|
storage.set_broadcast(network.network_id, enabled).await?;
|
|
}
|
|
}
|
|
println!(
|
|
"broadcast {} for `{}` ({})",
|
|
if enabled { "on" } else { "off" },
|
|
network.name,
|
|
network.network_id
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn set_active(
|
|
paths: &StoragePaths,
|
|
socket: &std::path::Path,
|
|
wanted: &str,
|
|
active: bool,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let networks = stored_networks(paths);
|
|
let network = resolve_network(&networks, wanted)?;
|
|
let id = network.network_id.to_string();
|
|
let name = network.name.clone();
|
|
|
|
if tsunagi::ipc::is_serving(socket).await {
|
|
let report = tsunagi::ipc::set_active(socket, &id, active).await?;
|
|
match (report.active, report.changed) {
|
|
(false, true) => println!(
|
|
"stopped `{}` ({}); everything it has is kept",
|
|
report.name,
|
|
short(&id, 10)
|
|
),
|
|
(false, false) => {
|
|
println!("`{}` ({}) was already stopped", report.name, short(&id, 10))
|
|
}
|
|
(true, true) => println!("started `{}` ({})", report.name, short(&id, 10)),
|
|
(true, false) => println!("`{}` ({}) was already running", report.name, short(&id, 10)),
|
|
}
|
|
if !report.active {
|
|
eprintln!(
|
|
"\nNothing was announced: to the others this device is away, and the address \
|
|
and name it holds stay reserved for it. `tsunagi network start {}` resumes \
|
|
it; `tsunagi network leave` is the one that gives them up.",
|
|
short(&id, 10)
|
|
);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// No agent: the stored flag is what the next start reads.
|
|
let storage = tsunagi::storage::Storage::open(paths)?;
|
|
storage.set_auto_start(network.network_id, active).await?;
|
|
storage.release_ownership_lock();
|
|
println!(
|
|
"`{name}` ({}) will {} with the next `tsunagi up`",
|
|
short(&id, 10),
|
|
if active { "start" } else { "stay stopped" }
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Leaves one network, announcing it if there is anything to announce with.
|
|
async fn leave_network(
|
|
paths: &StoragePaths,
|
|
socket: &std::path::Path,
|
|
wanted: &str,
|
|
offline: bool,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let networks = stored_networks(paths);
|
|
let network = resolve_network(&networks, wanted)?;
|
|
let id = network.network_id.to_string();
|
|
let name = network.name.clone();
|
|
|
|
// The running agent does it, because only it can publish the release
|
|
// while its sessions are still up.
|
|
if tsunagi::ipc::is_serving(socket).await {
|
|
let report = tsunagi::ipc::leave_network(socket, &id).await?;
|
|
println!("left `{}` ({})", report.name, short(&id, 10));
|
|
match (report.announced, report.peers_told) {
|
|
(true, 0) => eprintln!(
|
|
"\nNobody was connected, so nothing was told: the others keep the address \
|
|
and name this device claimed until it says otherwise, and it no longer can."
|
|
),
|
|
(true, peers) => eprintln!(
|
|
"\nThe release went to {peers} connected peer(s); they pass it on, so the \
|
|
address and name are freed for the rest as they sync."
|
|
),
|
|
(false, _) => eprintln!(
|
|
"\nThe network was not running, so nothing was announced: the others keep \
|
|
the address and name this device claimed."
|
|
),
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
if !offline {
|
|
return Err(format!(
|
|
"no agent is running for this state directory, so nothing can announce that \
|
|
`{name}` is being left. Start it and run this again to free the address and \
|
|
name for the others, or pass --offline to drop the network locally and leave \
|
|
them holding it."
|
|
)
|
|
.into());
|
|
}
|
|
|
|
// Local removal. The storage lock makes sure no agent is using it.
|
|
let storage = tsunagi::storage::Storage::open(paths)?;
|
|
storage.remove_network(network.network_id).await?;
|
|
// What a protocol kept for it goes too; there is no plugin loaded here
|
|
// to be asked, so the one this build has is asked directly.
|
|
forget_protocol_state(paths, network.network_id);
|
|
storage.release_ownership_lock();
|
|
println!("left `{name}` ({}) locally", short(&id, 10));
|
|
eprintln!(
|
|
"\nNothing was announced: the others keep the address and name this device \
|
|
claimed in it."
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Removes what the compiled-in protocols keep for a network.
|
|
///
|
|
/// The offline path has no agent and so no plugins to ask. Each failure is
|
|
/// reported and none is fatal: the network is already gone from the state.
|
|
fn forget_protocol_state(paths: &StoragePaths, network: tsunagi::NetworkId) {
|
|
let store = tsunagi_wg_quic::WireguardConfig::new(paths.state_dir.join("wg-quic"));
|
|
match tsunagi_wg_quic::WgKeyStore::open(store.key_store_path()) {
|
|
Ok(store) => {
|
|
if let Err(err) = store.forget(network) {
|
|
eprintln!("warning: the wg-quic key for it could not be removed: {err}");
|
|
}
|
|
}
|
|
// Never opened means never used, which is nothing to clean up.
|
|
Err(err) if !store.key_store_path().exists() => {
|
|
let _ = err;
|
|
}
|
|
Err(err) => eprintln!("warning: the wg-quic key store could not be opened: {err}"),
|
|
}
|
|
|
|
let pure_store = PureWgConfig::new(paths.state_dir.join("wg"));
|
|
match tsunagi_wireguard::WgKeyStore::open(pure_store.key_store_path()) {
|
|
Ok(store) => {
|
|
if let Err(err) = store.forget(network) {
|
|
eprintln!("warning: the wg key for it could not be removed: {err}");
|
|
}
|
|
}
|
|
Err(err) if !pure_store.key_store_path().exists() => {
|
|
let _ = err;
|
|
}
|
|
Err(err) => eprintln!("warning: the wg key store could not be opened: {err}"),
|
|
}
|
|
}
|
|
|
|
/// `tsunagi wipe`: back to a device that has never joined anything.
|
|
async fn wipe(args: WipeArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
let paths = args.paths.resolve()?;
|
|
let is_custom_state = args.paths.state_dir.is_some();
|
|
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
|
if tsunagi::ipc::is_serving(&socket).await {
|
|
return Err(
|
|
"stop the agent first: a wipe removes the state it is using, and leaving a \
|
|
network properly needs it running anyway"
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
let plan = tsunagi::storage::wipe_plan(&paths)?;
|
|
if plan.is_empty() {
|
|
println!("nothing stored: this device has never joined anything");
|
|
return Ok(());
|
|
}
|
|
|
|
let networks = stored_networks(&paths);
|
|
if !args.yes {
|
|
println!("`tsunagi wipe --yes` would remove:\n");
|
|
for entry in plan.entries() {
|
|
println!(" {}", entry.display());
|
|
}
|
|
if !networks.is_empty() {
|
|
println!("\nand with it, membership of:\n");
|
|
for network in &networks {
|
|
println!(" {} {}", network.name, network.network_id);
|
|
}
|
|
println!(
|
|
"\nNobody is told. Leave each network first — start the agent and run\n\
|
|
`tsunagi network leave <id>` — to free the address and name it holds\n\
|
|
for the others. Afterwards this device is a stranger: a new identity,\n\
|
|
no networks, and no way to sign anything for the old ones."
|
|
);
|
|
}
|
|
println!("\nNothing was removed.");
|
|
return Ok(());
|
|
}
|
|
|
|
let removed = tsunagi::storage::wipe(&paths)?;
|
|
// A socket file with nothing behind it is a leftover of the same kind. On
|
|
// Windows there is no socket file, so this only ever tidies a Unix one.
|
|
if !tsunagi::ipc::is_serving(&socket).await {
|
|
let _ = std::fs::remove_file(&socket);
|
|
}
|
|
println!("removed {} item(s):", removed.entries().count());
|
|
for entry in removed.entries() {
|
|
println!(" {}", entry.display());
|
|
}
|
|
if !networks.is_empty() {
|
|
eprintln!(
|
|
"\nThis device left {} network(s) without telling anybody; they keep what it \
|
|
claimed. The next start generates a new identity and knows nothing.",
|
|
networks.len()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `tsunagi dns`: the local resolver, and turning it on or off.
|
|
async fn dns_command(args: DnsArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
let paths = args.paths.resolve()?;
|
|
let is_custom_state = args.paths.state_dir.is_some();
|
|
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
|
|
|
let enable = match args.action {
|
|
None => return show_dns(&paths, &socket).await,
|
|
Some(DnsAction::On { .. }) => true,
|
|
Some(DnsAction::Off) => false,
|
|
};
|
|
let port = match args.action {
|
|
Some(DnsAction::On { port }) => port,
|
|
_ => None,
|
|
};
|
|
|
|
// The running agent, so it takes effect now; it stores the setting too,
|
|
// so the two can never say different things.
|
|
if tsunagi::ipc::is_serving(&socket).await {
|
|
let report = tsunagi::ipc::set_dns(&socket, enable, port).await?;
|
|
match report {
|
|
Some(report) => {
|
|
println!(
|
|
"serving {} on {}",
|
|
match report.zones.as_slice() {
|
|
[] => "no zone yet".to_string(),
|
|
zones => zones.join(", "),
|
|
},
|
|
if report.listening.is_empty() {
|
|
report
|
|
.bind_error
|
|
.clone()
|
|
.unwrap_or_else(|| "nothing".to_string())
|
|
} else {
|
|
report.listening.join(", ")
|
|
}
|
|
);
|
|
if let Some(err) = &report.publish_error {
|
|
eprintln!("\nthe system resolver was not told: {err}");
|
|
}
|
|
}
|
|
None => println!("not serving"),
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
let stored = dns_setting(&paths);
|
|
store_dns_setting(
|
|
&paths,
|
|
DnsSetting {
|
|
enabled: enable,
|
|
port: port.unwrap_or(stored.port),
|
|
},
|
|
)?;
|
|
println!("{} for future starts", if enable { "on" } else { "off" });
|
|
eprintln!("\nNo agent is running here, so it takes effect with the next `tsunagi up`.");
|
|
Ok(())
|
|
}
|
|
|
|
/// What the resolver is doing, or why it is not.
|
|
async fn show_dns(
|
|
paths: &StoragePaths,
|
|
socket: &std::path::Path,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
use report::Report;
|
|
|
|
let observed = observe(paths, socket).await;
|
|
let mut out = Report::new();
|
|
match &observed {
|
|
Observed::Agent(report) => out.push(match &report.dns {
|
|
Some(dns) => dns_section(dns),
|
|
None => dns_absent_section(),
|
|
}),
|
|
// Not running: the stored setting is what it will do next time,
|
|
// which is the only truthful thing to say.
|
|
Observed::Stored { .. } => {
|
|
use report::{Health, Row, Section};
|
|
let setting = dns_setting(paths);
|
|
let mut section = Section::new("dns");
|
|
section.push(
|
|
Row::new(
|
|
Health::Info,
|
|
if setting.enabled { "on" } else { "off" },
|
|
format!("port {}, for the next start", setting.port),
|
|
)
|
|
.with_note("no agent is running, so nothing is answering right now"),
|
|
);
|
|
out.push(section);
|
|
}
|
|
}
|
|
print_report("tsunagi dns", &out)
|
|
}
|
|
|
|
/// `tsunagi id`: what this device is, and what changes it.
|
|
async fn id(args: IdArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
let paths = args.paths.resolve()?;
|
|
let is_custom_state = args.paths.state_dir.is_some();
|
|
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
|
|
|
match args.action {
|
|
None => show_identity(&paths, &socket).await,
|
|
Some(IdAction::Hostname { name: None }) => show_hostname(&paths, &socket).await,
|
|
Some(IdAction::Hostname { name: Some(name) }) => set_hostname(&paths, &socket, &name).await,
|
|
Some(IdAction::Key { action: None }) => show_key(&paths, &socket).await,
|
|
Some(IdAction::Key {
|
|
action: Some(KeyAction::Rotate),
|
|
}) => rotate_key(&paths, &socket).await,
|
|
}
|
|
}
|
|
|
|
/// Everything about this device in one view.
|
|
async fn show_identity(
|
|
paths: &StoragePaths,
|
|
socket: &std::path::Path,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
use report::{Health, Report, Row, Section};
|
|
|
|
let observed = observe(paths, socket).await;
|
|
let mut out = Report::new();
|
|
|
|
let mut device = device_section(paths, &observed);
|
|
device.push(Row::new(
|
|
Health::Info,
|
|
"signs with",
|
|
"the endpoint key above; there is no separate signing certificate",
|
|
));
|
|
out.push(device);
|
|
|
|
// What this device *is*, not what it belongs to. The networks are
|
|
// `tsunagi network`, and their secrets are asked for by name there:
|
|
// printing them in an overview put them in every pasted report.
|
|
let networks = stored_networks(paths);
|
|
let mut section = Section::new("networks");
|
|
section.push(match networks.len() {
|
|
0 => Row::new(Health::Info, "none", "no network has been joined")
|
|
.with_note("`tsunagi join --network <name>` makes or joins one"),
|
|
count => Row::new(
|
|
Health::Info,
|
|
"joined",
|
|
format!(
|
|
"{count} network(s): {}",
|
|
networks
|
|
.iter()
|
|
.map(|network| network.name.as_str().to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
),
|
|
)
|
|
.with_note("`tsunagi network` lists them with their ids and addresses"),
|
|
});
|
|
out.push(section);
|
|
|
|
print_report("tsunagi id", &out)
|
|
}
|
|
|
|
/// The name this device answers to.
|
|
async fn show_hostname(
|
|
paths: &StoragePaths,
|
|
socket: &std::path::Path,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
match observe(paths, socket).await {
|
|
Observed::Agent(report) => println!("{}", report.hostname),
|
|
Observed::Stored { hostname, .. } => match hostname {
|
|
Some(hostname) => println!("{hostname}"),
|
|
None => println!(
|
|
"{}",
|
|
tsunagi::agent::system_hostname().unwrap_or_else(|| "unknown".into())
|
|
),
|
|
},
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Changes the name, through the agent when one is running.
|
|
///
|
|
/// Through it rather than behind its back: the agent republishes its signed
|
|
/// claim, which is what gives up the previous name, and tells its peers. A
|
|
/// write straight to the store while it ran would be overwritten by the next
|
|
/// thing the agent published.
|
|
async fn set_hostname(
|
|
paths: &StoragePaths,
|
|
socket: &std::path::Path,
|
|
name: &str,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
if tsunagi::ipc::is_serving(socket).await {
|
|
return match tsunagi::ipc::set_hostname(socket, name).await {
|
|
Ok(accepted) => {
|
|
println!("{accepted}");
|
|
Ok(())
|
|
}
|
|
Err(err) => {
|
|
Err(format!("the agent is running but would not accept the change: {err}").into())
|
|
}
|
|
};
|
|
}
|
|
|
|
let accepted = tsunagi::state::sanitise_hostname(name);
|
|
if accepted.is_empty() {
|
|
return Err("a hostname must contain at least one letter, digit, `-`, `.` or `_`".into());
|
|
}
|
|
let store = tsunagi::storage::StateStore::open(paths.state_db())?;
|
|
store.set_hostname(&accepted)?;
|
|
println!("{accepted}");
|
|
Ok(())
|
|
}
|
|
|
|
/// The key this device signs with.
|
|
async fn show_key(
|
|
paths: &StoragePaths,
|
|
socket: &std::path::Path,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
match observe(paths, socket).await {
|
|
Observed::Agent(report) => println!("{}", report.endpoint_id),
|
|
Observed::Stored { endpoint_id, .. } => match endpoint_id {
|
|
Some(id) => println!("{id}"),
|
|
None => return Err("this device has no identity yet; start an agent once".into()),
|
|
},
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Replaces the signing key.
|
|
async fn rotate_key(
|
|
paths: &StoragePaths,
|
|
socket: &std::path::Path,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
// The rotation writes, so it needs the directory to itself. Refused up
|
|
// front rather than after the lock fails, because what a lock failure
|
|
// says does not tell the reader what to do about it.
|
|
if tsunagi::ipc::is_serving(socket).await {
|
|
return Err(
|
|
"stop the agent first: replacing the signing key rewrites state it is using".into(),
|
|
);
|
|
}
|
|
|
|
let store = tsunagi::storage::StateStore::open(paths.state_db())?;
|
|
let (identity, released) = store.rotate_device_identity()?;
|
|
println!("{}", identity.endpoint_id());
|
|
if !released.is_empty() {
|
|
eprintln!(
|
|
"\nReleased what the previous key held in {} network(s). \
|
|
This device rejoins as a new member and is allocated a new address.",
|
|
released.len()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The secret of every network this device has joined.
|
|
fn show_secrets(
|
|
paths: &StoragePaths,
|
|
wanted: Option<&str>,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let networks = stored_networks(paths);
|
|
if networks.is_empty() {
|
|
eprintln!("no network has been joined");
|
|
return Ok(());
|
|
}
|
|
match wanted {
|
|
Some(wanted) => {
|
|
let network = resolve_network(&networks, wanted)?;
|
|
println!("{}", network.secret.encode().as_str());
|
|
}
|
|
None => {
|
|
for network in networks {
|
|
println!(
|
|
"{} {} {}",
|
|
network.name,
|
|
network.network_id,
|
|
network.secret.encode().as_str()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Reports this device, what the agent is doing, and what this host can do.
|
|
///
|
|
/// Three levels, and the distinction between the middle two is deliberate:
|
|
/// *degraded* is something the agent runs without and that the user can fix
|
|
/// from a stated one-liner, *broken* is something it cannot work around.
|
|
/// Getting those the wrong way round makes a diagnostic tool useless, so
|
|
/// each check below says which it is and why.
|
|
async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
use report::{Health, Report, Row, Section};
|
|
|
|
let paths = args.paths.resolve()?;
|
|
let is_custom_state = args.paths.state_dir.is_some();
|
|
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
|
let observed = observe(&paths, &socket).await;
|
|
|
|
let mut out = Report::new();
|
|
out.push(device_section(&paths, &observed));
|
|
|
|
let mut agent = Section::new("agent");
|
|
match &observed {
|
|
Observed::Agent(report) => {
|
|
agent.push(Row::new(
|
|
Health::Good,
|
|
"running",
|
|
format!("reachable at {}", socket.display()),
|
|
));
|
|
if !report.bound_sockets.is_empty() {
|
|
agent.push(Row::new(
|
|
Health::Info,
|
|
"bound",
|
|
report.bound_sockets.join(", "),
|
|
));
|
|
}
|
|
agent.push(if report.cache_healthy {
|
|
Row::new(Health::Good, "cache", "usable")
|
|
} else {
|
|
Row::new(Health::Degraded, "cache", "unavailable")
|
|
.with_note("disposable: the agent runs, rediscovering what it cached")
|
|
});
|
|
}
|
|
Observed::Stored {
|
|
why,
|
|
socket_present,
|
|
..
|
|
} => {
|
|
// Nothing running is an ordinary answer to "what is running", not
|
|
// a fault; a socket that will not answer is a fault.
|
|
agent.push(if *socket_present {
|
|
Row::new(Health::Degraded, "running", "not answering")
|
|
.with_note(format!("{why} · the rest was read from the store"))
|
|
} else {
|
|
Row::new(Health::Info, "running", "no")
|
|
.with_note(format!("{why} · the rest was read from the store"))
|
|
});
|
|
}
|
|
}
|
|
out.push(agent);
|
|
|
|
match &observed {
|
|
Observed::Agent(report) => {
|
|
for network in &report.networks {
|
|
// Whether another configured network answers to the same
|
|
// name, which is what makes two sections look like one.
|
|
let shared = report
|
|
.networks
|
|
.iter()
|
|
.filter(|other| other.name == network.name)
|
|
.count()
|
|
> 1;
|
|
out.push(network_section(network, &report.endpoint_id, shared));
|
|
}
|
|
}
|
|
// Without an agent there is no live view, but the store still knows
|
|
// which networks this device belongs to, which is worth saying.
|
|
Observed::Stored { .. } => out.push(configured_networks_section(&paths)),
|
|
}
|
|
|
|
if let Observed::Agent(report) = &observed {
|
|
out.push(match &report.dns {
|
|
Some(dns) => dns_section(dns),
|
|
None => dns_absent_section(),
|
|
});
|
|
}
|
|
|
|
out.push(host_section());
|
|
out.push(addresses_section().await);
|
|
print_report("tsunagi status", &out)
|
|
}
|
|
|
|
/// The local resolver: whether it answers, and whether the system asks it.
|
|
fn dns_section(dns: &tsunagi::ipc::DnsReport) -> report::Section {
|
|
use report::{Health, Row, Section};
|
|
|
|
let mut section = Section::new("dns");
|
|
section.push(Row::new(
|
|
Health::Info,
|
|
"zones",
|
|
match dns.zones.as_slice() {
|
|
[] => "none yet: this agent is in no network that can be one".to_string(),
|
|
zones => format!("{} · {} name(s)", zones.join(", "), dns.names),
|
|
},
|
|
));
|
|
for warning in &dns.zone_warnings {
|
|
section.push(Row::new(Health::Degraded, "zone name", warning.clone()));
|
|
}
|
|
|
|
match (dns.listening.as_slice(), &dns.bind_error) {
|
|
([], Some(err)) => {
|
|
section.push(Row::new(Health::Broken, "listening", err.clone()));
|
|
}
|
|
([], None) => {
|
|
section.push(Row::new(Health::Degraded, "listening", "not yet"));
|
|
}
|
|
// One address per family it could open. Both is the ordinary case;
|
|
// one is worth seeing rather than hiding, because then a question
|
|
// over the other family goes unanswered.
|
|
(addresses, _) => {
|
|
section.push(Row::new(Health::Good, "listening", addresses.join(", ")));
|
|
}
|
|
}
|
|
|
|
match &dns.publish_error {
|
|
None if !dns.listening.is_empty() => {
|
|
section.push(Row::new(
|
|
Health::Good,
|
|
"system resolver",
|
|
"asking this server for the zone",
|
|
));
|
|
}
|
|
None => {}
|
|
Some(err) => {
|
|
// The server still answers, so this is a degraded overlay and
|
|
// not a broken one; what is missing is the automatic part.
|
|
let row = Row::new(Health::Degraded, "system resolver", err.clone());
|
|
section.push(match (&dns.publish_remedy, dns.listening.first()) {
|
|
(Some(remedy), _) => row.with_note(remedy.clone()),
|
|
(None, Some(address)) => row.with_note(format!(
|
|
"resolve names yourself with `dig @{} -p {} <name>.{}`",
|
|
address.rsplit_once(':').map_or("", |(host, _)| host),
|
|
address.rsplit_once(':').map_or("", |(_, port)| port),
|
|
dns.zones.first().map_or("<zone>", String::as_str)
|
|
)),
|
|
(None, None) => row,
|
|
});
|
|
}
|
|
}
|
|
section
|
|
}
|
|
|
|
/// Says that there is no local resolver, when there is none.
|
|
///
|
|
/// Its absence is why a name does not resolve, and nothing else in the
|
|
/// report says so: a missing section reads as nothing to report rather than
|
|
/// as a feature that was never asked for.
|
|
fn dns_absent_section() -> report::Section {
|
|
use report::{Health, Row, Section};
|
|
|
|
let mut section = Section::new("dns");
|
|
section.push(
|
|
Row::new(
|
|
Health::Info,
|
|
"not serving",
|
|
"no local resolver for any network",
|
|
)
|
|
.with_note(
|
|
"members resolve by address only. `tsunagi up --dns` serves \
|
|
`<hostname>.<network>` from signed state, so a member that is switched \
|
|
off still resolves.",
|
|
),
|
|
);
|
|
section
|
|
}
|
|
|
|
/// One member of a network, from every source that knows something about it.
|
|
///
|
|
/// The three sources answer different questions and none of them answers the
|
|
/// whole one. The signed state says who belongs, and keeps saying it while
|
|
/// they are away. The session list says who is here. The overlay says whose
|
|
/// tunnel is up. Reporting them as three lists is what made a peer being
|
|
/// offline look like three unrelated faults.
|
|
struct MemberRow<'a> {
|
|
endpoint_id: &'a str,
|
|
hostname: Option<&'a str>,
|
|
/// `Some` exactly when there is an authenticated session right now.
|
|
transport: Option<&'a str>,
|
|
rtt_ms: Option<u64>,
|
|
overlay_address_v4: Option<&'a str>,
|
|
tunnel: Option<&'a tsunagi::ipc::OverlayPeerReport>,
|
|
failed_dials: u32,
|
|
}
|
|
|
|
impl MemberRow<'_> {
|
|
fn online(&self) -> bool {
|
|
self.transport.is_some()
|
|
}
|
|
|
|
/// What to call it: the name it announced, or a short form of its id.
|
|
///
|
|
/// A member that is away has no hostname, because nothing durable records
|
|
/// one — only the signed claim survives, and that carries an address.
|
|
fn label(&self) -> String {
|
|
match self.hostname {
|
|
Some(hostname) => hostname.to_string(),
|
|
None => short(self.endpoint_id, 12),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Joins the three views of a network into one list, online members first.
|
|
fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Vec<MemberRow<'a>> {
|
|
use std::collections::BTreeMap;
|
|
|
|
fn entry<'a, 'm>(
|
|
rows: &'m mut BTreeMap<&'a str, MemberRow<'a>>,
|
|
id: &'a str,
|
|
) -> &'m mut MemberRow<'a> {
|
|
rows.entry(id).or_insert_with(|| MemberRow {
|
|
endpoint_id: id,
|
|
hostname: None,
|
|
transport: None,
|
|
rtt_ms: None,
|
|
overlay_address_v4: None,
|
|
tunnel: None,
|
|
failed_dials: 0,
|
|
})
|
|
}
|
|
|
|
let mut rows: BTreeMap<&'a str, MemberRow<'a>> = BTreeMap::new();
|
|
for member in &network.members {
|
|
let row = entry(&mut rows, &member.endpoint_id);
|
|
row.overlay_address_v4 = member.overlay_address_v4.as_deref();
|
|
row.failed_dials = member.failed_dials;
|
|
}
|
|
for peer in &network.peers {
|
|
let row = entry(&mut rows, &peer.endpoint_id);
|
|
row.hostname = peer.hostname.as_deref();
|
|
row.transport = Some(&peer.transport);
|
|
row.rtt_ms = peer.rtt_ms;
|
|
}
|
|
if let Some(overlay) = &network.overlay {
|
|
for peer in &overlay.peers {
|
|
let row = entry(&mut rows, &peer.endpoint_id);
|
|
row.tunnel = Some(peer);
|
|
if row.overlay_address_v4.is_none() {
|
|
row.overlay_address_v4 = peer.address.as_deref();
|
|
}
|
|
}
|
|
}
|
|
|
|
// This agent is in the roster too — it signs claims like everyone else —
|
|
// but it is already the subject of the `device` section.
|
|
let mut rows: Vec<MemberRow<'a>> = rows
|
|
.into_values()
|
|
.filter(|row| row.endpoint_id != own_id)
|
|
.collect();
|
|
// Online first, as asked, then by name so the order is stable between
|
|
// runs rather than following whatever the map happened to hold.
|
|
rows.sort_by(|a, b| {
|
|
b.online()
|
|
.cmp(&a.online())
|
|
.then_with(|| a.label().cmp(&b.label()))
|
|
});
|
|
rows
|
|
}
|
|
|
|
/// One network: what it is, who is in it, and what has happened since start.
|
|
fn network_section(
|
|
network: &tsunagi::ipc::NetworkReport,
|
|
own_id: &str,
|
|
name_shared: bool,
|
|
) -> report::Section {
|
|
use report::{Health, Row, Section};
|
|
|
|
// The id is in the heading, not only in a row: a name is a label a user
|
|
// chose and two networks may share one, so a heading without the id
|
|
// reads as one network that is somehow both working and empty.
|
|
let mut section = Section::new(format!(
|
|
"network {} ({})",
|
|
network.name,
|
|
short(&network.network_id, 10)
|
|
));
|
|
section.push(if network.active {
|
|
Row::new(Health::Good, "state", network.network_id.clone())
|
|
} else {
|
|
Row::new(Health::Degraded, "state", "inactive")
|
|
});
|
|
if name_shared {
|
|
section.push(
|
|
Row::new(
|
|
Health::Degraded,
|
|
"name",
|
|
format!(
|
|
"another configured network is also called `{}`",
|
|
network.name
|
|
),
|
|
)
|
|
.with_note(
|
|
"a network is its name *and* its secret, so these two share nothing. \
|
|
Usually a mistyped secret; `tsunagi network secret` shows which is which.",
|
|
),
|
|
);
|
|
}
|
|
if let Some(conflict) = &network.range_conflict {
|
|
section.push(
|
|
Row::new(
|
|
Health::Degraded,
|
|
"range",
|
|
format!("cannot use {conflict}: another network here already does"),
|
|
)
|
|
.with_note(
|
|
"one agent has one interface, so an address belongs to one network. \
|
|
This one waits to adopt whatever its members settle on; give it \
|
|
`--ipv4-range` of its own to propose one.",
|
|
),
|
|
);
|
|
}
|
|
|
|
if let Some(overlay) = &network.overlay {
|
|
let interface = if overlay.on_host {
|
|
overlay.interface.clone()
|
|
} else {
|
|
// The name is real to the agent and to nothing else. Said here,
|
|
// because an address on an interface the operating system does
|
|
// not have explains every ping that goes nowhere.
|
|
format!("{} (in memory, --no-tun)", overlay.interface)
|
|
};
|
|
section.push(Row::new(
|
|
if overlay.on_host {
|
|
Health::Info
|
|
} else {
|
|
Health::Degraded
|
|
},
|
|
"overlay",
|
|
format!(
|
|
"{interface} {} mtu {}",
|
|
match &overlay.address {
|
|
Some(address) => format!("{address}/{}", overlay.prefix_len),
|
|
None => "no address agreed yet".to_string(),
|
|
},
|
|
overlay.mtu
|
|
),
|
|
));
|
|
}
|
|
|
|
// Only when something has: a relay that has carried nothing is not
|
|
// worth a line, and one that has is worth knowing about — it is
|
|
// somebody else's traffic on this device's uplink.
|
|
section.push(Row::new(
|
|
Health::Info,
|
|
"broadcast",
|
|
if network.broadcast { "on" } else { "off" },
|
|
));
|
|
let relayed = network.relay_forwarded + network.relay_sent_via + network.relay_received_via;
|
|
if relayed > 0 {
|
|
section.push(Row::new(
|
|
Health::Info,
|
|
"relay",
|
|
format!(
|
|
"{} carried for others · {} sent through a peer, {} arrived through one",
|
|
network.relay_forwarded, network.relay_sent_via, network.relay_received_via
|
|
),
|
|
));
|
|
}
|
|
|
|
let rows = member_rows(network, own_id);
|
|
let online = rows.iter().filter(|row| row.online()).count();
|
|
if rows.is_empty() {
|
|
// Why there is nobody, rather than just that there is nobody: the
|
|
// three reasons want different actions, and the third one used to
|
|
// read as the first.
|
|
match (&network.range, &network.range_conflict, network.candidates) {
|
|
(None, Some(_), _) => section.push(Row::new(
|
|
Health::Info,
|
|
"members",
|
|
"none: this network has no range to allocate from",
|
|
)),
|
|
// No candidates yet. DHT may still be bootstrapping; manual
|
|
// bootstrap remains useful when public UDP is unavailable.
|
|
(_, _, 0) => section.push(
|
|
Row::new(
|
|
Health::Degraded,
|
|
"members",
|
|
"none, and nobody to contact: no candidates in this network",
|
|
)
|
|
.with_note(format!(
|
|
"DHT lookup retries automatically when enabled. For manual \
|
|
bootstrap, start with `--peer <their-endpoint-id>`, or have \
|
|
the other device start with `--peer {}`.",
|
|
short(own_id, 12)
|
|
)),
|
|
),
|
|
_ => section.push(Row::new(
|
|
Health::Info,
|
|
"members",
|
|
format!(
|
|
"none yet · {} candidate(s) being tried",
|
|
network.candidates
|
|
),
|
|
)),
|
|
}
|
|
} else {
|
|
section.push(Row::new(
|
|
Health::Info,
|
|
"members",
|
|
format!("{online} of {} online", rows.len()),
|
|
));
|
|
}
|
|
|
|
for row in &rows {
|
|
section.push(member_row(row));
|
|
}
|
|
|
|
// Counters are history, not health. Grading them keeps a report red long
|
|
// after whatever caused them has gone away — which is exactly how a peer
|
|
// coming back still looked like three problems.
|
|
let (sent, received) = network.control_messages;
|
|
let mut totals = vec![format!("{sent} sent, {received} received")];
|
|
if network.dial_failures > 0 {
|
|
totals.push(format!("{} dial failure(s)", network.dial_failures));
|
|
}
|
|
if network.handshake_failures > 0 {
|
|
totals.push(format!(
|
|
"{} handshake failure(s)",
|
|
network.handshake_failures
|
|
));
|
|
}
|
|
if let Some(overlay) = &network.overlay
|
|
&& overlay.unroutable_packets > 0
|
|
{
|
|
totals.push(format!(
|
|
"{} packet(s) to an address nobody owns{}",
|
|
overlay.unroutable_packets,
|
|
match &overlay.unroutable_sample {
|
|
Some(sample) => format!(" ({sample})"),
|
|
None => String::new(),
|
|
}
|
|
));
|
|
}
|
|
// Repeated handshake failures with nobody connected is the signature of a
|
|
// mismatched secret, and that *is* a present-tense problem rather than a
|
|
// number from the past.
|
|
let health = if network.handshake_failures > 0 && online == 0 {
|
|
Health::Degraded
|
|
} else {
|
|
Health::Info
|
|
};
|
|
let totals_row = Row::new(health, "since start", totals.join(", "));
|
|
section.push(if health == Health::Degraded {
|
|
totals_row.with_note("handshakes are failing and nobody is connected: check that every member was given the same secret")
|
|
} else {
|
|
totals_row
|
|
});
|
|
|
|
section
|
|
}
|
|
|
|
/// One member: connected or not, and what is known either way.
|
|
fn member_row(row: &MemberRow<'_>) -> report::Row {
|
|
use report::{Health, Row};
|
|
|
|
let Some(transport) = row.transport else {
|
|
// Away. Not a fault of this agent, and in a mesh of laptops it is the
|
|
// ordinary condition, so it is stated rather than flagged.
|
|
let mut detail = "offline".to_string();
|
|
if let Some(v4) = row.overlay_address_v4 {
|
|
detail = format!("{v4:<15} offline · {v4} still reserved for it");
|
|
}
|
|
let out = Row::new(Health::Info, row.label(), detail);
|
|
return if row.failed_dials > 0 {
|
|
// Attributed to the member it concerns, rather than left as a
|
|
// network-wide counter with no explanation attached.
|
|
out.with_note(format!(
|
|
"{} dial attempt(s) failed since it was last reachable",
|
|
row.failed_dials
|
|
))
|
|
} else {
|
|
out
|
|
};
|
|
};
|
|
|
|
let direct = transport.eq_ignore_ascii_case("direct");
|
|
let tunnel_up = row.tunnel.is_some_and(|tunnel| tunnel.is_up());
|
|
let has_overlay = row.tunnel.is_some();
|
|
|
|
let mut detail = String::new();
|
|
if let Some(v4) = row.overlay_address_v4 {
|
|
detail.push_str(&format!("{v4:<15} "));
|
|
}
|
|
detail.push_str(&transport.to_lowercase());
|
|
if let Some(rtt) = row.rtt_ms {
|
|
detail.push_str(&format!(" rtt {rtt}ms"));
|
|
}
|
|
|
|
let health = if !direct || (has_overlay && !tunnel_up) {
|
|
Health::Degraded
|
|
} else {
|
|
Health::Good
|
|
};
|
|
|
|
let mut out = Row::new(health, row.label(), detail);
|
|
if let Some(tunnel) = row.tunnel {
|
|
let proto = if tunnel.protocol.is_empty() {
|
|
"wg-quic"
|
|
} else {
|
|
&tunnel.protocol
|
|
};
|
|
out = out.with_note(match tunnel.handshake_secs_ago {
|
|
Some(secs) => {
|
|
let stats = format!(
|
|
"tunnel up ({proto}), {} tx {} rx {}{}",
|
|
if proto == "tcp-tls" {
|
|
format!("uptime {secs}s,")
|
|
} else {
|
|
format!("handshake {secs}s ago,")
|
|
},
|
|
tunnel.tx_packets,
|
|
tunnel.rx_packets,
|
|
if tunnel.dropped > 0 {
|
|
format!(", {} dropped", tunnel.dropped)
|
|
} else {
|
|
String::new()
|
|
},
|
|
);
|
|
format!("{stats:<48} · {}", tunnel.path)
|
|
}
|
|
None => {
|
|
if proto == "wg-quic" || proto == "wg" {
|
|
"no WireGuard handshake yet; the tunnel cannot carry traffic".to_string()
|
|
} else {
|
|
format!("no {proto} handshake yet; the tunnel cannot carry traffic")
|
|
}
|
|
}
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Shortens an identifier for a column, with an ellipsis when it was cut.
|
|
fn short(text: &str, len: usize) -> String {
|
|
if text.chars().count() <= len {
|
|
text.to_string()
|
|
} else {
|
|
format!("{}…", text.chars().take(len).collect::<String>())
|
|
}
|
|
}
|
|
|
|
/// What this host can and cannot do for the data plane.
|
|
fn host_section() -> report::Section {
|
|
use report::{Health, Row, Section};
|
|
|
|
let mut host = Section::new("host");
|
|
let protocols: Vec<String> = PROTOCOLS
|
|
.iter()
|
|
.map(|spec| format!("{} (wire v{})", spec.name, spec.version))
|
|
.collect();
|
|
host.push(
|
|
Row::new(Health::Info, "protocols", protocols.join(", "))
|
|
.with_note("run `tsunagi protocols` to see what each one takes"),
|
|
);
|
|
host.push(Row::new(
|
|
Health::Info,
|
|
"implementation",
|
|
"userspace overlay (boringtun WireGuard, TCP TLS 1.3); no kernel module needed",
|
|
));
|
|
{
|
|
if cfg!(target_os = "linux") {
|
|
let tun_path = std::path::Path::new("/dev/net/tun");
|
|
host.push(if !tun_path.exists() {
|
|
Row::new(Health::Broken, "/dev/net/tun", "missing")
|
|
.with_note("load the `tun` module; without it there can be no interface")
|
|
} else {
|
|
match std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.open(tun_path)
|
|
{
|
|
Ok(_) => Row::new(Health::Good, "/dev/net/tun", "openable"),
|
|
Err(err) => Row::new(
|
|
Health::Broken,
|
|
"/dev/net/tun",
|
|
format!("not openable: {err}"),
|
|
)
|
|
.with_note("the device node must be readable and writable by this user"),
|
|
}
|
|
});
|
|
}
|
|
|
|
use tsunagi::overlay::{Privilege, probe_net_admin};
|
|
match probe_net_admin() {
|
|
Privilege::Available => {
|
|
let held = if cfg!(target_os = "windows") {
|
|
"assumes an elevated process; creation reports if not"
|
|
} else {
|
|
"CAP_NET_ADMIN held"
|
|
};
|
|
host.push(Row::new(Health::Good, "privileges", held));
|
|
host.push(Row::new(
|
|
Health::Good,
|
|
"interface",
|
|
"managed by the agent: created on start, removed on exit",
|
|
));
|
|
}
|
|
Privilege::Missing(_) => {
|
|
// The note is the command and nothing else: a paragraph of
|
|
// explanation belongs in the runtime error, not in a column
|
|
// the eye is meant to scan.
|
|
host.push(
|
|
Row::new(Health::Degraded, "privileges", "CAP_NET_ADMIN not held").with_note(
|
|
format!(
|
|
"sudo setcap cap_net_admin,cap_net_bind_service+p {}",
|
|
program_path()
|
|
),
|
|
),
|
|
);
|
|
host.push(Row::new(
|
|
Health::Degraded,
|
|
"interface",
|
|
"cannot be created; run with `--no-tun` meanwhile",
|
|
));
|
|
}
|
|
Privilege::Unsupported => {
|
|
host.push(Row::new(
|
|
Health::Degraded,
|
|
"privileges",
|
|
format!(
|
|
"managing interfaces is not implemented on {} yet",
|
|
std::env::consts::OS
|
|
),
|
|
));
|
|
host.push(Row::new(
|
|
Health::Degraded,
|
|
"interface",
|
|
"cannot be created; run with `--no-tun`",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
host
|
|
}
|
|
|
|
/// The addresses this host could reach a peer from.
|
|
async fn addresses_section() -> report::Section {
|
|
use report::{Health, Row, Section};
|
|
|
|
let mut addresses = Section::new("local addresses");
|
|
let found = netwatch_addresses().await;
|
|
if found.is_empty() {
|
|
addresses.push(
|
|
Row::new(Health::Degraded, "interfaces", "none found")
|
|
.with_note("best effort; the agent may still find a way out"),
|
|
);
|
|
}
|
|
for addr in found {
|
|
// Loopback alone reaches nobody, but on a host that also has a real
|
|
// address it is unremarkable, so it is labelled rather than flagged.
|
|
let kind = match (addr.is_loopback(), addr.is_ipv4()) {
|
|
(true, _) => "loopback",
|
|
(false, true) => "ipv4",
|
|
(false, false) => "ipv6",
|
|
};
|
|
addresses.push(Row::new(Health::Info, kind, addr.to_string()));
|
|
}
|
|
addresses
|
|
}
|
|
|
|
/// Writes a report to stdout under a title.
|
|
///
|
|
/// `anstream` decides whether the escapes survive: they are stripped when
|
|
/// stdout is not a terminal, when `NO_COLOR` is set, and on a Windows console
|
|
/// that cannot render them.
|
|
fn print_report(title: &str, out: &report::Report) -> Result<(), Box<dyn std::error::Error>> {
|
|
use std::io::Write;
|
|
let mut stdout = anstream::stdout().lock();
|
|
writeln!(stdout, "{title}\n")?;
|
|
write!(stdout, "{}", out.render(true))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The shape of what `tsunagi status` reports.
|
|
///
|
|
/// Findings are built first and rendered second, so what is reported is
|
|
/// decided separately from how it looks and can be tested without a
|
|
/// terminal. Colour is deliberately *redundant*: every row carries a word as
|
|
/// well, so the report reads the same when the escapes are stripped — piped
|
|
/// to a file, on a dumb terminal, or by someone who cannot distinguish the
|
|
/// colours.
|
|
mod report {
|
|
use anstyle::{AnsiColor, Color, Style};
|
|
|
|
/// How healthy one finding is.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Health {
|
|
/// Not a check at all: a fact, such as an identifier or a path.
|
|
///
|
|
/// Grading these would be noise — an endpoint id is neither good nor
|
|
/// bad — and a column of green `ok` next to plain data teaches the
|
|
/// eye to ignore the column, which is the opposite of the point.
|
|
Info,
|
|
/// Works, nothing to do.
|
|
Good,
|
|
/// The agent runs, but something it could do it cannot, and there is
|
|
/// a remedy. A missing capability with a one-line fix lands here.
|
|
Degraded,
|
|
/// Something the agent needs is unavailable and the function it
|
|
/// serves will not work at all.
|
|
Broken,
|
|
}
|
|
|
|
impl Health {
|
|
/// The word printed in the margin. Four characters, so rows line up.
|
|
fn word(self) -> &'static str {
|
|
match self {
|
|
Health::Info => " ",
|
|
Health::Good => "ok ",
|
|
Health::Degraded => "warn",
|
|
Health::Broken => "FAIL",
|
|
}
|
|
}
|
|
|
|
fn style(self) -> Style {
|
|
let colour = match self {
|
|
Health::Info => return Style::new(),
|
|
Health::Good => AnsiColor::Green,
|
|
Health::Degraded => AnsiColor::Yellow,
|
|
Health::Broken => AnsiColor::Red,
|
|
};
|
|
Style::new().fg_color(Some(Color::Ansi(colour)))
|
|
}
|
|
}
|
|
|
|
/// One finding.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Row {
|
|
health: Health,
|
|
label: String,
|
|
detail: String,
|
|
/// What to do about it, when there is something to do.
|
|
note: Option<String>,
|
|
}
|
|
|
|
impl Row {
|
|
/// A finding with no remedy attached.
|
|
pub fn new(health: Health, label: impl Into<String>, detail: impl Into<String>) -> Self {
|
|
Self {
|
|
health,
|
|
label: label.into(),
|
|
detail: detail.into(),
|
|
note: None,
|
|
}
|
|
}
|
|
|
|
/// Adds the remedy shown under the row.
|
|
pub fn with_note(mut self, note: impl Into<String>) -> Self {
|
|
self.note = Some(note.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
/// A group of findings under a heading.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Section {
|
|
title: String,
|
|
rows: Vec<Row>,
|
|
}
|
|
|
|
impl Section {
|
|
/// An empty section.
|
|
pub fn new(title: impl Into<String>) -> Self {
|
|
Self {
|
|
title: title.into(),
|
|
rows: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Adds a finding.
|
|
pub fn push(&mut self, row: Row) {
|
|
self.rows.push(row);
|
|
}
|
|
}
|
|
|
|
/// Everything `doctor` found.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Report {
|
|
sections: Vec<Section>,
|
|
}
|
|
|
|
impl Report {
|
|
/// An empty report.
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Adds a section, dropping it if it has no findings.
|
|
pub fn push(&mut self, section: Section) {
|
|
if !section.rows.is_empty() {
|
|
self.sections.push(section);
|
|
}
|
|
}
|
|
|
|
fn count(&self, health: Health) -> usize {
|
|
self.sections
|
|
.iter()
|
|
.flat_map(|section| §ion.rows)
|
|
.filter(|row| row.health == health)
|
|
.count()
|
|
}
|
|
|
|
/// The worst thing in the report.
|
|
pub fn worst(&self) -> Health {
|
|
if self.count(Health::Broken) > 0 {
|
|
Health::Broken
|
|
} else if self.count(Health::Degraded) > 0 {
|
|
Health::Degraded
|
|
} else {
|
|
Health::Good
|
|
}
|
|
}
|
|
|
|
/// Whether anything in the report was graded at all.
|
|
///
|
|
/// A report of plain facts — `tsunagi id` — has nothing to summarise,
|
|
/// and "everything checked out" under a list of identifiers would be
|
|
/// claiming something that was never checked.
|
|
fn has_checks(&self) -> bool {
|
|
self.sections
|
|
.iter()
|
|
.flat_map(|section| §ion.rows)
|
|
.any(|row| row.health != Health::Info)
|
|
}
|
|
|
|
/// The closing line.
|
|
fn summary(&self) -> String {
|
|
fn checks(count: usize) -> String {
|
|
if count == 1 {
|
|
"1 check".to_string()
|
|
} else {
|
|
format!("{count} checks")
|
|
}
|
|
}
|
|
let (degraded, broken) = (self.count(Health::Degraded), self.count(Health::Broken));
|
|
match (degraded, broken) {
|
|
(0, 0) => "everything checked out".to_string(),
|
|
(0, broken) => format!("{} broken", checks(broken)),
|
|
(degraded, 0) => format!("{} degraded", checks(degraded)),
|
|
(degraded, broken) => {
|
|
format!("{} degraded, {} broken", checks(degraded), checks(broken))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Renders the report.
|
|
///
|
|
/// `styled` false leaves out every escape sequence, which is what a
|
|
/// test asserts against and what a redirected stdout gets.
|
|
pub fn render(&self, styled: bool) -> String {
|
|
let width = self
|
|
.sections
|
|
.iter()
|
|
.flat_map(|section| §ion.rows)
|
|
.map(|row| row.label.chars().count())
|
|
.max()
|
|
.unwrap_or(0);
|
|
|
|
let paint = |style: Style, text: &str| {
|
|
if styled {
|
|
format!("{style}{text}{style:#}")
|
|
} else {
|
|
text.to_string()
|
|
}
|
|
};
|
|
let bold = Style::new().bold();
|
|
let dim = Style::new().dimmed();
|
|
|
|
let mut out = String::new();
|
|
for section in &self.sections {
|
|
out.push_str(&paint(bold, §ion.title));
|
|
out.push('\n');
|
|
for row in §ion.rows {
|
|
let label = format!("{:width$}", row.label, width = width);
|
|
let label = if row.health == Health::Info {
|
|
paint(dim, &label)
|
|
} else {
|
|
label
|
|
};
|
|
out.push_str(&format!(
|
|
" {} {} {}\n",
|
|
paint(row.health.style(), row.health.word()),
|
|
label,
|
|
row.detail
|
|
));
|
|
if let Some(note) = &row.note {
|
|
// Indented under the row it belongs to, and dimmed so
|
|
// the findings stay the thing the eye lands on.
|
|
out.push_str(&format!(
|
|
" {:4} {:width$} {}\n",
|
|
"",
|
|
"",
|
|
paint(dim, note),
|
|
width = width
|
|
));
|
|
}
|
|
}
|
|
out.push('\n');
|
|
}
|
|
|
|
if self.has_checks() {
|
|
let worst = self.worst();
|
|
out.push_str(&paint(worst.style(), &self.summary()));
|
|
out.push('\n');
|
|
} else {
|
|
// Trim the blank line the last section left behind.
|
|
while out.ends_with("\n\n") {
|
|
out.pop();
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use super::*;
|
|
|
|
fn sample() -> Report {
|
|
let mut report = Report::new();
|
|
let mut storage = Section::new("storage");
|
|
storage.push(Row::new(Health::Good, "state", "/var/lib/tsunagi"));
|
|
storage.push(
|
|
Row::new(Health::Degraded, "cache directory", "not writable")
|
|
.with_note("disposable; the agent runs without it"),
|
|
);
|
|
report.push(storage);
|
|
let mut plane = Section::new("data plane");
|
|
plane.push(Row::new(Health::Broken, "/dev/net/tun", "missing"));
|
|
report.push(plane);
|
|
report
|
|
}
|
|
|
|
/// Drops every CSI sequence, so a styled render can be compared with
|
|
/// a plain one.
|
|
fn strip(text: &str) -> String {
|
|
let mut out = String::new();
|
|
let mut chars = text.chars();
|
|
while let Some(ch) = chars.next() {
|
|
if ch == '\u{1b}' {
|
|
for ch in chars.by_ref() {
|
|
if ch == 'm' {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
out.push(ch);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn an_unstyled_report_carries_no_escape_sequences() {
|
|
// Colour must never be the only signal: this is what lands in a
|
|
// file, a pipe, or a terminal that cannot do colour.
|
|
let text = sample().render(false);
|
|
assert!(!text.contains('\u{1b}'), "{text:?}");
|
|
assert!(text.contains("ok "));
|
|
assert!(text.contains("warn"));
|
|
assert!(text.contains("FAIL"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_styled_report_says_the_same_thing_with_escapes_added() {
|
|
let styled = sample().render(true);
|
|
assert!(styled.contains('\u{1b}'));
|
|
assert_eq!(strip(&styled), sample().render(false));
|
|
}
|
|
|
|
#[test]
|
|
fn the_detail_column_starts_at_the_same_offset_on_every_row() {
|
|
// Labels differ in length across sections, so the padding has to
|
|
// be computed over the whole report rather than per section.
|
|
let mut report = Report::new();
|
|
let mut short = Section::new("short labels");
|
|
short.push(Row::new(Health::Good, "a", "detail-one"));
|
|
report.push(short);
|
|
let mut long = Section::new("long labels");
|
|
long.push(Row::new(
|
|
Health::Broken,
|
|
"a-much-longer-label",
|
|
"detail-two",
|
|
));
|
|
report.push(long);
|
|
|
|
let text = report.render(false);
|
|
let offsets: Vec<usize> = ["detail-one", "detail-two"]
|
|
.iter()
|
|
.map(|detail| {
|
|
let line = text
|
|
.lines()
|
|
.find(|line| line.contains(detail))
|
|
.unwrap_or_else(|| panic!("no row for {detail} in:\n{text}"));
|
|
line.find(detail).unwrap()
|
|
})
|
|
.collect();
|
|
assert_eq!(offsets[0], offsets[1], "misaligned:\n{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_summary_names_the_worst_thing_found() {
|
|
assert_eq!(sample().worst(), Health::Broken);
|
|
assert!(
|
|
sample()
|
|
.render(false)
|
|
.contains("1 check degraded, 1 check broken")
|
|
);
|
|
|
|
let mut clean = Report::new();
|
|
let mut section = Section::new("storage");
|
|
section.push(Row::new(Health::Good, "state", "fine"));
|
|
clean.push(section);
|
|
assert_eq!(clean.worst(), Health::Good);
|
|
assert!(clean.render(false).contains("everything checked out"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_report_of_plain_facts_claims_nothing_at_the_end() {
|
|
// `tsunagi id` reports identifiers, not checks. Summarising them
|
|
// as fine would assert something that was never tested.
|
|
let mut report = Report::new();
|
|
let mut section = Section::new("device");
|
|
section.push(Row::new(Health::Info, "endpoint id", "abc123"));
|
|
report.push(section);
|
|
|
|
let text = report.render(false);
|
|
assert!(!text.contains("everything checked out"), "{text:?}");
|
|
assert!(!text.contains("degraded") && !text.contains("broken"));
|
|
assert!(text.ends_with("abc123\n"), "{text:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_section_is_left_out_rather_than_printed_bare() {
|
|
let mut report = Report::new();
|
|
report.push(Section::new("nothing here"));
|
|
assert!(!report.render(false).contains("nothing here"));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The user this process is running as, for an instruction it can paste.
|
|
fn current_user() -> String {
|
|
std::env::var("USER")
|
|
.or_else(|_| std::env::var("LOGNAME"))
|
|
.unwrap_or_else(|_| "<your-user>".to_string())
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
|
|
async fn netwatch_addresses() -> Vec<std::net::IpAddr> {
|
|
// Best effort; used for diagnostics only.
|
|
let state = netwatch::interfaces::State::new().await;
|
|
let mut addresses = Vec::new();
|
|
for (name, iface) in &state.interfaces {
|
|
let name_lower = name.to_lowercase();
|
|
if name_lower.starts_with("tsun") || name_lower.contains("tsunagi") {
|
|
continue;
|
|
}
|
|
for prefix in iface.addrs() {
|
|
addresses.push(prefix.addr());
|
|
}
|
|
}
|
|
addresses.sort();
|
|
addresses.dedup();
|
|
addresses
|
|
}
|
|
|
|
fn dht_enabled(args: &UpArgs) -> bool {
|
|
!matches!(args.reach, Reach::Local) && (args.dht || !args.no_dht)
|
|
}
|
|
|
|
async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
let paths = args.paths.resolve()?;
|
|
|
|
// 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())?;
|
|
|
|
// The resolver is a property of the device, not of this command line.
|
|
// Enabled unless deliberately disabled. Explicit flags override the
|
|
// remembered choice, which otherwise survives subsequent restarts.
|
|
let stored_dns = dns_setting(&paths);
|
|
let serve_dns = !args.no_dns && (args.dns || stored_dns.enabled);
|
|
let dns_port = args.dns_port.unwrap_or(stored_dns.port);
|
|
if serve_dns != stored_dns.enabled || dns_port != stored_dns.port {
|
|
// Written before the agent takes the directory, which it is about
|
|
// to do; nothing else holds it at this point.
|
|
store_dns_setting(
|
|
&paths,
|
|
DnsSetting {
|
|
enabled: serve_dns,
|
|
port: dns_port,
|
|
},
|
|
)?;
|
|
}
|
|
|
|
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.clone())) as Arc<dyn NetworkDiscovery>,
|
|
]));
|
|
|
|
let mut config = AgentConfig::new(paths.clone())
|
|
.with_overlay_ipv4_range(ipv4_range)
|
|
.with_transport(args.reach.into())
|
|
.with_discovery(discovery)
|
|
.with_discovery_interval(Duration::from_secs(5));
|
|
if dht_enabled(&args) {
|
|
config = config.with_dht(MainlineDiscovery::default());
|
|
}
|
|
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());
|
|
}
|
|
|
|
// What the user asked for is checked first, before anything that could
|
|
// fail for a reason of its own: a misspelled protocol or setting is
|
|
// their mistake to see, not something to bury under a privilege error.
|
|
let wanted: Vec<&ProtocolSpec> = {
|
|
let mut wanted = Vec::new();
|
|
for name in args
|
|
.protocols
|
|
.iter()
|
|
.filter(|name| !name.eq_ignore_ascii_case("none"))
|
|
{
|
|
// Accept "wireguard" as a convenience alias for the canonical "wg".
|
|
let canonical = if name.eq_ignore_ascii_case("wireguard") {
|
|
WG_PROTOCOL
|
|
} else {
|
|
name.as_str()
|
|
};
|
|
let Some(spec) = PROTOCOLS.iter().find(|spec| spec.name == canonical) else {
|
|
let known: Vec<&str> = PROTOCOLS.iter().map(|spec| spec.name).collect();
|
|
return Err(format!(
|
|
"this build has no protocol called `{name}`; it has {}. \
|
|
Run `tsunagi protocols` to see what each one takes.",
|
|
known.join(", ")
|
|
)
|
|
.into());
|
|
};
|
|
wanted.push(spec);
|
|
}
|
|
wanted
|
|
};
|
|
|
|
let settings = parse_settings(&args.protocol_options)?;
|
|
// A setting nobody takes is a mistake, not a preference: one that was
|
|
// silently dropped looks exactly like one that did not work.
|
|
for setting in &settings {
|
|
if !wanted
|
|
.iter()
|
|
.any(|spec| !settings_for(spec, std::slice::from_ref(setting)).is_empty())
|
|
{
|
|
return Err(format!(
|
|
"no selected protocol takes `{}`; run `tsunagi protocols` to see what they do",
|
|
setting.key
|
|
)
|
|
.into());
|
|
}
|
|
}
|
|
|
|
// The interface belongs to the agent, so it is configured once whatever
|
|
// was selected to carry traffic over it.
|
|
let mut wireguard = None;
|
|
let mut tcp_tls_transport = None;
|
|
let mut tcp_tls_plugin = None;
|
|
let mut pure_wg_plugin: Option<Arc<PureWgPlugin>> = None;
|
|
let mut pure_wg_transport: Option<Arc<PureWgTransport>> = None;
|
|
if !wanted.is_empty() {
|
|
let tun_factory: Arc<dyn TunFactory> = if args.no_tun {
|
|
Arc::new(MemoryTunFactory::new())
|
|
} else {
|
|
system_tun_factory()?
|
|
};
|
|
let mtu = args.mtu.unwrap_or(tsunagi_wg_quic::DEFAULT_MTU);
|
|
config = config.with_interface(tun_factory, args.interface.clone(), mtu);
|
|
}
|
|
|
|
for spec in &wanted {
|
|
let options = settings_for(spec, &settings);
|
|
match spec.name {
|
|
tsunagi_wg_quic::WIREGUARD_PROTOCOL => {
|
|
let mut wg = WireguardConfig::new(paths.state_dir.join("wg-quic"));
|
|
if let Some(mtu) = args.mtu {
|
|
wg = wg.with_mtu(mtu);
|
|
}
|
|
let wg = WireguardPlugin::configure(wg, &options)?;
|
|
let plugin = WireguardPlugin::open(wg).await?;
|
|
config = config.with_plugin(plugin.clone() as Arc<dyn IpPlugin>);
|
|
wireguard = Some(plugin);
|
|
}
|
|
TCP_TLS_PROTOCOL => {
|
|
let tls_cfg = TcpTlsConfig::new();
|
|
let tls_cfg = TcpTlsCodec::configure(tls_cfg, &options)?;
|
|
let identity = tsunagi::storage::StateStore::open(paths.state_db())?
|
|
.load_or_create_device_identity()?;
|
|
let transport = Arc::new(
|
|
TcpTlsTransport::bind(&identity, tls_cfg.port, tls_cfg.sni)
|
|
.await
|
|
.map_err(|err| err.to_string())?,
|
|
);
|
|
for ep_addr in &bootstrap {
|
|
for ip in ep_addr.ip_addrs() {
|
|
transport.set_peer_addr(ep_addr.id, *ip);
|
|
}
|
|
}
|
|
let codec = TcpTlsCodec::new(identity.endpoint_id(), Arc::clone(&transport));
|
|
let plugin = Arc::new(TcpTlsPlugin::new(codec));
|
|
config = config
|
|
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>)
|
|
.with_custom_transport(
|
|
TCP_TLS_PROTOCOL,
|
|
Arc::clone(&transport)
|
|
as Arc<dyn tsunagi::dataplane::transport::PacketTransport>,
|
|
);
|
|
tcp_tls_transport = Some(transport);
|
|
tcp_tls_plugin = Some(plugin);
|
|
}
|
|
WG_PROTOCOL => {
|
|
let mut wg_cfg = PureWgConfig::new(paths.state_dir.join("wg"));
|
|
if let Some(mtu) = args.mtu {
|
|
wg_cfg = wg_cfg.with_mtu(mtu);
|
|
}
|
|
let wg_cfg = PureWgPlugin::configure(wg_cfg, &options)?;
|
|
let identity = tsunagi::storage::StateStore::open(paths.state_db())?
|
|
.load_or_create_device_identity()?;
|
|
let transport = Arc::new(
|
|
PureWgTransport::bind(&identity, wg_cfg.port)
|
|
.await
|
|
.map_err(|err| err.to_string())?,
|
|
);
|
|
for ep_addr in &bootstrap {
|
|
for addr in ep_addr.ip_addrs() {
|
|
transport.set_peer_addr(ep_addr.id, *addr);
|
|
}
|
|
}
|
|
let plugin = PureWgPlugin::open(wg_cfg, Arc::clone(&transport)).await?;
|
|
config = config
|
|
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>)
|
|
.with_custom_transport(
|
|
WG_PROTOCOL,
|
|
Arc::clone(&transport)
|
|
as Arc<dyn tsunagi::dataplane::transport::PacketTransport>,
|
|
);
|
|
pure_wg_transport = Some(transport);
|
|
pure_wg_plugin = Some(plugin);
|
|
}
|
|
other => return Err(format!("`{other}` is listed but not built in").into()),
|
|
}
|
|
}
|
|
|
|
let agent = match Agent::spawn(config).await {
|
|
Ok(agent) => agent,
|
|
// One agent per identity, and the state directory is that identity.
|
|
// It can be in as many networks as you like — but only through the
|
|
// agent that is already running, so the lock on its own is an
|
|
// answer to a question nobody asked.
|
|
Err(tsunagi::Error::StateLocked { path }) => {
|
|
let is_custom_state = args.paths.state_dir.is_some();
|
|
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
|
if tsunagi::ipc::is_serving(&socket).await {
|
|
return Err(format!(
|
|
"an agent is already running for {}, and one state directory is one \
|
|
agent — it is the device, not a network.\n\n\
|
|
To add a network to it:\n\n \
|
|
tsunagi join --network <name>\n\n\
|
|
To run a second, separate agent instead, give it everything of its \
|
|
own:\n\n \
|
|
tsunagi up --state-dir <dir> --cache-dir <dir> --interface tsun1 \
|
|
--ipv4-range <cidr>\n\n\
|
|
That is a different identity with its own interface, not this one \
|
|
with another network. `tsunagi network` lists what this one has.",
|
|
path.display()
|
|
)
|
|
.into());
|
|
}
|
|
return Err(tsunagi::Error::StateLocked { path }.into());
|
|
}
|
|
Err(err) => return Err(err.into()),
|
|
};
|
|
// From here on every exit goes through `agent.shutdown()`, so the endpoint
|
|
// is never dropped without being closed.
|
|
let mut events = agent.subscribe();
|
|
|
|
if let Some(transport) = tcp_tls_transport {
|
|
let agent_inbound = agent.clone();
|
|
tokio::spawn(async move {
|
|
transport
|
|
.accept_loop(move |inbound| {
|
|
let agent = agent_inbound.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(err) = agent.install_inbound_link(inbound).await {
|
|
tracing::debug!(%err, "failed to install inbound tcp-tls link");
|
|
}
|
|
});
|
|
})
|
|
.await;
|
|
});
|
|
}
|
|
if let Some(transport) = pure_wg_transport {
|
|
let agent_inbound = agent.clone();
|
|
tokio::spawn(async move {
|
|
transport
|
|
.accept_loop(move |inbound| {
|
|
let agent = agent_inbound.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(err) = agent.install_inbound_link(inbound).await {
|
|
tracing::debug!(%err, "failed to install inbound wg link");
|
|
}
|
|
});
|
|
})
|
|
.await;
|
|
});
|
|
}
|
|
|
|
let protocol_names: Vec<&str> = wanted.iter().map(|p| p.name).collect();
|
|
|
|
println!("tsunagi is up");
|
|
println!(" endpoint id {}", agent.endpoint_id());
|
|
println!(" hostname {}", agent.hostname());
|
|
println!(
|
|
" protocols {} (priority order)",
|
|
protocol_names.join(", ")
|
|
);
|
|
// What this device belongs to is a separate question from whether its
|
|
// agent is running, and `tsunagi join` answers it at any time.
|
|
let configured = agent.list_networks().await.unwrap_or_default();
|
|
let running = configured.iter().filter(|network| network.active).count();
|
|
println!(
|
|
" networks {}",
|
|
match configured.len() {
|
|
0 => "none yet · `tsunagi join --network <name>` makes or joins one".to_string(),
|
|
total => format!("{running} of {total} running · `tsunagi network` lists them"),
|
|
}
|
|
);
|
|
println!(" state {}", paths.state_dir.display());
|
|
// A local resolver for every network this agent is in, each a zone
|
|
// named after it. A name that shadows a public one is reported and then
|
|
// used, because that is a decision and not a mistake.
|
|
let dns = Arc::new(tokio::sync::Mutex::new(if serve_dns {
|
|
println!(" dns 127.0.0.1:{dns_port} and [::1]:{dns_port}");
|
|
Some(spawn_dns(agent.clone(), dns_port))
|
|
} else {
|
|
None
|
|
}));
|
|
|
|
// 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 pure_wg = pure_wg_plugin.clone();
|
|
let plugin = wireguard.clone();
|
|
let tcp_tls = tcp_tls_plugin.clone();
|
|
let dns = Arc::clone(&dns);
|
|
let source: Arc<dyn tsunagi::ipc::ReportSource> = Arc::new(AgentControl {
|
|
agent,
|
|
pure_wg,
|
|
plugin,
|
|
tcp_tls,
|
|
dns,
|
|
paths: paths.clone(),
|
|
});
|
|
let is_custom_state = args.paths.state_dir.is_some();
|
|
let path = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
|
match tsunagi::ipc::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
|
|
}
|
|
}
|
|
};
|
|
|
|
println!("\nPress Ctrl-C to stop.\n");
|
|
|
|
tracing::info!(
|
|
endpoint_id = %agent.endpoint_id(),
|
|
hostname = %agent.hostname(),
|
|
protocols = ?protocol_names,
|
|
"Agent started and listening"
|
|
);
|
|
|
|
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! {
|
|
reason = stop_signal() => {
|
|
tracing::info!("Stopping agent ({reason})...");
|
|
break;
|
|
}
|
|
event = events.recv() => match event {
|
|
Ok(event) => log_event(&event),
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
|
tracing::warn!("Event channel lagged, 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() => {
|
|
log_status_summary(&agent, None).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(control) = control {
|
|
control.shutdown().await;
|
|
}
|
|
// Before the agent, so the resolver stops being pointed at a server
|
|
// that is about to stop answering.
|
|
if let Some(dns) = dns.lock().await.take() {
|
|
dns.shutdown().await;
|
|
}
|
|
agent.shutdown().await;
|
|
tracing::info!("Agent stopped.");
|
|
Ok(())
|
|
}
|
|
|
|
/// 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,
|
|
pure_wg: Option<&PureWgPlugin>,
|
|
wireguard: Option<&WireguardPlugin>,
|
|
tcp_tls: Option<&TcpTlsPlugin>,
|
|
dns: Option<DnsState>,
|
|
) -> tsunagi::ipc::StatusReport {
|
|
use tsunagi::ipc::{
|
|
MemberReport, NetworkReport, OverlayPeerReport, OverlayReport, PeerReport, StatusReport,
|
|
};
|
|
|
|
let overlay = agent.overlay();
|
|
let dns = dns.map(dns_report);
|
|
|
|
let Ok(status) = agent.status().await else {
|
|
return StatusReport::default();
|
|
};
|
|
|
|
let networks = status
|
|
.networks
|
|
.iter()
|
|
.map(|network| {
|
|
let wg_view = wireguard.and_then(|plugin| plugin.overview(network.network_id));
|
|
let pure_wg_view = pure_wg.and_then(|plugin| plugin.overview(network.network_id));
|
|
let tcp_tls_reports = tcp_tls
|
|
.map(|p| p.active_peers(network.network_id))
|
|
.unwrap_or_default();
|
|
|
|
// Use whichever WireGuard view is available for the overlay
|
|
// address and range — wg-quic and pure wg both derive them
|
|
// identically from the control plane's allocations.
|
|
let wg_overlay_addr = wg_view
|
|
.as_ref()
|
|
.and_then(|v| v.overlay_address_v4)
|
|
.or_else(|| pure_wg_view.as_ref().and_then(|v| v.overlay_address_v4));
|
|
let wg_prefix_len = wg_view
|
|
.as_ref()
|
|
.and_then(|v| v.ipv4_range)
|
|
.or_else(|| pure_wg_view.as_ref().and_then(|v| v.ipv4_range))
|
|
.map_or(0, |r| r.prefix_len);
|
|
let has_any_plugin =
|
|
wg_view.is_some() || pure_wg_view.is_some() || !tcp_tls_reports.is_empty();
|
|
|
|
let overlay = if has_any_plugin && overlay.is_some() {
|
|
let mut peers: Vec<OverlayPeerReport> = Vec::new();
|
|
|
|
// Collect all unique peer IDs known in this network.
|
|
let mut all_peer_ids: Vec<EndpointId> = Vec::new();
|
|
for member in &network.members {
|
|
if member.endpoint_id != status.endpoint_id
|
|
&& !all_peer_ids.contains(&member.endpoint_id)
|
|
{
|
|
all_peer_ids.push(member.endpoint_id);
|
|
}
|
|
}
|
|
if let Some(ref view) = pure_wg_view {
|
|
for peer in &view.peers {
|
|
if !all_peer_ids.contains(&peer.endpoint_id) {
|
|
all_peer_ids.push(peer.endpoint_id);
|
|
}
|
|
}
|
|
}
|
|
if let Some(ref view) = wg_view {
|
|
for peer in &view.peers {
|
|
if !all_peer_ids.contains(&peer.endpoint_id) {
|
|
all_peer_ids.push(peer.endpoint_id);
|
|
}
|
|
}
|
|
}
|
|
for report in &tcp_tls_reports {
|
|
if !all_peer_ids.contains(&report.peer) {
|
|
all_peer_ids.push(report.peer);
|
|
}
|
|
}
|
|
|
|
for peer_id in all_peer_ids {
|
|
let pure_wg_match = pure_wg_view
|
|
.as_ref()
|
|
.and_then(|v| v.peers.iter().find(|p| p.endpoint_id == peer_id));
|
|
let tcp_tls_match = tcp_tls_reports.iter().find(|r| r.peer == peer_id);
|
|
let wg_quic_match = wg_view
|
|
.as_ref()
|
|
.and_then(|v| v.peers.iter().find(|p| p.endpoint_id == peer_id));
|
|
|
|
let member_addr = network
|
|
.members
|
|
.iter()
|
|
.find(|m| m.endpoint_id == peer_id)
|
|
.and_then(|m| m.overlay_address_v4)
|
|
.map(|a| a.to_string());
|
|
|
|
if let Some(pure_peer) = pure_wg_match
|
|
&& let Some(tunnel) = &pure_peer.tunnel
|
|
{
|
|
peers.push(OverlayPeerReport {
|
|
endpoint_id: peer_id.to_string(),
|
|
protocol: "wg".into(),
|
|
public_key: pure_peer.public_key.to_string(),
|
|
address: pure_peer
|
|
.overlay_address_v4
|
|
.map(|a| a.to_string())
|
|
.or(member_addr),
|
|
handshake_secs_ago: tunnel.health.since_handshake.map(|s| s.as_secs()),
|
|
tx_packets: tunnel.stats.tx_packets,
|
|
rx_packets: tunnel.stats.rx_packets,
|
|
dropped: tunnel.stats.dropped_wrong_source
|
|
+ tunnel.stats.dropped_oversize,
|
|
protocol_errors: tunnel.stats.protocol_errors,
|
|
path: tunnel.path.clone(),
|
|
});
|
|
} else if let Some(tcp_tls) = tcp_tls_match {
|
|
let pubkey = pure_wg_match
|
|
.map(|p| p.public_key.to_string())
|
|
.or_else(|| wg_quic_match.map(|p| p.public_key.to_string()))
|
|
.unwrap_or_default();
|
|
peers.push(OverlayPeerReport {
|
|
endpoint_id: peer_id.to_string(),
|
|
protocol: tcp_tls.protocol.clone(),
|
|
public_key: pubkey,
|
|
address: member_addr,
|
|
handshake_secs_ago: Some(tcp_tls.uptime_secs),
|
|
tx_packets: tcp_tls.tx_packets,
|
|
rx_packets: tcp_tls.rx_packets,
|
|
dropped: tcp_tls.dropped,
|
|
protocol_errors: 0,
|
|
path: tcp_tls.path.clone(),
|
|
});
|
|
} else if let Some(wg_peer) = wg_quic_match
|
|
&& let Some(tunnel) = &wg_peer.tunnel
|
|
{
|
|
peers.push(OverlayPeerReport {
|
|
endpoint_id: peer_id.to_string(),
|
|
protocol: "wg-quic".into(),
|
|
public_key: wg_peer.public_key.to_string(),
|
|
address: wg_peer
|
|
.overlay_address_v4
|
|
.map(|a| a.to_string())
|
|
.or(member_addr),
|
|
handshake_secs_ago: tunnel.health.since_handshake.map(|s| s.as_secs()),
|
|
tx_packets: tunnel.stats.tx_packets,
|
|
rx_packets: tunnel.stats.rx_packets,
|
|
dropped: tunnel.stats.dropped_wrong_source
|
|
+ tunnel.stats.dropped_oversize,
|
|
protocol_errors: tunnel.stats.protocol_errors,
|
|
path: tunnel.path.clone(),
|
|
});
|
|
} else if let Some(pure_peer) = pure_wg_match {
|
|
peers.push(OverlayPeerReport {
|
|
endpoint_id: peer_id.to_string(),
|
|
protocol: "wg".into(),
|
|
public_key: pure_peer.public_key.to_string(),
|
|
address: pure_peer
|
|
.overlay_address_v4
|
|
.map(|a| a.to_string())
|
|
.or(member_addr),
|
|
handshake_secs_ago: None,
|
|
tx_packets: 0,
|
|
rx_packets: 0,
|
|
dropped: 0,
|
|
protocol_errors: 0,
|
|
path: "no data link".into(),
|
|
});
|
|
} else if let Some(wg_peer) = wg_quic_match {
|
|
peers.push(OverlayPeerReport {
|
|
endpoint_id: peer_id.to_string(),
|
|
protocol: "wg-quic".into(),
|
|
public_key: wg_peer.public_key.to_string(),
|
|
address: wg_peer
|
|
.overlay_address_v4
|
|
.map(|a| a.to_string())
|
|
.or(member_addr),
|
|
handshake_secs_ago: None,
|
|
tx_packets: 0,
|
|
rx_packets: 0,
|
|
dropped: 0,
|
|
protocol_errors: 0,
|
|
path: "no data link".into(),
|
|
});
|
|
}
|
|
}
|
|
|
|
let (address, prefix_len) = if wg_overlay_addr.is_some() {
|
|
(wg_overlay_addr.map(|addr| addr.to_string()), wg_prefix_len)
|
|
} else {
|
|
(
|
|
network
|
|
.members
|
|
.iter()
|
|
.find(|m| m.endpoint_id == status.endpoint_id)
|
|
.and_then(|m| m.overlay_address_v4)
|
|
.map(|a| a.to_string()),
|
|
network.range.map_or(0, |r| r.prefix_len),
|
|
)
|
|
};
|
|
|
|
Some(OverlayReport {
|
|
interface: overlay
|
|
.as_ref()
|
|
.map_or_else(String::new, |o| o.interface.clone()),
|
|
on_host: overlay.as_ref().is_some_and(|o| o.on_host),
|
|
mtu: overlay.as_ref().map_or(0, |o| o.mtu),
|
|
address,
|
|
prefix_len,
|
|
peers,
|
|
unroutable_packets: overlay.as_ref().map_or(0, |o| o.counters.unroutable),
|
|
multicast_packets: overlay.as_ref().map_or(0, |o| o.counters.multicast),
|
|
unroutable_sample: overlay
|
|
.as_ref()
|
|
.and_then(|o| o.counters.unroutable_sample.map(|a| a.to_string())),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
NetworkReport {
|
|
broadcast: network.broadcast,
|
|
name: network.name.to_string(),
|
|
network_id: network.network_id.to_string(),
|
|
active: matches!(network.state, tsunagi::agent::NetworkState::Active),
|
|
candidates: network.candidates.len() as u32,
|
|
relay_forwarded: network.relay.forwarded,
|
|
relay_sent_via: network.relay.sent_via,
|
|
relay_received_via: network.relay.received_via,
|
|
peers: network
|
|
.peers
|
|
.iter()
|
|
.map(|peer| PeerReport {
|
|
endpoint_id: peer.endpoint_id.to_string(),
|
|
hostname: peer.hostname.clone(),
|
|
transport: peer.transport.to_string(),
|
|
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
|
|
})
|
|
.collect(),
|
|
members: network
|
|
.members
|
|
.iter()
|
|
.map(|member| MemberReport {
|
|
endpoint_id: member.endpoint_id.to_string(),
|
|
overlay_address_v4: member.overlay_address_v4.map(|addr| addr.to_string()),
|
|
// What this agent is currently experiencing trying to
|
|
// reach it, so a dial-failure count can be attributed
|
|
// to the member it belongs to instead of floating
|
|
// free as a network-wide number.
|
|
failed_dials: network
|
|
.candidates
|
|
.iter()
|
|
.find(|candidate| candidate.endpoint_id == member.endpoint_id)
|
|
.map_or(0, |candidate| candidate.consecutive_failures),
|
|
})
|
|
.collect(),
|
|
range: network.range.map(|range| range.to_string()),
|
|
range_conflict: network.range_conflict.map(|range| range.to_string()),
|
|
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,
|
|
dns,
|
|
}
|
|
}
|
|
|
|
/// 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"
|
|
}
|
|
}
|
|
|
|
/// Builds the interface factory.
|
|
///
|
|
/// One path: the agent creates and configures the interface itself. It is
|
|
/// also the one that cleans up after itself, because the interface is tied to
|
|
/// an open file descriptor and goes away with the agent, however the agent
|
|
/// goes away.
|
|
#[cfg(target_os = "linux")]
|
|
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
|
|
use tsunagi::overlay::{ManagedTunFactory, NetlinkProvisioner};
|
|
let provisioner = NetlinkProvisioner::new()?;
|
|
Ok(Arc::new(ManagedTunFactory::new(Arc::new(provisioner))))
|
|
}
|
|
|
|
/// The Wintun adapter, created and configured by the agent and removed when it
|
|
/// exits, the same as the Linux one.
|
|
#[cfg(target_os = "windows")]
|
|
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
|
|
use tsunagi::overlay::{ManagedTunFactory, WintunProvisioner};
|
|
let provisioner = WintunProvisioner::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, and with the one thing that
|
|
/// does work on every platform named.
|
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
|
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
|
|
Err(format!(
|
|
"managing the overlay interface is not implemented on {} yet. \
|
|
Run with `--no-tun` to keep the tunnels off the operating system.",
|
|
std::env::consts::OS
|
|
)
|
|
.into())
|
|
}
|
|
|
|
fn log_event(event: &Event) {
|
|
match event {
|
|
Event::NetworkActivated { network } => {
|
|
tracing::info!(network = %network.fmt_short(), "Network activated");
|
|
}
|
|
Event::NetworkDeactivated { network } => {
|
|
tracing::info!(network = %network.fmt_short(), "Network deactivated");
|
|
}
|
|
Event::PeerConnected {
|
|
network,
|
|
peer,
|
|
transport,
|
|
rtt,
|
|
..
|
|
} => {
|
|
let rtt_str = rtt
|
|
.map(|d| format!("{:.1}ms", d.as_secs_f64() * 1000.0))
|
|
.unwrap_or_else(|| "unknown".into());
|
|
tracing::info!(
|
|
network = %network.fmt_short(),
|
|
peer = %peer.fmt_short(),
|
|
"Peer connected via {transport:?} (rtt: {rtt_str})"
|
|
);
|
|
}
|
|
Event::PeerDisconnected {
|
|
network,
|
|
peer,
|
|
reason,
|
|
} => {
|
|
tracing::info!(
|
|
network = %network.fmt_short(),
|
|
peer = %peer.fmt_short(),
|
|
"Peer disconnected: {reason}"
|
|
);
|
|
}
|
|
Event::DataLinkUp {
|
|
network,
|
|
peer,
|
|
protocol,
|
|
path,
|
|
max_datagram,
|
|
} => {
|
|
tracing::info!(
|
|
network = %network.fmt_short(),
|
|
peer = %peer.fmt_short(),
|
|
"Data link established for {protocol} ({path}, MTU datagram: {max_datagram}B)"
|
|
);
|
|
}
|
|
Event::DataLinkDown {
|
|
network,
|
|
peer,
|
|
protocol,
|
|
reason,
|
|
} => {
|
|
tracing::info!(
|
|
network = %network.fmt_short(),
|
|
peer = %peer.fmt_short(),
|
|
"Data link closed for {protocol}: {reason}"
|
|
);
|
|
}
|
|
Event::DialFailed {
|
|
network,
|
|
peer,
|
|
reason,
|
|
} => {
|
|
tracing::debug!(
|
|
network = %network.fmt_short(),
|
|
peer = %peer.fmt_short(),
|
|
"Outbound dial failed: {reason}"
|
|
);
|
|
}
|
|
Event::HandshakeRejected {
|
|
network,
|
|
peer,
|
|
reason,
|
|
} => {
|
|
let peer_str = peer
|
|
.map(|p| p.fmt_short().to_string())
|
|
.unwrap_or_else(|| "unknown".into());
|
|
tracing::warn!(
|
|
network = ?network.map(|n| n.fmt_short().to_string()),
|
|
peer = %peer_str,
|
|
"Handshake rejected: {reason}"
|
|
);
|
|
}
|
|
Event::ProtocolViolation {
|
|
network,
|
|
peer,
|
|
reason,
|
|
} => {
|
|
let peer_str = peer
|
|
.map(|p| p.fmt_short().to_string())
|
|
.unwrap_or_else(|| "unknown".into());
|
|
tracing::warn!(
|
|
network = ?network.map(|n| n.fmt_short().to_string()),
|
|
peer = %peer_str,
|
|
"Protocol violation from {peer_str}: {reason}"
|
|
);
|
|
}
|
|
Event::PluginError {
|
|
network,
|
|
protocol,
|
|
reason,
|
|
} => {
|
|
tracing::warn!(network = %network.fmt_short(), %protocol, "Data plane plugin error: {reason}");
|
|
}
|
|
Event::CacheReset { reason } => {
|
|
tracing::warn!("Disposable cache was reset: {reason}");
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
async fn log_status_summary(agent: &Agent, network: Option<NetworkId>) {
|
|
let network = match network {
|
|
Some(network) => network,
|
|
None => match agent.list_networks().await {
|
|
Ok(networks) => match networks.iter().find(|network| network.active) {
|
|
Some(network) => network.network_id,
|
|
None => return,
|
|
},
|
|
Err(_) => return,
|
|
},
|
|
};
|
|
if let Ok(status) = agent.network_status(network).await {
|
|
tracing::debug!(
|
|
peers = status.peers.len(),
|
|
dial_failures = status.metrics.dial_failures,
|
|
handshake_failures = status.metrics.handshake_failures,
|
|
"Network status tick"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod status_tests {
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use super::report::Health;
|
|
use super::*;
|
|
use tsunagi::ipc::{MemberReport, NetworkReport, OverlayPeerReport, OverlayReport, PeerReport};
|
|
|
|
const OWN: &str = "aaaa0000";
|
|
const ONLINE: &str = "bbbb1111";
|
|
const AWAY: &str = "cccc2222";
|
|
|
|
fn overlay(peers: Vec<OverlayPeerReport>) -> OverlayReport {
|
|
OverlayReport {
|
|
interface: "tsundemo".into(),
|
|
// A real interface, which is the ordinary case; the in-memory
|
|
// one has a test of its own.
|
|
on_host: true,
|
|
mtu: 1280,
|
|
address: Some("10.13.37.69".into()),
|
|
prefix_len: 24,
|
|
peers,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn tunnel(endpoint_id: &str, handshake: Option<u64>) -> OverlayPeerReport {
|
|
OverlayPeerReport {
|
|
endpoint_id: endpoint_id.into(),
|
|
public_key: "keykeykey".into(),
|
|
address: Some("10.13.37.237".into()),
|
|
handshake_secs_ago: handshake,
|
|
tx_packets: 32,
|
|
rx_packets: 887,
|
|
path: "direct via 192.0.2.1:50303".into(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// The situation that prompted this: one peer left and came back.
|
|
fn network_after_a_peer_returned() -> NetworkReport {
|
|
NetworkReport {
|
|
broadcast: true,
|
|
name: "LAB".into(),
|
|
network_id: "xa7gyz".into(),
|
|
active: true,
|
|
candidates: 1,
|
|
relay_forwarded: 0,
|
|
relay_sent_via: 0,
|
|
relay_received_via: 0,
|
|
peers: vec![PeerReport {
|
|
endpoint_id: ONLINE.into(),
|
|
hostname: Some("music".into()),
|
|
transport: "direct".into(),
|
|
rtt_ms: Some(24),
|
|
}],
|
|
members: vec![
|
|
MemberReport {
|
|
endpoint_id: OWN.into(),
|
|
overlay_address_v4: Some("10.13.37.69".into()),
|
|
failed_dials: 0,
|
|
},
|
|
MemberReport {
|
|
endpoint_id: ONLINE.into(),
|
|
overlay_address_v4: Some("10.13.37.237".into()),
|
|
failed_dials: 0,
|
|
},
|
|
],
|
|
range: Some("10.13.37.0/24".into()),
|
|
range_conflict: None,
|
|
// Everything below happened while the peer was away.
|
|
dial_failures: 9,
|
|
handshake_failures: 0,
|
|
control_messages: (2, 2),
|
|
overlay: Some(OverlayReport {
|
|
unroutable_packets: 1,
|
|
unroutable_sample: Some("10.13.37.237".into()),
|
|
..overlay(vec![tunnel(ONLINE, Some(29))])
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn two_networks_with_one_name_are_told_apart_and_flagged() {
|
|
// The confusing case: two sections headed identically, one working
|
|
// and one empty, read as a single network that is somehow both.
|
|
let network = network_after_a_peer_returned();
|
|
let mut out = report::Report::new();
|
|
out.push(network_section(&network, OWN, true));
|
|
let text = out.render(false);
|
|
|
|
assert!(
|
|
text.contains(&format!("network LAB ({})", short(&network.network_id, 10))),
|
|
"the heading must identify the network, not just name it:\n{text}"
|
|
);
|
|
assert!(text.contains("also called `LAB`"), "{text}");
|
|
assert!(text.contains("mistyped secret"), "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_with_nobody_to_contact_says_so_instead_of_waiting_quietly() {
|
|
// The wiped-and-restarted case: both devices join the same network
|
|
// and sit there. An agent finds a peer by being told about one or
|
|
// by remembering an earlier session, and with neither it will wait
|
|
// for ever — which "nobody else has joined" reads as patience.
|
|
let mut network = network_after_a_peer_returned();
|
|
network.peers.clear();
|
|
network.members.clear();
|
|
network.overlay = None;
|
|
network.candidates = 0;
|
|
|
|
let mut out = report::Report::new();
|
|
out.push(network_section(&network, OWN, false));
|
|
let text = out.render(false);
|
|
assert!(text.contains("nobody to contact"), "{text}");
|
|
assert!(text.contains("--peer"), "the fix is named: {text}");
|
|
assert!(text.contains(OWN), "with this device's own id: {text}");
|
|
assert_eq!(out.worst(), Health::Degraded, "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn candidates_with_nobody_connected_yet_is_patience_rather_than_a_fault() {
|
|
// Something to try is the ordinary state of a network coming up.
|
|
let mut network = network_after_a_peer_returned();
|
|
network.peers.clear();
|
|
network.members.clear();
|
|
network.overlay = None;
|
|
network.candidates = 2;
|
|
|
|
let mut out = report::Report::new();
|
|
out.push(network_section(&network, OWN, false));
|
|
let text = out.render(false);
|
|
assert!(text.contains("2 candidate(s) being tried"), "{text}");
|
|
assert_ne!(out.worst(), Health::Degraded, "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_network_with_no_range_says_that_is_why_it_is_empty() {
|
|
// Rather than "nobody else has joined", which points at the wrong
|
|
// thing entirely: nobody can join a network with no addresses.
|
|
let mut network = network_after_a_peer_returned();
|
|
network.peers.clear();
|
|
network.members.clear();
|
|
network.overlay = None;
|
|
network.range = None;
|
|
network.range_conflict = Some("10.13.37.0/24".into());
|
|
|
|
let mut out = report::Report::new();
|
|
out.push(network_section(&network, OWN, false));
|
|
let text = out.render(false);
|
|
assert!(text.contains("no range to allocate from"), "{text}");
|
|
assert!(text.contains("another network here already does"), "{text}");
|
|
assert!(text.contains("--ipv4-range"), "the fix is named: {text}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_in_memory_interface_is_not_presented_as_a_host_interface() {
|
|
// `--no-tun` runs the tunnels and moves packets between agents, but
|
|
// the operating system has no interface, no address and no route. An
|
|
// address printed beside a name the host does not have is what makes
|
|
// a ping that goes nowhere look like a network fault.
|
|
let mut network = network_after_a_peer_returned();
|
|
if let Some(overlay) = &mut network.overlay {
|
|
overlay.on_host = false;
|
|
}
|
|
|
|
let mut out = report::Report::new();
|
|
out.push(network_section(&network, OWN, false));
|
|
let text = out.render(false);
|
|
assert!(text.contains("in memory"), "{text}");
|
|
assert!(text.contains("--no-tun"), "the reason is named: {text}");
|
|
assert_eq!(out.worst(), Health::Degraded, "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn with_no_local_resolver_the_report_says_so_rather_than_nothing() {
|
|
// The absence is the answer to "why does the name not resolve?".
|
|
// Left out, the report looked the same as one where DNS was running.
|
|
let mut out = report::Report::new();
|
|
out.push(dns_absent_section());
|
|
let text = out.render(false);
|
|
assert!(text.contains("not serving"), "{text}");
|
|
assert!(text.contains("--dns"), "the flag that starts it: {text}");
|
|
// Nothing is wrong with an agent that was never asked to serve DNS.
|
|
assert_eq!(out.worst(), Health::Good, "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn both_families_are_listed_while_only_one_bound_is_still_good() {
|
|
use tsunagi::ipc::DnsReport;
|
|
|
|
let both = DnsReport {
|
|
zones: vec!["lab".into()],
|
|
listening: vec!["10.13.37.69:5354".into(), "[::1]:5354".into()],
|
|
names: 2,
|
|
..Default::default()
|
|
};
|
|
let mut out = report::Report::new();
|
|
out.push(dns_section(&both));
|
|
let text = out.render(false);
|
|
assert!(text.contains("10.13.37.69:5354, [::1]:5354"), "{text}");
|
|
|
|
// One family is worth seeing rather than hiding: a question over the
|
|
// other one goes unanswered.
|
|
let one = DnsReport {
|
|
listening: vec!["127.0.0.1:5354".into()],
|
|
..both.clone()
|
|
};
|
|
let mut out = report::Report::new();
|
|
out.push(dns_section(&one));
|
|
assert!(out.render(false).contains("127.0.0.1:5354"));
|
|
|
|
// Neither, with a reason, is broken.
|
|
let none = DnsReport {
|
|
listening: Vec::new(),
|
|
bind_error: Some("address already in use".into()),
|
|
..both
|
|
};
|
|
let mut out = report::Report::new();
|
|
out.push(dns_section(&none));
|
|
assert_eq!(out.worst(), Health::Broken, "{}", out.render(false));
|
|
}
|
|
|
|
#[test]
|
|
fn counters_from_the_past_do_not_grade_the_present() {
|
|
// A peer that left and returned leaves dial failures and a packet
|
|
// sent to an address nobody owned behind it. Once it is back, those
|
|
// are history: reporting them as current faults made a working
|
|
// network look broken.
|
|
let network = network_after_a_peer_returned();
|
|
let mut out = report::Report::new();
|
|
out.push(network_section(&network, OWN, false));
|
|
|
|
assert_eq!(out.worst(), Health::Good, "{}", out.render(false));
|
|
let text = out.render(false);
|
|
assert!(text.contains("since start"), "{text}");
|
|
assert!(
|
|
text.contains("9 dial failure(s)"),
|
|
"the history is still shown: {text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn this_agent_is_not_listed_among_its_own_peers() {
|
|
let network = network_after_a_peer_returned();
|
|
let rows = member_rows(&network, OWN);
|
|
assert_eq!(rows.len(), 1, "only the other member");
|
|
assert_eq!(rows[0].endpoint_id, ONLINE);
|
|
}
|
|
|
|
#[test]
|
|
fn offline_members_are_listed_after_online_ones() {
|
|
let mut network = network_after_a_peer_returned();
|
|
network.members.push(MemberReport {
|
|
endpoint_id: AWAY.into(),
|
|
overlay_address_v4: Some("10.13.37.99".into()),
|
|
failed_dials: 9,
|
|
});
|
|
|
|
let rows = member_rows(&network, OWN);
|
|
assert_eq!(rows.len(), 2);
|
|
assert!(rows[0].online(), "the connected member comes first");
|
|
assert!(!rows[1].online());
|
|
assert_eq!(rows[1].endpoint_id, AWAY);
|
|
}
|
|
|
|
#[test]
|
|
fn a_member_that_is_away_is_stated_rather_than_flagged() {
|
|
// In a mesh of laptops a member being away is the ordinary
|
|
// condition, not a fault of this agent. It is said plainly, with
|
|
// what the signed state still knows about it, and the failed dials
|
|
// are attributed to it instead of floating free as a counter.
|
|
let mut network = network_after_a_peer_returned();
|
|
network.members.push(MemberReport {
|
|
endpoint_id: AWAY.into(),
|
|
overlay_address_v4: Some("10.13.37.99".into()),
|
|
failed_dials: 9,
|
|
});
|
|
|
|
let mut out = report::Report::new();
|
|
out.push(network_section(&network, OWN, false));
|
|
let text = out.render(false);
|
|
|
|
assert_eq!(out.worst(), Health::Good, "{text}");
|
|
assert!(text.contains("offline"), "{text}");
|
|
assert!(text.contains("10.13.37.99 still reserved for it"), "{text}");
|
|
assert!(text.contains("9 dial attempt(s) failed"), "{text}");
|
|
assert!(text.contains("1 of 2 online"), "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_relayed_peer_is_graded_as_degraded_quality() {
|
|
let mut network = network_after_a_peer_returned();
|
|
network.peers[0].transport = "relay".into();
|
|
|
|
let mut out = report::Report::new();
|
|
out.push(network_section(&network, OWN, false));
|
|
assert_eq!(out.worst(), Health::Degraded, "{}", out.render(false));
|
|
assert!(out.render(false).contains("relay"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_tunnel_that_never_handshook_is_flagged_while_the_peer_is_connected() {
|
|
let mut network = network_after_a_peer_returned();
|
|
network.overlay = Some(overlay(vec![tunnel(ONLINE, None)]));
|
|
|
|
let mut out = report::Report::new();
|
|
out.push(network_section(&network, OWN, false));
|
|
let text = out.render(false);
|
|
assert_eq!(out.worst(), Health::Degraded, "{text}");
|
|
assert!(text.contains("no WireGuard handshake yet"), "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn handshake_failures_with_nobody_connected_point_at_the_secret() {
|
|
// The classic symptom of one member being given a different secret.
|
|
// With a peer connected the same counter is just history.
|
|
let mut network = network_after_a_peer_returned();
|
|
network.peers.clear();
|
|
network.overlay = Some(overlay(Vec::new()));
|
|
network.handshake_failures = 4;
|
|
|
|
let mut out = report::Report::new();
|
|
out.push(network_section(&network, OWN, false));
|
|
let text = out.render(false);
|
|
assert_eq!(out.worst(), Health::Degraded, "{text}");
|
|
assert!(text.contains("same secret"), "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_tcp_tls_tunnel_shows_healthy_state_and_metrics() {
|
|
let mut network = network_after_a_peer_returned();
|
|
let mut t = tunnel(ONLINE, Some(42));
|
|
t.protocol = "tcp-tls".into();
|
|
t.path = "tcp-tls via 192.0.2.1:443".into();
|
|
t.tx_packets = 100;
|
|
t.rx_packets = 200;
|
|
network.overlay = Some(overlay(vec![t]));
|
|
|
|
let mut out = report::Report::new();
|
|
out.push(network_section(&network, OWN, false));
|
|
let text = out.render(false);
|
|
assert_eq!(out.worst(), Health::Good, "{text}");
|
|
assert!(text.contains("tunnel up (tcp-tls)"), "{text}");
|
|
assert!(text.contains("uptime 42s"), "{text}");
|
|
assert!(text.contains("tx 100 rx 200"), "{text}");
|
|
assert!(text.contains("tcp-tls via 192.0.2.1:443"), "{text}");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod network_tests {
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
#[test]
|
|
fn dht_defaults_and_local_mode_do_not_require_manual_peers() {
|
|
use clap::Parser;
|
|
for (flags, expected) in [
|
|
(vec![], true),
|
|
(vec!["--dht"], true),
|
|
(vec!["--no-dht"], false),
|
|
(vec!["--reach", "local"], false),
|
|
(vec!["--reach", "local", "--dht"], false),
|
|
(vec!["--reach", "direct"], true),
|
|
] {
|
|
let cli =
|
|
super::Cli::try_parse_from(["tsunagi", "up"].into_iter().chain(flags)).unwrap();
|
|
let super::Command::Up(args) = cli.command else {
|
|
panic!("expected up");
|
|
};
|
|
assert_eq!(super::dht_enabled(&args), expected);
|
|
}
|
|
assert!(super::Cli::try_parse_from(["tsunagi", "up", "--dht", "--no-dht"]).is_err());
|
|
}
|
|
|
|
use super::*;
|
|
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
|
use tsunagi::storage::StoredNetwork;
|
|
|
|
fn configured(name: &str, secret: &str) -> StoredNetwork {
|
|
let name = NetworkName::new(name).unwrap();
|
|
let secret =
|
|
NetworkSecret::from_bytes(&[secret.as_bytes(), &[0u8; 32]].concat()[..32]).unwrap();
|
|
let keys = NetworkKeys::derive(&name, &secret);
|
|
StoredNetwork {
|
|
broadcast: true,
|
|
network_id: keys.network_id(),
|
|
name,
|
|
secret,
|
|
auto_start: true,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_prefix_of_the_id_is_enough_and_an_ambiguous_one_is_refused() {
|
|
// `status` prints ids shortened, so what a person has to hand is a
|
|
// prefix. Accepting it is the difference between leaving a network
|
|
// and copying 52 characters correctly.
|
|
let networks = vec![configured("lab", "one"), configured("lab", "two")];
|
|
let full = networks[0].network_id.to_string();
|
|
|
|
let picked = resolve_network(&networks, &full[..10]).unwrap();
|
|
assert_eq!(picked.network_id, networks[0].network_id);
|
|
// The ellipsis a person copies out of the report is not part of it.
|
|
let picked = resolve_network(&networks, &format!("{}…", &full[..10])).unwrap();
|
|
assert_eq!(picked.network_id, networks[0].network_id);
|
|
|
|
let err = resolve_network(&networks, "").unwrap_err();
|
|
assert!(err.contains("by name or id"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_unique_exact_name_selects_the_stored_network_without_normalizing_it() {
|
|
let networks = vec![
|
|
configured("lab", "one"),
|
|
configured("Lab", "two"),
|
|
configured("Игры дома…", "three"),
|
|
];
|
|
for network in &networks {
|
|
assert_eq!(
|
|
resolve_network(&networks, network.name.as_str())
|
|
.unwrap()
|
|
.network_id,
|
|
network.network_id
|
|
);
|
|
}
|
|
assert!(resolve_network(&networks, "LAB").is_err());
|
|
assert!(resolve_network(&networks, "Игры дома").is_err());
|
|
assert!(resolve_network(&networks, "Игры").is_err());
|
|
assert!(resolve_network(&networks, "…").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_shared_name_lists_the_ids_without_choosing_or_disclosing_secrets() {
|
|
// Exactly the situation this command exists for: two networks called
|
|
// `lab`, one of them joined with a mistyped secret. Choosing for the
|
|
// user here is how the wrong one gets left.
|
|
let networks = vec![configured("lab", "one"), configured("lab", "two")];
|
|
let err = resolve_network(&networks, "lab").unwrap_err();
|
|
assert!(err.contains("matches 2 networks"), "{err}");
|
|
for network in &networks {
|
|
assert!(err.contains(&network.network_id.to_string()), "{err}");
|
|
assert!(!err.contains(network.secret.encode().as_str()), "{err}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_colliding_with_an_id_prefix_requires_a_full_id() {
|
|
let first = configured("lab", "one");
|
|
let id = first.network_id.to_string();
|
|
let networks = vec![
|
|
first,
|
|
configured(&id[..10], "two"),
|
|
configured(&id, "three"),
|
|
];
|
|
let err = resolve_network(&networks, &id[..10]).unwrap_err();
|
|
assert!(err.contains("matches 2 networks"), "{err}");
|
|
// An explicit full id remains definitive, even if another name is an id.
|
|
assert_eq!(
|
|
resolve_network(&networks, &id).unwrap().network_id,
|
|
networks[0].network_id
|
|
);
|
|
for network in &networks[1..] {
|
|
assert_eq!(
|
|
resolve_network(&networks, &network.network_id.to_string())
|
|
.unwrap()
|
|
.network_id,
|
|
network.network_id
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_id_that_matches_nothing_says_so() {
|
|
let networks = vec![configured("lab", "one")];
|
|
let err = resolve_network(&networks, "zzzzzz").unwrap_err();
|
|
assert!(err.contains("no configured network"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_prefix_shared_by_two_networks_is_refused_rather_than_guessed() {
|
|
// 33 distinct ids must share at least one initial base32 character.
|
|
let networks: Vec<_> = (0..33)
|
|
.map(|i| configured(&format!("network-{i}"), "secret"))
|
|
.collect();
|
|
let shared = networks
|
|
.iter()
|
|
.map(|network| network.network_id.to_string()[..1].to_string())
|
|
.find(|prefix| {
|
|
networks
|
|
.iter()
|
|
.filter(|network| network.network_id.to_string().starts_with(prefix))
|
|
.count()
|
|
> 1
|
|
})
|
|
.unwrap();
|
|
let err = resolve_network(&networks, &shared).unwrap_err();
|
|
assert!(
|
|
err.contains("use the full id or a unique id prefix"),
|
|
"{err}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod secret_tests {
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use super::*;
|
|
use tsunagi::identity::NetworkKeys;
|
|
|
|
fn paths(dir: &tempfile::TempDir) -> StoragePaths {
|
|
StoragePaths::new(dir.path().join("state"), dir.path().join("cache"))
|
|
}
|
|
|
|
fn already_joined(paths: &StoragePaths, name: &str) -> NetworkSecret {
|
|
let name = NetworkName::new(name).unwrap();
|
|
let secret = NetworkSecret::generate();
|
|
let keys = NetworkKeys::derive(&name, &secret);
|
|
std::fs::create_dir_all(&paths.state_dir).unwrap();
|
|
let store = tsunagi::storage::StateStore::open(paths.state_db()).unwrap();
|
|
store
|
|
.upsert_network(keys.network_id(), &name, &secret, true)
|
|
.unwrap();
|
|
secret
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_nobody_has_yet_gets_a_secret_of_its_own() {
|
|
// An ad-hoc network is a thing people want, and "generate a secret
|
|
// first" is a step with no purpose. The caller prints what this
|
|
// invents, because a secret nobody can read is no use.
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let paths = paths(&dir);
|
|
let name = NetworkName::new("spontaneous").unwrap();
|
|
|
|
let (secret, origin) = resolve_secret(&paths, &name, None, None).unwrap();
|
|
assert_eq!(origin, SecretOrigin::Generated);
|
|
// A real one: decodable, and different every time.
|
|
let text = secret.encode().as_str().to_string();
|
|
assert!(NetworkSecret::decode(&text).is_ok());
|
|
let (other, _) = resolve_secret(&paths, &name, None, None).unwrap();
|
|
assert_ne!(other.encode().as_str(), text);
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_this_device_already_has_resumes_it() {
|
|
// Otherwise `tsunagi join --network lab` would invent a stranger with
|
|
// the same name every time, which is the confusion this whole
|
|
// report format exists to prevent.
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let paths = paths(&dir);
|
|
let joined = already_joined(&paths, "lab");
|
|
|
|
let (secret, origin) =
|
|
resolve_secret(&paths, &NetworkName::new("lab").unwrap(), None, None).unwrap();
|
|
assert_eq!(origin, SecretOrigin::Stored);
|
|
assert_eq!(secret.encode().as_str(), joined.encode().as_str());
|
|
}
|
|
|
|
#[test]
|
|
fn a_secret_that_was_given_wins_over_the_stored_one() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let paths = paths(&dir);
|
|
already_joined(&paths, "lab");
|
|
let given = NetworkSecret::generate();
|
|
|
|
let (secret, origin) = resolve_secret(
|
|
&paths,
|
|
&NetworkName::new("lab").unwrap(),
|
|
Some(given.encode().as_str()),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(origin, SecretOrigin::Given);
|
|
assert_eq!(secret.encode().as_str(), given.encode().as_str());
|
|
}
|
|
|
|
#[test]
|
|
fn two_networks_of_one_name_refuse_to_guess() {
|
|
// The mistyped-secret case. Picking one would be picking wrong half
|
|
// the time, and doing it silently.
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let paths = paths(&dir);
|
|
already_joined(&paths, "lab");
|
|
already_joined(&paths, "lab");
|
|
|
|
let err = resolve_secret(&paths, &NetworkName::new("lab").unwrap(), None, None)
|
|
.expect_err("it cannot choose");
|
|
assert!(err.to_string().contains("does not say which"), "{err}");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod network_context_tests {
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use super::*;
|
|
use tsunagi::identity::NetworkKeys;
|
|
use tsunagi::storage::StoredNetwork;
|
|
|
|
fn configured(name: &str, seed: u8) -> StoredNetwork {
|
|
let name = NetworkName::new(name).unwrap();
|
|
let secret = NetworkSecret::from_bytes([seed; 32]).unwrap();
|
|
let keys = NetworkKeys::derive(&name, &secret);
|
|
StoredNetwork {
|
|
broadcast: true,
|
|
network_id: keys.network_id(),
|
|
name,
|
|
secret,
|
|
auto_start: true,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_network_already_here_is_told_from_a_new_one() {
|
|
// `up` prints which of the two happened. Without it, a command line
|
|
// that quietly recreates a network somebody just left looks exactly
|
|
// like the one they meant to start.
|
|
let known = configured("lab", 1);
|
|
let name = known.name.clone();
|
|
let (standing, shared) =
|
|
network_context(std::slice::from_ref(&known), &name, known.network_id);
|
|
assert_eq!(standing, NetworkStanding::Known);
|
|
assert_eq!(shared, None);
|
|
|
|
let fresh = configured("lab", 2);
|
|
let (standing, shared) =
|
|
network_context(std::slice::from_ref(&known), &name, fresh.network_id);
|
|
assert_eq!(
|
|
standing,
|
|
NetworkStanding::New,
|
|
"a different secret is a different network"
|
|
);
|
|
assert_eq!(
|
|
shared,
|
|
Some(known.network_id.to_string()),
|
|
"and the one it shares a name with is named"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_nobody_here_uses_shares_with_nothing() {
|
|
let (standing, shared) = network_context(
|
|
&[configured("lab", 1)],
|
|
&NetworkName::new("other").unwrap(),
|
|
configured("other", 3).network_id,
|
|
);
|
|
assert_eq!(standing, NetworkStanding::New);
|
|
assert_eq!(shared, None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_stopped_network_is_told_from_one_that_is_merely_known() {
|
|
// Joining a stopped network starts it — which is right, it is an
|
|
// instruction to run it — and saying so is what keeps a pause
|
|
// from ending without a word.
|
|
let mut stopped = configured("lab", 1);
|
|
stopped.auto_start = false;
|
|
let name = stopped.name.clone();
|
|
|
|
let (standing, _) =
|
|
network_context(std::slice::from_ref(&stopped), &name, stopped.network_id);
|
|
assert_eq!(standing, NetworkStanding::Stopped);
|
|
}
|
|
}
|