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 <codex@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-11 23:06:18 +08:00
1 parent 19e5c49ba3
commit a7383114d0
1 file changed
+53 -28
+53 -28
View File
@@ -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<Ipv4Inet>,
current: Option<Ipv4Inet>,
}
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(&current)
@@ -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())),
]
);
}