From a6fedbee5e4150e3976ee4f58477694eb8d3fa4c Mon Sep 17 00:00:00 2001 From: Michal Ziobro Date: Fri, 4 Sep 2026 18:43:41 +0300 Subject: [PATCH 1/8] Add missing DeviceConfig fields in introduction.md --- src/doc/introduction.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/doc/introduction.md b/src/doc/introduction.md index 09df437b72..eda0fa1c07 100644 --- a/src/doc/introduction.md +++ b/src/doc/introduction.md @@ -82,8 +82,10 @@ use telio_wg::Tun; pub struct DeviceConfig { pub private_key: SecretKey, pub adapter: AdapterType, + pub fwmark: Option, pub name: Option, pub tun: Option, + pub ext_if_filter: Option>, } ``` @@ -91,8 +93,10 @@ Let's discuss its fields shortly: - `private_key` a `telio::crypto::SecretKey` instance containing a 256-bit key, - `adapter` indicating which Wireguard implementation we want to use, +- `fwmark` the firewall mark to set on the sockets opened by Telio, Linux only, - `name` is the name of the network interface, when omitted, Telio uses the default one, -- `tun` a file descriptor of the already opened tunnel, if it's not provided Telio will open a new one. +- `tun` a file descriptor of the already opened tunnel, if it's not provided Telio will open a new one, +- `ext_if_filter` names of the interfaces to skip while looking for the default interface. The API provides a default config which is almost sufficient for simple cases, the only need that needs to be done is the generation of a private key: From 024717f7f9192dbc575b9f9bc5c2433c55617a8f Mon Sep 17 00:00:00 2001 From: Michal Ziobro Date: Fri, 4 Sep 2026 19:17:22 +0300 Subject: [PATCH 2/8] Add generic start function with extendable config --- src/ffi.rs | 208 +++++++++++++++++++++++++++-------------------- src/libtelio.udl | 24 ++++++ 2 files changed, 146 insertions(+), 86 deletions(-) diff --git a/src/ffi.rs b/src/ffi.rs index 56e6a6a127..6de22ed9e9 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -161,6 +161,55 @@ pub extern "C" fn Java_com_nordsec_telio_TelioCert_initCertStore<'caller>( }) } +/// Optional settings for starting the telio device. +/// +/// Every field defaults to "not set", so supporting a new option requires a +/// single additional field instead of an additional `start*` function. +#[derive(Debug, Default)] +pub struct StartConfig { + /// Name of the tunnel interface opened by the adapter. When not set, telio + /// picks a platform default. + pub name: Option, + /// Interfaces to skip while looking for the default interface. + pub ext_if_filter: Option>, + /// File descriptor of an already open tunnel, which telio takes ownership + /// of and closes on stop. When not set, the adapter opens its own tunnel. + /// Ignored on Windows. + pub tun: Option, +} + +impl StartConfig { + fn to_device_config( + &self, + private_key: SecretKey, + adapter: TelioAdapterType, + ) -> FfiResult { + let adapter = adapter + .try_into() + .map_err(|e| TelioError::UnknownError { inner: e })?; + + // Take ownership of the descriptor only after every fallible step: an + // early return would drop it and close an fd the caller still owns + #[cfg(not(target_os = "windows"))] + let tun = self.tun.map(|fd| { + use std::os::fd::{FromRawFd, OwnedFd}; + // SAFETY: the caller hands over an open descriptor it no longer uses + unsafe { OwnedFd::from_raw_fd(fd) } + }); + #[cfg(target_os = "windows")] + let tun = None; + + Ok(DeviceConfig { + private_key, + adapter, + fwmark: None, + name: self.name.clone(), + tun, + ext_if_filter: self.ext_if_filter.clone(), + }) + } +} + pub struct Telio { inner: Mutex>, id: usize, @@ -383,27 +432,7 @@ impl Telio { /// /// Adapter will attempt to open its own tunnel. pub fn start(&self, private_key: SecretKey, adapter: TelioAdapterType) -> FfiResult<()> { - telio_log_info!( - "Telio::start entry with instance id: {}. Public key: {:?}. Adapter: {:?}", - self.id, - private_key.public(), - &adapter - ); - catch_ffi_panic(|| { - self.device_op(true, |dev| { - dev.start(DeviceConfig { - private_key: private_key.clone(), - adapter: adapter - .try_into() - .map_err(|e| TelioError::UnknownError { inner: e })?, - fwmark: None, - name: None, - tun: None, - ext_if_filter: None, - }) - .log_result("Telio::start") - }) - }) + self.start_with_config(private_key, adapter, StartConfig::default()) } /// Start telio with specified adapter and name. @@ -415,28 +444,14 @@ impl Telio { adapter: TelioAdapterType, name: String, ) -> FfiResult<()> { - telio_log_info!( - "Telio::start entry with instance id: {}. Public key: {:?}. Adapter: {:?}. Name: {}", - self.id, - private_key.public(), - &adapter, - &name, - ); - catch_ffi_panic(|| { - self.device_op(true, |dev| { - dev.start(DeviceConfig { - private_key: private_key.clone(), - adapter: adapter - .try_into() - .map_err(|e| TelioError::UnknownError { inner: e })?, - fwmark: None, - name: Some(name.clone()), - tun: None, - ext_if_filter: None, - }) - .log_result("Telio::start_named") - }) - }) + self.start_with_config( + private_key, + adapter, + StartConfig { + name: Some(name), + ..Default::default() + }, + ) } /// Start telio with specified adapter type, adapter name @@ -449,27 +464,38 @@ impl Telio { adapter: TelioAdapterType, name: String, ext_if_filter: Vec, + ) -> FfiResult<()> { + self.start_with_config( + private_key, + adapter, + StartConfig { + name: Some(name), + ext_if_filter: Some(ext_if_filter), + ..Default::default() + }, + ) + } + + /// Start telio with the specified adapter and the options in `config`. + /// + /// Adapter will attempt to open its own tunnel unless `config.tun` is set. + pub fn start_with_config( + &self, + private_key: SecretKey, + adapter: TelioAdapterType, + config: StartConfig, ) -> FfiResult<()> { telio_log_info!( - "Telio::start entry with instance id: {}. Public key: {:?}. Adapter: {:?}. Name: {}", + "Telio::start entry with instance id: {}. Public key: {:?}. Adapter: {:?}. Config: {:?}", self.id, private_key.public(), &adapter, - &name, + &config, ); catch_ffi_panic(|| { self.device_op(true, |dev| { - dev.start(DeviceConfig { - private_key: private_key.clone(), - adapter: adapter - .try_into() - .map_err(|e| TelioError::UnknownError { inner: e })?, - fwmark: None, - name: Some(name.clone()), - tun: None, - ext_if_filter: Some(ext_if_filter.clone()), - }) - .log_result("Telio::start_named_ext_if_filter") + dev.start(config.to_device_config(private_key.clone(), adapter)?) + .log_result("Telio::start_with_config") }) }) } @@ -509,37 +535,16 @@ impl Telio { &self, private_key: SecretKey, adapter: TelioAdapterType, - _tun: i32, + tun: i32, ) -> FfiResult<()> { - telio_log_info!( - "Telio::start entry with instance id: {}. Public key: {:?}. Adapter: {:?}. Tun: {_tun}", - self.id, - private_key.public(), - &adapter - ); - - catch_ffi_panic(|| { - self.device_op(true, |dev| { - #[cfg(not(target_os = "windows"))] - let tun = { - use std::os::fd::{FromRawFd, OwnedFd}; - Some(unsafe { OwnedFd::from_raw_fd(_tun) }) - }; - #[cfg(target_os = "windows")] - let tun = None; - dev.start(DeviceConfig { - private_key: private_key.clone(), - adapter: adapter - .try_into() - .map_err(|e| TelioError::UnknownError { inner: e })?, - fwmark: None, - name: None, - tun, - ext_if_filter: None, - }) - .log_result("Telio::start_with_tun") - }) - }) + self.start_with_config( + private_key, + adapter, + StartConfig { + tun: Some(tun), + ..Default::default() + }, + ) } /// Stop telio device. @@ -1202,4 +1207,35 @@ mod tests { deserialize_feature_config(CORRECT_FEATURES_JSON_WITHOUT_IS_TEST_ENV.to_owned()); assert!(actual.is_ok()); } + + #[cfg(unix)] + #[test] + fn test_start_config_rejected_does_not_close_callers_tun() { + use std::os::{ + fd::{AsRawFd, BorrowedFd}, + unix::net::UnixStream, + }; + + let (callers_end, _other_end) = UnixStream::pair().unwrap(); + let fd = callers_end.as_raw_fd(); + let config = StartConfig { + tun: Some(fd), + ..Default::default() + }; + + // `Custom` cannot be converted to a device adapter, so the config is rejected + assert!(matches!( + config.to_device_config(SecretKey::gen(), TelioAdapterType::Custom), + Err(TelioError::UnknownError { .. }) + )); + + // dup fails with EBADF if the rejected call closed the descriptor + // SAFETY: `callers_end` keeps the descriptor open for the whole borrow + let still_open = unsafe { BorrowedFd::borrow_raw(fd) }.try_clone_to_owned(); + assert!( + still_open.is_ok(), + "caller's tun fd was closed: {:?}", + still_open + ); + } } diff --git a/src/libtelio.udl b/src/libtelio.udl index da80ea7140..b6cade91c7 100644 --- a/src/libtelio.udl +++ b/src/libtelio.udl @@ -306,6 +306,12 @@ interface Telio { [Throws=TelioError] void start_named_ext_if_filter(SecretKey secret_key, TelioAdapterType adapter, string name, sequence ext_if_filter); + /// Start telio with the specified adapter and the options in `config`. + /// + /// Adapter will attempt to open its own tunnel unless `config.tun` is set. + [Throws=TelioError] + void start_with_config(SecretKey secret_key, TelioAdapterType adapter, StartConfig config); + /// Start telio device with specified adapter and already open tunnel. /// /// Telio will take ownership of tunnel , and close it on stop. @@ -603,6 +609,24 @@ callback interface TelioProtectCb { void protect(i32 socket_id); }; +/// Optional settings for starting telio, consumed by [Telio::start_with_config]. +/// +/// Every field defaults to "not set", so supporting a new option requires a +/// single additional field instead of an additional `start*` function. +dictionary StartConfig { + /// Name of the tunnel interface opened by the adapter. When not set, telio + /// picks a platform default. + string? name = null; + + /// Interfaces to skip while looking for the default interface. + sequence? ext_if_filter = null; + + /// File descriptor of an already open tunnel, which telio takes ownership + /// of and closes on stop. When not set, the adapter opens its own tunnel. + /// Ignored on Windows. + i32? tun = null; +}; + /// A [Features] builder that allows a simpler initialization of /// features with defaults coming from libtelio lib. /// From 78cb8ac0a6c0892dfcad7087146239e40e3163aa Mon Sep 17 00:00:00 2001 From: Michal Ziobro Date: Mon, 7 Sep 2026 11:29:14 +0300 Subject: [PATCH 3/8] Add MTU setting to Telio API --- crates/telio-core/src/device.rs | 21 ++++++++++++++ crates/telio-wg/src/adapter.rs | 5 ++++ crates/telio-wg/src/wg.rs | 16 +++++++++++ src/ffi.rs | 49 ++++++++++++++++++++++++++++++++- src/libtelio.udl | 13 +++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/crates/telio-core/src/device.rs b/crates/telio-core/src/device.rs index 8af5e83753..fbca1523b9 100644 --- a/crates/telio-core/src/device.rs +++ b/crates/telio-core/src/device.rs @@ -228,6 +228,7 @@ pub struct DeviceConfig { pub name: Option, pub tun: Option, pub ext_if_filter: Option>, + pub mtu: Option, } pub struct Device { @@ -722,6 +723,16 @@ impl Device { }) } + /// Configure the MTU of the adapter interface, `None` restores the adapter's own handling. + pub fn set_adapter_mtu(&self, mtu: Option) -> Result { + self.async_runtime()?.block_on(async { + task_exec!(self.rt()?, async move |rt| { + Ok(rt.set_adapter_mtu(mtu).boxed().await) + }) + .await? + }) + } + /// Retrieves currently configured private key for the interface pub fn get_private_key(&self) -> Result { self.async_runtime()?.block_on(async { @@ -1268,6 +1279,7 @@ impl Runtime { firewall_reset_connections, enable_dynamic_wg_nt_control, enable_wg_nt_guid_rotation: features.wireguard.enable_wg_nt_guid_rotation, + mtu: config.mtu, skt_buffer_size : Runtime::sanitize_neptun_config(features.wireguard.skt_buffer_size, config.adapter.clone()), inter_thread_channel_size : Runtime::sanitize_neptun_config(features.wireguard.inter_thread_channel_size, config.adapter.clone()), max_inter_thread_batched_pkts : Runtime::sanitize_neptun_config(features.wireguard.max_inter_thread_batched_pkts, config.adapter.clone()), @@ -1294,6 +1306,7 @@ impl Runtime { firewall_reset_connections, enable_dynamic_wg_nt_control, enable_wg_nt_guid_rotation: features.wireguard.enable_wg_nt_guid_rotation, + mtu: config.mtu, skt_buffer_size: features.wireguard.skt_buffer_size, inter_thread_channel_size: features.wireguard.inter_thread_channel_size, max_inter_thread_batched_pkts: features.wireguard.max_inter_thread_batched_pkts, @@ -1755,6 +1768,14 @@ impl Runtime { Ok(()) } + async fn set_adapter_mtu(&mut self, mtu: Option) -> Result { + Ok(self + .entities + .wireguard_interface + .set_adapter_mtu(mtu) + .await?) + } + async fn set_private_key(&mut self, private_key: &SecretKey) -> Result { // TODO: create a global controll state to consolidate all entities diff --git a/crates/telio-wg/src/adapter.rs b/crates/telio-wg/src/adapter.rs index e386d416d9..1bf97ed05b 100644 --- a/crates/telio-wg/src/adapter.rs +++ b/crates/telio-wg/src/adapter.rs @@ -92,6 +92,11 @@ pub trait Adapter: Send + Sync { /// Set the (u)tun file descriptor to be used by the adapter async fn set_tun(&self, tun: Tun) -> Result<(), Error>; + /// Set the MTU of the adapter interface, `None` restores the adapter's own handling + async fn set_adapter_mtu(&self, _mtu: Option) -> Result<(), Error> { + Err(Error::UnsupportedAdapter) + } + /// Make a copy of this adapter. /// /// Only the custom adapters can be cloned this way. diff --git a/crates/telio-wg/src/wg.rs b/crates/telio-wg/src/wg.rs index 995796d8b8..b0146f9a96 100644 --- a/crates/telio-wg/src/wg.rs +++ b/crates/telio-wg/src/wg.rs @@ -77,6 +77,8 @@ pub trait WireGuard: Send + Sync + 'static { async fn reset_existing_connections(&self, exit_pubkey: PublicKey) -> Result<(), Error>; /// Set the ip stack for the adapter async fn set_ip_stack(&self, ip_stack: Option) -> Result<(), Error>; + /// Set the MTU of the adapter interface + async fn set_adapter_mtu(&self, mtu: u32) -> Result<(), Error>; /// Ensure that adapter is UP or DOWN async fn ensure_expected_adapter_state( &self, @@ -116,6 +118,8 @@ pub struct Config { /// When adapter creation fails, retry with a different GUID from a small fixed pool /// instead of reusing the same one. Windows only. pub enable_wg_nt_guid_rotation: bool, + /// MTU to set on the adapter interface, if None the adapter picks its own + pub mtu: Option, /// Configurable socket buffer size, if None doesn't modify default OS set values pub skt_buffer_size: Option, /// Configurable socket buffer size, if None doesn't modify default OS set values @@ -239,6 +243,7 @@ impl DynamicWg { /// firewall_reset_connections: None, /// enable_dynamic_wg_nt_control: None, /// enable_wg_nt_guid_rotation: false, + /// mtu: None, /// skt_buffer_size: None, /// inter_thread_channel_size: None, /// max_inter_thread_batched_pkts: None, @@ -478,6 +483,15 @@ impl WireGuard for DynamicWg { .await?) } + async fn set_adapter_mtu(&self, mtu: u32) -> Result<(), Error> { + task_exec!(&self.task, async move |s| Ok(s + .adapter + .set_adapter_mtu(mtu) + .await)) + .await??; + Ok(()) + } + /// Ensure that adapter is UP or DOWN async fn ensure_expected_adapter_state( &self, @@ -515,6 +529,7 @@ impl Config { firewall_reset_connections: self.firewall_reset_connections.clone(), enable_dynamic_wg_nt_control: self.enable_dynamic_wg_nt_control.clone(), enable_wg_nt_guid_rotation: self.enable_wg_nt_guid_rotation, + mtu: self.mtu, skt_buffer_size: self.skt_buffer_size, inter_thread_channel_size: self.inter_thread_channel_size, max_inter_thread_batched_pkts: self.max_inter_thread_batched_pkts, @@ -1124,6 +1139,7 @@ pub mod tests { firewall_reset_connections: None, enable_dynamic_wg_nt_control: None, enable_wg_nt_guid_rotation: true, + mtu: None, skt_buffer_size: None, inter_thread_channel_size: None, max_inter_thread_batched_pkts: None, diff --git a/src/ffi.rs b/src/ffi.rs index 6de22ed9e9..f5e73ca00c 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -172,6 +172,10 @@ pub struct StartConfig { pub name: Option, /// Interfaces to skip while looking for the default interface. pub ext_if_filter: Option>, + /// MTU to set on the adapter interface, at least 1280. When not set, the + /// adapter picks its own. Only supported by the Windows native adapter, + /// starting any other adapter with it set fails. + pub mtu: Option, /// File descriptor of an already open tunnel, which telio takes ownership /// of and closes on stop. When not set, the adapter opens its own tunnel. /// Ignored on Windows. @@ -184,6 +188,11 @@ impl StartConfig { private_key: SecretKey, adapter: TelioAdapterType, ) -> FfiResult { + if self.mtu.is_some() && !matches!(adapter, TelioAdapterType::WindowsNativeTun) { + return Err(TelioError::UnknownError { + inner: "MTU is only supported by the Windows native adapter".to_owned(), + }); + } let adapter = adapter .try_into() .map_err(|e| TelioError::UnknownError { inner: e })?; @@ -206,6 +215,7 @@ impl StartConfig { name: self.name.clone(), tun, ext_if_filter: self.ext_if_filter.clone(), + mtu: self.mtu, }) } } @@ -422,6 +432,7 @@ impl Telio { name: None, tun: None, ext_if_filter: None, + mtu: None, }) .log_result("Telio::start") }) @@ -486,7 +497,7 @@ impl Telio { config: StartConfig, ) -> FfiResult<()> { telio_log_info!( - "Telio::start entry with instance id: {}. Public key: {:?}. Adapter: {:?}. Config: {:?}", + "Telio::start_with_config entry with instance id: {}. Public key: {:?}. Adapter: {:?}. Config: {:?}", self.id, private_key.public(), &adapter, @@ -519,6 +530,25 @@ impl Telio { }) } + /// Set the MTU of the adapter interface, at least 1280. `None` restores the + /// adapter's own MTU handling. + /// + /// Only supported by the Windows native adapter, other adapters fail with + /// an unsupported-adapter error. + pub fn set_adapter_mtu(&self, mtu: Option) -> FfiResult<()> { + telio_log_info!( + "Telio::set_adapter_mtu entry with instance id: {}. MTU: {:?}", + self.id, + mtu, + ); + catch_ffi_panic(|| { + self.device_op(true, |dev| { + dev.set_adapter_mtu(mtu) + .log_result("Telio::set_adapter_mtu") + }) + }) + } + /// Start telio device with specified adapter and already open tunnel. /// /// Telio will take ownership of tunnel , and close it on stop. @@ -1208,6 +1238,23 @@ mod tests { assert!(actual.is_ok()); } + #[test] + fn test_start_config_mtu_requires_windows_native_adapter() { + let config = StartConfig { + mtu: Some(1400), + ..Default::default() + }; + let key = SecretKey::gen(); + + assert!(config + .to_device_config(key.clone(), TelioAdapterType::WindowsNativeTun) + .is_ok()); + assert!(matches!( + config.to_device_config(key, TelioAdapterType::NepTUN), + Err(TelioError::UnknownError { .. }) + )); + } + #[cfg(unix)] #[test] fn test_start_config_rejected_does_not_close_callers_tun() { diff --git a/src/libtelio.udl b/src/libtelio.udl index b6cade91c7..d12f1bbf0d 100644 --- a/src/libtelio.udl +++ b/src/libtelio.udl @@ -331,6 +331,14 @@ interface Telio { [Throws=TelioError] void set_ext_if_filter(sequence ext_if_filter); + /// Set the MTU of the adapter interface, at least 1280. `None` restores the + /// adapter's own MTU handling. + /// + /// Only supported by the Windows native adapter, other adapters fail with + /// an unsupported-adapter error. + [Throws=TelioError] + void set_adapter_mtu(u32? mtu); + /// Sets private key for started device. /// /// If private_key is not set, device will never connect. @@ -621,6 +629,11 @@ dictionary StartConfig { /// Interfaces to skip while looking for the default interface. sequence? ext_if_filter = null; + /// MTU to set on the adapter interface, at least 1280. When not set, the + /// adapter picks its own. Only supported by the Windows native adapter, + /// starting any other adapter with it set fails. + u32? mtu = null; + /// File descriptor of an already open tunnel, which telio takes ownership /// of and closes on stop. When not set, the adapter opens its own tunnel. /// Ignored on Windows. From 527807ae63d85355e83f1814a3eef93483a50fe3 Mon Sep 17 00:00:00 2001 From: Michal Ziobro Date: Mon, 7 Sep 2026 11:57:18 +0300 Subject: [PATCH 4/8] Add natlab test for start with config --- nat-lab/tests/libtelio_client/client.py | 16 +++++++++++ nat-lab/tests/test_telio_start_methods.py | 34 +++++++++++++++++++++++ nat-lab/tests/uniffi/libtelio_proxy.py | 10 +++++++ nat-lab/tests/uniffi/libtelio_remote.py | 10 +++++++ 4 files changed, 70 insertions(+) diff --git a/nat-lab/tests/libtelio_client/client.py b/nat-lab/tests/libtelio_client/client.py index 5e38f3bbb4..de1a59c643 100644 --- a/nat-lab/tests/libtelio_client/client.py +++ b/nat-lab/tests/libtelio_client/client.py @@ -20,6 +20,7 @@ Config, Features, Server, + StartConfig, TelioAdapterType, TelioNode, default_features, @@ -351,6 +352,21 @@ async def start_named_ext_if_filter(self, tun_name, ext_if_filter: List[str]): ext_if_list=ext_if_filter, ) + async def start_with_config(self, config: StartConfig): + # Defaulted in place, since every nat-lab client needs the router's name. + if config.name is None: + config.name = self.get_router().get_interface_name() + await self.get_proxy().start_with_config( + private_key=self._node.private_key, + adapter=self._adapter_type, + config=config, + ) + if isinstance(self.get_router(), LinuxRouter): + await self.get_proxy().set_fwmark(int(LINUX_FWMARK_VALUE)) + + async def set_adapter_mtu(self, mtu: int): + await self.get_proxy().set_adapter_mtu(mtu) + async def set_meshnet_config(self, meshnet_config: Config) -> None: made_changes = await self.configure_interface() diff --git a/nat-lab/tests/test_telio_start_methods.py b/nat-lab/tests/test_telio_start_methods.py index 6159847cce..5c36f2e9b0 100644 --- a/nat-lab/tests/test_telio_start_methods.py +++ b/nat-lab/tests/test_telio_start_methods.py @@ -7,6 +7,7 @@ default_features, features_with_endpoint_providers, EndpointProvider, + StartConfig, TelioAdapterType, ) from tests.utils.connection import ConnectionTag @@ -87,6 +88,39 @@ async def test_start_with_tun_and_switch_it_at_runtime(alpha_tag) -> None: await ping_between_all_nodes(env) +@pytest.mark.parametrize( + "interface_name", + [ + pytest.param(None, id="default_name"), + pytest.param("tun11", id="explicit_name"), + ], +) +async def test_start_with_config(interface_name) -> None: + setup_params = _generate_setup_parameters([ + ConnectionTag.DOCKER_CONE_CLIENT_1, + ConnectionTag.DOCKER_CONE_CLIENT_2, + ]) + + async with AsyncExitStack() as exit_stack: + env = await setup_mesh_nodes(exit_stack, setup_params) + alpha_client, _ = env.clients + alpha, _ = env.nodes + router = alpha_client.get_router() + old_interface_name = router.get_interface_name() + + await ping_between_all_nodes(env) + await alpha_client.stop_device() + + # A `None` name leaves the current interface name to the client helper + await alpha_client.start_with_config(StartConfig(name=interface_name)) + new_interface_name = interface_name or old_interface_name + router.set_interface_name(new_interface_name) + await alpha_client.set_meshnet_config(env.api.get_meshnet_config(alpha.id)) + if new_interface_name != old_interface_name: + await router.delete_interface(old_interface_name) + await ping_between_all_nodes(env) + + @pytest.mark.windows async def test_start_named_ext_if_filter() -> None: async with AsyncExitStack() as exit_stack: diff --git a/nat-lab/tests/uniffi/libtelio_proxy.py b/nat-lab/tests/uniffi/libtelio_proxy.py index 40d270f84e..b072991aa1 100644 --- a/nat-lab/tests/uniffi/libtelio_proxy.py +++ b/nat-lab/tests/uniffi/libtelio_proxy.py @@ -125,6 +125,12 @@ def start_named_ext_if_filter( ) ) + @move_to_async_thread + def start_with_config(self, private_key, adapter, config: libtelio.StartConfig): + self._handle_remote_error( + lambda r: r.start_with_config(private_key, adapter.value, config) + ) + @move_to_async_thread def set_fwmark(self, fwmark: int): self._handle_remote_error(lambda r: r.set_fwmark(fwmark)) @@ -133,6 +139,10 @@ def set_fwmark(self, fwmark: int): def set_ext_if_filter(self, ext_if_list: List[str]): self._handle_remote_error(lambda r: r.set_ext_if_filter(ext_if_list)) + @move_to_async_thread + def set_adapter_mtu(self, mtu: int): + self._handle_remote_error(lambda r: r.set_adapter_mtu(mtu)) + @move_to_async_thread def set_tun(self, tun: int): self._handle_remote_error(lambda r: r.set_tun(tun)) diff --git a/nat-lab/tests/uniffi/libtelio_remote.py b/nat-lab/tests/uniffi/libtelio_remote.py index dc49355d07..dce9b637db 100644 --- a/nat-lab/tests/uniffi/libtelio_remote.py +++ b/nat-lab/tests/uniffi/libtelio_remote.py @@ -172,6 +172,12 @@ def start_named_ext_if_filter( private_key, libtelio.TelioAdapterType(adapter), name, ext_if_list ) + @serialize_error + def start_with_config(self, private_key, adapter, config: libtelio.StartConfig): + self._libtelio.start_with_config( + private_key, libtelio.TelioAdapterType(adapter), config + ) + @serialize_error def create_tun(self, tun_id: int) -> int: return create_tun(tun_id) @@ -190,6 +196,10 @@ def set_fwmark(self, fwmark: int): def set_ext_if_filter(self, ext_if_list: List[str]): self._libtelio.set_ext_if_filter(ext_if_list) + @serialize_error + def set_adapter_mtu(self, mtu: int): + self._libtelio.set_adapter_mtu(mtu) + @serialize_error def set_tun(self, tun: int): self._libtelio.set_tun(tun) From f0f4f500b4ab40a5665b47ccf6f25440b66525df Mon Sep 17 00:00:00 2001 From: Michal Ziobro Date: Mon, 7 Sep 2026 14:31:39 +0300 Subject: [PATCH 5/8] Add test for MTU setting in telio --- .unreleased/LLT-7068 | 1 + nat-lab/tests/libtelio_client/client.py | 2 +- nat-lab/tests/test_adapter.py | 233 ++++++++++++++++++++++++ nat-lab/tests/uniffi/libtelio_proxy.py | 2 +- nat-lab/tests/uniffi/libtelio_remote.py | 4 +- 5 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 .unreleased/LLT-7068 diff --git a/.unreleased/LLT-7068 b/.unreleased/LLT-7068 new file mode 100644 index 0000000000..ceaefbe62d --- /dev/null +++ b/.unreleased/LLT-7068 @@ -0,0 +1 @@ +Added the `mtu` start option and `set_adapter_mtu`, for setting the adapter interface MTU. Supported on Windows only. diff --git a/nat-lab/tests/libtelio_client/client.py b/nat-lab/tests/libtelio_client/client.py index de1a59c643..64fa03aa52 100644 --- a/nat-lab/tests/libtelio_client/client.py +++ b/nat-lab/tests/libtelio_client/client.py @@ -364,7 +364,7 @@ async def start_with_config(self, config: StartConfig): if isinstance(self.get_router(), LinuxRouter): await self.get_proxy().set_fwmark(int(LINUX_FWMARK_VALUE)) - async def set_adapter_mtu(self, mtu: int): + async def set_adapter_mtu(self, mtu: Optional[int]): await self.get_proxy().set_adapter_mtu(mtu) async def set_meshnet_config(self, meshnet_config: Config) -> None: diff --git a/nat-lab/tests/test_adapter.py b/nat-lab/tests/test_adapter.py index 710212df43..d04c4463e0 100644 --- a/nat-lab/tests/test_adapter.py +++ b/nat-lab/tests/test_adapter.py @@ -9,6 +9,7 @@ ErrorEvent, ErrorCode, ErrorLevel, + StartConfig, TelioAdapterType, default_features, ) @@ -27,6 +28,10 @@ class AdapterState(Enum): UP = 1 +MTU_POLL_ATTEMPTS = 20 +MTU_POLL_INTERVAL_S = 0.5 + + async def get_interface_state(client_conn, client): itf_name = client.get_router().get_interface_name() process = await client_conn.create_process([ @@ -45,6 +50,62 @@ async def get_interface_state(client_conn, client): raise RuntimeError(f'Unexpected adapter state: "{output}"') +async def get_interface_mtu(client_conn, client, address_family: str) -> int: + itf_name = client.get_router().get_interface_name() + process = await client_conn.create_process([ + "powershell", + "-Command", + f'(Get-NetIPInterface -InterfaceAlias "{itf_name}"' + f" -AddressFamily {address_family}).NlMtu", + ]).execute() + output = process.get_stdout().strip() + + if not output.isdigit(): + raise RuntimeError( + f'Unexpected {address_family} MTU for "{itf_name}": "{output}"' + ) + + return int(output) + + +async def get_interface_mtus(client_conn, client) -> dict[str, int]: + return { + family: await get_interface_mtu(client_conn, client, family) + for family in ("IPv4", "IPv6") + } + + +async def wait_for_interface_mtu(client_conn, client, expected_mtu: int) -> None: + await wait_for_interface_mtus( + client_conn, client, {"IPv4": expected_mtu, "IPv6": expected_mtu} + ) + + +async def wait_for_interface_mtus( + client_conn, client, expected: dict[str, int] +) -> None: + """ + Wait for the address families to report the MTUs in `expected`. + + The adapter sets each family separately and the interface watcher may + re-apply the MTU on interface events, so the value is not readable + immediately after the call that requested it. + """ + actual: dict = {} + + for _ in range(MTU_POLL_ATTEMPTS): + try: + actual = await get_interface_mtus(client_conn, client) + except (ProcessExecError, RuntimeError): + # The interface is missing or still settling after a restart + actual = {} + if actual == expected: + break + await asyncio.sleep(MTU_POLL_INTERVAL_S) + + assert actual == expected, f"Expected adapter MTUs {expected}, last read {actual}" + + @pytest.mark.parametrize( "alpha_setup_params", [ @@ -487,3 +548,175 @@ async def test_adapter_state_for_meshnet( await client_alpha.set_mesh_off() state = await get_interface_state(client_conn, client_alpha) assert state == expected_idle_state + + +@pytest.mark.windows +class TestAdapterMtu: + """Setting the adapter MTU, supported on Windows only.""" + + @pytest.fixture + def alpha_setup_params(self) -> SetupParameters: + return SetupParameters( + connection_tag=ConnectionTag.VM_WINDOWS_1, + adapter_type_override=TelioAdapterType.WINDOWS_NATIVE_TUN, + is_meshnet=False, + features=default_features(enable_dynamic_wg_nt_control=False), + ) + + async def test_starts_with_configured_mtu(self, env: Environment) -> None: + client_conn, *_ = [conn.connection for conn in env.connections] + client_alpha, *_ = env.clients + expected_mtu = 1360 + + await client_alpha.stop_device() + await client_alpha.start_with_config(StartConfig(mtu=expected_mtu)) + + await wait_for_interface_mtu(client_conn, client_alpha, expected_mtu) + + async def test_changes_mtu_at_runtime(self, env: Environment) -> None: + client_conn, *_ = [conn.connection for conn in env.connections] + client_alpha, *_ = env.clients + expected_mtu = 1320 + + for family in ("IPv4", "IPv6"): + current_mtu = await get_interface_mtu(client_conn, client_alpha, family) + assert current_mtu != expected_mtu + + await client_alpha.set_adapter_mtu(expected_mtu) + + await wait_for_interface_mtu(client_conn, client_alpha, expected_mtu) + + async def test_restores_automatic_mtu(self, env: Environment) -> None: + client_conn, *_ = [conn.connection for conn in env.connections] + client_alpha, *_ = env.clients + automatic_mtus = await get_interface_mtus(client_conn, client_alpha) + forced_mtu = 1320 + assert forced_mtu not in automatic_mtus.values() + + await client_alpha.set_adapter_mtu(forced_mtu) + await wait_for_interface_mtu(client_conn, client_alpha, forced_mtu) + + await client_alpha.set_adapter_mtu(None) + await wait_for_interface_mtus(client_conn, client_alpha, automatic_mtus) + + async def test_rejects_too_low_mtu(self, env: Environment) -> None: + client_conn, *_ = [conn.connection for conn in env.connections] + client_alpha, *_ = env.clients + current_mtus = await get_interface_mtus(client_conn, client_alpha) + + with pytest.raises(RuntimeError, match="MTU must be at least 1280"): + await client_alpha.set_adapter_mtu(1279) + + for family, mtu in current_mtus.items(): + assert await get_interface_mtu(client_conn, client_alpha, family) == mtu + + +@pytest.mark.windows +class TestAdapterMtuWithDynamicWgNtControl: + """A set MTU must survive the adapter going up and down.""" + + @pytest.fixture(name="vpn_tags") + def _vpn_tags(self) -> list: + return [ConnectionTag.DOCKER_VPN_1] + + @pytest.fixture + def alpha_setup_params(self) -> SetupParameters: + return SetupParameters( + connection_tag=ConnectionTag.VM_WINDOWS_1, + adapter_type_override=TelioAdapterType.WINDOWS_NATIVE_TUN, + connection_tracker_config=generate_connection_tracker_config( + connection_tag=ConnectionTag.VM_WINDOWS_1, + vpn_1_limits=(1, 1), + ), + is_meshnet=False, + features=default_features(enable_dynamic_wg_nt_control=True), + ) + + async def test_mtu_survives_adapter_state_changes(self, env: Environment) -> None: + client_conn, *_ = [conn.connection for conn in env.connections] + client_alpha, *_ = env.clients + expected_mtu = 1340 + + state = await get_interface_state(client_conn, client_alpha) + assert state == AdapterState.DOWN + + await client_alpha.set_adapter_mtu(expected_mtu) + await wait_for_interface_mtu(client_conn, client_alpha, expected_mtu) + + server_ip = config.WG_SERVER["ipv4"] + server_port = config.WG_SERVER["port"] + server_public_key = config.WG_SERVER["public_key"] + assert ( + isinstance(server_ip, str) + and isinstance(server_port, int) + and isinstance(server_public_key, str) + ) + await client_alpha.vpn.connect(server_ip, server_port, server_public_key) + + state = await get_interface_state(client_conn, client_alpha) + assert state == AdapterState.UP + await wait_for_interface_mtu(client_conn, client_alpha, expected_mtu) + + await client_alpha.vpn.disconnect(server_public_key) + + state = await get_interface_state(client_conn, client_alpha) + assert state == AdapterState.DOWN + await wait_for_interface_mtu(client_conn, client_alpha, expected_mtu) + + +@pytest.mark.parametrize( + "alpha_setup_params", + [ + pytest.param( + SetupParameters( + connection_tag=ConnectionTag.DOCKER_CONE_CLIENT_1, + adapter_type_override=TelioAdapterType.NEP_TUN, + is_meshnet=False, + ), + ), + pytest.param( + SetupParameters( + connection_tag=ConnectionTag.DOCKER_CONE_CLIENT_1, + adapter_type_override=TelioAdapterType.LINUX_NATIVE_TUN, + is_meshnet=False, + ), + marks=[pytest.mark.linux_native], + ), + pytest.param( + SetupParameters( + connection_tag=ConnectionTag.VM_MAC, + adapter_type_override=TelioAdapterType.NEP_TUN, + is_meshnet=False, + ), + marks=[pytest.mark.mac], + ), + ], +) +class TestAdapterMtuUnsupported: + """Only the Windows native adapter supports setting the MTU.""" + + async def test_rejects_mtu_at_runtime( + self, + alpha_setup_params: SetupParameters, # pylint: disable=unused-argument + env: Environment, + ) -> None: + client_alpha, *_ = env.clients + + with pytest.raises(RuntimeError, match="UnsupportedAdapter"): + await client_alpha.set_adapter_mtu(1340) + + async def test_rejects_mtu_at_start( + self, + alpha_setup_params: SetupParameters, # pylint: disable=unused-argument + env: Environment, + ) -> None: + client_alpha, *_ = env.clients + + await client_alpha.stop_device() + with pytest.raises( + RuntimeError, match="MTU is only supported by the Windows native adapter" + ): + await client_alpha.start_with_config(StartConfig(mtu=1340)) + + # Leave the device running for the test cleanup + await client_alpha.start_with_config(StartConfig()) diff --git a/nat-lab/tests/uniffi/libtelio_proxy.py b/nat-lab/tests/uniffi/libtelio_proxy.py index b072991aa1..676b3074a3 100644 --- a/nat-lab/tests/uniffi/libtelio_proxy.py +++ b/nat-lab/tests/uniffi/libtelio_proxy.py @@ -140,7 +140,7 @@ def set_ext_if_filter(self, ext_if_list: List[str]): self._handle_remote_error(lambda r: r.set_ext_if_filter(ext_if_list)) @move_to_async_thread - def set_adapter_mtu(self, mtu: int): + def set_adapter_mtu(self, mtu: Optional[int]): self._handle_remote_error(lambda r: r.set_adapter_mtu(mtu)) @move_to_async_thread diff --git a/nat-lab/tests/uniffi/libtelio_remote.py b/nat-lab/tests/uniffi/libtelio_remote.py index dce9b637db..6eb7f2816b 100644 --- a/nat-lab/tests/uniffi/libtelio_remote.py +++ b/nat-lab/tests/uniffi/libtelio_remote.py @@ -13,7 +13,7 @@ init_serialization, ) from threading import Lock -from typing import List, Tuple +from typing import List, Tuple, Optional REMOTE_LOG = "remote.log" TCLI_LOG = "tcli.log" @@ -197,7 +197,7 @@ def set_ext_if_filter(self, ext_if_list: List[str]): self._libtelio.set_ext_if_filter(ext_if_list) @serialize_error - def set_adapter_mtu(self, mtu: int): + def set_adapter_mtu(self, mtu: Optional[int]): self._libtelio.set_adapter_mtu(mtu) @serialize_error From 454183507f6a3871cae4aeeebb29bbd735072606 Mon Sep 17 00:00:00 2001 From: Michal Ziobro Date: Mon, 7 Sep 2026 19:36:30 +0300 Subject: [PATCH 6/8] Use default WG mocks in stun and UPnP --- .../src/endpoint_providers/stun.rs | 36 ++++--------------- .../src/endpoint_providers/upnp.rs | 33 +++-------------- 2 files changed, 11 insertions(+), 58 deletions(-) diff --git a/crates/telio-traversal/src/endpoint_providers/stun.rs b/crates/telio-traversal/src/endpoint_providers/stun.rs index 05126213f1..be6f6eb248 100644 --- a/crates/telio-traversal/src/endpoint_providers/stun.rs +++ b/crates/telio-traversal/src/endpoint_providers/stun.rs @@ -1027,7 +1027,6 @@ mod stun_msg { mod tests { use super::*; use maplit::hashmap; - use mockall::mock; use std::{ cell::RefCell, net::{Ipv4Addr, Ipv6Addr, SocketAddr}, @@ -1042,16 +1041,15 @@ mod tests { PublicKey, SecretKey, encryption::{decrypt_request, decrypt_response, encrypt_request, encrypt_response}, }; - use telio_model::mesh::{IpNet, LinkState}; + use telio_model::mesh::IpNet; use telio_proto::{CodecError, PacketRelayed, PartialPongerMsg, PingerMsg}; use telio_sockets::NativeProtector; use telio_sockets::SocketPool; use telio_task::io::Chan; use telio_test::await_timeout; use telio_utils::exponential_backoff::MockBackoff; - use telio_utils::ip_stack::IpStack; use telio_wg::{ - Error, + MockWireGuard, uapi::{Interface, Peer}, }; use tokio::{ @@ -1758,7 +1756,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn exponential_backoff_is_applied_even_if_session_start_failed() { - let mut wg = MockWg::new(); + let mut wg = MockWireGuard::new(); // Expect a single call to get_interface when we enter the loop first and // and a second in the finished backoff @@ -1800,7 +1798,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn wait_with_session_start_until_stun_server_wg_peer_is_available() { - let mut wg = MockWg::default(); + let mut wg = MockWireGuard::default(); let wg_port = 12345; let peer_sock_v4 = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)) @@ -1938,26 +1936,6 @@ mod tests { // Test helpers - mock! { - Wg {} - #[async_trait] - impl WireGuard for Wg { - async fn get_interface(&self) -> Result; - async fn get_adapter_luid(&self) -> Result; - async fn wait_for_listen_port(&self, d: Duration) -> Result; - async fn get_link_state(&self, key: PublicKey) -> Result, Error>; - async fn set_secret_key(&self, key: SecretKey) -> Result<(), Error>; - async fn set_fwmark(&self, fwmark: u32) -> Result<(), Error>; - async fn add_peer(&self, peer: Peer) -> Result<(), Error>; - async fn del_peer(&self, key: PublicKey) -> Result<(), Error>; - async fn drop_connected_sockets(&self) -> Result<(), Error>; - async fn time_since_last_rx(&self, public_key: PublicKey) -> Result, Error>; - async fn stop(self); - async fn reset_existing_connections(&self, exit_pubkey: PublicKey) -> Result<(), Error>; - async fn set_ip_stack(&self, ip_stack: Option) -> Result<(), Error>; - } - } - struct StunPeerSockets { /// This socket represent a socket that is listening in remote peer. /// We will not fake entire tunnel, as it correct behavior would basically @@ -1975,7 +1953,7 @@ mod tests { socket_pool: Arc, // Tested system - stun_provider: StunEndpointProvider, + stun_provider: StunEndpointProvider, ipv6: bool, // External behavior @@ -1999,7 +1977,7 @@ mod tests { server_weights: Vec, ipv6: bool, ) -> Env { - let mut wg = MockWg::default(); + let mut wg = MockWireGuard::default(); let wg_port = 12345; let mut stun_servers = Vec::::new(); @@ -2101,7 +2079,7 @@ mod tests { backoff_array: Option<[u64; 6]>, stun_servers: Vec, stun_peers: Vec, - wg: MockWg, + wg: MockWireGuard, ipv6: bool, ) -> Env { let socket_pool = SocketPool::new( diff --git a/crates/telio-traversal/src/endpoint_providers/upnp.rs b/crates/telio-traversal/src/endpoint_providers/upnp.rs index 569ff512e5..40a832ef97 100644 --- a/crates/telio-traversal/src/endpoint_providers/upnp.rs +++ b/crates/telio-traversal/src/endpoint_providers/upnp.rs @@ -818,7 +818,6 @@ impl Runtime for State { mod tests { use super::{ EPHEMERAL_PORT_RANGE, EndpointCandidate, MockUpnpEpCommands, UpnpEndpointProvider, - async_trait, }; use std::{ @@ -832,45 +831,21 @@ mod tests { use crate::endpoint_providers::Error; use crate::ping_pong_handler::PingPongHandler; use lazy_static::lazy_static; - use mockall::mock; use parking_lot::Mutex; use serial_test::serial; use telio_crypto::PublicKey; use telio_crypto::SecretKey; - use telio_model::mesh::LinkState; use telio_sockets::{NativeProtector, SocketPool}; use telio_utils::exponential_backoff::{ ExponentialBackoff, ExponentialBackoffBounds, MockBackoff, }; - use telio_utils::ip_stack::IpStack; use telio_wg::{ - Error as wgError, WireGuard, + MockWireGuard, uapi::{Interface, Peer}, }; use tokio::sync::Mutex as TMutex; type Result = std::result::Result; - type Result1 = std::result::Result; - - mock! { - pub Wg {} - #[async_trait] - impl WireGuard for Wg { - async fn get_interface(&self) -> Result1; - async fn get_adapter_luid(&self) -> Result1; - async fn wait_for_listen_port(&self, d: Duration) -> Result1; - async fn get_link_state(&self, key: PublicKey) -> Result1>; - async fn set_secret_key(&self, key: SecretKey) -> Result1<()>; - async fn set_fwmark(&self, fwmark: u32) -> Result1<()>; - async fn add_peer(&self, peer: Peer) -> Result1<()>; - async fn del_peer(&self, key: PublicKey) -> Result1<()>; - async fn drop_connected_sockets(&self) -> Result1<()>; - async fn time_since_last_rx(&self, public_key: PublicKey) -> Result1>; - async fn stop(self); - async fn reset_existing_connections(&self, exit_pubkey: PublicKey) -> Result1<()>; - async fn set_ip_stack(&self, ip_stack: Option) -> Result1<()>; - } - } lazy_static! { static ref IGD_IS_AVAILABLE: Arc> = Arc::new(Mutex::new(false)); @@ -921,7 +896,7 @@ mod tests { pub async fn prepare_test_setup( is_battery_optimization_on: bool, - ) -> UpnpEndpointProvider { + ) -> UpnpEndpointProvider { let spool = SocketPool::new( NativeProtector::new( #[cfg(target_os = "macos")] @@ -953,7 +928,7 @@ mod tests { epc.udp.set_port(2000); // These are not properly used yet, just dummy variables - let mut wg = MockWg::default(); + let mut wg = MockWireGuard::default(); let wg_port = 55345; let wg_peers = Vec::<(PublicKey, Peer)>::new(); let backoff_array = [100, 200, 400, 800, 1600, 3200]; @@ -1107,7 +1082,7 @@ mod tests { } // These are not properly used yet, just dummy variables - let mut wg = MockWg::default(); + let mut wg = MockWireGuard::default(); let wg_port = 55345; wg.expect_get_interface().returning(move || { From 52d71ea25e3d352eafcfa31ee61f7151c3046572 Mon Sep 17 00:00:00 2001 From: Michal Ziobro Date: Tue, 8 Sep 2026 11:49:35 +0300 Subject: [PATCH 7/8] Implement MTU setting for WireguardNT adapter --- crates/telio-wg/src/adapter.rs | 8 ++ .../telio-wg/src/adapter/windows_native_wg.rs | 66 ++++++++++- crates/telio-wg/src/wg.rs | 109 +++++++++++++++++- .../src/windows/tunnel/interfacewatcher.rs | 100 +++++++++++----- .../telio-wg/src/windows/tunnel/mtumonitor.rs | 26 ++--- nat-lab/tests/test_adapter.py | 2 +- 6 files changed, 266 insertions(+), 45 deletions(-) diff --git a/crates/telio-wg/src/adapter.rs b/crates/telio-wg/src/adapter.rs index 1bf97ed05b..b9c571b234 100644 --- a/crates/telio-wg/src/adapter.rs +++ b/crates/telio-wg/src/adapter.rs @@ -112,6 +112,9 @@ pub trait Adapter: Send + Sync { } } +/// IPv6 minimum link MTU, the interface MTU applies to both address families +pub const MIN_MTU: u32 = 1280; + /// Enumeration of `Error` types for `Adapter` struct #[derive(Debug, TError)] pub enum Error { @@ -136,6 +139,10 @@ pub enum Error { #[error("Unsupported adapter")] UnsupportedAdapter, + /// MTU below the minimum any adapter accepts + #[error("MTU must be at least {min}, got {0}", min = MIN_MTU)] + MtuTooLow(u32), + /// Unsupported on Windows adapter #[error("Mismatched windows adapter")] MismatchedWindowsAdapter, @@ -308,6 +315,7 @@ pub(crate) async fn start(cfg: Config) -> Result, Error> { &name, cfg.enable_dynamic_wg_nt_control, cfg.enable_wg_nt_guid_rotation, + cfg.mtu, ) .await?, )) diff --git a/crates/telio-wg/src/adapter/windows_native_wg.rs b/crates/telio-wg/src/adapter/windows_native_wg.rs index 262e6ad795..d38d3a7d12 100644 --- a/crates/telio-wg/src/adapter/windows_native_wg.rs +++ b/crates/telio-wg/src/adapter/windows_native_wg.rs @@ -1,7 +1,10 @@ use super::{Adapter, Error as AdapterError, IsMeshnetEnabledCb, Tun as NativeTun}; use crate::{ uapi::{Cmd, Cmd::Get, Cmd::Set, Interface, Peer, Response}, - windows::{service, tunnel::interfacewatcher::InterfaceWatcher}, + windows::{ + service, + tunnel::{interfacewatcher::InterfaceWatcher, mtumonitor::set_interface_mtu}, + }, }; use async_trait::async_trait; use sha2::{Digest, Sha256}; @@ -30,6 +33,7 @@ use tokio::sync::Notify; use tokio::time::{sleep, timeout}; use utf16_lit::utf16_null; use uuid::Uuid; +use winapi::shared::ws2def::{ADDRESS_FAMILY, AF_INET, AF_INET6}; use windows::core::GUID; use windows::core::PCWSTR; use windows::Win32::Devices::DeviceAndDriverInstallation::GUID_DEVCLASS_NET; @@ -550,6 +554,7 @@ impl WindowsNativeWg { name: &str, enable_dynamic_wg_nt_control: IsMeshnetEnabledCb, enable_guid_rotation: bool, + mtu: Option, ) -> std::result::Result { const SWD_WIREGUARD: &str = r"SYSTEM\CurrentControlSet\Enum\SWD\WireGuard"; telio_log_debug!("Print registry before adapter creation!"); @@ -645,6 +650,10 @@ impl WindowsNativeWg { ))); } + if mtu.is_some() { + wg_dev.set_adapter_mtu_inner(mtu)?; + } + if wg_dev.enable_dynamic_wg_nt_control.is_none() { wg_dev.ensure_adapter_state(AdapterState::Up).await?; } @@ -831,6 +840,57 @@ impl WindowsNativeWg { } } } + + fn set_adapter_mtu_inner(&self, mtu: Option) -> std::result::Result<(), AdapterError> { + // The watcher stops the MTU monitors first, so they cannot overwrite the + // value set below, or restarts them when the forced MTU is cleared + let previous = self.set_forced_mtu(mtu)?; + let Some(mtu) = mtu else { + return Ok(()); + }; + + // An address family whose interface is not up yet gets the MTU from the + // interface watcher once it appears, so fail only when both fail + let mut failed = false; + for family in [AF_INET as ADDRESS_FAMILY, AF_INET6 as ADDRESS_FAMILY] { + match set_interface_mtu(self.luid, family, mtu) { + Ok(()) => telio_log_info!("Set adapter MTU {} for family {}", mtu, family), + Err(err) => { + telio_log_warn!( + "Failed to set adapter MTU {} for family {}: {}", + mtu, + family, + err + ); + if failed { + // Neither interface changed, so put the watcher back the way it + // was: the previous forced value, or the default route monitors + if let Err(restore_err) = self.set_forced_mtu(previous) { + telio_log_warn!( + "Failed to restore forced MTU {previous:?}: {restore_err}" + ); + } + return Err(AdapterError::WindowsNativeWg(Error::Fail(format!( + "Failed to set adapter MTU {mtu}, last error: {err}", + )))); + } + failed = true; + } + } + } + + Ok(()) + } + + /// Forward the forced MTU to the interface watcher, returning the previous value + fn set_forced_mtu(&self, mtu: Option) -> std::result::Result, AdapterError> { + match self.watcher.clone().lock() { + Ok(mut interface_watcher) => Ok(interface_watcher.set_forced_mtu(mtu)), + Err(_) => Err(AdapterError::WindowsNativeWg(Error::Fail( + "error obtaining lock".into(), + ))), + } + } } #[async_trait::async_trait] @@ -901,6 +961,10 @@ impl Adapter for WindowsNativeWg { Err(AdapterError::UnsupportedAdapter) } + async fn set_adapter_mtu(&self, mtu: Option) -> std::result::Result<(), AdapterError> { + self.set_adapter_mtu_inner(mtu) + } + fn clone_box(&self) -> Option> { None } diff --git a/crates/telio-wg/src/wg.rs b/crates/telio-wg/src/wg.rs index b0146f9a96..c1cc0f2b6b 100644 --- a/crates/telio-wg/src/wg.rs +++ b/crates/telio-wg/src/wg.rs @@ -77,8 +77,8 @@ pub trait WireGuard: Send + Sync + 'static { async fn reset_existing_connections(&self, exit_pubkey: PublicKey) -> Result<(), Error>; /// Set the ip stack for the adapter async fn set_ip_stack(&self, ip_stack: Option) -> Result<(), Error>; - /// Set the MTU of the adapter interface - async fn set_adapter_mtu(&self, mtu: u32) -> Result<(), Error>; + /// Set the MTU of the adapter interface, `None` restores the adapter's own handling + async fn set_adapter_mtu(&self, mtu: Option) -> Result<(), Error>; /// Ensure that adapter is UP or DOWN async fn ensure_expected_adapter_state( &self, @@ -89,6 +89,13 @@ pub trait WireGuard: Send + Sync + 'static { } } +fn check_mtu(mtu: u32) -> Result<(), Error> { + if mtu < adapter::MIN_MTU { + return Err(Error::MtuTooLow(mtu)); + } + Ok(()) +} + /// WireGuard implementation allowing dynamic selection of implementation. pub struct DynamicWg { task: Task, @@ -118,7 +125,8 @@ pub struct Config { /// When adapter creation fails, retry with a different GUID from a small fixed pool /// instead of reusing the same one. Windows only. pub enable_wg_nt_guid_rotation: bool, - /// MTU to set on the adapter interface, if None the adapter picks its own + /// MTU to set on the adapter interface, at least [adapter::MIN_MTU]. If None the + /// adapter picks its own pub mtu: Option, /// Configurable socket buffer size, if None doesn't modify default OS set values pub skt_buffer_size: Option, @@ -264,6 +272,9 @@ impl DynamicWg { where Self: Sized, { + if let Some(mtu) = cfg.mtu { + check_mtu(mtu)?; + } let adapter = Self::start_adapter(cfg.try_clone()?).await?; #[cfg(unix)] return Ok(Self::start_with( @@ -483,7 +494,10 @@ impl WireGuard for DynamicWg { .await?) } - async fn set_adapter_mtu(&self, mtu: u32) -> Result<(), Error> { + async fn set_adapter_mtu(&self, mtu: Option) -> Result<(), Error> { + if let Some(mtu) = mtu { + check_mtu(mtu)?; + } task_exec!(&self.task, async move |s| Ok(s .adapter .set_adapter_mtu(mtu) @@ -1167,6 +1181,10 @@ pub mod tests { Err(Error::UnsupportedAdapter) } + async fn set_adapter_mtu(&self, mtu: Option) -> Result<(), AdapterError> { + self.lock().await.set_adapter_mtu(mtu).await + } + fn clone_box(&self) -> Option> { None } @@ -1363,6 +1381,89 @@ pub mod tests { wg.stop().await; } + #[tokio::test(start_paused = true)] + async fn wg_sets_adapter_mtu() { + let Env { adapter, wg, .. } = setup().await; + + adapter + .lock() + .await + .expect_set_adapter_mtu() + .with(predicate::eq(Some(1400))) + .times(1) + .returning(|_| Ok(())); + wg.set_adapter_mtu(Some(1400)).await.unwrap(); + adapter.lock().await.checkpoint(); + + adapter + .lock() + .await + .expect_set_adapter_mtu() + .with(predicate::eq(None)) + .times(1) + .returning(|_| Ok(())); + wg.set_adapter_mtu(None).await.unwrap(); + adapter.lock().await.checkpoint(); + + adapter.lock().await.expect_stop().return_once(|| ()); + wg.stop().await; + } + + #[tokio::test(start_paused = true)] + async fn wg_rejects_too_low_adapter_mtu_before_reaching_adapter() { + let Env { adapter, wg, .. } = setup().await; + + assert!(matches!( + wg.set_adapter_mtu(Some(adapter::MIN_MTU - 1)).await, + Err(Error::MtuTooLow(_)) + )); + + adapter.lock().await.expect_stop().return_once(|| ()); + wg.stop().await; + } + + #[cfg(unix)] + #[tokio::test] + async fn wg_rejects_too_low_mtu_on_start() { + let cfg = Config { + mtu: Some(adapter::MIN_MTU - 1), + ..Config::new().unwrap() + }; + let io = Io { + events: Chan::default().tx, + analytics_tx: None, + libtelio_wide_event_publisher: None, + }; + let result = DynamicWg::start( + io, + cfg, + None, + Duration::from_millis(DEFAULT_POLLING_PERIOD_MS), + Duration::from_millis(DEFAULT_POLLING_PERIOD_AFTER_UPDATE_MS), + ) + .await; + assert!(matches!(result, Err(Error::MtuTooLow(_)))); + } + + #[tokio::test(start_paused = true)] + async fn wg_set_adapter_mtu_propagates_adapter_error() { + let Env { adapter, wg, .. } = setup().await; + + adapter + .lock() + .await + .expect_set_adapter_mtu() + .times(1) + .returning(|_| Err(AdapterError::UnsupportedAdapter)); + assert!(matches!( + wg.set_adapter_mtu(Some(1400)).await, + Err(Error::UnsupportedAdapter) + )); + + adapter.lock().await.expect_stop().return_once(|| ()); + wg.stop().await; + } + #[tokio::test(start_paused = true)] async fn wg_adds_peer() { let Env { diff --git a/crates/telio-wg/src/windows/tunnel/interfacewatcher.rs b/crates/telio-wg/src/windows/tunnel/interfacewatcher.rs index c1b7c105a0..81635a4503 100644 --- a/crates/telio-wg/src/windows/tunnel/interfacewatcher.rs +++ b/crates/telio-wg/src/windows/tunnel/interfacewatcher.rs @@ -12,7 +12,7 @@ // use super::addressconfig; -use super::mtumonitor::MtuMonitor; +use super::mtumonitor::{set_interface_mtu, MtuMonitor}; use crate::{adapter::IsMeshnetEnabledCb, windows::cleanup::*}; use std::sync::{Arc, Mutex}; use telio_utils::{ @@ -46,6 +46,8 @@ struct AdapterConfiguration { stored_events: Vec, last_known_config: Option>, + // MTU requested via the API, disables the default route MTU monitoring + forced_mtu: Option, mtu_monitor: Vec>>, } @@ -84,6 +86,7 @@ impl AdapterConfiguration { adapter: None, stored_events: Vec::new(), last_known_config: None, + forced_mtu: None, mtu_monitor: Vec::new(), } } @@ -141,12 +144,8 @@ impl InterfaceWatcher { } } - if let Ok(watched_adapter) = self.watched_adapter.clone().lock() { - for mtu_monitor in watched_adapter.mtu_monitor.as_slice() { - if let Ok(mut mtumon) = mtu_monitor.clone().lock() { - mtumon.stop(); - } - } + if let Ok(mut watched_adapter) = self.watched_adapter.clone().lock() { + Self::stop_mtu_monitors(&mut watched_adapter); } else { telio_log_error!("error obtaining lock"); } @@ -154,6 +153,33 @@ impl InterfaceWatcher { telio_log_trace!("--- InterfaceWatcher::stop"); } + /// Force the interface MTU, or go back to following the default route MTU with `None`. + /// + /// Returns the previously forced MTU, so a caller can restore it when applying the + /// new one fails. Returns `None` when the lock could not be taken. + pub fn set_forced_mtu(&mut self, mtu: Option) -> Option { + telio_log_trace!("+++ InterfaceWatcher::set_forced_mtu"); + + let previous = if let Ok(mut watched_adapter) = self.watched_adapter.clone().lock() { + let previous = std::mem::replace(&mut watched_adapter.forced_mtu, mtu); + Self::stop_mtu_monitors(&mut watched_adapter); + if mtu.is_none() { + // A family whose interface is not up yet fails here and gets + // its monitor from `setup` once it appears + for family in [AF_INET as ADDRESS_FAMILY, AF_INET6 as ADDRESS_FAMILY] { + Self::start_mtu_monitor(&mut watched_adapter, family); + } + } + previous + } else { + telio_log_error!("error obtaining lock"); + None + }; + + telio_log_trace!("--- InterfaceWatcher::set_forced_mtu"); + previous + } + pub fn configure(&mut self, adapter: Arc, luid: u64) { telio_log_trace!("+++ InterfaceWatcher::configure"); @@ -223,35 +249,57 @@ impl InterfaceWatcher { telio_log_trace!("--- InterfaceWatcher::clear_last_known_configuration"); } - fn setup(watched_adapter: &mut AdapterConfiguration, family: ADDRESS_FAMILY) { - telio_log_trace!("+++ InterfaceWatcher::setup"); + fn stop_mtu_monitors(watched_adapter: &mut AdapterConfiguration) { + for mtu_monitor in watched_adapter.mtu_monitor.as_slice() { + if let Ok(mut mtumon) = mtu_monitor.clone().lock() { + mtumon.stop(); + } + } + watched_adapter.mtu_monitor.clear(); + } - let family_str: String = match family as i32 { + fn family_name(family: ADDRESS_FAMILY) -> String { + match family as i32 { AF_INET => "IPv4".to_string(), AF_INET6 => "IPv6".to_string(), _ => format!("unk {family}"), + } + } + + fn start_mtu_monitor(watched_adapter: &mut AdapterConfiguration, family: ADDRESS_FAMILY) { + let family_str = Self::family_name(family); + telio_log_info!("Monitoring MTU of default routes for {}", family_str); + let arc_mtu_monitor = Arc::new(Mutex::new(MtuMonitor::new(watched_adapter.luid, family))); + if let Ok(mut mtu_monitor) = arc_mtu_monitor.lock() { + match mtu_monitor.start_monitoring() { + Ok(_) => { + watched_adapter.mtu_monitor.push(arc_mtu_monitor.clone()); + } + Err(err) => { + // TODO: collect and broadcast errors? + // iw.errors <- interfaceWatcherError{services.ErrorMonitorMTUChanges, err} + telio_log_debug!("Not monitoring MTU for {}: {}", family_str, err); + } + } }; + } + + fn setup(watched_adapter: &mut AdapterConfiguration, family: ADDRESS_FAMILY) { + telio_log_trace!("+++ InterfaceWatcher::setup"); + + let family_str = Self::family_name(family); // TODO: we have successfully started the adapter, now stop watchdog // iw.watchdog.Stop() // OPTWGWINCONF: use MtuMonitor to dynamically adjust the MTU size, if it wasn't forced by config. - // if iw.conf.Interface.MTU == 0 - { - telio_log_info!("Monitoring MTU of default routes for {}", family_str); - let arc_mtu_monitor = - Arc::new(Mutex::new(MtuMonitor::new(watched_adapter.luid, family))); - if let Ok(mut mtu_monitor) = arc_mtu_monitor.lock() { - match mtu_monitor.start_monitoring() { - Ok(_) => { - watched_adapter.mtu_monitor.push(arc_mtu_monitor.clone()); - } - Err(_err) => { - // TODO: collect and broadcast errors? - // iw.errors <- interfaceWatcherError{services.ErrorMonitorMTUChanges, err} - } - } - }; + if let Some(mtu) = watched_adapter.forced_mtu { + telio_log_info!("Setting forced MTU {} for {}", mtu, family_str); + if let Err(err) = set_interface_mtu(watched_adapter.luid, family, mtu) { + telio_log_error!("Failed to set forced MTU for {}: {}", family_str, err); + } + } else { + Self::start_mtu_monitor(watched_adapter, family); } if let Some(last_known_config) = &watched_adapter.last_known_config { diff --git a/crates/telio-wg/src/windows/tunnel/mtumonitor.rs b/crates/telio-wg/src/windows/tunnel/mtumonitor.rs index 5a052bdc20..2f22d482ad 100644 --- a/crates/telio-wg/src/windows/tunnel/mtumonitor.rs +++ b/crates/telio-wg/src/windows/tunnel/mtumonitor.rs @@ -206,19 +206,11 @@ impl MtuMonitor { } if mtu > 0 && *last_mtu != mtu { - let own_luid = InterfaceLuid::new(self.own_luid); - telio_log_trace!("+++ MtuMonitor::do_it: get_ip_interface"); - let mut own_iface = own_luid.get_ip_interface(self.family)?; - own_iface.NlMtu = mtu.saturating_sub(80); - if own_iface.NlMtu < self.min_mtu { - own_iface.NlMtu = self.min_mtu; - } - - telio_log_trace!("+++ MtuMonitor::do_it: SetIpInterfaceEntry"); - let result = unsafe { SetIpInterfaceEntry(&mut own_iface) }; - if NO_ERROR != result { - return Err(result); - } + set_interface_mtu( + self.own_luid, + self.family, + mtu.saturating_sub(80).max(self.min_mtu), + )?; *last_mtu = mtu; } @@ -285,6 +277,14 @@ impl MtuMonitor { } } +/// Set the MTU of the `family` IP interface of the adapter identified by `luid` +pub fn set_interface_mtu(luid: u64, family: ADDRESS_FAMILY, mtu: u32) -> Result<(), NETIO_STATUS> { + let luid = InterfaceLuid::new(luid); + let mut iface = luid.get_ip_interface(family)?; + iface.NlMtu = mtu; + luid.set_ip_interface(&mut iface) +} + #[cfg(windows)] impl Drop for MtuMonitor { fn drop(&mut self) { diff --git a/nat-lab/tests/test_adapter.py b/nat-lab/tests/test_adapter.py index d04c4463e0..7e4a52c46a 100644 --- a/nat-lab/tests/test_adapter.py +++ b/nat-lab/tests/test_adapter.py @@ -604,7 +604,7 @@ async def test_rejects_too_low_mtu(self, env: Environment) -> None: client_alpha, *_ = env.clients current_mtus = await get_interface_mtus(client_conn, client_alpha) - with pytest.raises(RuntimeError, match="MTU must be at least 1280"): + with pytest.raises(RuntimeError, match="MtuTooLow"): await client_alpha.set_adapter_mtu(1279) for family, mtu in current_mtus.items(): From b6e7de563fea6cde197d1c9aac1225e141bacd7f Mon Sep 17 00:00:00 2001 From: Michal Ziobro Date: Tue, 8 Sep 2026 12:21:57 +0300 Subject: [PATCH 8/8] Update docs --- README.md | 9 ++++++++- src/doc/integrating_telio.md | 15 ++++++++++----- src/doc/introduction.md | 5 ++++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8894de7dce..3018494211 100644 --- a/README.md +++ b/README.md @@ -320,8 +320,11 @@ We need to provide an instance of the `DeviceConfig` structure: pub struct DeviceConfig { pub private_key: SecretKey, pub adapter: AdapterType, + pub fwmark: Option, pub name: Option, pub tun: Option, + pub ext_if_filter: Option>, + pub mtu: Option, } ``` @@ -329,8 +332,12 @@ Let's discuss its fields shortly: - `private_key` a `telio::crypto::SecretKey` instance containing a 256-bit key, - `adapter` indicating which Wireguard implementation we want to use, +- `fwmark` the firewall mark to set on the sockets opened by Telio, Linux only, - `name` is the name of the network interface, when omitted, Telio uses the default one, -- `tun` a file descriptor of the already opened tunnel, if it's not provided Telio will open a new one. +- `tun` a file descriptor of the already opened tunnel, if it's not provided Telio will open a new one, +- `ext_if_filter` names of the interfaces to skip while looking for the default interface, +- `mtu` the MTU of the adapter interface, Windows native adapter only. It can also be + changed on a running device with `Device::set_adapter_mtu`. The API provides a default config which is almost sufficient for simple cases, the only need that needs to be done is the generation of a private key: diff --git a/src/doc/integrating_telio.md b/src/doc/integrating_telio.md index 01a765e626..0b7f5873f0 100644 --- a/src/doc/integrating_telio.md +++ b/src/doc/integrating_telio.md @@ -337,10 +337,11 @@ let adapter = get_default_adapter(); let telio = Telio::new(Default::default(), Box::new(EventHandler)).unwrap(); -// There are three ways to start telio: +// There are four ways to start telio: // * start - telio does everything // * start_with_tun - use existing tun (android, apple) // * start_with_name - create tun with name (windows, linux) +// * start_with_config - like start, with optional settings (name, ext_if_filter, mtu, tun) telio.start(sk, adapter).unwrap(); telio.stop().unwrap(); @@ -355,10 +356,11 @@ adapter := GetDefaultAdapter() telio, err := Telio {...} -// There are three ways to start telio: +// There are four ways to start telio: // * Start - telio does everything // * StartWithTun - use existing tun (android, apple) // * StartWithName - create tun with name (windows, linux) +// * StartWithConfig - like start, with optional settings (name, ext_if_filter, mtu, tun) _, err = telio.Start(sk, adapter) _, err = telio.Stop() @@ -373,10 +375,11 @@ let adapter = getDefaultAdapter() let telio = Telio(...) -// There are three ways to start telio: +// There are four ways to start telio: // * start - telio does everything // * startWithTun - use existing tun (android, apple) // * startWithName - create tun with name (windows, linux) +// * startWithConfig - like start, with optional settings (name, ext_if_filter, mtu, tun) telio.start(sk, adapter) telio.stop() @@ -391,10 +394,11 @@ var adapter = GetDefaultAdapter(); Telio telio = new Telio(...); -// There are three ways to start telio: +// There are four ways to start telio: // * Start - telio does everything // * StartWithTun - use existing tun (android, apple) // * StartWithName - create tun with name (windows, linux) +// * StartWithConfig - like start, with optional settings (name, ext_if_filter, mtu, tun) telio.Start(sk, adapter); telio.Stop(); @@ -409,10 +413,11 @@ val adapter = getDefaultAdapter() val telio = Telio.new(...)!! -// There are three ways to start telio: +// There are four ways to start telio: // * start - telio does everything // * startWithTun - use existing tun (android, apple) // * startWithName - create tun with name (windows, linux) +// * startWithConfig - like start, with optional settings (name, ext_if_filter, mtu, tun) telio.start(sk, adapter)!! telio.stop()!! diff --git a/src/doc/introduction.md b/src/doc/introduction.md index eda0fa1c07..a541903f12 100644 --- a/src/doc/introduction.md +++ b/src/doc/introduction.md @@ -86,6 +86,7 @@ pub struct DeviceConfig { pub name: Option, pub tun: Option, pub ext_if_filter: Option>, + pub mtu: Option, } ``` @@ -96,7 +97,9 @@ Let's discuss its fields shortly: - `fwmark` the firewall mark to set on the sockets opened by Telio, Linux only, - `name` is the name of the network interface, when omitted, Telio uses the default one, - `tun` a file descriptor of the already opened tunnel, if it's not provided Telio will open a new one, -- `ext_if_filter` names of the interfaces to skip while looking for the default interface. +- `ext_if_filter` names of the interfaces to skip while looking for the default interface, +- `mtu` the MTU of the adapter interface, Windows native adapter only. It can also be + changed on a running device with `Device::set_adapter_mtu`. The API provides a default config which is almost sufficient for simple cases, the only need that needs to be done is the generation of a private key: