use std::time::{Duration, SystemTime};
use crate::vortix_core::engine::event::{EngineEvent, KillswitchEngageReason, TunnelDownReason};
use crate::vortix_core::engine::input::{
Input, LinkState, ProfileChange, TunnelStatusObservation, UserCommand,
};
use crate::vortix_core::engine::state::{
Connection, DetailedConnectionInfo, FailureReason, DEFAULT_RETRY_BUDGET_SECS,
};
use crate::vortix_core::ports::tunnel::{Tunnel, TunnelHandle};
use crate::vortix_core::profile::{Profile, ProfileId, ProtocolKind};
#[derive(Debug, Clone)]
pub struct EngineSettings {
pub retry_budget: Duration,
pub initial_backoff: Duration,
}
impl Default for EngineSettings {
fn default() -> Self {
Self {
retry_budget: Duration::from_secs(DEFAULT_RETRY_BUDGET_SECS),
initial_backoff: Duration::from_secs(2),
}
}
}
pub type ProfileResolver = Box<dyn Fn(&ProfileId) -> Option<Profile> + Send>;
pub type TunnelFactory<T> = Box<dyn Fn(&Profile) -> T + Send>;
pub struct Engine<T: Tunnel> {
state: Connection,
tunnel: T,
tunnel_factory: Option<TunnelFactory<T>>,
settings: EngineSettings,
profile_resolver: ProfileResolver,
}
impl<T: Tunnel> Engine<T> {
pub fn new(
tunnel: T,
profile_resolver: impl Fn(&ProfileId) -> Option<Profile> + Send + 'static,
) -> Self {
Self {
state: Connection::default(),
tunnel,
tunnel_factory: None,
settings: EngineSettings::default(),
profile_resolver: Box::new(profile_resolver),
}
}
#[must_use]
pub fn with_settings(mut self, settings: EngineSettings) -> Self {
self.settings = settings;
self
}
#[must_use]
pub fn with_tunnel_factory(mut self, factory: impl Fn(&Profile) -> T + Send + 'static) -> Self {
self.tunnel_factory = Some(Box::new(factory));
self
}
#[must_use]
pub fn state(&self) -> &Connection {
&self.state
}
pub fn handle(&mut self, input: Input) -> Vec<EngineEvent> {
let mut events = Vec::new();
match input {
Input::UserCommand(cmd) => self.handle_user_command(cmd, &mut events),
Input::Tick => self.handle_tick(&mut events),
Input::NetworkLinkChanged(link) => self.handle_link(link, &mut events),
Input::TelemetryReport(_) => {
}
Input::ProfileChanged(change) => self.handle_profile_change(change, &mut events),
Input::TunnelStatusObserved(obs) => self.handle_tunnel_status(obs, &mut events),
}
events
}
fn handle_user_command(&mut self, cmd: UserCommand, events: &mut Vec<EngineEvent>) {
match cmd {
UserCommand::Connect { profile_id } => self.try_connect(profile_id, events),
UserCommand::Disconnect | UserCommand::ForceDisconnect => self.try_disconnect(events),
UserCommand::Reconnect => self.try_reconnect(events),
UserCommand::UserAnswered { .. } => {}
}
}
fn try_connect(&mut self, profile_id: ProfileId, events: &mut Vec<EngineEvent>) {
if !matches!(self.state, Connection::Disconnected { .. }) {
return;
}
let Some(profile) = (self.profile_resolver)(&profile_id) else {
self.state = Connection::Disconnected {
last_failure: Some(FailureReason::ProfileGone(profile_id)),
};
return;
};
let now = SystemTime::now();
self.state = Connection::Connecting {
profile_id: profile_id.clone(),
started_at: now,
attempt: 1,
retry_budget_remaining: self.settings.retry_budget,
};
events.push(EngineEvent::ConnectAttemptStarted {
profile_id: profile_id.clone(),
protocol: profile.protocol,
attempt: 1,
});
if let Some(factory) = &self.tunnel_factory {
self.tunnel = factory(&profile);
}
match self.tunnel.up(&profile) {
Ok(handle) => {
self.transition_to_connected(handle, profile.protocol, events);
}
Err(err) => self.handle_connect_failure(profile_id, profile.protocol, 1, err, events),
}
}
fn try_disconnect(&mut self, events: &mut Vec<EngineEvent>) {
let (profile_id, _interface) = match &self.state {
Connection::Connected {
profile_id,
details,
..
} => (profile_id.clone(), details.interface.clone()),
Connection::Connecting { profile_id, .. }
| Connection::Reconnecting { profile_id, .. } => (profile_id.clone(), String::new()),
_ => return,
};
let handle = self.synth_handle(&profile_id);
self.state = Connection::Disconnecting {
profile_id: profile_id.clone(),
started_at: SystemTime::now(),
};
match self.tunnel.down(handle) {
Ok(()) => {
events.push(EngineEvent::TunnelDown {
profile_id,
reason: TunnelDownReason::UserDisconnect,
});
self.state = Connection::Disconnected { last_failure: None };
}
Err(err) => {
self.state = Connection::Disconnected {
last_failure: Some(FailureReason::Other(err.to_string())),
};
}
}
}
fn try_reconnect(&mut self, events: &mut Vec<EngineEvent>) {
let profile_id = match self.state.profile_id() {
Some(id) => id.clone(),
None => return,
};
self.try_disconnect(events);
self.try_connect(profile_id, events);
}
fn handle_tick(&mut self, events: &mut Vec<EngineEvent>) {
match &self.state {
Connection::Connecting {
profile_id,
started_at,
attempt,
..
}
| Connection::Reconnecting {
profile_id,
started_at,
attempt,
..
} => {
let elapsed = started_at.elapsed().unwrap_or(Duration::ZERO);
if elapsed >= self.settings.retry_budget {
let pid = profile_id.clone();
let total = *attempt;
events.push(EngineEvent::RetryBudgetExhausted {
profile_id: pid.clone(),
total_attempts: total,
elapsed,
});
self.state = Connection::Disconnected {
last_failure: Some(FailureReason::RetryBudgetExhausted {
attempts: total,
elapsed,
}),
};
}
}
_ => {}
}
}
fn handle_link(&mut self, link: LinkState, events: &mut Vec<EngineEvent>) {
match (link, &self.state) {
(LinkState::Down, Connection::Connected { profile_id, .. }) => {
let pid = profile_id.clone();
events.push(EngineEvent::NetworkLinkLost);
events.push(EngineEvent::TunnelDown {
profile_id: pid.clone(),
reason: TunnelDownReason::NetworkLinkLost,
});
self.state = Connection::Reconnecting {
profile_id: pid,
started_at: SystemTime::now(),
attempt: 1,
retry_budget_remaining: self.settings.retry_budget,
last_error: Some("network link lost".to_string()),
};
}
(LinkState::Up, Connection::Reconnecting { .. }) => {
events.push(EngineEvent::NetworkLinkRestored { new_gateway: None });
}
_ => {}
}
}
fn handle_profile_change(&mut self, change: ProfileChange, events: &mut Vec<EngineEvent>) {
match change {
ProfileChange::Renamed {
profile_id,
old_display_name,
new_display_name,
} => {
events.push(EngineEvent::ProfileRenamed {
profile_id,
old_display_name,
new_display_name,
});
}
ProfileChange::Deleted { profile_id } => {
events.push(EngineEvent::ProfileDeletionRequested {
profile_id: profile_id.clone(),
});
if self.state.profile_id() == Some(&profile_id) {
self.try_disconnect(events);
}
}
ProfileChange::Imported { .. } => { }
}
}
fn handle_tunnel_status(
&mut self,
obs: TunnelStatusObservation,
events: &mut Vec<EngineEvent>,
) {
if let TunnelStatusObservation::Active {
profile_id,
interface_name,
started_at,
} = obs
{
if matches!(self.state, Connection::Disconnected { .. }) {
self.state = Connection::Connected {
profile_id: profile_id.clone(),
since: started_at,
health: crate::vortix_core::engine::state::ConnectionHealth::default(),
details: Box::new(DetailedConnectionInfo {
interface: interface_name.clone(),
..Default::default()
}),
};
let protocol = (self.profile_resolver)(&profile_id)
.map_or(ProtocolKind::WireGuard, |p| p.protocol);
events.push(EngineEvent::TunnelUp {
profile_id,
protocol,
interface_name,
pid: None,
});
}
}
}
fn transition_to_connected(
&mut self,
handle: TunnelHandle,
protocol: ProtocolKind,
events: &mut Vec<EngineEvent>,
) {
let now = SystemTime::now();
events.push(EngineEvent::TunnelUp {
profile_id: handle.profile_id.clone(),
protocol,
interface_name: handle.interface_name.clone(),
pid: handle.pid,
});
events.push(EngineEvent::KillswitchEngaged {
reason: KillswitchEngageReason::AutoOnConnect,
});
self.state = Connection::Connected {
profile_id: handle.profile_id.clone(),
since: now,
health: crate::vortix_core::engine::state::ConnectionHealth::default(),
details: Box::new(DetailedConnectionInfo {
interface: handle.interface_name,
pid: handle.pid,
..Default::default()
}),
};
}
#[allow(clippy::needless_pass_by_value)] fn handle_connect_failure(
&mut self,
profile_id: ProfileId,
_protocol: ProtocolKind,
attempt: u32,
err: crate::vortix_core::ports::tunnel::TunnelError,
events: &mut Vec<EngineEvent>,
) {
let reason = tunnel_err_to_failure(err);
events.push(EngineEvent::ConnectAttemptFailed {
profile_id: profile_id.clone(),
attempt,
reason: reason.clone(),
});
self.state = Connection::Disconnected {
last_failure: Some(reason),
};
}
fn synth_handle(&self, profile_id: &ProfileId) -> TunnelHandle {
let (interface, pid) = match &self.state {
Connection::Connected { details, .. } => (details.interface.clone(), details.pid),
_ => (String::new(), None),
};
TunnelHandle {
profile_id: profile_id.clone(),
interface_name: interface,
pid,
started_at: SystemTime::now(),
kind: self.tunnel.kind_tag(),
}
}
}
fn tunnel_err_to_failure(err: crate::vortix_core::ports::tunnel::TunnelError) -> FailureReason {
use crate::vortix_core::ports::tunnel::TunnelError;
match err {
TunnelError::HandshakeFailed(s) => FailureReason::HandshakeFailed(s),
TunnelError::AuthFailed(s) => FailureReason::AuthFailed(s),
TunnelError::Timeout(d) => FailureReason::Timeout(d),
TunnelError::DaemonExited(s) | TunnelError::Subprocess(s) | TunnelError::Other(s) => {
FailureReason::Other(s)
}
TunnelError::Io(e) => FailureReason::Other(e.to_string()),
TunnelError::CapabilityUnsupported(c) => {
FailureReason::ConfigInvalid(format!("capability unsupported: {c}"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vortix_core::ports::tunnel::mock::{MockTunnel, ScriptedTunnelOutcome};
use std::path::PathBuf;
fn corp_profile() -> Profile {
Profile::new(
ProfileId::new("corp"),
"corp",
ProtocolKind::WireGuard,
PathBuf::from("/etc/wireguard/corp.conf"),
)
}
fn engine_with(tunnel: MockTunnel) -> Engine<MockTunnel> {
let p = corp_profile();
Engine::new(tunnel, move |id| {
if id.as_str() == "corp" {
Some(p.clone())
} else {
None
}
})
}
#[test]
fn connect_succeeds_transitions_to_connected() {
let tunnel = MockTunnel::new();
let mut engine = engine_with(tunnel);
let events = engine.handle(Input::UserCommand(UserCommand::Connect {
profile_id: ProfileId::new("corp"),
}));
assert!(matches!(engine.state(), Connection::Connected { .. }));
let kinds: Vec<&'static str> = events
.iter()
.map(|e| match e {
EngineEvent::ConnectAttemptStarted { .. } => "start",
EngineEvent::TunnelUp { .. } => "up",
EngineEvent::KillswitchEngaged { .. } => "ks",
_ => "other",
})
.collect();
assert!(kinds.contains(&"start"));
assert!(kinds.contains(&"up"));
assert!(kinds.contains(&"ks"));
}
#[test]
fn missing_profile_recorded_as_profile_gone() {
let mut engine = engine_with(MockTunnel::new());
let _ = engine.handle(Input::UserCommand(UserCommand::Connect {
profile_id: ProfileId::new("does-not-exist"),
}));
assert!(matches!(
engine.state(),
Connection::Disconnected {
last_failure: Some(FailureReason::ProfileGone(_))
}
));
}
#[test]
fn handshake_failure_transitions_to_disconnected_with_reason() {
let tunnel = MockTunnel::new();
tunnel.script_up(ScriptedTunnelOutcome::HandshakeFailed("bad key".into()));
let mut engine = engine_with(tunnel);
let events = engine.handle(Input::UserCommand(UserCommand::Connect {
profile_id: ProfileId::new("corp"),
}));
assert!(matches!(
engine.state(),
Connection::Disconnected {
last_failure: Some(FailureReason::HandshakeFailed(_))
}
));
assert!(events
.iter()
.any(|e| matches!(e, EngineEvent::ConnectAttemptFailed { .. })));
}
#[test]
fn link_down_while_connected_transitions_to_reconnecting() {
let mut engine = engine_with(MockTunnel::new());
let _ = engine.handle(Input::UserCommand(UserCommand::Connect {
profile_id: ProfileId::new("corp"),
}));
let events = engine.handle(Input::NetworkLinkChanged(LinkState::Down));
assert!(matches!(engine.state(), Connection::Reconnecting { .. }));
assert!(events
.iter()
.any(|e| matches!(e, EngineEvent::NetworkLinkLost)));
}
#[test]
fn profile_renamed_emits_event_without_state_change() {
let mut engine = engine_with(MockTunnel::new());
let _ = engine.handle(Input::UserCommand(UserCommand::Connect {
profile_id: ProfileId::new("corp"),
}));
let events = engine.handle(Input::ProfileChanged(ProfileChange::Renamed {
profile_id: ProfileId::new("corp"),
old_display_name: "corp".into(),
new_display_name: "work-corp".into(),
}));
assert!(matches!(engine.state(), Connection::Connected { .. }));
assert!(events
.iter()
.any(|e| matches!(e, EngineEvent::ProfileRenamed { .. })));
}
}