use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use crate::vortix_core::engine::registry::Conflict;
use crate::vortix_core::engine::state::{ConnectionHealth, DegradedReason, FailureReason};
use crate::vortix_core::profile::{ProfileId, ProtocolKind};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventEnvelope {
pub schema_version: u32,
pub timestamp: SystemTime,
pub event: EngineEvent,
}
impl EventEnvelope {
#[must_use]
pub fn new(event: EngineEvent) -> Self {
Self {
schema_version: SCHEMA_VERSION,
timestamp: SystemTime::now(),
event,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EngineEvent {
ConnectAttemptStarted {
profile_id: ProfileId,
protocol: ProtocolKind,
attempt: u32,
},
ConnectAttemptFailed {
profile_id: ProfileId,
attempt: u32,
reason: FailureReason,
},
TunnelUp {
profile_id: ProfileId,
protocol: ProtocolKind,
interface_name: String,
pid: Option<u32>,
},
TunnelDown {
profile_id: ProfileId,
reason: TunnelDownReason,
},
HandshakeStale {
profile_id: ProfileId,
seconds_since_last_handshake: u64,
},
ConnectionHealthChanged {
profile_id: ProfileId,
old: ConnectionHealth,
new: ConnectionHealth,
},
IpChanged { old: Option<String>, new: String },
KillswitchEngaged { reason: KillswitchEngageReason },
KillswitchDisengaged,
RetryScheduled {
profile_id: ProfileId,
next_attempt: u32,
delay: Duration,
retry_budget_remaining: Duration,
},
RetryBudgetExhausted {
profile_id: ProfileId,
total_attempts: u32,
elapsed: Duration,
},
NetworkLinkLost,
NetworkLinkRestored { new_gateway: Option<String> },
ProfileRenamed {
profile_id: ProfileId,
old_display_name: String,
new_display_name: String,
},
ProfileDeletionRequested { profile_id: ProfileId },
JournalRetentionApplied { deleted: u32 },
DegradedReasonCleared {
profile_id: ProfileId,
reason: DegradedReason,
},
UserPromptRequested {
profile_id: ProfileId,
prompt_id: String,
prompt_kind: crate::vortix_core::engine::state::PromptKind,
prompt_text: String,
},
PrimaryTunnelChanged {
from: Option<ProfileId>,
to: Option<ProfileId>,
via_interface: Option<String>,
reason: PrimaryChangeReason,
},
ConnectAttemptBlockedByConflict {
conflict: Conflict,
profile_id: ProfileId,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum PrimaryChangeReason {
InitialConnect,
PriorPrimaryDisconnected,
ExternalRouteChange,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum TunnelDownReason {
UserDisconnect,
NetworkLinkLost,
DaemonExited,
HandshakeFailed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum KillswitchEngageReason {
UserRequest,
AutoOnConnect,
AlwaysOn,
RecoveredFromCrash,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_round_trips_through_json() {
let env = EventEnvelope::new(EngineEvent::TunnelUp {
profile_id: ProfileId::new("corp"),
protocol: ProtocolKind::WireGuard,
interface_name: "wg0".to_string(),
pid: None,
});
let json = serde_json::to_string(&env).unwrap();
let back: EventEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(back.schema_version, SCHEMA_VERSION);
match back.event {
EngineEvent::TunnelUp { interface_name, .. } => {
assert_eq!(interface_name, "wg0");
}
_ => panic!("expected TunnelUp"),
}
}
#[test]
fn snake_case_tag_uses_kind() {
let env = EventEnvelope::new(EngineEvent::NetworkLinkLost);
let json = serde_json::to_string(&env).unwrap();
assert!(json.contains(r#""kind":"network_link_lost""#));
}
#[test]
fn primary_tunnel_changed_round_trips_through_json() {
let event = EngineEvent::PrimaryTunnelChanged {
from: Some(ProfileId::new("corp")),
to: Some(ProfileId::new("home")),
via_interface: Some("wg1".to_string()),
reason: PrimaryChangeReason::PriorPrimaryDisconnected,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""kind":"primary_tunnel_changed""#));
assert!(json.contains(r#""reason":"prior_primary_disconnected""#));
let back: EngineEvent = serde_json::from_str(&json).unwrap();
match back {
EngineEvent::PrimaryTunnelChanged {
from,
to,
via_interface,
reason,
} => {
assert_eq!(from.as_ref().map(ProfileId::as_str), Some("corp"));
assert_eq!(to.as_ref().map(ProfileId::as_str), Some("home"));
assert_eq!(via_interface.as_deref(), Some("wg1"));
assert_eq!(reason, PrimaryChangeReason::PriorPrimaryDisconnected);
}
_ => panic!("expected PrimaryTunnelChanged"),
}
}
#[test]
fn primary_tunnel_changed_with_no_prior_primary_round_trips() {
let event = EngineEvent::PrimaryTunnelChanged {
from: None,
to: Some(ProfileId::new("corp")),
via_interface: None,
reason: PrimaryChangeReason::InitialConnect,
};
let json = serde_json::to_string(&event).unwrap();
let back: EngineEvent = serde_json::from_str(&json).unwrap();
match back {
EngineEvent::PrimaryTunnelChanged {
from,
to,
via_interface,
reason,
} => {
assert!(from.is_none());
assert_eq!(to.as_ref().map(ProfileId::as_str), Some("corp"));
assert!(via_interface.is_none());
assert_eq!(reason, PrimaryChangeReason::InitialConnect);
}
_ => panic!("expected PrimaryTunnelChanged"),
}
}
#[test]
fn primary_change_reason_external_route_change_round_trips() {
let event = EngineEvent::PrimaryTunnelChanged {
from: Some(ProfileId::new("corp")),
to: None,
via_interface: None,
reason: PrimaryChangeReason::ExternalRouteChange,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""reason":"external_route_change""#));
let back: EngineEvent = serde_json::from_str(&json).unwrap();
if let EngineEvent::PrimaryTunnelChanged { reason, .. } = back {
assert_eq!(reason, PrimaryChangeReason::ExternalRouteChange);
} else {
panic!("expected PrimaryTunnelChanged");
}
}
#[test]
fn connect_attempt_blocked_by_conflict_round_trips_default_route_takeover() {
let event = EngineEvent::ConnectAttemptBlockedByConflict {
conflict: Conflict::DefaultRouteTakeover {
current: ProfileId::new("corp"),
new: ProfileId::new("home"),
},
profile_id: ProfileId::new("home"),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""kind":"connect_attempt_blocked_by_conflict""#));
assert!(json.contains(r#""kind":"default_route_takeover""#));
let back: EngineEvent = serde_json::from_str(&json).unwrap();
match back {
EngineEvent::ConnectAttemptBlockedByConflict {
conflict,
profile_id,
} => {
assert_eq!(profile_id.as_str(), "home");
match conflict {
Conflict::DefaultRouteTakeover { current, new } => {
assert_eq!(current.as_str(), "corp");
assert_eq!(new.as_str(), "home");
}
_ => panic!("expected DefaultRouteTakeover"),
}
}
_ => panic!("expected ConnectAttemptBlockedByConflict"),
}
}
#[test]
fn connect_attempt_blocked_by_conflict_round_trips_route_overlap() {
use crate::vortix_core::cidr::Cidr;
use std::net::IpAddr;
use std::str::FromStr;
let cidr = Cidr::new(IpAddr::from_str("10.0.0.0").unwrap(), 8).unwrap();
let event = EngineEvent::ConnectAttemptBlockedByConflict {
conflict: Conflict::RouteOverlap {
with: ProfileId::new("corp"),
overlapping_cidrs: vec![cidr],
},
profile_id: ProfileId::new("home"),
};
let json = serde_json::to_string(&event).unwrap();
let back: EngineEvent = serde_json::from_str(&json).unwrap();
match back {
EngineEvent::ConnectAttemptBlockedByConflict {
conflict:
Conflict::RouteOverlap {
with,
overlapping_cidrs,
},
profile_id,
} => {
assert_eq!(with.as_str(), "corp");
assert_eq!(profile_id.as_str(), "home");
assert_eq!(overlapping_cidrs.len(), 1);
assert_eq!(overlapping_cidrs[0].prefix_len, 8);
}
_ => panic!("expected ConnectAttemptBlockedByConflict / RouteOverlap"),
}
}
#[test]
fn existing_tunnel_up_serialization_unchanged() {
let event = EngineEvent::TunnelUp {
profile_id: ProfileId::new("corp"),
protocol: ProtocolKind::WireGuard,
interface_name: "wg0".to_string(),
pid: Some(1234),
};
let json = serde_json::to_string(&event).unwrap();
assert_eq!(
json,
r#"{"kind":"tunnel_up","profile_id":"corp","protocol":"WireGuard","interface_name":"wg0","pid":1234}"#
);
}
#[test]
fn existing_network_link_lost_serialization_unchanged() {
let event = EngineEvent::NetworkLinkLost;
let json = serde_json::to_string(&event).unwrap();
assert_eq!(json, r#"{"kind":"network_link_lost"}"#);
}
#[test]
fn existing_journal_retention_applied_serialization_unchanged() {
let event = EngineEvent::JournalRetentionApplied { deleted: 7 };
let json = serde_json::to_string(&event).unwrap();
assert_eq!(json, r#"{"kind":"journal_retention_applied","deleted":7}"#);
}
}