diff --git a/lynx/.sqlx/query-2d7fdc4a481f272f3cbb28dfa84cabc82859bdfc2e1a79cdc60f3bc6d2a7ce21.json b/lynx/.sqlx/query-2d7fdc4a481f272f3cbb28dfa84cabc82859bdfc2e1a79cdc60f3bc6d2a7ce21.json new file mode 100644 index 0000000..f37c70a --- /dev/null +++ b/lynx/.sqlx/query-2d7fdc4a481f272f3cbb28dfa84cabc82859bdfc2e1a79cdc60f3bc6d2a7ce21.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO system_config (key, value)\n VALUES ('setup_token_issued_at', NOW()::text)\n ON CONFLICT (key) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "2d7fdc4a481f272f3cbb28dfa84cabc82859bdfc2e1a79cdc60f3bc6d2a7ce21" +} diff --git a/lynx/.sqlx/query-59d886170505494a62c65c7b089fc6c8b0af5d39ef14acf7e219e2b9773c0447.json b/lynx/.sqlx/query-59d886170505494a62c65c7b089fc6c8b0af5d39ef14acf7e219e2b9773c0447.json new file mode 100644 index 0000000..88a5b11 --- /dev/null +++ b/lynx/.sqlx/query-59d886170505494a62c65c7b089fc6c8b0af5d39ef14acf7e219e2b9773c0447.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET password_hash = $1, force_password_change = TRUE WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "59d886170505494a62c65c7b089fc6c8b0af5d39ef14acf7e219e2b9773c0447" +} diff --git a/lynx/.sqlx/query-b90f0087cb54a980e483949206cf8a20a60cdddf3517b5b60e27cd9821d02f3e.json b/lynx/.sqlx/query-b90f0087cb54a980e483949206cf8a20a60cdddf3517b5b60e27cd9821d02f3e.json deleted file mode 100644 index 1402b7f..0000000 --- a/lynx/.sqlx/query-b90f0087cb54a980e483949206cf8a20a60cdddf3517b5b60e27cd9821d02f3e.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO audit_log\n (id, agent_id, organization_id, user_id, command_type, result, error,\n previous_hash, entry_hash)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Uuid", - "Uuid", - "Text", - "Text", - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "b90f0087cb54a980e483949206cf8a20a60cdddf3517b5b60e27cd9821d02f3e" -} diff --git a/lynx/.sqlx/query-bdf70cd6a08e50102718e83221929b232a00327c7008dabd71021ce47d095471.json b/lynx/.sqlx/query-bdf70cd6a08e50102718e83221929b232a00327c7008dabd71021ce47d095471.json new file mode 100644 index 0000000..2068527 --- /dev/null +++ b/lynx/.sqlx/query-bdf70cd6a08e50102718e83221929b232a00327c7008dabd71021ce47d095471.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO rotation_log (id, reason, scope) VALUES ($1, 'scheduled', 'all')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "bdf70cd6a08e50102718e83221929b232a00327c7008dabd71021ce47d095471" +} diff --git a/lynx/.sqlx/query-c89f35b1ecf257cfaed002ded6ff12bc3024dc5ff057f2fe6e6a434eacbabce1.json b/lynx/.sqlx/query-c89f35b1ecf257cfaed002ded6ff12bc3024dc5ff057f2fe6e6a434eacbabce1.json new file mode 100644 index 0000000..bb62726 --- /dev/null +++ b/lynx/.sqlx/query-c89f35b1ecf257cfaed002ded6ff12bc3024dc5ff057f2fe6e6a434eacbabce1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT NOT EXISTS(\n SELECT 1 FROM agent_events\n WHERE agent_id = $1\n AND event IN ('rebooting', 'updating')\n AND created_at > NOW() - INTERVAL '5 minutes'\n ) AS \"not_in_grace!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "not_in_grace!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c89f35b1ecf257cfaed002ded6ff12bc3024dc5ff057f2fe6e6a434eacbabce1" +} diff --git a/lynx/.sqlx/query-d98c209b07244d0e9ff0be3653026ef23efb0b13214f6e5c7f0423f79142bb6b.json b/lynx/.sqlx/query-d98c209b07244d0e9ff0be3653026ef23efb0b13214f6e5c7f0423f79142bb6b.json deleted file mode 100644 index ab3010e..0000000 --- a/lynx/.sqlx/query-d98c209b07244d0e9ff0be3653026ef23efb0b13214f6e5c7f0423f79142bb6b.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, agent_id, organization_id, user_id, command_type,\n result, error, previous_hash, entry_hash, created_at\n FROM audit_log\n WHERE created_at > $1\n ORDER BY created_at ASC\n LIMIT $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "agent_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "organization_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "command_type", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "result", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "error", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "previous_hash", - "type_info": "Text" - }, - { - "ordinal": 8, - "name": "entry_hash", - "type_info": "Text" - }, - { - "ordinal": 9, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Timestamptz", - "Int8" - ] - }, - "nullable": [ - false, - false, - true, - true, - false, - false, - true, - false, - false, - false - ] - }, - "hash": "d98c209b07244d0e9ff0be3653026ef23efb0b13214f6e5c7f0423f79142bb6b" -} diff --git a/lynx/.sqlx/query-fc5ca36b2abcaca6e8c284720db0f6b35753bff57a4c68fd82fbd3649e365f0b.json b/lynx/.sqlx/query-fc5ca36b2abcaca6e8c284720db0f6b35753bff57a4c68fd82fbd3649e365f0b.json new file mode 100644 index 0000000..5160e4c --- /dev/null +++ b/lynx/.sqlx/query-fc5ca36b2abcaca6e8c284720db0f6b35753bff57a4c68fd82fbd3649e365f0b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(\n SELECT 1 FROM agent_events\n WHERE agent_id = $1\n AND event = 'heartbeat_lost'\n AND created_at > COALESCE(\n (SELECT created_at FROM agent_events\n WHERE agent_id = $1\n AND event IN ('rebooting', 'updating')\n ORDER BY created_at DESC LIMIT 1),\n '1970-01-01'::timestamptz\n )\n ) AS \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "fc5ca36b2abcaca6e8c284720db0f6b35753bff57a4c68fd82fbd3649e365f0b" +} diff --git a/lynx/.sqlx/query-ff5b6781f0fd181c7a6acc923513dc98af2d4233da678195c197658a8b0b0109.json b/lynx/.sqlx/query-ff5b6781f0fd181c7a6acc923513dc98af2d4233da678195c197658a8b0b0109.json deleted file mode 100644 index d5ab8c7..0000000 --- a/lynx/.sqlx/query-ff5b6781f0fd181c7a6acc923513dc98af2d4233da678195c197658a8b0b0109.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT entry_hash, previous_hash,\n id as \"id: Uuid\",\n agent_id as \"agent_id: Uuid\",\n organization_id as \"organization_id: Uuid\",\n user_id as \"user_id: Uuid\",\n command_type, result, error\n FROM audit_log ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "entry_hash", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "previous_hash", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "id: Uuid", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "agent_id: Uuid", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "organization_id: Uuid", - "type_info": "Uuid" - }, - { - "ordinal": 5, - "name": "user_id: Uuid", - "type_info": "Uuid" - }, - { - "ordinal": 6, - "name": "command_type", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "result", - "type_info": "Text" - }, - { - "ordinal": 8, - "name": "error", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - false, - false, - true - ] - }, - "hash": "ff5b6781f0fd181c7a6acc923513dc98af2d4233da678195c197658a8b0b0109" -} diff --git a/lynx/agent/setup-agent.sh b/lynx/agent/setup-agent.sh index fd4d2e0..4541d61 100644 --- a/lynx/agent/setup-agent.sh +++ b/lynx/agent/setup-agent.sh @@ -953,7 +953,7 @@ LoadCredential=database-url:/etc/lynx/credentials/database-url LoadCredential=internal-token:/etc/lynx/credentials/internal-token LoadCredential=lynx-dashboard-pubkey:/etc/lynx/credentials/lynx-dashboard-pubkey LoadCredential=sync-token:/etc/lynx/credentials/sync-token -LoadCredential=lynx-wg-psk:/etc/lynx/credentials/lynx-wg-psk +LoadCredential=lynx-wg-psk:-/etc/lynx/credentials/lynx-wg-psk LoadCredential=lynx-kek:/etc/lynx/credentials/lynx-kek # Minimal hardening — agent is a privileged system daemon (package management, diff --git a/lynx/agent/src/audit/mod.rs b/lynx/agent/src/audit/mod.rs index 27d0587..7174953 100644 --- a/lynx/agent/src/audit/mod.rs +++ b/lynx/agent/src/audit/mod.rs @@ -17,6 +17,7 @@ pub struct AuditEntry<'a> { pub enum AuditResult { Success, Rejected, + RejectedRateLimit, Failed, } @@ -25,6 +26,7 @@ impl AuditResult { match self { AuditResult::Success => "success", AuditResult::Rejected => "rejected", + AuditResult::RejectedRateLimit => "rejected_rate_limit", AuditResult::Failed => "failed", } } diff --git a/lynx/agent/src/handlers/system.rs b/lynx/agent/src/handlers/system.rs index cf3b3dc..a6dda5d 100644 --- a/lynx/agent/src/handlers/system.rs +++ b/lynx/agent/src/handlers/system.rs @@ -53,8 +53,8 @@ pub async fn run_verified_command( organization_id: None, user_id: None, command_type: "unknown", - result: AuditResult::Rejected, - error: Some("rejected_rate_limit".to_string()), + result: AuditResult::RejectedRateLimit, + error: None, }, ) .await diff --git a/lynx/agent/src/handlers/wireguard.rs b/lynx/agent/src/handlers/wireguard.rs index 9d51c26..b637313 100644 --- a/lynx/agent/src/handlers/wireguard.rs +++ b/lynx/agent/src/handlers/wireguard.rs @@ -76,6 +76,30 @@ pub fn handle_wg_rotate_psk(cmd: &VerifiedCommand) -> std::result::Result { + let updated = conf + .lines() + .map(|line| { + if line.trim_start().starts_with("PresharedKey") { + format!("PresharedKey = {}", new_psk.as_str()) + } else { + line.to_string() + } + }) + .collect::>() + .join("\n"); + if let Err(e) = std::fs::write(WG_CONF_PATH, updated) { + tracing::warn!("failed to update PresharedKey in {WG_CONF_PATH}: {e}"); + } + } + Err(e) => tracing::warn!("failed to read {WG_CONF_PATH} for PSK update: {e}"), + } + tracing::info!("WireGuard PSK rotated and persisted"); Ok(json!({ "ok": true })) } diff --git a/lynx/agent/src/sync/mod.rs b/lynx/agent/src/sync/mod.rs index 146ca71..bc5fc13 100644 --- a/lynx/agent/src/sync/mod.rs +++ b/lynx/agent/src/sync/mod.rs @@ -86,6 +86,25 @@ async fn sync_batch(db: &PgPool, url: &str, token: &str) -> anyhow::Result<()> { .send() .await?; + if resp.status().as_u16() == 422 { + // Dashboard rejected the batch due to hash chain mismatch — our sync cursor + // is ahead of what the dashboard has (e.g. dashboard DB was wiped or restored + // from a backup). Reset to epoch so the next cycle resends from genesis. + let body = resp.text().await.unwrap_or_default(); + tracing::warn!( + detail = &body[..body.len().min(200)], + "audit sync: hash chain mismatch — resetting sync cursor to epoch for full resend" + ); + let epoch = chrono::DateTime::::from_timestamp(0, 0).unwrap_or_default(); + sqlx::query!( + "UPDATE sync_state SET last_synced_at = $1 WHERE id = 1", + epoch + ) + .execute(db) + .await?; + return Ok(()); + } + if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); diff --git a/lynx/dashboard/server/.sqlx/query-c89f35b1ecf257cfaed002ded6ff12bc3024dc5ff057f2fe6e6a434eacbabce1.json b/lynx/dashboard/server/.sqlx/query-c89f35b1ecf257cfaed002ded6ff12bc3024dc5ff057f2fe6e6a434eacbabce1.json new file mode 100644 index 0000000..5c57960 --- /dev/null +++ b/lynx/dashboard/server/.sqlx/query-c89f35b1ecf257cfaed002ded6ff12bc3024dc5ff057f2fe6e6a434eacbabce1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT NOT EXISTS(\n SELECT 1 FROM agent_events\n WHERE agent_id = $1\n AND event IN ('rebooting', 'updating')\n AND created_at > NOW() - INTERVAL '5 minutes'\n ) AS \"not_in_grace!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "not_in_grace", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c89f35b1ecf257cfaed002ded6ff12bc3024dc5ff057f2fe6e6a434eacbabce1" +} \ No newline at end of file diff --git a/lynx/dashboard/server/.sqlx/query-fc5ca36b2abcaca6e8c284720db0f6b35753bff57a4c68fd82fbd3649e365f0b.json b/lynx/dashboard/server/.sqlx/query-fc5ca36b2abcaca6e8c284720db0f6b35753bff57a4c68fd82fbd3649e365f0b.json new file mode 100644 index 0000000..ed89990 --- /dev/null +++ b/lynx/dashboard/server/.sqlx/query-fc5ca36b2abcaca6e8c284720db0f6b35753bff57a4c68fd82fbd3649e365f0b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(\n SELECT 1 FROM agent_events\n WHERE agent_id = $1\n AND event = 'heartbeat_lost'\n AND created_at > COALESCE(\n (SELECT created_at FROM agent_events\n WHERE agent_id = $1\n AND event IN ('rebooting', 'updating')\n ORDER BY created_at DESC LIMIT 1),\n '1970-01-01'::timestamptz\n )\n ) AS \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fc5ca36b2abcaca6e8c284720db0f6b35753bff57a4c68fd82fbd3649e365f0b" +} \ No newline at end of file diff --git a/lynx/dashboard/server/src/admin/handlers/rotation.rs b/lynx/dashboard/server/src/admin/handlers/rotation.rs index 38827fa..b810600 100644 --- a/lynx/dashboard/server/src/admin/handlers/rotation.rs +++ b/lynx/dashboard/server/src/admin/handlers/rotation.rs @@ -125,7 +125,7 @@ pub async fn rotate_jwt_sessions(state: &AppState) -> Result<(), AppError> { } pub async fn rotate_wireguard_psks(state: &AppState, triggered_by: Uuid) -> Result<(), AppError> { - use crate::agents::wg; + use crate::agents::{wg, ws_hub}; use std::io::Write; let agents = sqlx::query!( @@ -134,8 +134,6 @@ pub async fn rotate_wireguard_psks(state: &AppState, triggered_by: Uuid) -> Resu .fetch_all(&state.db) .await?; - let client = crate::agents::client::build_agent_client(&state.config); - for agent in &agents { // Generate new PSK and persist to Podman secret (replaces old one). let new_psk = match wg::create_psk(agent.id).await { @@ -146,7 +144,7 @@ pub async fn rotate_wireguard_psks(state: &AppState, triggered_by: Uuid) -> Resu } }; - // Update WireGuard interface on dashboard side. + // Update WireGuard interface on dashboard side (local agent manages the WG hub). let psk_update = std::process::Command::new("wg") .args([ "set", @@ -176,7 +174,7 @@ pub async fn rotate_wireguard_psks(state: &AppState, triggered_by: Uuid) -> Resu .await .insert(agent.id, new_psk.clone()); - // Send new PSK to agent via signed command. + // Send new PSK to agent via WebSocket (the only reliable channel to remote agents). let command = serde_json::json!({ "type": "wg.rotate_psk", "new_psk": *new_psk, @@ -184,20 +182,14 @@ pub async fn rotate_wireguard_psks(state: &AppState, triggered_by: Uuid) -> Resu let signed = cmd::sign_command(&state.config, agent.id, triggered_by, "write", &command) .map_err(AppError::Internal)?; + let signed_val = serde_json::to_value(&signed).unwrap_or_default(); - let url = format!("https://{}:{}/cmd", agent.wg_ip, agent.api_port); - - let _ = client - .post(&url) - .header( - "Authorization", - format!("Bearer {}", &*state.config.internal_token), - ) - .json(&signed) - .send() - .await; - - tracing::info!(agent_id = %agent.id, "WireGuard PSK rotated"); + match ws_hub::push_command(state, agent.id, signed_val).await { + Some(_) => tracing::info!(agent_id = %agent.id, "WireGuard PSK rotated"), + None => { + tracing::warn!(agent_id = %agent.id, "WireGuard PSK rotation: agent WS unavailable — will retry on reconnect") + } + } } Ok(()) diff --git a/lynx/dashboard/server/src/agents/heartbeat.rs b/lynx/dashboard/server/src/agents/heartbeat.rs index bcd2d13..a3fb700 100644 --- a/lynx/dashboard/server/src/agents/heartbeat.rs +++ b/lynx/dashboard/server/src/agents/heartbeat.rs @@ -121,8 +121,67 @@ async fn poll_agents(state: &AppState) { tracing::debug!(agent_id = %id, status = new_status, version = ?reported_version, "heartbeat polled"); - // Fire heartbeat_lost event when a previously-online agent becomes unreachable. - if new_status == "offline" && agent.status == "online" { + // Fire heartbeat_lost when an agent becomes unreachable OR when it + // remains offline after a grace-period disconnect (rebooting/updating). + // Without the second condition, an agent that goes offline via an + // expected disconnect and never returns is silently ignored forever. + let should_fire_heartbeat_lost = if new_status == "offline" { + if agent.status == "online" { + // Transition online → offline: fire immediately. + true + } else if agent.status == "offline" { + // Already offline: only fire if the most-recent grace-period + // event (rebooting/updating) is now older than 5 minutes. + let past_grace: bool = sqlx::query_scalar!( + r#" + SELECT NOT EXISTS( + SELECT 1 FROM agent_events + WHERE agent_id = $1 + AND event IN ('rebooting', 'updating') + AND created_at > NOW() - INTERVAL '5 minutes' + ) AS "not_in_grace!" + "#, + id + ) + .fetch_one(&state.db) + .await + .unwrap_or(false); + + if past_grace { + // Only fire once — skip if a heartbeat_lost was already + // recorded more recently than the last grace-period event. + let already_fired: bool = sqlx::query_scalar!( + r#" + SELECT EXISTS( + SELECT 1 FROM agent_events + WHERE agent_id = $1 + AND event = 'heartbeat_lost' + AND created_at > COALESCE( + (SELECT created_at FROM agent_events + WHERE agent_id = $1 + AND event IN ('rebooting', 'updating') + ORDER BY created_at DESC LIMIT 1), + '1970-01-01'::timestamptz + ) + ) AS "exists!" + "#, + id + ) + .fetch_one(&state.db) + .await + .unwrap_or(false); + !already_fired + } else { + false + } + } else { + false + } + } else { + false + }; + + if should_fire_heartbeat_lost { let event_id = uuid::Uuid::now_v7(); let _ = sqlx::query!( "INSERT INTO agent_events (id, agent_id, event, detail) VALUES ($1, $2, 'heartbeat_lost', NULL)", diff --git a/lynx/dashboard/server/src/agents/ws_hub.rs b/lynx/dashboard/server/src/agents/ws_hub.rs index 575476c..94799d1 100644 --- a/lynx/dashboard/server/src/agents/ws_hub.rs +++ b/lynx/dashboard/server/src/agents/ws_hub.rs @@ -110,14 +110,30 @@ async fn handle_socket(state: AppState, agent_id: Uuid, mut socket: WebSocket) { }); } + // If no message arrives for 90 s (3× heartbeat interval), the underlying TCP + // connection is silently dead (e.g. iptables DROP on the agent side). Without this + // timer, is_connected() stays true indefinitely and the heartbeat scheduler skips + // the HTTP poll, so heartbeat_lost never fires. + const WS_IDLE_TIMEOUT: Duration = Duration::from_secs(90); + let idle_deadline = tokio::time::sleep(WS_IDLE_TIMEOUT); + tokio::pin!(idle_deadline); + + // Send timeout: if socket.send() blocks longer than WS_IDLE_TIMEOUT (e.g. TCP send + // buffer full because the agent's WG interface is gone), we must not block the select + // loop — the idle_deadline arm would never fire. Apply the same timeout to sends. + const WS_SEND_TIMEOUT: Duration = Duration::from_secs(30); + loop { tokio::select! { Some(msg) = rx.recv() => { - if socket.send(msg).await.is_err() { - break; + match tokio::time::timeout(WS_SEND_TIMEOUT, socket.send(msg)).await { + Ok(Ok(())) => {} + Ok(Err(_)) | Err(_) => break, // send error or timeout → dead connection } } msg = socket.recv() => { + // Any inbound message resets the idle timer. + idle_deadline.as_mut().reset(tokio::time::Instant::now() + WS_IDLE_TIMEOUT); match msg { Some(Ok(Message::Text(text))) => { if let Err(e) = handle_agent_message(&state, agent_id, &pending, text.as_str()).await { @@ -131,6 +147,10 @@ async fn handle_socket(state: AppState, agent_id: Uuid, mut socket: WebSocket) { _ => {} } } + _ = &mut idle_deadline => { + tracing::warn!(agent_id = %agent_id, "WS idle timeout — no message in 90 s, closing connection"); + break; + } } } @@ -297,8 +317,9 @@ async fn handle_agent_message( if let Ok(req_id) = Uuid::parse_str(id_str) { let mut map = pending.lock().await; if let Some(tx) = map.remove(&req_id) { - let body = msg.data.get("body").cloned().unwrap_or(json!({})); - let _ = tx.send(body); + // Pass the full response data (ok + error + body) so callers + // can inspect ok/error when the command fails, not just body. + let _ = tx.send(msg.data.clone()); } } }