feat(dhcp): support automatic IPv6 address assignment (#2688)

Accept a boolean or an IPv6 CIDR in dhcpv6 and --dhcpv6, using
fd6b:8c2d:91e4::/64 when no initial subnet is supplied. Keep IPv4
configuration serialization compatible and DHCPv6 disabled by default.

Share the DHCP lifecycle and host update between both address families,
with typed allocation state, peer subnet selection, conflict recovery,
and bounded IPv6 probing. Preserve static addresses of the other family
and prevent static config patches from replacing DHCP-owned leases.

Carry IPv6 settings, leases, and events through management, web config,
and mobile VPN setup. Reuse the shared NIC registry and mobile address
list for IPv6-only and dual-stack operation without extra FD owners.

Cover config round trips, allocation and recovery, mobile
reconciliation, three-node IPv6 and dual-stack connectivity, and shared
TUN member exit. Validate with workspace clippy, Docker TUN tests,
frontend tests/builds, WASI and Android Rust checks, and Android VPN
Kotlin compilation.
This commit is contained in:
KKRainbow authored and GitHub committed 2026-10-14 00:36:47 +08:00
1 parent 7c8ca07812
commit 03078b4791
43 files changed
+1639 -522

No files matched your search

+2
View File
@@ -122,11 +122,13 @@ endpoint-discovery = [
"dep:webpki-roots",
]
dhcp-ipv4 = []
dhcp-ipv6 = []
public-ipv6-provider = []
vpn-portal = []
wrapped-transport = []
extended-services = [
"dhcp-ipv4",
"dhcp-ipv6",
"public-ipv6-provider",
"vpn-portal",
"wrapped-transport",
+5
View File
@@ -17,6 +17,11 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
instance_id: Some(config.get_id().to_string()),
dhcp: Some(config.get_dhcp()),
dhcp_ipv4_cidr: config.get_dhcp_config().cidr().map(|cidr| cidr.to_string()),
dhcpv6: Some(config.get_dhcpv6()),
dhcp_ipv6_cidr: config
.get_dhcpv6_config()
.cidr()
.map(|cidr| cidr.to_string()),
..Default::default()
};
+44
View File
@@ -121,6 +121,7 @@ const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
"ipv6_public_addr_auto",
"ipv6_public_addr_prefix",
"dhcp",
"dhcpv6",
"network_identity",
"listeners",
"mapped_listeners",
@@ -256,6 +257,12 @@ impl NetworkConfigExt for NetworkConfig {
{
cfg.set_dhcp_config(super::DhcpConfig::from_cidr(cidr)?);
}
cfg.set_dhcpv6(self.dhcpv6.unwrap_or(self.dhcp_ipv6_cidr.is_some()));
if cfg.get_dhcpv6()
&& let Some(cidr) = &self.dhcp_ipv6_cidr
{
cfg.set_dhcpv6_config(super::DhcpConfig::from_cidr(cidr)?);
}
cfg.set_inst_name(self.network_name.clone().unwrap_or_default());
// The web UI does not expose credential inputs directly, but imported/saved
@@ -557,6 +564,11 @@ impl NetworkConfigExt for NetworkConfig {
result.dhcp = Some(config.get_dhcp());
result.dhcp_ipv4_cidr = config.get_dhcp_config().cidr().map(|cidr| cidr.to_string());
result.dhcpv6 = Some(config.get_dhcpv6());
result.dhcp_ipv6_cidr = config
.get_dhcpv6_config()
.cidr()
.map(|cidr| cidr.to_string());
let network_identity = config.get_network_identity();
result.network_name = Some(network_identity.network_name.clone());
@@ -735,6 +747,38 @@ mod tests {
}
}
#[test]
fn dhcpv6_cidr_survives_management_round_trips() {
let mut input = NetworkConfig {
dhcp: Some(true),
dhcp_ipv6_cidr: Some("fd12::/64".to_owned()),
..standalone_config()
};
for enabled in [None, Some(true), Some(false)] {
input.dhcpv6 = enabled;
let config = input.gen_config().unwrap();
assert_eq!(config.get_dhcpv6(), enabled.unwrap_or(true));
let readback = NetworkConfig::new_from_config(&config).unwrap();
assert_eq!(readback.dhcpv6, Some(enabled.unwrap_or(true)));
assert_eq!(
readback.dhcp_ipv6_cidr,
if enabled == Some(false) {
None
} else {
input.dhcp_ipv6_cidr.clone()
}
);
assert_eq!(
readback.gen_config().unwrap().get_dhcpv6_config(),
config.get_dhcpv6_config()
);
assert_eq!(
super::super::api::network_config_from_toml(&config).dhcp_ipv6_cidr,
readback.dhcp_ipv6_cidr
);
}
}
#[test]
fn dhcp_api_rejects_invalid_or_unusable_subnets() {
let mut input = standalone_config();
+38 -20
View File
@@ -1,29 +1,29 @@
use std::{fmt, str::FromStr};
use std::{fmt, marker::PhantomData, str::FromStr};
use anyhow::Context;
use cidr::Ipv4Cidr;
use cidr::{Cidr, Family, Ipv4Cidr, Ipv6Cidr};
use serde::{Deserialize, Deserializer, Serialize, de};
/// DHCP enablement and an optional subnet used before peers advertise one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(untagged)]
pub enum DhcpConfig {
pub enum DhcpConfig<C = Ipv4Cidr> {
Enabled(bool),
Cidr(Ipv4Cidr),
Cidr(C),
}
impl Default for DhcpConfig {
impl<C> Default for DhcpConfig<C> {
fn default() -> Self {
Self::Enabled(false)
}
}
impl DhcpConfig {
impl<C: Cidr> DhcpConfig<C> {
pub fn enabled(self) -> bool {
!matches!(self, Self::Enabled(false))
}
pub fn cidr(self) -> Option<Ipv4Cidr> {
pub fn cidr(self) -> Option<C> {
match self {
Self::Enabled(_) => None,
Self::Cidr(cidr) => Some(cidr),
@@ -31,18 +31,27 @@ impl DhcpConfig {
}
pub fn from_cidr(value: &str) -> anyhow::Result<Self> {
let cidr: Ipv4Cidr = value
let cidr: C = value
.parse()
.with_context(|| format!("invalid DHCP IPv4 CIDR: {value}"))?;
anyhow::ensure!(
cidr.network_length() <= 30,
"DHCP IPv4 CIDR must have usable host addresses (prefix length 0..=30)"
);
.with_context(|| format!("invalid DHCP CIDR: {value}"))?;
match cidr.family() {
Family::Ipv4 => anyhow::ensure!(
cidr.network_length() <= 30,
"DHCP IPv4 CIDR must have usable host addresses (prefix length 0..=30)"
),
Family::Ipv6 => {
let subnet: Ipv6Cidr = value.parse()?;
anyhow::ensure!(
valid_ipv6_subnet(subnet),
"DHCP IPv6 CIDR must be a unicast subnet within fc00::/7 or 2000::/3, with prefix length at most 126"
);
}
}
Ok(Self::Cidr(cidr))
}
}
impl FromStr for DhcpConfig {
impl<C: Cidr> FromStr for DhcpConfig<C> {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
@@ -53,15 +62,15 @@ impl FromStr for DhcpConfig {
}
}
impl<'de> Deserialize<'de> for DhcpConfig {
impl<'de, C: Cidr> Deserialize<'de> for DhcpConfig<C> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct DhcpVisitor;
struct DhcpVisitor<C>(PhantomData<C>);
impl de::Visitor<'_> for DhcpVisitor {
type Value = DhcpConfig;
impl<C: Cidr> de::Visitor<'_> for DhcpVisitor<C> {
type Value = DhcpConfig<C>;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a boolean or an IPv4 CIDR with prefix length 0..=30")
formatter.write_str("a boolean or a usable DHCP CIDR")
}
fn visit_bool<E: de::Error>(self, value: bool) -> Result<Self::Value, E> {
@@ -73,6 +82,15 @@ impl<'de> Deserialize<'de> for DhcpConfig {
}
}
deserializer.deserialize_any(DhcpVisitor)
deserializer.deserialize_any(DhcpVisitor(PhantomData))
}
}
// Only overlay unicast subnets are eligible for automatic assignment. Keep at
// least two candidates after reserving the subnet endpoints.
pub(crate) fn valid_ipv6_subnet(subnet: Ipv6Cidr) -> bool {
let first = subnet.first_address().segments()[0];
subnet.network_length() <= 126
&& ((subnet.network_length() >= 7 && first & 0xfe00 == 0xfc00)
|| (subnet.network_length() >= 3 && first & 0xe000 == 0x2000))
}
+1
View File
@@ -18,6 +18,7 @@ pub mod runtime;
pub mod toml;
pub use dhcp::DhcpConfig;
pub(crate) use dhcp::valid_ipv6_subnet;
pub use encryption::EncryptionAlgorithm;
pub(crate) const DEFAULT_UDP_STUN_SERVERS: &[&str] = &[
+7 -1
View File
@@ -3,7 +3,7 @@
use std::{collections::BTreeSet, sync::Arc};
use arc_swap::ArcSwap;
use cidr::Ipv4Cidr;
use cidr::{Ipv4Cidr, Ipv6Cidr};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -18,6 +18,10 @@ pub struct CoreRuntimeConfig {
pub dhcp_ipv4: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dhcp_ipv4_cidr: Option<Ipv4Cidr>,
#[serde(default)]
pub dhcp_ipv6: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dhcp_ipv6_cidr: Option<Ipv6Cidr>,
pub gateway: GatewayRuntimeConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manual_routes: Option<BTreeSet<Ipv4Cidr>>,
@@ -33,6 +37,8 @@ impl Default for CoreRuntimeConfig {
acl: AclRuleConfig::default(),
dhcp_ipv4: false,
dhcp_ipv4_cidr: None,
dhcp_ipv6: false,
dhcp_ipv6_cidr: None,
gateway: GatewayRuntimeConfig::default(),
manual_routes: None,
proxy: ProxyRuntimeConfig::default(),
+78 -17
View File
@@ -64,6 +64,17 @@ pub trait ConfigLoader: Send + Sync {
self.set_dhcp_config(DhcpConfig::Enabled(dhcp));
}
fn get_dhcpv6_config(&self) -> DhcpConfig<cidr::Ipv6Cidr>;
fn set_dhcpv6_config(&self, dhcp: DhcpConfig<cidr::Ipv6Cidr>);
fn get_dhcpv6(&self) -> bool {
self.get_dhcpv6_config().enabled()
}
fn set_dhcpv6(&self, dhcp: bool) {
self.set_dhcpv6_config(DhcpConfig::Enabled(dhcp));
}
fn add_proxy_cidr(
&self,
cidr: cidr::Ipv4Cidr,
@@ -406,6 +417,7 @@ struct Config {
ipv6_public_addr_auto: Option<bool>,
ipv6_public_addr_prefix: Option<String>,
dhcp: Option<DhcpConfig>,
dhcpv6: Option<DhcpConfig<cidr::Ipv6Cidr>>,
network_identity: Option<NetworkIdentity>,
listeners: Option<Vec<url::Url>>,
mapped_listeners: Option<Vec<url::Url>>,
@@ -741,6 +753,14 @@ impl ConfigLoader for TomlConfig {
self.config.lock().unwrap().dhcp = Some(dhcp);
}
fn get_dhcpv6_config(&self) -> DhcpConfig<cidr::Ipv6Cidr> {
self.config.lock().unwrap().dhcpv6.unwrap_or_default()
}
fn set_dhcpv6_config(&self, dhcp: DhcpConfig<cidr::Ipv6Cidr>) {
self.config.lock().unwrap().dhcpv6 = Some(dhcp);
}
fn add_proxy_cidr(
&self,
cidr: cidr::Ipv4Cidr,
@@ -1107,6 +1127,42 @@ mod tests {
}
}
#[test]
fn dhcpv6_config_round_trip_and_validation() {
assert!(!TomlConfig::default().get_dhcpv6());
for value in [
"true",
"false",
"'fd12:3456::/64'",
"'fd12::/126'",
"'2001:db8::/48'",
] {
let config =
TomlConfig::new_from_str(&format!("dhcp = true\ndhcpv6 = {value}")).unwrap();
let exported = config.dump();
let restored = TomlConfig::new_from_str(&exported).unwrap();
assert_eq!(restored.get_dhcpv6_config(), config.get_dhcpv6_config());
assert!(restored.get_dhcp());
assert!(exported.contains("dhcp = true"));
}
for value in [
"yes",
"10.0.0.0/24",
"fd12::1/64",
"fd12::/127",
"fd12::/128",
"::/0",
"::/64",
"fe80::/64",
"ff00::/8",
] {
assert!(
TomlConfig::new_from_str(&format!("dhcpv6 = '{value}'")).is_err(),
"accepted {value}"
);
}
}
#[test]
fn dhcp_rejects_invalid_or_unusable_subnets() {
for value in [
@@ -1131,18 +1187,20 @@ mod tests {
#[test]
fn parse_error_preserves_source_and_location() {
let error =
TomlConfig::new_from_str_with_source("fixture.toml", "dhcp = \"yes\"").unwrap_err();
let display = error.to_string();
for field in ["dhcp", "dhcpv6"] {
let input = format!("{field} = \"yes\"");
let error = TomlConfig::new_from_str_with_source("fixture.toml", &input).unwrap_err();
let display = error.to_string();
assert!(display.contains("fixture.toml"));
assert!(display.contains("dhcp = \"yes\""));
assert!(display.contains("invalid DHCP IPv4 CIDR"));
assert!(
error
.chain()
.any(|cause| cause.downcast_ref::<toml::de::Error>().is_some())
);
assert!(display.contains("fixture.toml"));
assert!(display.contains(&input));
assert!(display.contains("invalid DHCP CIDR"));
assert!(
error
.chain()
.any(|cause| cause.downcast_ref::<toml::de::Error>().is_some())
);
}
}
#[cfg(feature = "config-write")]
@@ -1948,13 +2006,16 @@ mod diagnostic_compatibility_tests {
#[test]
fn non_ascii_before_typed_error_keeps_byte_location() {
let error = TomlConfig::new_from_str("hostname = \"节点\"\ndhcp = \"yes\"")
.unwrap_err()
.to_string();
for field in ["dhcp", "dhcpv6"] {
let invalid_field = format!("{field} = \"yes\"");
let error = TomlConfig::new_from_str(&format!("hostname = \"节点\"\n{invalid_field}"))
.unwrap_err()
.to_string();
assert!(error.contains("dhcp = \"yes\""));
assert!(error.contains('^'));
assert!(error.contains("invalid DHCP IPv4 CIDR"));
assert!(error.contains(&invalid_field));
assert!(error.contains('^'));
assert!(error.contains("invalid DHCP CIDR"));
}
}
#[cfg(feature = "rich-config-errors")]
File diff suppressed because it is too large. Load diff
@@ -8,6 +8,7 @@ use crate::config::{peers::PeerRuntimeSnapshot, runtime::CoreRuntimeConfig};
use super::CoreInstanceConfig;
const DHCP_IPV4_AVAILABLE: bool = cfg!(feature = "dhcp-ipv4");
const DHCP_IPV6_AVAILABLE: bool = cfg!(feature = "dhcp-ipv6");
const SMOLTCP_GATEWAY_AVAILABLE: bool = cfg!(feature = "proxy-smoltcp-stack");
const PACKET_PROXY_AVAILABLE: bool = cfg!(feature = "proxy-packet");
const PROXY_CIDR_MONITOR_AVAILABLE: bool = cfg!(feature = "proxy-cidr-monitor");
@@ -28,6 +29,7 @@ fn validate_snapshot(
) -> anyhow::Result<()> {
let core = &peer.runtime.core;
require(DHCP_IPV4_AVAILABLE, runtime.dhcp_ipv4, "DHCP IPv4")?;
require(DHCP_IPV6_AVAILABLE, runtime.dhcp_ipv6, "DHCP IPv6")?;
require(
SMOLTCP_GATEWAY_AVAILABLE,
runtime.gateway.socks5_bind.is_some() || !runtime.gateway.port_forwards.is_empty(),
+34 -8
View File
@@ -210,14 +210,20 @@ impl CoreInstanceConfig {
network_name: network_name.clone(),
},
routes: RouteConfig {
ipv4: config.get_ipv4().map(|value| IpPrefix {
address: value.address().into(),
prefix_len: value.network_length(),
}),
ipv6: config.get_ipv6().map(|value| IpPrefix {
address: value.address().into(),
prefix_len: value.network_length(),
}),
ipv4: config
.get_ipv4()
.filter(|_| !config.get_dhcp())
.map(|value| IpPrefix {
address: value.address().into(),
prefix_len: value.network_length(),
}),
ipv6: config
.get_ipv6()
.filter(|_| !config.get_dhcpv6())
.map(|value| IpPrefix {
address: value.address().into(),
prefix_len: value.network_length(),
}),
proxy_networks: proxy_networks
.into_iter()
.map(|proxy| ProxyNetworkConfig {
@@ -297,6 +303,8 @@ impl CoreInstanceConfig {
},
dhcp_ipv4: config.get_dhcp(),
dhcp_ipv4_cidr: config.get_dhcp_config().cidr(),
dhcp_ipv6: config.get_dhcpv6(),
dhcp_ipv6_cidr: config.get_dhcpv6_config().cidr(),
gateway: GatewayRuntimeConfig {
socks5_bind,
port_forwards: if host.ignore_unsupported_config && !host.gateway_enabled {
@@ -408,6 +416,24 @@ mod tests {
use super::*;
#[test]
fn dhcp_initial_routes_ignore_static_addresses_of_managed_families() {
for dhcp_ipv4 in [false, true] {
for dhcp_ipv6 in [false, true] {
let config =
TomlConfig::new_from_str("ipv4 = '10.20.0.7/24'\nipv6 = 'fd12::7/64'").unwrap();
config.set_dhcp(dhcp_ipv4);
config.set_dhcpv6(dhcp_ipv6);
let normalized = CoreInstanceConfig::from_toml(&config).unwrap();
let routes = &normalized.peer.snapshot.runtime.core.routes;
assert_eq!(routes.ipv4.is_some(), !dhcp_ipv4);
assert_eq!(routes.ipv6.is_some(), !dhcp_ipv6);
assert_eq!(config.get_ipv4().unwrap(), "10.20.0.7/24".parse().unwrap());
assert_eq!(config.get_ipv6().unwrap(), "fd12::7/64".parse().unwrap());
}
}
}
#[test]
fn tcp_stun_servers_follow_toml_override_rules() {
let fallback = TomlConfig::new_from_str(
+15 -15
View File
@@ -6,8 +6,8 @@
use std::sync::{Arc, Weak};
#[cfg(feature = "dhcp-ipv4")]
use crate::gateway::dhcp::{DhcpIpv4Host, DhcpIpv4RouteSource};
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
use crate::gateway::dhcp::{DhcpHost, DhcpRouteSource};
use super::{CoreInstance, CoreInstanceHost, CoreInstanceState};
@@ -66,16 +66,16 @@ where
}
}
#[cfg(feature = "dhcp-ipv4")]
async fn start_dhcp_ipv4(&self, host: Option<Arc<dyn DhcpIpv4Host>>) -> anyhow::Result<()> {
if !self.runtime_config.snapshot().services.dhcp_ipv4 {
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
async fn start_dhcp(&self, host: Option<Arc<dyn DhcpHost>>) -> anyhow::Result<()> {
let config = self.runtime_config.snapshot();
if !config.services.dhcp_ipv4 && !config.services.dhcp_ipv6 {
return Ok(());
}
let host = host.ok_or_else(|| {
anyhow::anyhow!("DHCP IPv4 is enabled but no host adapter was provided")
})?;
let route_source: Arc<dyn DhcpIpv4RouteSource> = self.peer_manager.clone();
self.dhcp_ipv4
let host = host
.ok_or_else(|| anyhow::anyhow!("DHCP is enabled but no host adapter was provided"))?;
let route_source: Arc<dyn DhcpRouteSource> = self.peer_manager.clone();
self.dhcp
.start(route_source, self.runtime_config.clone(), host)
.await;
Ok(())
@@ -125,9 +125,9 @@ where
self.public_ipv6_provider.start().await;
let dhcp_host = self.instance_runtime.prepare(self.packet_plane()).await?;
#[cfg(feature = "dhcp-ipv4")]
self.start_dhcp_ipv4(dhcp_host).await?;
#[cfg(not(feature = "dhcp-ipv4"))]
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
self.start_dhcp(dhcp_host).await?;
#[cfg(not(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6")))]
let _ = dhcp_host;
#[cfg(feature = "wrapped-transport")]
if let Some(wrapped_transport) = &self.wrapped_transport {
@@ -173,8 +173,8 @@ where
self.vpn_portal.stop().await;
#[cfg(feature = "public-ipv6-provider")]
self.public_ipv6_provider.stop().await;
#[cfg(feature = "dhcp-ipv4")]
self.dhcp_ipv4.stop().await;
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
self.dhcp.stop().await;
#[cfg(feature = "proxy-cidr-monitor")]
self.proxy_cidr_monitor.stop().await;
if let Some(listener) = &self.listener {
+9 -9
View File
@@ -58,7 +58,7 @@ use crate::{
},
},
events::CoreEventSink,
gateway::dhcp::DhcpIpv4Host,
gateway::dhcp::DhcpHost,
host::{
dns::{DnsRecordResolver, DnsResolver},
packet::{HostPacketReceiver, PacketSink, host_packet_channel},
@@ -96,8 +96,8 @@ use crate::gateway::vpn_portal::PortalRuntimeConfig;
#[cfg(feature = "public-ipv6-provider")]
use crate::peers::public_ipv6::provider::PublicIpv6ProviderPlatform;
#[cfg(feature = "dhcp-ipv4")]
use crate::gateway::dhcp::DhcpIpv4Runtime;
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
use crate::gateway::dhcp::DhcpRuntime;
#[cfg(feature = "proxy-cidr-monitor")]
use crate::gateway::proxy::cidr_monitor::ProxyCidrMonitorRuntime;
#[cfg(feature = "proxy-packet")]
@@ -238,7 +238,7 @@ pub trait InstanceRuntimeHost: std::any::Any + Send + Sync + 'static {
async fn prepare(
&self,
packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>>;
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>>;
async fn shutdown(&self);
@@ -276,7 +276,7 @@ impl InstanceRuntimeHost for () {
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
Ok(None)
}
@@ -435,8 +435,8 @@ where
packet_proxy: Arc<CoreProxyModule<H>>,
#[cfg(feature = "proxy-cidr-monitor")]
proxy_cidr_monitor: ProxyCidrMonitorRuntime,
#[cfg(feature = "dhcp-ipv4")]
dhcp_ipv4: DhcpIpv4Runtime,
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
dhcp: DhcpRuntime,
pub(super) packet_egress: Arc<dyn PacketEgressHost>,
pub(super) packet_receiver: Mutex<Option<HostPacketReceiver>>,
pub(super) peer_center: Arc<PeerCenterInstance>,
@@ -884,8 +884,8 @@ where
packet_proxy,
#[cfg(feature = "proxy-cidr-monitor")]
proxy_cidr_monitor,
#[cfg(feature = "dhcp-ipv4")]
dhcp_ipv4: DhcpIpv4Runtime::new(),
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
dhcp: DhcpRuntime::new(),
packet_egress,
packet_receiver: Mutex::new(Some(packet_rx)),
peer_center,
+58 -7
View File
@@ -628,6 +628,57 @@ mod portable_runtime {
);
}
#[cfg(feature = "dhcp-ipv6")]
#[tokio::test]
async fn runtime_update_preserves_dhcp_owned_ipv6() {
let mut initial = test_config("dhcp-runtime-update");
initial.connectivity.runtime.dhcp_ipv6 = true;
let instance = build_instance(initial).unwrap();
let lease = IpPrefix {
address: "fd12::7".parse().unwrap(),
prefix_len: 64,
};
instance.runtime_config.update_peer_with(|peer| {
peer.runtime.core.routes.ipv6 = Some(lease.clone());
});
let mut replacement = test_config("dhcp-runtime-update");
replacement.connectivity.runtime.dhcp_ipv6 = true;
instance
.update_runtime_config(runtime_snapshot(&replacement))
.await
.unwrap();
assert_eq!(
instance
.runtime_config
.snapshot()
.peer
.runtime
.core
.routes
.ipv6,
Some(lease)
);
let static_replacement = test_config("dhcp-runtime-update");
instance
.update_runtime_config(runtime_snapshot(&static_replacement))
.await
.unwrap();
assert_eq!(
instance
.runtime_config
.snapshot()
.peer
.runtime
.core
.routes
.ipv6,
None
);
}
#[tokio::test]
async fn core_instance_is_a_direct_managed_record() {
let instance = build_instance(test_config("managed-directly")).unwrap();
@@ -1332,7 +1383,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
Ok(None)
}
@@ -1366,7 +1417,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
anyhow::bail!("host prepare failed")
}
@@ -1477,7 +1528,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
self.prepare_started.notify_one();
self.prepare_release.notified().await;
Ok(None)
@@ -1531,7 +1582,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
assert_eq!(tokio::runtime::Handle::current().id(), self.handle.id());
self.prepare_started.notify_one();
if self.block_prepare {
@@ -1616,7 +1667,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
Ok(None)
}
@@ -1698,7 +1749,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
Ok(None)
}
@@ -1988,7 +2039,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
Ok(None)
}
@@ -91,7 +91,9 @@ where
{
candidate.set_ipv4(Some(ipv4.into()));
}
if let Some(ipv6) = patch.ipv6 {
if let Some(ipv6) = patch.ipv6
&& !candidate.get_dhcpv6()
{
candidate.set_ipv6(Some(ipv6.into()));
}
candidate.patch_flags(FlagsPatch {
@@ -60,6 +60,7 @@ where
dev_name,
my_node_info: Some(MyNodeInfo {
virtual_ipv4: node.ipv4_addr.map(Into::into),
virtual_ipv6: node.ipv6_addr.map(Into::into),
hostname: node.hostname,
version: node.version,
ips: Some(node.ip_list),
+5
View File
@@ -98,6 +98,7 @@ pub struct PeerSnapshot {
pub struct NodeSnapshot {
pub peer_id: PeerId,
pub ipv4_addr: Option<cidr::Ipv4Inet>,
pub ipv6_addr: Option<cidr::Ipv6Inet>,
pub proxy_networks: Vec<ProxyNetworkConfig>,
pub hostname: String,
pub stun_info: StunInfo,
@@ -283,6 +284,9 @@ fn retain_runtime_owned_peer_state(
if current.services.dhcp_ipv4 && next.services.dhcp_ipv4 {
next_peer.runtime.core.routes.ipv4 = current.peer.runtime.core.routes.ipv4.clone();
}
if current.services.dhcp_ipv6 && next.services.dhcp_ipv6 {
next_peer.runtime.core.routes.ipv6 = current.peer.runtime.core.routes.ipv6.clone();
}
}
fn validate_portable_routes(routes: &crate::config::RouteConfig) -> anyhow::Result<()> {
@@ -1265,6 +1269,7 @@ impl PeerManagerCore {
NodeSnapshot {
peer_id: self.my_peer_id,
ipv4_addr: self.context.ipv4(),
ipv6_addr: self.context.ipv6(),
proxy_networks: self.context.proxy_networks(),
hostname: self.context.hostname(),
stun_info: self.context.stun_info(),
+5 -9
View File
@@ -3,24 +3,20 @@
use crate::{
config::toml::TomlConfig,
connectivity::connector_host::HostConnectorEnvironmentSnapshot,
gateway::dhcp::{DhcpIpv4ApplyOutcome, DhcpIpv4Host},
gateway::dhcp::{DhcpAddresses, DhcpApplyOutcome, DhcpHost},
instance::{CorePacketPlane, InstanceRuntimeHost},
};
struct WasiInstanceRuntimeHost;
#[async_trait::async_trait]
impl DhcpIpv4Host for WasiInstanceRuntimeHost {
impl DhcpHost for WasiInstanceRuntimeHost {
fn take_interface_closed(&self) -> bool {
false
}
async fn apply_dhcp_ipv4(
&self,
_previous: Option<cidr::Ipv4Inet>,
next: Option<cidr::Ipv4Inet>,
) -> DhcpIpv4ApplyOutcome {
DhcpIpv4ApplyOutcome::applied(next)
async fn apply_dhcp(&self, next: DhcpAddresses) -> DhcpApplyOutcome {
DhcpApplyOutcome::applied(next)
}
}
@@ -29,7 +25,7 @@ impl InstanceRuntimeHost for WasiInstanceRuntimeHost {
async fn prepare(
&self,
_packet_plane: std::sync::Arc<CorePacketPlane>,
) -> anyhow::Result<Option<std::sync::Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<std::sync::Arc<dyn DhcpHost>>> {
Ok(Some(std::sync::Arc::new(WasiInstanceRuntimeHost)))
}
+6
View File
@@ -85,6 +85,8 @@ fn hosted_network_config(config: &NetworkConfig) -> NetworkConfig {
instance_id: config.instance_id.clone(),
dhcp: config.dhcp,
dhcp_ipv4_cidr: config.dhcp_ipv4_cidr.clone(),
dhcpv6: config.dhcpv6,
dhcp_ipv6_cidr: config.dhcp_ipv6_cidr.clone(),
virtual_ipv4: config.virtual_ipv4.clone(),
network_length: config.network_length,
hostname: config.hostname.clone(),
@@ -405,6 +407,8 @@ mod tests {
instance_id: Some(uuid::Uuid::new_v4().to_string()),
dhcp: Some(true),
dhcp_ipv4_cidr: Some("192.168.100.0/24".to_owned()),
dhcpv6: Some(true),
dhcp_ipv6_cidr: Some("fd12::/64".to_owned()),
network_name: Some("network".to_owned()),
network_secret: Some("secret".to_owned()),
networking_method: Some(NetworkingMethod::Manual as i32),
@@ -455,6 +459,8 @@ mod tests {
let hosted = hosted_network_config(&original);
assert_eq!(hosted.dhcp_ipv4_cidr, original.dhcp_ipv4_cidr);
assert_eq!(hosted.dhcpv6, original.dhcpv6);
assert_eq!(hosted.dhcp_ipv6_cidr, original.dhcp_ipv6_cidr);
assert_eq!(
hosted.peer_urls,
vec!["tcp://peer.example:11010".to_owned()]
+3 -1
View File
@@ -1090,7 +1090,9 @@ mod manager {
loop {
match event_receiver.recv().await {
Ok(easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Changed(_, _)
| easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Conflicted(_)) => {
| easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Conflicted(_)
| easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv6Changed(_, _)
| easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv6Conflicted(_)) => {
let _ = app_clone.emit("dhcp_ip_changed", &instance_id_str);
}
Ok(easytier::common::global_ctx::GlobalCtxEvent::ProxyCidrsUpdated(_, _)
+108 -7
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IPv4CidrRange } from 'ip-num/IPRange'
import { IPv4CidrRange, IPv6CidrRange } from 'ip-num/IPRange'
import type { MobileTunConfig } from './backend'
const mocks = vi.hoisted(() => {
@@ -72,17 +72,26 @@ function makeMobileTunConfig(instanceIds: string[]): MobileTunConfig {
const config = mocks.configs.get(id)!
const info = mocks.networkInfo.get(id) as any
const ipv4 = info?.my_node_info?.virtual_ipv4
if (!ipv4?.address?.addr || info.error_msg)
const ipv6 = info?.my_node_info?.virtual_ipv6
if ((!ipv4?.address?.addr && !ipv6?.address?.addr) || info.error_msg)
continue
const address = `${ipv4.address.addr}/${ipv4.network_length || 24}`
addresses.push(address)
const range = IPv4CidrRange.fromCidr(address)
routes.add(`${range.getFirst()}/${range.getPrefix()}`)
if (ipv4?.address?.addr) {
const address = `${ipv4.address.addr}/${ipv4.network_length || 24}`
addresses.push(address)
const range = IPv4CidrRange.fromCidr(address)
routes.add(`${range.getFirst()}/${range.getPrefix()}`)
}
if (ipv6?.address?.addr) {
const address = `${ipv6.address.addr}/${ipv6.network_length || 64}`
addresses.push(address)
const range = IPv6CidrRange.fromCidr(address)
routes.add(`${range.getFirst()}/${range.getPrefix()}`)
}
const configuredRoutes = config.enable_manual_routes
? config.routes as string[]
: (info.routes ?? []).flatMap((route: any) => route.proxy_cidrs ?? [])
configuredRoutes.forEach((route: string) => routes.add(route.includes('/') ? route : `${route}/32`))
if (config.enable_magic_dns) {
if (config.enable_magic_dns && ipv4?.address?.addr) {
routes.add('100.100.100.101/32')
dns = '100.100.100.101'
}
@@ -113,6 +122,12 @@ function setReady(instanceId: string, ipv4: string) {
})
}
function setReadyIpv6(instanceId: string, ipv6: string) {
const info = mocks.networkInfo.get(instanceId) as any ?? { my_node_info: {}, routes: [] }
info.my_node_info.virtual_ipv6 = { address: { addr: ipv6 }, network_length: 64 }
mocks.networkInfo.set(instanceId, info)
}
async function loadVpnModule() {
const mobileVpn = await import('./mobile_vpn')
await mobileVpn.initMobileVpnService()
@@ -147,6 +162,92 @@ beforeEach(() => {
})
describe('mobile VPN reconciliation', () => {
it('starts an IPv6-only VPN after DHCPv6 becomes ready', async () => {
setConfig('A')
mocks.configs.get('A')!.dhcpv6 = true
mocks.configs.get('A')!.enable_magic_dns = true
mocks.networkInfo.set('A', { my_node_info: {}, routes: [] })
const vpn = await loadVpnModule()
await vpn.onNetworkInstanceChange('A')
expect(mocks.startVpn).not.toHaveBeenCalled()
setReadyIpv6('A', 'fd12::7')
await vi.advanceTimersByTimeAsync(2000)
expect(mocks.startVpn).toHaveBeenCalledOnce()
expect(mocks.startVpn).toHaveBeenCalledWith(expect.objectContaining({
ipv4Addrs: ['fd12::7/64'],
routes: ['fd12:0:0:0:0:0:0:0/64'],
dns: undefined,
}))
expect(mocks.setTunFd).toHaveBeenCalledWith(1, ['A'])
})
it('preserves IPv4 when the DHCPv6 address changes or is withdrawn', async () => {
setConfig('A')
mocks.configs.get('A')!.dhcpv6 = true
setReady('A', '10.0.0.1')
setReadyIpv6('A', 'fd12::7')
const vpn = await loadVpnModule()
await vpn.onNetworkInstanceChange('A')
setReadyIpv6('A', 'fd13::8')
await vpn.onNetworkInstanceUpdate('A')
expect(mocks.stopVpn).toHaveBeenCalledOnce()
expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({
ipv4Addrs: ['10.0.0.1/24', 'fd13::8/64'],
routes: ['10.0.0.0/24', 'fd13:0:0:0:0:0:0:0/64'],
}))
setReady('A', '10.0.0.1')
await vpn.onNetworkInstanceUpdate('A')
expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({
ipv4Addrs: ['10.0.0.1/24'],
routes: ['10.0.0.0/24'],
}))
})
it('keeps IPv6 active after IPv4 is withdrawn and stops after both are lost', async () => {
setConfig('A')
mocks.configs.get('A')!.dhcp = true
mocks.configs.get('A')!.dhcpv6 = true
setReady('A', '10.0.0.1')
setReadyIpv6('A', 'fd12::7')
const vpn = await loadVpnModule()
await vpn.onNetworkInstanceChange('A')
mocks.networkInfo.set('A', { my_node_info: {}, routes: [] })
setReadyIpv6('A', 'fd12::7')
await vpn.onNetworkInstanceUpdate('A')
expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({
ipv4Addrs: ['fd12::7/64'],
}))
expect(mocks.setTunFd).toHaveBeenLastCalledWith(1, ['A'])
mocks.networkInfo.set('A', { my_node_info: {}, routes: [] })
await vpn.onNetworkInstanceUpdate('A')
expect(mocks.setTunFd).toHaveBeenLastCalledWith(0)
})
it('adds a DHCPv6 member to the shared VPN without blocking a ready IPv4 member', async () => {
setConfig('A')
setConfig('B')
setReady('A', '10.0.0.1')
mocks.configs.get('B')!.dhcpv6 = true
mocks.networkInfo.set('B', { my_node_info: {}, routes: [] })
const vpn = await loadVpnModule()
await vpn.onNetworkInstanceChange('A')
expect(mocks.startVpn).toHaveBeenCalledWith(expect.objectContaining({
ipv4Addrs: ['10.0.0.1/24'],
}))
setReadyIpv6('B', 'fd12::7')
await vpn.onNetworkInstanceUpdate('B')
expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({
ipv4Addrs: ['10.0.0.1/24', 'fd12::7/64'],
}))
expect(mocks.setTunFd).toHaveBeenLastCalledWith(1, ['A', 'B'])
})
it('preserves healthy members and reports a rejected bootstrap member', async () => {
setConfig('A')
setReady('A', '10.126.126.1')
+6 -5
View File
@@ -378,12 +378,13 @@ async function reconcileNetworkInstance(generation: number) {
}
const virtualIpv4 = curNetworkInfo.my_node_info?.virtual_ipv4
const virtualIp = virtualIpv4?.address?.addr ? Utils.ipv4ToString(virtualIpv4.address) : undefined
if (!virtualIp) {
if (!config.dhcp && !config.virtual_ipv4 && curNetworkInfo.my_node_info)
const virtualIpv6 = curNetworkInfo.my_node_info?.virtual_ipv6
const dhcp = config.dhcp || config.dhcpv6
if (!virtualIpv4?.address?.addr && !virtualIpv6?.address) {
if (!dhcp && !config.virtual_ipv4 && curNetworkInfo.my_node_info)
continue
retryReason ??= config.dhcp ? 'dhcp_ipv4_unavailable' : 'static_ipv4_unavailable'
if (!config.dhcp)
retryReason ??= dhcp ? 'dhcp_address_unavailable' : 'static_address_unavailable'
if (!dhcp)
networkInfoUnavailable = true
continue
}
+3
View File
@@ -108,6 +108,8 @@ message NetworkConfig {
optional bool prefer_peer_relay = 72;
// Bootstrap subnet used when DHCP has not learned a subnet from peers.
optional string dhcp_ipv4_cidr = 74;
optional bool dhcpv6 = 75;
optional string dhcp_ipv6_cidr = 76;
}
message ManagedCredentialConfig {
@@ -160,6 +162,7 @@ message MyNodeInfo {
repeated common.Url listeners = 6;
optional string vpn_portal_cfg = 7 [deprecated = true];
uint32 peer_id = 8;
common.Ipv6Inet virtual_ipv6 = 9;
}
message NetworkInstanceRunningInfo {
@@ -26,6 +26,7 @@ const {
const BOOLEAN_CONFIG_FIELDS = [
'dhcp',
'dhcpv6',
'advanced_settings',
'latency_first',
'use_smoltcp',
@@ -113,6 +114,9 @@ function allFieldFixture() {
...DEFAULT_NETWORK_CONFIG(),
instance_id: '11111111-2222-3333-4444-555555555555',
dhcp: false,
dhcp_ipv4_cidr: '10.9.8.0/25',
dhcpv6: true,
dhcp_ipv6_cidr: 'fd12::/64',
virtual_ipv4: '10.9.8.7',
network_length: 25,
hostname: 'frontend-e2e',
@@ -25,7 +25,7 @@ const eventValue = computed(() => {
<template>
<Fieldset :legend="t(`event.${eventKey}`)">
<template v-if="eventKey !== 'Unknown'">
<div v-if="event.DhcpIpv4Changed">
<div v-if="event.DhcpIpv4Changed || event.DhcpIpv6Changed">
{{ `${eventValue[0]} -> ${eventValue[1]}` }}
</div>
<pre v-else>{{ eventValue }}</pre>
@@ -342,6 +342,8 @@ event:
VpnPortalStarted: VPN门户已启动
VpnPortalClientConnected: VPN门户客户端已连接
VpnPortalClientDisconnected: VPN门户客户端已断开连接
DhcpIpv6Changed: DHCP IPv6地址更改
DhcpIpv6Conflicted: DHCP IPv6地址冲突
DhcpIpv4Changed: DHCP IPv4地址更改
DhcpIpv4Conflicted: DHCP IPv4地址冲突
PortForwardAdded: 端口转发添加
@@ -342,6 +342,8 @@ event:
VpnPortalStarted: VpnPortalStarted
VpnPortalClientConnected: VpnPortalClientConnected
VpnPortalClientDisconnected: VpnPortalClientDisconnected
DhcpIpv6Changed: DHCP IPv6 address changed
DhcpIpv6Conflicted: DHCP IPv6 address conflict
DhcpIpv4Changed: DhcpIpv4Changed
DhcpIpv4Conflicted: DhcpIpv4Conflicted
PortForwardAdded: PortForwardAdded
@@ -32,6 +32,7 @@ import {
Flags as FlagsPb,
NatType,
type PeerFeatureFlag,
type Ipv6Inet,
type SecureModeConfig,
} from '../generated/proto/common'
import { prepareNetworkConfigForProtoJson } from './networkCompat'
@@ -452,6 +453,7 @@ export interface Url {
export interface NodeInfo {
virtual_ipv4: Ipv4Inet,
virtual_ipv6?: Ipv6Inet,
hostname: string
version: string
ips: {
@@ -581,6 +583,8 @@ export enum EventType {
VpnPortalClientConnected = 'VpnPortalClientConnected', // string, string
VpnPortalClientDisconnected = 'VpnPortalClientDisconnected', // string, string, string
DhcpIpv6Changed = 'DhcpIpv6Changed',
DhcpIpv6Conflicted = 'DhcpIpv6Conflicted',
DhcpIpv4Changed = 'DhcpIpv4Changed', // ipv4 | null, ipv4 | null
DhcpIpv4Conflicted = 'DhcpIpv4Conflicted', // ipv4 | null
@@ -194,6 +194,8 @@ fn apply_member_override(
merged.instance_id = base.instance_id;
merged.dhcp = base.dhcp;
merged.dhcp_ipv4_cidr = base.dhcp_ipv4_cidr;
merged.dhcpv6 = base.dhcpv6;
merged.dhcp_ipv6_cidr = base.dhcp_ipv6_cidr;
merged.virtual_ipv4 = base.virtual_ipv4;
merged.network_length = base.network_length;
merged.hostname = base.hostname;
@@ -1256,6 +1258,8 @@ mod tests {
hostname: Some("tampered".to_owned()),
virtual_ipv4: Some("192.168.1.1".to_owned()),
dhcp_ipv4_cidr: Some("192.168.100.0/24".to_owned()),
dhcpv6: Some(true),
dhcp_ipv6_cidr: Some("fd12::/64".to_owned()),
managed_credentials: Vec::new(),
acl: None,
..Default::default()
@@ -1287,6 +1291,8 @@ mod tests {
);
assert_eq!(permanent.network_config.managed_credentials.len(), 2);
assert_eq!(permanent.network_config.dhcp_ipv4_cidr, None);
assert_eq!(permanent.network_config.dhcpv6, None);
assert_eq!(permanent.network_config.dhcp_ipv6_cidr, None);
assert!(permanent.network_config.acl.is_some());
}
}
+2
View File
@@ -422,11 +422,13 @@ endpoint-discovery = [
"easytier-core/endpoint-discovery",
]
dhcp-ipv4 = ["easytier-core/dhcp-ipv4"]
dhcp-ipv6 = ["easytier-core/dhcp-ipv6"]
public-ipv6-provider = ["linux-netlink", "easytier-core/public-ipv6-provider"]
vpn-portal = ["easytier-core/vpn-portal"]
wrapped-transport = ["easytier-core/wrapped-transport"]
extended-services = [
"dhcp-ipv4",
"dhcp-ipv6",
"public-ipv6-provider",
"vpn-portal",
"wrapped-transport",
+3
View File
@@ -52,6 +52,9 @@ core_clap:
dhcp:
en: "automatically assign an IPv4 address. Accepts true, false, or an IPv4 CIDR (e.g. --dhcp 192.168.100.0/24) for the initial subnet, defaulting to 10.126.126.0/24. Subnets advertised by peers take priority. Prefix lengths /31 and /32 are not supported. IP conflicts automatically trigger reassignment."
zh-CN: "自动分配 IPv4 地址。支持 true、false 或 IPv4 CIDR(如 --dhcp 192.168.100.0/24)指定初始网段,默认使用 10.126.126.0/24。优先使用对等节点公布的网段,不支持 /31 和 /32。IP 冲突时会自动重新分配。"
dhcpv6:
en: "automatically assign an IPv6 address. Accepts true, false, or an IPv6 CIDR for the initial subnet, defaulting to fd6b:8c2d:91e4::/64. Peer subnets take priority. Supports unicast subnets within fc00::/7 or 2000::/3 through /126, reserving the subnet endpoints. Conflicts trigger reassignment."
zh-CN: "自动分配 IPv6 地址。支持 true、false 或 IPv6 CIDR 指定初始网段,默认 fd6b:8c2d:91e4::/64。优先使用对等节点公布的网段。支持 fc00::/7 或 2000::/3 内最长 /126 的单播网段,保留网段首尾地址。冲突时自动重新分配。"
peers:
en: "peers to connect initially"
zh-CN: "最初要连接的对等节点"
+31 -2
View File
@@ -55,6 +55,8 @@ pub enum GlobalCtxEvent {
VpnPortalClientDisconnected(String, String), // (portal, client ip)
DhcpIpv4Changed(Option<cidr::Ipv4Inet>, Option<cidr::Ipv4Inet>), // (old, new)
DhcpIpv6Changed(Option<cidr::Ipv6Inet>, Option<cidr::Ipv6Inet>), // (old, new)
DhcpIpv6Conflicted(Option<cidr::Ipv6Inet>),
DhcpIpv4Conflicted(Option<cidr::Ipv4Inet>),
PublicIpv6Changed(Option<cidr::Ipv6Inet>, Option<cidr::Ipv6Inet>), // (old, new)
PublicIpv6RoutesUpdated(Vec<cidr::Ipv6Inet>, Vec<cidr::Ipv6Inet>), // (added, removed)
@@ -183,10 +185,10 @@ impl GlobalCtx {
.unwrap_or_else(|| config_fs.get_flags());
let ipv4 = runtime
.map(|runtime| Self::runtime_ipv4(&runtime.peer))
.unwrap_or_else(|| config_fs.get_ipv4());
.unwrap_or_else(|| config_fs.get_ipv4().filter(|_| !config_fs.get_dhcp()));
let ipv6 = runtime
.map(|runtime| Self::runtime_ipv6(&runtime.peer))
.unwrap_or_else(|| config_fs.get_ipv6());
.unwrap_or_else(|| config_fs.get_ipv6().filter(|_| !config_fs.get_dhcpv6()));
if flags.enable_encryption && effective_encryption_uses_xor(&flags.encryption_algorithm) {
tracing::warn!("using insecure XOR because no AEAD encryption is configured");
}
@@ -441,6 +443,33 @@ pub mod tests {
assert_eq!(global_ctx.get_ipv4(), Some(lease));
}
#[test]
fn dhcp_initial_addresses_match_normalized_runtime() {
for dhcp_ipv4 in [false, true] {
for dhcp_ipv6 in [false, true] {
let config =
TomlConfigLoader::new_from_str("ipv4 = '10.20.0.7/24'\nipv6 = 'fd12::7/64'")
.unwrap();
config.set_dhcp(dhcp_ipv4);
config.set_dhcpv6(dhcp_ipv6);
let global_ctx = GlobalCtx::new(config.clone());
let normalized =
easytier_core::instance::CoreInstanceConfig::from_toml(&config).unwrap();
let routes = &normalized.peer.snapshot.runtime.core.routes;
assert_eq!(global_ctx.get_ipv4().is_some(), !dhcp_ipv4);
assert_eq!(global_ctx.get_ipv6().is_some(), !dhcp_ipv6);
assert_eq!(
global_ctx.get_ipv4().map(|ip| ip.address().to_string()),
routes.ipv4.as_ref().map(|ip| ip.address.to_string())
);
assert_eq!(
global_ctx.get_ipv6().map(|ip| ip.address().to_string()),
routes.ipv6.as_ref().map(|ip| ip.address.to_string())
);
}
}
}
#[test]
fn runtime_state_does_not_rewrite_toml_config() {
let config = TomlConfigLoader::default();
+49
View File
@@ -221,6 +221,15 @@ struct NetworkOptions {
)]
dhcp: Option<DhcpConfig>,
#[arg(
long,
env = "ET_DHCPV6",
help = t!("core_clap.dhcpv6").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
dhcpv6: Option<DhcpConfig<cidr::Ipv6Cidr>>,
#[arg(
short,
long,
@@ -691,6 +700,9 @@ impl NetworkOptions {
if let Some(dhcp) = self.dhcp {
cfg.set_dhcp_config(dhcp);
}
if let Some(dhcp) = self.dhcpv6 {
cfg.set_dhcpv6_config(dhcp);
}
if let Some(ipv4) = &self.ipv4 {
cfg.set_ipv4(Some(ipv4.parse().with_context(|| {
@@ -1644,6 +1656,43 @@ mod tests {
}
}
#[test]
fn dhcpv6_cli_accepts_optional_bool_or_cidr() {
for (args, expected) in [
(vec!["--dhcpv6"], DhcpConfig::Enabled(true)),
(vec!["--dhcpv6=false"], DhcpConfig::Enabled(false)),
(
vec!["--dhcpv6", "fd12::/64"],
DhcpConfig::Cidr("fd12::/64".parse().unwrap()),
),
] {
let config = TomlConfigLoader::new_from_str("dhcp = true").unwrap();
let cli = Cli::try_parse_from(std::iter::once("easytier-core").chain(args)).unwrap();
cli.network_options.merge_into(&config).unwrap();
assert_eq!(config.get_dhcpv6_config(), expected);
assert!(config.get_dhcp());
let normalized =
easytier_core::instance::CoreInstanceConfig::from_toml(&config).unwrap();
assert_eq!(
normalized.connectivity.runtime.dhcp_ipv6,
expected.enabled()
);
assert_eq!(
normalized.connectivity.runtime.dhcp_ipv6_cidr,
expected.cidr()
);
}
for value in [
"yes",
"10.0.0.0/24",
"fd12::1/64",
"fe80::/64",
"fd12::/127",
] {
assert!(Cli::try_parse_from(["easytier-core", "--dhcpv6", value]).is_err());
}
}
#[test]
fn dhcp_cli_rejects_invalid_or_unusable_subnets() {
for value in [
@@ -187,6 +187,12 @@ fn log_event(instance_id: Uuid, event: GlobalCtxEvent) {
GlobalCtxEvent::DhcpIpv4Conflicted(ip) => {
event!(info, ?ip, "[{}] dhcp ip conflict", instance_id);
}
GlobalCtxEvent::DhcpIpv6Changed(old, new) => {
event!(info, ?old, ?new, "[{}] dhcp ipv6 changed", instance_id);
}
GlobalCtxEvent::DhcpIpv6Conflicted(ip) => {
event!(info, ?ip, "[{}] dhcp ipv6 conflict", instance_id);
}
GlobalCtxEvent::PublicIpv6Changed(old, new) => {
event!(info, ?old, ?new, "[{}] public ipv6 changed", instance_id);
}
+4 -3
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
#[cfg(feature = "web-client")]
use easytier_core::config::runtime::CoreInstanceRuntimeConfig;
use easytier_core::{
gateway::dhcp::DhcpIpv4Host, host::packet::HostPacketReceiver, instance::CorePacketPlane,
gateway::dhcp::DhcpHost, host::packet::HostPacketReceiver, instance::CorePacketPlane,
};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
@@ -12,6 +12,7 @@ use crate::common::global_ctx::ArcGlobalCtx;
#[cfg(feature = "tun")]
use crate::instance::shared_virtual_nic::ArcSharedVirtualNicRegistry;
mod dhcp;
mod event_journal;
mod implementation;
#[cfg(feature = "tun")]
@@ -83,7 +84,7 @@ impl NativeInstanceRuntimeHost {
async fn prepare_runtime(
&self,
packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
self.event_journal.start(self.cancel.clone()).await;
self.tun.prepare(packet_plane.clone()).await?;
Ok(Some(
@@ -130,7 +131,7 @@ impl NativeInstanceRuntimeHost {
&config.peer,
));
}
if patch.ipv6.is_some() {
if patch.ipv6.is_some() && !config.services.dhcp_ipv6 {
self.global_ctx
.set_ipv6(crate::common::global_ctx::GlobalCtx::runtime_ipv6(
&config.peer,
@@ -0,0 +1,89 @@
use easytier_core::gateway::dhcp::{DhcpAddresses, DhcpApplyOutcome, DhcpApplyPermit, DhcpHost};
#[cfg(feature = "tun")]
use super::tun_common::NativeDhcpHost;
#[cfg(not(feature = "tun"))]
use super::tun_runtime::NativeDhcpHost;
use crate::common::global_ctx::GlobalCtxEvent;
impl NativeDhcpHost {
pub(super) fn current(&self) -> DhcpAddresses {
DhcpAddresses {
ipv4: self
.global_ctx
.config
.get_dhcp()
.then(|| self.global_ctx.get_ipv4())
.flatten(),
ipv6: self
.global_ctx
.config
.get_dhcpv6()
.then(|| self.global_ctx.get_ipv6())
.flatten(),
}
}
#[cfg(all(feature = "tun", not(mobile)))]
pub(super) fn with_static_addresses(&self, mut addresses: DhcpAddresses) -> DhcpAddresses {
if !self.global_ctx.config.get_dhcp() {
addresses.ipv4 = self.global_ctx.config.get_ipv4();
}
if !self.global_ctx.config.get_dhcpv6() {
addresses.ipv6 = self.global_ctx.config.get_ipv6();
}
addresses
}
pub(super) fn set_addresses(&self, addresses: DhcpAddresses) {
// A static configuration patch uses a different operation lock. Never
// write a previously read static address back over such a patch.
if self.global_ctx.config.get_dhcp() {
self.global_ctx.set_ipv4(addresses.ipv4);
}
if self.global_ctx.config.get_dhcpv6() {
self.global_ctx.set_ipv6(addresses.ipv6);
}
}
pub(super) fn ensure_open(&self) -> anyhow::Result<()> {
if self.cancel.is_cancelled() {
anyhow::bail!("instance is closing; DHCP update cancelled");
}
Ok(())
}
}
#[async_trait::async_trait]
impl DhcpHost for NativeDhcpHost {
fn take_interface_closed(&self) -> bool {
self.take_closed()
}
async fn apply_dhcp(&self, next: DhcpAddresses) -> DhcpApplyOutcome {
let permit = self.operation.clone().lock_owned().await;
let result = self.apply(next).await;
let outcome = match result {
Ok(()) => DhcpApplyOutcome::applied(self.current()),
Err(error) => DhcpApplyOutcome::failed(self.current(), error),
};
outcome.with_permit(DhcpApplyPermit::new(permit))
}
fn publish_dhcp(&self, previous: DhcpAddresses, actual: DhcpAddresses) {
if self.global_ctx.config.get_dhcp() && previous.ipv4 != actual.ipv4 {
self.global_ctx.issue_event(if actual.ipv4.is_none() {
GlobalCtxEvent::DhcpIpv4Conflicted(previous.ipv4)
} else {
GlobalCtxEvent::DhcpIpv4Changed(previous.ipv4, actual.ipv4)
});
}
if self.global_ctx.config.get_dhcpv6() && previous.ipv6 != actual.ipv6 {
self.global_ctx.issue_event(if actual.ipv6.is_none() {
GlobalCtxEvent::DhcpIpv6Conflicted(previous.ipv6)
} else {
GlobalCtxEvent::DhcpIpv6Changed(previous.ipv6, actual.ipv6)
});
}
}
}
@@ -1,7 +1,7 @@
use std::sync::Arc;
use easytier_core::{
gateway::dhcp::DhcpIpv4Host,
gateway::dhcp::DhcpHost,
host::packet::HostPacketReceiver,
instance::{CorePacketPlane, InstanceRuntimeHost, PacketEgressHost},
};
@@ -17,7 +17,7 @@ impl InstanceRuntimeHost for NativeInstanceRuntimeHost {
async fn prepare(
&self,
packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
self.prepare_runtime(packet_plane).await
}
@@ -3,27 +3,20 @@ use std::{
sync::{Arc, OnceLock},
};
use cidr::Ipv4Inet;
#[cfg(not(mobile))]
use easytier_core::instance::CorePacketPlane;
use easytier_core::{
gateway::dhcp::{DhcpIpv4ApplyOutcome, DhcpIpv4ApplyPermit, DhcpIpv4Host},
host::packet::HostPacketReceiver,
};
use easytier_core::{gateway::dhcp::DhcpAddresses, host::packet::HostPacketReceiver};
#[cfg(not(mobile))]
use futures::FutureExt as _;
use tokio::{
sync::{Mutex, Notify},
task::JoinSet,
};
#[cfg(not(mobile))]
use tokio::sync::Notify;
use tokio::{sync::Mutex, task::JoinSet};
use tokio_util::sync::CancellationToken;
use super::MagicDnsRuntime;
#[cfg(not(mobile))]
use crate::instance::shared_virtual_nic::ArcSharedVirtualNicRegistry;
use crate::{
common::global_ctx::{ArcGlobalCtx, GlobalCtxEvent},
instance::virtual_nic::NicCtx,
};
use crate::{common::global_ctx::ArcGlobalCtx, instance::virtual_nic::NicCtx};
struct NicCtxContainer {
_nic_ctx: Option<Box<dyn Any + Send>>,
@@ -115,11 +108,12 @@ impl TunNicState {
}
}
pub(super) struct NativeDhcpIpv4Host {
pub(super) struct NativeDhcpHost {
pub(super) global_ctx: ArcGlobalCtx,
pub(super) operation: Arc<Mutex<()>>,
pub(super) cancel: CancellationToken,
pub(super) nic: TunNicState,
#[cfg(not(mobile))]
pub(super) closed: Arc<Notify>,
#[cfg(not(mobile))]
pub(super) packet_plane: Arc<CorePacketPlane>,
@@ -127,84 +121,69 @@ pub(super) struct NativeDhcpIpv4Host {
pub(super) shared_virtual_nic_registry: ArcSharedVirtualNicRegistry,
}
impl NativeDhcpIpv4Host {
fn ensure_open(&self) -> anyhow::Result<()> {
if self.cancel.is_cancelled() {
anyhow::bail!("instance is closing; DHCP update cancelled");
impl NativeDhcpHost {
pub(super) fn take_closed(&self) -> bool {
#[cfg(not(mobile))]
{
self.closed.notified().now_or_never().is_some()
}
#[cfg(mobile)]
{
// The mobile VPN owner recreates and attaches its interface in response
// to address events; desktop interface notifications do not apply here.
false
}
Ok(())
}
async fn apply(&self, next: Option<Ipv4Inet>) -> anyhow::Result<Option<Ipv4Inet>> {
pub(super) async fn apply(&self, next: DhcpAddresses) -> anyhow::Result<()> {
self.ensure_open()?;
// The static family may already have an attached VPN interface before
// DHCP's first round. No address event will rebuild it if nothing changed.
#[cfg(mobile)]
if next == self.current() {
return Ok(());
}
tokio::select! {
_ = self.cancel.cancelled() => anyhow::bail!("instance is closing; DHCP update cancelled"),
_ = self.nic.drain() => {}
}
#[cfg(not(mobile))]
self.set_addresses(DhcpAddresses::default());
self.ensure_open()?;
#[cfg(not(mobile))]
if let Some(ip) = next
&& !self.global_ctx.no_tun()
{
let mut nic = NicCtx::new(
self.global_ctx.clone(),
self.packet_plane.clone(),
self.nic.receiver(),
self.closed.clone(),
self.shared_virtual_nic_registry.clone(),
)
.await;
tokio::select! {
_ = self.cancel.cancelled() => anyhow::bail!("instance is closing; DHCP update cancelled"),
result = nic.run(Some(ip), self.global_ctx.get_ipv6()) => result?,
let addresses = self.with_static_addresses(next);
if !self.global_ctx.no_tun() && (addresses.ipv4.is_some() || addresses.ipv6.is_some()) {
let mut nic = NicCtx::new(
self.global_ctx.clone(),
self.packet_plane.clone(),
self.nic.receiver(),
self.closed.clone(),
self.shared_virtual_nic_registry.clone(),
)
.await;
tokio::select! {
_ = self.cancel.cancelled() => anyhow::bail!("instance is closing; DHCP update cancelled"),
result = nic.run(addresses.ipv4, addresses.ipv6) => result?,
}
let magic_dns = if let Some(ip) = addresses.ipv4 {
let shared_route_backend = nic.shared_route_backend_for_dns();
MagicDnsRuntime::start(
self.global_ctx.clone(),
self.packet_plane.clone(),
nic.ifname().await,
ip,
shared_route_backend,
)
} else {
MagicDnsRuntime::default()
};
self.nic.install(nic, magic_dns).await;
}
let shared_route_backend = nic.shared_route_backend_for_dns();
let magic_dns = MagicDnsRuntime::start(
self.global_ctx.clone(),
self.packet_plane.clone(),
nic.ifname().await,
ip,
shared_route_backend,
);
self.nic.install(nic, magic_dns).await;
}
self.global_ctx.set_ipv4(next);
Ok(next)
}
}
#[async_trait::async_trait]
impl DhcpIpv4Host for NativeDhcpIpv4Host {
fn take_interface_closed(&self) -> bool {
self.closed.notified().now_or_never().is_some()
}
async fn apply_dhcp_ipv4(
&self,
_previous: Option<Ipv4Inet>,
next: Option<Ipv4Inet>,
) -> DhcpIpv4ApplyOutcome {
let permit = self.operation.clone().lock_owned().await;
let outcome = match self.apply(next).await {
Ok(actual) => DhcpIpv4ApplyOutcome::applied(actual),
Err(error) => DhcpIpv4ApplyOutcome::failed(self.global_ctx.get_ipv4(), error),
};
outcome.with_permit(DhcpIpv4ApplyPermit::new(permit))
}
fn publish_dhcp_ipv4(
&self,
previous: Option<Ipv4Inet>,
requested: Option<Ipv4Inet>,
actual: Option<Ipv4Inet>,
) {
let event = if requested.is_none() {
GlobalCtxEvent::DhcpIpv4Conflicted(previous)
} else {
GlobalCtxEvent::DhcpIpv4Changed(previous, actual)
};
self.global_ctx.issue_event(event);
self.set_addresses(next);
Ok(())
}
}
@@ -213,12 +192,12 @@ mod tests {
use easytier_core::{config::toml::TomlConfig, process_runtime::CoreProcessRuntime};
use super::*;
use crate::instance::test_instance::TestInstance;
use crate::{common::global_ctx::GlobalCtxEvent, instance::test_instance::TestInstance};
#[tokio::test]
async fn dhcp_preserves_publication_permit_and_cancelled_address() {
let instance = TestInstance::new_with_process_runtime(
TomlConfig::default(),
TomlConfig::new_from_str("dhcp = true\ndhcpv6 = true").unwrap(),
CoreProcessRuntime::new(),
);
let global_ctx = instance.get_global_ctx();
@@ -241,21 +220,33 @@ mod tests {
instance.get_core_instance().packet_plane(),
);
let mut events = global_ctx.subscribe();
let address = "10.20.0.7/24".parse().unwrap();
let mut previous = None;
for next in [Some(address), None] {
let outcome = host.apply_dhcp_ipv4(previous, next).await;
let addresses = DhcpAddresses {
ipv4: Some("10.20.0.7/24".parse().unwrap()),
ipv6: Some("fd12::7/64".parse().unwrap()),
};
let mut previous = DhcpAddresses::default();
for next in [addresses, DhcpAddresses::default()] {
let outcome = host.apply_dhcp(next).await;
assert!(outcome.result.is_ok());
assert_eq!(outcome.actual, next);
assert_eq!(global_ctx.get_ipv4(), next);
assert_eq!(global_ctx.get_ipv4(), next.ipv4);
assert_eq!(global_ctx.get_ipv6(), next.ipv6);
assert!(operation.try_lock().is_err());
host.publish_dhcp_ipv4(previous, next, outcome.actual);
host.publish_dhcp(previous, outcome.actual);
assert_eq!(
events.try_recv().unwrap(),
if next.is_none() {
GlobalCtxEvent::DhcpIpv4Conflicted(previous)
if next.ipv4.is_none() {
GlobalCtxEvent::DhcpIpv4Conflicted(previous.ipv4)
} else {
GlobalCtxEvent::DhcpIpv4Changed(previous, next)
GlobalCtxEvent::DhcpIpv4Changed(previous.ipv4, next.ipv4)
}
);
assert_eq!(
events.try_recv().unwrap(),
if next.ipv6.is_none() {
GlobalCtxEvent::DhcpIpv6Conflicted(previous.ipv6)
} else {
GlobalCtxEvent::DhcpIpv6Changed(previous.ipv6, next.ipv6)
}
);
drop(outcome);
@@ -263,12 +254,14 @@ mod tests {
previous = next;
}
global_ctx.set_ipv4(Some(address));
global_ctx.set_ipv4(addresses.ipv4);
global_ctx.set_ipv6(addresses.ipv6);
cancel.cancel();
let outcome = host.apply_dhcp_ipv4(Some(address), None).await;
let outcome = host.apply_dhcp(DhcpAddresses::default()).await;
assert!(outcome.result.is_err());
assert_eq!(outcome.actual, Some(address));
assert_eq!(global_ctx.get_ipv4(), Some(address));
assert_eq!(outcome.actual, addresses);
assert_eq!(global_ctx.get_ipv4(), addresses.ipv4);
assert_eq!(global_ctx.get_ipv6(), addresses.ipv6);
runtime.shutdown().await;
}
}
@@ -2,7 +2,7 @@ use std::{sync::Arc, time::Duration};
use anyhow::Context as _;
use easytier_core::{
gateway::dhcp::DhcpIpv4Host, host::packet::HostPacketReceiver, instance::CorePacketPlane,
gateway::dhcp::DhcpHost, host::packet::HostPacketReceiver, instance::CorePacketPlane,
};
use tokio::{
sync::{Mutex, Notify, oneshot},
@@ -12,7 +12,7 @@ use tokio_util::sync::CancellationToken;
use super::{
MagicDnsRuntime,
tun_common::{NativeDhcpIpv4Host, TunNicState},
tun_common::{NativeDhcpHost, TunNicState},
};
use crate::{
common::{error::Error, global_ctx::ArcGlobalCtx},
@@ -139,7 +139,10 @@ impl NativeTunRuntime {
pub(super) async fn prepare(&self, packet_plane: Arc<CorePacketPlane>) -> anyhow::Result<()> {
self.nic.drain().await;
if !self.global_ctx.get_flags().no_tun {
if !self.global_ctx.get_flags().no_tun
&& !self.global_ctx.config.get_dhcp()
&& !self.global_ctx.config.get_dhcpv6()
{
self.start_static_ip(packet_plane).await?;
}
Ok(())
@@ -160,8 +163,8 @@ impl NativeTunRuntime {
&self,
operation: Arc<Mutex<()>>,
packet_plane: Arc<CorePacketPlane>,
) -> Arc<dyn DhcpIpv4Host> {
Arc::new(NativeDhcpIpv4Host {
) -> Arc<dyn DhcpHost> {
Arc::new(NativeDhcpHost {
global_ctx: self.global_ctx.clone(),
operation,
cancel: self.cancel.clone(),
@@ -1,15 +1,14 @@
use std::sync::Arc;
use cidr::Ipv4Inet;
use easytier_core::{
gateway::dhcp::{DhcpIpv4ApplyOutcome, DhcpIpv4ApplyPermit, DhcpIpv4Host},
gateway::dhcp::{DhcpAddresses, DhcpHost},
host::packet::HostPacketReceiver,
instance::CorePacketPlane,
};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use crate::common::global_ctx::{ArcGlobalCtx, GlobalCtxEvent};
use crate::common::global_ctx::ArcGlobalCtx;
pub(super) struct NativeTunRuntime {
global_ctx: ArcGlobalCtx,
@@ -43,8 +42,8 @@ impl NativeTunRuntime {
&self,
operation: Arc<Mutex<()>>,
_packet_plane: Arc<CorePacketPlane>,
) -> Arc<dyn DhcpIpv4Host> {
Arc::new(NativeDhcpIpv4Host {
) -> Arc<dyn DhcpHost> {
Arc::new(NativeDhcpHost {
global_ctx: self.global_ctx.clone(),
operation,
cancel: self.cancel.clone(),
@@ -52,61 +51,20 @@ impl NativeTunRuntime {
}
}
struct NativeDhcpIpv4Host {
global_ctx: ArcGlobalCtx,
operation: Arc<Mutex<()>>,
cancel: CancellationToken,
pub(super) struct NativeDhcpHost {
pub(super) global_ctx: ArcGlobalCtx,
pub(super) operation: Arc<Mutex<()>>,
pub(super) cancel: CancellationToken,
}
impl NativeDhcpIpv4Host {
fn ensure_open(&self) -> anyhow::Result<()> {
if self.cancel.is_cancelled() {
anyhow::bail!("instance is closing; DHCP update cancelled");
}
Ok(())
}
async fn apply(&self, next: Option<Ipv4Inet>) -> anyhow::Result<Option<Ipv4Inet>> {
self.ensure_open()?;
let Some(ip) = next else {
self.global_ctx.set_ipv4(None);
return Ok(None);
};
self.global_ctx.set_ipv4(Some(ip));
Ok(Some(ip))
}
}
#[async_trait::async_trait]
impl DhcpIpv4Host for NativeDhcpIpv4Host {
fn take_interface_closed(&self) -> bool {
impl NativeDhcpHost {
pub(super) fn take_closed(&self) -> bool {
false
}
async fn apply_dhcp_ipv4(
&self,
_previous: Option<Ipv4Inet>,
next: Option<Ipv4Inet>,
) -> DhcpIpv4ApplyOutcome {
let permit = self.operation.clone().lock_owned().await;
let outcome = match self.apply(next).await {
Ok(actual) => DhcpIpv4ApplyOutcome::applied(actual),
Err(error) => DhcpIpv4ApplyOutcome::failed(self.global_ctx.get_ipv4(), error),
};
outcome.with_permit(DhcpIpv4ApplyPermit::new(permit))
}
fn publish_dhcp_ipv4(
&self,
previous: Option<Ipv4Inet>,
requested: Option<Ipv4Inet>,
actual: Option<Ipv4Inet>,
) {
let event = if requested.is_none() {
GlobalCtxEvent::DhcpIpv4Conflicted(previous)
} else {
GlobalCtxEvent::DhcpIpv4Changed(previous, actual)
};
self.global_ctx.issue_event(event);
pub(super) async fn apply(&self, next: DhcpAddresses) -> anyhow::Result<()> {
self.ensure_open()?;
self.set_addresses(next);
Ok(())
}
}
@@ -2,14 +2,14 @@ use std::sync::Arc;
use anyhow::Context as _;
use easytier_core::{
gateway::dhcp::DhcpIpv4Host, host::packet::HostPacketReceiver, instance::CorePacketPlane,
gateway::dhcp::DhcpHost, host::packet::HostPacketReceiver, instance::CorePacketPlane,
};
use tokio::sync::{Mutex, Notify, mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use super::{
MagicDnsRuntime,
tun_common::{NativeDhcpIpv4Host, TunNicState},
tun_common::{NativeDhcpHost, TunNicState},
};
use crate::{
common::global_ctx::ArcGlobalCtx,
@@ -187,13 +187,12 @@ impl NativeTunRuntime {
&self,
operation: Arc<Mutex<()>>,
_packet_plane: Arc<CorePacketPlane>,
) -> Arc<dyn DhcpIpv4Host> {
Arc::new(NativeDhcpIpv4Host {
) -> Arc<dyn DhcpHost> {
Arc::new(NativeDhcpHost {
global_ctx: self.global_ctx.clone(),
operation,
cancel: self.cancel.clone(),
nic: self.nic.clone(),
closed: Arc::new(Notify::new()),
})
}
+78 -12
View File
@@ -178,8 +178,13 @@ fn ipv4_route_exists_in_ns(ns: &str, needle: &str) -> bool {
}
fn tun_ifindex(ns: &str, dev_name: &str) -> Option<u64> {
let output = Command::new("ip")
.args(["-n", ns, "-j", "link", "show", "dev", dev_name])
let _root = NetNS::new(Some(ROOT_NETNS_NAME.to_owned())).guard();
let mut command = Command::new("ip");
if ns != ROOT_NETNS_NAME {
command.args(["-n", ns]);
}
let output = command
.args(["-j", "link", "show", "dev", dev_name])
.output()
.unwrap();
if !output.status.success() {
@@ -545,46 +550,69 @@ fn shared_tun_claim_cleanup_outlives_member_runtime() {
));
}
#[rstest::rstest]
#[case::static_ipv4(false)]
#[case::with_dhcpv6(true)]
#[tokio::test]
#[serial_test::serial]
async fn same_namespace_members_share_tun_across_independent_networks() {
async fn same_namespace_members_share_tun_across_independent_networks(#[case] dhcpv6: bool) {
let dev_name = test_dev_name();
let first_peer_ns = TestNetnsGuard::new(short_name("sva"), "10.231.1.2/24", "fd31::2/64");
let second_peer_ns = TestNetnsGuard::new(short_name("svb"), "10.231.2.2/24", "fd32::2/64");
let runtime = SharedTestRuntime::new();
let mut first = runtime.instance(test_config(
let first_config = test_config(
"shared_tun_first",
"shared_tun_network_a",
"shared_tun_secret_a",
None,
Some(&dev_name),
"10.144.250.1/24",
));
let mut second = runtime.instance(test_config(
);
if dhcpv6 {
first_config.set_dhcpv6_config(easytier_core::config::DhcpConfig::Cidr(
"fd42:a::/64".parse().unwrap(),
));
}
let mut first = runtime.instance(first_config);
let second_config = test_config(
"shared_tun_second",
"shared_tun_network_b",
"shared_tun_secret_b",
None,
Some(&dev_name),
"10.144.251.1/24",
));
let mut first_peer = runtime.instance(test_config(
);
if dhcpv6 {
second_config.set_dhcpv6_config(easytier_core::config::DhcpConfig::Cidr(
"fd42:b::/64".parse().unwrap(),
));
}
let mut second = runtime.instance(second_config);
let first_peer_config = test_config(
"shared_tun_first_peer",
"shared_tun_network_a",
"shared_tun_secret_a",
Some(&first_peer_ns.name),
None,
"10.144.250.2/24",
));
let mut second_peer = runtime.instance(test_config(
);
if dhcpv6 {
first_peer_config.set_ipv6(Some("fd42:a::2/64".parse().unwrap()));
}
let mut first_peer = runtime.instance(first_peer_config);
let second_peer_config = test_config(
"shared_tun_second_peer",
"shared_tun_network_b",
"shared_tun_secret_b",
Some(&second_peer_ns.name),
None,
"10.144.251.2/24",
));
);
if dhcpv6 {
second_peer_config.set_ipv6(Some("fd42:b::2/64".parse().unwrap()));
}
let mut second_peer = runtime.instance(second_peer_config);
first.run().await.unwrap();
second.run().await.unwrap();
@@ -630,7 +658,45 @@ async fn same_namespace_members_share_tun_across_independent_networks() {
)
.await;
drop_insts(vec![first, second, first_peer, second_peer]).await;
if dhcpv6 {
for (instance, ns) in [
(&first, &first_peer_ns.name),
(&second, &second_peer_ns.name),
] {
wait_for_condition(
|| async { instance.get_global_ctx().get_ipv6().is_some() },
WAIT,
)
.await;
let address = instance
.get_global_ctx()
.get_ipv6()
.unwrap()
.address()
.to_string();
wait_for_condition(|| async { ping_test(ns, &address, None).await }, WAIT).await;
}
// Removing one DHCP member must leave the other member's addresses
// and the physical TUN intact.
let original_index = tun_ifindex(ROOT_NETNS_NAME, &dev_name);
assert!(original_index.is_some());
drop_insts(vec![first]).await;
assert_eq!(tun_ifindex(ROOT_NETNS_NAME, &dev_name), original_index);
let address = second
.get_global_ctx()
.get_ipv6()
.unwrap()
.address()
.to_string();
wait_for_condition(
|| async { ping_test(&second_peer_ns.name, &address, None).await },
WAIT,
)
.await;
drop_insts(vec![second, first_peer, second_peer]).await;
} else {
drop_insts(vec![first, second, first_peer, second_peer]).await;
}
}
#[cfg(feature = "proxy-cidr-monitor")]
+131
View File
@@ -5350,3 +5350,134 @@ pub async fn relay_peer_session_cleanup() {
drop(core_1);
drop_insts(insts).await;
}
#[rstest::rstest]
#[case::ipv6_only(false, false)]
#[case::dual_stack(true, false)]
#[case::static_ipv4(false, true)]
#[tokio::test]
#[serial_test::serial]
pub async fn dhcpv6_three_node_connectivity(#[case] dhcp_ipv4: bool, #[case] static_ipv4: bool) {
let network = prepare_linux_namespaces();
let insts = init_three_node_ex(
&network,
"udp",
|cfg| {
if !static_ipv4 {
cfg.set_ipv4(None);
}
cfg.set_ipv6(None);
cfg.set_dhcp(dhcp_ipv4);
cfg.set_dhcpv6_config(easytier_core::config::DhcpConfig::Cidr(
"fd42:1234::/64".parse().unwrap(),
));
cfg
},
false,
)
.await;
wait_for_condition(
|| async {
insts.iter().all(|inst| {
inst.get_global_ctx().get_ipv6().is_some()
&& (!dhcp_ipv4 || inst.get_global_ctx().get_ipv4().is_some())
})
},
Duration::from_secs(30),
)
.await;
let addresses = insts
.iter()
.map(|inst| inst.get_global_ctx().get_ipv6().unwrap())
.collect::<Vec<_>>();
assert_eq!(
addresses
.iter()
.collect::<std::collections::HashSet<_>>()
.len(),
3
);
for address in &addresses {
assert_eq!(address.network(), "fd42:1234::/64".parse().unwrap());
}
wait_for_condition(
|| async {
insts[0]
.get_core_instance()
.route_snapshots()
.await
.iter()
.all(|route| route.ipv6_addr.is_some())
},
Duration::from_secs(15),
)
.await;
for (index, inst) in insts.iter().enumerate() {
let info = easytier_core::management::network_instance_running_info(
inst.get_core_instance().as_ref(),
)
.await
.unwrap();
assert_eq!(
info.my_node_info.unwrap().virtual_ipv6,
Some(addresses[index].into())
);
easytier_core::management::apply_config_patch(
&inst.get_core_instance(),
easytier_proto::api::config::InstanceConfigPatch {
ipv6: Some("fd55::1/64".parse::<cidr::Ipv6Inet>().unwrap().into()),
..Default::default()
},
None,
)
.await
.unwrap();
assert_eq!(inst.get_global_ctx().get_ipv6(), Some(addresses[index]));
if static_ipv4 {
assert_eq!(
inst.get_global_ctx()
.get_ipv4()
.unwrap()
.address()
.to_string(),
format!("10.144.144.{}", index + 1)
);
} else if !dhcp_ipv4 {
assert!(inst.get_global_ctx().get_ipv4().is_none());
}
}
_tunnel_pingpong_netns_with_timeout(
core_tcp_listener("tcp://[::]:23461".parse().unwrap()),
core_tcp_dialer(
format!("tcp://[{}]:23461", addresses[2].address())
.parse()
.unwrap(),
),
NetNS::new(Some(network.namespace("net_c").into())),
NetNS::new(Some(network.namespace("net_a").into())),
vec![0x6; 256],
Duration::from_secs(10),
)
.await
.unwrap();
if dhcp_ipv4 || static_ipv4 {
_tunnel_pingpong_netns_with_timeout(
core_tcp_listener("tcp://0.0.0.0:23462".parse().unwrap()),
core_tcp_dialer(
format!(
"tcp://{}:23462",
insts[2].get_global_ctx().get_ipv4().unwrap().address()
)
.parse()
.unwrap(),
),
NetNS::new(Some(network.namespace("net_c").into())),
NetNS::new(Some(network.namespace("net_a").into())),
vec![0x4; 256],
Duration::from_secs(10),
)
.await
.unwrap();
}
drop_insts(insts).await;
}
@@ -40,9 +40,15 @@ class TauriVpnService : VpnService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
println("vpn on start command ${intent?.getExtras()} $intent")
startVpnForegroundService()
var args = intent?.getExtras()
ipv4Addrs = getIpv4Addrs(args)
// A sticky restart can have no Intent after process death. The VPN
// owner must supply the active addresses before creating an interface.
if (ipv4Addrs.isEmpty()) {
stopSelf(startId)
return START_NOT_STICKY
}
startVpnForegroundService()
ipv4Addr = ipv4Addrs.firstOrNull()
routes = args?.getStringArray(ROUTES) ?: emptyArray()
dns = args?.getString(DNS)
@@ -174,7 +180,7 @@ class TauriVpnService : VpnService() {
return ipv4Addrs
}
return arrayOf(args?.getString(IPV4_ADDR) ?: "10.126.126.1/24")
return listOfNotNull(args?.getString(IPV4_ADDR)?.takeIf { it.isNotBlank() }).toTypedArray()
}
private fun createVpnInterface(args: Bundle?): ParcelFileDescriptor {