mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-04 18:15:39 +00:00
feat: stabilize mobile runtime and VPN portal (#2536)
This commit is contained in:
+22
-1
@@ -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
|
||||
|
||||
|
||||
Generated
+1
@@ -2549,6 +2549,7 @@ dependencies = [
|
||||
"easytier-ffi",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Mutex<RotatingLog>>,
|
||||
}
|
||||
|
||||
impl DiagnosticMakeWriter {
|
||||
pub(crate) fn new(directory: &Path) -> io::Result<Self> {
|
||||
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<std::sync::MutexGuard<'_, RotatingLog>> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
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<File>,
|
||||
active_bytes: u64,
|
||||
}
|
||||
|
||||
impl RotatingLog {
|
||||
fn open(directory: &Path) -> io::Result<Self> {
|
||||
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::<Result<Vec<_>, _>>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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<EnvFilter, Registry>,
|
||||
}
|
||||
|
||||
impl DiagnosticLogger {
|
||||
fn install(directory: &Path) -> Result<Self, String> {
|
||||
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<Result<DiagnosticLogger, String>> = 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<Result<(), String>> = 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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<ChecksumField>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum TransportPlan {
|
||||
HeaderOnly,
|
||||
WithPseudoHeaderChecksum(ChecksumField),
|
||||
Icmp {
|
||||
checksum_offset: usize,
|
||||
quoted: Option<QuotedIpv4Plan>,
|
||||
},
|
||||
}
|
||||
|
||||
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<Ipv4Layout, Ipv4TranslationError> {
|
||||
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<TransportPlan, Ipv4TranslationError> {
|
||||
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<QuotedIpv4Plan, Ipv4TranslationError> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
@@ -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::<Ipv4Addr>));
|
||||
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<Ipv4Addr> {
|
||||
))
|
||||
}
|
||||
|
||||
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<PeerManagerCore>, 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();
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<Vec<crate::gateway::vpn_portal::PortalListener>> {
|
||||
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(),
|
||||
|
||||
@@ -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<H>(
|
||||
async fn validate_persist_and_commit_candidate<H>(
|
||||
instance: &CoreInstance<H>,
|
||||
shared: &TomlConfig,
|
||||
candidate: &TomlConfig,
|
||||
persistence: Option<&dyn ConfigPatchPersistence>,
|
||||
) -> anyhow::Result<CoreInstanceConfig>
|
||||
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<H>(
|
||||
instance: &CoreInstance<H>,
|
||||
shared: &TomlConfig,
|
||||
candidate: &TomlConfig,
|
||||
persistence: Option<&dyn ConfigPatchPersistence>,
|
||||
) -> anyhow::Result<bool>
|
||||
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<H>(
|
||||
instance: &CoreInstance<H>,
|
||||
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::<std::net::Ipv4Addr>()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"invalid VPN portal client virtual IP: {}",
|
||||
client.virtual_ip
|
||||
)
|
||||
})?;
|
||||
let virtual_ip =
|
||||
client
|
||||
.virtual_ip
|
||||
.parse::<cidr::Ipv4Inet>()
|
||||
.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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -167,15 +167,10 @@ where
|
||||
H: CoreInstanceHost,
|
||||
{
|
||||
pub fn new(manager: Arc<InstanceManager<F>>) -> 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<F, H>
|
||||
where
|
||||
F: InstanceFactory,
|
||||
H: CoreInstanceHost,
|
||||
{
|
||||
manager: Arc<InstanceManager<F>>,
|
||||
_host: std::marker::PhantomData<fn() -> H>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
#[cfg(feature = "web-client")]
|
||||
impl<F, H> ConfigPatchPersistence for ManagerPathlessConfigPatchPersistence<F, H>
|
||||
where
|
||||
F: InstanceFactory<Instance = CoreInstance<H>>,
|
||||
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<F, H>
|
||||
where
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
) -> (Arc<PeerManagerCore>, 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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2369,6 +2369,7 @@ struct PeerRouteServiceImpl {
|
||||
interface_peers_generation: AtomicU64,
|
||||
applied_interface_peers_generation: AtomicU64,
|
||||
applied_interface_peers: std::sync::Mutex<BTreeSet<PeerId>>,
|
||||
self_conn_info_withdrawn: AtomicBool,
|
||||
|
||||
last_update_my_foreign_network: AtomicCell<Option<Instant>>,
|
||||
|
||||
@@ -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<PeerId> {
|
||||
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::<Vec<_>>();
|
||||
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::<BTreeSet<_>>(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::<BTreeSet<_>>(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::<BTreeSet<_>>(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::<BTreeSet<_>>(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);
|
||||
|
||||
@@ -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: 删除客户端
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
}],
|
||||
},
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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",
|
||||
);
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
) -> Option<std::sync::Arc<tokio::sync::Notify>> {
|
||||
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();
|
||||
|
||||
@@ -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 门户客户端组成员关系;可重复指定"
|
||||
|
||||
+21
-7
@@ -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]
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -612,6 +612,16 @@ fn is_missing_web_client_service(error: &RpcError) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_vpn_portal_client_cidr(value: &str) -> anyhow::Result<cidr::Ipv4Inet> {
|
||||
let value = value.trim();
|
||||
if !value.contains('/') {
|
||||
anyhow::bail!("client virtual IPv4 must include its network prefix");
|
||||
}
|
||||
value
|
||||
.parse::<cidr::Ipv4Inet>()
|
||||
.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<String>,
|
||||
) -> Result<(), Error> {
|
||||
virtual_ip
|
||||
.parse::<std::net::Ipv4Addr>()
|
||||
.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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -262,12 +262,10 @@ impl WireGuardPortalHost {
|
||||
|
||||
fn derive_client(master: [u8; 32], client: &PortalClientConfig) -> anyhow::Result<DerivedClient> {
|
||||
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<SocketAddr> {
|
||||
@@ -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()
|
||||
|
||||
@@ -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>,
|
||||
endpoint_updates: watch::Sender<String>,
|
||||
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<u32> {
|
||||
engine
|
||||
.slots
|
||||
|
||||
Reference in New Issue
Block a user