From ff7e235414939912638eca7c02b3cf4a6107e248 Mon Sep 17 00:00:00 2001 From: tsunagi Date: Mon, 21 Sep 2026 19:44:21 +0100 Subject: [PATCH] Split the command line by level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `up` now says which level each setting belongs to, and `--help` shows the two sections. System: how the agent reaches peers, the one interface it owns, the address range, the resolver. Transport: which protocols carry packets and what they take. `--wireguard` is gone. `--protocol` takes a list and defaults to `wg-quic`, which is what the protocol is now called — WireGuard's cryptography in QUIC datagrams, so the name says what is on the wire rather than what the implementation borrows. `--protocol none` runs the control plane alone. Protocol settings moved to `-o key=value`, or `-o protocol:key=value` when several are selected. Each protocol declares its own settings and their help, so `tsunagi protocols` can list them without the agent knowing anything about any protocol, and a setting nobody takes is refused rather than dropped — a dropped setting looks exactly like one that did not work. What the user asked for is checked before anything that could fail on its own, so a misspelled protocol is not buried under a privilege error. `--wg-prefix` and `--wg-mtu` became `--interface` and `--mtu`: they were never the protocol's, and the interface they describe belongs to the agent. `--transport` became `--reach`, because "transport" now means the protocol level and using the word for iroh's path policy as well would be a collision of meaning rather than a shortage of words. The plugin gave up the last things that were not its own: the interface name it carried in its own state, and the check that this agent's address is really on an interface. Both are the agent's, and the check is now the agent's too, still said once per address rather than every round. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 49 ++- crates/tsunagi-cli/src/main.rs | 300 ++++++++++++++---- crates/tsunagi-cli/tests/dns_service.rs | 2 +- crates/tsunagi/src/agent/network.rs | 46 ++- .../tsunagi/src/dataplane/wireguard/plugin.rs | 136 ++++---- crates/tsunagi/tests/wireguard.rs | 7 +- 6 files changed, 386 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index 191a366..1423d57 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,13 @@ cargo build --release ./target/release/tsunagi id secret generate # prints tsn1...; share it privately ./target/release/tsunagi status # this device, the agent, and this host -./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard +./target/release/tsunagi up --network lab --secret "$SECRET" ``` It prints its endpoint id and then waits. On the second machine, pass that id: ```bash -./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard \ +./target/release/tsunagi up --network lab --secret "$SECRET" \ --peer ``` @@ -134,8 +134,8 @@ id and collides with essentially nothing. what they find, so `--ipv4-range` only matters for whoever starts the network: ```bash -tsunagi up --network lab --secret "$SECRET" --wireguard --ipv4-range 10.44.0.0/16 -tsunagi up --network lab --secret "$SECRET" --wireguard --ipv4-range none # IPv6 only +tsunagi up --network lab --secret "$SECRET" --ipv4-range 10.44.0.0/16 +tsunagi up --network lab --secret "$SECRET" --ipv4-range none # IPv6 only ``` An address is claimed with a record signed by that member's persistent device @@ -149,13 +149,48 @@ Because the address is allocated at run time rather than derived, it is not known until the agent has started and agreed with its peers. The agent then assigns it to the interface itself. +## Levels + +The command line is split the way the design is. A bare `tsunagi up` needs +only a network name and a secret; everything else sits under the level it +belongs to, which `--help` shows as two sections: + +* **System** — what the agent itself does: how it reaches peers (`--reach`), + the one overlay interface it owns (`--interface`, `--mtu`, `--no-tun`), the + address range (`--ipv4-range`), and the local resolver (`--dns`). +* **Transport** — which protocols carry packets (`--protocol`, a list) and + their own settings (`-o key=value`, or `-o protocol:key=value`). + +```bash +tsunagi protocols # what this build can carry packets with +``` + +```text +wg-quic (wire version 4) + what WireGuard's cryptography carried in iroh's QUIC datagrams + -o keepalive=SECONDS keeps a tunnel and its link warm through a NAT + -o mtu=BYTES largest packet a tunnel will carry, at least 576 +``` + +Each protocol declares its own settings, so the agent can list them without +knowing anything about the protocol, and a setting no selected protocol takes +is refused rather than ignored. `--protocol none` runs the control plane by +itself. + +A pair of peers uses a protocol they both have **at the same wire version**. +That is not the software version: two peers on different builds carry traffic +for each other for as long as the bytes between them have not changed. A peer +with nothing in common keeps its control plane — messages and signed state +still flow — and simply has no data plane, which `tsunagi status` shows as a +session with no agreed protocol. + ## Names `--dns` serves a local DNS zone for the network's members, so they can be reached by name instead of by address: ```bash -tsunagi up --network lab --secret "$SECRET" --wireguard --dns +tsunagi up --network lab --secret "$SECRET" --dns dig @10.13.37.69 -p 5354 music.lab ``` @@ -407,7 +442,7 @@ explicitly. Two different lookups are involved, and only one of them is this project's: **1. Resolving one endpoint's address — iroh's, and it works today.** -With `--transport relay` or `--transport direct`, iroh publishes a signed +With `--reach relay` or `--reach direct`, iroh publishes a signed record of this endpoint's addresses, keyed by its endpoint id, to the public service run by Number 0 — "n0", the company behind iroh — at `dns.iroh.link`, over pkarr and DNS, and resolves other endpoints the same way. That is why `--peer ` works with no address attached: @@ -426,7 +461,7 @@ What this means in practice: - With `relay` or `direct`, **your endpoint id and IP addresses are published to a public third-party service** (Number 0's, unless you change it). They are not secret, and the network secret is never published, but an observer of that service learns that your endpoint - exists and where it is. `--transport local` publishes nothing. + exists and where it is. `--reach local` publishes nothing. - A relay, when one is needed, sees the volume and timing of your traffic — not its contents. The default relays are Number 0's, in the US, EU and Asia-Pacific. diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index a80b34e..16c20ae 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -50,6 +50,8 @@ enum Command { Up(Box), /// 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, } #[derive(Debug, Args)] @@ -160,7 +162,7 @@ impl PathArgs { /// behind iroh — at `dns.iroh.link`, and resolve peers through it. That is /// what makes `--peer ` work without an address. #[derive(Debug, Clone, Copy, ValueEnum)] -enum Transport { +enum Reach { /// Loopback and the local network only. Publishes nothing. Local, /// Public address lookup, direct paths only, no relays. @@ -170,12 +172,12 @@ enum Transport { Relay, } -impl From for TransportPolicy { - fn from(value: Transport) -> Self { +impl From for TransportPolicy { + fn from(value: Reach) -> Self { match value { - Transport::Local => TransportPolicy::LocalOnly, - Transport::Direct => TransportPolicy::DirectOnly, - Transport::Relay => TransportPolicy::N0Defaults, + Reach::Local => TransportPolicy::LocalOnly, + Reach::Direct => TransportPolicy::DirectOnly, + Reach::Relay => TransportPolicy::N0Defaults, } } } @@ -203,44 +205,70 @@ struct UpArgs { secret_file: Option, /// Hostname to announce. Defaults to the machine's. - #[arg(long)] + #[arg(long, help_heading = "System")] hostname: Option, - /// How much external connectivity to use. - #[arg(long, value_enum, default_value_t = Transport::Relay)] - transport: Transport, + /// 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, /// A peer to contact, as `` or `@,...`. /// /// One agent needs to know another to begin with. Repeat for several. - #[arg(long = "peer", value_name = "PEER")] + #[arg(long = "peer", value_name = "PEER", help_heading = "System")] peers: Vec, /// Local address to bind. Repeat for several; defaults to iroh's choice. - #[arg(long = "bind", value_name = "ADDR")] + #[arg(long = "bind", value_name = "ADDR", help_heading = "System")] binds: Vec, - /// Run the WireGuard data plane. - #[arg(long)] - wireguard: bool, + /// 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, /// Do not create a real network interface. /// - /// The WireGuard tunnels still run and handshake, so the mesh can be - /// verified with no privileges; traffic just does not reach the - /// operating system. - #[arg(long)] + /// 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, - /// Interface name prefix for the WireGuard data plane. - #[arg(long, default_value = "tsun")] - wg_prefix: String, - - /// Interface MTU for the WireGuard data plane. + /// Protocols to carry packets with, best first. /// - /// Must be at least 1280, the minimum IPv6 requires. - #[arg(long)] - wg_mtu: Option, + /// 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-quic", + help_heading = "Transport" + )] + protocols: Vec, + + /// 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, /// IPv4 overlay range, as `address/prefix`, or `none` to disable IPv4. /// @@ -249,22 +277,22 @@ struct UpArgs { /// 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")] + #[arg(long, value_name = "CIDR", help_heading = "System")] ipv4_range: Option, /// Serve a local DNS zone for this network's members. /// /// Members resolve as `.`, from signed state, so a /// member that is switched off still resolves. IPv4 only. - #[arg(long)] + #[arg(long, help_heading = "System")] dns: bool, /// The zone to answer for. Defaults to the network name. - #[arg(long, value_name = "NAME")] + #[arg(long, value_name = "NAME", help_heading = "System")] dns_zone: Option, /// Port for the local DNS server. - #[arg(long, default_value_t = 5354)] + #[arg(long, default_value_t = 5354, help_heading = "System")] dns_port: u16, /// How often to print a status summary, in seconds. Zero disables it. @@ -353,6 +381,7 @@ async fn run(command: Command) -> Result<(), Box> { Command::Id(args) => id(args).await, Command::Up(args) => up(*args).await, Command::Status(args) => status(args).await, + Command::Protocols => show_protocols(), } } @@ -539,7 +568,6 @@ fn dns_publisher() -> Arc { /// Starts the DNS service for one network and keeps it in step with state. fn spawn_dns( agent: Agent, - wireguard: Option>, network: NetworkId, zone: tsunagi::dns::ZoneName, port: u16, @@ -599,10 +627,11 @@ fn spawn_dns( .iter() .find(|member| member.endpoint_id == own) .and_then(|member| member.overlay_address_v4); - let interface = wireguard - .as_ref() - .and_then(|plugin| plugin.overview(network)) - .map(|view| view.interface) + // The interface belongs to the agent, so the resolver + // setting attaches to that one and not to a protocol's. + let interface = agent + .overlay() + .map(|overlay| overlay.interface) .filter(|name| !name.is_empty()); let wanted = listen_addresses(overlay, port); if attempted != wanted { @@ -727,6 +756,110 @@ fn update(state: &Arc>, edit: impl FnOnce(&mut DnsSta } } +/// 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: tsunagi::dataplane::wireguard::WIREGUARD_PROTOCOL, + version: tsunagi::dataplane::wireguard::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, + key: String, + value: String, +} + +/// Parses `-o` settings, which are `key=value` or `protocol:key=value`. +fn parse_settings(raw: &[String]) -> Result, Box> { + 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> { + 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 @@ -1872,7 +2005,7 @@ async fn up(args: UpArgs) -> Result<(), Box> { let mut config = AgentConfig::new(paths.clone()) .with_overlay_ipv4_range(ipv4_range) - .with_transport(args.transport.into()) + .with_transport(args.reach.into()) .with_discovery(discovery) .with_discovery_interval(Duration::from_secs(5)); if let Some(hostname) = &args.hostname { @@ -1882,31 +2015,77 @@ async fn up(args: UpArgs) -> Result<(), Box> { config = config.with_bind_addrs(args.binds.clone()); } - // The data plane is optional and never required for the control plane. - let wireguard = if args.wireguard { - // The interface belongs to the agent, not to the protocol: one - // agent, one interface, and every protocol carries traffic for the - // same addresses on it. + // 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")) + { + let Some(spec) = PROTOCOLS.iter().find(|spec| spec.name == name.as_str()) 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; + if !wanted.is_empty() { let tun_factory: Arc = if args.no_tun { Arc::new(MemoryTunFactory::new()) } else { system_tun_factory()? }; let mtu = args - .wg_mtu + .mtu .unwrap_or(tsunagi::dataplane::wireguard::DEFAULT_MTU); - config = config.with_interface(tun_factory, args.wg_prefix.clone(), mtu); + config = config.with_interface(tun_factory, args.interface.clone(), mtu); + } - let mut wg = WireguardConfig::new(paths.state_dir.join("wireguard")); - if let Some(mtu) = args.wg_mtu { - wg = wg.with_mtu(mtu); + for spec in &wanted { + let options = settings_for(spec, &settings); + match spec.name { + tsunagi::dataplane::wireguard::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); + wireguard = Some(plugin); + } + other => return Err(format!("`{other}` is listed but not built in").into()), } - let plugin = WireguardPlugin::open(wg).await?; - config = config.with_plugin(plugin.clone() as Arc); - Some(plugin) - } else { - None - }; + } let agent = Agent::spawn(config).await?; // From here on every exit goes through `agent.shutdown()`, so the endpoint @@ -1943,13 +2122,7 @@ async fn up(args: UpArgs) -> Result<(), Box> { tracing::warn!("{warning}"); } println!(" dns zone {}", zone.as_str()); - Some(spawn_dns( - agent.clone(), - wireguard.clone(), - network, - zone, - args.dns_port, - )) + Some(spawn_dns(agent.clone(), network, zone, args.dns_port)) } Err(err) => { agent.shutdown().await; @@ -2298,8 +2471,11 @@ async fn print_status(agent: &Agent, network: NetworkId, wireguard: Option<&Wire && let Some(view) = plugin.overview(network) { println!( - "wireguard: {} on {}/{} mtu {}, {}/{} tunnel(s) established", - view.interface, + "{}: {} on {}/{} mtu {}, {}/{} tunnel(s) established", + plugin.protocol_id(), + agent + .overlay() + .map_or_else(|| "no interface".to_string(), |overlay| overlay.interface), view.overlay_address_v4 .map_or_else(|| "no address yet".to_string(), |addr| addr.to_string()), view.ipv4_range.map_or(0, |range| range.prefix_len), diff --git a/crates/tsunagi-cli/tests/dns_service.rs b/crates/tsunagi-cli/tests/dns_service.rs index de5e86c..7aaa247 100644 --- a/crates/tsunagi-cli/tests/dns_service.rs +++ b/crates/tsunagi-cli/tests/dns_service.rs @@ -92,7 +92,7 @@ fn start(zone: &str, port: u16) -> Running { .arg("--cache-dir") .arg(dir.path().join("cache")) // No real interface and no internet: this is about the wiring. - .args(["--transport", "local", "--no-tun", "--wireguard", "--dns"]) + .args(["--reach", "local", "--no-tun", "--dns"]) .args(["--dns-zone", zone]) .args(["--dns-port", &port.to_string()]) .args(["--log", "error", "--status-interval", "0"]) diff --git a/crates/tsunagi/src/agent/network.rs b/crates/tsunagi/src/agent/network.rs index 8fdb229..c837445 100644 --- a/crates/tsunagi/src/agent/network.rs +++ b/crates/tsunagi/src/agent/network.rs @@ -213,6 +213,9 @@ struct Runtime { /// Peers already told about a protocol version that cannot match, so it /// is said once rather than on every announcement. reported_mismatch: HashSet<(EndpointId, String)>, + /// The address last reported as absent from the interface, so it is said + /// once rather than for ever. + reported_missing: Option, /// Signed records, merged from every replica we have talked to. state: StateSet, /// Snapshots received while dispatching, handled on the next loop pass. @@ -246,6 +249,7 @@ impl Runtime { link_results_tx, link_results_rx, reported_mismatch: HashSet::new(), + reported_missing: None, state: StateSet::new(), pending_state: Vec::new(), own_version: 0, @@ -878,14 +882,50 @@ impl Runtime { }); return; } - if let Some(interface) = &self.params.interface - && let Err(err) = interface.sync_addresses().await - { + let Some(interface) = self.params.interface.clone() else { + return; + }; + if let Err(err) = interface.sync_addresses().await { self.emit(Event::PluginError { network: self.network_id, protocol: "overlay".into(), reason: err.to_string(), }); + return; + } + + // The agent assigns this address itself, so finding it absent means + // the assignment did not take — something outside removed it, or the + // provisioner reported a success it did not achieve. Left unsaid it + // looks like a broken network: packets would leave with the wrong + // source and every peer would drop them. Checked rather than + // assumed, because the assumption is exactly the kind that has been + // wrong here before. Said once per address, not every round. + let missing = match local { + Some(address) if !crate::overlay::address_is_local(std::net::IpAddr::V4(address)) => { + let already = self.reported_missing == Some(address); + self.reported_missing = Some(address); + (!already).then_some(address) + } + _ => { + self.reported_missing = None; + None + } + }; + if let Some(address) = missing { + self.emit(Event::PluginError { + network: self.network_id, + protocol: "overlay".into(), + reason: format!( + "this agent was allocated {address}/{} but the address is not on any \ + interface, so the overlay cannot work: packets would leave with the \ + wrong source and every peer would drop them. It should have been \ + assigned to `{}` automatically; check whether something else removed \ + it.", + range.prefix_len, + interface.name() + ), + }); } } diff --git a/crates/tsunagi/src/dataplane/wireguard/plugin.rs b/crates/tsunagi/src/dataplane/wireguard/plugin.rs index 652b85d..6db8e92 100644 --- a/crates/tsunagi/src/dataplane/wireguard/plugin.rs +++ b/crates/tsunagi/src/dataplane/wireguard/plugin.rs @@ -25,7 +25,7 @@ //! A failure here is reported and retried. It never stops the control plane. use std::collections::{BTreeSet, HashMap}; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::Ipv4Addr; use std::path::PathBuf; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; @@ -44,11 +44,15 @@ use super::announcement::{ValidatedAnnouncement, WgAnnouncement}; use super::device::{PeerSummary, WireguardDevice}; use super::keys::{WgPublicKey, WgSecretKey}; use super::store::WgKeyStore; -use crate::overlay::config::{DEFAULT_INTERFACE_PREFIX, interface_name}; use crate::state::Ipv4Range; /// The protocol identifier this plugin announces. -pub const WIREGUARD_PROTOCOL: &str = "wireguard"; +/// The protocol id of this plugin. +/// +/// `wg-quic`, because that is what it is: WireGuard's cryptography carried +/// in QUIC datagrams. The name is on the wire, so it is a protocol name and +/// not a description of the implementation. +pub const WIREGUARD_PROTOCOL: &str = "wg-quic"; /// Smallest interface MTU the overlay accepts. /// @@ -77,14 +81,12 @@ pub const WIREGUARD_OVERHEAD: u32 = 32; pub struct WireguardConfig { /// Directory for the plugin's own key store. Separate from agent state. pub state_dir: PathBuf, - /// Prefix of the interface names this plugin creates. - /// - /// Two agents on one host in the same network need different prefixes, - /// because the rest of the name is derived from the network id. - pub interface_prefix: String, /// WireGuard keepalive, which keeps tunnels and their links warm. pub keepalive: Option, - /// Interface MTU. See [`DEFAULT_MTU`]. + /// The largest packet a tunnel will carry. + /// + /// Not the interface MTU, which belongs to the agent: this is what this + /// protocol refuses to encrypt because it would not fit one datagram. pub mtu: u32, /// How long to coalesce changes before reconciling. pub reconcile_debounce: Duration, @@ -98,7 +100,6 @@ impl WireguardConfig { pub fn new(state_dir: impl Into) -> Self { Self { state_dir: state_dir.into(), - interface_prefix: DEFAULT_INTERFACE_PREFIX.to_string(), keepalive: Some(25), mtu: DEFAULT_MTU, reconcile_debounce: Duration::from_millis(200), @@ -106,12 +107,6 @@ impl WireguardConfig { } } - /// Sets the interface name prefix. - pub fn with_interface_prefix(mut self, prefix: impl Into) -> Self { - self.interface_prefix = prefix.into(); - self - } - /// Sets the interface MTU. /// /// Validated when the plugin is opened; see [`MIN_MTU`]. @@ -138,9 +133,7 @@ impl WireguardConfig { pub struct NetworkOverview { /// The network. pub network: NetworkId, - /// Packet interface this plugin created for it. - pub interface: String, - /// Interface MTU. + /// The largest packet a tunnel in this network will carry. pub mtu: u32, /// This agent's WireGuard public key in this network. pub public_key: WgPublicKey, @@ -186,7 +179,6 @@ impl PeerOverview { #[derive(Debug)] struct NetworkState { key: WgSecretKey, - interface: String, device: Option>, announcements: HashMap, links: HashMap, @@ -194,8 +186,6 @@ struct NetworkState { allocations: HashMap, /// The range those allocations came from. ipv4_range: Option, - /// The address last reported as missing, so it is said once, not forever. - reported_missing_v4: Option, } #[derive(Debug, Default)] @@ -245,22 +235,60 @@ pub struct WireguardPlugin { impl WireguardPlugin { /// The settings this protocol accepts. - pub const OPTIONS: &'static [crate::dataplane::ProtocolOption] = - &[crate::dataplane::ProtocolOption { + pub const OPTIONS: &'static [crate::dataplane::ProtocolOption] = &[ + crate::dataplane::ProtocolOption { key: "keepalive", value: "SECONDS", - help: "persistent keepalive interval; 0 turns it off", + help: "keeps a tunnel and its link warm through a NAT; 0 turns it off", default: Some("25"), - }]; + }, + crate::dataplane::ProtocolOption { + key: "mtu", + value: "BYTES", + help: "largest packet a tunnel will carry, at least 576", + default: Some("1280"), + }, + ]; + + /// Applies `key=value` settings to a configuration. + /// + /// An unknown key is refused rather than ignored: a setting that was + /// silently dropped looks exactly like one that did not work. + pub fn configure( + mut config: WireguardConfig, + options: &[(String, String)], + ) -> Result { + for (key, value) in options { + match key.as_str() { + "keepalive" => { + let seconds: u16 = value.parse().map_err(|_| { + PluginError::Other(format!("keepalive={value} is not a number of seconds")) + })?; + config.keepalive = (seconds > 0).then_some(seconds); + } + "mtu" => { + let mtu: u32 = value.parse().map_err(|_| { + PluginError::Other(format!("mtu={value} is not a number of bytes")) + })?; + config = config.with_mtu(mtu); + } + other => { + let known: Vec<&str> = Self::OPTIONS.iter().map(|spec| spec.key).collect(); + return Err(PluginError::Other(format!( + "`{other}` is not a setting of {WIREGUARD_PROTOCOL}; it takes {}", + known.join(", ") + ))); + } + } + } + Ok(config) + } /// Opens the plugin's key store and starts its reconciliation task. /// /// Must be called from inside a tokio runtime; the plugin starts no /// runtime of its own. pub async fn open(config: WireguardConfig) -> Result, PluginError> { - // Validate the prefix once, here, rather than failing per network. - interface_name(&config.interface_prefix, NetworkId::from_bytes([0u8; 32]))?; - if config.mtu < MIN_MTU { return Err(PluginError::Other(format!( "an MTU of {} is below the {MIN_MTU} bytes every IPv4 host must be able \ @@ -325,7 +353,6 @@ impl WireguardPlugin { Some(NetworkOverview { network, - interface: state.interface.clone(), mtu: self.worker.config.mtu, public_key: state.key.public(), overlay_address_v4: state.allocations.get(&self.worker.local_id()).copied(), @@ -399,7 +426,6 @@ impl Worker { return Ok(false); } - let name = interface_name(&self.config.interface_prefix, network)?; let worker = Arc::clone(self); let key = tokio::task::spawn_blocking(move || worker.store.load_or_create(network)) .await @@ -409,13 +435,11 @@ impl Worker { let mut shared = self.lock_shared(); shared.networks.entry(network).or_insert(NetworkState { key, - interface: name, device: None, announcements: HashMap::new(), links: HashMap::new(), allocations: HashMap::new(), ipv4_range: None, - reported_missing_v4: None, }); } @@ -471,10 +495,6 @@ impl Worker { }; let allocations = state.allocations.clone(); - let own_v4 = state.allocations.get(&self.local_id()).copied(); - let interface = state.device.as_ref().map(|_| state.interface.clone()); - let range = state.ipv4_range; - let state_reported = state.reported_missing_v4; let mut wanted: Vec = Vec::new(); let mut too_small: Vec<(usize, usize)> = Vec::new(); for (endpoint_id, announcement) in &state.announcements { @@ -515,53 +535,15 @@ impl Worker { } device.retain_peers(&wanted); - // The agent assigns this address itself, so finding it absent means - // the assignment did not take — something outside removed it, or the - // provisioner reported a success it did not achieve. Left unsaid it - // looks like a broken network: the kernel would send packets with the - // wrong source address and every peer would drop them. So it is - // checked rather than assumed, because the assumption is exactly the - // kind that has been wrong here before. - let missing_v4 = match (own_v4, interface.as_deref(), range) { - (Some(address), Some(interface), Some(range)) - if !crate::overlay::tun::address_is_local(IpAddr::V4(address)) => - { - let already = state_reported == Some(address); - if let Some(state) = shared.networks.get_mut(&network) { - state.reported_missing_v4 = Some(address); - } - (!already).then_some((address, interface.to_string(), range)) - } - _ => { - if let Some(state) = shared.networks.get_mut(&network) { - state.reported_missing_v4 = None; - } - None - } - }; drop(shared); - if let Some((address, interface, range)) = missing_v4 { - self.report( - network, - format!( - "this agent was allocated {address}/{} but the address is not on any \ - interface, so IPv4 cannot work: packets would leave with the wrong \ - source and every peer would drop them. It should have been assigned \ - to `{interface}` automatically; check whether something else removed \ - it.", - range.prefix_len - ), - ); - } - for (available, needed) in too_small { self.report( network, format!( "this path carries only {available} byte datagrams but a {} byte MTU needs \ - {needed}; packets larger than {} bytes will be dropped. Lower the MTU only \ - if you can stay at or above {MIN_MTU}, which IPv6 requires.", + {needed}; packets larger than {} bytes will be dropped. The floor is \ + {MIN_MTU} bytes, what every IPv4 host must be able to reassemble.", self.config.mtu, available.saturating_sub(WIREGUARD_OVERHEAD as usize) ), diff --git a/crates/tsunagi/tests/wireguard.rs b/crates/tsunagi/tests/wireguard.rs index 3f0a900..50aea88 100644 --- a/crates/tsunagi/tests/wireguard.rs +++ b/crates/tsunagi/tests/wireguard.rs @@ -474,10 +474,10 @@ async fn an_allocated_address_missing_from_the_host_is_reported() { reason.contains(&allocated.to_string()), "unexpected: {reason}" ); - // The agent assigns the address itself, so the report says which - // interface should have had it rather than a command to run. + // The agent owns the interface, so it is the agent that notices and the + // report names the interface the address should have been on. assert!( - reason.contains(&a.plugin.overview(network_id).unwrap().interface), + reason.contains(&a.agent.overlay().unwrap().interface), "must name the interface: {reason}" ); @@ -815,7 +815,6 @@ async fn restarting_keeps_the_wireguard_identity_and_overlay_address() { }) .await; assert_eq!(after.public_key, before.public_key); - assert_eq!(after.interface, before.interface); // The tunnel comes back on its own. wait_until("the tunnel is re-established", || async {