Files
EasyTier/easytier/src/instance/shared_virtual_nic/tests.rs
T
KKRainbow d25ccd4c17 fix(windows): reuse numbered TUN names after complete shutdown (#2694)
* fix(windows): reuse stable TUN identities after complete shutdown

Wait for NIC forwarding and packet-drain tasks to release their handles
before stopping or replacing the interface. Stop the old static-IP NIC
before entering its recreation path. Store typed NIC state so shutdown
can await the owned tasks instead of only cancelling them on drop.

Use the instance UUID for the default Windows interface name and derive
a stable Wintun GUID from the device name. Keep explicit names and their
existing sharing behavior, while generated names remain dedicated. Keep
resolved names out of the input flags so recreation preserves that
choice. Pin rust-tun to the fix that joins its reader during session
drop.

Remove the global NetworkList cleanup that deleted profiles based only
on their names. Stable identities avoid creating a new profile for each
restart without guessing which existing records are safe to delete.

Add regression tests for receiver release and Windows instance restarts
with another dedicated or shared-NIC member still running.

Validation: formatting and workspace Clippy pass; 66 NIC, shared-NIC and
TUN runtime tests pass in the privileged rust container.

Fixes #2682 Related: #110

* fix(windows): allocate the first unused numbered TUN name

Use et_1, et_2 and subsequent vacant slots for default Windows NICs.
Reserve each name across threads and processes with a named kernel
object, retaining it through firewall cleanup and releasing it on exit.
Check the current device tree to skip disabled adapters without treating
removed adapters in the IP interface tables as occupied slots.

Keep GUIDs derived from names and preserve explicit NIC sharing. Cover
concurrent allocation, case-insensitive reservations and slot reuse.

* fix(windows): release idle TUN names across cached and service owners

Release a shared NIC name after its dispatcher has stopped and its
firewall rules have been removed, even when the registry caches the NIC.
Clear the old interface name so a later cache drop cannot remove rules
belonging to its replacement. Await dispatcher teardown on control
failure and timeout as well as on successful shutdown.

Grant only SYNCHRONIZE access to name reservations across accounts so
SYSTEM services and desktop processes can skip occupied slots.
Cover idle registry reuse, firewall ownership and failed-control cleanup
with regression tests.

* test(tun): use the renamed dispatcher task handle on Unix

Update the two Unix-only teardown tests to match the task field used
by explicit dispatcher shutdown.

* fix(windows): report firewall cleanup failures and verify all rules

Inspect all eight interface rules, including the ALL protocol pair.
Treat only a missing rule as absent and propagate other lookup errors.
Attempt every rule deletion and report failures with rule names instead
of silently returning success after a permission or service error.

Validate rule protocols, directions, profiles, interface binding and
allow settings on Windows. Cover repeated add/remove, unprivileged
failures, full cleanup and shared-NIC rule ownership across restarts.

Pin tun-easytier to merged v2 commit
5ba6be073e5b486b68c12f80fac5cfb50601f024 and update Cargo.lock. Its tree
matches the previously tested PR commit.
2026-10-14 13:43:35 +08:00

397 lines
14 KiB
Rust

use std::sync::Mutex as StdMutex;
use crate::common::{ifcfg::IfConfiguerTrait, netns::NetNS};
use super::*;
const MEMBER: SharedVirtualNicMemberId = uuid::Uuid::from_u128(1);
const REGISTRATION: SharedVirtualNicMemberRegistrationId = uuid::Uuid::from_u128(10);
#[cfg(target_os = "windows")]
#[tokio::test]
#[ignore = "requires Administrator privileges and wintun.dll"]
async fn idle_shared_nic_releases_name_while_registry_remains_alive() {
use crate::{
arch::windows::list_interface_firewall_rules,
common::config::{ConfigLoader, TomlConfigLoader},
instance::test_instance::TestInstance,
};
use easytier_core::process_runtime::CoreProcessRuntime;
use std::time::Duration;
let name = "et_test_idle_shared";
let config = || {
let config =
TomlConfigLoader::new_from_str("ipv4 = \"198.18.28.1/30\"\nlisteners = []").unwrap();
let mut flags = config.get_flags();
flags.dev_name = name.to_owned();
config.set_flags(flags);
config
};
let registry = TestInstance::new_shared_virtual_nic_registry();
let mut first = TestInstance::new_with_process_runtime_and_shared_virtual_nic_registry(
config(),
CoreProcessRuntime::new(),
registry.clone(),
);
first.run().await.unwrap();
assert_eq!(list_interface_firewall_rules(name).unwrap().len(), 8);
let cached = registry
.lock()
.await
.get_by_dev_name_for_test(name)
.unwrap();
first.clear_resources().await;
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let nic = cached.lock().await;
if nic.member_registrations.is_empty() && nic.dispatcher.is_none() {
break;
}
drop(nic);
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(list_interface_firewall_rules(name).unwrap().is_empty());
// An independent registry has the same name-allocation boundary as a
// separate process. The previous registry deliberately remains alive.
let mut second = TestInstance::new_with_process_runtime_and_shared_virtual_nic_registry(
config(),
CoreProcessRuntime::new(),
TestInstance::new_shared_virtual_nic_registry(),
);
second.run().await.unwrap();
assert_eq!(second.get_global_ctx().get_tun_device_name().unwrap(), name);
let rules = list_interface_firewall_rules(name).unwrap();
assert_eq!(rules.len(), 8);
drop(first);
drop(registry);
drop(cached);
assert_eq!(list_interface_firewall_rules(name).unwrap(), rules);
second.clear_resources().await;
tokio::time::timeout(Duration::from_secs(5), async {
while !list_interface_firewall_rules(name).unwrap().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
}
fn virtual_nic_config(mtu: u32, netns: Option<&str>) -> VirtualNicConfig {
VirtualNicConfig::new("et0".to_owned(), mtu, NetNS::new(netns.map(str::to_owned)))
}
fn address_claims(ip: &str) -> SharedIfConfigClaims {
SharedIfConfigClaims {
ipv4_addresses: BTreeSet::from([ip.parse().unwrap()]),
..Default::default()
}
}
fn registered_nic() -> SharedVirtualNic {
let mut nic = SharedVirtualNic::new(virtual_nic_config(1500, None));
nic.member_registrations.insert(MEMBER, REGISTRATION);
nic.ifcfg
.apply_member_claims(MEMBER, address_claims("10.50.0.2/24"));
nic
}
#[derive(Debug, PartialEq, Eq)]
enum RouteChange {
Add(SharedIpv4Route, Option<Ipv4Addr>),
Remove(SharedIpv4Route, Option<Ipv4Addr>),
}
#[derive(Clone, Default)]
struct RecordingIfConfiger {
routes: Arc<StdMutex<Vec<RouteChange>>>,
fail_remove_ip: bool,
}
#[async_trait::async_trait]
impl IfConfiguerTrait for RecordingIfConfiger {
async fn add_ipv4_route_with_source_hint(
&self,
_name: &str,
address: Ipv4Addr,
prefix: u8,
cost: Option<i32>,
source_hint: Option<Ipv4Addr>,
) -> Result<(), Error> {
self.routes.lock().unwrap().push(RouteChange::Add(
SharedIpv4Route::new(address, prefix, cost),
source_hint,
));
Ok(())
}
async fn remove_ipv4_route_with_cost_and_source_hint(
&self,
_name: &str,
address: Ipv4Addr,
prefix: u8,
cost: Option<i32>,
source_hint: Option<Ipv4Addr>,
) -> Result<(), Error> {
self.routes.lock().unwrap().push(RouteChange::Remove(
SharedIpv4Route::new(address, prefix, cost),
source_hint,
));
Ok(())
}
async fn remove_ip(&self, _name: &str, _ip: Option<Ipv4Inet>) -> Result<(), Error> {
if self.fail_remove_ip {
return Err(anyhow::anyhow!("forced remove_ip failure").into());
}
Ok(())
}
}
async fn set_ifcfg(nic: &SharedVirtualNic, ifcfg: RecordingIfConfiger) {
let mut device = nic.nic.lock().await;
device.set_ifname_for_test("et0".to_owned());
device.set_ifcfg_for_test(Box::new(ifcfg));
}
#[tokio::test]
async fn conflicting_claims_leave_existing_configuration_intact() {
for configure_os in [true, false] {
let mut nic = SharedVirtualNic::new(virtual_nic_config(1500, None));
let mut healthy = address_claims("10.144.1.1/24");
healthy
.ipv6_addresses
.insert("fd00:1::1/64".parse().unwrap());
nic.ifcfg.apply_member_claims(MEMBER, healthy.clone());
let original = nic.ifcfg.effective();
let mut conflicting_ipv6 = address_claims("10.144.2.1/24");
// IPv4 is disjoint, but the later IPv6 claim overlaps the healthy member.
conflicting_ipv6
.ipv6_addresses
.insert("fd00:1::2/64".parse().unwrap());
let conflicting_route = SharedIfConfigClaims {
ipv4_routes: BTreeSet::from([SharedIpv4Route::new(
"10.144.0.0".parse().unwrap(),
16,
None,
)]),
..Default::default()
};
for claims in [conflicting_ipv6, conflicting_route] {
let rejected_member = uuid::Uuid::from_u128(2);
let err = nic
.apply_member_claims(rejected_member, claims, configure_os)
.await
.unwrap_err();
assert!(err.to_string().contains("overlaps member"));
assert_eq!(nic.ifcfg.claims_of(MEMBER), healthy);
assert_eq!(
nic.ifcfg.claims_of(rejected_member),
SharedIfConfigClaims::default()
);
assert!(nic.is_valid());
}
assert_eq!(nic.ifcfg.effective(), original);
}
}
#[tokio::test]
async fn mobile_claims_do_not_configure_the_os_interface() {
// There is no OS interface or ifname; native ifcfg calls would fail.
let mut nic = SharedVirtualNic::new(virtual_nic_config(1500, None));
let claims = SharedIfConfigClaims {
ipv4_addresses: BTreeSet::from(["10.144.1.1/24".parse().unwrap()]),
ipv6_addresses: BTreeSet::from(["fd00:1::1/64".parse().unwrap()]),
ipv4_routes: BTreeSet::from([SharedIpv4Route::new(
"10.145.0.0".parse().unwrap(),
16,
None,
)]),
ipv6_routes: BTreeSet::from([SharedIpv6Route::new("fd00:2::".parse().unwrap(), 64, None)]),
mtu: Some(1300),
};
for claims in [claims, SharedIfConfigClaims::default()] {
nic.apply_member_claims(MEMBER, claims.clone(), false)
.await
.unwrap();
assert_eq!(nic.ifcfg.claims_of(MEMBER), claims);
assert!(nic.is_valid());
}
#[cfg(mobile)]
{
nic.member_registrations.insert(MEMBER, REGISTRATION);
nic.link_up().await.unwrap();
nic.apply_member_mtu_for_registration(MEMBER, REGISTRATION, 1200)
.await
.unwrap();
assert_eq!(nic.ifcfg.claims_of(MEMBER).mtu, Some(1200));
}
}
#[tokio::test]
async fn stale_registration_cannot_update_or_remove_current_claims() {
let mut nic = registered_nic();
let original = nic.ifcfg.effective();
let stale = uuid::Uuid::from_u128(9);
nic.apply_member_claims_for_registration(MEMBER, stale, SharedIfConfigClaims::default())
.await
.unwrap();
nic.apply_member_mtu_for_registration(MEMBER, stale, 1200)
.await
.unwrap();
nic.remove_member_registration_claims(MEMBER, stale)
.await
.unwrap();
assert_eq!(nic.ifcfg.effective(), original);
assert_eq!(nic.member_registrations.get(&MEMBER), Some(&REGISTRATION));
}
#[cfg(not(mobile))]
#[tokio::test]
async fn failed_cleanup_and_replacement_invalidate_without_committing_claims() {
for replacing in [false, true] {
let mut nic = registered_nic();
let original = nic.ifcfg.effective();
set_ifcfg(
&nic,
RecordingIfConfiger {
fail_remove_ip: true,
..Default::default()
},
)
.await;
let result = if replacing {
nic.attach_member_registration(MEMBER, uuid::Uuid::from_u128(11))
.await
} else {
nic.remove_member_registration_claims(MEMBER, REGISTRATION)
.await
};
assert!(result.is_err(), "replacing={replacing}");
assert!(!nic.is_valid());
assert_eq!(nic.ifcfg.effective(), original);
assert_eq!(
nic.member_registrations.get(&MEMBER).copied(),
replacing.then_some(REGISTRATION)
);
}
}
#[tokio::test]
async fn changed_route_source_is_removed_before_its_replacement_is_added() {
let route = SharedIpv4Route::new("10.90.2.0".parse().unwrap(), 24, Some(10));
let fallback = "10.1.2.1".parse().unwrap();
let preferred = "10.90.2.1".parse().unwrap();
for adding_owner in [true, false] {
let mut nic = SharedVirtualNic::new(virtual_nic_config(1500, None));
let ifcfg = RecordingIfConfiger::default();
set_ifcfg(&nic, ifcfg.clone()).await;
let mut claims = address_claims("10.1.2.1/24");
claims.ipv4_routes.insert(route.clone());
nic.ifcfg.apply_member_claims(MEMBER, claims);
let mut with_owner = nic.ifcfg.clone();
let mut claims = address_claims("10.90.2.1/24");
claims.ipv4_routes.insert(route.clone());
with_owner.apply_member_claims(uuid::Uuid::from_u128(2), claims);
let (next, old_source, new_source) = if adding_owner {
(with_owner, fallback, preferred)
} else {
(
std::mem::replace(&mut nic.ifcfg, with_owner),
preferred,
fallback,
)
};
nic.apply_ifcfg_update(&next).await.unwrap();
let changes = ifcfg.routes.lock().unwrap();
let mut expected = vec![
RouteChange::Remove(route.clone(), Some(old_source)),
RouteChange::Add(route.clone(), Some(new_source)),
];
if cfg!(target_os = "linux") && !adding_owner {
expected.push(RouteChange::Add(route.clone(), Some(new_source)));
}
assert_eq!(*changes, expected, "adding_owner={adding_owner}");
}
}
#[tokio::test]
async fn removing_route_preserves_its_old_cost_and_source_hint() {
let kept = SharedIpv4Route::new("10.90.4.0".parse().unwrap(), 24, Some(10));
let removed = SharedIpv4Route {
cost: Some(20),
..kept.clone()
};
let source = "10.90.4.1".parse().unwrap();
let mut nic = SharedVirtualNic::new(virtual_nic_config(1500, None));
let ifcfg = RecordingIfConfiger::default();
set_ifcfg(&nic, ifcfg.clone()).await;
let mut claims = address_claims("10.90.4.1/24");
claims.ipv4_routes.extend([kept.clone(), removed.clone()]);
nic.ifcfg.apply_member_claims(MEMBER, claims.clone());
claims.ipv4_routes.remove(&removed);
let mut next = nic.ifcfg.clone();
next.apply_member_claims(MEMBER, claims);
nic.apply_ifcfg_update(&next).await.unwrap();
assert_eq!(
*ifcfg.routes.lock().unwrap(),
[RouteChange::Remove(removed, Some(source))]
);
}
fn create_member(
registry: &mut SharedVirtualNicRegistry,
dev_name: &str,
mtu: u32,
netns: Option<&str>,
) -> SharedVirtualNicMember {
registry.create_member(
dev_name.to_owned(),
virtual_nic_config(mtu, netns),
uuid::Uuid::new_v4(),
Arc::new(Notify::new()),
)
}
#[test]
fn registry_shares_devices_only_within_the_same_name_and_namespace() {
let mut registry = SharedVirtualNicRegistry::new();
let first = create_member(&mut registry, "et0", 1400, Some("net-a"));
for (name, netns, shared) in [
("et0", Some("net-a"), true),
("et0", Some("net-b"), false),
("et1", Some("net-a"), false),
("et0", None, false),
] {
let next = create_member(&mut registry, name, 1300, netns);
assert_eq!(Arc::ptr_eq(&first.shared_nic, &next.shared_nic), shared);
assert_eq!(next.configured_mtu, 1300);
}
assert_eq!(first.configured_mtu, 1400);
}
#[test]
fn registry_replaces_invalid_devices() {
let mut registry = SharedVirtualNicRegistry::new();
let first = create_member(&mut registry, "et0", 1500, None);
first.shared_nic.try_lock().unwrap().mark_invalid();
assert!(
registry
.get("et0", &virtual_nic_config(1500, None))
.is_none()
);
let next = create_member(&mut registry, "et0", 1500, None);
assert!(!Arc::ptr_eq(&first.shared_nic, &next.shared_nic));
assert!(Arc::ptr_eq(
&registry
.get("et0", &virtual_nic_config(1500, None))
.unwrap(),
&next.shared_nic,
));
}