diff --git a/README.md b/README.md index 45dc104..5c20227 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Within a few seconds both print something like: --- status --- control: 1 peer(s), 0 dial failure(s), 0 handshake failure(s) -wireguard: tsunkkcp43lmdje on fd15:1d9e:fa21:f201:…/64 mtu 1100, 1/1 tunnel(s) established +wireguard: tsunkkcp43lmdje on fd15:1d9e:fa21:f201:…/64 mtu 1280, 1/1 tunnel(s) established 4jO4kx9Z fd15:1d9e:fa21:f201:… handshake 3s ago tx=0 rx=0 dropped=0 path=Direct via Ip(…) ``` @@ -123,15 +123,21 @@ tsunagi tun-setup --network lab --secret "$SECRET" ``` ```text -# Interface tsunjwc6dcrtmo5, address fd80:1210:f724:f620:d1bb:f982:3b6e:19bd/64, mtu 1100 +# Interface tsunjwc6dcrtmo5, address fd80:1210:f724:f620:d1bb:f982:3b6e:19bd/64, mtu 1280 # Run once as root; then run `tsunagi up` as ab. sudo ip tuntap add dev tsunjwc6dcrtmo5 mode tun user ab -sudo ip link set dev tsunjwc6dcrtmo5 mtu 1100 up +sudo ip link set dev tsunjwc6dcrtmo5 mtu 1280 up sudo sysctl -qw net.ipv6.conf.tsunjwc6dcrtmo5.keep_addr_on_down=1 sudo ip -6 address add fd80:1210:f724:f620:d1bb:f982:3b6e:19bd/64 dev tsunjwc6dcrtmo5 nodad ``` +The MTU is 1280 because that is the minimum IPv6 requires (RFC 8200). Linux +disables IPv6 entirely on an interface below it — the per-device +`/proc/sys/net/ipv6` entries vanish and `ip -6 address add` fails with +`Invalid argument` — so a smaller MTU cannot work at all. The agent refuses +one rather than letting it fail later. + The order and the last two lines are not decoration. A persistent TUN interface has **no carrier** until a process attaches to it, and Linux flushes IPv6 addresses from an interface that loses carrier unless diff --git a/docs/wireguard.md b/docs/wireguard.md index a263096..04a4cee 100644 --- a/docs/wireguard.md +++ b/docs/wireguard.md @@ -101,12 +101,24 @@ than silent non-connectivity. ## MTU -Every packet rides in one transport datagram, and WireGuard adds 32 bytes. A -QUIC datagram on a relayed path can be as small as roughly 1160 bytes, so the -default interface MTU is **1100**, which leaves headroom rather than relying on -the best case. Packets that do not fit are dropped and counted -(`dropped_oversize`), never truncated. The observed datagram limit of each link -is reported in the status output. +Two constraints pull against each other. + +**IPv6 sets a floor of 1280 bytes** (RFC 8200), and Linux enforces it +brutally: an interface whose MTU drops below 1280 loses IPv6 entirely — its +`/proc/sys/net/ipv6/conf/` directory disappears and `ip -6 address add` +answers `Invalid argument`. So the overlay MTU cannot go below 1280, and the +plugin refuses a smaller one at startup instead of letting it fail obscurely. + +**The transport sets a ceiling.** Every packet rides in one datagram and +WireGuard adds 32 bytes, so a link must carry `mtu + 32` = 1312 bytes. A direct +QUIC path typically offers around 1380, which fits. A relayed path can offer +less, and then full-size packets do not fit: they are dropped and counted as +`dropped_oversize`, never truncated, and the plugin reports the exact numbers +when the tunnel is set up. + +There is no room left to trade, so the default MTU is exactly 1280. +Fragmenting a packet across several datagrams would lift the ceiling and is +not implemented. ## Lifecycle diff --git a/src/bin/tsunagi.rs b/src/bin/tsunagi.rs index 1994671..ce10c3d 100644 --- a/src/bin/tsunagi.rs +++ b/src/bin/tsunagi.rs @@ -83,7 +83,7 @@ struct TunSetupArgs { #[arg(long, default_value = "tsun")] wg_prefix: String, - /// Interface MTU, matching `tsunagi up --wg-mtu`. + /// Interface MTU, matching `tsunagi up --wg-mtu`. At least 1280. #[arg(long)] wg_mtu: Option, } @@ -195,6 +195,8 @@ struct UpArgs { wg_prefix: String, /// Interface MTU for the WireGuard data plane. + /// + /// Must be at least 1280, the minimum IPv6 requires. #[arg(long)] wg_mtu: Option, diff --git a/src/dataplane/wireguard/mod.rs b/src/dataplane/wireguard/mod.rs index 11d40ac..ec9b928 100644 --- a/src/dataplane/wireguard/mod.rs +++ b/src/dataplane/wireguard/mod.rs @@ -57,8 +57,8 @@ pub use keys::{WgPublicKey, WgSecretKey}; pub use overlay::{OVERLAY_PREFIX_LEN, overlay_address, overlay_prefix}; pub use packet::IpHeader; pub use plugin::{ - DEFAULT_MTU, NetworkOverview, PeerOverview, WIREGUARD_PROTOCOL, WireguardConfig, - WireguardPlugin, + DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WIREGUARD_PROTOCOL, + WireguardConfig, WireguardPlugin, }; pub use store::WgKeyStore; pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest}; diff --git a/src/dataplane/wireguard/plugin.rs b/src/dataplane/wireguard/plugin.rs index 363407d..7ddb0fb 100644 --- a/src/dataplane/wireguard/plugin.rs +++ b/src/dataplane/wireguard/plugin.rs @@ -50,13 +50,27 @@ use super::tun::{TunFactory, TunRequest}; /// The protocol identifier this plugin announces. pub const WIREGUARD_PROTOCOL: &str = "wireguard"; +/// Smallest interface MTU IPv6 permits, from RFC 8200. +/// +/// This is not advice, it is a hard limit. Linux tears IPv6 down entirely on +/// an interface whose MTU is below it — the per-device `/proc/sys/net/ipv6` +/// entries disappear and `ip -6 address add` fails with `Invalid argument` — +/// so the overlay address could never be assigned. Anything smaller is +/// rejected up front instead of failing obscurely later. +pub const MIN_MTU: u32 = 1280; + /// Default interface MTU. /// -/// Every packet rides in one transport datagram, and WireGuard adds 32 bytes. -/// A QUIC datagram on a relayed path can be as small as roughly 1160 bytes, so -/// 1100 leaves headroom instead of relying on the best case. Packets that do -/// not fit are dropped and counted, never truncated. -pub const DEFAULT_MTU: u32 = 1100; +/// Equal to [`MIN_MTU`], because the overlay is IPv6 and there is no room +/// below it. +pub const DEFAULT_MTU: u32 = MIN_MTU; + +/// Bytes WireGuard adds to a packet: type and reserved, receiver index, +/// counter and the Poly1305 tag. +/// +/// A link therefore has to carry `mtu + WIREGUARD_OVERHEAD` bytes in one +/// datagram for a full-size packet to get through. +pub const WIREGUARD_OVERHEAD: u32 = 32; /// Configuration of the WireGuard plugin. #[derive(Debug, Clone)] @@ -99,6 +113,8 @@ impl WireguardConfig { } /// Sets the interface MTU. + /// + /// Validated when the plugin is opened; see [`MIN_MTU`]. pub fn with_mtu(mut self, mtu: u32) -> Self { self.mtu = mtu; self @@ -235,6 +251,15 @@ impl WireguardPlugin { // 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 IPv6 requires (RFC 8200). \ + Linux disables IPv6 on an interface below that, so the overlay address \ + could never be assigned.", + config.mtu + ))); + } + let path = config.key_store_path(); let store = tokio::task::spawn_blocking(move || WgKeyStore::open(path)) .await @@ -432,6 +457,7 @@ impl Worker { }; let mut wanted: Vec = Vec::new(); + let mut too_small: Vec<(usize, usize)> = Vec::new(); for (endpoint_id, announcement) in &state.announcements { let Some(link) = state.links.get(endpoint_id) else { continue; @@ -443,6 +469,16 @@ impl Worker { if device.has_peer(&announcement.public_key) { continue; } + + // A link that cannot carry a full-size packet will silently drop + // the large ones, which looks like a broken network rather than a + // configuration problem. Say so when the tunnel is set up. + let needed = self.config.mtu.saturating_add(WIREGUARD_OVERHEAD) as usize; + let available = link.max_datagram_size(); + if available < needed { + too_small.push((available, needed)); + } + if let Err(err) = device.add_peer( *endpoint_id, announcement.public_key, @@ -453,6 +489,20 @@ impl Worker { } } device.retain_peers(&wanted); + drop(shared); + + 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.", + self.config.mtu, + available.saturating_sub(WIREGUARD_OVERHEAD as usize) + ), + ); + } } /// Removes a network's interface and tunnels, keeping its key. diff --git a/tests/wireguard.rs b/tests/wireguard.rs index 57b9f3f..3a81ac0 100644 --- a/tests/wireguard.rs +++ b/tests/wireguard.rs @@ -615,6 +615,42 @@ impl IpPlugin for ForgingPlugin { fn on_network_deactivated(&self, _network: NetworkId) {} } +#[tokio::test] +async fn an_mtu_below_the_ipv6_minimum_is_refused() { + use tsunagi::dataplane::wireguard::{DEFAULT_MTU, MIN_MTU, WIREGUARD_OVERHEAD}; + + // Linux disables IPv6 outright on an interface below 1280 bytes, so the + // overlay address could never be assigned. Catch it here rather than as + // an obscure RTNETLINK error much later. + let dir = TempDir::new().unwrap(); + let result = WireguardPlugin::open( + WireguardConfig::new(dir.path()).with_mtu(MIN_MTU - 1), + Arc::new(MemoryTunFactory::new()), + ) + .await; + match result { + Err(err) => { + let text = err.to_string(); + assert!(text.contains("1280"), "unexpected message: {text}"); + assert!(text.contains("IPv6"), "unexpected message: {text}"); + } + Ok(_) => panic!("an MTU below the IPv6 minimum must be refused"), + } + + // The default is exactly the minimum, and a link has to carry it plus + // WireGuard's own overhead. + assert_eq!(DEFAULT_MTU, MIN_MTU); + assert_eq!(WIREGUARD_OVERHEAD, 32); + assert!( + WireguardPlugin::open( + WireguardConfig::new(dir.path().join("ok")), + Arc::new(MemoryTunFactory::new()), + ) + .await + .is_ok() + ); +} + #[tokio::test] async fn the_overlay_address_is_derived_from_the_key_alone() { let (name, secret) = network("wg-derivation");