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
|
source-owned rows and versions are never rewritten, cached for promotion, or
|
||||||
otherwise changed by this projection.
|
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
|
Relay eligibility comes from the transport-authenticated credential identity
|
||||||
and grant, not self-reported route metadata. The advertisement Module does not
|
and grant, not self-reported route metadata. The advertisement Module does not
|
||||||
support changing a credential's relay permission in place; such a permission
|
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
|
protocol peers; `attached` describes only the local transport and its trusted
|
||||||
ingress provenance, not a parent/child peer role.
|
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
|
Each manager owns its ACL execution state, route service, RPC endpoint, secure
|
||||||
sessions, packet processing, and lifecycle. Portal code supplies raw packets
|
sessions, packet processing, and lifecycle. Portal code supplies raw packets
|
||||||
and peer configuration but does not build, reload, or coordinate ACL filters.
|
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
|
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
|
network secret or ACL group secrets. A non-Secure-Mode network retains the
|
||||||
legacy admin-attached identity for compatibility. A credential peer cannot host
|
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
|
## Compact compatibility Host
|
||||||
|
|
||||||
|
|||||||
Generated
+1
@@ -2549,6 +2549,7 @@ dependencies = [
|
|||||||
"easytier-ffi",
|
"easytier-ffi",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -280,6 +280,11 @@ Generated service descriptors and message types remain in `easytier-proto`.
|
|||||||
- VPN portal client/session policy;
|
- VPN portal client/session policy;
|
||||||
- UDP broadcast classification and rewrite 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,
|
TUN, raw sockets, transparent-destination lookup, concrete protocol engines,
|
||||||
native DNS servers, namespace operations, and route application stay in native
|
native DNS servers, namespace operations, and route application stay in native
|
||||||
Adapters.
|
Adapters.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ crate-type = ["staticlib", "rlib"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
easytier-ffi = { path = "../easytier-ffi", default-features = false, features = [
|
easytier-ffi = { path = "../easytier-ffi", default-features = false, features = [
|
||||||
"c-abi",
|
"c-abi",
|
||||||
|
|||||||
@@ -28,14 +28,34 @@ extern "C" {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Enable diagnostic EasyTier logging to stderr.
|
* @brief Configure persistent EasyTier diagnostic logging.
|
||||||
*
|
*
|
||||||
* Installs a narrow global tracing subscriber that records port-forward
|
* Enabling writes targeted connection trace/debug events into rotating log
|
||||||
* lifecycle events. Repeated calls are idempotent.
|
* 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.
|
* @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
|
//! All exported functions are panic-safe: panics are caught at the FFI
|
||||||
//! boundary and reported through `easytier_ios_last_error`.
|
//! boundary and reported through `easytier_ios_last_error`.
|
||||||
|
|
||||||
|
mod diagnostic_logging;
|
||||||
mod error;
|
mod error;
|
||||||
mod strings;
|
mod strings;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
ffi::{CStr, c_char, c_int},
|
ffi::{CStr, c_char, c_int},
|
||||||
panic::{AssertUnwindSafe, catch_unwind},
|
panic::{AssertUnwindSafe, catch_unwind},
|
||||||
|
path::Path,
|
||||||
ptr,
|
ptr,
|
||||||
sync::OnceLock,
|
sync::OnceLock,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use diagnostic_logging::DiagnosticMakeWriter;
|
||||||
use strings::cstring_for;
|
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
|
const DIAGNOSTIC_FILTER: &str = concat!(
|
||||||
/// diagnose EasyTier transport failures. The subscriber writes port-forward
|
"easytier_ios::diagnostics=trace,",
|
||||||
/// lifecycle events to stderr.
|
"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
|
/// Enabling installs a process-wide subscriber on first use, writes into
|
||||||
/// first (see `easytier_ios_last_error`). Repeated calls are idempotent.
|
/// `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)]
|
#[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, || {
|
guarded(-1, || {
|
||||||
error::clear_error();
|
error::clear_error();
|
||||||
static RESULT: OnceLock<Result<(), String>> = OnceLock::new();
|
let Some(Ok(logger)) = DIAGNOSTIC_LOGGER.get() else {
|
||||||
let result = RESULT.get_or_init(|| {
|
return 0;
|
||||||
let filter =
|
};
|
||||||
tracing_subscriber::EnvFilter::new("easytier_core::gateway::port_forward=info");
|
match logger.writer.flush() {
|
||||||
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 {
|
|
||||||
Ok(()) => 0,
|
Ok(()) => 0,
|
||||||
Err(message) => {
|
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
|
-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, ()> {
|
fn acquire() -> MutexGuard<'static, ()> {
|
||||||
init_logs();
|
init_logs();
|
||||||
TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner())
|
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()),
|
wireguard_private_key: Some("server-private-key".to_owned()),
|
||||||
clients: vec![manage::VpnPortalClientConfig {
|
clients: vec![manage::VpnPortalClientConfig {
|
||||||
name: "alice".to_owned(),
|
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()],
|
groups: vec!["staff".to_owned()],
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
@@ -751,7 +751,7 @@ mod tests {
|
|||||||
Some("server-private-key")
|
Some("server-private-key")
|
||||||
);
|
);
|
||||||
assert_eq!(portal.clients[0].name, "alice");
|
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()]);
|
assert_eq!(portal.clients[0].groups, vec!["staff".to_owned()]);
|
||||||
|
|
||||||
let output = NetworkConfig::new_from_config(&config).unwrap();
|
let output = NetworkConfig::new_from_config(&config).unwrap();
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ impl std::fmt::Debug for VpnPortalConfig {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct VpnPortalClientConfig {
|
pub struct VpnPortalClientConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub virtual_ip: std::net::Ipv4Addr,
|
pub virtual_ip: cidr::Ipv4Inet,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub groups: Vec<String>,
|
pub groups: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -1279,7 +1279,7 @@ wireguard_private_key = "wireguard-private-key"
|
|||||||
|
|
||||||
[[vpn_portal_config.clients]]
|
[[vpn_portal_config.clients]]
|
||||||
name = "alice"
|
name = "alice"
|
||||||
virtual_ip = "10.144.144.10"
|
virtual_ip = "10.144.144.10/24"
|
||||||
groups = ["staff"]
|
groups = ["staff"]
|
||||||
|
|
||||||
[acl.acl_v1.group]
|
[acl.acl_v1.group]
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
//! Protocol-neutral portal runtime and host adapter seam.
|
//! Protocol-neutral portal runtime and host adapter seam.
|
||||||
|
|
||||||
mod ipv4_translator;
|
|
||||||
mod runtime;
|
mod runtime;
|
||||||
|
|
||||||
pub use runtime::{
|
pub use runtime::{
|
||||||
DEFAULT_PORTAL_CLIENT_ADDRESS, MAX_VPN_PORTAL_CLIENTS, PortalClientConfig,
|
MAX_VPN_PORTAL_CLIENTS, PortalClientConfig, PortalClientConfigPlan, PortalClientInfoSnapshot,
|
||||||
PortalClientConfigPlan, PortalClientInfoSnapshot, PortalClientState, PortalHost,
|
PortalClientState, PortalHost, PortalInfoSnapshot, PortalListener, PortalModule,
|
||||||
PortalInfoSnapshot, PortalListener, PortalModule, PortalRuntimeConfig, PortalSession,
|
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
|
//! Native adapters authenticate clients and yield sessions. This module owns
|
||||||
//! configured client identities, attached-peer lifetimes, per-client
|
//! 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::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
@@ -29,15 +29,12 @@ use crate::{
|
|||||||
socket::SocketListener,
|
socket::SocketListener,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::ipv4_translator::{rewrite_ipv4_destination, rewrite_ipv4_source};
|
|
||||||
|
|
||||||
pub const MAX_VPN_PORTAL_CLIENTS: usize = 64;
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PortalClientConfig {
|
pub struct PortalClientConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub virtual_ip: Ipv4Addr,
|
pub virtual_ip: Ipv4Inet,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub groups: Vec<String>,
|
pub groups: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -428,7 +425,7 @@ impl PortalModule {
|
|||||||
status.generation = status.generation.wrapping_add(1);
|
status.generation = status.generation.wrapping_add(1);
|
||||||
status.state = PortalClientState::Connecting;
|
status.state = PortalClientState::Connecting;
|
||||||
status.endpoint = Some(session.endpoint.borrow_and_update().clone());
|
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.error = None;
|
||||||
status.generation
|
status.generation
|
||||||
};
|
};
|
||||||
@@ -481,44 +478,15 @@ impl PortalModule {
|
|||||||
let mut client_stream = session.from_client;
|
let mut client_stream = session.from_client;
|
||||||
let endpoint = session.endpoint;
|
let endpoint = session.endpoint;
|
||||||
let client_sink = session.to_client;
|
let client_sink = session.to_client;
|
||||||
let client_ip = Arc::new(Mutex::new(None::<Ipv4Addr>));
|
|
||||||
let client_to_mesh = {
|
let client_to_mesh = {
|
||||||
let attached = attached.clone();
|
let attached = attached.clone();
|
||||||
let client_ip = client_ip.clone();
|
|
||||||
let statuses = statuses.clone();
|
|
||||||
let name = client.name.clone();
|
let name = client.name.clone();
|
||||||
let virtual_ip = client.virtual_ip;
|
let virtual_ip = client.virtual_ip.address();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(mut payload) = client_stream.recv().await {
|
while let Some(payload) = client_stream.recv().await {
|
||||||
let Some(source) = ipv4_source(&payload) else {
|
if !has_ipv4_source(&payload, virtual_ip) {
|
||||||
|
tracing::warn!(client = %name, expected = ?virtual_ip, "VPN client source does not match its assigned address");
|
||||||
continue;
|
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 {
|
if let Err(error) = attached.send_packet(&payload).await {
|
||||||
tracing::debug!(?error, client = %name, "attached peer send failed");
|
tracing::debug!(?error, client = %name, "attached peer send failed");
|
||||||
@@ -529,28 +497,12 @@ impl PortalModule {
|
|||||||
};
|
};
|
||||||
let mesh_to_client = {
|
let mesh_to_client = {
|
||||||
let attached = attached.clone();
|
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 {
|
tokio::spawn(async move {
|
||||||
while let Some(packet) = attached.recv_packet().await {
|
while let Some(packet) = attached.recv_packet().await {
|
||||||
let Some(tunnel_ip) = *client_ip.lock().await else {
|
let payload = packet.payload().to_vec();
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut payload = packet.payload().to_vec();
|
|
||||||
if rewrite_ipv4_destination(&mut payload, virtual_ip, tunnel_ip).is_err() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if client_sink.send(payload).await.is_err() {
|
if client_sink.send(payload).await.is_err() {
|
||||||
break;
|
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 status = statuses.get(&client.name).cloned().unwrap_or_default();
|
||||||
let client_config = match (self.host.as_ref(), listener_url.as_ref()) {
|
let client_config = match (self.host.as_ref(), listener_url.as_ref()) {
|
||||||
(Some(host), Some(listener_url)) => {
|
(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 {
|
host.render_client_config(&PortalClientConfigPlan {
|
||||||
name: client.name.clone(),
|
name: client.name.clone(),
|
||||||
address: DEFAULT_PORTAL_CLIENT_ADDRESS,
|
address: client.virtual_ip.address(),
|
||||||
allowed_ips: allowed_ips.clone(),
|
allowed_ips: client_allowed_ips,
|
||||||
listener_url: listener_url.clone(),
|
listener_url: listener_url.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -717,7 +673,7 @@ impl PortalModule {
|
|||||||
};
|
};
|
||||||
PortalClientInfoSnapshot {
|
PortalClientInfoSnapshot {
|
||||||
name: client.name.clone(),
|
name: client.name.clone(),
|
||||||
virtual_ip: client.virtual_ip,
|
virtual_ip: client.virtual_ip.address(),
|
||||||
groups: client.groups.clone(),
|
groups: client.groups.clone(),
|
||||||
state: status.state,
|
state: status.state,
|
||||||
peer_id: status.peer_id,
|
peer_id: status.peer_id,
|
||||||
@@ -744,12 +700,6 @@ impl PortalModule {
|
|||||||
for route in self.peer_manager.list_route_snapshots().await {
|
for route in self.peer_manager.list_route_snapshots().await {
|
||||||
allowed.extend(route.proxy_cidrs);
|
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 {
|
for proxy in &snapshot.peer.runtime.core.routes.proxy_networks {
|
||||||
let mapped = proxy.mapped.as_ref().unwrap_or(&proxy.real);
|
let mapped = proxy.mapped.as_ref().unwrap_or(&proxy.real);
|
||||||
allowed.insert(format!("{}/{}", mapped.address, mapped.prefix_len));
|
allowed.insert(format!("{}/{}", mapped.address, mapped.prefix_len));
|
||||||
@@ -784,7 +734,7 @@ fn validate_clients(
|
|||||||
if !names.insert(client.name.as_str()) {
|
if !names.insert(client.name.as_str()) {
|
||||||
anyhow::bail!("duplicate VPN portal client name: {}", client.name);
|
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);
|
anyhow::bail!("duplicate VPN portal virtual IP: {}", client.virtual_ip);
|
||||||
}
|
}
|
||||||
for group in &client.groups {
|
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");
|
anyhow::bail!("VPN portal requires an admin node with a non-empty network secret");
|
||||||
}
|
}
|
||||||
if snapshot.services.dhcp_ipv4 {
|
let host_address = snapshot
|
||||||
anyhow::bail!("VPN portal does not support DHCP IPv4 on the portal node");
|
|
||||||
}
|
|
||||||
let prefix = snapshot
|
|
||||||
.peer
|
.peer
|
||||||
.runtime
|
.runtime
|
||||||
.core
|
.core
|
||||||
.routes
|
.routes
|
||||||
.ipv4
|
.ipv4
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("VPN portal requires a static IPv4 address"))?;
|
.and_then(|prefix| match prefix.address {
|
||||||
let IpAddr::V4(portal_ip) = prefix.address else {
|
IpAddr::V4(address) => Some(address),
|
||||||
anyhow::bail!("VPN portal requires an IPv4 route prefix");
|
IpAddr::V6(_) => None,
|
||||||
};
|
});
|
||||||
let network = Ipv4Inet::new(portal_ip, prefix.prefix_len)
|
|
||||||
.map_err(|error| anyhow::anyhow!("invalid portal IPv4 prefix: {error}"))?
|
|
||||||
.network();
|
|
||||||
for client in &config.clients {
|
for client in &config.clients {
|
||||||
if client.virtual_ip == portal_ip
|
let address = client.virtual_ip.address();
|
||||||
|| !network.contains(&client.virtual_ip)
|
let network = client.virtual_ip.network();
|
||||||
|| client.virtual_ip == network.first_address()
|
if host_address == Some(address)
|
||||||
|| client.virtual_ip == network.last_address()
|
|| address == network.first_address()
|
||||||
|
|| address == network.last_address()
|
||||||
{
|
{
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"VPN portal client {} has an unusable virtual IP {}",
|
"VPN portal client {} has an unusable virtual IP {}",
|
||||||
client.name,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -937,7 +886,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_client_config(&self, plan: &PortalClientConfigPlan) -> String {
|
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 {
|
fn client(name: &str, virtual_ip: Ipv4Addr, groups: &[&str]) -> PortalClientConfig {
|
||||||
PortalClientConfig {
|
PortalClientConfig {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
virtual_ip,
|
virtual_ip: Ipv4Inet::new(virtual_ip, 24).unwrap(),
|
||||||
groups: groups.iter().map(|group| (*group).to_owned()).collect(),
|
groups: groups.iter().map(|group| (*group).to_owned()).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1108,6 +1062,21 @@ mod tests {
|
|||||||
packet[20] = 8;
|
packet[20] = 8;
|
||||||
packet
|
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) {
|
fn network_runtime() -> (Arc<PeerManagerCore>, CoreRuntimeConfigStore) {
|
||||||
network_runtime_with_secure_mode(false)
|
network_runtime_with_secure_mode(false)
|
||||||
}
|
}
|
||||||
@@ -1213,6 +1182,22 @@ mod tests {
|
|||||||
assert!(error.contains("VPN portal requires an admin node"));
|
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]
|
#[test]
|
||||||
fn portal_session_debug_redacts_identity_private_key() {
|
fn portal_session_debug_redacts_identity_private_key() {
|
||||||
let identity_private_key = [173u8; 32];
|
let identity_private_key = [173u8; 32];
|
||||||
@@ -1233,11 +1218,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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();
|
let (peer_manager, runtime_config) = network_runtime();
|
||||||
peer_manager.run().await.unwrap();
|
peer_manager.run().await.unwrap();
|
||||||
|
let virtual_ip = Ipv4Addr::new(10, 82, 0, 2);
|
||||||
let config = PortalRuntimeConfig {
|
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([(
|
let statuses = Arc::new(RwLock::new(BTreeMap::from([(
|
||||||
"alice".to_owned(),
|
"alice".to_owned(),
|
||||||
@@ -1248,7 +1234,7 @@ mod tests {
|
|||||||
Arc::new(Mutex::new(())),
|
Arc::new(Mutex::new(())),
|
||||||
)])));
|
)])));
|
||||||
let (to_runtime, from_client) = mpsc::channel(1);
|
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 (endpoint_sender, endpoint) = tokio::sync::watch::channel("portal://alice".to_owned());
|
||||||
let session = PortalSession {
|
let session = PortalSession {
|
||||||
client_name: "alice".to_owned(),
|
client_name: "alice".to_owned(),
|
||||||
@@ -1270,34 +1256,51 @@ mod tests {
|
|||||||
cancel,
|
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 {
|
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||||
loop {
|
loop {
|
||||||
let status = statuses.read().await.get("alice").cloned().unwrap();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
status.state,
|
status.state,
|
||||||
PortalClientState::Error,
|
PortalClientState::Error,
|
||||||
"portal session failed before learning tunnel IP: {:?}",
|
"portal session failed before publishing virtual IP: {:?}",
|
||||||
status.error
|
status.error
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!task.is_finished(),
|
!task.is_finished(),
|
||||||
"portal session ended before learning tunnel IP"
|
"portal session ended before publishing virtual IP"
|
||||||
);
|
);
|
||||||
tokio::task::yield_now().await;
|
tokio::task::yield_now().await;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.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();
|
endpoint_sender.send("portal://roamed".to_owned()).unwrap();
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||||
loop {
|
loop {
|
||||||
@@ -1469,30 +1472,26 @@ mod tests {
|
|||||||
));
|
));
|
||||||
|
|
||||||
to_runtime
|
to_runtime
|
||||||
.send(raw_ipv4(
|
.send(raw_ipv4(virtual_ip, Ipv4Addr::new(10, 82, 0, 1)))
|
||||||
DEFAULT_PORTAL_CLIENT_ADDRESS,
|
|
||||||
Ipv4Addr::new(10, 82, 0, 1),
|
|
||||||
))
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let attached_peer_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
let attached_peer_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||||
loop {
|
loop {
|
||||||
let status = statuses.read().await.get("alice").cloned().unwrap();
|
let status = statuses.read().await.get("alice").cloned().unwrap();
|
||||||
if status.state == PortalClientState::Online
|
if status.state == PortalClientState::Online && status.tunnel_ip == Some(virtual_ip)
|
||||||
&& status.tunnel_ip == Some(DEFAULT_PORTAL_CLIENT_ADDRESS)
|
|
||||||
{
|
{
|
||||||
return status.peer_id.unwrap();
|
return status.peer_id.unwrap();
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
!task.is_finished(),
|
!task.is_finished(),
|
||||||
"portal session ended before learning its tunnel address: {:?}",
|
"portal session ended before publishing its virtual address: {:?}",
|
||||||
status.error
|
status.error
|
||||||
);
|
);
|
||||||
tokio::task::yield_now().await;
|
tokio::task::yield_now().await;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("portal session did not learn its tunnel address");
|
.expect("portal session did not publish its virtual address");
|
||||||
|
|
||||||
drop(from_runtime);
|
drop(from_runtime);
|
||||||
let mesh_packet = raw_ipv4(Ipv4Addr::new(10, 82, 0, 1), virtual_ip);
|
let mesh_packet = raw_ipv4(Ipv4Addr::new(10, 82, 0, 1), virtual_ip);
|
||||||
@@ -1632,6 +1631,39 @@ mod tests {
|
|||||||
peer_manager.clear_resources().await;
|
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]
|
#[tokio::test]
|
||||||
async fn portal_restarts_after_listener_accept_failure() {
|
async fn portal_restarts_after_listener_accept_failure() {
|
||||||
let (peer_manager, runtime_config) = network_runtime();
|
let (peer_manager, runtime_config) = network_runtime();
|
||||||
|
|||||||
@@ -152,6 +152,13 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn stop_components(&self) {
|
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")]
|
#[cfg(feature = "vpn-portal")]
|
||||||
self.vpn_portal.stop().await;
|
self.vpn_portal.stop().await;
|
||||||
#[cfg(feature = "public-ipv6-provider")]
|
#[cfg(feature = "public-ipv6-provider")]
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ mod portable_runtime {
|
|||||||
config.vpn_portal = Some(crate::gateway::vpn_portal::PortalRuntimeConfig {
|
config.vpn_portal = Some(crate::gateway::vpn_portal::PortalRuntimeConfig {
|
||||||
clients: vec![crate::gateway::vpn_portal::PortalClientConfig {
|
clients: vec![crate::gateway::vpn_portal::PortalClientConfig {
|
||||||
name: "alice".to_owned(),
|
name: "alice".to_owned(),
|
||||||
virtual_ip: "10.82.0.2".parse().unwrap(),
|
virtual_ip: "10.82.0.2/24".parse().unwrap(),
|
||||||
groups: Vec::new(),
|
groups: Vec::new(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
@@ -476,6 +476,37 @@ mod portable_runtime {
|
|||||||
Ok(())
|
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")]
|
#[cfg(feature = "vpn-portal")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn runtime_update_rejects_portal_client_address_conflict() {
|
async fn runtime_update_rejects_portal_client_address_conflict() {
|
||||||
@@ -893,6 +924,198 @@ source = "web"
|
|||||||
assert!(persisted[0].contains(&secret));
|
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")))]
|
#[cfg(all(feature = "management", not(feature = "proxy-smoltcp-stack")))]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unavailable_gateway_patch_does_not_commit_shared_toml() {
|
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]]
|
[[vpn_portal_config.clients]]
|
||||||
name = "alice"
|
name = "alice"
|
||||||
virtual_ip = "10.82.0.2"
|
virtual_ip = "10.82.0.2/24"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
|
|||||||
@@ -51,30 +51,32 @@ where
|
|||||||
// sub-patches remain applied if a later sub-patch fails.
|
// sub-patches remain applied if a later sub-patch fails.
|
||||||
let patch_result: anyhow::Result<(bool, bool)> = async {
|
let patch_result: anyhow::Result<(bool, bool)> = async {
|
||||||
let result = patch_port_forwards(&candidate, patch.port_forwards);
|
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?;
|
result?;
|
||||||
|
|
||||||
let result = patch_acl(&candidate, patch.acl);
|
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?;
|
result?;
|
||||||
|
|
||||||
let result = patch_proxy_networks(&candidate, patch.proxy_networks);
|
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?;
|
result?;
|
||||||
|
|
||||||
let result = patch_routes(&candidate, patch.routes);
|
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?;
|
result?;
|
||||||
|
|
||||||
let result = patch_exit_nodes_config(&candidate, patch.exit_nodes);
|
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?;
|
result?;
|
||||||
instance
|
instance
|
||||||
.update_exit_nodes(normalized.peer.exit_nodes.clone())
|
.update_exit_nodes(normalized.peer.exit_nodes.clone())
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let result = patch_mapped_listeners(&candidate, patch.mapped_listeners);
|
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?;
|
result?;
|
||||||
|
|
||||||
patch_connectors(instance, patch.connectors)?;
|
patch_connectors(instance, patch.connectors)?;
|
||||||
@@ -117,29 +119,45 @@ where
|
|||||||
// Runs last so client validation sees the fully patched candidate,
|
// Runs last so client validation sees the fully patched candidate,
|
||||||
// including routes and the node IPv4 set earlier in this request.
|
// including routes and the node IPv4 set earlier in this request.
|
||||||
if !patch.vpn_portal_clients.is_empty() {
|
if !patch.vpn_portal_clients.is_empty() {
|
||||||
|
let previous = config.detached_snapshot();
|
||||||
apply_vpn_portal_client_patches(&candidate, patch.vpn_portal_clients)?;
|
apply_vpn_portal_client_patches(&candidate, patch.vpn_portal_clients)?;
|
||||||
// Deep-validate and hot-apply before committing, so a rejected
|
// Deep-validate and durably persist before hot-applying. A failed
|
||||||
// client set leaves neither the shared TOML model nor the live
|
// write leaves the live Portal untouched. If the host rejects the
|
||||||
// portal changed.
|
// 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)?;
|
let normalized = validate_candidate(instance, &candidate)?;
|
||||||
|
persist_candidate_if_changed(instance, &config, &candidate, persistence).await?;
|
||||||
#[cfg(feature = "vpn-portal")]
|
#[cfg(feature = "vpn-portal")]
|
||||||
{
|
{
|
||||||
let portal = normalized
|
let portal = normalized
|
||||||
.vpn_portal
|
.vpn_portal
|
||||||
.clone()
|
.clone()
|
||||||
.ok_or_else(|| anyhow::anyhow!("VPN portal is not configured"))?;
|
.ok_or_else(|| anyhow::anyhow!("VPN portal is not configured"))?;
|
||||||
instance
|
if let Err(error) = instance
|
||||||
.update_vpn_portal_clients(
|
.update_vpn_portal_clients(
|
||||||
portal.clients,
|
portal.clients,
|
||||||
&runtime_config_from_normalized(&normalized),
|
&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"))]
|
#[cfg(not(feature = "vpn-portal"))]
|
||||||
{
|
{
|
||||||
let _ = normalized;
|
let _ = normalized;
|
||||||
}
|
}
|
||||||
validate_and_commit_candidate(instance, &config, &candidate)?;
|
config.replace_from_snapshot(&candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(managed) = &managed_credentials {
|
if let Some(managed) = &managed_credentials {
|
||||||
@@ -177,17 +195,19 @@ where
|
|||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
candidate.set_managed_credentials(entries);
|
candidate.set_managed_credentials(entries);
|
||||||
validate_candidate(instance, &candidate)?;
|
validate_candidate(instance, &candidate)?;
|
||||||
// File-backed configs persist every successful patch, so the
|
// When durable storage is configured, persist before installing
|
||||||
// durable file and the shared TOML model can never diverge.
|
// secret authority so a successful replacement survives restart.
|
||||||
persistence
|
if let Some(persistence) = persistence {
|
||||||
.ok_or_else(|| anyhow::anyhow!("durable config patching is unavailable"))?
|
persistence
|
||||||
.persist(instance.instance_id(), &candidate)
|
.persist(instance.instance_id(), &candidate)
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
config.replace_from_snapshot(&candidate);
|
config.replace_from_snapshot(&candidate);
|
||||||
managed_credentials_changed =
|
managed_credentials_changed =
|
||||||
CredentialManager::install_managed_credentials(replacement);
|
CredentialManager::install_managed_credentials(replacement);
|
||||||
} else {
|
} 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 normalized = validate_candidate(instance, &candidate)?;
|
||||||
let runtime = runtime_config_from_normalized(&normalized);
|
let runtime = runtime_config_from_normalized(&normalized);
|
||||||
@@ -241,19 +261,42 @@ where
|
|||||||
Ok(normalized)
|
Ok(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_and_commit_candidate<H>(
|
async fn validate_persist_and_commit_candidate<H>(
|
||||||
instance: &CoreInstance<H>,
|
instance: &CoreInstance<H>,
|
||||||
shared: &TomlConfig,
|
shared: &TomlConfig,
|
||||||
candidate: &TomlConfig,
|
candidate: &TomlConfig,
|
||||||
|
persistence: Option<&dyn ConfigPatchPersistence>,
|
||||||
) -> anyhow::Result<CoreInstanceConfig>
|
) -> anyhow::Result<CoreInstanceConfig>
|
||||||
where
|
where
|
||||||
H: CoreInstanceHost,
|
H: CoreInstanceHost,
|
||||||
{
|
{
|
||||||
let normalized = validate_candidate(instance, candidate)?;
|
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)
|
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>(
|
fn runtime_config_from_toml<H>(
|
||||||
instance: &CoreInstance<H>,
|
instance: &CoreInstance<H>,
|
||||||
config: &TomlConfig,
|
config: &TomlConfig,
|
||||||
@@ -471,15 +514,16 @@ fn apply_vpn_portal_client_patches(
|
|||||||
tracing::warn!("ignored VPN portal client add without client");
|
tracing::warn!("ignored VPN portal client add without client");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let virtual_ip = client
|
let virtual_ip =
|
||||||
.virtual_ip
|
client
|
||||||
.parse::<std::net::Ipv4Addr>()
|
.virtual_ip
|
||||||
.with_context(|| {
|
.parse::<cidr::Ipv4Inet>()
|
||||||
format!(
|
.with_context(|| {
|
||||||
"invalid VPN portal client virtual IP: {}",
|
format!(
|
||||||
client.virtual_ip
|
"invalid VPN portal client virtual CIDR: {}",
|
||||||
)
|
client.virtual_ip
|
||||||
})?;
|
)
|
||||||
|
})?;
|
||||||
portal
|
portal
|
||||||
.clients
|
.clients
|
||||||
.push(crate::config::toml::VpnPortalClientConfig {
|
.push(crate::config::toml::VpnPortalClientConfig {
|
||||||
@@ -560,7 +604,7 @@ mod tests {
|
|||||||
wireguard_private_key: None,
|
wireguard_private_key: None,
|
||||||
clients: vec![VpnPortalClientConfig {
|
clients: vec![VpnPortalClientConfig {
|
||||||
name: "alice".to_owned(),
|
name: "alice".to_owned(),
|
||||||
virtual_ip: "10.0.0.2".parse().unwrap(),
|
virtual_ip: "10.0.0.2/24".parse().unwrap(),
|
||||||
groups: Vec::new(),
|
groups: Vec::new(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
@@ -603,7 +647,7 @@ mod tests {
|
|||||||
fn vpn_portal_client_patches_add_remove_and_clear() {
|
fn vpn_portal_client_patches_add_remove_and_clear() {
|
||||||
let config = portal_config();
|
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"]);
|
assert_eq!(configured_names(&config), ["alice", "bob"]);
|
||||||
|
|
||||||
apply_vpn_portal_client_patches(&config, vec![remove("alice")]).unwrap();
|
apply_vpn_portal_client_patches(&config, vec![remove("alice")]).unwrap();
|
||||||
@@ -636,7 +680,7 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
error
|
error
|
||||||
.to_string()
|
.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,
|
state: PortalClientState::Online,
|
||||||
peer_id: Some(42),
|
peer_id: Some(42),
|
||||||
endpoint: Some("198.51.100.2:51820".to_owned()),
|
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(),
|
client_config: "[Interface]\nPrivateKey = secret\n".to_owned(),
|
||||||
error: None,
|
error: None,
|
||||||
},
|
},
|
||||||
@@ -479,7 +479,7 @@ mod tests {
|
|||||||
assert_eq!(online.state, VpnPortalClientState::Online as i32);
|
assert_eq!(online.state, VpnPortalClientState::Online as i32);
|
||||||
assert_eq!(online.peer_id, Some(42));
|
assert_eq!(online.peer_id, Some(42));
|
||||||
assert_eq!(online.endpoint.as_deref(), Some("198.51.100.2:51820"));
|
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.client_config, "[Interface]\nPrivateKey = secret\n");
|
||||||
assert_eq!(online.error, None);
|
assert_eq!(online.error, None);
|
||||||
|
|
||||||
|
|||||||
@@ -167,15 +167,10 @@ where
|
|||||||
H: CoreInstanceHost,
|
H: CoreInstanceHost,
|
||||||
{
|
{
|
||||||
pub fn new(manager: Arc<InstanceManager<F>>) -> Self {
|
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 {
|
Self {
|
||||||
resolver: ManagerInstanceResolver { manager },
|
resolver: ManagerInstanceResolver { manager },
|
||||||
#[cfg(feature = "web-client")]
|
#[cfg(feature = "web-client")]
|
||||||
config_patch_persistence: Some(persistence),
|
config_patch_persistence: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(feature = "web-client")]
|
#[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")]
|
#[cfg(feature = "web-client")]
|
||||||
struct ManagerConfigPatchPersistence<F, H>
|
struct ManagerConfigPatchPersistence<F, H>
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ use crate::{
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct AttachedPeerConfig {
|
pub struct AttachedPeerConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub virtual_ip: Ipv4Addr,
|
pub virtual_ip: cidr::Ipv4Inet,
|
||||||
pub groups: Vec<String>,
|
pub groups: Vec<String>,
|
||||||
/// Stable identity key supplied by the caller; changing it changes the
|
/// Identity key supplied by the caller. It stays stable for one attached
|
||||||
/// peer identity, so callers must persist and reuse it across restarts.
|
/// runtime; replacing the runtime may deliberately rotate the identity.
|
||||||
pub identity_private_key: [u8; 32],
|
pub identity_private_key: [u8; 32],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,28 +393,9 @@ fn build_peer_snapshot(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.filter(|secret| !secret.is_empty())
|
.filter(|secret| !secret.is_empty())
|
||||||
.ok_or_else(|| anyhow::anyhow!("attached peers require a non-empty network secret"))?;
|
.ok_or_else(|| anyhow::anyhow!("attached peers require a non-empty network secret"))?;
|
||||||
if network.services.dhcp_ipv4 {
|
let address = config.virtual_ip.address();
|
||||||
anyhow::bail!("attached peers require a static network-manager IPv4 address");
|
let client_network = config.virtual_ip.network();
|
||||||
}
|
if address == client_network.first_address() || address == client_network.last_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()
|
|
||||||
{
|
|
||||||
anyhow::bail!("unusable attached-peer IPv4 address: {}", config.virtual_ip);
|
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.instance_id = None;
|
||||||
snapshot.runtime.core.node.hostname = Some(config.name.clone());
|
snapshot.runtime.core.node.hostname = Some(config.name.clone());
|
||||||
snapshot.runtime.core.routes.ipv4 = Some(IpPrefix {
|
snapshot.runtime.core.routes.ipv4 = Some(IpPrefix {
|
||||||
address: IpAddr::V4(config.virtual_ip),
|
address: IpAddr::V4(address),
|
||||||
prefix_len: network_ipv4.prefix_len,
|
prefix_len: config.virtual_ip.network_length(),
|
||||||
});
|
});
|
||||||
snapshot.runtime.core.routes.ipv6 = None;
|
snapshot.runtime.core.routes.ipv6 = None;
|
||||||
snapshot.runtime.core.routes.advertised_routes.clear();
|
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(
|
fn peer_manager_with_acl(
|
||||||
tcp_whitelist: Vec<String>,
|
tcp_whitelist: Vec<String>,
|
||||||
) -> (Arc<PeerManagerCore>, CoreRuntimeConfigStore) {
|
) -> (Arc<PeerManagerCore>, CoreRuntimeConfigStore) {
|
||||||
@@ -713,7 +698,7 @@ mod tests {
|
|||||||
store.clone(),
|
store.clone(),
|
||||||
AttachedPeerConfig {
|
AttachedPeerConfig {
|
||||||
name: "group-update".to_owned(),
|
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()],
|
groups: vec!["ops".to_owned(), "audit".to_owned()],
|
||||||
identity_private_key: [2; 32],
|
identity_private_key: [2; 32],
|
||||||
},
|
},
|
||||||
@@ -745,7 +730,7 @@ mod tests {
|
|||||||
store,
|
store,
|
||||||
AttachedPeerConfig {
|
AttachedPeerConfig {
|
||||||
name: "direct-group-update".to_owned(),
|
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()],
|
groups: vec!["ops".to_owned()],
|
||||||
identity_private_key: [5; 32],
|
identity_private_key: [5; 32],
|
||||||
},
|
},
|
||||||
@@ -831,7 +816,7 @@ mod tests {
|
|||||||
let network = store.snapshot();
|
let network = store.snapshot();
|
||||||
let config = AttachedPeerConfig {
|
let config = AttachedPeerConfig {
|
||||||
name: "sanitized".to_owned(),
|
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()],
|
groups: vec!["ops".to_owned()],
|
||||||
identity_private_key: [3; 32],
|
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]
|
#[tokio::test]
|
||||||
async fn secure_attached_peer_uses_credential_identity_and_granted_groups() {
|
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);
|
let (network_peer_manager, store) = peer_manager_with_acl_and_secure(Vec::new(), true);
|
||||||
@@ -896,7 +905,7 @@ mod tests {
|
|||||||
store,
|
store,
|
||||||
AttachedPeerConfig {
|
AttachedPeerConfig {
|
||||||
name: "credential".to_owned(),
|
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()],
|
groups: vec!["ops".to_owned()],
|
||||||
identity_private_key,
|
identity_private_key,
|
||||||
},
|
},
|
||||||
@@ -944,7 +953,7 @@ mod tests {
|
|||||||
store.clone(),
|
store.clone(),
|
||||||
AttachedPeerConfig {
|
AttachedPeerConfig {
|
||||||
name: "first".to_owned(),
|
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()],
|
groups: vec!["ops".to_owned()],
|
||||||
identity_private_key: [1; 32],
|
identity_private_key: [1; 32],
|
||||||
},
|
},
|
||||||
@@ -956,7 +965,7 @@ mod tests {
|
|||||||
store.clone(),
|
store.clone(),
|
||||||
AttachedPeerConfig {
|
AttachedPeerConfig {
|
||||||
name: "second".to_owned(),
|
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(),
|
groups: Vec::new(),
|
||||||
identity_private_key: [2; 32],
|
identity_private_key: [2; 32],
|
||||||
},
|
},
|
||||||
@@ -1030,7 +1039,7 @@ mod tests {
|
|||||||
store,
|
store,
|
||||||
AttachedPeerConfig {
|
AttachedPeerConfig {
|
||||||
name: "reconnected".to_owned(),
|
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()],
|
groups: vec!["ops".to_owned()],
|
||||||
identity_private_key: [3; 32],
|
identity_private_key: [3; 32],
|
||||||
},
|
},
|
||||||
@@ -1059,7 +1068,7 @@ mod tests {
|
|||||||
store,
|
store,
|
||||||
AttachedPeerConfig {
|
AttachedPeerConfig {
|
||||||
name: "cancelled-close".to_owned(),
|
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(),
|
groups: Vec::new(),
|
||||||
identity_private_key,
|
identity_private_key,
|
||||||
},
|
},
|
||||||
@@ -1126,7 +1135,7 @@ mod tests {
|
|||||||
store,
|
store,
|
||||||
AttachedPeerConfig {
|
AttachedPeerConfig {
|
||||||
name: "closed-receiver".to_owned(),
|
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(),
|
groups: Vec::new(),
|
||||||
identity_private_key: [3; 32],
|
identity_private_key: [3; 32],
|
||||||
},
|
},
|
||||||
@@ -1158,7 +1167,7 @@ mod tests {
|
|||||||
store,
|
store,
|
||||||
AttachedPeerConfig {
|
AttachedPeerConfig {
|
||||||
name: "dropped".to_owned(),
|
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(),
|
groups: Vec::new(),
|
||||||
identity_private_key: [3; 32],
|
identity_private_key: [3; 32],
|
||||||
},
|
},
|
||||||
@@ -1198,7 +1207,7 @@ mod tests {
|
|||||||
store,
|
store,
|
||||||
AttachedPeerConfig {
|
AttachedPeerConfig {
|
||||||
name: "dropped-secure".to_owned(),
|
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()],
|
groups: vec!["ops".to_owned()],
|
||||||
identity_private_key,
|
identity_private_key,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1519,6 +1519,13 @@ impl PeerManagerCore {
|
|||||||
self.route.clone()
|
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) {
|
pub fn mark_recent_traffic(&self, dst_peer_id: PeerId) {
|
||||||
let flags = self.context.flags();
|
let flags = self.context.flags();
|
||||||
self.recent_traffic
|
self.recent_traffic
|
||||||
|
|||||||
@@ -2369,6 +2369,7 @@ struct PeerRouteServiceImpl {
|
|||||||
interface_peers_generation: AtomicU64,
|
interface_peers_generation: AtomicU64,
|
||||||
applied_interface_peers_generation: AtomicU64,
|
applied_interface_peers_generation: AtomicU64,
|
||||||
applied_interface_peers: std::sync::Mutex<BTreeSet<PeerId>>,
|
applied_interface_peers: std::sync::Mutex<BTreeSet<PeerId>>,
|
||||||
|
self_conn_info_withdrawn: AtomicBool,
|
||||||
|
|
||||||
last_update_my_foreign_network: AtomicCell<Option<Instant>>,
|
last_update_my_foreign_network: AtomicCell<Option<Instant>>,
|
||||||
|
|
||||||
@@ -2442,6 +2443,7 @@ impl PeerRouteServiceImpl {
|
|||||||
interface_peers_generation: AtomicU64::new(1),
|
interface_peers_generation: AtomicU64::new(1),
|
||||||
applied_interface_peers_generation: AtomicU64::new(0),
|
applied_interface_peers_generation: AtomicU64::new(0),
|
||||||
applied_interface_peers: std::sync::Mutex::new(BTreeSet::new()),
|
applied_interface_peers: std::sync::Mutex::new(BTreeSet::new()),
|
||||||
|
self_conn_info_withdrawn: AtomicBool::new(false),
|
||||||
|
|
||||||
last_update_my_foreign_network: AtomicCell::new(None),
|
last_update_my_foreign_network: AtomicCell::new(None),
|
||||||
|
|
||||||
@@ -2606,6 +2608,10 @@ impl PeerRouteServiceImpl {
|
|||||||
&self,
|
&self,
|
||||||
snapshot: &InterfacePeerSnapshot,
|
snapshot: &InterfacePeerSnapshot,
|
||||||
) -> BTreeSet<PeerId> {
|
) -> BTreeSet<PeerId> {
|
||||||
|
if self.self_conn_info_withdrawn.load(Ordering::Acquire) {
|
||||||
|
return BTreeSet::new();
|
||||||
|
}
|
||||||
|
|
||||||
if !self.peer_relay_projection_enabled() {
|
if !self.peer_relay_projection_enabled() {
|
||||||
return snapshot.peers.clone();
|
return snapshot.peers.clone();
|
||||||
}
|
}
|
||||||
@@ -2780,7 +2786,9 @@ impl PeerRouteServiceImpl {
|
|||||||
|
|
||||||
fn local_route_snapshot(&self) -> OspfRouteSnapshot {
|
fn local_route_snapshot(&self) -> OspfRouteSnapshot {
|
||||||
let mut snapshot = self.synced_route_info.route_snapshot();
|
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;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4201,6 +4209,63 @@ impl Debug for PeerRoute {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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(
|
pub fn new(
|
||||||
my_peer_id: PeerId,
|
my_peer_id: PeerId,
|
||||||
context: ArcPeerContext,
|
context: ArcPeerContext,
|
||||||
@@ -5314,6 +5379,127 @@ mod tests {
|
|||||||
assert_eq!(get_peer_identity_type_calls.load(Ordering::Relaxed), 4);
|
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]
|
#[tokio::test]
|
||||||
async fn periodic_requery_without_peer_change_keeps_route_version_stable() {
|
async fn periodic_requery_without_peer_change_keeps_route_version_stable() {
|
||||||
let service_impl = test_peer_relay_service_impl(1);
|
let service_impl = test_peer_relay_service_impl(1);
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ vpn_portal_add_client: 添加客户端
|
|||||||
vpn_portal_no_clients: 尚未配置客户端
|
vpn_portal_no_clients: 尚未配置客户端
|
||||||
vpn_portal_client_name: 客户端名称
|
vpn_portal_client_name: 客户端名称
|
||||||
vpn_portal_client_name_placeholder: 例如:alice-phone
|
vpn_portal_client_name_placeholder: 例如:alice-phone
|
||||||
vpn_portal_client_virtual_ip: 虚拟网地址
|
vpn_portal_client_virtual_ip: 虚拟网 CIDR
|
||||||
vpn_portal_client_virtual_ip_placeholder: 例如:10.126.126.10
|
vpn_portal_client_virtual_ip_placeholder: 例如:10.126.126.10/24
|
||||||
vpn_portal_client_groups: ACL 组
|
vpn_portal_client_groups: ACL 组
|
||||||
vpn_portal_client_groups_placeholder: 选择 ACL 组
|
vpn_portal_client_groups_placeholder: 选择 ACL 组
|
||||||
vpn_portal_remove_client: 删除客户端
|
vpn_portal_remove_client: 删除客户端
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ vpn_portal_add_client: Add Client
|
|||||||
vpn_portal_no_clients: No clients configured
|
vpn_portal_no_clients: No clients configured
|
||||||
vpn_portal_client_name: Client Name
|
vpn_portal_client_name: Client Name
|
||||||
vpn_portal_client_name_placeholder: "Example: alice-phone"
|
vpn_portal_client_name_placeholder: "Example: alice-phone"
|
||||||
vpn_portal_client_virtual_ip: Virtual Network Address
|
vpn_portal_client_virtual_ip: Virtual Network CIDR
|
||||||
vpn_portal_client_virtual_ip_placeholder: "Example: 10.126.126.10"
|
vpn_portal_client_virtual_ip_placeholder: "Example: 10.126.126.10/24"
|
||||||
vpn_portal_client_groups: ACL Groups
|
vpn_portal_client_groups: ACL Groups
|
||||||
vpn_portal_client_groups_placeholder: Select ACL groups
|
vpn_portal_client_groups_placeholder: Select ACL groups
|
||||||
vpn_portal_remove_client: Remove Client
|
vpn_portal_remove_client: Remove Client
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ function makeConfig(): NetworkConfig {
|
|||||||
wireguard_private_key: 'portal-private-key',
|
wireguard_private_key: 'portal-private-key',
|
||||||
clients: [{
|
clients: [{
|
||||||
name: 'phone-a',
|
name: 'phone-a',
|
||||||
virtual_ip: '10.1.2.10',
|
virtual_ip: '10.1.2.10/24',
|
||||||
groups: ['ops'],
|
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_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_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_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, '#vpn_portal_client_groups_0').value).toBe('ops')
|
||||||
expect(input(wrapper, '#dev_name').value).toBe('tun-test')
|
expect(input(wrapper, '#dev_name').value).toBe('tun-test')
|
||||||
expect(input(wrapper, '#mtu').value).toBe('1280')
|
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_listen', '[::]:23000')
|
||||||
await setInput(wrapper, '#vpn_portal_wireguard_private_key', 'edited-private-key')
|
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_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, '#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, 'input[data-add-label="add_listener_url"]', 'tcp://0.0.0.0:13010')
|
||||||
await setInput(wrapper, '#dev_name', 'tun-edited')
|
await setInput(wrapper, '#dev_name', 'tun-edited')
|
||||||
@@ -489,7 +489,7 @@ describe('Config.vue network config projection', () => {
|
|||||||
wireguard_private_key: 'edited-private-key',
|
wireguard_private_key: 'edited-private-key',
|
||||||
clients: [{
|
clients: [{
|
||||||
name: 'laptop-a',
|
name: 'laptop-a',
|
||||||
virtual_ip: '10.1.2.20',
|
virtual_ip: '10.1.2.20/24',
|
||||||
groups: ['ops', 'admin'],
|
groups: ['ops', 'admin'],
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
@@ -525,7 +525,7 @@ describe('Config.vue network config projection', () => {
|
|||||||
wireguard_private_key: 'edited-private-key',
|
wireguard_private_key: 'edited-private-key',
|
||||||
clients: [{
|
clients: [{
|
||||||
name: 'laptop-a',
|
name: 'laptop-a',
|
||||||
virtual_ip: '10.1.2.20',
|
virtual_ip: '10.1.2.20/24',
|
||||||
groups: ['ops', 'admin'],
|
groups: ['ops', 'admin'],
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -568,15 +568,15 @@ mod tests {
|
|||||||
fn vpn_portal_client_changes_produce_hot_patches() {
|
fn vpn_portal_client_changes_produce_hot_patches() {
|
||||||
let current = config_with_vpn_portal(
|
let current = config_with_vpn_portal(
|
||||||
vec![
|
vec![
|
||||||
portal_client("alice", "10.144.144.4"),
|
portal_client("alice", "10.144.144.4/24"),
|
||||||
portal_client("carol", "10.144.144.6"),
|
portal_client("carol", "10.144.144.6/24"),
|
||||||
],
|
],
|
||||||
"0.0.0.0:22121",
|
"0.0.0.0:22121",
|
||||||
);
|
);
|
||||||
let desired = config_with_vpn_portal(
|
let desired = config_with_vpn_portal(
|
||||||
vec![
|
vec![
|
||||||
portal_client("bob", "10.144.144.5"),
|
portal_client("bob", "10.144.144.5/24"),
|
||||||
portal_client("carol", "10.144.144.7"),
|
portal_client("carol", "10.144.144.7/24"),
|
||||||
],
|
],
|
||||||
"0.0.0.0:22121",
|
"0.0.0.0:22121",
|
||||||
);
|
);
|
||||||
@@ -595,16 +595,24 @@ mod tests {
|
|||||||
],
|
],
|
||||||
"removals must precede additions; changed clients are remove+add"
|
"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]
|
#[test]
|
||||||
fn vpn_portal_client_no_op_produces_empty_patch_section() {
|
fn vpn_portal_client_no_op_produces_empty_patch_section() {
|
||||||
let current = config_with_vpn_portal(
|
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",
|
"0.0.0.0:22121",
|
||||||
);
|
);
|
||||||
let desired = config_with_vpn_portal(
|
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",
|
"0.0.0.0:22121",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -617,11 +625,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn vpn_portal_listener_identity_change_requires_recreate() {
|
fn vpn_portal_listener_identity_change_requires_recreate() {
|
||||||
let current = config_with_vpn_portal(
|
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",
|
"0.0.0.0:22121",
|
||||||
);
|
);
|
||||||
let desired = config_with_vpn_portal(
|
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",
|
"0.0.0.0:22122",
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -652,7 +660,7 @@ mod tests {
|
|||||||
fn vpn_portal_enable_or_disable_requires_recreate() {
|
fn vpn_portal_enable_or_disable_requires_recreate() {
|
||||||
let without_portal = config_with_port_forwards(Vec::new());
|
let without_portal = config_with_port_forwards(Vec::new());
|
||||||
let with_portal = config_with_vpn_portal(
|
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",
|
"0.0.0.0:22121",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1087,19 +1087,33 @@ async fn mark_config_revision_applied_if_current(
|
|||||||
let Some(data) = session_data.upgrade() else {
|
let Some(data) = session_data.upgrade() else {
|
||||||
return RoundStatus::Stop;
|
return RoundStatus::Stop;
|
||||||
};
|
};
|
||||||
let mut data = data.write().await;
|
let notify = {
|
||||||
if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req) {
|
let mut data = data.write().await;
|
||||||
return RoundStatus::Ready(());
|
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(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use easytier::proto::api::manage::{NetworkingMethod, PortForwardConfig};
|
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]
|
#[test]
|
||||||
fn patch_delete_requires_runtime_to_remove_every_requested_instance() {
|
fn patch_delete_requires_runtime_to_remove_every_requested_instance() {
|
||||||
let deleted_id = uuid::Uuid::new_v4();
|
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)"
|
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 传入,避免命令行暴露)"
|
zh-CN: "Base64 WireGuard 服务端私钥(建议通过 ET_VPN_PORTAL_PRIVATE_KEY 传入,避免命令行暴露)"
|
||||||
vpn_portal_client:
|
vpn_portal_client:
|
||||||
en: "named VPN portal client in NAME=IP form; may be repeated"
|
en: "named VPN portal client in NAME=CIDR form, for example alice=10.144.0.5/16; may be repeated"
|
||||||
zh-CN: "NAME=IP 格式的具名 VPN 门户客户端;可重复指定"
|
zh-CN: "NAME=CIDR 格式的具名 VPN 门户客户端,例如 alice=10.144.0.5/16;可重复指定"
|
||||||
vpn_portal_client_group:
|
vpn_portal_client_group:
|
||||||
en: "VPN portal client group membership in NAME=GROUP form; may be repeated"
|
en: "VPN portal client group membership in NAME=GROUP form; may be repeated"
|
||||||
zh-CN: "NAME=GROUP 格式的 VPN 门户客户端组成员关系;可重复指定"
|
zh-CN: "NAME=GROUP 格式的 VPN 门户客户端组成员关系;可重复指定"
|
||||||
|
|||||||
+21
-7
@@ -896,7 +896,7 @@ impl NetworkOptions {
|
|||||||
}
|
}
|
||||||
if !url.path().is_empty() {
|
if !url.path().is_empty() {
|
||||||
anyhow::bail!(
|
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()
|
if !url.username().is_empty()
|
||||||
@@ -924,15 +924,20 @@ impl NetworkOptions {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|value| {
|
.map(|value| {
|
||||||
let (name, virtual_ip) = value.split_once('=').ok_or_else(|| {
|
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() {
|
if name.is_empty() {
|
||||||
anyhow::bail!("vpn portal client name cannot be 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 {
|
Ok(VpnPortalClientConfig {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
virtual_ip: virtual_ip.parse().with_context(|| {
|
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(),
|
groups: Vec::new(),
|
||||||
})
|
})
|
||||||
@@ -1949,7 +1954,7 @@ wireguard_private_key = "existing-key"
|
|||||||
|
|
||||||
[[vpn_portal_config.clients]]
|
[[vpn_portal_config.clients]]
|
||||||
name = "existing"
|
name = "existing"
|
||||||
virtual_ip = "10.144.144.9"
|
virtual_ip = "10.144.144.9/24"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1971,8 +1976,8 @@ virtual_ip = "10.144.144.9"
|
|||||||
NetworkOptions {
|
NetworkOptions {
|
||||||
vpn_portal_private_key: Some("replacement-key".to_owned()),
|
vpn_portal_private_key: Some("replacement-key".to_owned()),
|
||||||
vpn_portal_clients: vec![
|
vpn_portal_clients: vec![
|
||||||
"alice=10.144.144.10".to_owned(),
|
"alice=10.144.144.10/24".to_owned(),
|
||||||
"bob=10.144.144.11".to_owned(),
|
"bob=10.144.144.11/24".to_owned(),
|
||||||
],
|
],
|
||||||
vpn_portal_client_groups: vec!["alice=staff".to_owned(), "alice=dev".to_owned()],
|
vpn_portal_client_groups: vec!["alice=staff".to_owned(), "alice=dev".to_owned()],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -2024,7 +2029,7 @@ virtual_ip = "10.144.144.9"
|
|||||||
|
|
||||||
let unknown_client = NetworkOptions {
|
let unknown_client = NetworkOptions {
|
||||||
vpn_portal: Some("wg://0.0.0.0:51820".to_owned()),
|
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()],
|
vpn_portal_client_groups: vec!["bob=staff".to_owned()],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
@@ -2035,6 +2040,15 @@ virtual_ip = "10.144.144.9"
|
|||||||
unknown_client.contains("unknown CLI client: bob"),
|
unknown_client.contains("unknown CLI client: bob"),
|
||||||
"{unknown_client}"
|
"{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]
|
#[test]
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ enum VpnPortalSubCommand {
|
|||||||
AddClient {
|
AddClient {
|
||||||
#[arg(help = "client name")]
|
#[arg(help = "client name")]
|
||||||
name: String,
|
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,
|
virtual_ip: String,
|
||||||
#[arg(long, help = "ACL groups assigned to the client")]
|
#[arg(long, help = "ACL groups assigned to the client")]
|
||||||
groups: Vec<String>,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -637,6 +647,13 @@ mod tests {
|
|||||||
assert!(!is_missing_web_client_service(&error));
|
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]
|
#[test]
|
||||||
fn proxy_cidrs_are_displayed_one_per_line() {
|
fn proxy_cidrs_are_displayed_one_per_line() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2725,9 +2742,7 @@ impl<'a> CommandHandler<'a> {
|
|||||||
virtual_ip: String,
|
virtual_ip: String,
|
||||||
groups: Vec<String>,
|
groups: Vec<String>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
virtual_ip
|
let virtual_ip = parse_vpn_portal_client_cidr(&virtual_ip)?.to_string();
|
||||||
.parse::<std::net::Ipv4Addr>()
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid virtual ip ({virtual_ip}): {e}"))?;
|
|
||||||
self.apply_to_instances(|handler| {
|
self.apply_to_instances(|handler| {
|
||||||
let name = name.clone();
|
let name = name.clone();
|
||||||
let virtual_ip = virtual_ip.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])),
|
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
|
||||||
clients: vec![VpnPortalClientConfig {
|
clients: vec![VpnPortalClientConfig {
|
||||||
name: "test-client".to_owned(),
|
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(),
|
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_info.client_config.contains("198.51.100.0/24"),
|
||||||
"client config must include remote proxy CIDRs"
|
"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) =
|
let (server_public, client_private) =
|
||||||
test_wireguard_keys(&portal_config, "test-client").unwrap();
|
test_wireguard_keys(&portal_config, "test-client").unwrap();
|
||||||
run_wireguard_client(
|
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(server_public.as_slice()).unwrap(),
|
||||||
Key::try_from(client_private.as_slice()).unwrap(),
|
Key::try_from(client_private.as_slice()).unwrap(),
|
||||||
vec!["10.144.144.0/24".to_string()],
|
vec!["10.144.144.0/24".to_string()],
|
||||||
"192.0.2.42".to_string(),
|
"10.144.144.4".to_owned(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1867,12 +1873,12 @@ pub async fn wireguard_vpn_portal_multi_client() {
|
|||||||
clients: vec![
|
clients: vec![
|
||||||
VpnPortalClientConfig {
|
VpnPortalClientConfig {
|
||||||
name: "client-a".to_owned(),
|
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(),
|
groups: Vec::new(),
|
||||||
},
|
},
|
||||||
VpnPortalClientConfig {
|
VpnPortalClientConfig {
|
||||||
name: "client-b".to_owned(),
|
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(),
|
groups: Vec::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1890,9 +1896,9 @@ pub async fn wireguard_vpn_portal_multi_client() {
|
|||||||
.get_vpn_portal_config()
|
.get_vpn_portal_config()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
for (ns, client_name, tunnel_ip) in [
|
for (ns, client_name, virtual_ip) in [
|
||||||
("net_d", "client-a", "192.0.2.42"),
|
("net_d", "client-a", "10.144.144.4"),
|
||||||
("net_f", "client-b", "192.0.2.43"),
|
("net_f", "client-b", "10.144.144.5"),
|
||||||
] {
|
] {
|
||||||
let net_ns = NetNS::new(Some(ns.into()));
|
let net_ns = NetNS::new(Some(ns.into()));
|
||||||
let _g = net_ns.guard();
|
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(server_public.as_slice()).unwrap(),
|
||||||
Key::try_from(client_private.as_slice()).unwrap(),
|
Key::try_from(client_private.as_slice()).unwrap(),
|
||||||
vec!["10.144.144.0/24".to_string()],
|
vec!["10.144.144.0/24".to_string()],
|
||||||
tunnel_ip.to_string(),
|
virtual_ip.to_owned(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -1923,9 +1929,8 @@ pub async fn wireguard_vpn_portal_multi_client() {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跨客户端互 ping 对方的虚拟 IP:一次流量同时覆盖源地址改写
|
// 跨客户端互 ping 对方的虚拟 IP,验证 WireGuard 地址与 attached
|
||||||
// (tunnel_ip -> virtual_ip)与目的地址改写(virtual_ip -> tunnel_ip),
|
// peer 地址相同且双向数据包无需地址改写。
|
||||||
// 回程再反向各执行一遍
|
|
||||||
wait_for_condition(
|
wait_for_condition(
|
||||||
|| async { ping_test("net_d", "10.144.144.5", None).await },
|
|| async { ping_test("net_d", "10.144.144.5", None).await },
|
||||||
Duration::from_secs(10),
|
Duration::from_secs(10),
|
||||||
@@ -1938,7 +1943,7 @@ pub async fn wireguard_vpn_portal_multi_client() {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// TCP 数据面:node1 侧看到的连接源地址必须是 client-a 的虚拟 IP,
|
// TCP 数据面:node1 侧看到的连接源地址必须是 client-a 的虚拟 IP,
|
||||||
// 并做一段随机数据回环,覆盖 TCP 增量校验和改写路径
|
// 并做一段随机数据回环,验证传输层校验和保持不变。
|
||||||
let mut buf = vec![0u8; 1024];
|
let mut buf = vec![0u8; 1024];
|
||||||
rand::thread_rng().fill(&mut buf[..]);
|
rand::thread_rng().fill(&mut buf[..]);
|
||||||
let expected = buf.clone();
|
let expected = buf.clone();
|
||||||
@@ -1964,7 +1969,8 @@ pub async fn wireguard_vpn_portal_multi_client() {
|
|||||||
}
|
}
|
||||||
echo_task.await.unwrap();
|
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;
|
let portal_info = insts[2].get_core_instance().vpn_portal_info().await;
|
||||||
assert_eq!(portal_info.clients.len(), 2);
|
assert_eq!(portal_info.clients.len(), 2);
|
||||||
let client_a = portal_info
|
let client_a = portal_info
|
||||||
@@ -1982,8 +1988,8 @@ pub async fn wireguard_vpn_portal_multi_client() {
|
|||||||
assert!(client.peer_id.is_some());
|
assert!(client.peer_id.is_some());
|
||||||
}
|
}
|
||||||
assert_ne!(client_a.peer_id, client_b.peer_id);
|
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_a.tunnel_ip, Some("10.144.144.4".parse().unwrap()));
|
||||||
assert_eq!(client_b.tunnel_ip, Some("192.0.2.43".parse().unwrap()));
|
assert_eq!(client_b.tunnel_ip, Some("10.144.144.5".parse().unwrap()));
|
||||||
|
|
||||||
drop_insts(insts).await;
|
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])),
|
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
|
||||||
clients: vec![VpnPortalClientConfig {
|
clients: vec![VpnPortalClientConfig {
|
||||||
name: "roaming-client".to_owned(),
|
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(),
|
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(server_public.as_slice()).unwrap(),
|
||||||
Key::try_from(client_private.as_slice()).unwrap(),
|
Key::try_from(client_private.as_slice()).unwrap(),
|
||||||
vec!["10.144.144.0/24".to_string()],
|
vec!["10.144.144.0/24".to_string()],
|
||||||
"192.0.2.42".to_string(),
|
"10.144.144.4".to_owned(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -2156,7 +2162,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() {
|
|||||||
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
|
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
|
||||||
clients: vec![VpnPortalClientConfig {
|
clients: vec![VpnPortalClientConfig {
|
||||||
name: "client-a".to_owned(),
|
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(),
|
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(server_public.as_slice()).unwrap(),
|
||||||
Key::try_from(client_private.as_slice()).unwrap(),
|
Key::try_from(client_private.as_slice()).unwrap(),
|
||||||
vec!["10.144.144.0/24".to_string()],
|
vec!["10.144.144.0/24".to_string()],
|
||||||
"192.0.2.42".to_string(),
|
"10.144.144.4".to_owned(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -2204,7 +2210,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() {
|
|||||||
action: ConfigPatchAction::Add as i32,
|
action: ConfigPatchAction::Add as i32,
|
||||||
client: Some(VpnPortalClientConfigPb {
|
client: Some(VpnPortalClientConfigPb {
|
||||||
name: "client-b".to_owned(),
|
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(),
|
groups: Vec::new(),
|
||||||
}),
|
}),
|
||||||
}],
|
}],
|
||||||
@@ -2234,7 +2240,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() {
|
|||||||
action: ConfigPatchAction::Add as i32,
|
action: ConfigPatchAction::Add as i32,
|
||||||
client: Some(VpnPortalClientConfigPb {
|
client: Some(VpnPortalClientConfigPb {
|
||||||
name: "client-b".to_owned(),
|
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(),
|
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(server_public.as_slice()).unwrap(),
|
||||||
Key::try_from(client_private.as_slice()).unwrap(),
|
Key::try_from(client_private.as_slice()).unwrap(),
|
||||||
vec!["10.144.144.0/24".to_string()],
|
vec!["10.144.144.0/24".to_string()],
|
||||||
"192.0.2.43".to_string(),
|
"10.144.144.5".to_owned(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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].state, PortalClientState::Online);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
info.clients[0].tunnel_ip,
|
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
|
// 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> {
|
fn derive_client(master: [u8; 32], client: &PortalClientConfig) -> anyhow::Result<DerivedClient> {
|
||||||
let wireguard_private = derive_named_key(&master, b"wireguard-client", &client.name)?;
|
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 {
|
Ok(DerivedClient {
|
||||||
config: client.clone(),
|
config: client.clone(),
|
||||||
wireguard_private,
|
wireguard_private,
|
||||||
wireguard_public: PublicKey::from(&StaticSecret::from(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> {
|
fn secondary_ipv6_bind_address(address: SocketAddr, primary_port: u16) -> Option<SocketAddr> {
|
||||||
@@ -394,17 +392,13 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn named_keys_are_domain_separated_and_stable() {
|
fn named_wireguard_keys_are_stable_and_client_scoped() {
|
||||||
let master = [7; 32];
|
let master = [7; 32];
|
||||||
let client = derive_named_key(&master, b"wireguard-client", "laptop").unwrap();
|
let client = derive_named_key(&master, b"wireguard-client", "laptop").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client,
|
client,
|
||||||
derive_named_key(&master, b"wireguard-client", "laptop").unwrap()
|
derive_named_key(&master, b"wireguard-client", "laptop").unwrap()
|
||||||
);
|
);
|
||||||
assert_ne!(
|
|
||||||
client,
|
|
||||||
derive_named_key(&master, b"attached-noise", "laptop").unwrap()
|
|
||||||
);
|
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
client,
|
client,
|
||||||
derive_named_key(&master, b"wireguard-client", "phone").unwrap()
|
derive_named_key(&master, b"wireguard-client", "phone").unwrap()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use easytier_core::{
|
|||||||
gateway::vpn_portal::{PortalClientConfig, PortalSession},
|
gateway::vpn_portal::{PortalClientConfig, PortalSession},
|
||||||
socket::udp::VirtualUdpSocket,
|
socket::udp::VirtualUdpSocket,
|
||||||
};
|
};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::{Mutex, mpsc, watch},
|
sync::{Mutex, mpsc, watch},
|
||||||
task::JoinSet,
|
task::JoinSet,
|
||||||
@@ -41,7 +42,6 @@ pub(super) struct DerivedClient {
|
|||||||
pub(super) config: PortalClientConfig,
|
pub(super) config: PortalClientConfig,
|
||||||
pub(super) wireguard_private: [u8; 32],
|
pub(super) wireguard_private: [u8; 32],
|
||||||
pub(super) wireguard_public: PublicKey,
|
pub(super) wireguard_public: PublicKey,
|
||||||
pub(super) identity_private_key: [u8; 32],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PortalChannels {
|
struct PortalChannels {
|
||||||
@@ -52,6 +52,7 @@ struct PortalChannels {
|
|||||||
|
|
||||||
struct ClientSession {
|
struct ClientSession {
|
||||||
generation: u64,
|
generation: u64,
|
||||||
|
identity_private_key: [u8; 32],
|
||||||
endpoint: Option<Endpoint>,
|
endpoint: Option<Endpoint>,
|
||||||
endpoint_updates: watch::Sender<String>,
|
endpoint_updates: watch::Sender<String>,
|
||||||
tunnel: Tunn,
|
tunnel: Tunn,
|
||||||
@@ -356,7 +357,7 @@ impl PortalEngine {
|
|||||||
let _ = self.accepted.send(PortalSession {
|
let _ = self.accepted.send(PortalSession {
|
||||||
client_name: slot.client.config.name.clone(),
|
client_name: slot.client.config.name.clone(),
|
||||||
endpoint: channels.endpoint,
|
endpoint: channels.endpoint,
|
||||||
identity_private_key: slot.client.identity_private_key,
|
identity_private_key: session.identity_private_key,
|
||||||
from_client: channels.from_client,
|
from_client: channels.from_client,
|
||||||
to_client: channels.to_client,
|
to_client: channels.to_client,
|
||||||
});
|
});
|
||||||
@@ -402,6 +403,7 @@ impl PortalEngine {
|
|||||||
});
|
});
|
||||||
ClientSession {
|
ClientSession {
|
||||||
generation,
|
generation,
|
||||||
|
identity_private_key: new_attached_identity_private_key(),
|
||||||
endpoint: Some(Endpoint { socket, remote }),
|
endpoint: Some(Endpoint { socket, remote }),
|
||||||
endpoint_updates,
|
endpoint_updates,
|
||||||
tunnel: Tunn::new(
|
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 {
|
fn is_handshake_initiation(packet: &[u8]) -> bool {
|
||||||
packet.len() == 148 && packet.get(..4) == Some(&1u32.to_le_bytes())
|
packet.len() == 148 && packet.get(..4) == Some(&1u32.to_le_bytes())
|
||||||
}
|
}
|
||||||
@@ -542,15 +549,22 @@ mod tests {
|
|||||||
DerivedClient {
|
DerivedClient {
|
||||||
config: PortalClientConfig {
|
config: PortalClientConfig {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
virtual_ip: "192.0.2.1".parse().unwrap(),
|
virtual_ip: "10.82.0.2/24".parse().unwrap(),
|
||||||
groups: Vec::new(),
|
groups: Vec::new(),
|
||||||
},
|
},
|
||||||
wireguard_private: secret.to_bytes(),
|
wireguard_private: secret.to_bytes(),
|
||||||
wireguard_public: PublicKey::from(&secret),
|
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> {
|
fn slot_index(engine: &PortalEngine, name: &str) -> Option<u32> {
|
||||||
engine
|
engine
|
||||||
.slots
|
.slots
|
||||||
|
|||||||
Reference in New Issue
Block a user