diff --git a/devolutions-gateway/src/api/tunnel.rs b/devolutions-gateway/src/api/tunnel.rs index 6c3af7254..ffd7270d6 100644 --- a/devolutions-gateway/src/api/tunnel.rs +++ b/devolutions-gateway/src/api/tunnel.rs @@ -37,20 +37,37 @@ pub struct EnrollResponse { #[derive(Serialize)] pub struct AgentDomainAdvertisement { + /// Domain route advertised by the Agent. pub domain: String, + /// Whether the Agent discovered the domain automatically. pub auto_detected: bool, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentStatus { + /// No tunnel connection exists for the Agent. + Offline, + /// The Agent has a tunnel connection and a recent heartbeat. + Online, + /// The Agent has a tunnel connection, but its heartbeat has expired. + Unresponsive, +} + #[derive(Serialize)] pub struct AgentInfo { + /// Stable Agent identity. pub agent_id: Uuid, + /// Unique management name assigned during enrollment. pub name: String, - pub cert_fingerprint: Option, - pub is_online: bool, + /// Current tunnel connection status. + pub status: AgentStatus, + /// Last heartbeat timestamp in milliseconds since the Unix epoch. pub last_seen_ms: Option, + /// Subnet routes currently advertised by the Agent. pub subnets: Option>, + /// Domain routes currently advertised by the Agent. pub domains: Option>, - pub route_epoch: Option, } pub fn make_router(state: DgwState) -> Router { @@ -194,20 +211,21 @@ fn agent_info( return AgentInfo { agent_id: accepted.agent_id, name: accepted.name, - cert_fingerprint: None, - is_online: false, + status: AgentStatus::Offline, last_seen_ms: None, subnets: None, domains: None, - route_epoch: None, }; }; AgentInfo { agent_id: accepted.agent_id, name: accepted.name, - cert_fingerprint: Some(runtime.cert_fingerprint), - is_online: runtime.is_online, + status: if runtime.is_online { + AgentStatus::Online + } else { + AgentStatus::Unresponsive + }, last_seen_ms: Some(runtime.last_seen_ms), subnets: Some(runtime.subnets), domains: Some( @@ -220,7 +238,6 @@ fn agent_info( }) .collect(), ), - route_epoch: Some(runtime.route_epoch), } } @@ -292,3 +309,38 @@ async fn delete_agent( Ok(axum::http::StatusCode::NO_CONTENT) } + +#[cfg(test)] +mod tests { + use super::*; + + fn accepted_agent(agent_id: Uuid) -> agent_tunnel::authorization::AcceptedAgent { + agent_tunnel::authorization::AcceptedAgent { + agent_id, + name: String::from("montreal-office"), + client_spki_sha256: [0x11; 32], + } + } + + #[test] + fn connected_agent_without_recent_heartbeat_is_unresponsive() { + let agent_id = Uuid::new_v4(); + let runtime = agent_tunnel::registry::AgentInfo { + agent_id, + name: String::from("montreal-office"), + cert_fingerprint: String::from("fingerprint"), + is_online: false, + last_seen_ms: 1234, + subnets: Vec::new(), + domains: Vec::new(), + route_epoch: 1, + }; + + let info = agent_info(accepted_agent(agent_id), Some(runtime)); + + assert_eq!(info.status, AgentStatus::Unresponsive); + assert_eq!(info.last_seen_ms, Some(1234)); + assert_eq!(info.subnets, Some(Vec::new())); + assert!(info.domains.is_some_and(|domains| domains.is_empty())); + } +} diff --git a/testsuite/tests/cli/agent/tunnel.rs b/testsuite/tests/cli/agent/tunnel.rs index 55a5e1dae..d6460ae57 100644 --- a/testsuite/tests/cli/agent/tunnel.rs +++ b/testsuite/tests/cli/agent/tunnel.rs @@ -473,8 +473,7 @@ async fn wait_for_registered_agent( }) }); - if agent.get("is_online").and_then(serde_json::Value::as_bool) == Some(true) - && agent.get("route_epoch").and_then(serde_json::Value::as_u64) == Some(1) + if agent.get("status").and_then(serde_json::Value::as_str) == Some("online") && subnets == expected_subnets && domains == expected_domains && domains_are_explicit @@ -680,14 +679,14 @@ async fn enrolled_agent_forwards_domain_only_route_and_reconnects() { Some("smoke-agent") ); assert_eq!( - offline.get("is_online").and_then(serde_json::Value::as_bool), - Some(false) + offline.get("status").and_then(serde_json::Value::as_str), + Some("offline") ); - assert!(offline.get("cert_fingerprint").is_some_and(serde_json::Value::is_null)); assert!(offline.get("last_seen_ms").is_some_and(serde_json::Value::is_null)); assert!(offline.get("subnets").is_some_and(serde_json::Value::is_null)); assert!(offline.get("domains").is_some_and(serde_json::Value::is_null)); - assert!(offline.get("route_epoch").is_some_and(serde_json::Value::is_null)); + assert!(offline.get("cert_fingerprint").is_none()); + assert!(offline.get("route_epoch").is_none()); let mut agent = agent_tokio_cmd() .env("DAGENT_CONFIG_PATH", agent_config.path()) .arg("run")