Raise the overlay MTU to 1280: below that Linux disables IPv6
The setup recipe failed with a missing sysctl directory and "RTNETLINK answers: Invalid argument". The cause was the default MTU of 1100. IPv6 requires a minimum MTU of 1280 (RFC 8200) and Linux enforces it by tearing IPv6 down on any interface below it: the per-device /proc/sys/net/ipv6/conf entries disappear and an address can no longer be assigned. Evidence on the test host: every interface at 1280 or above has an IPv6 conf directory, every interface below it (1230, 1100) has none. So the overlay MTU is now 1280, which is also the floor. A smaller value is refused when the plugin opens, naming the reason, rather than surfacing as an obscure netlink error after the user has already run four commands. That leaves no slack against the other constraint: a packet needs mtu + 32 bytes of transport datagram, so 1312. A direct QUIC path offers roughly 1380 and fits; a relayed path may not, so the plugin now reports the exact numbers when a link cannot carry a full-size packet, instead of only counting silent drops. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -90,7 +90,7 @@ Within a few seconds both print something like:
|
|||||||
|
|
||||||
--- status ---
|
--- status ---
|
||||||
control: 1 peer(s), 0 dial failure(s), 0 handshake failure(s)
|
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(…)
|
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
|
```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.
|
# Run once as root; then run `tsunagi up` as ab.
|
||||||
|
|
||||||
sudo ip tuntap add dev tsunjwc6dcrtmo5 mode tun user 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 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
|
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
|
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
|
interface has **no carrier** until a process attaches to it, and Linux flushes
|
||||||
IPv6 addresses from an interface that loses carrier unless
|
IPv6 addresses from an interface that loses carrier unless
|
||||||
|
|||||||
+18
-6
@@ -101,12 +101,24 @@ than silent non-connectivity.
|
|||||||
|
|
||||||
## MTU
|
## MTU
|
||||||
|
|
||||||
Every packet rides in one transport datagram, and WireGuard adds 32 bytes. A
|
Two constraints pull against each other.
|
||||||
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
|
**IPv6 sets a floor of 1280 bytes** (RFC 8200), and Linux enforces it
|
||||||
the best case. Packets that do not fit are dropped and counted
|
brutally: an interface whose MTU drops below 1280 loses IPv6 entirely — its
|
||||||
(`dropped_oversize`), never truncated. The observed datagram limit of each link
|
`/proc/sys/net/ipv6/conf/<dev>` directory disappears and `ip -6 address add`
|
||||||
is reported in the status output.
|
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
|
## Lifecycle
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -83,7 +83,7 @@ struct TunSetupArgs {
|
|||||||
#[arg(long, default_value = "tsun")]
|
#[arg(long, default_value = "tsun")]
|
||||||
wg_prefix: String,
|
wg_prefix: String,
|
||||||
|
|
||||||
/// Interface MTU, matching `tsunagi up --wg-mtu`.
|
/// Interface MTU, matching `tsunagi up --wg-mtu`. At least 1280.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
wg_mtu: Option<u32>,
|
wg_mtu: Option<u32>,
|
||||||
}
|
}
|
||||||
@@ -195,6 +195,8 @@ struct UpArgs {
|
|||||||
wg_prefix: String,
|
wg_prefix: String,
|
||||||
|
|
||||||
/// Interface MTU for the WireGuard data plane.
|
/// Interface MTU for the WireGuard data plane.
|
||||||
|
///
|
||||||
|
/// Must be at least 1280, the minimum IPv6 requires.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
wg_mtu: Option<u32>,
|
wg_mtu: Option<u32>,
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ pub use keys::{WgPublicKey, WgSecretKey};
|
|||||||
pub use overlay::{OVERLAY_PREFIX_LEN, overlay_address, overlay_prefix};
|
pub use overlay::{OVERLAY_PREFIX_LEN, overlay_address, overlay_prefix};
|
||||||
pub use packet::IpHeader;
|
pub use packet::IpHeader;
|
||||||
pub use plugin::{
|
pub use plugin::{
|
||||||
DEFAULT_MTU, NetworkOverview, PeerOverview, WIREGUARD_PROTOCOL, WireguardConfig,
|
DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WIREGUARD_PROTOCOL,
|
||||||
WireguardPlugin,
|
WireguardConfig, WireguardPlugin,
|
||||||
};
|
};
|
||||||
pub use store::WgKeyStore;
|
pub use store::WgKeyStore;
|
||||||
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest};
|
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest};
|
||||||
|
|||||||
@@ -50,13 +50,27 @@ use super::tun::{TunFactory, TunRequest};
|
|||||||
/// The protocol identifier this plugin announces.
|
/// The protocol identifier this plugin announces.
|
||||||
pub const WIREGUARD_PROTOCOL: &str = "wireguard";
|
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.
|
/// Default interface MTU.
|
||||||
///
|
///
|
||||||
/// Every packet rides in one transport datagram, and WireGuard adds 32 bytes.
|
/// Equal to [`MIN_MTU`], because the overlay is IPv6 and there is no room
|
||||||
/// A QUIC datagram on a relayed path can be as small as roughly 1160 bytes, so
|
/// below it.
|
||||||
/// 1100 leaves headroom instead of relying on the best case. Packets that do
|
pub const DEFAULT_MTU: u32 = MIN_MTU;
|
||||||
/// not fit are dropped and counted, never truncated.
|
|
||||||
pub const DEFAULT_MTU: u32 = 1100;
|
/// 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.
|
/// Configuration of the WireGuard plugin.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -99,6 +113,8 @@ impl WireguardConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the interface MTU.
|
/// Sets the interface MTU.
|
||||||
|
///
|
||||||
|
/// Validated when the plugin is opened; see [`MIN_MTU`].
|
||||||
pub fn with_mtu(mut self, mtu: u32) -> Self {
|
pub fn with_mtu(mut self, mtu: u32) -> Self {
|
||||||
self.mtu = mtu;
|
self.mtu = mtu;
|
||||||
self
|
self
|
||||||
@@ -235,6 +251,15 @@ impl WireguardPlugin {
|
|||||||
// Validate the prefix once, here, rather than failing per network.
|
// Validate the prefix once, here, rather than failing per network.
|
||||||
interface_name(&config.interface_prefix, NetworkId::from_bytes([0u8; 32]))?;
|
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 path = config.key_store_path();
|
||||||
let store = tokio::task::spawn_blocking(move || WgKeyStore::open(path))
|
let store = tokio::task::spawn_blocking(move || WgKeyStore::open(path))
|
||||||
.await
|
.await
|
||||||
@@ -432,6 +457,7 @@ impl Worker {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut wanted: Vec<WgPublicKey> = Vec::new();
|
let mut wanted: Vec<WgPublicKey> = Vec::new();
|
||||||
|
let mut too_small: Vec<(usize, usize)> = Vec::new();
|
||||||
for (endpoint_id, announcement) in &state.announcements {
|
for (endpoint_id, announcement) in &state.announcements {
|
||||||
let Some(link) = state.links.get(endpoint_id) else {
|
let Some(link) = state.links.get(endpoint_id) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -443,6 +469,16 @@ impl Worker {
|
|||||||
if device.has_peer(&announcement.public_key) {
|
if device.has_peer(&announcement.public_key) {
|
||||||
continue;
|
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(
|
if let Err(err) = device.add_peer(
|
||||||
*endpoint_id,
|
*endpoint_id,
|
||||||
announcement.public_key,
|
announcement.public_key,
|
||||||
@@ -453,6 +489,20 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
device.retain_peers(&wanted);
|
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.
|
/// Removes a network's interface and tunnels, keeping its key.
|
||||||
|
|||||||
@@ -615,6 +615,42 @@ impl IpPlugin for ForgingPlugin {
|
|||||||
fn on_network_deactivated(&self, _network: NetworkId) {}
|
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]
|
#[tokio::test]
|
||||||
async fn the_overlay_address_is_derived_from_the_key_alone() {
|
async fn the_overlay_address_is_derived_from_the_key_alone() {
|
||||||
let (name, secret) = network("wg-derivation");
|
let (name, secret) = network("wg-derivation");
|
||||||
|
|||||||
Reference in New Issue
Block a user