From a7383114d06e427eace9ea168f0ed62598c7036b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=9A=E5=88=9A?= <225284228a@gmail.com> Date: Sat, 12 Sep 2026 03:06:18 +1200 Subject: [PATCH] fix(core): do not allocate DHCP IP without an assigned peer IPv4 (#2551) Fixes the DHCP allocator treating its hard-coded fallback subnet as an interface lease when no peer IPv4 has been observed. With DHCP enabled and no devices carrying a virtual IPv4, the allocator now waits instead of pulling 10.126.126.0/24 onto the TUN interface. Behavior preserved when an explicit allocator subnet is supplied or when peers with assigned IPv4 exist. --------- Co-authored-by: 225284228a-droid <225284228a-droid@users.noreply.github.com> Co-authored-by: Codex --- easytier-core/src/gateway/dhcp.rs | 81 ++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 28 deletions(-) diff --git a/easytier-core/src/gateway/dhcp.rs b/easytier-core/src/gateway/dhcp.rs index 76320816..299869ee 100644 --- a/easytier-core/src/gateway/dhcp.rs +++ b/easytier-core/src/gateway/dhcp.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, net::Ipv4Addr, sync::Arc, time::Duration}; +use std::{collections::HashSet, sync::Arc, time::Duration}; use async_trait::async_trait; use cidr::Ipv4Inet; @@ -22,22 +22,16 @@ pub enum DhcpIpv4Decision { }, } -#[derive(Debug)] +#[derive(Debug, Default)] pub struct DhcpIpv4Allocator { - default_subnet: Ipv4Inet, + default_subnet: Option, current: Option, } -impl Default for DhcpIpv4Allocator { - fn default() -> Self { - Self::new(Ipv4Inet::new(Ipv4Addr::new(10, 126, 126, 0), 24).unwrap()) - } -} - impl DhcpIpv4Allocator { pub fn new(default_subnet: Ipv4Inet) -> Self { Self { - default_subnet, + default_subnet: Some(default_subnet), current: None, } } @@ -59,7 +53,13 @@ impl DhcpIpv4Allocator { return DhcpIpv4Decision::WaitForPeers; } - let subnet = used_ipv4.iter().next().unwrap_or(&self.default_subnet); + let Some(subnet) = self + .default_subnet + .as_ref() + .or_else(|| used_ipv4.iter().next()) + else { + return DhcpIpv4Decision::WaitForPeers; + }; if let Some(current) = self.current && current.network() == subnet.network() && !used_ipv4.contains(¤t) @@ -410,15 +410,12 @@ mod tests { } #[test] - fn uses_default_subnet_when_routes_have_no_ipv4() { + fn does_not_fall_back_to_a_builtin_subnet_without_assigned_ipv4() { let allocator = DhcpIpv4Allocator::default(); assert_eq!( allocator.evaluate(true, &HashSet::new()), - DhcpIpv4Decision::Change { - previous: None, - next: Some("10.126.126.1/24".parse().unwrap()), - } + DhcpIpv4Decision::WaitForPeers ); } @@ -460,7 +457,7 @@ mod tests { } #[tokio::test] - async fn service_commits_only_after_host_apply_succeeds() { + async fn service_does_not_apply_ipv4_when_no_peer_has_one() { let host = Arc::new(RecordingHost::default()); let (service, runtime_config) = service( DhcpIpv4RouteSnapshot { @@ -472,21 +469,49 @@ mod tests { assert!(service.reconcile_once().await); - assert_eq!(service.current(), Some("10.126.126.1/24".parse().unwrap())); + assert_eq!(service.current(), None); + assert!(host.changes.lock().unwrap().is_empty()); + assert!(host.published.lock().unwrap().is_empty()); + assert!( + runtime_config + .snapshot() + .peer + .runtime + .core + .routes + .ipv4 + .is_none() + ); + } + + #[tokio::test] + async fn service_commits_only_after_host_apply_succeeds() { + let host = Arc::new(RecordingHost::default()); + let (service, runtime_config) = service( + DhcpIpv4RouteSnapshot { + has_routes: true, + used_ipv4: HashSet::from(["198.18.0.2/24".parse().unwrap()]), + }, + host.clone(), + ); + + assert!(service.reconcile_once().await); + + assert_eq!(service.current(), Some("198.18.0.1/24".parse().unwrap())); assert_eq!( *host.changes.lock().unwrap(), - [(None, Some("10.126.126.1/24".parse().unwrap()))] + [(None, Some("198.18.0.1/24".parse().unwrap()))] ); assert_eq!( runtime_config.snapshot().peer.runtime.core.routes.ipv4, - Some(IpPrefix::new("10.126.126.1".parse().unwrap(), 24).unwrap()) + Some(IpPrefix::new("198.18.0.1".parse().unwrap(), 24).unwrap()) ); assert_eq!( *host.published.lock().unwrap(), [( None, - Some("10.126.126.1/24".parse().unwrap()), - Some("10.126.126.1/24".parse().unwrap()) + Some("198.18.0.1/24".parse().unwrap()), + Some("198.18.0.1/24".parse().unwrap()) )] ); } @@ -498,14 +523,14 @@ mod tests { let (service, _runtime_config) = service( DhcpIpv4RouteSnapshot { has_routes: true, - used_ipv4: HashSet::new(), + used_ipv4: HashSet::from(["198.18.0.2/24".parse().unwrap()]), }, host.clone(), ); service.reconcile_once().await; - let expected = Some(IpPrefix::new("10.126.126.1".parse().unwrap(), 24).unwrap()); + let expected = Some(IpPrefix::new("198.18.0.1".parse().unwrap(), 24).unwrap()); assert!(host.published_with_permit.load(Ordering::Acquire)); assert_eq!( host.published_runtime_ipv4.lock().unwrap().as_slice(), @@ -521,7 +546,7 @@ mod tests { let (service, runtime_config) = service( DhcpIpv4RouteSnapshot { has_routes: true, - used_ipv4: HashSet::new(), + used_ipv4: HashSet::from(["198.18.0.2/24".parse().unwrap()]), }, host.clone(), ); @@ -544,7 +569,7 @@ mod tests { let (service, _runtime_config) = service( DhcpIpv4RouteSnapshot { has_routes: true, - used_ipv4: HashSet::new(), + used_ipv4: HashSet::from(["198.18.0.2/24".parse().unwrap()]), }, host.clone(), ); @@ -556,8 +581,8 @@ mod tests { assert_eq!( host.changes.lock().unwrap().as_slice(), [ - (None, Some("10.126.126.1/24".parse().unwrap())), - (None, Some("10.126.126.1/24".parse().unwrap())), + (None, Some("198.18.0.1/24".parse().unwrap())), + (None, Some("198.18.0.1/24".parse().unwrap())), ] ); }