use std::collections::HashMap;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use crate::vortix_core::cidr::{claims_default_route_v4, claims_default_route_v6, Cidr};
use crate::vortix_core::engine::event::EngineEvent;
use crate::vortix_core::engine::fsm::Engine;
use crate::vortix_core::engine::input::{Input, UserCommand};
use crate::vortix_core::engine::state::{Connection, ConnectionHealth};
use crate::vortix_core::ports::tunnel::Tunnel;
use crate::vortix_core::profile::ProfileId;
use crate::vortix_core::state::{KillSwitchMode, KillSwitchState};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum PrimaryTunnelChangeReason {
NewTunnelTookDefaultRoute,
PriorPrimaryDisconnected,
ExternalRouteChange,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Role {
Primary { allowed_ips: Vec<Cidr> },
Addressable { allowed_ips: Vec<Cidr> },
AddressableSuppressed { allowed_ips: Vec<Cidr> },
Reconnecting { prior_role: Box<Role> },
AwaitingInput,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TunnelSnapshot {
pub profile_id: ProfileId,
pub state: Connection,
pub role: Role,
pub health: ConnectionHealth,
pub interface_name: Option<String>,
pub started_at: Option<SystemTime>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Conflict {
DefaultRouteTakeover { current: ProfileId, new: ProfileId },
RouteOverlap {
with: ProfileId,
overlapping_cidrs: Vec<Cidr>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RegistryError {
Conflict(Conflict),
ProfileNotFound(ProfileId),
TunnelFailure(String),
}
impl std::fmt::Display for RegistryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Conflict(Conflict::DefaultRouteTakeover { current, new }) => write!(
f,
"default-route takeover: profile `{new}` cannot claim 0/0 — `{current}` holds it"
),
Self::Conflict(Conflict::RouteOverlap {
with,
overlapping_cidrs,
}) => write!(
f,
"route overlap with `{with}` on {} CIDR(s)",
overlapping_cidrs.len()
),
Self::ProfileNotFound(id) => write!(f, "profile not found: `{id}`"),
Self::TunnelFailure(msg) => write!(f, "tunnel failure: {msg}"),
}
}
}
impl std::error::Error for RegistryError {}
struct RegistryEntry<T: Tunnel> {
engine: Engine<T>,
allowed_ips: Vec<Cidr>,
}
impl<T: Tunnel> RegistryEntry<T> {
fn claims_default_route(&self) -> bool {
claims_default_route_v4(&self.allowed_ips) || claims_default_route_v6(&self.allowed_ips)
}
}
const DEFAULT_ROUTE_CACHE_MAX_AGE: std::time::Duration = std::time::Duration::from_millis(500);
#[derive(Clone, Debug)]
struct CachedRouteInterface {
iface: Option<String>,
at: std::time::Instant,
}
pub struct TunnelRegistry<T: Tunnel> {
fsms: HashMap<ProfileId, RegistryEntry<T>>,
primary: Option<ProfileId>,
killswitch_mode: KillSwitchMode,
killswitch_state: KillSwitchState,
#[allow(clippy::type_complexity)]
default_route_interface_probe: Option<Box<dyn Fn() -> Option<String> + Send>>,
cached_route: Option<CachedRouteInterface>,
}
impl<T: Tunnel> Default for TunnelRegistry<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: Tunnel> TunnelRegistry<T> {
#[must_use]
pub fn new() -> Self {
Self {
fsms: HashMap::new(),
primary: None,
killswitch_mode: KillSwitchMode::default(),
killswitch_state: KillSwitchState::default(),
default_route_interface_probe: None,
cached_route: None,
}
}
#[cfg(test)]
fn with_route_probe<F>(probe: F) -> Self
where
F: Fn() -> Option<String> + Send + 'static,
{
Self {
fsms: HashMap::new(),
primary: None,
killswitch_mode: KillSwitchMode::default(),
killswitch_state: KillSwitchState::default(),
default_route_interface_probe: Some(Box::new(probe)),
cached_route: None,
}
}
#[must_use]
pub fn tunnel_count(&self) -> usize {
self.fsms.len()
}
#[must_use]
pub fn primary(&self) -> Option<&ProfileId> {
self.primary.as_ref()
}
#[must_use]
pub fn killswitch_mode(&self) -> KillSwitchMode {
self.killswitch_mode
}
#[must_use]
pub fn killswitch_state(&self) -> KillSwitchState {
self.killswitch_state
}
pub fn set_killswitch_mode(&mut self, mode: KillSwitchMode) {
self.killswitch_mode = mode;
}
pub fn set_killswitch_state(&mut self, state: KillSwitchState) {
self.killswitch_state = state;
}
pub fn insert(&mut self, profile_id: ProfileId, engine: Engine<T>, allowed_ips: Vec<Cidr>) {
self.fsms.insert(
profile_id,
RegistryEntry {
engine,
allowed_ips,
},
);
}
pub fn remove(&mut self, profile_id: &ProfileId) -> Option<Engine<T>> {
self.fsms.remove(profile_id).map(|e| e.engine)
}
#[allow(clippy::needless_pass_by_value)] pub fn set_connected(
&mut self,
profile_id: ProfileId,
allowed_ips: Vec<Cidr>,
details: crate::vortix_core::engine::state::DetailedConnectionInfo,
since: std::time::SystemTime,
engine_factory: impl FnOnce() -> Engine<T>,
) {
if let Some(entry) = self.fsms.get_mut(&profile_id) {
entry
.engine
.seed_connected_state(profile_id.clone(), details, since);
entry.allowed_ips = allowed_ips;
} else {
let mut engine = engine_factory();
engine.seed_connected_state(profile_id.clone(), details, since);
self.fsms.insert(
profile_id,
RegistryEntry {
engine,
allowed_ips,
},
);
}
let from = self.primary.clone();
self.recompute_primary();
if self.primary != from {
log_primary_change(
from.as_ref(),
self.primary.as_ref(),
PrimaryTunnelChangeReason::ExternalRouteChange,
);
}
}
pub fn set_disconnected(&mut self, profile_id: &ProfileId) {
if let Some(entry) = self.fsms.get_mut(profile_id) {
entry.engine.seed_disconnected_state();
}
self.fsms.remove(profile_id);
let from = self.primary.clone();
self.recompute_primary();
if self.primary != from {
log_primary_change(
from.as_ref(),
self.primary.as_ref(),
PrimaryTunnelChangeReason::ExternalRouteChange,
);
}
}
#[allow(clippy::needless_pass_by_value)] pub fn set_connecting(
&mut self,
profile_id: ProfileId,
allowed_ips: Vec<Cidr>,
started_at: std::time::SystemTime,
attempt: u32,
retry_budget_remaining: std::time::Duration,
engine_factory: impl FnOnce() -> Engine<T>,
) {
if let Some(entry) = self.fsms.get_mut(&profile_id) {
entry.engine.seed_connecting_state(
profile_id.clone(),
started_at,
attempt,
retry_budget_remaining,
);
entry.allowed_ips = allowed_ips;
} else {
let mut engine = engine_factory();
engine.seed_connecting_state(
profile_id.clone(),
started_at,
attempt,
retry_budget_remaining,
);
self.fsms.insert(
profile_id,
RegistryEntry {
engine,
allowed_ips,
},
);
}
}
pub fn set_disconnecting(&mut self, profile_id: &ProfileId, started_at: std::time::SystemTime) {
if let Some(entry) = self.fsms.get_mut(profile_id) {
entry
.engine
.seed_disconnecting_state(profile_id.clone(), started_at);
}
let from = self.primary.clone();
self.recompute_primary();
if self.primary != from {
log_primary_change(
from.as_ref(),
self.primary.as_ref(),
PrimaryTunnelChangeReason::ExternalRouteChange,
);
}
}
#[allow(clippy::needless_pass_by_value)] pub fn set_failed(
&mut self,
profile_id: ProfileId,
allowed_ips: Vec<Cidr>,
failure: crate::vortix_core::engine::state::FailureReason,
engine_factory: impl FnOnce() -> Engine<T>,
) {
if let Some(entry) = self.fsms.get_mut(&profile_id) {
entry.engine.seed_failed_state(failure);
entry.allowed_ips = allowed_ips;
} else {
let mut engine = engine_factory();
engine.seed_failed_state(failure);
self.fsms.insert(
profile_id,
RegistryEntry {
engine,
allowed_ips,
},
);
}
let from = self.primary.clone();
self.recompute_primary();
if self.primary != from {
log_primary_change(
from.as_ref(),
self.primary.as_ref(),
PrimaryTunnelChangeReason::ExternalRouteChange,
);
}
}
#[must_use]
pub fn snapshot(&self, profile_id: &ProfileId) -> Option<TunnelSnapshot> {
let entry = self.fsms.get(profile_id)?;
Some(self.build_snapshot(profile_id, entry))
}
#[must_use]
pub fn snapshot_all(&self) -> Vec<TunnelSnapshot> {
let mut out: Vec<TunnelSnapshot> = self
.fsms
.iter()
.map(|(id, entry)| self.build_snapshot(id, entry))
.collect();
out.sort_by(|a, b| a.profile_id.as_str().cmp(b.profile_id.as_str()));
out
}
fn build_snapshot(&self, profile_id: &ProfileId, entry: &RegistryEntry<T>) -> TunnelSnapshot {
let state = entry.engine.state().clone();
let (interface_name, started_at, health) = match &state {
Connection::Connected {
details,
since,
health,
..
} => (
Some(details.interface.clone()),
Some(*since),
health.clone(),
),
Connection::Connecting { started_at, .. }
| Connection::Reconnecting { started_at, .. }
| Connection::Disconnecting { started_at, .. } => {
(None, Some(*started_at), ConnectionHealth::default())
}
Connection::AwaitingUserInput { since, .. } => {
(None, Some(*since), ConnectionHealth::default())
}
Connection::Disconnected { .. } => (None, None, ConnectionHealth::default()),
};
let role = self.derive_role(profile_id, entry, &state);
TunnelSnapshot {
profile_id: profile_id.clone(),
state,
role,
health,
interface_name,
started_at,
}
}
fn derive_role(
&self,
profile_id: &ProfileId,
entry: &RegistryEntry<T>,
state: &Connection,
) -> Role {
match state {
Connection::AwaitingUserInput { .. } => Role::AwaitingInput,
Connection::Reconnecting { .. } => {
let prior = self.derive_role_from_allowed_ips(profile_id, entry);
Role::Reconnecting {
prior_role: Box::new(prior),
}
}
Connection::Disconnected { .. }
| Connection::Disconnecting { .. }
| Connection::Connecting { .. }
| Connection::Connected { .. } => self.derive_role_from_allowed_ips(profile_id, entry),
}
}
fn derive_role_from_allowed_ips(
&self,
profile_id: &ProfileId,
entry: &RegistryEntry<T>,
) -> Role {
if self.primary.as_ref() == Some(profile_id) {
return Role::Primary {
allowed_ips: entry.allowed_ips.clone(),
};
}
if let Connection::Connected { details, .. } = entry.engine.state() {
if !details.interface_authoritative {
return Role::Addressable {
allowed_ips: entry.allowed_ips.clone(),
};
}
}
let claims_default = entry.claims_default_route();
if !claims_default {
return Role::Addressable {
allowed_ips: entry.allowed_ips.clone(),
};
}
if self.primary.is_some() {
Role::AddressableSuppressed {
allowed_ips: entry.allowed_ips.clone(),
}
} else {
Role::Addressable {
allowed_ips: entry.allowed_ips.clone(),
}
}
}
#[allow(clippy::needless_pass_by_value)] pub fn connect(
&mut self,
profile_id: ProfileId,
allowed_ips: Vec<Cidr>,
engine_factory: impl FnOnce() -> Engine<T>,
force: bool,
) -> Result<(), RegistryError> {
if !force {
if let Some(conflict) = self.detect_conflict(&profile_id, &allowed_ips) {
tracing::info!(
target: "vortix::registry",
profile_id = %profile_id,
?conflict,
"connect blocked by conflict",
);
return Err(RegistryError::Conflict(conflict));
}
}
let entry = self
.fsms
.entry(profile_id.clone())
.or_insert_with(|| RegistryEntry {
engine: engine_factory(),
allowed_ips: allowed_ips.clone(),
});
entry.allowed_ips = allowed_ips;
let events = entry
.engine
.handle(Input::UserCommand(UserCommand::Connect {
profile_id: profile_id.clone(),
}));
if let Connection::Disconnected {
last_failure: Some(reason),
} = entry.engine.state()
{
use crate::vortix_core::engine::state::FailureReason;
return match reason {
FailureReason::ProfileGone(id) => Err(RegistryError::ProfileNotFound(id.clone())),
other => Err(RegistryError::TunnelFailure(format!("{other:?}"))),
};
}
self.refresh_primary_internal(events.iter());
Ok(())
}
pub fn connect_with_tunnel(
&mut self,
profile_id: ProfileId,
allowed_ips: Vec<Cidr>,
tunnel: T,
profile_resolver: impl Fn(&ProfileId) -> Option<crate::vortix_core::profile::Profile>
+ Send
+ 'static,
force: bool,
) -> Result<(), RegistryError> {
self.connect(
profile_id,
allowed_ips,
|| Engine::new(tunnel, profile_resolver),
force,
)
}
pub fn disconnect(&mut self, profile_id: &ProfileId) -> Result<(), RegistryError> {
let was_primary = self.primary.as_ref() == Some(profile_id);
let entry = self
.fsms
.get_mut(profile_id)
.ok_or_else(|| RegistryError::ProfileNotFound(profile_id.clone()))?;
let events = entry
.engine
.handle(Input::UserCommand(UserCommand::Disconnect {
profile_id: Some(profile_id.clone()),
}));
let from = self.primary.clone();
self.refresh_primary_internal(events.iter());
if was_primary && self.primary.as_ref() != from.as_ref() {
log_primary_change(
from.as_ref(),
self.primary.as_ref(),
PrimaryTunnelChangeReason::PriorPrimaryDisconnected,
);
}
Ok(())
}
pub fn disconnect_all(&mut self) {
let primary = self.primary.clone();
let mut order: Vec<ProfileId> = self
.fsms
.keys()
.filter(|id| Some(*id) != primary.as_ref())
.cloned()
.collect();
order.sort_by(|a, b| a.as_str().cmp(b.as_str()));
if let Some(p) = primary {
order.push(p);
}
for id in order {
if let Err(err) = self.disconnect(&id) {
tracing::warn!(
target: "vortix::registry",
profile_id = %id,
%err,
"disconnect_all: individual disconnect failed; continuing",
);
}
}
}
pub fn reconnect(&mut self, profile_id: &ProfileId) -> Result<(), RegistryError> {
let entry = self
.fsms
.get_mut(profile_id)
.ok_or_else(|| RegistryError::ProfileNotFound(profile_id.clone()))?;
let events = entry
.engine
.handle(Input::UserCommand(UserCommand::Reconnect {
profile_id: Some(profile_id.clone()),
}));
self.refresh_primary_internal(events.iter());
Ok(())
}
pub fn reconnect_all(&mut self) {
let ids: Vec<ProfileId> = {
let mut v: Vec<ProfileId> = self.fsms.keys().cloned().collect();
v.sort_by(|a, b| a.as_str().cmp(b.as_str()));
v
};
for id in ids {
let _ = self.reconnect(&id);
}
}
pub fn refresh_primary(&mut self) {
let from = self.primary.clone();
self.recompute_primary();
if self.primary != from {
log_primary_change(
from.as_ref(),
self.primary.as_ref(),
PrimaryTunnelChangeReason::ExternalRouteChange,
);
}
self.drain_pending_after_disconnect();
}
fn refresh_primary_internal<'a, I>(&mut self, events: I)
where
I: IntoIterator<Item = &'a EngineEvent>,
{
let from = self.primary.clone();
let reason = guess_reason_from_events(events);
self.recompute_primary();
if self.primary != from {
log_primary_change(from.as_ref(), self.primary.as_ref(), reason);
}
self.drain_pending_after_disconnect();
}
fn recompute_primary(&mut self) {
let probed = self.default_route_interface_cached();
let Some(iface) = probed else {
self.primary = None;
return;
};
let mut found: Option<ProfileId> = None;
for (pid, entry) in &self.fsms {
if let Connection::Connected { details, .. } = entry.engine.state() {
if details.interface_authoritative && details.interface == iface {
found = Some(pid.clone());
break;
}
}
}
self.primary = found;
}
pub fn feed_default_route_interface(&mut self, iface: Option<String>) {
self.cached_route = Some(CachedRouteInterface {
iface,
at: std::time::Instant::now(),
});
}
fn default_route_interface_cached(&self) -> Option<String> {
if let Some(probe) = &self.default_route_interface_probe {
return probe();
}
let cached = self.cached_route.as_ref()?;
let age = cached.at.elapsed();
if age > DEFAULT_ROUTE_CACHE_MAX_AGE {
tracing::warn!(
target: "vortix::vortix_core::engine::registry",
age_ms = u64::try_from(age.as_millis()).unwrap_or(u64::MAX),
"default-route cache is stale; scanner thread may be falling behind"
);
}
cached.iface.clone()
}
pub fn queue_after_disconnect(
&mut self,
during: &ProfileId,
queued: ProfileId,
) -> Result<(), RegistryError> {
let entry = self
.fsms
.get_mut(during)
.ok_or_else(|| RegistryError::ProfileNotFound(during.clone()))?;
entry.engine.set_pending_after_disconnect(Some(queued));
Ok(())
}
fn drain_pending_after_disconnect(&mut self) {
let mut to_fire: Vec<(ProfileId, ProfileId)> = Vec::new();
for (host_id, entry) in &mut self.fsms {
let is_disconnected = matches!(entry.engine.state(), Connection::Disconnected { .. });
if !is_disconnected {
continue;
}
if let Some(queued) = entry.engine.pending_after_disconnect().cloned() {
to_fire.push((host_id.clone(), queued));
entry.engine.set_pending_after_disconnect(None);
}
}
for (host_id, queued) in to_fire {
tracing::info!(
target: "vortix::registry",
during = %host_id,
queued = %queued,
"firing queued connect after disconnect",
);
if let Some(entry) = self.fsms.get_mut(&queued) {
let events = entry
.engine
.handle(Input::UserCommand(UserCommand::Connect {
profile_id: queued.clone(),
}));
let _ = events; } else {
tracing::warn!(
target: "vortix::registry",
queued = %queued,
"queued profile was not pre-inserted; dropping",
);
}
}
self.recompute_primary();
}
#[must_use]
pub fn detect_conflict(
&self,
new_profile: &ProfileId,
new_allowed_ips: &[Cidr],
) -> Option<Conflict> {
let new_claims_default =
claims_default_route_v4(new_allowed_ips) || claims_default_route_v6(new_allowed_ips);
if !new_claims_default {
return None;
}
for (pid, entry) in &self.fsms {
if pid == new_profile {
continue;
}
if !entry.claims_default_route() {
continue;
}
match entry.engine.state() {
Connection::Connected { .. } | Connection::Connecting { .. } => {
return Some(Conflict::DefaultRouteTakeover {
current: pid.clone(),
new: new_profile.clone(),
});
}
_ => {}
}
}
None
}
#[must_use]
pub fn pending_default_route_claimant(&self) -> Option<&ProfileId> {
for (pid, entry) in &self.fsms {
if entry.claims_default_route()
&& matches!(entry.engine.state(), Connection::Connecting { .. })
{
return Some(pid);
}
}
None
}
pub fn tick(&mut self) {
for entry in self.fsms.values_mut() {
let _ = entry.engine.handle(Input::Tick);
}
self.refresh_primary();
}
}
fn guess_reason_from_events<'a, I>(events: I) -> PrimaryTunnelChangeReason
where
I: IntoIterator<Item = &'a EngineEvent>,
{
let mut saw_up = false;
let mut saw_down = false;
for ev in events {
match ev {
EngineEvent::TunnelUp { .. } => saw_up = true,
EngineEvent::TunnelDown { .. } => saw_down = true,
_ => {}
}
}
if saw_down {
PrimaryTunnelChangeReason::PriorPrimaryDisconnected
} else if saw_up {
PrimaryTunnelChangeReason::NewTunnelTookDefaultRoute
} else {
PrimaryTunnelChangeReason::ExternalRouteChange
}
}
fn log_primary_change(
from: Option<&ProfileId>,
to: Option<&ProfileId>,
reason: PrimaryTunnelChangeReason,
) {
tracing::info!(
target: "vortix::registry",
from = ?from.map(ProfileId::as_str),
to = ?to.map(ProfileId::as_str),
?reason,
"primary tunnel changed",
);
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::*;
use crate::vortix_core::engine::state::DetailedConnectionInfo;
use crate::vortix_core::ports::tunnel::mock::{MockTunnel, ScriptedTunnelOutcome};
use crate::vortix_core::profile::{Profile, ProfileId, ProtocolKind};
fn profile(name: &str) -> Profile {
Profile::new(
ProfileId::new(name),
name,
ProtocolKind::WireGuard,
PathBuf::from(format!("/etc/wireguard/{name}.conf")),
)
}
fn v4(s: &str) -> Cidr {
s.parse().expect("valid cidr")
}
fn default_route_v4() -> Vec<Cidr> {
vec![v4("0.0.0.0/0")]
}
fn split_pair_v4() -> Vec<Cidr> {
vec![v4("0.0.0.0/1"), v4("128.0.0.0/1")]
}
fn quartet_v4() -> Vec<Cidr> {
vec![
v4("0.0.0.0/2"),
v4("64.0.0.0/2"),
v4("128.0.0.0/2"),
v4("192.0.0.0/2"),
]
}
fn resolver_for(name: &'static str) -> impl Fn(&ProfileId) -> Option<Profile> + Send + 'static {
let owned = name.to_string();
move |id| {
if id.as_str() == owned {
Some(profile(&owned))
} else {
None
}
}
}
fn registry_with_iface<F>(probe: F) -> TunnelRegistry<MockTunnel>
where
F: Fn() -> Option<String> + Send + 'static,
{
TunnelRegistry::with_route_probe(probe)
}
fn connect_with_iface(
reg: &mut TunnelRegistry<MockTunnel>,
name: &'static str,
iface: &str,
allowed_ips: Vec<Cidr>,
force: bool,
) -> Result<(), RegistryError> {
let tunnel = MockTunnel::new();
tunnel.script_up(ScriptedTunnelOutcome::UpSuccess {
interface_name: iface.into(),
pid: None,
});
reg.connect_with_tunnel(
ProfileId::new(name),
allowed_ips,
tunnel,
resolver_for(name),
force,
)
}
#[test]
fn empty_registry_has_no_primary_or_tunnels() {
let reg: TunnelRegistry<MockTunnel> = TunnelRegistry::new();
assert_eq!(reg.tunnel_count(), 0);
assert!(reg.primary().is_none());
assert!(reg.snapshot_all().is_empty());
}
#[test]
fn connect_one_profile_becomes_primary_after_refresh() {
let iface = Arc::new(Mutex::new(None::<String>));
let iface_probe = Arc::clone(&iface);
let mut reg = registry_with_iface(move || iface_probe.lock().unwrap().clone());
*iface.lock().unwrap() = Some("utun7".into());
connect_with_iface(&mut reg, "corp", "utun7", default_route_v4(), false).unwrap();
assert_eq!(reg.tunnel_count(), 1);
assert_eq!(reg.primary(), Some(&ProfileId::new("corp")));
let snap = reg.snapshot(&ProfileId::new("corp")).unwrap();
assert!(matches!(snap.role, Role::Primary { .. }));
assert_eq!(snap.interface_name.as_deref(), Some("utun7"));
}
#[test]
fn connect_two_disjoint_allowed_ips_both_connected_primary_owns_zero_slash_zero() {
let mut reg = registry_with_iface(|| Some("utun7".into())); connect_with_iface(&mut reg, "corp", "utun7", default_route_v4(), false).unwrap();
connect_with_iface(&mut reg, "lab", "utun8", vec![v4("10.0.0.0/8")], false).unwrap();
assert_eq!(reg.tunnel_count(), 2);
assert_eq!(reg.primary(), Some(&ProfileId::new("corp")));
let snaps = reg.snapshot_all();
let corp = snaps
.iter()
.find(|s| s.profile_id.as_str() == "corp")
.unwrap();
let lab = snaps
.iter()
.find(|s| s.profile_id.as_str() == "lab")
.unwrap();
assert!(matches!(corp.role, Role::Primary { .. }));
assert!(matches!(lab.role, Role::Addressable { .. }));
}
#[test]
fn disconnect_primary_promotes_secondary_with_zero_slash_zero() {
let iface = Arc::new(Mutex::new(Some("utun7".to_string())));
let iface_probe = Arc::clone(&iface);
let mut reg = registry_with_iface(move || iface_probe.lock().unwrap().clone());
connect_with_iface(&mut reg, "corp", "utun7", default_route_v4(), false).unwrap();
connect_with_iface(&mut reg, "home", "utun8", default_route_v4(), true).unwrap();
assert_eq!(reg.primary(), Some(&ProfileId::new("corp")));
*iface.lock().unwrap() = Some("utun8".into());
reg.disconnect(&ProfileId::new("corp")).unwrap();
assert_eq!(reg.primary(), Some(&ProfileId::new("home")));
let home = reg.snapshot(&ProfileId::new("home")).unwrap();
assert!(matches!(home.role, Role::Primary { .. }));
}
#[test]
fn conflict_when_existing_primary_holds_default_route() {
let mut reg = registry_with_iface(|| Some("utun7".into()));
connect_with_iface(&mut reg, "corp", "utun7", default_route_v4(), false).unwrap();
let err = connect_with_iface(&mut reg, "home", "utun8", default_route_v4(), false)
.expect_err("expected conflict");
match err {
RegistryError::Conflict(Conflict::DefaultRouteTakeover { current, new }) => {
assert_eq!(current.as_str(), "corp");
assert_eq!(new.as_str(), "home");
}
other => panic!("expected DefaultRouteTakeover, got {other:?}"),
}
assert_eq!(reg.tunnel_count(), 1);
assert!(reg.snapshot(&ProfileId::new("home")).is_none());
}
#[test]
fn conflict_against_pending_default_route_claimant() {
let mut reg = registry_with_iface(|| Some("utun7".into()));
connect_with_iface(&mut reg, "corp", "utun7", default_route_v4(), false).unwrap();
assert!(reg.pending_default_route_claimant().is_none());
let c = reg
.detect_conflict(&ProfileId::new("home"), &default_route_v4())
.expect("conflict expected");
assert!(matches!(
c,
Conflict::DefaultRouteTakeover { ref current, .. } if current.as_str() == "corp"
));
}
#[test]
fn split_slash_one_pair_treated_as_default_route_for_conflict() {
let mut reg = registry_with_iface(|| Some("utun7".into()));
connect_with_iface(&mut reg, "corp", "utun7", default_route_v4(), false).unwrap();
let err = connect_with_iface(&mut reg, "home", "utun8", split_pair_v4(), false)
.expect_err("split-CIDR pair should conflict with existing 0/0 primary");
assert!(matches!(
err,
RegistryError::Conflict(Conflict::DefaultRouteTakeover { .. })
));
}
#[test]
fn slash_two_quartet_treated_as_default_route_for_conflict() {
let mut reg = registry_with_iface(|| Some("utun7".into()));
connect_with_iface(&mut reg, "corp", "utun7", default_route_v4(), false).unwrap();
let err = connect_with_iface(&mut reg, "home", "utun8", quartet_v4(), false)
.expect_err("/2 quartet should conflict with existing 0/0 primary");
assert!(matches!(
err,
RegistryError::Conflict(Conflict::DefaultRouteTakeover { .. })
));
}
#[test]
fn three_split_route_tunnels_no_default_route_no_primary() {
let mut reg = registry_with_iface(|| Some("eth0".into()));
connect_with_iface(&mut reg, "corp", "utun7", vec![v4("10.0.0.0/8")], false).unwrap();
connect_with_iface(&mut reg, "lab", "utun8", vec![v4("192.168.0.0/16")], false).unwrap();
connect_with_iface(&mut reg, "ops", "utun9", vec![v4("172.16.0.0/12")], false).unwrap();
assert_eq!(reg.tunnel_count(), 3);
assert!(reg.primary().is_none());
for snap in reg.snapshot_all() {
assert!(matches!(snap.role, Role::Addressable { .. }));
}
}
#[test]
fn force_true_bypasses_conflict_check() {
let mut reg = registry_with_iface(|| Some("utun7".into()));
connect_with_iface(&mut reg, "corp", "utun7", default_route_v4(), false).unwrap();
connect_with_iface(&mut reg, "home", "utun8", default_route_v4(), true).unwrap();
assert_eq!(reg.tunnel_count(), 2);
}
#[test]
fn disconnect_all_tears_down_mixed_states_to_empty_registry_state() {
let iface = Arc::new(Mutex::new(Some("utun7".to_string())));
let iface_probe = Arc::clone(&iface);
let mut reg = registry_with_iface(move || iface_probe.lock().unwrap().clone());
connect_with_iface(&mut reg, "corp", "utun7", default_route_v4(), false).unwrap();
connect_with_iface(&mut reg, "lab", "utun8", vec![v4("10.0.0.0/8")], false).unwrap();
connect_with_iface(&mut reg, "ops", "utun9", vec![v4("172.16.0.0/12")], false).unwrap();
*iface.lock().unwrap() = None; reg.disconnect_all();
for snap in reg.snapshot_all() {
assert!(matches!(snap.state, Connection::Disconnected { .. }));
}
assert!(reg.primary().is_none());
}
#[test]
fn pending_after_disconnect_fires_queued_connect_when_host_reaches_disconnected() {
let mut reg = registry_with_iface(|| Some("utun7".into()));
connect_with_iface(&mut reg, "corp", "utun7", default_route_v4(), false).unwrap();
let home_tunnel = MockTunnel::new();
home_tunnel.script_up(ScriptedTunnelOutcome::UpSuccess {
interface_name: "utun8".into(),
pid: None,
});
let home_engine = Engine::new(home_tunnel, resolver_for("home"));
reg.insert(ProfileId::new("home"), home_engine, default_route_v4());
reg.queue_after_disconnect(&ProfileId::new("corp"), ProfileId::new("home"))
.unwrap();
reg.disconnect(&ProfileId::new("corp")).unwrap();
let home = reg.snapshot(&ProfileId::new("home")).unwrap();
assert!(
matches!(home.state, Connection::Connected { .. }),
"queued home should have reached Connected, got {:?}",
home.state
);
}
#[test]
fn connect_returns_profile_not_found_when_resolver_returns_none() {
let mut reg = registry_with_iface(|| None);
let tunnel = MockTunnel::new();
let res = reg.connect_with_tunnel(
ProfileId::new("ghost"),
default_route_v4(),
tunnel,
|_id| None, false,
);
assert!(matches!(res, Err(RegistryError::ProfileNotFound(_))));
}
#[test]
fn snapshot_all_returns_stable_sorted_order() {
let mut reg = registry_with_iface(|| None);
connect_with_iface(&mut reg, "zeta", "utun9", vec![v4("10.0.0.0/8")], false).unwrap();
connect_with_iface(&mut reg, "alpha", "utun7", vec![v4("10.1.0.0/16")], false).unwrap();
connect_with_iface(&mut reg, "mid", "utun8", vec![v4("10.2.0.0/16")], false).unwrap();
let snaps = reg.snapshot_all();
let ids: Vec<&str> = snaps.iter().map(|s| s.profile_id.as_str()).collect();
assert_eq!(ids, vec!["alpha", "mid", "zeta"]);
}
#[test]
fn feed_default_route_interface_drives_primary_election() {
let mut reg: TunnelRegistry<MockTunnel> = TunnelRegistry::new();
connect_with_iface(&mut reg, "corp", "utun7", default_route_v4(), false).unwrap();
assert!(
reg.primary().is_none(),
"primary must be unset before any cache feed"
);
reg.feed_default_route_interface(Some("utun7".to_string()));
reg.refresh_primary();
assert_eq!(
reg.primary().map(ProfileId::as_str),
Some("corp"),
"primary should match the Connected tunnel whose interface owns default route"
);
reg.feed_default_route_interface(None);
reg.refresh_primary();
assert!(
reg.primary().is_none(),
"primary must clear when kernel reports no default route"
);
}
#[test]
fn cached_route_interface_serves_stale_values_to_avoid_ui_flicker() {
let mut reg: TunnelRegistry<MockTunnel> = TunnelRegistry::new();
connect_with_iface(&mut reg, "corp", "utun7", default_route_v4(), false).unwrap();
reg.feed_default_route_interface(Some("utun7".to_string()));
let stale_at = std::time::Instant::now()
.checked_sub(DEFAULT_ROUTE_CACHE_MAX_AGE + Duration::from_secs(1))
.expect("Instant - small Duration must not underflow");
reg.cached_route = Some(CachedRouteInterface {
iface: Some("utun7".to_string()),
at: stale_at,
});
reg.refresh_primary();
assert_eq!(
reg.primary().map(ProfileId::as_str),
Some("corp"),
"stale cache must still feed primary election; otherwise scanner-lag flickers the UI"
);
}
#[test]
fn primary_role_promoted_from_kernel_truth_when_allowed_ips_empty() {
let mut reg: TunnelRegistry<MockTunnel> = TunnelRegistry::new();
connect_with_iface(&mut reg, "ovpn-cert", "utun9", vec![], false).unwrap();
reg.feed_default_route_interface(Some("utun9".to_string()));
reg.refresh_primary();
assert_eq!(
reg.primary().map(ProfileId::as_str),
Some("ovpn-cert"),
"kernel says utun9 owns default route → ovpn-cert is primary"
);
let snap = reg
.snapshot(&ProfileId::new("ovpn-cert"))
.expect("snapshot for connected profile");
assert!(
matches!(snap.role, Role::Primary { .. }),
"ovpn-cert must render as Primary when kernel says it owns default route; got {:?}",
snap.role
);
}
fn seed_connected_unauthoritative(
reg: &mut TunnelRegistry<MockTunnel>,
name: &'static str,
iface: &str,
allowed_ips: Vec<Cidr>,
) {
let details = DetailedConnectionInfo {
interface: iface.into(),
interface_authoritative: false,
..Default::default()
};
reg.set_connected(
ProfileId::new(name),
allowed_ips,
details,
std::time::SystemTime::now(),
|| Engine::new(MockTunnel::new(), resolver_for(name)),
);
}
#[test]
fn recompute_primary_skips_unauthoritative_even_when_iface_matches_kernel() {
let mut reg: TunnelRegistry<MockTunnel> = TunnelRegistry::new();
seed_connected_unauthoritative(&mut reg, "external", "utun8", default_route_v4());
connect_with_iface(&mut reg, "internal", "utun8", default_route_v4(), true).unwrap();
reg.feed_default_route_interface(Some("utun8".to_string()));
reg.refresh_primary();
assert_eq!(
reg.primary().map(ProfileId::as_str),
Some("internal"),
"primary must be the authoritative entry, not the unauthoritative one with the same iface"
);
}
#[test]
fn derive_role_returns_addressable_for_unauthoritative_regardless_of_allowed_ips() {
let mut reg: TunnelRegistry<MockTunnel> = TunnelRegistry::new();
connect_with_iface(&mut reg, "primary", "utun9", default_route_v4(), false).unwrap();
reg.feed_default_route_interface(Some("utun9".to_string()));
reg.refresh_primary();
seed_connected_unauthoritative(&mut reg, "external", "utun7", default_route_v4());
let snap = reg
.snapshot(&ProfileId::new("external"))
.expect("snapshot for external profile");
assert!(
matches!(snap.role, Role::Addressable { .. }),
"unauthoritative entry must render as Addressable regardless of allowed_ips; got {:?}",
snap.role
);
}
#[test]
fn detailed_connection_info_default_sets_interface_authoritative_true() {
let d = DetailedConnectionInfo::default();
assert!(d.interface_authoritative);
}
}