From 25f6e2dc5e3b4b22057e4c59edde3a158b499be0 Mon Sep 17 00:00:00 2001 From: KKRainbow <443152178@qq.com> Date: Sat, 29 Aug 2026 13:40:56 +0800 Subject: [PATCH] feat: stabilize mobile runtime and VPN portal (#2536) --- CONTEXT.md | 23 +- Cargo.lock | 1 + docs/core-architecture.md | 5 + easytier-contrib/easytier-ios/Cargo.toml | 1 + easytier-contrib/easytier-ios/easytier-ios.h | 30 +- .../easytier-ios/src/diagnostic_logging.rs | 308 ++++++ easytier-contrib/easytier-ios/src/lib.rs | 190 +++- easytier-core/src/config/api_input.rs | 4 +- easytier-core/src/config/toml.rs | 4 +- easytier-core/src/gateway/vpn_portal.rs | 7 +- .../src/gateway/vpn_portal/ipv4_translator.rs | 974 ------------------ .../src/gateway/vpn_portal/runtime.rs | 244 +++-- easytier-core/src/instance/lifecycle.rs | 7 + easytier-core/src/instance/tests.rs | 227 +++- .../src/management/full/config_patch.rs | 110 +- .../src/management/instance_rpc/full.rs | 4 +- .../src/management/instance_rpc/mod.rs | 41 +- easytier-core/src/peers/attached.rs | 85 +- easytier-core/src/peers/peer_manager.rs | 7 + .../src/peers/route/peer_ospf_route.rs | 188 +++- easytier-web/frontend-lib/src/locales/cn.yaml | 4 +- easytier-web/frontend-lib/src/locales/en.yaml | 4 +- .../frontend-lib/tests/config-ui.spec.ts | 10 +- .../src/client_manager/runtime_reconcile.rs | 26 +- .../session/runtime_revision.rs | 76 +- easytier/locales/app.yml | 4 +- easytier/src/core.rs | 28 +- easytier/src/easytier-cli.rs | 23 +- easytier/src/tests/three_node.rs | 52 +- easytier/src/vpn_portal/wireguard.rs | 8 +- easytier/src/vpn_portal/wireguard/engine.rs | 22 +- 31 files changed, 1415 insertions(+), 1302 deletions(-) create mode 100644 easytier-contrib/easytier-ios/src/diagnostic_logging.rs delete mode 100644 easytier-core/src/gateway/vpn_portal/ipv4_translator.rs diff --git a/CONTEXT.md b/CONTEXT.md index b85797b5..3c1a477a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,6 +39,13 @@ adjacency so direct-destination fallback remains available. Other peers' source-owned rows and versions are never rewritten, cached for promotion, or otherwise changed by this projection. +Before a graceful Instance stop, the owner publishes a new-version empty +connection row while keeping its physical adjacencies available for route +synchronization. It waits for the current direct route Sessions to acknowledge +that withdrawal up to a bounded deadline, then continues shutdown. Abrupt +process loss cannot publish this withdrawal and retains the normal route +expiry behavior. + Relay eligibility comes from the transport-authenticated credential identity and grant, not self-reported route metadata. The advertisement Module does not support changing a credential's relay permission in place; such a permission @@ -52,6 +59,17 @@ authenticated portal client owns one complete peer manager. The managers are protocol peers; `attached` describes only the local transport and its trusted ingress provenance, not a parent/child peer role. +An attached peer owns one complete IPv4 CIDR (for example `10.144.0.5/16`). +Its address and advertised network are independent of the network manager's +own static or DHCP address. A VPN portal derives the attached peer route and +the external client's allowed network from that single CIDR; it does not infer +either value from the portal-hosting instance. + +An external portal client uses that same IPv4 address on its native tunnel +interface. The portal validates the source address and forwards IPv4 packets +unchanged between the native tunnel and the attached peer; it does not assign +a second tunnel-only address or perform address translation. + Each manager owns its ACL execution state, route service, RPC endpoint, secure sessions, packet processing, and lifecycle. Portal code supplies raw packets and peer configuration but does not build, reload, or coordinate ACL filters. @@ -61,7 +79,10 @@ credential peer. Its portal-owned, in-memory credential grant carries ACL groups and is revoked with the attached runtime; the peer never receives the network secret or ACL group secrets. A non-Secure-Mode network retains the legacy admin-attached identity for compatibility. A credential peer cannot host -a portal because it cannot issue credential grants. +a portal because it cannot issue credential grants. Each live portal Session +owns a fresh attached-peer identity, while the external client key remains +stable across Sessions; a replacement Session must never reuse the previous +non-reusable credential identity. ## Compact compatibility Host diff --git a/Cargo.lock b/Cargo.lock index 1ff1e5f2..6c00510f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2549,6 +2549,7 @@ dependencies = [ "easytier-ffi", "serde_json", "tokio", + "tracing", "tracing-subscriber", "uuid", ] diff --git a/docs/core-architecture.md b/docs/core-architecture.md index b7458b9b..a717cdd4 100644 --- a/docs/core-architecture.md +++ b/docs/core-architecture.md @@ -280,6 +280,11 @@ Generated service descriptors and message types remain in `easytier-proto`. - VPN portal client/session policy; - UDP broadcast classification and rewrite policy. +Each VPN portal client is normalized to one attached-peer IPv4 CIDR. The +portable gateway owns that client address and prefix; the hosting network +manager's DHCP or static address is not a source of portal client routing +facts. + TUN, raw sockets, transparent-destination lookup, concrete protocol engines, native DNS servers, namespace operations, and route application stay in native Adapters. diff --git a/easytier-contrib/easytier-ios/Cargo.toml b/easytier-contrib/easytier-ios/Cargo.toml index d984e613..5f465ba8 100644 --- a/easytier-contrib/easytier-ios/Cargo.toml +++ b/easytier-contrib/easytier-ios/Cargo.toml @@ -8,6 +8,7 @@ crate-type = ["staticlib", "rlib"] [dependencies] serde_json = "1.0" +tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } easytier-ffi = { path = "../easytier-ffi", default-features = false, features = [ "c-abi", diff --git a/easytier-contrib/easytier-ios/easytier-ios.h b/easytier-contrib/easytier-ios/easytier-ios.h index 87c283c8..e1295569 100644 --- a/easytier-contrib/easytier-ios/easytier-ios.h +++ b/easytier-contrib/easytier-ios/easytier-ios.h @@ -28,14 +28,34 @@ extern "C" { #endif /** - * @brief Enable diagnostic EasyTier logging to stderr. + * @brief Configure persistent EasyTier diagnostic logging. * - * Installs a narrow global tracing subscriber that records port-forward - * lifecycle events. Repeated calls are idempotent. + * Enabling writes targeted connection trace/debug events into rotating log + * files in `directory`; disabling turns the filter off and flushes output. * - * @return 0 on success, -1 if another tracing subscriber was installed first. + * @param directory UTF-8 directory path. Required when enabling; ignored when + * disabling. + * @param enabled Non-zero to enable, zero to disable. + * @return 0 on success, -1 on failure. */ -int easytier_ios_enable_diagnostic_logging(void); +int easytier_ios_configure_diagnostic_logging(const char *directory, + int enabled); + +/** + * @brief Append a host lifecycle or network-path marker to the active log. + * + * This is a no-op while diagnostic logging is disabled. + * + * @param message Non-null NUL-terminated UTF-8 event text. + * @return 0 on success, -1 on failure. + */ +int easytier_ios_append_diagnostic_event(const char *message); + +/** @brief Flush diagnostic log output. */ +int easytier_ios_flush_diagnostic_logging(void); + +/** @brief Delete all diagnostic log content and reopen the active log. */ +int easytier_ios_clear_diagnostic_logs(void); /** * @brief Start one EasyTier network instance from a TOML config string. diff --git a/easytier-contrib/easytier-ios/src/diagnostic_logging.rs b/easytier-contrib/easytier-ios/src/diagnostic_logging.rs new file mode 100644 index 00000000..8ee24cf0 --- /dev/null +++ b/easytier-contrib/easytier-ios/src/diagnostic_logging.rs @@ -0,0 +1,308 @@ +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Write}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use tracing_subscriber::fmt::MakeWriter; + +pub(crate) const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024; +pub(crate) const MAX_LOG_FILES: usize = 4; + +#[derive(Clone)] +pub(crate) struct DiagnosticMakeWriter { + inner: Arc>, +} + +impl DiagnosticMakeWriter { + pub(crate) fn new(directory: &Path) -> io::Result { + Ok(Self { + inner: Arc::new(Mutex::new(RotatingLog::open(directory)?)), + }) + } + + pub(crate) fn set_directory(&self, directory: &Path) -> io::Result<()> { + self.lock()?.set_directory(directory) + } + + pub(crate) fn clear(&self) -> io::Result<()> { + self.lock()?.clear() + } + + pub(crate) fn flush(&self) -> io::Result<()> { + self.lock()?.flush() + } + + fn lock(&self) -> io::Result> { + self.inner + .lock() + .map_err(|_| io::Error::other("diagnostic log lock poisoned")) + } +} + +impl<'a> MakeWriter<'a> for DiagnosticMakeWriter { + type Writer = BufferedEventWriter; + + fn make_writer(&'a self) -> Self::Writer { + BufferedEventWriter { + target: self.clone(), + buffer: Vec::new(), + } + } +} + +pub(crate) struct BufferedEventWriter { + target: DiagnosticMakeWriter, + buffer: Vec, +} + +impl BufferedEventWriter { + fn commit(&mut self) -> io::Result<()> { + if self.buffer.is_empty() { + return Ok(()); + } + let buffer = std::mem::take(&mut self.buffer); + self.target.lock()?.write_event(&buffer) + } +} + +impl Write for BufferedEventWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.buffer.extend_from_slice(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.commit() + } +} + +impl Drop for BufferedEventWriter { + fn drop(&mut self) { + let _ = self.commit(); + } +} + +struct RotatingLog { + directory: PathBuf, + active: Option, + active_bytes: u64, +} + +impl RotatingLog { + fn open(directory: &Path) -> io::Result { + fs::create_dir_all(directory)?; + let mut log = Self { + directory: directory.to_owned(), + active: None, + active_bytes: 0, + }; + log.truncate_oversized_files()?; + log.open_active()?; + Ok(log) + } + + fn set_directory(&mut self, directory: &Path) -> io::Result<()> { + if self.directory == directory && self.active.is_some() { + return Ok(()); + } + self.flush()?; + self.active = None; + self.directory = directory.to_owned(); + fs::create_dir_all(directory)?; + self.truncate_oversized_files()?; + self.open_active() + } + + fn active_path(&self) -> PathBuf { + self.directory.join("easytier.log") + } + + fn rotated_path(&self, index: usize) -> PathBuf { + self.directory.join(format!("easytier.{index}.log")) + } + + fn truncate_oversized_files(&self) -> io::Result<()> { + let paths = std::iter::once(self.active_path()) + .chain((1..MAX_LOG_FILES).map(|index| self.rotated_path(index))); + for path in paths { + if path + .metadata() + .is_ok_and(|metadata| metadata.len() > MAX_LOG_BYTES) + { + OpenOptions::new() + .write(true) + .open(path)? + .set_len(MAX_LOG_BYTES)?; + } + } + Ok(()) + } + + fn open_active(&mut self) -> io::Result<()> { + let path = self.active_path(); + let file = OpenOptions::new().create(true).append(true).open(&path)?; + self.active_bytes = file.metadata()?.len(); + self.active = Some(file); + if self.active_bytes >= MAX_LOG_BYTES { + self.rotate()?; + } + Ok(()) + } + + fn write_event(&mut self, event: &[u8]) -> io::Result<()> { + if event.is_empty() { + return Ok(()); + } + if self.active_bytes > 0 + && self.active_bytes.saturating_add(event.len() as u64) > MAX_LOG_BYTES + { + self.rotate()?; + } + let remaining = MAX_LOG_BYTES.saturating_sub(self.active_bytes) as usize; + let event = &event[..event.len().min(remaining)]; + if let Some(active) = self.active.as_mut() { + active.write_all(event)?; + self.active_bytes += event.len() as u64; + } + Ok(()) + } + + fn rotate(&mut self) -> io::Result<()> { + self.flush()?; + self.active = None; + + let oldest = self.rotated_path(MAX_LOG_FILES - 1); + if oldest.exists() { + fs::remove_file(oldest)?; + } + for index in (1..MAX_LOG_FILES - 1).rev() { + let source = self.rotated_path(index); + if source.exists() { + fs::rename(source, self.rotated_path(index + 1))?; + } + } + let active = self.active_path(); + if active.exists() { + fs::rename(active, self.rotated_path(1))?; + } + self.active_bytes = 0; + self.active = Some( + OpenOptions::new() + .create(true) + .append(true) + .open(self.active_path())?, + ); + Ok(()) + } + + fn clear(&mut self) -> io::Result<()> { + self.flush()?; + self.active = None; + for index in 1..MAX_LOG_FILES { + let path = self.rotated_path(index); + if path.exists() { + fs::remove_file(path)?; + } + } + let active = self.active_path(); + if active.exists() { + fs::remove_file(&active)?; + } + self.active_bytes = 0; + self.active = Some(OpenOptions::new().create(true).append(true).open(active)?); + Ok(()) + } + + fn flush(&mut self) -> io::Result<()> { + match self.active.as_mut() { + Some(active) => active.flush(), + None => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TempDir(PathBuf); + + impl TempDir { + fn new(name: &str) -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("easytier-ios-{name}-{unique}")); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn rotates_without_exceeding_file_limit() { + let directory = TempDir::new("rotation"); + let mut log = RotatingLog::open(&directory.0).unwrap(); + let event = vec![b'x'; (MAX_LOG_BYTES / 2 + 1) as usize]; + + for _ in 0..6 { + log.write_event(&event).unwrap(); + } + log.flush().unwrap(); + + let files = fs::read_dir(&directory.0) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(files.len(), MAX_LOG_FILES); + assert!( + files + .iter() + .all(|entry| entry.metadata().unwrap().len() <= MAX_LOG_BYTES) + ); + } + + #[test] + fn clear_removes_rotated_content_and_keeps_active_file_writable() { + let directory = TempDir::new("clear"); + let mut log = RotatingLog::open(&directory.0).unwrap(); + let event = vec![b'x'; (MAX_LOG_BYTES / 2 + 1) as usize]; + log.write_event(&event).unwrap(); + log.write_event(&event).unwrap(); + + log.clear().unwrap(); + log.write_event(b"after clear\n").unwrap(); + log.flush().unwrap(); + + assert_eq!(fs::read(log.active_path()).unwrap(), b"after clear\n"); + assert!(!log.rotated_path(1).exists()); + } + + #[test] + fn opening_truncates_oversized_known_files() { + let directory = TempDir::new("oversized"); + for name in ["easytier.log", "easytier.1.log"] { + let file = File::create(directory.0.join(name)).unwrap(); + file.set_len(MAX_LOG_BYTES + 1).unwrap(); + } + + let log = RotatingLog::open(&directory.0).unwrap(); + + for index in 1..MAX_LOG_FILES { + let path = log.rotated_path(index); + if path.exists() { + assert!(path.metadata().unwrap().len() <= MAX_LOG_BYTES); + } + } + assert!(log.active_path().metadata().unwrap().len() <= MAX_LOG_BYTES); + } +} diff --git a/easytier-contrib/easytier-ios/src/lib.rs b/easytier-contrib/easytier-ios/src/lib.rs index c238b24c..de2a8959 100644 --- a/easytier-contrib/easytier-ios/src/lib.rs +++ b/easytier-contrib/easytier-ios/src/lib.rs @@ -12,43 +12,186 @@ //! All exported functions are panic-safe: panics are caught at the FFI //! boundary and reported through `easytier_ios_last_error`. +mod diagnostic_logging; mod error; mod strings; use std::{ ffi::{CStr, c_char, c_int}, panic::{AssertUnwindSafe, catch_unwind}, + path::Path, ptr, sync::OnceLock, }; +use diagnostic_logging::DiagnosticMakeWriter; use strings::cstring_for; +use tracing_subscriber::{ + EnvFilter, Registry, layer::SubscriberExt, reload, util::SubscriberInitExt, +}; -/// Install a narrow tracing subscriber for an embedding app that needs to -/// diagnose EasyTier transport failures. The subscriber writes port-forward -/// lifecycle events to stderr. +const DIAGNOSTIC_FILTER: &str = concat!( + "easytier_ios::diagnostics=trace,", + "easytier_core::instance=trace,", + "easytier_core::connectivity=debug,", + "easytier_core::socket::udp=debug,", + "easytier_core::gateway::port_forward=trace" +); + +struct DiagnosticLogger { + writer: DiagnosticMakeWriter, + filter: reload::Handle, +} + +impl DiagnosticLogger { + fn install(directory: &Path) -> Result { + let writer = DiagnosticMakeWriter::new(directory).map_err(|error| error.to_string())?; + let (filter_layer, filter) = reload::Layer::new(EnvFilter::new("off")); + let format_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_target(true) + .with_writer(writer.clone()); + tracing_subscriber::registry() + .with(filter_layer) + .with(format_layer) + .try_init() + .map_err(|error| error.to_string())?; + Ok(Self { writer, filter }) + } + + fn enable(&self, directory: &Path) -> Result<(), String> { + self.writer + .set_directory(directory) + .map_err(|error| error.to_string())?; + self.filter + .reload(EnvFilter::new(DIAGNOSTIC_FILTER)) + .map_err(|error| error.to_string()) + } + + fn disable(&self) -> Result<(), String> { + self.filter + .reload(EnvFilter::new("off")) + .map_err(|error| error.to_string())?; + self.writer.flush().map_err(|error| error.to_string()) + } +} + +static DIAGNOSTIC_LOGGER: OnceLock> = OnceLock::new(); + +/// Configure persistent EasyTier diagnostic logging for the embedding app. /// -/// Returns 0 on success, -1 when another global subscriber was installed -/// first (see `easytier_ios_last_error`). Repeated calls are idempotent. +/// Enabling installs a process-wide subscriber on first use, writes into +/// `directory`, and turns on trace/debug events for connection lifecycle +/// modules. Disabling reloads the filter to `off`, avoiding event construction +/// while keeping the subscriber ready for a later enable. +/// +/// # Safety +/// When `enabled` is non-zero, `directory` must be a non-null pointer to a +/// NUL-terminated UTF-8 path. #[unsafe(no_mangle)] -pub extern "C" fn easytier_ios_enable_diagnostic_logging() -> c_int { +pub unsafe extern "C" fn easytier_ios_configure_diagnostic_logging( + directory: *const c_char, + enabled: c_int, +) -> c_int { + unsafe { + guarded(-1, || { + error::clear_error(); + if enabled == 0 { + let Some(logger) = DIAGNOSTIC_LOGGER.get() else { + return 0; + }; + return match logger { + Ok(logger) => diagnostic_result(logger.disable()), + Err(message) => diagnostic_failure(message), + }; + } + + let directory = match cstr_arg(directory, "diagnostic log directory") { + Ok(directory) if !directory.is_empty() => Path::new(directory), + Ok(_) => { + error::set_error("diagnostic log directory must not be empty"); + return -1; + } + Err(message) => { + error::set_error(&message); + return -1; + } + }; + let logger = DIAGNOSTIC_LOGGER.get_or_init(|| DiagnosticLogger::install(directory)); + match logger { + Ok(logger) => diagnostic_result(logger.enable(directory)), + Err(message) => diagnostic_failure(message), + } + }) + } +} + +fn diagnostic_result(result: Result<(), String>) -> c_int { + match result { + Ok(()) => 0, + Err(message) => diagnostic_failure(&message), + } +} + +fn diagnostic_failure(message: &str) -> c_int { + error::set_error(&format!("diagnostic logging failed: {message}")); + -1 +} + +/// Append a host-app lifecycle or network-path marker to the diagnostic log. +/// This is a no-op while logging is disabled or has never been enabled. +/// +/// # Safety +/// `message` must be a non-null pointer to a NUL-terminated UTF-8 string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn easytier_ios_append_diagnostic_event(message: *const c_char) -> c_int { + unsafe { + guarded(-1, || { + error::clear_error(); + let message = match cstr_arg(message, "diagnostic event") { + Ok(message) => message, + Err(message) => { + error::set_error(&message); + return -1; + } + }; + tracing::info!(target: "easytier_ios::diagnostics", %message, "host event"); + 0 + }) + } +} + +/// Flush buffered diagnostic output. A logger that has never been enabled is +/// already flushed and returns success. +#[unsafe(no_mangle)] +pub extern "C" fn easytier_ios_flush_diagnostic_logging() -> c_int { guarded(-1, || { error::clear_error(); - static RESULT: OnceLock> = OnceLock::new(); - let result = RESULT.get_or_init(|| { - let filter = - tracing_subscriber::EnvFilter::new("easytier_core::gateway::port_forward=info"); - tracing_subscriber::fmt() - .with_ansi(false) - .with_env_filter(filter) - .with_writer(std::io::stderr) - .try_init() - .map_err(|error| error.to_string()) - }); - match result { + let Some(Ok(logger)) = DIAGNOSTIC_LOGGER.get() else { + return 0; + }; + match logger.writer.flush() { Ok(()) => 0, Err(message) => { - error::set_error(&format!("failed to enable diagnostic logging: {message}")); + error::set_error(&format!("failed to flush diagnostic log: {message}")); + -1 + } + } + }) +} + +/// Delete all rotated diagnostic content while keeping the active writer open. +#[unsafe(no_mangle)] +pub extern "C" fn easytier_ios_clear_diagnostic_logs() -> c_int { + guarded(-1, || { + error::clear_error(); + let Some(Ok(logger)) = DIAGNOSTIC_LOGGER.get() else { + return 0; + }; + match logger.writer.clear() { + Ok(()) => 0, + Err(message) => { + error::set_error(&format!("failed to clear diagnostic logs: {message}")); -1 } } @@ -354,6 +497,15 @@ mod tests { }); } + #[test] + fn diagnostic_filter_excludes_packet_payload_targets() { + // These targets include exceptional Debug events containing complete + // ZCPacket or decrypted packet buffers. Persistent diagnostics must + // never enable them at any level. + assert!(!DIAGNOSTIC_FILTER.contains("peer_manager")); + assert!(!DIAGNOSTIC_FILTER.contains("easytier::tunnel")); + } + fn acquire() -> MutexGuard<'static, ()> { init_logs(); TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()) diff --git a/easytier-core/src/config/api_input.rs b/easytier-core/src/config/api_input.rs index 174ede6e..91d725cc 100644 --- a/easytier-core/src/config/api_input.rs +++ b/easytier-core/src/config/api_input.rs @@ -723,7 +723,7 @@ mod tests { wireguard_private_key: Some("server-private-key".to_owned()), clients: vec![manage::VpnPortalClientConfig { name: "alice".to_owned(), - virtual_ip: "10.144.144.10".to_owned(), + virtual_ip: "10.144.144.10/16".to_owned(), groups: vec!["staff".to_owned()], }], } @@ -751,7 +751,7 @@ mod tests { Some("server-private-key") ); assert_eq!(portal.clients[0].name, "alice"); - assert_eq!(portal.clients[0].virtual_ip.to_string(), "10.144.144.10"); + assert_eq!(portal.clients[0].virtual_ip.to_string(), "10.144.144.10/16"); assert_eq!(portal.clients[0].groups, vec!["staff".to_owned()]); let output = NetworkConfig::new_from_config(&config).unwrap(); diff --git a/easytier-core/src/config/toml.rs b/easytier-core/src/config/toml.rs index db2ecced..09a791a2 100644 --- a/easytier-core/src/config/toml.rs +++ b/easytier-core/src/config/toml.rs @@ -473,7 +473,7 @@ impl std::fmt::Debug for VpnPortalConfig { #[serde(deny_unknown_fields)] pub struct VpnPortalClientConfig { pub name: String, - pub virtual_ip: std::net::Ipv4Addr, + pub virtual_ip: cidr::Ipv4Inet, #[serde(default)] pub groups: Vec, } @@ -1279,7 +1279,7 @@ wireguard_private_key = "wireguard-private-key" [[vpn_portal_config.clients]] name = "alice" -virtual_ip = "10.144.144.10" +virtual_ip = "10.144.144.10/24" groups = ["staff"] [acl.acl_v1.group] diff --git a/easytier-core/src/gateway/vpn_portal.rs b/easytier-core/src/gateway/vpn_portal.rs index 236ea854..b4ffd7ed 100644 --- a/easytier-core/src/gateway/vpn_portal.rs +++ b/easytier-core/src/gateway/vpn_portal.rs @@ -1,10 +1,9 @@ //! Protocol-neutral portal runtime and host adapter seam. -mod ipv4_translator; mod runtime; pub use runtime::{ - DEFAULT_PORTAL_CLIENT_ADDRESS, MAX_VPN_PORTAL_CLIENTS, PortalClientConfig, - PortalClientConfigPlan, PortalClientInfoSnapshot, PortalClientState, PortalHost, - PortalInfoSnapshot, PortalListener, PortalModule, PortalRuntimeConfig, PortalSession, + MAX_VPN_PORTAL_CLIENTS, PortalClientConfig, PortalClientConfigPlan, PortalClientInfoSnapshot, + PortalClientState, PortalHost, PortalInfoSnapshot, PortalListener, PortalModule, + PortalRuntimeConfig, PortalSession, }; diff --git a/easytier-core/src/gateway/vpn_portal/ipv4_translator.rs b/easytier-core/src/gateway/vpn_portal/ipv4_translator.rs deleted file mode 100644 index ed9c0469..00000000 --- a/easytier-core/src/gateway/vpn_portal/ipv4_translator.rs +++ /dev/null @@ -1,974 +0,0 @@ -use std::net::Ipv4Addr; - -const IPV4_MIN_HEADER_LEN: usize = 20; -const TCP_MIN_HEADER_LEN: usize = 20; -const UDP_HEADER_LEN: usize = 8; -const ICMP_MIN_HEADER_LEN: usize = 8; - -const IP_PROTOCOL_ICMP: u8 = 1; -const IP_PROTOCOL_TCP: u8 = 6; -const IP_PROTOCOL_UDP: u8 = 17; - -const IPV4_CHECKSUM_OFFSET: usize = 10; -const IPV4_SOURCE_OFFSET: usize = 12; -const IPV4_DESTINATION_OFFSET: usize = 16; -const TCP_CHECKSUM_OFFSET: usize = 16; -const UDP_CHECKSUM_OFFSET: usize = 6; -const ICMP_CHECKSUM_OFFSET: usize = 2; -const ICMP_QUOTED_PACKET_OFFSET: usize = 8; - -#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] -pub(crate) enum Ipv4TranslationError { - #[error("IPv4 packet is too short: expected at least 20 bytes, got {actual}")] - PacketTooShort { actual: usize }, - #[error("unsupported IP version {version}; expected IPv4")] - UnsupportedIpVersion { version: u8 }, - #[error("invalid IPv4 IHL {ihl_words}; expected at least 5 words")] - InvalidHeaderLength { ihl_words: u8 }, - #[error("truncated IPv4 header: header is {header_len} bytes, packet is {actual} bytes")] - TruncatedHeader { header_len: usize, actual: usize }, - #[error("IPv4 total length {declared} does not match payload length {actual}")] - TotalLengthMismatch { declared: usize, actual: usize }, - #[error("unexpected IPv4 source: expected {expected}, got {actual}")] - UnexpectedSource { - expected: Ipv4Addr, - actual: Ipv4Addr, - }, - #[error("unexpected IPv4 destination: expected {expected}, got {actual}")] - UnexpectedDestination { - expected: Ipv4Addr, - actual: Ipv4Addr, - }, - #[error("unsupported IPv4 protocol {protocol}")] - UnsupportedProtocol { protocol: u8 }, - #[error("truncated {protocol} header: expected at least {required} bytes, got {actual}")] - TruncatedTransportHeader { - protocol: &'static str, - required: usize, - actual: usize, - }, - #[error("invalid TCP data offset {data_offset_words}; expected at least 5 words")] - InvalidTcpHeaderLength { data_offset_words: u8 }, - #[error("truncated TCP header: header is {header_len} bytes, fragment carries {actual} bytes")] - TruncatedTcpHeader { header_len: usize, actual: usize }, - #[error("invalid UDP length {declared} for an IPv4 payload carrying {actual} UDP bytes")] - InvalidUdpLength { declared: usize, actual: usize }, - #[error("non-final IPv4 fragment carries {actual} bytes; expected a multiple of 8")] - InvalidFragmentLength { actual: usize }, - #[error("ICMP error quotes only {actual} IPv4 bytes; expected at least 20")] - QuotedPacketTooShort { actual: usize }, - #[error("ICMP error quotes IP version {version}; expected IPv4")] - UnsupportedQuotedIpVersion { version: u8 }, - #[error("ICMP error quotes an invalid IPv4 IHL {ihl_words}; expected at least 5 words")] - InvalidQuotedHeaderLength { ihl_words: u8 }, - #[error( - "ICMP error quotes a truncated IPv4 header: header is {header_len} bytes, quote is {actual} bytes" - )] - TruncatedQuotedHeader { header_len: usize, actual: usize }, - #[error( - "ICMP error quotes an IPv4 total length {declared} smaller than its {header_len}-byte header" - )] - InvalidQuotedTotalLength { declared: usize, header_len: usize }, - #[error("unsupported protocol {protocol} in translated ICMP IPv4 quote")] - UnsupportedQuotedProtocol { protocol: u8 }, -} - -#[derive(Clone, Copy)] -enum AddressField { - Source, - Destination, -} - -impl AddressField { - fn offset(self) -> usize { - match self { - Self::Source => IPV4_SOURCE_OFFSET, - Self::Destination => IPV4_DESTINATION_OFFSET, - } - } -} - -#[derive(Clone, Copy)] -struct Ipv4Layout { - header_len: usize, - protocol: u8, - fragment_offset: u16, - more_fragments: bool, -} - -#[derive(Clone, Copy)] -struct ChecksumField { - offset: usize, - udp: bool, -} - -#[derive(Clone, Copy)] -struct QuotedIpv4Plan { - header_offset: usize, - header_len: usize, - replace_source: bool, - replace_destination: bool, - transport_checksum: Option, -} - -#[derive(Clone, Copy)] -enum TransportPlan { - HeaderOnly, - WithPseudoHeaderChecksum(ChecksumField), - Icmp { - checksum_offset: usize, - quoted: Option, - }, -} - -pub(crate) fn rewrite_ipv4_source( - packet: &mut [u8], - old_address: Ipv4Addr, - new_address: Ipv4Addr, -) -> Result<(), Ipv4TranslationError> { - rewrite_ipv4_address(packet, AddressField::Source, old_address, new_address) -} - -pub(crate) fn rewrite_ipv4_destination( - packet: &mut [u8], - old_address: Ipv4Addr, - new_address: Ipv4Addr, -) -> Result<(), Ipv4TranslationError> { - rewrite_ipv4_address(packet, AddressField::Destination, old_address, new_address) -} - -fn rewrite_ipv4_address( - packet: &mut [u8], - field: AddressField, - old_address: Ipv4Addr, - new_address: Ipv4Addr, -) -> Result<(), Ipv4TranslationError> { - let layout = parse_complete_ipv4(packet)?; - let actual_address = read_ipv4_address(packet, field.offset()); - if actual_address != old_address { - return Err(match field { - AddressField::Source => Ipv4TranslationError::UnexpectedSource { - expected: old_address, - actual: actual_address, - }, - AddressField::Destination => Ipv4TranslationError::UnexpectedDestination { - expected: old_address, - actual: actual_address, - }, - }); - } - - let transport_plan = analyze_transport(packet, layout, old_address)?; - - match transport_plan { - TransportPlan::HeaderOnly => {} - TransportPlan::WithPseudoHeaderChecksum(checksum) => { - rewrite_pseudo_header_checksum(packet, checksum, old_address, new_address); - } - TransportPlan::Icmp { - checksum_offset, - quoted, - } => { - let mut fragmented_checksum = read_u16(packet, checksum_offset); - if let Some(quoted) = quoted { - rewrite_quoted_ipv4( - packet, - quoted, - old_address, - new_address, - &mut fragmented_checksum, - ); - } - - if layout.more_fragments { - write_u16(packet, checksum_offset, fragmented_checksum); - } else { - let icmp = &packet[layout.header_len..]; - let checksum = checksum_with_zeroed_word(icmp, ICMP_CHECKSUM_OFFSET); - write_u16(packet, checksum_offset, checksum); - } - } - } - - packet[field.offset()..field.offset() + 4].copy_from_slice(&new_address.octets()); - let checksum = checksum_with_zeroed_word(&packet[..layout.header_len], IPV4_CHECKSUM_OFFSET); - write_u16(packet, IPV4_CHECKSUM_OFFSET, checksum); - Ok(()) -} - -fn parse_complete_ipv4(packet: &[u8]) -> Result { - if packet.len() < IPV4_MIN_HEADER_LEN { - return Err(Ipv4TranslationError::PacketTooShort { - actual: packet.len(), - }); - } - - let version = packet[0] >> 4; - if version != 4 { - return Err(Ipv4TranslationError::UnsupportedIpVersion { version }); - } - let ihl_words = packet[0] & 0x0f; - if ihl_words < 5 { - return Err(Ipv4TranslationError::InvalidHeaderLength { ihl_words }); - } - let header_len = usize::from(ihl_words) * 4; - if header_len > packet.len() { - return Err(Ipv4TranslationError::TruncatedHeader { - header_len, - actual: packet.len(), - }); - } - - let declared = usize::from(read_u16(packet, 2)); - if declared != packet.len() { - return Err(Ipv4TranslationError::TotalLengthMismatch { - declared, - actual: packet.len(), - }); - } - - let fragment = read_u16(packet, 6); - let layout = Ipv4Layout { - header_len, - protocol: packet[9], - fragment_offset: fragment & 0x1fff, - more_fragments: fragment & 0x2000 != 0, - }; - let fragment_payload_len = packet.len() - header_len; - if layout.more_fragments && !fragment_payload_len.is_multiple_of(8) { - return Err(Ipv4TranslationError::InvalidFragmentLength { - actual: fragment_payload_len, - }); - } - Ok(layout) -} - -fn analyze_transport( - packet: &[u8], - layout: Ipv4Layout, - old_address: Ipv4Addr, -) -> Result { - if !matches!( - layout.protocol, - IP_PROTOCOL_TCP | IP_PROTOCOL_UDP | IP_PROTOCOL_ICMP - ) { - return Err(Ipv4TranslationError::UnsupportedProtocol { - protocol: layout.protocol, - }); - } - if layout.fragment_offset != 0 { - return Ok(TransportPlan::HeaderOnly); - } - - let transport_len = packet.len() - layout.header_len; - match layout.protocol { - IP_PROTOCOL_TCP => { - let required = if layout.more_fragments { - TCP_CHECKSUM_OFFSET + 2 - } else { - TCP_MIN_HEADER_LEN - }; - if transport_len < required { - return Err(Ipv4TranslationError::TruncatedTransportHeader { - protocol: "TCP", - required, - actual: transport_len, - }); - } - let data_offset_words = packet[layout.header_len + 12] >> 4; - if data_offset_words < 5 { - return Err(Ipv4TranslationError::InvalidTcpHeaderLength { data_offset_words }); - } - let tcp_header_len = usize::from(data_offset_words) * 4; - if !layout.more_fragments && tcp_header_len > transport_len { - return Err(Ipv4TranslationError::TruncatedTcpHeader { - header_len: tcp_header_len, - actual: transport_len, - }); - } - Ok(TransportPlan::WithPseudoHeaderChecksum(ChecksumField { - offset: layout.header_len + TCP_CHECKSUM_OFFSET, - udp: false, - })) - } - IP_PROTOCOL_UDP => { - if transport_len < UDP_HEADER_LEN { - return Err(Ipv4TranslationError::TruncatedTransportHeader { - protocol: "UDP", - required: UDP_HEADER_LEN, - actual: transport_len, - }); - } - let udp_len = usize::from(read_u16(packet, layout.header_len + 4)); - let invalid = udp_len < UDP_HEADER_LEN - || (!layout.more_fragments && udp_len != transport_len) - || (layout.more_fragments && udp_len <= transport_len); - if invalid { - return Err(Ipv4TranslationError::InvalidUdpLength { - declared: udp_len, - actual: transport_len, - }); - } - Ok(TransportPlan::WithPseudoHeaderChecksum(ChecksumField { - offset: layout.header_len + UDP_CHECKSUM_OFFSET, - udp: true, - })) - } - IP_PROTOCOL_ICMP => { - if transport_len < ICMP_MIN_HEADER_LEN { - return Err(Ipv4TranslationError::TruncatedTransportHeader { - protocol: "ICMP", - required: ICMP_MIN_HEADER_LEN, - actual: transport_len, - }); - } - let icmp_offset = layout.header_len; - let quoted = if is_icmp_error(packet[icmp_offset]) { - Some(analyze_quoted_ipv4( - packet, - icmp_offset + ICMP_QUOTED_PACKET_OFFSET, - old_address, - )?) - } else { - None - }; - Ok(TransportPlan::Icmp { - checksum_offset: icmp_offset + ICMP_CHECKSUM_OFFSET, - quoted, - }) - } - _ => unreachable!("supported protocol checked above"), - } -} - -fn analyze_quoted_ipv4( - packet: &[u8], - header_offset: usize, - old_address: Ipv4Addr, -) -> Result { - let quote = &packet[header_offset..]; - if quote.len() < IPV4_MIN_HEADER_LEN { - return Err(Ipv4TranslationError::QuotedPacketTooShort { - actual: quote.len(), - }); - } - let version = quote[0] >> 4; - if version != 4 { - return Err(Ipv4TranslationError::UnsupportedQuotedIpVersion { version }); - } - let ihl_words = quote[0] & 0x0f; - if ihl_words < 5 { - return Err(Ipv4TranslationError::InvalidQuotedHeaderLength { ihl_words }); - } - let header_len = usize::from(ihl_words) * 4; - if header_len > quote.len() { - return Err(Ipv4TranslationError::TruncatedQuotedHeader { - header_len, - actual: quote.len(), - }); - } - let total_len = usize::from(read_u16(quote, 2)); - if total_len < header_len { - return Err(Ipv4TranslationError::InvalidQuotedTotalLength { - declared: total_len, - header_len, - }); - } - - let replace_source = read_ipv4_address(quote, IPV4_SOURCE_OFFSET) == old_address; - let replace_destination = read_ipv4_address(quote, IPV4_DESTINATION_OFFSET) == old_address; - let fragment_offset = read_u16(quote, 6) & 0x1fff; - let visible_len = total_len.min(quote.len()); - let protocol = quote[9]; - let transport_checksum = if (!replace_source && !replace_destination) || fragment_offset != 0 { - None - } else { - let relative_checksum_offset = match protocol { - IP_PROTOCOL_TCP => header_len + TCP_CHECKSUM_OFFSET, - IP_PROTOCOL_UDP => header_len + UDP_CHECKSUM_OFFSET, - IP_PROTOCOL_ICMP => usize::MAX, - _ => { - return Err(Ipv4TranslationError::UnsupportedQuotedProtocol { protocol }); - } - }; - let checksum_visible = - relative_checksum_offset != usize::MAX && relative_checksum_offset + 2 <= visible_len; - checksum_visible.then_some(ChecksumField { - offset: header_offset + relative_checksum_offset, - udp: protocol == IP_PROTOCOL_UDP, - }) - }; - - Ok(QuotedIpv4Plan { - header_offset, - header_len, - replace_source, - replace_destination, - transport_checksum, - }) -} - -fn rewrite_quoted_ipv4( - packet: &mut [u8], - plan: QuotedIpv4Plan, - old_address: Ipv4Addr, - new_address: Ipv4Addr, - outer_icmp_checksum: &mut u16, -) { - if !plan.replace_source && !plan.replace_destination { - return; - } - - if let Some(checksum) = plan.transport_checksum { - let current = read_u16(packet, checksum.offset); - if !checksum.udp || current != 0 { - let mut updated = current; - if plan.replace_source { - updated = update_checksum_for_address(updated, old_address, new_address); - } - if plan.replace_destination { - updated = update_checksum_for_address(updated, old_address, new_address); - } - if checksum.udp && updated == 0 { - updated = u16::MAX; - } - write_tracked_word(packet, checksum.offset, updated, outer_icmp_checksum); - } - } - - if plan.replace_source { - write_tracked_address( - packet, - plan.header_offset + IPV4_SOURCE_OFFSET, - new_address, - outer_icmp_checksum, - ); - } - if plan.replace_destination { - write_tracked_address( - packet, - plan.header_offset + IPV4_DESTINATION_OFFSET, - new_address, - outer_icmp_checksum, - ); - } - - let inner_header = &packet[plan.header_offset..plan.header_offset + plan.header_len]; - let checksum = checksum_with_zeroed_word(inner_header, IPV4_CHECKSUM_OFFSET); - write_tracked_word( - packet, - plan.header_offset + IPV4_CHECKSUM_OFFSET, - checksum, - outer_icmp_checksum, - ); -} - -fn rewrite_pseudo_header_checksum( - packet: &mut [u8], - checksum: ChecksumField, - old_address: Ipv4Addr, - new_address: Ipv4Addr, -) { - let current = read_u16(packet, checksum.offset); - if checksum.udp && current == 0 { - return; - } - let mut updated = update_checksum_for_address(current, old_address, new_address); - if checksum.udp && updated == 0 { - updated = u16::MAX; - } - write_u16(packet, checksum.offset, updated); -} - -fn write_tracked_address( - packet: &mut [u8], - offset: usize, - address: Ipv4Addr, - enclosing_checksum: &mut u16, -) { - let octets = address.octets(); - write_tracked_word( - packet, - offset, - u16::from_be_bytes([octets[0], octets[1]]), - enclosing_checksum, - ); - write_tracked_word( - packet, - offset + 2, - u16::from_be_bytes([octets[2], octets[3]]), - enclosing_checksum, - ); -} - -fn write_tracked_word( - packet: &mut [u8], - offset: usize, - new_value: u16, - enclosing_checksum: &mut u16, -) { - let old_value = read_u16(packet, offset); - if old_value == new_value { - return; - } - *enclosing_checksum = update_checksum_word(*enclosing_checksum, old_value, new_value); - write_u16(packet, offset, new_value); -} - -fn update_checksum_for_address(checksum: u16, old_address: Ipv4Addr, new_address: Ipv4Addr) -> u16 { - let old = old_address.octets(); - let new = new_address.octets(); - let checksum = update_checksum_word( - checksum, - u16::from_be_bytes([old[0], old[1]]), - u16::from_be_bytes([new[0], new[1]]), - ); - update_checksum_word( - checksum, - u16::from_be_bytes([old[2], old[3]]), - u16::from_be_bytes([new[2], new[3]]), - ) -} - -fn update_checksum_word(checksum: u16, old_value: u16, new_value: u16) -> u16 { - let mut sum = u32::from(!checksum) + u32::from(!old_value) + u32::from(new_value); - while sum >> 16 != 0 { - sum = (sum & 0xffff) + (sum >> 16); - } - !(sum as u16) -} - -fn checksum_with_zeroed_word(bytes: &[u8], zero_offset: usize) -> u16 { - let mut sum = 0u32; - for (offset, chunk) in bytes.chunks(2).enumerate() { - let byte_offset = offset * 2; - let word = if byte_offset == zero_offset { - 0 - } else if let [high, low] = chunk { - u16::from_be_bytes([*high, *low]) - } else { - u16::from(chunk[0]) << 8 - }; - sum += u32::from(word); - } - while sum >> 16 != 0 { - sum = (sum & 0xffff) + (sum >> 16); - } - !(sum as u16) -} - -fn is_icmp_error(icmp_type: u8) -> bool { - matches!(icmp_type, 3 | 4 | 5 | 11 | 12) -} - -fn read_ipv4_address(bytes: &[u8], offset: usize) -> Ipv4Addr { - Ipv4Addr::new( - bytes[offset], - bytes[offset + 1], - bytes[offset + 2], - bytes[offset + 3], - ) -} - -fn read_u16(bytes: &[u8], offset: usize) -> u16 { - u16::from_be_bytes([bytes[offset], bytes[offset + 1]]) -} - -fn write_u16(bytes: &mut [u8], offset: usize, value: u16) { - bytes[offset..offset + 2].copy_from_slice(&value.to_be_bytes()); -} - -#[cfg(test)] -mod tests { - use super::*; - - const CLIENT_IP: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1); - const VIRTUAL_IP: Ipv4Addr = Ipv4Addr::new(10, 144, 144, 10); - const REMOTE_IP: Ipv4Addr = Ipv4Addr::new(10, 144, 144, 20); - - fn build_ipv4( - source: Ipv4Addr, - destination: Ipv4Addr, - protocol: u8, - payload: &[u8], - options: &[u8], - fragment: u16, - ) -> Vec { - assert_eq!(options.len() % 4, 0); - let header_len = IPV4_MIN_HEADER_LEN + options.len(); - let mut packet = vec![0; header_len + payload.len()]; - packet[0] = 0x40 | u8::try_from(header_len / 4).unwrap(); - let packet_len = u16::try_from(packet.len()).unwrap(); - write_u16(&mut packet, 2, packet_len); - write_u16(&mut packet, 4, 0x1234); - write_u16(&mut packet, 6, fragment); - packet[8] = 64; - packet[9] = protocol; - packet[IPV4_SOURCE_OFFSET..IPV4_SOURCE_OFFSET + 4].copy_from_slice(&source.octets()); - packet[IPV4_DESTINATION_OFFSET..IPV4_DESTINATION_OFFSET + 4] - .copy_from_slice(&destination.octets()); - packet[IPV4_MIN_HEADER_LEN..header_len].copy_from_slice(options); - packet[header_len..].copy_from_slice(payload); - let checksum = checksum_with_zeroed_word(&packet[..header_len], IPV4_CHECKSUM_OFFSET); - write_u16(&mut packet, IPV4_CHECKSUM_OFFSET, checksum); - packet - } - - fn tcp_segment(source: Ipv4Addr, destination: Ipv4Addr, data: &[u8]) -> Vec { - let mut tcp = vec![0; TCP_MIN_HEADER_LEN + data.len()]; - write_u16(&mut tcp, 0, 12345); - write_u16(&mut tcp, 2, 443); - tcp[12] = 5 << 4; - tcp[13] = 0x18; - write_u16(&mut tcp, 14, 4096); - tcp[TCP_MIN_HEADER_LEN..].copy_from_slice(data); - let checksum = transport_checksum(source, destination, IP_PROTOCOL_TCP, &tcp); - write_u16(&mut tcp, TCP_CHECKSUM_OFFSET, checksum); - tcp - } - - fn udp_datagram( - source: Ipv4Addr, - destination: Ipv4Addr, - data: &[u8], - checksum_enabled: bool, - ) -> Vec { - let mut udp = vec![0; UDP_HEADER_LEN + data.len()]; - write_u16(&mut udp, 0, 5353); - write_u16(&mut udp, 2, 53); - let udp_len = u16::try_from(udp.len()).unwrap(); - write_u16(&mut udp, 4, udp_len); - udp[UDP_HEADER_LEN..].copy_from_slice(data); - if checksum_enabled { - let checksum = transport_checksum(source, destination, IP_PROTOCOL_UDP, &udp); - write_u16( - &mut udp, - UDP_CHECKSUM_OFFSET, - if checksum == 0 { u16::MAX } else { checksum }, - ); - } - udp - } - - fn icmp_message(icmp_type: u8, body: &[u8]) -> Vec { - let mut icmp = vec![0; ICMP_MIN_HEADER_LEN + body.len()]; - icmp[0] = icmp_type; - icmp[1] = 0; - icmp[4..8].copy_from_slice(&[0x12, 0x34, 0, 1]); - icmp[ICMP_MIN_HEADER_LEN..].copy_from_slice(body); - let checksum = checksum_with_zeroed_word(&icmp, ICMP_CHECKSUM_OFFSET); - write_u16(&mut icmp, ICMP_CHECKSUM_OFFSET, checksum); - icmp - } - - fn transport_checksum( - source: Ipv4Addr, - destination: Ipv4Addr, - protocol: u8, - transport: &[u8], - ) -> u16 { - let mut bytes = Vec::with_capacity(12 + transport.len()); - bytes.extend_from_slice(&source.octets()); - bytes.extend_from_slice(&destination.octets()); - bytes.push(0); - bytes.push(protocol); - bytes.extend_from_slice(&u16::try_from(transport.len()).unwrap().to_be_bytes()); - bytes.extend_from_slice(transport); - checksum_with_zeroed_word( - &bytes, - 12 + if protocol == IP_PROTOCOL_TCP { - TCP_CHECKSUM_OFFSET - } else { - UDP_CHECKSUM_OFFSET - }, - ) - } - - fn assert_valid_ipv4_checksum(packet: &[u8]) { - let header_len = usize::from(packet[0] & 0x0f) * 4; - assert_eq!( - read_u16(packet, IPV4_CHECKSUM_OFFSET), - checksum_with_zeroed_word(&packet[..header_len], IPV4_CHECKSUM_OFFSET) - ); - } - - fn assert_valid_transport_checksum(packet: &[u8], protocol: u8) { - let header_len = usize::from(packet[0] & 0x0f) * 4; - let source = read_ipv4_address(packet, IPV4_SOURCE_OFFSET); - let destination = read_ipv4_address(packet, IPV4_DESTINATION_OFFSET); - let transport = &packet[header_len..]; - let offset = if protocol == IP_PROTOCOL_TCP { - TCP_CHECKSUM_OFFSET - } else { - UDP_CHECKSUM_OFFSET - }; - assert_eq!( - read_u16(transport, offset), - transport_checksum(source, destination, protocol, transport) - ); - } - - #[test] - fn rewrites_tcp_source_with_ipv4_options() { - let tcp = tcp_segment(CLIENT_IP, REMOTE_IP, b"tcp payload"); - let mut packet = build_ipv4( - CLIENT_IP, - REMOTE_IP, - IP_PROTOCOL_TCP, - &tcp, - &[1, 1, 1, 0], - 0, - ); - - rewrite_ipv4_source(&mut packet, CLIENT_IP, VIRTUAL_IP).unwrap(); - - assert_eq!(read_ipv4_address(&packet, IPV4_SOURCE_OFFSET), VIRTUAL_IP); - assert_eq!( - read_ipv4_address(&packet, IPV4_DESTINATION_OFFSET), - REMOTE_IP - ); - assert_valid_ipv4_checksum(&packet); - assert_valid_transport_checksum(&packet, IP_PROTOCOL_TCP); - } - - #[test] - fn rewrites_tcp_destination() { - let tcp = tcp_segment(REMOTE_IP, VIRTUAL_IP, b"reply"); - let mut packet = build_ipv4(REMOTE_IP, VIRTUAL_IP, IP_PROTOCOL_TCP, &tcp, &[], 0); - - rewrite_ipv4_destination(&mut packet, VIRTUAL_IP, CLIENT_IP).unwrap(); - - assert_eq!(read_ipv4_address(&packet, IPV4_SOURCE_OFFSET), REMOTE_IP); - assert_eq!( - read_ipv4_address(&packet, IPV4_DESTINATION_OFFSET), - CLIENT_IP - ); - assert_valid_ipv4_checksum(&packet); - assert_valid_transport_checksum(&packet, IP_PROTOCOL_TCP); - } - - #[test] - fn rewrites_udp_checksum_and_preserves_disabled_checksum() { - for checksum_enabled in [true, false] { - let udp = udp_datagram(CLIENT_IP, REMOTE_IP, b"dns", checksum_enabled); - let mut packet = build_ipv4(CLIENT_IP, REMOTE_IP, IP_PROTOCOL_UDP, &udp, &[], 0); - - rewrite_ipv4_source(&mut packet, CLIENT_IP, VIRTUAL_IP).unwrap(); - - assert_valid_ipv4_checksum(&packet); - let udp_offset = IPV4_MIN_HEADER_LEN + UDP_CHECKSUM_OFFSET; - if checksum_enabled { - assert_valid_transport_checksum(&packet, IP_PROTOCOL_UDP); - assert_ne!(read_u16(&packet, udp_offset), 0); - } else { - assert_eq!(read_u16(&packet, udp_offset), 0); - } - } - } - - #[test] - fn rewrites_icmp_echo_outer_address_and_checksum() { - let icmp = icmp_message(8, b"echo payload"); - let original_icmp_checksum = read_u16(&icmp, ICMP_CHECKSUM_OFFSET); - let mut packet = build_ipv4(CLIENT_IP, REMOTE_IP, IP_PROTOCOL_ICMP, &icmp, &[], 0); - - rewrite_ipv4_source(&mut packet, CLIENT_IP, VIRTUAL_IP).unwrap(); - - assert_valid_ipv4_checksum(&packet); - let translated_icmp = &packet[IPV4_MIN_HEADER_LEN..]; - assert_eq!( - read_u16(translated_icmp, ICMP_CHECKSUM_OFFSET), - original_icmp_checksum - ); - assert_eq!( - read_u16(translated_icmp, ICMP_CHECKSUM_OFFSET), - checksum_with_zeroed_word(translated_icmp, ICMP_CHECKSUM_OFFSET) - ); - } - - #[test] - fn rewrites_icmp_error_quoted_ipv4_and_visible_udp_checksum() { - let udp = udp_datagram(VIRTUAL_IP, REMOTE_IP, b"request", true); - let quoted = build_ipv4(VIRTUAL_IP, REMOTE_IP, IP_PROTOCOL_UDP, &udp, &[], 0); - let icmp = icmp_message(3, "ed); - let mut packet = build_ipv4(REMOTE_IP, VIRTUAL_IP, IP_PROTOCOL_ICMP, &icmp, &[], 0); - - rewrite_ipv4_destination(&mut packet, VIRTUAL_IP, CLIENT_IP).unwrap(); - - assert_valid_ipv4_checksum(&packet); - let outer_ihl = IPV4_MIN_HEADER_LEN; - let translated_icmp = &packet[outer_ihl..]; - assert_eq!( - read_u16(translated_icmp, ICMP_CHECKSUM_OFFSET), - checksum_with_zeroed_word(translated_icmp, ICMP_CHECKSUM_OFFSET) - ); - let translated_quote = &translated_icmp[ICMP_QUOTED_PACKET_OFFSET..]; - assert_eq!( - read_ipv4_address(translated_quote, IPV4_SOURCE_OFFSET), - CLIENT_IP - ); - assert_valid_ipv4_checksum(translated_quote); - assert_valid_transport_checksum(translated_quote, IP_PROTOCOL_UDP); - } - - #[test] - fn rewrites_icmp_error_quoted_destination_and_visible_tcp_checksum() { - let tcp = tcp_segment(REMOTE_IP, CLIENT_IP, b"request"); - let quoted = build_ipv4(REMOTE_IP, CLIENT_IP, IP_PROTOCOL_TCP, &tcp, &[], 0); - let icmp = icmp_message(11, "ed); - let mut packet = build_ipv4(CLIENT_IP, REMOTE_IP, IP_PROTOCOL_ICMP, &icmp, &[], 0); - - rewrite_ipv4_source(&mut packet, CLIENT_IP, VIRTUAL_IP).unwrap(); - - assert_valid_ipv4_checksum(&packet); - let translated_icmp = &packet[IPV4_MIN_HEADER_LEN..]; - assert_eq!( - read_u16(translated_icmp, ICMP_CHECKSUM_OFFSET), - checksum_with_zeroed_word(translated_icmp, ICMP_CHECKSUM_OFFSET) - ); - let translated_quote = &translated_icmp[ICMP_QUOTED_PACKET_OFFSET..]; - assert_eq!( - read_ipv4_address(translated_quote, IPV4_DESTINATION_OFFSET), - VIRTUAL_IP - ); - assert_valid_ipv4_checksum(translated_quote); - assert_valid_transport_checksum(translated_quote, IP_PROTOCOL_TCP); - } - - #[test] - fn rewrites_fragmented_tcp_checksum_only_in_first_fragment() { - let tcp = tcp_segment(CLIENT_IP, REMOTE_IP, b"0123456789abcdef01234567"); - let split = 24; - let mut first = build_ipv4( - CLIENT_IP, - REMOTE_IP, - IP_PROTOCOL_TCP, - &tcp[..split], - &[], - 0x2000, - ); - let mut second = build_ipv4( - CLIENT_IP, - REMOTE_IP, - IP_PROTOCOL_TCP, - &tcp[split..], - &[], - u16::try_from(split / 8).unwrap(), - ); - let second_payload_before = second[IPV4_MIN_HEADER_LEN..].to_vec(); - - rewrite_ipv4_source(&mut first, CLIENT_IP, VIRTUAL_IP).unwrap(); - rewrite_ipv4_source(&mut second, CLIENT_IP, VIRTUAL_IP).unwrap(); - - assert_valid_ipv4_checksum(&first); - assert_valid_ipv4_checksum(&second); - assert_eq!(&second[IPV4_MIN_HEADER_LEN..], second_payload_before); - let mut translated_tcp = first[IPV4_MIN_HEADER_LEN..].to_vec(); - translated_tcp.extend_from_slice(&second[IPV4_MIN_HEADER_LEN..]); - assert_eq!( - read_u16(&translated_tcp, TCP_CHECKSUM_OFFSET), - transport_checksum(VIRTUAL_IP, REMOTE_IP, IP_PROTOCOL_TCP, &translated_tcp) - ); - } - - #[test] - fn rejects_truncated_and_length_mismatched_ipv4_packets() { - let mut short = vec![0; IPV4_MIN_HEADER_LEN - 1]; - assert_eq!( - rewrite_ipv4_source(&mut short, CLIENT_IP, VIRTUAL_IP), - Err(Ipv4TranslationError::PacketTooShort { - actual: IPV4_MIN_HEADER_LEN - 1 - }) - ); - - let udp = udp_datagram(CLIENT_IP, REMOTE_IP, b"data", true); - let mut mismatched = build_ipv4(CLIENT_IP, REMOTE_IP, IP_PROTOCOL_UDP, &udp, &[], 0); - let declared = mismatched.len(); - mismatched.push(0); - assert_eq!( - rewrite_ipv4_source(&mut mismatched, CLIENT_IP, VIRTUAL_IP), - Err(Ipv4TranslationError::TotalLengthMismatch { - declared, - actual: declared + 1, - }) - ); - - let mut truncated_options = vec![0; IPV4_MIN_HEADER_LEN]; - truncated_options[0] = 0x46; - write_u16(&mut truncated_options, 2, IPV4_MIN_HEADER_LEN as u16); - assert_eq!( - rewrite_ipv4_source(&mut truncated_options, CLIENT_IP, VIRTUAL_IP), - Err(Ipv4TranslationError::TruncatedHeader { - header_len: 24, - actual: IPV4_MIN_HEADER_LEN, - }) - ); - } - - #[test] - fn rejects_unsupported_protocol_without_mutating_packet() { - let mut packet = build_ipv4(CLIENT_IP, REMOTE_IP, 47, &[0; 8], &[], 0); - let original = packet.clone(); - - assert_eq!( - rewrite_ipv4_source(&mut packet, CLIENT_IP, VIRTUAL_IP), - Err(Ipv4TranslationError::UnsupportedProtocol { protocol: 47 }) - ); - assert_eq!(packet, original); - } - - #[test] - fn rejects_unexpected_source_and_destination_without_mutating_packet() { - let tcp = tcp_segment(CLIENT_IP, REMOTE_IP, b"payload"); - let packet = build_ipv4(CLIENT_IP, REMOTE_IP, IP_PROTOCOL_TCP, &tcp, &[], 0); - - let mut source_packet = packet.clone(); - assert_eq!( - rewrite_ipv4_source(&mut source_packet, VIRTUAL_IP, CLIENT_IP), - Err(Ipv4TranslationError::UnexpectedSource { - expected: VIRTUAL_IP, - actual: CLIENT_IP, - }) - ); - assert_eq!(source_packet, packet); - - let mut destination_packet = packet.clone(); - assert_eq!( - rewrite_ipv4_destination(&mut destination_packet, VIRTUAL_IP, CLIENT_IP), - Err(Ipv4TranslationError::UnexpectedDestination { - expected: VIRTUAL_IP, - actual: REMOTE_IP, - }) - ); - assert_eq!(destination_packet, packet); - } - - #[test] - fn rejects_truncated_transport_and_icmp_quote() { - let mut tcp = build_ipv4( - CLIENT_IP, - REMOTE_IP, - IP_PROTOCOL_TCP, - &[0; TCP_MIN_HEADER_LEN - 1], - &[], - 0, - ); - assert_eq!( - rewrite_ipv4_source(&mut tcp, CLIENT_IP, VIRTUAL_IP), - Err(Ipv4TranslationError::TruncatedTransportHeader { - protocol: "TCP", - required: TCP_MIN_HEADER_LEN, - actual: TCP_MIN_HEADER_LEN - 1, - }) - ); - - let icmp = icmp_message(11, &[0; IPV4_MIN_HEADER_LEN - 1]); - let mut packet = build_ipv4(REMOTE_IP, VIRTUAL_IP, IP_PROTOCOL_ICMP, &icmp, &[], 0); - assert_eq!( - rewrite_ipv4_destination(&mut packet, VIRTUAL_IP, CLIENT_IP), - Err(Ipv4TranslationError::QuotedPacketTooShort { - actual: IPV4_MIN_HEADER_LEN - 1, - }) - ); - } -} diff --git a/easytier-core/src/gateway/vpn_portal/runtime.rs b/easytier-core/src/gateway/vpn_portal/runtime.rs index ac3ec58d..d2a69567 100644 --- a/easytier-core/src/gateway/vpn_portal/runtime.rs +++ b/easytier-core/src/gateway/vpn_portal/runtime.rs @@ -2,7 +2,7 @@ //! //! Native adapters authenticate clients and yield sessions. This module owns //! configured client identities, attached-peer lifetimes, per-client -//! generations, and IPv4 address translation at the Host packet seam. +//! generations, and raw IPv4 packet forwarding at the Host packet seam. use std::{ collections::{BTreeMap, BTreeSet}, @@ -29,15 +29,12 @@ use crate::{ socket::SocketListener, }; -use super::ipv4_translator::{rewrite_ipv4_destination, rewrite_ipv4_source}; - pub const MAX_VPN_PORTAL_CLIENTS: usize = 64; -pub const DEFAULT_PORTAL_CLIENT_ADDRESS: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PortalClientConfig { pub name: String, - pub virtual_ip: Ipv4Addr, + pub virtual_ip: Ipv4Inet, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub groups: Vec, } @@ -428,7 +425,7 @@ impl PortalModule { status.generation = status.generation.wrapping_add(1); status.state = PortalClientState::Connecting; status.endpoint = Some(session.endpoint.borrow_and_update().clone()); - status.tunnel_ip = None; + status.tunnel_ip = Some(client.virtual_ip.address()); status.error = None; status.generation }; @@ -481,44 +478,15 @@ impl PortalModule { let mut client_stream = session.from_client; let endpoint = session.endpoint; let client_sink = session.to_client; - let client_ip = Arc::new(Mutex::new(None::)); let client_to_mesh = { let attached = attached.clone(); - let client_ip = client_ip.clone(); - let statuses = statuses.clone(); let name = client.name.clone(); - let virtual_ip = client.virtual_ip; + let virtual_ip = client.virtual_ip.address(); tokio::spawn(async move { - while let Some(mut payload) = client_stream.recv().await { - let Some(source) = ipv4_source(&payload) else { + while let Some(payload) = client_stream.recv().await { + if !has_ipv4_source(&payload, virtual_ip) { + tracing::warn!(client = %name, expected = ?virtual_ip, "VPN client source does not match its assigned address"); continue; - }; - match *client_ip.lock().await { - Some(expected) if expected != source => { - tracing::warn!(client = %name, ?expected, ?source, "VPN client source changed"); - continue; - } - None | Some(_) => {} - } - if rewrite_ipv4_source(&mut payload, source, virtual_ip).is_err() { - continue; - } - let learned = { - let mut tunnel_ip = client_ip.lock().await; - if tunnel_ip.is_none() { - *tunnel_ip = Some(source); - true - } else { - false - } - }; - if learned { - let mut statuses = statuses.write().await; - if let Some(status) = statuses.get_mut(&name) - && status.generation == generation - { - status.tunnel_ip = Some(source); - } } if let Err(error) = attached.send_packet(&payload).await { tracing::debug!(?error, client = %name, "attached peer send failed"); @@ -529,28 +497,12 @@ impl PortalModule { }; let mesh_to_client = { let attached = attached.clone(); - let client_ip = client_ip.clone(); - let statuses = statuses.clone(); - let name = client.name.clone(); - let virtual_ip = client.virtual_ip; tokio::spawn(async move { while let Some(packet) = attached.recv_packet().await { - let Some(tunnel_ip) = *client_ip.lock().await else { - continue; - }; - let mut payload = packet.payload().to_vec(); - if rewrite_ipv4_destination(&mut payload, virtual_ip, tunnel_ip).is_err() { - continue; - } + let payload = packet.payload().to_vec(); if client_sink.send(payload).await.is_err() { break; } - let mut statuses = statuses.write().await; - if let Some(status) = statuses.get_mut(&name) - && status.generation == generation - { - status.tunnel_ip = Some(tunnel_ip); - } } }) }; @@ -706,10 +658,14 @@ impl PortalModule { let status = statuses.get(&client.name).cloned().unwrap_or_default(); let client_config = match (self.host.as_ref(), listener_url.as_ref()) { (Some(host), Some(listener_url)) => { + let mut client_allowed_ips = allowed_ips.clone(); + client_allowed_ips.push(client.virtual_ip.network().to_string()); + client_allowed_ips.sort(); + client_allowed_ips.dedup(); host.render_client_config(&PortalClientConfigPlan { name: client.name.clone(), - address: DEFAULT_PORTAL_CLIENT_ADDRESS, - allowed_ips: allowed_ips.clone(), + address: client.virtual_ip.address(), + allowed_ips: client_allowed_ips, listener_url: listener_url.clone(), }) } @@ -717,7 +673,7 @@ impl PortalModule { }; PortalClientInfoSnapshot { name: client.name.clone(), - virtual_ip: client.virtual_ip, + virtual_ip: client.virtual_ip.address(), groups: client.groups.clone(), state: status.state, peer_id: status.peer_id, @@ -744,12 +700,6 @@ impl PortalModule { for route in self.peer_manager.list_route_snapshots().await { allowed.extend(route.proxy_cidrs); } - if let Some(ipv4) = snapshot.peer.runtime.core.routes.ipv4.as_ref() - && let IpAddr::V4(address) = ipv4.address - && let Ok(inet) = Ipv4Inet::new(address, ipv4.prefix_len) - { - allowed.insert(inet.network().to_string()); - } for proxy in &snapshot.peer.runtime.core.routes.proxy_networks { let mapped = proxy.mapped.as_ref().unwrap_or(&proxy.real); allowed.insert(format!("{}/{}", mapped.address, mapped.prefix_len)); @@ -784,7 +734,7 @@ fn validate_clients( if !names.insert(client.name.as_str()) { anyhow::bail!("duplicate VPN portal client name: {}", client.name); } - if !addresses.insert(client.virtual_ip) { + if !addresses.insert(client.virtual_ip.address()) { anyhow::bail!("duplicate VPN portal virtual IP: {}", client.virtual_ip); } for group in &client.groups { @@ -814,33 +764,28 @@ fn validate_runtime_compatibility( { anyhow::bail!("VPN portal requires an admin node with a non-empty network secret"); } - if snapshot.services.dhcp_ipv4 { - anyhow::bail!("VPN portal does not support DHCP IPv4 on the portal node"); - } - let prefix = snapshot + let host_address = snapshot .peer .runtime .core .routes .ipv4 .as_ref() - .ok_or_else(|| anyhow::anyhow!("VPN portal requires a static IPv4 address"))?; - let IpAddr::V4(portal_ip) = prefix.address else { - anyhow::bail!("VPN portal requires an IPv4 route prefix"); - }; - let network = Ipv4Inet::new(portal_ip, prefix.prefix_len) - .map_err(|error| anyhow::anyhow!("invalid portal IPv4 prefix: {error}"))? - .network(); + .and_then(|prefix| match prefix.address { + IpAddr::V4(address) => Some(address), + IpAddr::V6(_) => None, + }); for client in &config.clients { - if client.virtual_ip == portal_ip - || !network.contains(&client.virtual_ip) - || client.virtual_ip == network.first_address() - || client.virtual_ip == network.last_address() + let address = client.virtual_ip.address(); + let network = client.virtual_ip.network(); + if host_address == Some(address) + || address == network.first_address() + || address == network.last_address() { anyhow::bail!( "VPN portal client {} has an unusable virtual IP {}", client.name, - client.virtual_ip + address ); } } @@ -879,6 +824,10 @@ fn ipv4_source(payload: &[u8]) -> Option { )) } +fn has_ipv4_source(payload: &[u8], expected: Ipv4Addr) -> bool { + ipv4_source(payload) == Some(expected) +} + #[cfg(test)] mod tests { use super::*; @@ -937,7 +886,12 @@ mod tests { } fn render_client_config(&self, plan: &PortalClientConfigPlan) -> String { - format!("config:{}", plan.name) + format!( + "config:{}:{}:{}", + plan.name, + plan.address, + plan.allowed_ips.join(",") + ) } } @@ -1092,7 +1046,7 @@ mod tests { fn client(name: &str, virtual_ip: Ipv4Addr, groups: &[&str]) -> PortalClientConfig { PortalClientConfig { name: name.to_owned(), - virtual_ip, + virtual_ip: Ipv4Inet::new(virtual_ip, 24).unwrap(), groups: groups.iter().map(|group| (*group).to_owned()).collect(), } } @@ -1108,6 +1062,21 @@ mod tests { packet[20] = 8; packet } + + #[test] + fn portal_client_packet_requires_its_assigned_source() { + let assigned = Ipv4Addr::new(10, 82, 0, 2); + + assert!(has_ipv4_source( + &raw_ipv4(assigned, Ipv4Addr::new(10, 82, 0, 1)), + assigned + )); + assert!(!has_ipv4_source( + &raw_ipv4(Ipv4Addr::new(10, 82, 0, 99), Ipv4Addr::new(10, 82, 0, 1)), + assigned + )); + assert!(!has_ipv4_source(&[0u8; 8], assigned)); + } fn network_runtime() -> (Arc, CoreRuntimeConfigStore) { network_runtime_with_secure_mode(false) } @@ -1213,6 +1182,22 @@ mod tests { assert!(error.contains("VPN portal requires an admin node")); } + #[test] + fn portal_client_cidr_is_independent_of_host_addressing() { + let runtime_config = runtime_config(); + runtime_config.update_peer_with(|peer| peer.runtime.core.routes.ipv4 = None); + runtime_config.update_services(|services| services.dhcp_ipv4 = true); + let config = PortalRuntimeConfig { + clients: vec![PortalClientConfig { + name: "alice".to_owned(), + virtual_ip: "10.82.0.2/16".parse().unwrap(), + groups: vec!["ops".to_owned()], + }], + }; + + validate_clients(&config, runtime_config.snapshot().as_ref()).unwrap(); + } + #[test] fn portal_session_debug_redacts_identity_private_key() { let identity_private_key = [173u8; 32]; @@ -1233,11 +1218,12 @@ mod tests { } #[tokio::test] - async fn portal_session_publishes_learned_tunnel_ip_without_mesh_reply() { + async fn portal_session_publishes_virtual_ip_before_first_client_packet() { let (peer_manager, runtime_config) = network_runtime(); peer_manager.run().await.unwrap(); + let virtual_ip = Ipv4Addr::new(10, 82, 0, 2); let config = PortalRuntimeConfig { - clients: vec![client("alice", Ipv4Addr::new(10, 82, 0, 2), &["ops"])], + clients: vec![client("alice", virtual_ip, &["ops"])], }; let statuses = Arc::new(RwLock::new(BTreeMap::from([( "alice".to_owned(), @@ -1248,7 +1234,7 @@ mod tests { Arc::new(Mutex::new(())), )]))); let (to_runtime, from_client) = mpsc::channel(1); - let (to_client, _from_runtime) = mpsc::channel(1); + let (to_client, mut from_runtime) = mpsc::channel(1); let (endpoint_sender, endpoint) = tokio::sync::watch::channel("portal://alice".to_owned()); let session = PortalSession { client_name: "alice".to_owned(), @@ -1270,34 +1256,51 @@ mod tests { cancel, )); - to_runtime - .send(raw_ipv4( - DEFAULT_PORTAL_CLIENT_ADDRESS, - Ipv4Addr::new(10, 82, 0, 1), - )) - .await - .unwrap(); tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { let status = statuses.read().await.get("alice").cloned().unwrap(); - if status.tunnel_ip == Some(DEFAULT_PORTAL_CLIENT_ADDRESS) { + if status.state == PortalClientState::Online && status.tunnel_ip == Some(virtual_ip) + { return; } assert_ne!( status.state, PortalClientState::Error, - "portal session failed before learning tunnel IP: {:?}", + "portal session failed before publishing virtual IP: {:?}", status.error ); assert!( !task.is_finished(), - "portal session ended before learning tunnel IP" + "portal session ended before publishing virtual IP" ); tokio::task::yield_now().await; } }) .await - .expect("learned tunnel IP was not published"); + .expect("configured virtual IP was not published before the first client packet"); + + let mesh_packet = raw_ipv4(Ipv4Addr::new(10, 82, 0, 1), virtual_ip); + let outbound = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let _ = peer_manager + .send_msg_by_ip( + crate::packet::ZCPacket::new_with_payload(&mesh_packet), + IpAddr::V4(virtual_ip), + false, + ) + .await; + if let Ok(Some(packet)) = + tokio::time::timeout(std::time::Duration::from_millis(20), from_runtime.recv()) + .await + { + break packet; + } + } + }) + .await + .expect("mesh packet was not delivered before the first client packet"); + assert_eq!(&outbound[16..20], virtual_ip.octets().as_slice()); + endpoint_sender.send("portal://roamed".to_owned()).unwrap(); tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { @@ -1469,30 +1472,26 @@ mod tests { )); to_runtime - .send(raw_ipv4( - DEFAULT_PORTAL_CLIENT_ADDRESS, - Ipv4Addr::new(10, 82, 0, 1), - )) + .send(raw_ipv4(virtual_ip, Ipv4Addr::new(10, 82, 0, 1))) .await .unwrap(); let attached_peer_id = tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { let status = statuses.read().await.get("alice").cloned().unwrap(); - if status.state == PortalClientState::Online - && status.tunnel_ip == Some(DEFAULT_PORTAL_CLIENT_ADDRESS) + if status.state == PortalClientState::Online && status.tunnel_ip == Some(virtual_ip) { return status.peer_id.unwrap(); } assert!( !task.is_finished(), - "portal session ended before learning its tunnel address: {:?}", + "portal session ended before publishing its virtual address: {:?}", status.error ); tokio::task::yield_now().await; } }) .await - .expect("portal session did not learn its tunnel address"); + .expect("portal session did not publish its virtual address"); drop(from_runtime); let mesh_packet = raw_ipv4(Ipv4Addr::new(10, 82, 0, 1), virtual_ip); @@ -1632,6 +1631,39 @@ mod tests { peer_manager.clear_resources().await; } + #[tokio::test] + async fn portal_client_config_routes_its_own_network_not_the_host_network() { + let (peer_manager, runtime_config) = network_runtime(); + let module = PortalModule::new( + peer_manager.clone(), + runtime_config, + Some(PortalRuntimeConfig { + clients: vec![PortalClientConfig { + name: "alice".to_owned(), + virtual_ip: "10.90.0.2/16".parse().unwrap(), + groups: vec!["ops".to_owned()], + }], + }), + Some(StaticPortalHost::new(vec![Box::new( + PendingPortalListener { + url: "test://127.0.0.1:10004".parse().unwrap(), + accept_calls: Arc::new(AtomicUsize::new(0)), + }, + )])), + Arc::new(()), + ) + .unwrap(); + + module.start().await.unwrap(); + let snapshot = module.info_snapshot().await; + + assert!(snapshot.clients[0].client_config.contains("10.90.0.2")); + assert!(snapshot.clients[0].client_config.contains("10.90.0.0/16")); + assert!(!snapshot.clients[0].client_config.contains("10.82.0.0/24")); + module.stop().await; + peer_manager.clear_resources().await; + } + #[tokio::test] async fn portal_restarts_after_listener_accept_failure() { let (peer_manager, runtime_config) = network_runtime(); diff --git a/easytier-core/src/instance/lifecycle.rs b/easytier-core/src/instance/lifecycle.rs index e622d251..499a8ac9 100644 --- a/easytier-core/src/instance/lifecycle.rs +++ b/easytier-core/src/instance/lifecycle.rs @@ -152,6 +152,13 @@ where } async fn stop_components(&self) { + if !self.peer_manager.withdraw_routes_before_stop().await { + tracing::warn!( + instance = %self.instance_name, + "not every direct route session acknowledged the shutdown withdrawal" + ); + } + #[cfg(feature = "vpn-portal")] self.vpn_portal.stop().await; #[cfg(feature = "public-ipv6-provider")] diff --git a/easytier-core/src/instance/tests.rs b/easytier-core/src/instance/tests.rs index 4fee8754..13de336d 100644 --- a/easytier-core/src/instance/tests.rs +++ b/easytier-core/src/instance/tests.rs @@ -344,7 +344,7 @@ mod portable_runtime { config.vpn_portal = Some(crate::gateway::vpn_portal::PortalRuntimeConfig { clients: vec![crate::gateway::vpn_portal::PortalClientConfig { name: "alice".to_owned(), - virtual_ip: "10.82.0.2".parse().unwrap(), + virtual_ip: "10.82.0.2/24".parse().unwrap(), groups: Vec::new(), }], }); @@ -476,6 +476,37 @@ mod portable_runtime { Ok(()) } } + + #[cfg(feature = "vpn-portal")] + struct RejectingPortalHost; + + #[cfg(feature = "vpn-portal")] + #[async_trait] + impl crate::gateway::vpn_portal::PortalHost for RejectingPortalHost { + async fn start_listeners( + &self, + ) -> anyhow::Result> { + anyhow::bail!("injected portal start failure") + } + + fn name(&self) -> String { + "rejecting-test-portal".to_owned() + } + + fn render_client_config( + &self, + _plan: &crate::gateway::vpn_portal::PortalClientConfigPlan, + ) -> String { + String::new() + } + + async fn update_clients( + &self, + _clients: &[crate::gateway::vpn_portal::PortalClientConfig], + ) -> anyhow::Result<()> { + anyhow::bail!("injected portal update failure") + } + } #[cfg(feature = "vpn-portal")] #[tokio::test] async fn runtime_update_rejects_portal_client_address_conflict() { @@ -893,6 +924,198 @@ source = "web" assert!(persisted[0].contains(&secret)); } + #[cfg(feature = "management")] + #[tokio::test] + async fn ordinary_config_patch_is_durable_before_commit() { + use easytier_proto::api::config::InstanceConfigPatch; + + let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16); + let config = TomlConfig::new_from_str( + r#" +instance_name = "durable-ordinary-patch" +hostname = "before" + +[network_identity] +network_name = "durable-network" +network_secret = "network-secret" + +[source] +source = "web" +"#, + ) + .unwrap(); + let instance = + CoreInstance::from_toml(config, adapters(None, Arc::new(packet_sink))).unwrap(); + instance.start().await.unwrap(); + let patch = InstanceConfigPatch { + hostname: Some("after".to_owned()), + ..Default::default() + }; + let persistence = RecordingConfigPatchPersistence { + writes: std::sync::Mutex::new(Vec::new()), + fail: AtomicBool::new(true), + }; + + let error = + crate::management::apply_config_patch(&instance, patch.clone(), Some(&persistence)) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("injected config persistence failure") + ); + assert_eq!(instance.toml_config().unwrap().get_hostname(), "before"); + + persistence.fail.store(false, Ordering::Relaxed); + crate::management::apply_config_patch(&instance, patch, Some(&persistence)) + .await + .unwrap(); + + assert_eq!(instance.toml_config().unwrap().get_hostname(), "after"); + { + let persisted = persistence.writes.lock().unwrap(); + assert_eq!(persisted.len(), 1); + assert!(persisted[0].contains("hostname = \"after\"")); + } + instance.stop().await; + } + + #[cfg(all(feature = "management", feature = "vpn-portal"))] + #[tokio::test] + async fn portal_client_patch_restores_durable_state_after_failures() { + use easytier_proto::api::{ + config::{ConfigPatchAction, InstanceConfigPatch, VpnPortalClientPatch}, + manage::VpnPortalClientConfig, + }; + + let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16); + let mut host_adapters = adapters(None, Arc::new(packet_sink)); + host_adapters.vpn_portal = Some(Arc::new(RejectingPortalHost)); + let instance = CoreInstance::from_toml( + TomlConfig::new_from_str( + r#" +instance_name = "durable-portal-patch" +ipv4 = "10.82.0.1/24" + +[network_identity] +network_name = "durable-portal-network" +network_secret = "network-secret" + +[vpn_portal_config] +wireguard_listen = "0.0.0.0:51820" + +[[vpn_portal_config.clients]] +name = "alice" +virtual_ip = "10.82.0.2/24" + +[source] +source = "web" +"#, + ) + .unwrap(), + host_adapters, + ) + .unwrap(); + instance.set_state(CoreInstanceState::Running); + let persistence = RecordingConfigPatchPersistence { + writes: std::sync::Mutex::new(Vec::new()), + fail: AtomicBool::new(true), + }; + + let error = crate::management::apply_config_patch( + &instance, + InstanceConfigPatch { + vpn_portal_clients: vec![ + VpnPortalClientPatch { + action: ConfigPatchAction::Remove as i32, + client: Some(VpnPortalClientConfig { + name: "alice".to_owned(), + ..Default::default() + }), + }, + VpnPortalClientPatch { + action: ConfigPatchAction::Add as i32, + client: Some(VpnPortalClientConfig { + name: "bob".to_owned(), + virtual_ip: "10.82.0.3/24".to_owned(), + ..Default::default() + }), + }, + ], + ..Default::default() + }, + Some(&persistence), + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("injected config persistence failure") + ); + let clients = instance + .toml_config() + .unwrap() + .get_vpn_portal_config() + .unwrap() + .clients; + assert_eq!(clients.len(), 1); + assert_eq!(clients[0].name, "alice"); + assert!(persistence.writes.lock().unwrap().is_empty()); + + persistence.fail.store(false, Ordering::Relaxed); + let error = crate::management::apply_config_patch( + &instance, + InstanceConfigPatch { + vpn_portal_clients: vec![ + VpnPortalClientPatch { + action: ConfigPatchAction::Remove as i32, + client: Some(VpnPortalClientConfig { + name: "alice".to_owned(), + ..Default::default() + }), + }, + VpnPortalClientPatch { + action: ConfigPatchAction::Add as i32, + client: Some(VpnPortalClientConfig { + name: "bob".to_owned(), + virtual_ip: "10.82.0.3/24".to_owned(), + ..Default::default() + }), + }, + ], + ..Default::default() + }, + Some(&persistence), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("injected portal update failure")); + + crate::management::apply_config_patch( + &instance, + InstanceConfigPatch { + hostname: Some("after-rollback".to_owned()), + ..Default::default() + }, + Some(&persistence), + ) + .await + .unwrap(); + + { + let persisted = persistence.writes.lock().unwrap(); + assert_eq!(persisted.len(), 3); + assert!(persisted[0].contains("name = \"bob\"")); + assert!(persisted[1].contains("name = \"alice\"")); + assert!(persisted[2].contains("name = \"alice\"")); + assert!(!persisted[2].contains("name = \"bob\"")); + } + instance.peer_manager.clear_resources().await; + } + #[cfg(all(feature = "management", not(feature = "proxy-smoltcp-stack")))] #[tokio::test] async fn unavailable_gateway_patch_does_not_commit_shared_toml() { @@ -985,7 +1208,7 @@ wireguard_listen = "0.0.0.0:51820" [[vpn_portal_config.clients]] name = "alice" -virtual_ip = "10.82.0.2" +virtual_ip = "10.82.0.2/24" "#, ) .unwrap(), diff --git a/easytier-core/src/management/full/config_patch.rs b/easytier-core/src/management/full/config_patch.rs index f7252f93..e10535b4 100644 --- a/easytier-core/src/management/full/config_patch.rs +++ b/easytier-core/src/management/full/config_patch.rs @@ -51,30 +51,32 @@ where // sub-patches remain applied if a later sub-patch fails. let patch_result: anyhow::Result<(bool, bool)> = async { let result = patch_port_forwards(&candidate, patch.port_forwards); - validate_and_commit_candidate(instance, &config, &candidate)?; + validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?; result?; let result = patch_acl(&candidate, patch.acl); - validate_and_commit_candidate(instance, &config, &candidate)?; + validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?; result?; let result = patch_proxy_networks(&candidate, patch.proxy_networks); - validate_and_commit_candidate(instance, &config, &candidate)?; + validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?; result?; let result = patch_routes(&candidate, patch.routes); - validate_and_commit_candidate(instance, &config, &candidate)?; + validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?; result?; let result = patch_exit_nodes_config(&candidate, patch.exit_nodes); - let normalized = validate_and_commit_candidate(instance, &config, &candidate)?; + let normalized = + validate_persist_and_commit_candidate(instance, &config, &candidate, persistence) + .await?; result?; instance .update_exit_nodes(normalized.peer.exit_nodes.clone()) .await; let result = patch_mapped_listeners(&candidate, patch.mapped_listeners); - validate_and_commit_candidate(instance, &config, &candidate)?; + validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?; result?; patch_connectors(instance, patch.connectors)?; @@ -117,29 +119,45 @@ where // Runs last so client validation sees the fully patched candidate, // including routes and the node IPv4 set earlier in this request. if !patch.vpn_portal_clients.is_empty() { + let previous = config.detached_snapshot(); apply_vpn_portal_client_patches(&candidate, patch.vpn_portal_clients)?; - // Deep-validate and hot-apply before committing, so a rejected - // client set leaves neither the shared TOML model nor the live - // portal changed. + // Deep-validate and durably persist before hot-applying. A failed + // write leaves the live Portal untouched. If the host rejects the + // hot update, restore the previous durable snapshot before + // returning so a later patch cannot overwrite from stale shared + // state and a restart cannot apply a rejected client set. let normalized = validate_candidate(instance, &candidate)?; + persist_candidate_if_changed(instance, &config, &candidate, persistence).await?; #[cfg(feature = "vpn-portal")] { let portal = normalized .vpn_portal .clone() .ok_or_else(|| anyhow::anyhow!("VPN portal is not configured"))?; - instance + if let Err(error) = instance .update_vpn_portal_clients( portal.clients, &runtime_config_from_normalized(&normalized), ) - .await?; + .await + { + if let Some(persistence) = persistence + && let Err(rollback_error) = + persistence.persist(instance.instance_id(), &previous).await + { + return Err(error.context(format!( + "failed to restore durable configuration after VPN portal update: \ + {rollback_error:#}" + ))); + } + return Err(error); + } } #[cfg(not(feature = "vpn-portal"))] { let _ = normalized; } - validate_and_commit_candidate(instance, &config, &candidate)?; + config.replace_from_snapshot(&candidate); } if let Some(managed) = &managed_credentials { @@ -177,17 +195,19 @@ where .map_err(anyhow::Error::msg)?; candidate.set_managed_credentials(entries); validate_candidate(instance, &candidate)?; - // File-backed configs persist every successful patch, so the - // durable file and the shared TOML model can never diverge. - persistence - .ok_or_else(|| anyhow::anyhow!("durable config patching is unavailable"))? - .persist(instance.instance_id(), &candidate) - .await?; + // When durable storage is configured, persist before installing + // secret authority so a successful replacement survives restart. + if let Some(persistence) = persistence { + persistence + .persist(instance.instance_id(), &candidate) + .await?; + } config.replace_from_snapshot(&candidate); managed_credentials_changed = CredentialManager::install_managed_credentials(replacement); } else { - validate_and_commit_candidate(instance, &config, &candidate)?; + validate_persist_and_commit_candidate(instance, &config, &candidate, persistence) + .await?; } let normalized = validate_candidate(instance, &candidate)?; let runtime = runtime_config_from_normalized(&normalized); @@ -241,19 +261,42 @@ where Ok(normalized) } -fn validate_and_commit_candidate( +async fn validate_persist_and_commit_candidate( instance: &CoreInstance, shared: &TomlConfig, candidate: &TomlConfig, + persistence: Option<&dyn ConfigPatchPersistence>, ) -> anyhow::Result where H: CoreInstanceHost, { let normalized = validate_candidate(instance, candidate)?; - shared.replace_from_snapshot(candidate); + if persist_candidate_if_changed(instance, shared, candidate, persistence).await? { + shared.replace_from_snapshot(candidate); + } Ok(normalized) } +async fn persist_candidate_if_changed( + instance: &CoreInstance, + shared: &TomlConfig, + candidate: &TomlConfig, + persistence: Option<&dyn ConfigPatchPersistence>, +) -> anyhow::Result +where + H: CoreInstanceHost, +{ + if shared.dump() == candidate.dump() { + return Ok(false); + } + if let Some(persistence) = persistence { + persistence + .persist(instance.instance_id(), candidate) + .await?; + } + Ok(true) +} + fn runtime_config_from_toml( instance: &CoreInstance, config: &TomlConfig, @@ -471,15 +514,16 @@ fn apply_vpn_portal_client_patches( tracing::warn!("ignored VPN portal client add without client"); continue; }; - let virtual_ip = client - .virtual_ip - .parse::() - .with_context(|| { - format!( - "invalid VPN portal client virtual IP: {}", - client.virtual_ip - ) - })?; + let virtual_ip = + client + .virtual_ip + .parse::() + .with_context(|| { + format!( + "invalid VPN portal client virtual CIDR: {}", + client.virtual_ip + ) + })?; portal .clients .push(crate::config::toml::VpnPortalClientConfig { @@ -560,7 +604,7 @@ mod tests { wireguard_private_key: None, clients: vec![VpnPortalClientConfig { name: "alice".to_owned(), - virtual_ip: "10.0.0.2".parse().unwrap(), + virtual_ip: "10.0.0.2/24".parse().unwrap(), groups: Vec::new(), }], }); @@ -603,7 +647,7 @@ mod tests { fn vpn_portal_client_patches_add_remove_and_clear() { let config = portal_config(); - apply_vpn_portal_client_patches(&config, vec![add("bob", "10.0.0.3")]).unwrap(); + apply_vpn_portal_client_patches(&config, vec![add("bob", "10.0.0.3/24")]).unwrap(); assert_eq!(configured_names(&config), ["alice", "bob"]); apply_vpn_portal_client_patches(&config, vec![remove("alice")]).unwrap(); @@ -636,7 +680,7 @@ mod tests { assert!( error .to_string() - .contains("invalid VPN portal client virtual IP") + .contains("invalid VPN portal client virtual CIDR") ); } } diff --git a/easytier-core/src/management/instance_rpc/full.rs b/easytier-core/src/management/instance_rpc/full.rs index 8d66334f..7b698e5c 100644 --- a/easytier-core/src/management/instance_rpc/full.rs +++ b/easytier-core/src/management/instance_rpc/full.rs @@ -447,7 +447,7 @@ mod tests { state: PortalClientState::Online, peer_id: Some(42), endpoint: Some("198.51.100.2:51820".to_owned()), - tunnel_ip: Some(Ipv4Addr::new(192, 0, 2, 1)), + tunnel_ip: Some(Ipv4Addr::new(10, 82, 0, 2)), client_config: "[Interface]\nPrivateKey = secret\n".to_owned(), error: None, }, @@ -479,7 +479,7 @@ mod tests { assert_eq!(online.state, VpnPortalClientState::Online as i32); assert_eq!(online.peer_id, Some(42)); assert_eq!(online.endpoint.as_deref(), Some("198.51.100.2:51820")); - assert_eq!(online.tunnel_ip.as_deref(), Some("192.0.2.1")); + assert_eq!(online.tunnel_ip.as_deref(), Some("10.82.0.2")); assert_eq!(online.client_config, "[Interface]\nPrivateKey = secret\n"); assert_eq!(online.error, None); diff --git a/easytier-core/src/management/instance_rpc/mod.rs b/easytier-core/src/management/instance_rpc/mod.rs index ff60a42b..20b360ba 100644 --- a/easytier-core/src/management/instance_rpc/mod.rs +++ b/easytier-core/src/management/instance_rpc/mod.rs @@ -167,15 +167,10 @@ where H: CoreInstanceHost, { pub fn new(manager: Arc>) -> Self { - #[cfg(feature = "web-client")] - let persistence = Arc::new(ManagerPathlessConfigPatchPersistence { - manager: manager.clone(), - _host: std::marker::PhantomData, - }); Self { resolver: ManagerInstanceResolver { manager }, #[cfg(feature = "web-client")] - config_patch_persistence: Some(persistence), + config_patch_persistence: None, } } #[cfg(feature = "web-client")] @@ -225,40 +220,6 @@ impl ConfigPatchPersistence for InMemoryConfigPatchPersistence { } } -#[cfg(feature = "web-client")] -struct ManagerPathlessConfigPatchPersistence -where - F: InstanceFactory, - H: CoreInstanceHost, -{ - manager: Arc>, - _host: std::marker::PhantomData H>, -} - -#[async_trait::async_trait] -#[cfg(feature = "web-client")] -impl ConfigPatchPersistence for ManagerPathlessConfigPatchPersistence -where - F: InstanceFactory>, - H: CoreInstanceHost, -{ - async fn persist(&self, instance_id: uuid::Uuid, _config: &TomlConfig) -> anyhow::Result<()> { - let Some(control) = self.manager.config_control(instance_id) else { - return Ok(()); - }; - if control.is_read_only() { - anyhow::bail!("configuration file is read-only"); - } - if let Some(path) = control.path { - anyhow::bail!( - "config file {} requires a durable config storage backend", - path.display() - ); - } - Ok(()) - } -} - #[cfg(feature = "web-client")] struct ManagerConfigPatchPersistence where diff --git a/easytier-core/src/peers/attached.rs b/easytier-core/src/peers/attached.rs index e431e5a5..d7f77872 100644 --- a/easytier-core/src/peers/attached.rs +++ b/easytier-core/src/peers/attached.rs @@ -35,10 +35,10 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq)] pub struct AttachedPeerConfig { pub name: String, - pub virtual_ip: Ipv4Addr, + pub virtual_ip: cidr::Ipv4Inet, pub groups: Vec, - /// Stable identity key supplied by the caller; changing it changes the - /// peer identity, so callers must persist and reuse it across restarts. + /// Identity key supplied by the caller. It stays stable for one attached + /// runtime; replacing the runtime may deliberately rotate the identity. pub identity_private_key: [u8; 32], } @@ -393,28 +393,9 @@ fn build_peer_snapshot( .as_deref() .filter(|secret| !secret.is_empty()) .ok_or_else(|| anyhow::anyhow!("attached peers require a non-empty network secret"))?; - if network.services.dhcp_ipv4 { - anyhow::bail!("attached peers require a static network-manager IPv4 address"); - } - let network_ipv4 = network - .peer - .runtime - .core - .routes - .ipv4 - .as_ref() - .ok_or_else(|| anyhow::anyhow!("attached peers require a network-manager IPv4 prefix"))?; - let IpAddr::V4(network_address) = network_ipv4.address else { - anyhow::bail!("attached peers require a network-manager IPv4 prefix"); - }; - let network_prefix = cidr::Ipv4Inet::new(network_address, network_ipv4.prefix_len) - .map_err(|error| anyhow::anyhow!("invalid network-manager IPv4 prefix: {error}"))?; - let network_prefix = network_prefix.network(); - if config.virtual_ip == network_address - || !network_prefix.contains(&config.virtual_ip) - || config.virtual_ip == network_prefix.first_address() - || config.virtual_ip == network_prefix.last_address() - { + let address = config.virtual_ip.address(); + let client_network = config.virtual_ip.network(); + if address == client_network.first_address() || address == client_network.last_address() { anyhow::bail!("unusable attached-peer IPv4 address: {}", config.virtual_ip); } @@ -423,8 +404,8 @@ fn build_peer_snapshot( snapshot.runtime.core.node.instance_id = None; snapshot.runtime.core.node.hostname = Some(config.name.clone()); snapshot.runtime.core.routes.ipv4 = Some(IpPrefix { - address: IpAddr::V4(config.virtual_ip), - prefix_len: network_ipv4.prefix_len, + address: IpAddr::V4(address), + prefix_len: config.virtual_ip.network_length(), }); snapshot.runtime.core.routes.ipv6 = None; snapshot.runtime.core.routes.advertised_routes.clear(); @@ -544,6 +525,10 @@ mod tests { }, }; + fn attached_ipv4(address: Ipv4Addr) -> cidr::Ipv4Inet { + cidr::Ipv4Inet::new(address, 24).unwrap() + } + fn peer_manager_with_acl( tcp_whitelist: Vec, ) -> (Arc, CoreRuntimeConfigStore) { @@ -713,7 +698,7 @@ mod tests { store.clone(), AttachedPeerConfig { name: "group-update".to_owned(), - virtual_ip: Ipv4Addr::new(10, 82, 0, 2), + virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 2)), groups: vec!["ops".to_owned(), "audit".to_owned()], identity_private_key: [2; 32], }, @@ -745,7 +730,7 @@ mod tests { store, AttachedPeerConfig { name: "direct-group-update".to_owned(), - virtual_ip: Ipv4Addr::new(10, 82, 0, 2), + virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 2)), groups: vec!["ops".to_owned()], identity_private_key: [5; 32], }, @@ -831,7 +816,7 @@ mod tests { let network = store.snapshot(); let config = AttachedPeerConfig { name: "sanitized".to_owned(), - virtual_ip: Ipv4Addr::new(10, 82, 0, 2), + virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 2)), groups: vec!["ops".to_owned()], identity_private_key: [3; 32], }; @@ -878,6 +863,30 @@ mod tests { ); } + #[tokio::test] + async fn attached_peer_uses_its_own_cidr_when_host_uses_dhcp() { + let (_network_peer_manager, store) = peer_manager_with_acl_and_secure(Vec::new(), true); + store.update_peer_with(|peer| peer.runtime.core.routes.ipv4 = None); + store.update_services(|services| services.dhcp_ipv4 = true); + let network = store.snapshot(); + let config = AttachedPeerConfig { + name: "wireguard-client".to_owned(), + virtual_ip: "10.90.0.2/16".parse().unwrap(), + groups: vec!["ops".to_owned()], + identity_private_key: [3; 32], + }; + + let (snapshot, _) = build_peer_snapshot(network.as_ref(), &config).unwrap(); + + assert_eq!( + snapshot.runtime.core.routes.ipv4, + Some(IpPrefix { + address: IpAddr::V4(Ipv4Addr::new(10, 90, 0, 2)), + prefix_len: 16, + }) + ); + } + #[tokio::test] async fn secure_attached_peer_uses_credential_identity_and_granted_groups() { let (network_peer_manager, store) = peer_manager_with_acl_and_secure(Vec::new(), true); @@ -896,7 +905,7 @@ mod tests { store, AttachedPeerConfig { name: "credential".to_owned(), - virtual_ip: Ipv4Addr::new(10, 82, 0, 2), + virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 2)), groups: vec!["ops".to_owned()], identity_private_key, }, @@ -944,7 +953,7 @@ mod tests { store.clone(), AttachedPeerConfig { name: "first".to_owned(), - virtual_ip: Ipv4Addr::new(10, 82, 0, 2), + virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 2)), groups: vec!["ops".to_owned()], identity_private_key: [1; 32], }, @@ -956,7 +965,7 @@ mod tests { store.clone(), AttachedPeerConfig { name: "second".to_owned(), - virtual_ip: Ipv4Addr::new(10, 82, 0, 3), + virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 3)), groups: Vec::new(), identity_private_key: [2; 32], }, @@ -1030,7 +1039,7 @@ mod tests { store, AttachedPeerConfig { name: "reconnected".to_owned(), - virtual_ip: Ipv4Addr::new(10, 82, 0, 4), + virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 4)), groups: vec!["ops".to_owned()], identity_private_key: [3; 32], }, @@ -1059,7 +1068,7 @@ mod tests { store, AttachedPeerConfig { name: "cancelled-close".to_owned(), - virtual_ip: Ipv4Addr::new(10, 82, 0, 4), + virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 4)), groups: Vec::new(), identity_private_key, }, @@ -1126,7 +1135,7 @@ mod tests { store, AttachedPeerConfig { name: "closed-receiver".to_owned(), - virtual_ip: Ipv4Addr::new(10, 82, 0, 4), + virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 4)), groups: Vec::new(), identity_private_key: [3; 32], }, @@ -1158,7 +1167,7 @@ mod tests { store, AttachedPeerConfig { name: "dropped".to_owned(), - virtual_ip: Ipv4Addr::new(10, 82, 0, 4), + virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 4)), groups: Vec::new(), identity_private_key: [3; 32], }, @@ -1198,7 +1207,7 @@ mod tests { store, AttachedPeerConfig { name: "dropped-secure".to_owned(), - virtual_ip: Ipv4Addr::new(10, 82, 0, 5), + virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 5)), groups: vec!["ops".to_owned()], identity_private_key, }, diff --git a/easytier-core/src/peers/peer_manager.rs b/easytier-core/src/peers/peer_manager.rs index 365749a6..489bc1e8 100644 --- a/easytier-core/src/peers/peer_manager.rs +++ b/easytier-core/src/peers/peer_manager.rs @@ -1519,6 +1519,13 @@ impl PeerManagerCore { self.route.clone() } + pub(crate) async fn withdraw_routes_before_stop(&self) -> bool { + let Some(route) = self.route_algo_inst.ospf_route() else { + return true; + }; + route.withdraw_self_conn_info().await + } + pub fn mark_recent_traffic(&self, dst_peer_id: PeerId) { let flags = self.context.flags(); self.recent_traffic diff --git a/easytier-core/src/peers/route/peer_ospf_route.rs b/easytier-core/src/peers/route/peer_ospf_route.rs index e64afcbd..c35a10e1 100644 --- a/easytier-core/src/peers/route/peer_ospf_route.rs +++ b/easytier-core/src/peers/route/peer_ospf_route.rs @@ -2369,6 +2369,7 @@ struct PeerRouteServiceImpl { interface_peers_generation: AtomicU64, applied_interface_peers_generation: AtomicU64, applied_interface_peers: std::sync::Mutex>, + self_conn_info_withdrawn: AtomicBool, last_update_my_foreign_network: AtomicCell>, @@ -2442,6 +2443,7 @@ impl PeerRouteServiceImpl { interface_peers_generation: AtomicU64::new(1), applied_interface_peers_generation: AtomicU64::new(0), applied_interface_peers: std::sync::Mutex::new(BTreeSet::new()), + self_conn_info_withdrawn: AtomicBool::new(false), last_update_my_foreign_network: AtomicCell::new(None), @@ -2606,6 +2608,10 @@ impl PeerRouteServiceImpl { &self, snapshot: &InterfacePeerSnapshot, ) -> BTreeSet { + if self.self_conn_info_withdrawn.load(Ordering::Acquire) { + return BTreeSet::new(); + } + if !self.peer_relay_projection_enabled() { return snapshot.peers.clone(); } @@ -2780,7 +2786,9 @@ impl PeerRouteServiceImpl { fn local_route_snapshot(&self) -> OspfRouteSnapshot { let mut snapshot = self.synced_route_info.route_snapshot(); - if !self.peer_relay_projection_enabled() { + if !self.peer_relay_projection_enabled() + && !self.self_conn_info_withdrawn.load(Ordering::Acquire) + { return snapshot; } @@ -4201,6 +4209,63 @@ impl Debug for PeerRoute { } impl PeerRoute { + pub(crate) async fn withdraw_self_conn_info(&self) -> bool { + if self.service_impl.stopped.load(Ordering::Acquire) { + return true; + } + if self.service_impl.interface.lock().await.is_none() { + return true; + } + + self.service_impl + .self_conn_info_withdrawn + .store(true, Ordering::Release); + self.service_impl.mark_interface_peers_dirty(); + let direct_peers = self.service_impl.interface_peer_snapshot().await; + self.service_impl.update_my_infos().await; + + let Some(conn_info_version) = self + .service_impl + .synced_route_info + .conn_map + .read() + .get(&self.my_peer_id) + .map(|info| info.version.get()) + else { + return false; + }; + let sessions = direct_peers + .peers + .iter() + .filter_map(|peer_id| { + self.service_impl + .get_session(*peer_id) + .map(|session| (*peer_id, session)) + }) + .collect::>(); + if sessions.is_empty() { + return true; + } + + let Some(peer_rpc) = self.peer_rpc.upgrade() else { + return false; + }; + let synchronized = crate::foundation::time::timeout(Duration::from_secs(1), async { + futures::future::join_all(sessions.iter().map(|(peer_id, _)| { + self.service_impl + .sync_route_with_peer(*peer_id, peer_rpc.clone(), false) + })) + .await; + + sessions.iter().all(|(_, session)| { + session.check_saved_conn_version_update_to_date(self.my_peer_id, conn_info_version) + }) + }) + .await; + + matches!(synchronized, Ok(true)) + } + pub fn new( my_peer_id: PeerId, context: ArcPeerContext, @@ -5314,6 +5379,127 @@ mod tests { assert_eq!(get_peer_identity_type_calls.load(Ordering::Relaxed), 4); } + #[tokio::test] + async fn shutdown_withdrawal_preserves_local_physical_routes() { + let (route, _peer_rpc) = + test_route_with_admin_peer(Arc::new(NoopPeerContext::default())).await; + + assert!(route.service_impl.update_my_infos().await); + assert_eq!( + route + .service_impl + .synced_route_info + .get_connected_peers::>(1), + Some(BTreeSet::from([2])) + ); + let previous_version = route + .service_impl + .synced_route_info + .conn_map + .read() + .get(&1) + .unwrap() + .version + .get(); + + assert!(route.withdraw_self_conn_info().await); + { + let conn_map = route.service_impl.synced_route_info.conn_map.read(); + let withdrawn = conn_map.get(&1).unwrap(); + assert!(withdrawn.connected_peers.is_empty()); + assert!(withdrawn.version.get() > previous_version); + } + + let local_snapshot = route.service_impl.local_route_snapshot(); + assert_eq!( + local_snapshot + .conn_map + .iter() + .find(|row| row.peer_id == 1) + .unwrap() + .connected_peers, + BTreeSet::from([2]) + ); + + route.service_impl.mark_interface_peers_dirty(); + assert!(!route.service_impl.update_my_conn_info().await); + assert!( + route + .service_impl + .synced_route_info + .get_connected_peers::>(1) + .unwrap() + .is_empty() + ); + } + + #[tokio::test] + async fn empty_self_conn_info_round_trips_through_list_and_bitmap() { + let source = test_service_impl(1); + *source.interface.lock().await = Some(Box::new(CountingInterface { + my_peer_id: 1, + peers: Arc::new(Mutex::new(vec![2])), + peer_identity_types: Arc::new(Mutex::new(HashMap::from([( + 2, + Some(PeerIdentityType::Admin), + )]))), + list_peers_calls: Arc::new(AtomicU32::new(0)), + get_peer_identity_type_calls: Arc::new(AtomicU32::new(0)), + })); + assert!(source.update_my_infos().await); + source + .self_conn_info_withdrawn + .store(true, Ordering::Release); + source.mark_interface_peers_dirty(); + assert!(source.update_my_infos().await); + + let session = SyncRouteSession::new(1, 2); + let mut estimated_size = 0; + let peer_list = source + .build_conn_peer_list(&session, &mut estimated_size) + .expect("withdrawn row should remain in the peer list"); + let listed = peer_list + .peer_conn_infos + .iter() + .find(|info| info.peer_id.is_some_and(|id| id.peer_id == 1)) + .expect("peer list should carry the withdrawn self row"); + assert!(listed.connected_peer_ids.is_empty()); + + let list_receiver = test_service_impl(2); + install_conn_row(&list_receiver, 1, [2]); + list_receiver + .synced_route_info + .update_conn_info_with_list(&peer_list); + assert!( + list_receiver + .synced_route_info + .get_connected_peers::>(1) + .unwrap() + .is_empty() + ); + + let bitmap = source.build_conn_bitmap(); + let self_index = bitmap + .peer_ids + .iter() + .position(|id| id.peer_id == 1) + .expect("bitmap should carry the withdrawn self row"); + assert!(bitmap.get_connected_peers(self_index).is_empty()); + + let bitmap_receiver = test_service_impl(2); + install_conn_row(&bitmap_receiver, 1, [2]); + bitmap_receiver + .synced_route_info + .update_conn_info_with_bitmap(&bitmap); + assert!( + bitmap_receiver + .synced_route_info + .get_connected_peers::>(1) + .unwrap() + .is_empty() + ); + } + #[tokio::test] async fn periodic_requery_without_peer_change_keeps_route_version_stable() { let service_impl = test_peer_relay_service_impl(1); diff --git a/easytier-web/frontend-lib/src/locales/cn.yaml b/easytier-web/frontend-lib/src/locales/cn.yaml index d1c028ac..aa955863 100644 --- a/easytier-web/frontend-lib/src/locales/cn.yaml +++ b/easytier-web/frontend-lib/src/locales/cn.yaml @@ -27,8 +27,8 @@ vpn_portal_add_client: 添加客户端 vpn_portal_no_clients: 尚未配置客户端 vpn_portal_client_name: 客户端名称 vpn_portal_client_name_placeholder: 例如:alice-phone -vpn_portal_client_virtual_ip: 虚拟网地址 -vpn_portal_client_virtual_ip_placeholder: 例如:10.126.126.10 +vpn_portal_client_virtual_ip: 虚拟网 CIDR +vpn_portal_client_virtual_ip_placeholder: 例如:10.126.126.10/24 vpn_portal_client_groups: ACL 组 vpn_portal_client_groups_placeholder: 选择 ACL 组 vpn_portal_remove_client: 删除客户端 diff --git a/easytier-web/frontend-lib/src/locales/en.yaml b/easytier-web/frontend-lib/src/locales/en.yaml index a171870b..58a72a00 100644 --- a/easytier-web/frontend-lib/src/locales/en.yaml +++ b/easytier-web/frontend-lib/src/locales/en.yaml @@ -27,8 +27,8 @@ vpn_portal_add_client: Add Client vpn_portal_no_clients: No clients configured vpn_portal_client_name: Client Name vpn_portal_client_name_placeholder: "Example: alice-phone" -vpn_portal_client_virtual_ip: Virtual Network Address -vpn_portal_client_virtual_ip_placeholder: "Example: 10.126.126.10" +vpn_portal_client_virtual_ip: Virtual Network CIDR +vpn_portal_client_virtual_ip_placeholder: "Example: 10.126.126.10/24" vpn_portal_client_groups: ACL Groups vpn_portal_client_groups_placeholder: Select ACL groups vpn_portal_remove_client: Remove Client diff --git a/easytier-web/frontend-lib/tests/config-ui.spec.ts b/easytier-web/frontend-lib/tests/config-ui.spec.ts index 10341c82..7da4745b 100644 --- a/easytier-web/frontend-lib/tests/config-ui.spec.ts +++ b/easytier-web/frontend-lib/tests/config-ui.spec.ts @@ -331,7 +331,7 @@ function makeConfig(): NetworkConfig { wireguard_private_key: 'portal-private-key', clients: [{ name: 'phone-a', - virtual_ip: '10.1.2.10', + virtual_ip: '10.1.2.10/24', groups: ['ops'], }], }, @@ -421,7 +421,7 @@ describe('Config.vue network config projection', () => { expect(input(wrapper, '#vpn_portal_wireguard_listen').value).toBe('0.0.0.0:22023') expect(input(wrapper, '#vpn_portal_wireguard_private_key').value).toBe('portal-private-key') expect(input(wrapper, '#vpn_portal_client_name_0').value).toBe('phone-a') - expect(input(wrapper, '#vpn_portal_client_virtual_ip_0').value).toBe('10.1.2.10') + expect(input(wrapper, '#vpn_portal_client_virtual_ip_0').value).toBe('10.1.2.10/24') expect(input(wrapper, '#vpn_portal_client_groups_0').value).toBe('ops') expect(input(wrapper, '#dev_name').value).toBe('tun-test') expect(input(wrapper, '#mtu').value).toBe('1280') @@ -455,7 +455,7 @@ describe('Config.vue network config projection', () => { await setInput(wrapper, '#vpn_portal_wireguard_listen', '[::]:23000') await setInput(wrapper, '#vpn_portal_wireguard_private_key', 'edited-private-key') await setInput(wrapper, '#vpn_portal_client_name_0', 'laptop-a') - await setInput(wrapper, '#vpn_portal_client_virtual_ip_0', '10.1.2.20') + await setInput(wrapper, '#vpn_portal_client_virtual_ip_0', '10.1.2.20/24') await setInput(wrapper, '#vpn_portal_client_groups_0', 'ops,admin') await setInput(wrapper, 'input[data-add-label="add_listener_url"]', 'tcp://0.0.0.0:13010') await setInput(wrapper, '#dev_name', 'tun-edited') @@ -489,7 +489,7 @@ describe('Config.vue network config projection', () => { wireguard_private_key: 'edited-private-key', clients: [{ name: 'laptop-a', - virtual_ip: '10.1.2.20', + virtual_ip: '10.1.2.20/24', groups: ['ops', 'admin'], }], }, @@ -525,7 +525,7 @@ describe('Config.vue network config projection', () => { wireguard_private_key: 'edited-private-key', clients: [{ name: 'laptop-a', - virtual_ip: '10.1.2.20', + virtual_ip: '10.1.2.20/24', groups: ['ops', 'admin'], }], }, diff --git a/easytier-web/src/client_manager/runtime_reconcile.rs b/easytier-web/src/client_manager/runtime_reconcile.rs index f231ca43..71273ad5 100644 --- a/easytier-web/src/client_manager/runtime_reconcile.rs +++ b/easytier-web/src/client_manager/runtime_reconcile.rs @@ -568,15 +568,15 @@ mod tests { fn vpn_portal_client_changes_produce_hot_patches() { let current = config_with_vpn_portal( vec![ - portal_client("alice", "10.144.144.4"), - portal_client("carol", "10.144.144.6"), + portal_client("alice", "10.144.144.4/24"), + portal_client("carol", "10.144.144.6/24"), ], "0.0.0.0:22121", ); let desired = config_with_vpn_portal( vec![ - portal_client("bob", "10.144.144.5"), - portal_client("carol", "10.144.144.7"), + portal_client("bob", "10.144.144.5/24"), + portal_client("carol", "10.144.144.7/24"), ], "0.0.0.0:22121", ); @@ -595,16 +595,24 @@ mod tests { ], "removals must precede additions; changed clients are remove+add" ); + let added = patch + .vpn_portal_clients + .iter() + .filter(|item| item.action == ConfigPatchAction::Add as i32) + .filter_map(|item| item.client.as_ref()) + .map(|client| client.virtual_ip.as_str()) + .collect::>(); + assert_eq!(added, ["10.144.144.5/24", "10.144.144.7/24"]); } #[test] fn vpn_portal_client_no_op_produces_empty_patch_section() { let current = config_with_vpn_portal( - vec![portal_client("alice", "10.144.144.4")], + vec![portal_client("alice", "10.144.144.4/24")], "0.0.0.0:22121", ); let desired = config_with_vpn_portal( - vec![portal_client("alice", "10.144.144.4")], + vec![portal_client("alice", "10.144.144.4/24")], "0.0.0.0:22121", ); @@ -617,11 +625,11 @@ mod tests { #[test] fn vpn_portal_listener_identity_change_requires_recreate() { let current = config_with_vpn_portal( - vec![portal_client("alice", "10.144.144.4")], + vec![portal_client("alice", "10.144.144.4/24")], "0.0.0.0:22121", ); let desired = config_with_vpn_portal( - vec![portal_client("alice", "10.144.144.4")], + vec![portal_client("alice", "10.144.144.4/24")], "0.0.0.0:22122", ); assert!( @@ -652,7 +660,7 @@ mod tests { fn vpn_portal_enable_or_disable_requires_recreate() { let without_portal = config_with_port_forwards(Vec::new()); let with_portal = config_with_vpn_portal( - vec![portal_client("alice", "10.144.144.4")], + vec![portal_client("alice", "10.144.144.4/24")], "0.0.0.0:22121", ); diff --git a/easytier-web/src/client_manager/session/runtime_revision.rs b/easytier-web/src/client_manager/session/runtime_revision.rs index 4c0c297b..c426f513 100644 --- a/easytier-web/src/client_manager/session/runtime_revision.rs +++ b/easytier-web/src/client_manager/session/runtime_revision.rs @@ -1087,19 +1087,33 @@ async fn mark_config_revision_applied_if_current( let Some(data) = session_data.upgrade() else { return RoundStatus::Stop; }; - let mut data = data.write().await; - if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req) { - return RoundStatus::Ready(()); + let notify = { + let mut data = data.write().await; + if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req) { + return RoundStatus::Ready(()); + } + if data.runtime_config_epoch != round.runtime_config_epoch { + return RoundStatus::Ready(()); + } + record_applied_config_revision(&mut data, round.target_config_revision.clone()) + }; + if let Some(notify) = notify { + notify.notify_one(); } - if data.runtime_config_epoch != round.runtime_config_epoch { - return RoundStatus::Ready(()); - } - data.applied_config_revision = round.target_config_revision.clone(); - data.pending_managed_config_delta = None; RoundStatus::Ready(()) } +fn record_applied_config_revision( + data: &mut SessionData, + revision: Option, +) -> Option> { + let changed = data.applied_config_revision != revision; + data.applied_config_revision = revision; + data.pending_managed_config_delta = None; + changed.then(|| SessionRpcService::mark_webhook_validation_dirty_locked(data)) +} + #[cfg(test)] mod tests { use easytier::proto::api::manage::{NetworkingMethod, PortForwardConfig}; @@ -1128,6 +1142,52 @@ mod tests { } } + #[tokio::test] + async fn newly_applied_revision_wakes_webhook_validation() { + let storage = + crate::client_manager::storage::Storage::new(crate::db::Db::memory_db().await); + let mut data = SessionData::new( + storage.weak_ref(), + url::Url::parse("http://127.0.0.1").unwrap(), + None, + std::sync::Arc::new(crate::FeatureFlags::default()), + std::sync::Arc::new(crate::webhook::WebhookConfig::new( + None, None, None, None, None, + )), + ); + + let notify = record_applied_config_revision(&mut data, Some("rev-applied".to_string())) + .expect("new applied revision should wake validation"); + assert_eq!(data.applied_config_revision.as_deref(), Some("rev-applied")); + assert!(data.webhook_validation_dirty); + + notify.notify_one(); + tokio::time::timeout(std::time::Duration::from_millis(100), notify.notified()) + .await + .expect("validation worker was not notified"); + } + + #[tokio::test] + async fn unchanged_applied_revision_does_not_add_validation_work() { + let storage = + crate::client_manager::storage::Storage::new(crate::db::Db::memory_db().await); + let mut data = SessionData::new( + storage.weak_ref(), + url::Url::parse("http://127.0.0.1").unwrap(), + None, + std::sync::Arc::new(crate::FeatureFlags::default()), + std::sync::Arc::new(crate::webhook::WebhookConfig::new( + None, None, None, None, None, + )), + ); + data.applied_config_revision = Some("rev-applied".to_string()); + + assert!( + record_applied_config_revision(&mut data, Some("rev-applied".to_string())).is_none() + ); + assert!(!data.webhook_validation_dirty); + } + #[test] fn patch_delete_requires_runtime_to_remove_every_requested_instance() { let deleted_id = uuid::Uuid::new_v4(); diff --git a/easytier/locales/app.yml b/easytier/locales/app.yml index 7c11a313..d20b243a 100644 --- a/easytier/locales/app.yml +++ b/easytier/locales/app.yml @@ -106,8 +106,8 @@ core_clap: en: "base64 WireGuard server private key (prefer ET_VPN_PORTAL_PRIVATE_KEY over command-line exposure)" zh-CN: "Base64 WireGuard 服务端私钥(建议通过 ET_VPN_PORTAL_PRIVATE_KEY 传入,避免命令行暴露)" vpn_portal_client: - en: "named VPN portal client in NAME=IP form; may be repeated" - zh-CN: "NAME=IP 格式的具名 VPN 门户客户端;可重复指定" + en: "named VPN portal client in NAME=CIDR form, for example alice=10.144.0.5/16; may be repeated" + zh-CN: "NAME=CIDR 格式的具名 VPN 门户客户端,例如 alice=10.144.0.5/16;可重复指定" vpn_portal_client_group: en: "VPN portal client group membership in NAME=GROUP form; may be repeated" zh-CN: "NAME=GROUP 格式的 VPN 门户客户端组成员关系;可重复指定" diff --git a/easytier/src/core.rs b/easytier/src/core.rs index a76eedbb..7190227e 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -896,7 +896,7 @@ impl NetworkOptions { } if !url.path().is_empty() { anyhow::bail!( - "legacy VPN portal CIDR paths are no longer supported; use wg://host:port and configure --vpn-portal-client NAME=IP" + "legacy VPN portal CIDR paths are no longer supported; use wg://host:port and configure --vpn-portal-client NAME=CIDR" ); } if !url.username().is_empty() @@ -924,15 +924,20 @@ impl NetworkOptions { .iter() .map(|value| { let (name, virtual_ip) = value.split_once('=').ok_or_else(|| { - anyhow::anyhow!("invalid vpn portal client {value:?}; expected NAME=IP") + anyhow::anyhow!("invalid vpn portal client {value:?}; expected NAME=CIDR") })?; if name.is_empty() { anyhow::bail!("vpn portal client name cannot be empty"); } + if !virtual_ip.contains('/') { + anyhow::bail!( + "invalid vpn portal client {value:?}; expected NAME=CIDR, for example alice=10.144.0.5/16" + ); + } Ok(VpnPortalClientConfig { name: name.to_owned(), virtual_ip: virtual_ip.parse().with_context(|| { - format!("invalid virtual IP for vpn portal client {name}: {virtual_ip}") + format!("invalid virtual CIDR for vpn portal client {name}: {virtual_ip}") })?, groups: Vec::new(), }) @@ -1949,7 +1954,7 @@ wireguard_private_key = "existing-key" [[vpn_portal_config.clients]] name = "existing" -virtual_ip = "10.144.144.9" +virtual_ip = "10.144.144.9/24" "#, ) .unwrap(); @@ -1971,8 +1976,8 @@ virtual_ip = "10.144.144.9" NetworkOptions { vpn_portal_private_key: Some("replacement-key".to_owned()), vpn_portal_clients: vec![ - "alice=10.144.144.10".to_owned(), - "bob=10.144.144.11".to_owned(), + "alice=10.144.144.10/24".to_owned(), + "bob=10.144.144.11/24".to_owned(), ], vpn_portal_client_groups: vec!["alice=staff".to_owned(), "alice=dev".to_owned()], ..Default::default() @@ -2024,7 +2029,7 @@ virtual_ip = "10.144.144.9" let unknown_client = NetworkOptions { vpn_portal: Some("wg://0.0.0.0:51820".to_owned()), - vpn_portal_clients: vec!["alice=10.144.144.10".to_owned()], + vpn_portal_clients: vec!["alice=10.144.144.10/24".to_owned()], vpn_portal_client_groups: vec!["bob=staff".to_owned()], ..Default::default() } @@ -2035,6 +2040,15 @@ virtual_ip = "10.144.144.9" unknown_client.contains("unknown CLI client: bob"), "{unknown_client}" ); + + let bare_ip = NetworkOptions { + vpn_portal_clients: vec!["alice=10.144.144.10".to_owned()], + ..Default::default() + } + .parse_vpn_portal_clients() + .unwrap_err() + .to_string(); + assert!(bare_ip.contains("expected NAME=CIDR"), "{bare_ip}"); } #[test] diff --git a/easytier/src/easytier-cli.rs b/easytier/src/easytier-cli.rs index f9efb9e5..1886049e 100644 --- a/easytier/src/easytier-cli.rs +++ b/easytier/src/easytier-cli.rs @@ -279,7 +279,7 @@ enum VpnPortalSubCommand { AddClient { #[arg(help = "client name")] name: String, - #[arg(long, help = "client virtual IPv4 address inside the mesh network")] + #[arg(long, help = "client virtual IPv4 CIDR inside the mesh network")] virtual_ip: String, #[arg(long, help = "ACL groups assigned to the client")] groups: Vec, @@ -612,6 +612,16 @@ fn is_missing_web_client_service(error: &RpcError) -> bool { ) } +fn parse_vpn_portal_client_cidr(value: &str) -> anyhow::Result { + let value = value.trim(); + if !value.contains('/') { + anyhow::bail!("client virtual IPv4 must include its network prefix"); + } + value + .parse::() + .map_err(|error| anyhow::anyhow!("invalid client virtual IPv4 CIDR ({value}): {error}")) +} + #[cfg(test)] mod tests { use super::*; @@ -637,6 +647,13 @@ mod tests { assert!(!is_missing_web_client_service(&error)); } + #[test] + fn vpn_portal_client_requires_a_complete_ipv4_cidr() { + let client = parse_vpn_portal_client_cidr("10.90.0.2/16").unwrap(); + assert_eq!(client.to_string(), "10.90.0.2/16"); + assert!(parse_vpn_portal_client_cidr("10.90.0.2").is_err()); + } + #[test] fn proxy_cidrs_are_displayed_one_per_line() { assert_eq!( @@ -2725,9 +2742,7 @@ impl<'a> CommandHandler<'a> { virtual_ip: String, groups: Vec, ) -> Result<(), Error> { - virtual_ip - .parse::() - .map_err(|e| anyhow::anyhow!("invalid virtual ip ({virtual_ip}): {e}"))?; + let virtual_ip = parse_vpn_portal_client_cidr(&virtual_ip)?.to_string(); self.apply_to_instances(|handler| { let name = name.clone(); let virtual_ip = virtual_ip.clone(); diff --git a/easytier/src/tests/three_node.rs b/easytier/src/tests/three_node.rs index 252eb16f..764767ea 100644 --- a/easytier/src/tests/three_node.rs +++ b/easytier/src/tests/three_node.rs @@ -1767,7 +1767,7 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) { wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])), clients: vec![VpnPortalClientConfig { name: "test-client".to_owned(), - virtual_ip: "10.144.144.4".parse().unwrap(), + virtual_ip: "10.144.144.4/24".parse().unwrap(), groups: Vec::new(), }], }); @@ -1808,6 +1808,12 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) { client_info.client_config.contains("198.51.100.0/24"), "client config must include remote proxy CIDRs" ); + assert!( + client_info + .client_config + .contains("Address = 10.144.144.4/32"), + "client config must assign the attached peer virtual IP" + ); let (server_public, client_private) = test_wireguard_keys(&portal_config, "test-client").unwrap(); run_wireguard_client( @@ -1816,7 +1822,7 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) { Key::try_from(server_public.as_slice()).unwrap(), Key::try_from(client_private.as_slice()).unwrap(), vec!["10.144.144.0/24".to_string()], - "192.0.2.42".to_string(), + "10.144.144.4".to_owned(), ) .unwrap(); @@ -1867,12 +1873,12 @@ pub async fn wireguard_vpn_portal_multi_client() { clients: vec![ VpnPortalClientConfig { name: "client-a".to_owned(), - virtual_ip: "10.144.144.4".parse().unwrap(), + virtual_ip: "10.144.144.4/24".parse().unwrap(), groups: Vec::new(), }, VpnPortalClientConfig { name: "client-b".to_owned(), - virtual_ip: "10.144.144.5".parse().unwrap(), + virtual_ip: "10.144.144.5/24".parse().unwrap(), groups: Vec::new(), }, ], @@ -1890,9 +1896,9 @@ pub async fn wireguard_vpn_portal_multi_client() { .get_vpn_portal_config() .unwrap(); - for (ns, client_name, tunnel_ip) in [ - ("net_d", "client-a", "192.0.2.42"), - ("net_f", "client-b", "192.0.2.43"), + for (ns, client_name, virtual_ip) in [ + ("net_d", "client-a", "10.144.144.4"), + ("net_f", "client-b", "10.144.144.5"), ] { let net_ns = NetNS::new(Some(ns.into())); let _g = net_ns.guard(); @@ -1904,7 +1910,7 @@ pub async fn wireguard_vpn_portal_multi_client() { Key::try_from(server_public.as_slice()).unwrap(), Key::try_from(client_private.as_slice()).unwrap(), vec!["10.144.144.0/24".to_string()], - tunnel_ip.to_string(), + virtual_ip.to_owned(), ) .unwrap(); } @@ -1923,9 +1929,8 @@ pub async fn wireguard_vpn_portal_multi_client() { .await; } - // 跨客户端互 ping 对方的虚拟 IP:一次流量同时覆盖源地址改写 - // (tunnel_ip -> virtual_ip)与目的地址改写(virtual_ip -> tunnel_ip), - // 回程再反向各执行一遍 + // 跨客户端互 ping 对方的虚拟 IP,验证 WireGuard 地址与 attached + // peer 地址相同且双向数据包无需地址改写。 wait_for_condition( || async { ping_test("net_d", "10.144.144.5", None).await }, Duration::from_secs(10), @@ -1938,7 +1943,7 @@ pub async fn wireguard_vpn_portal_multi_client() { .await; // TCP 数据面:node1 侧看到的连接源地址必须是 client-a 的虚拟 IP, - // 并做一段随机数据回环,覆盖 TCP 增量校验和改写路径 + // 并做一段随机数据回环,验证传输层校验和保持不变。 let mut buf = vec![0u8; 1024]; rand::thread_rng().fill(&mut buf[..]); let expected = buf.clone(); @@ -1964,7 +1969,8 @@ pub async fn wireguard_vpn_portal_multi_client() { } echo_task.await.unwrap(); - // portal 状态:两个客户端均在线,tunnel_ip 学习正确,peer_id 互不相同 + // portal 状态:两个客户端均在线,隧道地址等于各自的 attached peer + // 虚拟 IP,peer_id 互不相同。 let portal_info = insts[2].get_core_instance().vpn_portal_info().await; assert_eq!(portal_info.clients.len(), 2); let client_a = portal_info @@ -1982,8 +1988,8 @@ pub async fn wireguard_vpn_portal_multi_client() { assert!(client.peer_id.is_some()); } assert_ne!(client_a.peer_id, client_b.peer_id); - assert_eq!(client_a.tunnel_ip, Some("192.0.2.42".parse().unwrap())); - assert_eq!(client_b.tunnel_ip, Some("192.0.2.43".parse().unwrap())); + assert_eq!(client_a.tunnel_ip, Some("10.144.144.4".parse().unwrap())); + assert_eq!(client_b.tunnel_ip, Some("10.144.144.5".parse().unwrap())); drop_insts(insts).await; } @@ -2006,7 +2012,7 @@ pub async fn wireguard_vpn_portal_client_roaming() { wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])), clients: vec![VpnPortalClientConfig { name: "roaming-client".to_owned(), - virtual_ip: "10.144.144.4".parse().unwrap(), + virtual_ip: "10.144.144.4/24".parse().unwrap(), groups: Vec::new(), }], }); @@ -2033,7 +2039,7 @@ pub async fn wireguard_vpn_portal_client_roaming() { Key::try_from(server_public.as_slice()).unwrap(), Key::try_from(client_private.as_slice()).unwrap(), vec!["10.144.144.0/24".to_string()], - "192.0.2.42".to_string(), + "10.144.144.4".to_owned(), ) .unwrap(); } @@ -2156,7 +2162,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() { wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])), clients: vec![VpnPortalClientConfig { name: "client-a".to_owned(), - virtual_ip: "10.144.144.4".parse().unwrap(), + virtual_ip: "10.144.144.4/24".parse().unwrap(), groups: Vec::new(), }], }); @@ -2186,7 +2192,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() { Key::try_from(server_public.as_slice()).unwrap(), Key::try_from(client_private.as_slice()).unwrap(), vec!["10.144.144.0/24".to_string()], - "192.0.2.42".to_string(), + "10.144.144.4".to_owned(), ) .unwrap(); } @@ -2204,7 +2210,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() { action: ConfigPatchAction::Add as i32, client: Some(VpnPortalClientConfigPb { name: "client-b".to_owned(), - virtual_ip: "10.144.144.5".to_owned(), + virtual_ip: "10.144.144.5/24".to_owned(), groups: Vec::new(), }), }], @@ -2234,7 +2240,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() { action: ConfigPatchAction::Add as i32, client: Some(VpnPortalClientConfigPb { name: "client-b".to_owned(), - virtual_ip: "10.144.144.9".to_owned(), + virtual_ip: "10.144.144.9/24".to_owned(), groups: Vec::new(), }), }], @@ -2274,7 +2280,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() { Key::try_from(server_public.as_slice()).unwrap(), Key::try_from(client_private.as_slice()).unwrap(), vec!["10.144.144.0/24".to_string()], - "192.0.2.43".to_string(), + "10.144.144.5".to_owned(), ) .unwrap(); } @@ -2325,7 +2331,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() { assert_eq!(info.clients[0].state, PortalClientState::Online); assert_eq!( info.clients[0].tunnel_ip, - Some("192.0.2.43".parse().unwrap()) + Some("10.144.144.5".parse().unwrap()) ); // Release the held CoreInstance Arc so drop_insts can observe a clean diff --git a/easytier/src/vpn_portal/wireguard.rs b/easytier/src/vpn_portal/wireguard.rs index 9a64fba1..a7b688ae 100644 --- a/easytier/src/vpn_portal/wireguard.rs +++ b/easytier/src/vpn_portal/wireguard.rs @@ -262,12 +262,10 @@ impl WireGuardPortalHost { fn derive_client(master: [u8; 32], client: &PortalClientConfig) -> anyhow::Result { let wireguard_private = derive_named_key(&master, b"wireguard-client", &client.name)?; - let identity_private_key = derive_named_key(&master, b"attached-noise", &client.name)?; Ok(DerivedClient { config: client.clone(), wireguard_private, wireguard_public: PublicKey::from(&StaticSecret::from(wireguard_private)), - identity_private_key, }) } fn secondary_ipv6_bind_address(address: SocketAddr, primary_port: u16) -> Option { @@ -394,17 +392,13 @@ mod tests { use super::*; #[test] - fn named_keys_are_domain_separated_and_stable() { + fn named_wireguard_keys_are_stable_and_client_scoped() { let master = [7; 32]; let client = derive_named_key(&master, b"wireguard-client", "laptop").unwrap(); assert_eq!( client, derive_named_key(&master, b"wireguard-client", "laptop").unwrap() ); - assert_ne!( - client, - derive_named_key(&master, b"attached-noise", "laptop").unwrap() - ); assert_ne!( client, derive_named_key(&master, b"wireguard-client", "phone").unwrap() diff --git a/easytier/src/vpn_portal/wireguard/engine.rs b/easytier/src/vpn_portal/wireguard/engine.rs index ad906945..a10a71cb 100644 --- a/easytier/src/vpn_portal/wireguard/engine.rs +++ b/easytier/src/vpn_portal/wireguard/engine.rs @@ -22,6 +22,7 @@ use easytier_core::{ gateway::vpn_portal::{PortalClientConfig, PortalSession}, socket::udp::VirtualUdpSocket, }; +use rand::rngs::OsRng; use tokio::{ sync::{Mutex, mpsc, watch}, task::JoinSet, @@ -41,7 +42,6 @@ pub(super) struct DerivedClient { pub(super) config: PortalClientConfig, pub(super) wireguard_private: [u8; 32], pub(super) wireguard_public: PublicKey, - pub(super) identity_private_key: [u8; 32], } struct PortalChannels { @@ -52,6 +52,7 @@ struct PortalChannels { struct ClientSession { generation: u64, + identity_private_key: [u8; 32], endpoint: Option, endpoint_updates: watch::Sender, tunnel: Tunn, @@ -356,7 +357,7 @@ impl PortalEngine { let _ = self.accepted.send(PortalSession { client_name: slot.client.config.name.clone(), endpoint: channels.endpoint, - identity_private_key: slot.client.identity_private_key, + identity_private_key: session.identity_private_key, from_client: channels.from_client, to_client: channels.to_client, }); @@ -402,6 +403,7 @@ impl PortalEngine { }); ClientSession { generation, + identity_private_key: new_attached_identity_private_key(), endpoint: Some(Endpoint { socket, remote }), endpoint_updates, tunnel: Tunn::new( @@ -521,6 +523,11 @@ impl PortalEngine { } } } + +fn new_attached_identity_private_key() -> [u8; 32] { + StaticSecret::random_from_rng(OsRng).to_bytes() +} + fn is_handshake_initiation(packet: &[u8]) -> bool { packet.len() == 148 && packet.get(..4) == Some(&1u32.to_le_bytes()) } @@ -542,15 +549,22 @@ mod tests { DerivedClient { config: PortalClientConfig { name: name.to_owned(), - virtual_ip: "192.0.2.1".parse().unwrap(), + virtual_ip: "10.82.0.2/24".parse().unwrap(), groups: Vec::new(), }, wireguard_private: secret.to_bytes(), wireguard_public: PublicKey::from(&secret), - identity_private_key: [seed.wrapping_add(1); 32], } } + #[test] + fn attached_identity_is_unique_to_each_live_session() { + assert_ne!( + new_attached_identity_private_key(), + new_attached_identity_private_key() + ); + } + fn slot_index(engine: &PortalEngine, name: &str) -> Option { engine .slots