From f123f9ebd03d4bc19729f957a0e1e11e7ae22bbd Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 28 Aug 2026 16:10:42 -0400 Subject: [PATCH 1/2] feat(dgw): clarify agent tunnel status Expose online, unresponsive, and offline Agent states through the management endpoints. Keep routing information available when runtime state exists, and stop publishing certificate fingerprints and route synchronization epochs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/src/api/tunnel.rs | 35 ++++++++++++++++++++------- testsuite/tests/cli/agent/tunnel.rs | 11 ++++----- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/devolutions-gateway/src/api/tunnel.rs b/devolutions-gateway/src/api/tunnel.rs index 6c3af7254..3c222e15e 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, 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), } } 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") From 6ba684f3a06d0a597badd5e2e2e1b4ad0feb6c4e Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 28 Aug 2026 16:33:25 -0400 Subject: [PATCH 2/2] test(dgw): cover unresponsive agent status Verify that a connected Agent with an expired heartbeat is reported as unresponsive while retaining its last runtime snapshot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/src/api/tunnel.rs | 37 ++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/devolutions-gateway/src/api/tunnel.rs b/devolutions-gateway/src/api/tunnel.rs index 3c222e15e..ffd7270d6 100644 --- a/devolutions-gateway/src/api/tunnel.rs +++ b/devolutions-gateway/src/api/tunnel.rs @@ -43,7 +43,7 @@ pub struct AgentDomainAdvertisement { pub auto_detected: bool, } -#[derive(Clone, Copy, Serialize)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum AgentStatus { /// No tunnel connection exists for the Agent. @@ -309,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())); + } +}