Enhanced code review and frontend validation with intelligent triggers: Code Review Agent Enhancement: - Added Sequential Thinking MCP integration for complex issues - Triggers on 2+ rejections or 3+ critical issues - New escalation format with root cause analysis - Comprehensive solution strategies with trade-off evaluation - Educational feedback to break rejection cycles - Files: .claude/agents/code-review.md (+308 lines) - Docs: CODE_REVIEW_ST_ENHANCEMENT.md, CODE_REVIEW_ST_TESTING.md Frontend Design Skill Enhancement: - Automatic invocation for ANY UI change - Comprehensive validation checklist (200+ checkpoints) - 8 validation categories (visual, interactive, responsive, a11y, etc.) - 3 validation levels (quick, standard, comprehensive) - Integration with code review workflow - Files: .claude/skills/frontend-design/SKILL.md (+120 lines) - Docs: UI_VALIDATION_CHECKLIST.md (462 lines), AUTOMATIC_VALIDATION_ENHANCEMENT.md (587 lines) Settings Optimization: - Repaired .claude/settings.local.json (fixed m365 pattern) - Reduced permissions from 49 to 33 (33% reduction) - Removed duplicates, sorted alphabetically - Created SETTINGS_PERMISSIONS.md documentation Checkpoint Command Enhancement: - Dual checkpoint system (git + database) - Saves session context to API for cross-machine recall - Includes git metadata in database context - Files: .claude/commands/checkpoint.md (+139 lines) Decision Rationale: - Sequential Thinking MCP breaks rejection cycles by identifying root causes - Automatic frontend validation catches UI issues before code review - Dual checkpoints enable complete project memory across machines - Settings optimization improves maintainability Total: 1,200+ lines of documentation and enhancements Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
46 lines
430 KiB
JSON
46 lines
430 KiB
JSON
{"parentUuid":null,"isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":"Design a stackable policy system for GuruRMM based on these requirements and codebase findings:\n\n**Requirements:**\n- Policies assignable at: Client, Site, and Agent levels\n- Stack order: Client → Site → Agent (last wins for conflicts)\n- Settings include: metrics collection (temps, intervals), alert thresholds (CPU > X% for Y minutes), system tray options, auto-update settings\n- Alerts: Dashboard-only initially (no email/webhook)\n- Delivery: Via existing ConfigUpdate WebSocket message\n\n**Codebase Context:**\n- Database: PostgreSQL with existing tables: clients, sites, agents, metrics, agent_state\n- Server: Axum web framework, WebSocket in ws/mod.rs, ServerMessage::ConfigUpdate exists but unused\n- Agent: Has ConfigUpdatePayload structure, TrayPolicy already shows policy pattern\n- Agent config includes: MetricsConfig (interval, collect_*), WatchdogConfig (services, processes)\n\n**Key Files:**\n- server/src/db/ - Database modules (agents.rs, clients.rs, sites.rs, metrics.rs)\n- server/src/api/mod.rs - API routes\n- server/src/ws/mod.rs - WebSocket handling, ServerMessage enum\n- agent/src/config.rs - Agent configuration\n- agent/src/transport/mod.rs - Message types including ConfigUpdatePayload\n\n**Design Requirements:**\n1. Database schema for policies, policy_assignments, alerts\n2. Policy structure that covers: metrics settings, thresholds, tray settings, update settings\n3. Server-side policy merge logic (evaluate effective policy for an agent)\n4. API endpoints for CRUD and deployment\n5. Alert evaluation approach (when to check thresholds)\n6. Agent-side policy application\n\nProvide a detailed implementation plan with specific structs, tables, and code patterns."},"uuid":"41927386-bec3-49ca-9188-a7928d2fe3ae","timestamp":"2025-12-21T21:34:20.254Z"}
|
||
{"parentUuid":"41927386-bec3-49ca-9188-a7928d2fe3ae","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01W5WvUcwjiJtvZAm8KiiP8U","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015Z3bFNyfpLsBzr939MGeJg","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\db\\mod.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":12316,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":12316,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWLYrnar7ZGXJxuc1jvw3","type":"assistant","uuid":"4cef42a4-ae49-4700-a2fd-1bfbc46f4d59","timestamp":"2025-12-21T21:34:25.826Z"}
|
||
{"parentUuid":"4cef42a4-ae49-4700-a2fd-1bfbc46f4d59","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01W5WvUcwjiJtvZAm8KiiP8U","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013CDgLAFmsXjoedASPczH1L","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":12316,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":12316,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWLYrnar7ZGXJxuc1jvw3","type":"assistant","uuid":"f053925b-372a-4acb-bd89-819300700ff3","timestamp":"2025-12-21T21:34:26.365Z"}
|
||
{"parentUuid":"f053925b-372a-4acb-bd89-819300700ff3","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01W5WvUcwjiJtvZAm8KiiP8U","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TsmCeEdSPzQT1y9Fozn15u","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\config.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":12316,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":12316,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWLYrnar7ZGXJxuc1jvw3","type":"assistant","uuid":"8910753e-86ed-4af9-9d58-1e73dc82e2d2","timestamp":"2025-12-21T21:34:26.833Z"}
|
||
{"parentUuid":"8910753e-86ed-4af9-9d58-1e73dc82e2d2","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01W5WvUcwjiJtvZAm8KiiP8U","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DkzFQRqeMW8vnwA6M4J4k8","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\transport\\mod.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":12316,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":12316,"ephemeral_1h_input_tokens":0},"output_tokens":271,"service_tier":"standard"}},"requestId":"req_011CWLYrnar7ZGXJxuc1jvw3","type":"assistant","uuid":"6b4f0998-3a6d-49d6-8dee-3a2ca10f8643","timestamp":"2025-12-21T21:34:27.170Z"}
|
||
{"parentUuid":"6b4f0998-3a6d-49d6-8dee-3a2ca10f8643","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015Z3bFNyfpLsBzr939MGeJg","type":"tool_result","content":" 1→//! Database models and queries\n 2→//!\n 3→//! Provides database access for clients, sites, agents, metrics, commands, users, and updates.\n 4→\n 5→pub mod agents;\n 6→pub mod clients;\n 7→pub mod commands;\n 8→pub mod metrics;\n 9→pub mod sites;\n 10→pub mod updates;\n 11→pub mod users;\n 12→\n 13→pub use agents::*;\n 14→pub use clients::*;\n 15→pub use commands::*;\n 16→pub use metrics::*;\n 17→pub use sites::*;\n 18→pub use updates::*;\n 19→pub use users::*;\n 20→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"ad1d3635-e39d-4494-b758-b10a723e8dca","timestamp":"2025-12-21T21:34:46.668Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\db\\mod.rs","content":"//! Database models and queries\n//!\n//! Provides database access for clients, sites, agents, metrics, commands, users, and updates.\n\npub mod agents;\npub mod clients;\npub mod commands;\npub mod metrics;\npub mod sites;\npub mod updates;\npub mod users;\n\npub use agents::*;\npub use clients::*;\npub use commands::*;\npub use metrics::*;\npub use sites::*;\npub use updates::*;\npub use users::*;\n","numLines":20,"startLine":1,"totalLines":20}}}
|
||
{"parentUuid":"ad1d3635-e39d-4494-b758-b10a723e8dca","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013CDgLAFmsXjoedASPczH1L","type":"tool_result","content":" 1→//! WebSocket handler for agent connections\n 2→//!\n 3→//! Handles real-time communication with agents including:\n 4→//! - Authentication handshake\n 5→//! - Metrics ingestion\n 6→//! - Command dispatching\n 7→//! - Watchdog event handling\n 8→\n 9→use std::collections::HashMap;\n 10→use std::sync::Arc;\n 11→\n 12→use axum::{\n 13→ extract::{\n 14→ ws::{Message, WebSocket},\n 15→ State, WebSocketUpgrade,\n 16→ },\n 17→ response::Response,\n 18→};\n 19→use futures_util::{SinkExt, StreamExt};\n 20→use serde::{Deserialize, Serialize};\n 21→use tokio::sync::{mpsc, RwLock};\n 22→use tracing::{debug, error, info, warn};\n 23→use uuid::Uuid;\n 24→\n 25→use crate::db;\n 26→use crate::AppState;\n 27→\n 28→/// Connected agents manager\n 29→pub struct AgentConnections {\n 30→ /// Map of agent ID to sender channel\n 31→ connections: HashMap<Uuid, mpsc::Sender<ServerMessage>>,\n 32→}\n 33→\n 34→impl AgentConnections {\n 35→ pub fn new() -> Self {\n 36→ Self {\n 37→ connections: HashMap::new(),\n 38→ }\n 39→ }\n 40→\n 41→ /// Add a new agent connection\n 42→ pub fn add(&mut self, agent_id: Uuid, tx: mpsc::Sender<ServerMessage>) {\n 43→ self.connections.insert(agent_id, tx);\n 44→ }\n 45→\n 46→ /// Remove an agent connection\n 47→ pub fn remove(&mut self, agent_id: &Uuid) {\n 48→ self.connections.remove(agent_id);\n 49→ }\n 50→\n 51→ /// Send a message to a specific agent\n 52→ pub async fn send_to(&self, agent_id: &Uuid, msg: ServerMessage) -> bool {\n 53→ if let Some(tx) = self.connections.get(agent_id) {\n 54→ tx.send(msg).await.is_ok()\n 55→ } else {\n 56→ false\n 57→ }\n 58→ }\n 59→\n 60→ /// Check if an agent is connected\n 61→ pub fn is_connected(&self, agent_id: &Uuid) -> bool {\n 62→ self.connections.contains_key(agent_id)\n 63→ }\n 64→\n 65→ /// Get count of connected agents\n 66→ pub fn count(&self) -> usize {\n 67→ self.connections.len()\n 68→ }\n 69→}\n 70→\n 71→impl Default for AgentConnections {\n 72→ fn default() -> Self {\n 73→ Self::new()\n 74→ }\n 75→}\n 76→\n 77→/// Messages from agent to server\n 78→#[derive(Debug, Clone, Serialize, Deserialize)]\n 79→#[serde(tag = \"type\", content = \"payload\")]\n 80→#[serde(rename_all = \"snake_case\")]\n 81→pub enum AgentMessage {\n 82→ Auth(AuthPayload),\n 83→ Metrics(MetricsPayload),\n 84→ NetworkState(NetworkStatePayload),\n 85→ CommandResult(CommandResultPayload),\n 86→ WatchdogEvent(WatchdogEventPayload),\n 87→ UpdateResult(UpdateResultPayload),\n 88→ Heartbeat,\n 89→}\n 90→\n 91→/// Messages from server to agent\n 92→#[derive(Debug, Clone, Serialize, Deserialize)]\n 93→#[serde(tag = \"type\", content = \"payload\")]\n 94→#[serde(rename_all = \"snake_case\")]\n 95→pub enum ServerMessage {\n 96→ AuthAck(AuthAckPayload),\n 97→ Command(CommandPayload),\n 98→ ConfigUpdate(serde_json::Value),\n 99→ Update(UpdatePayload),\n 100→ Ack { message_id: Option<String> },\n 101→ Error { code: String, message: String },\n 102→}\n 103→\n 104→#[derive(Debug, Clone, Serialize, Deserialize)]\n 105→pub struct AuthPayload {\n 106→ pub api_key: String,\n 107→ /// Hardware-derived device ID (for site-based registration)\n 108→ #[serde(default)]\n 109→ pub device_id: Option<String>,\n 110→ pub hostname: String,\n 111→ pub os_type: String,\n 112→ pub os_version: String,\n 113→ pub agent_version: String,\n 114→ /// Architecture (amd64, arm64, etc.)\n 115→ #[serde(default = \"default_arch\")]\n 116→ pub architecture: String,\n 117→ /// Previous version if reconnecting after update\n 118→ #[serde(default)]\n 119→ pub previous_version: Option<String>,\n 120→ /// Update ID if reconnecting after update\n 121→ #[serde(default)]\n 122→ pub pending_update_id: Option<Uuid>,\n 123→}\n 124→\n 125→fn default_arch() -> String {\n 126→ \"amd64\".to_string()\n 127→}\n 128→\n 129→#[derive(Debug, Clone, Serialize, Deserialize)]\n 130→pub struct AuthAckPayload {\n 131→ pub success: bool,\n 132→ pub agent_id: Option<Uuid>,\n 133→ pub error: Option<String>,\n 134→}\n 135→\n 136→#[derive(Debug, Clone, Serialize, Deserialize)]\n 137→pub struct MetricsPayload {\n 138→ pub timestamp: chrono::DateTime<chrono::Utc>,\n 139→ pub cpu_percent: f32,\n 140→ pub memory_percent: f32,\n 141→ pub memory_used_bytes: u64,\n 142→ pub memory_total_bytes: u64,\n 143→ pub disk_percent: f32,\n 144→ pub disk_used_bytes: u64,\n 145→ pub disk_total_bytes: u64,\n 146→ pub network_rx_bytes: u64,\n 147→ pub network_tx_bytes: u64,\n 148→ pub os_type: String,\n 149→ pub os_version: String,\n 150→ pub hostname: String,\n 151→ // Extended metrics (optional for backwards compatibility)\n 152→ #[serde(default)]\n 153→ pub uptime_seconds: Option<u64>,\n 154→ #[serde(default)]\n 155→ pub boot_time: Option<i64>,\n 156→ #[serde(default)]\n 157→ pub logged_in_user: Option<String>,\n 158→ #[serde(default)]\n 159→ pub user_idle_seconds: Option<u64>,\n 160→ #[serde(default)]\n 161→ pub public_ip: Option<String>,\n 162→ // Temperature metrics (optional for backwards compatibility)\n 163→ #[serde(default)]\n 164→ pub cpu_temp_celsius: Option<f32>,\n 165→ #[serde(default)]\n 166→ pub gpu_temp_celsius: Option<f32>,\n 167→ #[serde(default)]\n 168→ pub temperatures: Option<Vec<TemperatureSensor>>,\n 169→}\n 170→\n 171→/// Temperature sensor reading from agent\n 172→#[derive(Debug, Clone, Serialize, Deserialize)]\n 173→pub struct TemperatureSensor {\n 174→ pub label: String,\n 175→ pub temp_celsius: f32,\n 176→ #[serde(default)]\n 177→ pub critical_celsius: Option<f32>,\n 178→}\n 179→\n 180→#[derive(Debug, Clone, Serialize, Deserialize)]\n 181→pub struct CommandResultPayload {\n 182→ pub command_id: Uuid,\n 183→ pub exit_code: i32,\n 184→ pub stdout: String,\n 185→ pub stderr: String,\n 186→ pub duration_ms: u64,\n 187→}\n 188→\n 189→#[derive(Debug, Clone, Serialize, Deserialize)]\n 190→pub struct WatchdogEventPayload {\n 191→ pub name: String,\n 192→ pub event: String,\n 193→ pub details: Option<String>,\n 194→}\n 195→\n 196→/// Network interface information\n 197→#[derive(Debug, Clone, Serialize, Deserialize)]\n 198→pub struct NetworkInterface {\n 199→ pub name: String,\n 200→ pub mac_address: Option<String>,\n 201→ pub ipv4_addresses: Vec<String>,\n 202→ pub ipv6_addresses: Vec<String>,\n 203→}\n 204→\n 205→/// Network state payload from agent\n 206→#[derive(Debug, Clone, Serialize, Deserialize)]\n 207→pub struct NetworkStatePayload {\n 208→ pub timestamp: chrono::DateTime<chrono::Utc>,\n 209→ pub interfaces: Vec<NetworkInterface>,\n 210→ pub state_hash: String,\n 211→}\n 212→\n 213→#[derive(Debug, Clone, Serialize, Deserialize)]\n 214→pub struct CommandPayload {\n 215→ pub id: Uuid,\n 216→ pub command_type: String,\n 217→ pub command: String,\n 218→ pub timeout_seconds: Option<u64>,\n 219→ pub elevated: bool,\n 220→}\n 221→\n 222→/// Update command payload from server to agent\n 223→#[derive(Debug, Clone, Serialize, Deserialize)]\n 224→pub struct UpdatePayload {\n 225→ /// Unique update ID for tracking\n 226→ pub update_id: Uuid,\n 227→ /// Target version to update to\n 228→ pub target_version: String,\n 229→ /// Download URL for the new binary\n 230→ pub download_url: String,\n 231→ /// SHA256 checksum of the binary\n 232→ pub checksum_sha256: String,\n 233→ /// Whether to force update (skip version check)\n 234→ #[serde(default)]\n 235→ pub force: bool,\n 236→}\n 237→\n 238→/// Update result payload from agent to server\n 239→#[derive(Debug, Clone, Serialize, Deserialize)]\n 240→pub struct UpdateResultPayload {\n 241→ /// Update ID (from the server)\n 242→ pub update_id: Uuid,\n 243→ /// Update status\n 244→ pub status: String,\n 245→ /// Old version before update\n 246→ pub old_version: String,\n 247→ /// New version after update (if successful)\n 248→ pub new_version: Option<String>,\n 249→ /// Error message if failed\n 250→ pub error: Option<String>,\n 251→}\n 252→\n 253→/// Result of successful agent authentication\n 254→struct AuthResult {\n 255→ agent_id: Uuid,\n 256→ agent_version: String,\n 257→ os_type: String,\n 258→ architecture: String,\n 259→ previous_version: Option<String>,\n 260→ pending_update_id: Option<Uuid>,\n 261→}\n 262→\n 263→/// WebSocket upgrade handler\n 264→pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {\n 265→ ws.on_upgrade(|socket| handle_socket(socket, state))\n 266→}\n 267→\n 268→/// Handle a WebSocket connection\n 269→async fn handle_socket(socket: WebSocket, state: AppState) {\n 270→ let (mut sender, mut receiver) = socket.split();\n 271→\n 272→ // Create channel for outgoing messages\n 273→ let (tx, mut rx) = mpsc::channel::<ServerMessage>(100);\n 274→\n 275→ // Wait for authentication message\n 276→ let auth_result = match authenticate(&mut receiver, &mut sender, &state).await {\n 277→ Ok(result) => {\n 278→ info!(\"Agent authenticated: {}\", result.agent_id);\n 279→\n 280→ // Send auth success\n 281→ let ack = ServerMessage::AuthAck(AuthAckPayload {\n 282→ success: true,\n 283→ agent_id: Some(result.agent_id),\n 284→ error: None,\n 285→ });\n 286→\n 287→ if let Ok(json) = serde_json::to_string(&ack) {\n 288→ let _ = sender.send(Message::Text(json)).await;\n 289→ }\n 290→\n 291→ // Register connection\n 292→ state.agents.write().await.add(result.agent_id, tx.clone());\n 293→\n 294→ // Update agent status\n 295→ let _ = db::update_agent_status(&state.db, result.agent_id, \"online\").await;\n 296→\n 297→ // Check if this is a post-update reconnection\n 298→ if let Some(prev_version) = &result.previous_version {\n 299→ if prev_version != &result.agent_version {\n 300→ info!(\n 301→ \"Agent {} reconnected after update: {} -> {}\",\n 302→ result.agent_id, prev_version, result.agent_version\n 303→ );\n 304→ // Mark update as completed\n 305→ let _ = db::complete_update_by_agent(\n 306→ &state.db,\n 307→ result.agent_id,\n 308→ result.pending_update_id,\n 309→ prev_version,\n 310→ &result.agent_version,\n 311→ ).await;\n 312→ }\n 313→ }\n 314→\n 315→ // Check if agent needs update (auto-update enabled)\n 316→ if let Some(available) = state.updates.needs_update(\n 317→ &result.agent_version,\n 318→ &result.os_type,\n 319→ &result.architecture,\n 320→ ).await {\n 321→ info!(\n 322→ \"Agent {} needs update: {} -> {}\",\n 323→ result.agent_id, result.agent_version, available.version\n 324→ );\n 325→\n 326→ let update_id = Uuid::new_v4();\n 327→\n 328→ // Record update in database\n 329→ if let Err(e) = db::create_agent_update(\n 330→ &state.db,\n 331→ result.agent_id,\n 332→ update_id,\n 333→ &result.agent_version,\n 334→ &available.version.to_string(),\n 335→ &available.download_url,\n 336→ &available.checksum_sha256,\n 337→ ).await {\n 338→ error!(\"Failed to record update: {}\", e);\n 339→ } else {\n 340→ // Send update command\n 341→ let update_msg = ServerMessage::Update(UpdatePayload {\n 342→ update_id,\n 343→ target_version: available.version.to_string(),\n 344→ download_url: available.download_url.clone(),\n 345→ checksum_sha256: available.checksum_sha256.clone(),\n 346→ force: false,\n 347→ });\n 348→\n 349→ if let Err(e) = tx.send(update_msg).await {\n 350→ error!(\"Failed to send update command: {}\", e);\n 351→ }\n 352→ }\n 353→ }\n 354→\n 355→ result\n 356→ }\n 357→ Err(e) => {\n 358→ error!(\"Authentication failed: {}\", e);\n 359→\n 360→ let ack = ServerMessage::AuthAck(AuthAckPayload {\n 361→ success: false,\n 362→ agent_id: None,\n 363→ error: Some(e.to_string()),\n 364→ });\n 365→\n 366→ if let Ok(json) = serde_json::to_string(&ack) {\n 367→ let _ = sender.send(Message::Text(json)).await;\n 368→ }\n 369→\n 370→ return;\n 371→ }\n 372→ };\n 373→\n 374→ let agent_id = auth_result.agent_id;\n 375→\n 376→ // Spawn task to forward outgoing messages\n 377→ let send_task = tokio::spawn(async move {\n 378→ while let Some(msg) = rx.recv().await {\n 379→ if let Ok(json) = serde_json::to_string(&msg) {\n 380→ if sender.send(Message::Text(json)).await.is_err() {\n 381→ break;\n 382→ }\n 383→ }\n 384→ }\n 385→ });\n 386→\n 387→ // Handle incoming messages\n 388→ while let Some(msg_result) = receiver.next().await {\n 389→ match msg_result {\n 390→ Ok(Message::Text(text)) => {\n 391→ if let Err(e) = handle_agent_message(&text, agent_id, &state).await {\n 392→ error!(\"Error handling agent message: {}\", e);\n 393→ }\n 394→ }\n 395→ Ok(Message::Ping(data)) => {\n 396→ if tx\n 397→ .send(ServerMessage::Ack { message_id: None })\n 398→ .await\n 399→ .is_err()\n 400→ {\n 401→ break;\n 402→ }\n 403→ }\n 404→ Ok(Message::Close(_)) => {\n 405→ info!(\"Agent {} disconnected\", agent_id);\n 406→ break;\n 407→ }\n 408→ Err(e) => {\n 409→ error!(\"WebSocket error for agent {}: {}\", agent_id, e);\n 410→ break;\n 411→ }\n 412→ _ => {}\n 413→ }\n 414→ }\n 415→\n 416→ // Cleanup\n 417→ state.agents.write().await.remove(&agent_id);\n 418→ let _ = db::update_agent_status(&state.db, agent_id, \"offline\").await;\n 419→ send_task.abort();\n 420→\n 421→ info!(\"Agent {} connection closed\", agent_id);\n 422→}\n 423→\n 424→/// Authenticate an agent connection\n 425→///\n 426→/// Supports two modes:\n 427→/// 1. Legacy: API key maps directly to an agent (api_key_hash in agents table)\n 428→/// 2. Site-based: API key maps to a site, device_id identifies the specific agent\n 429→async fn authenticate(\n 430→ receiver: &mut futures_util::stream::SplitStream<WebSocket>,\n 431→ sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,\n 432→ state: &AppState,\n 433→) -> anyhow::Result<AuthResult> {\n 434→ use tokio::time::{timeout, Duration};\n 435→\n 436→ // Wait for auth message with timeout\n 437→ let msg = timeout(Duration::from_secs(10), receiver.next())\n 438→ .await\n 439→ .map_err(|_| anyhow::anyhow!(\"Authentication timeout\"))?\n 440→ .ok_or_else(|| anyhow::anyhow!(\"Connection closed before auth\"))?\n 441→ .map_err(|e| anyhow::anyhow!(\"WebSocket error: {}\", e))?;\n 442→\n 443→ let text = match msg {\n 444→ Message::Text(t) => t,\n 445→ _ => return Err(anyhow::anyhow!(\"Expected text message for auth\")),\n 446→ };\n 447→\n 448→ let agent_msg: AgentMessage =\n 449→ serde_json::from_str(&text).map_err(|e| anyhow::anyhow!(\"Invalid auth message: {}\", e))?;\n 450→\n 451→ let auth = match agent_msg {\n 452→ AgentMessage::Auth(a) => a,\n 453→ _ => return Err(anyhow::anyhow!(\"Expected auth message\")),\n 454→ };\n 455→\n 456→ // Try site-based authentication first (if device_id is provided)\n 457→ if let Some(device_id) = &auth.device_id {\n 458→ // Check if api_key looks like a site code (WORD-WORD-NUMBER format)\n 459→ let site = if is_site_code_format(&auth.api_key) {\n 460→ info!(\"Attempting site code authentication: {}\", auth.api_key);\n 461→ db::get_site_by_code(&state.db, &auth.api_key)\n 462→ .await\n 463→ .map_err(|e| anyhow::anyhow!(\"Database error: {}\", e))?\n 464→ } else {\n 465→ // Hash the API key and look up by hash\n 466→ let api_key_hash = hash_api_key(&auth.api_key);\n 467→ db::get_site_by_api_key_hash(&state.db, &api_key_hash)\n 468→ .await\n 469→ .map_err(|e| anyhow::anyhow!(\"Database error: {}\", e))?\n 470→ };\n 471→\n 472→ if let Some(site) = site {\n 473→ info!(\"Site-based auth: site={} ({})\", site.name, site.id);\n 474→\n 475→ // Look up or create agent by site_id + device_id\n 476→ let agent = match db::get_agent_by_site_and_device(&state.db, site.id, device_id)\n 477→ .await\n 478→ .map_err(|e| anyhow::anyhow!(\"Database error: {}\", e))?\n 479→ {\n 480→ Some(agent) => {\n 481→ // Update existing agent info\n 482→ let _ = db::update_agent_info_full(\n 483→ &state.db,\n 484→ agent.id,\n 485→ Some(&auth.hostname),\n 486→ Some(device_id),\n 487→ Some(&auth.os_version),\n 488→ Some(&auth.agent_version),\n 489→ )\n 490→ .await;\n 491→ agent\n 492→ }\n 493→ None => {\n 494→ // Auto-register new agent under this site\n 495→ info!(\n 496→ \"Auto-registering new agent: hostname={}, device_id={}, site={}\",\n 497→ auth.hostname, device_id, site.name\n 498→ );\n 499→\n 500→ db::create_agent_with_site(\n 501→ &state.db,\n 502→ db::CreateAgentWithSite {\n 503→ site_id: site.id,\n 504→ device_id: device_id.clone(),\n 505→ hostname: auth.hostname.clone(),\n 506→ os_type: auth.os_type.clone(),\n 507→ os_version: Some(auth.os_version.clone()),\n 508→ agent_version: Some(auth.agent_version.clone()),\n 509→ },\n 510→ )\n 511→ .await\n 512→ .map_err(|e| anyhow::anyhow!(\"Failed to create agent: {}\", e))?\n 513→ }\n 514→ };\n 515→\n 516→ return Ok(AuthResult {\n 517→ agent_id: agent.id,\n 518→ agent_version: auth.agent_version.clone(),\n 519→ os_type: auth.os_type.clone(),\n 520→ architecture: auth.architecture.clone(),\n 521→ previous_version: auth.previous_version.clone(),\n 522→ pending_update_id: auth.pending_update_id,\n 523→ });\n 524→ }\n 525→ }\n 526→\n 527→ // Fall back to legacy: look up agent directly by API key hash\n 528→ let api_key_hash = hash_api_key(&auth.api_key);\n 529→ let agent = db::get_agent_by_api_key_hash(&state.db, &api_key_hash)\n 530→ .await\n 531→ .map_err(|e| anyhow::anyhow!(\"Database error: {}\", e))?\n 532→ .ok_or_else(|| anyhow::anyhow!(\"Invalid API key\"))?;\n 533→\n 534→ // Update agent info (including hostname in case it changed)\n 535→ let _ = db::update_agent_info(\n 536→ &state.db,\n 537→ agent.id,\n 538→ Some(&auth.hostname),\n 539→ Some(&auth.os_version),\n 540→ Some(&auth.agent_version),\n 541→ )\n 542→ .await;\n 543→\n 544→ Ok(AuthResult {\n 545→ agent_id: agent.id,\n 546→ agent_version: auth.agent_version,\n 547→ os_type: auth.os_type,\n 548→ architecture: auth.architecture,\n 549→ previous_version: auth.previous_version,\n 550→ pending_update_id: auth.pending_update_id,\n 551→ })\n 552→}\n 553→\n 554→/// Handle a message from an authenticated agent\n 555→async fn handle_agent_message(\n 556→ text: &str,\n 557→ agent_id: Uuid,\n 558→ state: &AppState,\n 559→) -> anyhow::Result<()> {\n 560→ let msg: AgentMessage = serde_json::from_str(text)?;\n 561→\n 562→ match msg {\n 563→ AgentMessage::Metrics(metrics) => {\n 564→ debug!(\"Received metrics from agent {}: CPU={:.1}%\", agent_id, metrics.cpu_percent);\n 565→\n 566→ // Store metrics in database\n 567→ let create_metrics = db::CreateMetrics {\n 568→ agent_id,\n 569→ cpu_percent: Some(metrics.cpu_percent),\n 570→ memory_percent: Some(metrics.memory_percent),\n 571→ memory_used_bytes: Some(metrics.memory_used_bytes as i64),\n 572→ disk_percent: Some(metrics.disk_percent),\n 573→ disk_used_bytes: Some(metrics.disk_used_bytes as i64),\n 574→ network_rx_bytes: Some(metrics.network_rx_bytes as i64),\n 575→ network_tx_bytes: Some(metrics.network_tx_bytes as i64),\n 576→ // Extended metrics\n 577→ uptime_seconds: metrics.uptime_seconds.map(|v| v as i64),\n 578→ boot_time: metrics.boot_time,\n 579→ logged_in_user: metrics.logged_in_user.clone(),\n 580→ user_idle_seconds: metrics.user_idle_seconds.map(|v| v as i64),\n 581→ public_ip: metrics.public_ip.clone(),\n 582→ memory_total_bytes: Some(metrics.memory_total_bytes as i64),\n 583→ disk_total_bytes: Some(metrics.disk_total_bytes as i64),\n 584→ // Temperature metrics\n 585→ cpu_temp_celsius: metrics.cpu_temp_celsius,\n 586→ gpu_temp_celsius: metrics.gpu_temp_celsius,\n 587→ temperatures: metrics.temperatures.as_ref().map(|t| serde_json::to_value(t).ok()).flatten(),\n 588→ };\n 589→\n 590→ db::insert_metrics(&state.db, create_metrics).await?;\n 591→\n 592→ // Also update agent_state for quick access to latest extended info\n 593→ let _ = db::upsert_agent_state(\n 594→ &state.db,\n 595→ agent_id,\n 596→ metrics.uptime_seconds.map(|v| v as i64),\n 597→ metrics.boot_time,\n 598→ metrics.logged_in_user.as_deref(),\n 599→ metrics.user_idle_seconds.map(|v| v as i64),\n 600→ metrics.public_ip.as_deref(),\n 601→ ).await;\n 602→\n 603→ // Update last_seen\n 604→ db::update_agent_status(&state.db, agent_id, \"online\").await?;\n 605→ }\n 606→\n 607→ AgentMessage::CommandResult(result) => {\n 608→ info!(\n 609→ \"Received command result from agent {}: command={}, exit={}\",\n 610→ agent_id, result.command_id, result.exit_code\n 611→ );\n 612→\n 613→ // Update command in database\n 614→ let cmd_result = db::CommandResult {\n 615→ exit_code: result.exit_code,\n 616→ stdout: result.stdout,\n 617→ stderr: result.stderr,\n 618→ };\n 619→\n 620→ db::update_command_result(&state.db, result.command_id, cmd_result).await?;\n 621→ }\n 622→\n 623→ AgentMessage::WatchdogEvent(event) => {\n 624→ info!(\n 625→ \"Received watchdog event from agent {}: {} - {}\",\n 626→ agent_id, event.name, event.event\n 627→ );\n 628→\n 629→ // Store watchdog event (table exists but we'll add the insert function later)\n 630→ // For now, just log it\n 631→ }\n 632→\n 633→ AgentMessage::NetworkState(network_state) => {\n 634→ debug!(\n 635→ \"Received network state from agent {}: {} interfaces\",\n 636→ agent_id,\n 637→ network_state.interfaces.len()\n 638→ );\n 639→ // Log interface details at trace level\n 640→ for iface in &network_state.interfaces {\n 641→ tracing::trace!(\n 642→ \" Interface {}: IPv4={:?}, IPv6={:?}\",\n 643→ iface.name,\n 644→ iface.ipv4_addresses,\n 645→ iface.ipv6_addresses\n 646→ );\n 647→ }\n 648→ // Store network state in database\n 649→ if let Ok(interfaces_json) = serde_json::to_value(&network_state.interfaces) {\n 650→ let _ = db::update_agent_network_state(\n 651→ &state.db,\n 652→ agent_id,\n 653→ &interfaces_json,\n 654→ &network_state.state_hash,\n 655→ ).await;\n 656→ }\n 657→ // Update last_seen\n 658→ db::update_agent_status(&state.db, agent_id, \"online\").await?;\n 659→ }\n 660→\n 661→ AgentMessage::Heartbeat => {\n 662→ debug!(\"Received heartbeat from agent {}\", agent_id);\n 663→ db::update_agent_status(&state.db, agent_id, \"online\").await?;\n 664→ }\n 665→\n 666→ AgentMessage::UpdateResult(result) => {\n 667→ info!(\n 668→ \"Received update result from agent {}: update_id={}, status={}\",\n 669→ agent_id, result.update_id, result.status\n 670→ );\n 671→\n 672→ // Update the agent_updates record\n 673→ match result.status.as_str() {\n 674→ \"completed\" => {\n 675→ let _ = db::complete_agent_update(&state.db, result.update_id, result.new_version.as_deref()).await;\n 676→ info!(\"Agent {} successfully updated to {}\", agent_id, result.new_version.unwrap_or_default());\n 677→ }\n 678→ \"failed\" | \"rolled_back\" => {\n 679→ let _ = db::fail_agent_update(&state.db, result.update_id, result.error.as_deref()).await;\n 680→ warn!(\"Agent {} update failed: {}\", agent_id, result.error.unwrap_or_default());\n 681→ }\n 682→ _ => {\n 683→ debug!(\"Agent {} update status: {}\", agent_id, result.status);\n 684→ }\n 685→ }\n 686→ }\n 687→\n 688→ AgentMessage::Auth(_) => {\n 689→ warn!(\"Received unexpected auth message from already authenticated agent\");\n 690→ }\n 691→ }\n 692→\n 693→ Ok(())\n 694→}\n 695→\n 696→/// Hash an API key for storage/lookup\n 697→pub fn hash_api_key(api_key: &str) -> String {\n 698→ use sha2::{Digest, Sha256};\n 699→ let mut hasher = Sha256::new();\n 700→ hasher.update(api_key.as_bytes());\n 701→ format!(\"{:x}\", hasher.finalize())\n 702→}\n 703→\n 704→/// Generate a new API key\n 705→pub fn generate_api_key(prefix: &str) -> String {\n 706→ use rand::Rng;\n 707→ let random_bytes: [u8; 24] = rand::thread_rng().gen();\n 708→ let encoded = base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, random_bytes);\n 709→ format!(\"{}{}\", prefix, encoded)\n 710→}\n 711→\n 712→/// Check if a string looks like a site code (WORD-WORD-NUMBER format)\n 713→/// Examples: SWIFT-CLOUD-6910, APPLE-GREEN-9145\n 714→fn is_site_code_format(s: &str) -> bool {\n 715→ let parts: Vec<&str> = s.split('-').collect();\n 716→ if parts.len() != 3 {\n 717→ return false;\n 718→ }\n 719→ // First two parts should be alphabetic (words)\n 720→ // Third part should be numeric (4 digits)\n 721→ parts[0].chars().all(|c| c.is_ascii_alphabetic())\n 722→ && parts[1].chars().all(|c| c.is_ascii_alphabetic())\n 723→ && parts[2].chars().all(|c| c.is_ascii_digit())\n 724→ && parts[2].len() == 4\n 725→}\n 726→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"5fd9ca29-4b9f-412b-b4e2-63600098f84a","timestamp":"2025-12-21T21:34:46.677Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs","content":"//! WebSocket handler for agent connections\n//!\n//! Handles real-time communication with agents including:\n//! - Authentication handshake\n//! - Metrics ingestion\n//! - Command dispatching\n//! - Watchdog event handling\n\nuse std::collections::HashMap;\nuse std::sync::Arc;\n\nuse axum::{\n extract::{\n ws::{Message, WebSocket},\n State, WebSocketUpgrade,\n },\n response::Response,\n};\nuse futures_util::{SinkExt, StreamExt};\nuse serde::{Deserialize, Serialize};\nuse tokio::sync::{mpsc, RwLock};\nuse tracing::{debug, error, info, warn};\nuse uuid::Uuid;\n\nuse crate::db;\nuse crate::AppState;\n\n/// Connected agents manager\npub struct AgentConnections {\n /// Map of agent ID to sender channel\n connections: HashMap<Uuid, mpsc::Sender<ServerMessage>>,\n}\n\nimpl AgentConnections {\n pub fn new() -> Self {\n Self {\n connections: HashMap::new(),\n }\n }\n\n /// Add a new agent connection\n pub fn add(&mut self, agent_id: Uuid, tx: mpsc::Sender<ServerMessage>) {\n self.connections.insert(agent_id, tx);\n }\n\n /// Remove an agent connection\n pub fn remove(&mut self, agent_id: &Uuid) {\n self.connections.remove(agent_id);\n }\n\n /// Send a message to a specific agent\n pub async fn send_to(&self, agent_id: &Uuid, msg: ServerMessage) -> bool {\n if let Some(tx) = self.connections.get(agent_id) {\n tx.send(msg).await.is_ok()\n } else {\n false\n }\n }\n\n /// Check if an agent is connected\n pub fn is_connected(&self, agent_id: &Uuid) -> bool {\n self.connections.contains_key(agent_id)\n }\n\n /// Get count of connected agents\n pub fn count(&self) -> usize {\n self.connections.len()\n }\n}\n\nimpl Default for AgentConnections {\n fn default() -> Self {\n Self::new()\n }\n}\n\n/// Messages from agent to server\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\", content = \"payload\")]\n#[serde(rename_all = \"snake_case\")]\npub enum AgentMessage {\n Auth(AuthPayload),\n Metrics(MetricsPayload),\n NetworkState(NetworkStatePayload),\n CommandResult(CommandResultPayload),\n WatchdogEvent(WatchdogEventPayload),\n UpdateResult(UpdateResultPayload),\n Heartbeat,\n}\n\n/// Messages from server to agent\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\", content = \"payload\")]\n#[serde(rename_all = \"snake_case\")]\npub enum ServerMessage {\n AuthAck(AuthAckPayload),\n Command(CommandPayload),\n ConfigUpdate(serde_json::Value),\n Update(UpdatePayload),\n Ack { message_id: Option<String> },\n Error { code: String, message: String },\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AuthPayload {\n pub api_key: String,\n /// Hardware-derived device ID (for site-based registration)\n #[serde(default)]\n pub device_id: Option<String>,\n pub hostname: String,\n pub os_type: String,\n pub os_version: String,\n pub agent_version: String,\n /// Architecture (amd64, arm64, etc.)\n #[serde(default = \"default_arch\")]\n pub architecture: String,\n /// Previous version if reconnecting after update\n #[serde(default)]\n pub previous_version: Option<String>,\n /// Update ID if reconnecting after update\n #[serde(default)]\n pub pending_update_id: Option<Uuid>,\n}\n\nfn default_arch() -> String {\n \"amd64\".to_string()\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AuthAckPayload {\n pub success: bool,\n pub agent_id: Option<Uuid>,\n pub error: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MetricsPayload {\n pub timestamp: chrono::DateTime<chrono::Utc>,\n pub cpu_percent: f32,\n pub memory_percent: f32,\n pub memory_used_bytes: u64,\n pub memory_total_bytes: u64,\n pub disk_percent: f32,\n pub disk_used_bytes: u64,\n pub disk_total_bytes: u64,\n pub network_rx_bytes: u64,\n pub network_tx_bytes: u64,\n pub os_type: String,\n pub os_version: String,\n pub hostname: String,\n // Extended metrics (optional for backwards compatibility)\n #[serde(default)]\n pub uptime_seconds: Option<u64>,\n #[serde(default)]\n pub boot_time: Option<i64>,\n #[serde(default)]\n pub logged_in_user: Option<String>,\n #[serde(default)]\n pub user_idle_seconds: Option<u64>,\n #[serde(default)]\n pub public_ip: Option<String>,\n // Temperature metrics (optional for backwards compatibility)\n #[serde(default)]\n pub cpu_temp_celsius: Option<f32>,\n #[serde(default)]\n pub gpu_temp_celsius: Option<f32>,\n #[serde(default)]\n pub temperatures: Option<Vec<TemperatureSensor>>,\n}\n\n/// Temperature sensor reading from agent\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TemperatureSensor {\n pub label: String,\n pub temp_celsius: f32,\n #[serde(default)]\n pub critical_celsius: Option<f32>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CommandResultPayload {\n pub command_id: Uuid,\n pub exit_code: i32,\n pub stdout: String,\n pub stderr: String,\n pub duration_ms: u64,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct WatchdogEventPayload {\n pub name: String,\n pub event: String,\n pub details: Option<String>,\n}\n\n/// Network interface information\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct NetworkInterface {\n pub name: String,\n pub mac_address: Option<String>,\n pub ipv4_addresses: Vec<String>,\n pub ipv6_addresses: Vec<String>,\n}\n\n/// Network state payload from agent\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct NetworkStatePayload {\n pub timestamp: chrono::DateTime<chrono::Utc>,\n pub interfaces: Vec<NetworkInterface>,\n pub state_hash: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CommandPayload {\n pub id: Uuid,\n pub command_type: String,\n pub command: String,\n pub timeout_seconds: Option<u64>,\n pub elevated: bool,\n}\n\n/// Update command payload from server to agent\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdatePayload {\n /// Unique update ID for tracking\n pub update_id: Uuid,\n /// Target version to update to\n pub target_version: String,\n /// Download URL for the new binary\n pub download_url: String,\n /// SHA256 checksum of the binary\n pub checksum_sha256: String,\n /// Whether to force update (skip version check)\n #[serde(default)]\n pub force: bool,\n}\n\n/// Update result payload from agent to server\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdateResultPayload {\n /// Update ID (from the server)\n pub update_id: Uuid,\n /// Update status\n pub status: String,\n /// Old version before update\n pub old_version: String,\n /// New version after update (if successful)\n pub new_version: Option<String>,\n /// Error message if failed\n pub error: Option<String>,\n}\n\n/// Result of successful agent authentication\nstruct AuthResult {\n agent_id: Uuid,\n agent_version: String,\n os_type: String,\n architecture: String,\n previous_version: Option<String>,\n pending_update_id: Option<Uuid>,\n}\n\n/// WebSocket upgrade handler\npub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {\n ws.on_upgrade(|socket| handle_socket(socket, state))\n}\n\n/// Handle a WebSocket connection\nasync fn handle_socket(socket: WebSocket, state: AppState) {\n let (mut sender, mut receiver) = socket.split();\n\n // Create channel for outgoing messages\n let (tx, mut rx) = mpsc::channel::<ServerMessage>(100);\n\n // Wait for authentication message\n let auth_result = match authenticate(&mut receiver, &mut sender, &state).await {\n Ok(result) => {\n info!(\"Agent authenticated: {}\", result.agent_id);\n\n // Send auth success\n let ack = ServerMessage::AuthAck(AuthAckPayload {\n success: true,\n agent_id: Some(result.agent_id),\n error: None,\n });\n\n if let Ok(json) = serde_json::to_string(&ack) {\n let _ = sender.send(Message::Text(json)).await;\n }\n\n // Register connection\n state.agents.write().await.add(result.agent_id, tx.clone());\n\n // Update agent status\n let _ = db::update_agent_status(&state.db, result.agent_id, \"online\").await;\n\n // Check if this is a post-update reconnection\n if let Some(prev_version) = &result.previous_version {\n if prev_version != &result.agent_version {\n info!(\n \"Agent {} reconnected after update: {} -> {}\",\n result.agent_id, prev_version, result.agent_version\n );\n // Mark update as completed\n let _ = db::complete_update_by_agent(\n &state.db,\n result.agent_id,\n result.pending_update_id,\n prev_version,\n &result.agent_version,\n ).await;\n }\n }\n\n // Check if agent needs update (auto-update enabled)\n if let Some(available) = state.updates.needs_update(\n &result.agent_version,\n &result.os_type,\n &result.architecture,\n ).await {\n info!(\n \"Agent {} needs update: {} -> {}\",\n result.agent_id, result.agent_version, available.version\n );\n\n let update_id = Uuid::new_v4();\n\n // Record update in database\n if let Err(e) = db::create_agent_update(\n &state.db,\n result.agent_id,\n update_id,\n &result.agent_version,\n &available.version.to_string(),\n &available.download_url,\n &available.checksum_sha256,\n ).await {\n error!(\"Failed to record update: {}\", e);\n } else {\n // Send update command\n let update_msg = ServerMessage::Update(UpdatePayload {\n update_id,\n target_version: available.version.to_string(),\n download_url: available.download_url.clone(),\n checksum_sha256: available.checksum_sha256.clone(),\n force: false,\n });\n\n if let Err(e) = tx.send(update_msg).await {\n error!(\"Failed to send update command: {}\", e);\n }\n }\n }\n\n result\n }\n Err(e) => {\n error!(\"Authentication failed: {}\", e);\n\n let ack = ServerMessage::AuthAck(AuthAckPayload {\n success: false,\n agent_id: None,\n error: Some(e.to_string()),\n });\n\n if let Ok(json) = serde_json::to_string(&ack) {\n let _ = sender.send(Message::Text(json)).await;\n }\n\n return;\n }\n };\n\n let agent_id = auth_result.agent_id;\n\n // Spawn task to forward outgoing messages\n let send_task = tokio::spawn(async move {\n while let Some(msg) = rx.recv().await {\n if let Ok(json) = serde_json::to_string(&msg) {\n if sender.send(Message::Text(json)).await.is_err() {\n break;\n }\n }\n }\n });\n\n // Handle incoming messages\n while let Some(msg_result) = receiver.next().await {\n match msg_result {\n Ok(Message::Text(text)) => {\n if let Err(e) = handle_agent_message(&text, agent_id, &state).await {\n error!(\"Error handling agent message: {}\", e);\n }\n }\n Ok(Message::Ping(data)) => {\n if tx\n .send(ServerMessage::Ack { message_id: None })\n .await\n .is_err()\n {\n break;\n }\n }\n Ok(Message::Close(_)) => {\n info!(\"Agent {} disconnected\", agent_id);\n break;\n }\n Err(e) => {\n error!(\"WebSocket error for agent {}: {}\", agent_id, e);\n break;\n }\n _ => {}\n }\n }\n\n // Cleanup\n state.agents.write().await.remove(&agent_id);\n let _ = db::update_agent_status(&state.db, agent_id, \"offline\").await;\n send_task.abort();\n\n info!(\"Agent {} connection closed\", agent_id);\n}\n\n/// Authenticate an agent connection\n///\n/// Supports two modes:\n/// 1. Legacy: API key maps directly to an agent (api_key_hash in agents table)\n/// 2. Site-based: API key maps to a site, device_id identifies the specific agent\nasync fn authenticate(\n receiver: &mut futures_util::stream::SplitStream<WebSocket>,\n sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,\n state: &AppState,\n) -> anyhow::Result<AuthResult> {\n use tokio::time::{timeout, Duration};\n\n // Wait for auth message with timeout\n let msg = timeout(Duration::from_secs(10), receiver.next())\n .await\n .map_err(|_| anyhow::anyhow!(\"Authentication timeout\"))?\n .ok_or_else(|| anyhow::anyhow!(\"Connection closed before auth\"))?\n .map_err(|e| anyhow::anyhow!(\"WebSocket error: {}\", e))?;\n\n let text = match msg {\n Message::Text(t) => t,\n _ => return Err(anyhow::anyhow!(\"Expected text message for auth\")),\n };\n\n let agent_msg: AgentMessage =\n serde_json::from_str(&text).map_err(|e| anyhow::anyhow!(\"Invalid auth message: {}\", e))?;\n\n let auth = match agent_msg {\n AgentMessage::Auth(a) => a,\n _ => return Err(anyhow::anyhow!(\"Expected auth message\")),\n };\n\n // Try site-based authentication first (if device_id is provided)\n if let Some(device_id) = &auth.device_id {\n // Check if api_key looks like a site code (WORD-WORD-NUMBER format)\n let site = if is_site_code_format(&auth.api_key) {\n info!(\"Attempting site code authentication: {}\", auth.api_key);\n db::get_site_by_code(&state.db, &auth.api_key)\n .await\n .map_err(|e| anyhow::anyhow!(\"Database error: {}\", e))?\n } else {\n // Hash the API key and look up by hash\n let api_key_hash = hash_api_key(&auth.api_key);\n db::get_site_by_api_key_hash(&state.db, &api_key_hash)\n .await\n .map_err(|e| anyhow::anyhow!(\"Database error: {}\", e))?\n };\n\n if let Some(site) = site {\n info!(\"Site-based auth: site={} ({})\", site.name, site.id);\n\n // Look up or create agent by site_id + device_id\n let agent = match db::get_agent_by_site_and_device(&state.db, site.id, device_id)\n .await\n .map_err(|e| anyhow::anyhow!(\"Database error: {}\", e))?\n {\n Some(agent) => {\n // Update existing agent info\n let _ = db::update_agent_info_full(\n &state.db,\n agent.id,\n Some(&auth.hostname),\n Some(device_id),\n Some(&auth.os_version),\n Some(&auth.agent_version),\n )\n .await;\n agent\n }\n None => {\n // Auto-register new agent under this site\n info!(\n \"Auto-registering new agent: hostname={}, device_id={}, site={}\",\n auth.hostname, device_id, site.name\n );\n\n db::create_agent_with_site(\n &state.db,\n db::CreateAgentWithSite {\n site_id: site.id,\n device_id: device_id.clone(),\n hostname: auth.hostname.clone(),\n os_type: auth.os_type.clone(),\n os_version: Some(auth.os_version.clone()),\n agent_version: Some(auth.agent_version.clone()),\n },\n )\n .await\n .map_err(|e| anyhow::anyhow!(\"Failed to create agent: {}\", e))?\n }\n };\n\n return Ok(AuthResult {\n agent_id: agent.id,\n agent_version: auth.agent_version.clone(),\n os_type: auth.os_type.clone(),\n architecture: auth.architecture.clone(),\n previous_version: auth.previous_version.clone(),\n pending_update_id: auth.pending_update_id,\n });\n }\n }\n\n // Fall back to legacy: look up agent directly by API key hash\n let api_key_hash = hash_api_key(&auth.api_key);\n let agent = db::get_agent_by_api_key_hash(&state.db, &api_key_hash)\n .await\n .map_err(|e| anyhow::anyhow!(\"Database error: {}\", e))?\n .ok_or_else(|| anyhow::anyhow!(\"Invalid API key\"))?;\n\n // Update agent info (including hostname in case it changed)\n let _ = db::update_agent_info(\n &state.db,\n agent.id,\n Some(&auth.hostname),\n Some(&auth.os_version),\n Some(&auth.agent_version),\n )\n .await;\n\n Ok(AuthResult {\n agent_id: agent.id,\n agent_version: auth.agent_version,\n os_type: auth.os_type,\n architecture: auth.architecture,\n previous_version: auth.previous_version,\n pending_update_id: auth.pending_update_id,\n })\n}\n\n/// Handle a message from an authenticated agent\nasync fn handle_agent_message(\n text: &str,\n agent_id: Uuid,\n state: &AppState,\n) -> anyhow::Result<()> {\n let msg: AgentMessage = serde_json::from_str(text)?;\n\n match msg {\n AgentMessage::Metrics(metrics) => {\n debug!(\"Received metrics from agent {}: CPU={:.1}%\", agent_id, metrics.cpu_percent);\n\n // Store metrics in database\n let create_metrics = db::CreateMetrics {\n agent_id,\n cpu_percent: Some(metrics.cpu_percent),\n memory_percent: Some(metrics.memory_percent),\n memory_used_bytes: Some(metrics.memory_used_bytes as i64),\n disk_percent: Some(metrics.disk_percent),\n disk_used_bytes: Some(metrics.disk_used_bytes as i64),\n network_rx_bytes: Some(metrics.network_rx_bytes as i64),\n network_tx_bytes: Some(metrics.network_tx_bytes as i64),\n // Extended metrics\n uptime_seconds: metrics.uptime_seconds.map(|v| v as i64),\n boot_time: metrics.boot_time,\n logged_in_user: metrics.logged_in_user.clone(),\n user_idle_seconds: metrics.user_idle_seconds.map(|v| v as i64),\n public_ip: metrics.public_ip.clone(),\n memory_total_bytes: Some(metrics.memory_total_bytes as i64),\n disk_total_bytes: Some(metrics.disk_total_bytes as i64),\n // Temperature metrics\n cpu_temp_celsius: metrics.cpu_temp_celsius,\n gpu_temp_celsius: metrics.gpu_temp_celsius,\n temperatures: metrics.temperatures.as_ref().map(|t| serde_json::to_value(t).ok()).flatten(),\n };\n\n db::insert_metrics(&state.db, create_metrics).await?;\n\n // Also update agent_state for quick access to latest extended info\n let _ = db::upsert_agent_state(\n &state.db,\n agent_id,\n metrics.uptime_seconds.map(|v| v as i64),\n metrics.boot_time,\n metrics.logged_in_user.as_deref(),\n metrics.user_idle_seconds.map(|v| v as i64),\n metrics.public_ip.as_deref(),\n ).await;\n\n // Update last_seen\n db::update_agent_status(&state.db, agent_id, \"online\").await?;\n }\n\n AgentMessage::CommandResult(result) => {\n info!(\n \"Received command result from agent {}: command={}, exit={}\",\n agent_id, result.command_id, result.exit_code\n );\n\n // Update command in database\n let cmd_result = db::CommandResult {\n exit_code: result.exit_code,\n stdout: result.stdout,\n stderr: result.stderr,\n };\n\n db::update_command_result(&state.db, result.command_id, cmd_result).await?;\n }\n\n AgentMessage::WatchdogEvent(event) => {\n info!(\n \"Received watchdog event from agent {}: {} - {}\",\n agent_id, event.name, event.event\n );\n\n // Store watchdog event (table exists but we'll add the insert function later)\n // For now, just log it\n }\n\n AgentMessage::NetworkState(network_state) => {\n debug!(\n \"Received network state from agent {}: {} interfaces\",\n agent_id,\n network_state.interfaces.len()\n );\n // Log interface details at trace level\n for iface in &network_state.interfaces {\n tracing::trace!(\n \" Interface {}: IPv4={:?}, IPv6={:?}\",\n iface.name,\n iface.ipv4_addresses,\n iface.ipv6_addresses\n );\n }\n // Store network state in database\n if let Ok(interfaces_json) = serde_json::to_value(&network_state.interfaces) {\n let _ = db::update_agent_network_state(\n &state.db,\n agent_id,\n &interfaces_json,\n &network_state.state_hash,\n ).await;\n }\n // Update last_seen\n db::update_agent_status(&state.db, agent_id, \"online\").await?;\n }\n\n AgentMessage::Heartbeat => {\n debug!(\"Received heartbeat from agent {}\", agent_id);\n db::update_agent_status(&state.db, agent_id, \"online\").await?;\n }\n\n AgentMessage::UpdateResult(result) => {\n info!(\n \"Received update result from agent {}: update_id={}, status={}\",\n agent_id, result.update_id, result.status\n );\n\n // Update the agent_updates record\n match result.status.as_str() {\n \"completed\" => {\n let _ = db::complete_agent_update(&state.db, result.update_id, result.new_version.as_deref()).await;\n info!(\"Agent {} successfully updated to {}\", agent_id, result.new_version.unwrap_or_default());\n }\n \"failed\" | \"rolled_back\" => {\n let _ = db::fail_agent_update(&state.db, result.update_id, result.error.as_deref()).await;\n warn!(\"Agent {} update failed: {}\", agent_id, result.error.unwrap_or_default());\n }\n _ => {\n debug!(\"Agent {} update status: {}\", agent_id, result.status);\n }\n }\n }\n\n AgentMessage::Auth(_) => {\n warn!(\"Received unexpected auth message from already authenticated agent\");\n }\n }\n\n Ok(())\n}\n\n/// Hash an API key for storage/lookup\npub fn hash_api_key(api_key: &str) -> String {\n use sha2::{Digest, Sha256};\n let mut hasher = Sha256::new();\n hasher.update(api_key.as_bytes());\n format!(\"{:x}\", hasher.finalize())\n}\n\n/// Generate a new API key\npub fn generate_api_key(prefix: &str) -> String {\n use rand::Rng;\n let random_bytes: [u8; 24] = rand::thread_rng().gen();\n let encoded = base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, random_bytes);\n format!(\"{}{}\", prefix, encoded)\n}\n\n/// Check if a string looks like a site code (WORD-WORD-NUMBER format)\n/// Examples: SWIFT-CLOUD-6910, APPLE-GREEN-9145\nfn is_site_code_format(s: &str) -> bool {\n let parts: Vec<&str> = s.split('-').collect();\n if parts.len() != 3 {\n return false;\n }\n // First two parts should be alphabetic (words)\n // Third part should be numeric (4 digits)\n parts[0].chars().all(|c| c.is_ascii_alphabetic())\n && parts[1].chars().all(|c| c.is_ascii_alphabetic())\n && parts[2].chars().all(|c| c.is_ascii_digit())\n && parts[2].len() == 4\n}\n","numLines":726,"startLine":1,"totalLines":726}}}
|
||
{"parentUuid":"5fd9ca29-4b9f-412b-b4e2-63600098f84a","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TsmCeEdSPzQT1y9Fozn15u","type":"tool_result","content":" 1→//! Agent configuration handling\n 2→//!\n 3→//! Configuration is loaded from a TOML file (default: agent.toml).\n 4→//! The config file defines server connection, metrics collection,\n 5→//! and watchdog settings.\n 6→\n 7→use anyhow::{Context, Result};\n 8→use serde::{Deserialize, Serialize};\n 9→use std::path::Path;\n 10→\n 11→/// Root configuration structure\n 12→#[derive(Debug, Clone, Serialize, Deserialize)]\n 13→pub struct AgentConfig {\n 14→ /// Server connection settings\n 15→ pub server: ServerConfig,\n 16→\n 17→ /// Metrics collection settings\n 18→ #[serde(default)]\n 19→ pub metrics: MetricsConfig,\n 20→\n 21→ /// Watchdog settings for monitoring services/processes\n 22→ #[serde(default)]\n 23→ pub watchdog: WatchdogConfig,\n 24→}\n 25→\n 26→/// Server connection configuration\n 27→#[derive(Debug, Clone, Serialize, Deserialize)]\n 28→pub struct ServerConfig {\n 29→ /// WebSocket URL for the GuruRMM server (e.g., wss://rmm.example.com/ws)\n 30→ pub url: String,\n 31→\n 32→ /// API key for authentication (obtained from server during registration)\n 33→ pub api_key: String,\n 34→\n 35→ /// Optional custom hostname to report (defaults to system hostname)\n 36→ pub hostname_override: Option<String>,\n 37→}\n 38→\n 39→/// Metrics collection configuration\n 40→#[derive(Debug, Clone, Serialize, Deserialize)]\n 41→pub struct MetricsConfig {\n 42→ /// Interval in seconds between metrics collection (default: 60)\n 43→ #[serde(default = \"default_metrics_interval\")]\n 44→ pub interval_seconds: u64,\n 45→\n 46→ /// Whether to collect CPU metrics\n 47→ #[serde(default = \"default_true\")]\n 48→ pub collect_cpu: bool,\n 49→\n 50→ /// Whether to collect memory metrics\n 51→ #[serde(default = \"default_true\")]\n 52→ pub collect_memory: bool,\n 53→\n 54→ /// Whether to collect disk metrics\n 55→ #[serde(default = \"default_true\")]\n 56→ pub collect_disk: bool,\n 57→\n 58→ /// Whether to collect network metrics\n 59→ #[serde(default = \"default_true\")]\n 60→ pub collect_network: bool,\n 61→}\n 62→\n 63→impl Default for MetricsConfig {\n 64→ fn default() -> Self {\n 65→ Self {\n 66→ interval_seconds: 60,\n 67→ collect_cpu: true,\n 68→ collect_memory: true,\n 69→ collect_disk: true,\n 70→ collect_network: true,\n 71→ }\n 72→ }\n 73→}\n 74→\n 75→/// Watchdog configuration for service/process monitoring\n 76→#[derive(Debug, Clone, Serialize, Deserialize)]\n 77→pub struct WatchdogConfig {\n 78→ /// Enable/disable watchdog functionality\n 79→ #[serde(default)]\n 80→ pub enabled: bool,\n 81→\n 82→ /// Interval in seconds between watchdog checks (default: 30)\n 83→ #[serde(default = \"default_watchdog_interval\")]\n 84→ pub check_interval_seconds: u64,\n 85→\n 86→ /// List of Windows/systemd services to monitor\n 87→ #[serde(default)]\n 88→ pub services: Vec<ServiceWatch>,\n 89→\n 90→ /// List of processes to monitor\n 91→ #[serde(default)]\n 92→ pub processes: Vec<ProcessWatch>,\n 93→}\n 94→\n 95→impl Default for WatchdogConfig {\n 96→ fn default() -> Self {\n 97→ Self {\n 98→ enabled: false,\n 99→ check_interval_seconds: 30,\n 100→ services: Vec::new(),\n 101→ processes: Vec::new(),\n 102→ }\n 103→ }\n 104→}\n 105→\n 106→/// Configuration for monitoring a service\n 107→#[derive(Debug, Clone, Serialize, Deserialize)]\n 108→pub struct ServiceWatch {\n 109→ /// Service name (e.g., \"CagService\" for Datto RMM, \"Syncro\" for Syncro)\n 110→ pub name: String,\n 111→\n 112→ /// Action to take when service is stopped\n 113→ #[serde(default)]\n 114→ pub action: WatchAction,\n 115→\n 116→ /// Maximum number of restart attempts before alerting (default: 3)\n 117→ #[serde(default = \"default_max_restarts\")]\n 118→ pub max_restarts: u32,\n 119→\n 120→ /// Cooldown period in seconds between restart attempts\n 121→ #[serde(default = \"default_restart_cooldown\")]\n 122→ pub restart_cooldown_seconds: u64,\n 123→}\n 124→\n 125→/// Configuration for monitoring a process\n 126→#[derive(Debug, Clone, Serialize, Deserialize)]\n 127→pub struct ProcessWatch {\n 128→ /// Process name (e.g., \"AEM.exe\")\n 129→ pub name: String,\n 130→\n 131→ /// Action to take when process is not found\n 132→ #[serde(default)]\n 133→ pub action: WatchAction,\n 134→\n 135→ /// Optional path to executable to start if process is not running\n 136→ pub start_command: Option<String>,\n 137→}\n 138→\n 139→/// Action to take when a watched service/process is down\n 140→#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]\n 141→#[serde(rename_all = \"lowercase\")]\n 142→pub enum WatchAction {\n 143→ /// Only send an alert to the server\n 144→ #[default]\n 145→ Alert,\n 146→\n 147→ /// Attempt to restart the service/process\n 148→ Restart,\n 149→\n 150→ /// Ignore (for temporary disable without removing config)\n 151→ Ignore,\n 152→}\n 153→\n 154→// Default value functions for serde\n 155→fn default_metrics_interval() -> u64 {\n 156→ 60\n 157→}\n 158→\n 159→fn default_watchdog_interval() -> u64 {\n 160→ 30\n 161→}\n 162→\n 163→fn default_max_restarts() -> u32 {\n 164→ 3\n 165→}\n 166→\n 167→fn default_restart_cooldown() -> u64 {\n 168→ 60\n 169→}\n 170→\n 171→fn default_true() -> bool {\n 172→ true\n 173→}\n 174→\n 175→impl AgentConfig {\n 176→ /// Load configuration from a TOML file\n 177→ pub fn load(path: &Path) -> Result<Self> {\n 178→ let content = std::fs::read_to_string(path)\n 179→ .with_context(|| format!(\"Failed to read config file: {:?}\", path))?;\n 180→\n 181→ let config: Self = toml::from_str(&content)\n 182→ .with_context(|| format!(\"Failed to parse config file: {:?}\", path))?;\n 183→\n 184→ config.validate()?;\n 185→ Ok(config)\n 186→ }\n 187→\n 188→ /// Validate the configuration\n 189→ fn validate(&self) -> Result<()> {\n 190→ // Validate server URL\n 191→ if self.server.url.is_empty() {\n 192→ anyhow::bail!(\"Server URL cannot be empty\");\n 193→ }\n 194→\n 195→ if !self.server.url.starts_with(\"ws://\") && !self.server.url.starts_with(\"wss://\") {\n 196→ anyhow::bail!(\"Server URL must start with ws:// or wss://\");\n 197→ }\n 198→\n 199→ // Validate API key\n 200→ if self.server.api_key.is_empty() {\n 201→ anyhow::bail!(\"API key cannot be empty\");\n 202→ }\n 203→\n 204→ // Validate intervals\n 205→ if self.metrics.interval_seconds < 10 {\n 206→ anyhow::bail!(\"Metrics interval must be at least 10 seconds\");\n 207→ }\n 208→\n 209→ if self.watchdog.check_interval_seconds < 5 {\n 210→ anyhow::bail!(\"Watchdog check interval must be at least 5 seconds\");\n 211→ }\n 212→\n 213→ Ok(())\n 214→ }\n 215→\n 216→ /// Generate a sample configuration\n 217→ pub fn sample() -> Self {\n 218→ Self {\n 219→ server: ServerConfig {\n 220→ url: \"wss://rmm-api.azcomputerguru.com/ws\".to_string(),\n 221→ api_key: \"your-api-key-here\".to_string(),\n 222→ hostname_override: None,\n 223→ },\n 224→ metrics: MetricsConfig::default(),\n 225→ watchdog: WatchdogConfig {\n 226→ enabled: true,\n 227→ check_interval_seconds: 30,\n 228→ services: vec![\n 229→ ServiceWatch {\n 230→ name: \"CagService\".to_string(), // Datto RMM\n 231→ action: WatchAction::Restart,\n 232→ max_restarts: 3,\n 233→ restart_cooldown_seconds: 60,\n 234→ },\n 235→ ServiceWatch {\n 236→ name: \"Syncro\".to_string(),\n 237→ action: WatchAction::Restart,\n 238→ max_restarts: 3,\n 239→ restart_cooldown_seconds: 60,\n 240→ },\n 241→ ],\n 242→ processes: vec![ProcessWatch {\n 243→ name: \"AEM.exe\".to_string(), // Datto AEM\n 244→ action: WatchAction::Alert,\n 245→ start_command: None,\n 246→ }],\n 247→ },\n 248→ }\n 249→ }\n 250→\n 251→ /// Get the hostname to report to the server\n 252→ pub fn get_hostname(&self) -> String {\n 253→ self.server\n 254→ .hostname_override\n 255→ .clone()\n 256→ .unwrap_or_else(|| hostname::get().map(|h| h.to_string_lossy().to_string()).unwrap_or_else(|_| \"unknown\".to_string()))\n 257→ }\n 258→}\n 259→\n 260→#[cfg(test)]\n 261→mod tests {\n 262→ use super::*;\n 263→\n 264→ #[test]\n 265→ fn test_sample_config_is_valid_structure() {\n 266→ let sample = AgentConfig::sample();\n 267→ // Sample uses placeholder values, so it won't pass full validation\n 268→ // but the structure should be correct\n 269→ assert!(!sample.server.url.is_empty());\n 270→ assert!(!sample.server.api_key.is_empty());\n 271→ assert!(sample.watchdog.enabled);\n 272→ assert!(!sample.watchdog.services.is_empty());\n 273→ }\n 274→\n 275→ #[test]\n 276→ fn test_default_metrics_config() {\n 277→ let config = MetricsConfig::default();\n 278→ assert_eq!(config.interval_seconds, 60);\n 279→ assert!(config.collect_cpu);\n 280→ assert!(config.collect_memory);\n 281→ assert!(config.collect_disk);\n 282→ assert!(config.collect_network);\n 283→ }\n 284→\n 285→ #[test]\n 286→ fn test_watch_action_default() {\n 287→ let action = WatchAction::default();\n 288→ assert_eq!(action, WatchAction::Alert);\n 289→ }\n 290→}\n 291→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"1f6e7e27-b3ab-41d9-a375-9ad1197cccf4","timestamp":"2025-12-21T21:34:46.678Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\config.rs","content":"//! Agent configuration handling\n//!\n//! Configuration is loaded from a TOML file (default: agent.toml).\n//! The config file defines server connection, metrics collection,\n//! and watchdog settings.\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::Path;\n\n/// Root configuration structure\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AgentConfig {\n /// Server connection settings\n pub server: ServerConfig,\n\n /// Metrics collection settings\n #[serde(default)]\n pub metrics: MetricsConfig,\n\n /// Watchdog settings for monitoring services/processes\n #[serde(default)]\n pub watchdog: WatchdogConfig,\n}\n\n/// Server connection configuration\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ServerConfig {\n /// WebSocket URL for the GuruRMM server (e.g., wss://rmm.example.com/ws)\n pub url: String,\n\n /// API key for authentication (obtained from server during registration)\n pub api_key: String,\n\n /// Optional custom hostname to report (defaults to system hostname)\n pub hostname_override: Option<String>,\n}\n\n/// Metrics collection configuration\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MetricsConfig {\n /// Interval in seconds between metrics collection (default: 60)\n #[serde(default = \"default_metrics_interval\")]\n pub interval_seconds: u64,\n\n /// Whether to collect CPU metrics\n #[serde(default = \"default_true\")]\n pub collect_cpu: bool,\n\n /// Whether to collect memory metrics\n #[serde(default = \"default_true\")]\n pub collect_memory: bool,\n\n /// Whether to collect disk metrics\n #[serde(default = \"default_true\")]\n pub collect_disk: bool,\n\n /// Whether to collect network metrics\n #[serde(default = \"default_true\")]\n pub collect_network: bool,\n}\n\nimpl Default for MetricsConfig {\n fn default() -> Self {\n Self {\n interval_seconds: 60,\n collect_cpu: true,\n collect_memory: true,\n collect_disk: true,\n collect_network: true,\n }\n }\n}\n\n/// Watchdog configuration for service/process monitoring\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct WatchdogConfig {\n /// Enable/disable watchdog functionality\n #[serde(default)]\n pub enabled: bool,\n\n /// Interval in seconds between watchdog checks (default: 30)\n #[serde(default = \"default_watchdog_interval\")]\n pub check_interval_seconds: u64,\n\n /// List of Windows/systemd services to monitor\n #[serde(default)]\n pub services: Vec<ServiceWatch>,\n\n /// List of processes to monitor\n #[serde(default)]\n pub processes: Vec<ProcessWatch>,\n}\n\nimpl Default for WatchdogConfig {\n fn default() -> Self {\n Self {\n enabled: false,\n check_interval_seconds: 30,\n services: Vec::new(),\n processes: Vec::new(),\n }\n }\n}\n\n/// Configuration for monitoring a service\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ServiceWatch {\n /// Service name (e.g., \"CagService\" for Datto RMM, \"Syncro\" for Syncro)\n pub name: String,\n\n /// Action to take when service is stopped\n #[serde(default)]\n pub action: WatchAction,\n\n /// Maximum number of restart attempts before alerting (default: 3)\n #[serde(default = \"default_max_restarts\")]\n pub max_restarts: u32,\n\n /// Cooldown period in seconds between restart attempts\n #[serde(default = \"default_restart_cooldown\")]\n pub restart_cooldown_seconds: u64,\n}\n\n/// Configuration for monitoring a process\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProcessWatch {\n /// Process name (e.g., \"AEM.exe\")\n pub name: String,\n\n /// Action to take when process is not found\n #[serde(default)]\n pub action: WatchAction,\n\n /// Optional path to executable to start if process is not running\n pub start_command: Option<String>,\n}\n\n/// Action to take when a watched service/process is down\n#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]\n#[serde(rename_all = \"lowercase\")]\npub enum WatchAction {\n /// Only send an alert to the server\n #[default]\n Alert,\n\n /// Attempt to restart the service/process\n Restart,\n\n /// Ignore (for temporary disable without removing config)\n Ignore,\n}\n\n// Default value functions for serde\nfn default_metrics_interval() -> u64 {\n 60\n}\n\nfn default_watchdog_interval() -> u64 {\n 30\n}\n\nfn default_max_restarts() -> u32 {\n 3\n}\n\nfn default_restart_cooldown() -> u64 {\n 60\n}\n\nfn default_true() -> bool {\n true\n}\n\nimpl AgentConfig {\n /// Load configuration from a TOML file\n pub fn load(path: &Path) -> Result<Self> {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {:?}\", path))?;\n\n let config: Self = toml::from_str(&content)\n .with_context(|| format!(\"Failed to parse config file: {:?}\", path))?;\n\n config.validate()?;\n Ok(config)\n }\n\n /// Validate the configuration\n fn validate(&self) -> Result<()> {\n // Validate server URL\n if self.server.url.is_empty() {\n anyhow::bail!(\"Server URL cannot be empty\");\n }\n\n if !self.server.url.starts_with(\"ws://\") && !self.server.url.starts_with(\"wss://\") {\n anyhow::bail!(\"Server URL must start with ws:// or wss://\");\n }\n\n // Validate API key\n if self.server.api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n // Validate intervals\n if self.metrics.interval_seconds < 10 {\n anyhow::bail!(\"Metrics interval must be at least 10 seconds\");\n }\n\n if self.watchdog.check_interval_seconds < 5 {\n anyhow::bail!(\"Watchdog check interval must be at least 5 seconds\");\n }\n\n Ok(())\n }\n\n /// Generate a sample configuration\n pub fn sample() -> Self {\n Self {\n server: ServerConfig {\n url: \"wss://rmm-api.azcomputerguru.com/ws\".to_string(),\n api_key: \"your-api-key-here\".to_string(),\n hostname_override: None,\n },\n metrics: MetricsConfig::default(),\n watchdog: WatchdogConfig {\n enabled: true,\n check_interval_seconds: 30,\n services: vec![\n ServiceWatch {\n name: \"CagService\".to_string(), // Datto RMM\n action: WatchAction::Restart,\n max_restarts: 3,\n restart_cooldown_seconds: 60,\n },\n ServiceWatch {\n name: \"Syncro\".to_string(),\n action: WatchAction::Restart,\n max_restarts: 3,\n restart_cooldown_seconds: 60,\n },\n ],\n processes: vec![ProcessWatch {\n name: \"AEM.exe\".to_string(), // Datto AEM\n action: WatchAction::Alert,\n start_command: None,\n }],\n },\n }\n }\n\n /// Get the hostname to report to the server\n pub fn get_hostname(&self) -> String {\n self.server\n .hostname_override\n .clone()\n .unwrap_or_else(|| hostname::get().map(|h| h.to_string_lossy().to_string()).unwrap_or_else(|_| \"unknown\".to_string()))\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_sample_config_is_valid_structure() {\n let sample = AgentConfig::sample();\n // Sample uses placeholder values, so it won't pass full validation\n // but the structure should be correct\n assert!(!sample.server.url.is_empty());\n assert!(!sample.server.api_key.is_empty());\n assert!(sample.watchdog.enabled);\n assert!(!sample.watchdog.services.is_empty());\n }\n\n #[test]\n fn test_default_metrics_config() {\n let config = MetricsConfig::default();\n assert_eq!(config.interval_seconds, 60);\n assert!(config.collect_cpu);\n assert!(config.collect_memory);\n assert!(config.collect_disk);\n assert!(config.collect_network);\n }\n\n #[test]\n fn test_watch_action_default() {\n let action = WatchAction::default();\n assert_eq!(action, WatchAction::Alert);\n }\n}\n","numLines":291,"startLine":1,"totalLines":291}}}
|
||
{"parentUuid":"1f6e7e27-b3ab-41d9-a375-9ad1197cccf4","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DkzFQRqeMW8vnwA6M4J4k8","type":"tool_result","content":" 1→//! Transport layer for agent-server communication\n 2→//!\n 3→//! Handles WebSocket connection to the GuruRMM server with:\n 4→//! - Auto-reconnection on disconnect\n 5→//! - Authentication via API key\n 6→//! - Sending metrics and receiving commands\n 7→//! - Heartbeat to maintain connection\n 8→\n 9→mod websocket;\n 10→\n 11→pub use websocket::WebSocketClient;\n 12→\n 13→use serde::{Deserialize, Serialize};\n 14→use uuid::Uuid;\n 15→\n 16→/// Messages sent from agent to server\n 17→#[derive(Debug, Clone, Serialize, Deserialize)]\n 18→#[serde(tag = \"type\", content = \"payload\")]\n 19→#[serde(rename_all = \"snake_case\")]\n 20→pub enum AgentMessage {\n 21→ /// Authentication message (sent on connect)\n 22→ Auth(AuthPayload),\n 23→\n 24→ /// Metrics report\n 25→ Metrics(crate::metrics::SystemMetrics),\n 26→\n 27→ /// Network state update (sent on connect and when interfaces change)\n 28→ NetworkState(crate::metrics::NetworkState),\n 29→\n 30→ /// Command execution result\n 31→ CommandResult(CommandResultPayload),\n 32→\n 33→ /// Watchdog event (service stopped, restarted, etc.)\n 34→ WatchdogEvent(WatchdogEventPayload),\n 35→\n 36→ /// Update result (success, failure, rollback)\n 37→ UpdateResult(UpdateResultPayload),\n 38→\n 39→ /// Heartbeat to keep connection alive\n 40→ Heartbeat,\n 41→}\n 42→\n 43→/// Authentication payload\n 44→#[derive(Debug, Clone, Serialize, Deserialize)]\n 45→pub struct AuthPayload {\n 46→ /// API key for this agent (or site)\n 47→ pub api_key: String,\n 48→\n 49→ /// Unique device identifier (hardware-derived)\n 50→ pub device_id: String,\n 51→\n 52→ /// Hostname of this machine\n 53→ pub hostname: String,\n 54→\n 55→ /// Operating system type\n 56→ pub os_type: String,\n 57→\n 58→ /// Operating system version\n 59→ pub os_version: String,\n 60→\n 61→ /// Agent version\n 62→ pub agent_version: String,\n 63→\n 64→ /// Architecture (amd64, arm64, etc.)\n 65→ #[serde(default = \"default_arch\")]\n 66→ pub architecture: String,\n 67→\n 68→ /// Previous version if reconnecting after update\n 69→ #[serde(skip_serializing_if = \"Option::is_none\")]\n 70→ pub previous_version: Option<String>,\n 71→\n 72→ /// Update ID if reconnecting after update\n 73→ #[serde(skip_serializing_if = \"Option::is_none\")]\n 74→ pub pending_update_id: Option<Uuid>,\n 75→}\n 76→\n 77→fn default_arch() -> String {\n 78→ #[cfg(target_arch = \"x86_64\")]\n 79→ { \"amd64\".to_string() }\n 80→ #[cfg(target_arch = \"aarch64\")]\n 81→ { \"arm64\".to_string() }\n 82→ #[cfg(not(any(target_arch = \"x86_64\", target_arch = \"aarch64\")))]\n 83→ { \"unknown\".to_string() }\n 84→}\n 85→\n 86→/// Command execution result payload\n 87→#[derive(Debug, Clone, Serialize, Deserialize)]\n 88→pub struct CommandResultPayload {\n 89→ /// Command ID (from the server)\n 90→ pub command_id: Uuid,\n 91→\n 92→ /// Exit code (0 = success)\n 93→ pub exit_code: i32,\n 94→\n 95→ /// Standard output\n 96→ pub stdout: String,\n 97→\n 98→ /// Standard error\n 99→ pub stderr: String,\n 100→\n 101→ /// Execution duration in milliseconds\n 102→ pub duration_ms: u64,\n 103→}\n 104→\n 105→/// Watchdog event payload\n 106→#[derive(Debug, Clone, Serialize, Deserialize)]\n 107→pub struct WatchdogEventPayload {\n 108→ /// Service or process name\n 109→ pub name: String,\n 110→\n 111→ /// Event type\n 112→ pub event: WatchdogEvent,\n 113→\n 114→ /// Additional details\n 115→ pub details: Option<String>,\n 116→}\n 117→\n 118→/// Types of watchdog events\n 119→#[derive(Debug, Clone, Serialize, Deserialize)]\n 120→#[serde(rename_all = \"snake_case\")]\n 121→pub enum WatchdogEvent {\n 122→ /// Service/process was found stopped\n 123→ Stopped,\n 124→\n 125→ /// Service/process was restarted by the agent\n 126→ Restarted,\n 127→\n 128→ /// Restart attempt failed\n 129→ RestartFailed,\n 130→\n 131→ /// Max restart attempts reached\n 132→ MaxRestartsReached,\n 133→\n 134→ /// Service/process recovered on its own\n 135→ Recovered,\n 136→}\n 137→\n 138→/// Messages sent from server to agent\n 139→#[derive(Debug, Clone, Serialize, Deserialize)]\n 140→#[serde(tag = \"type\", content = \"payload\")]\n 141→#[serde(rename_all = \"snake_case\")]\n 142→pub enum ServerMessage {\n 143→ /// Authentication acknowledgment\n 144→ AuthAck(AuthAckPayload),\n 145→\n 146→ /// Command to execute\n 147→ Command(CommandPayload),\n 148→\n 149→ /// Configuration update\n 150→ ConfigUpdate(ConfigUpdatePayload),\n 151→\n 152→ /// Agent update command\n 153→ Update(UpdatePayload),\n 154→\n 155→ /// Tray policy update\n 156→ TrayPolicyUpdate(crate::ipc::TrayPolicy),\n 157→\n 158→ /// Acknowledgment of received message\n 159→ Ack { message_id: Option<String> },\n 160→\n 161→ /// Error message\n 162→ Error { code: String, message: String },\n 163→}\n 164→\n 165→/// Authentication acknowledgment payload\n 166→#[derive(Debug, Clone, Serialize, Deserialize)]\n 167→pub struct AuthAckPayload {\n 168→ /// Whether authentication was successful\n 169→ pub success: bool,\n 170→\n 171→ /// Agent ID assigned by server\n 172→ pub agent_id: Option<Uuid>,\n 173→\n 174→ /// Error message if authentication failed\n 175→ pub error: Option<String>,\n 176→}\n 177→\n 178→/// Command payload from server\n 179→#[derive(Debug, Clone, Serialize, Deserialize)]\n 180→pub struct CommandPayload {\n 181→ /// Unique command ID\n 182→ pub id: Uuid,\n 183→\n 184→ /// Type of command\n 185→ pub command_type: CommandType,\n 186→\n 187→ /// Command text to execute\n 188→ pub command: String,\n 189→\n 190→ /// Optional timeout in seconds\n 191→ pub timeout_seconds: Option<u64>,\n 192→\n 193→ /// Whether to run as elevated/admin\n 194→ pub elevated: bool,\n 195→}\n 196→\n 197→/// Types of commands\n 198→#[derive(Debug, Clone, Serialize, Deserialize)]\n 199→#[serde(rename_all = \"snake_case\")]\n 200→pub enum CommandType {\n 201→ /// Shell command (cmd on Windows, bash on Unix)\n 202→ Shell,\n 203→\n 204→ /// PowerShell command (Windows)\n 205→ PowerShell,\n 206→\n 207→ /// Python script\n 208→ Python,\n 209→\n 210→ /// Raw script (requires interpreter path)\n 211→ Script { interpreter: String },\n 212→}\n 213→\n 214→/// Configuration update payload\n 215→#[derive(Debug, Clone, Serialize, Deserialize)]\n 216→pub struct ConfigUpdatePayload {\n 217→ /// New metrics interval (if changed)\n 218→ pub metrics_interval_seconds: Option<u64>,\n 219→\n 220→ /// Updated watchdog config\n 221→ pub watchdog: Option<WatchdogConfigUpdate>,\n 222→}\n 223→\n 224→/// Watchdog configuration update\n 225→#[derive(Debug, Clone, Serialize, Deserialize)]\n 226→pub struct WatchdogConfigUpdate {\n 227→ /// Enable/disable watchdog\n 228→ pub enabled: Option<bool>,\n 229→\n 230→ /// Check interval\n 231→ pub check_interval_seconds: Option<u64>,\n 232→\n 233→ // Services and processes would be included here for remote config updates\n 234→}\n 235→\n 236→/// Update command payload from server\n 237→#[derive(Debug, Clone, Serialize, Deserialize)]\n 238→pub struct UpdatePayload {\n 239→ /// Unique update ID for tracking\n 240→ pub update_id: Uuid,\n 241→\n 242→ /// Target version to update to\n 243→ pub target_version: String,\n 244→\n 245→ /// Download URL for the new binary\n 246→ pub download_url: String,\n 247→\n 248→ /// SHA256 checksum of the binary\n 249→ pub checksum_sha256: String,\n 250→\n 251→ /// Whether to force update (skip version check)\n 252→ #[serde(default)]\n 253→ pub force: bool,\n 254→}\n 255→\n 256→/// Update result payload sent back to server\n 257→#[derive(Debug, Clone, Serialize, Deserialize)]\n 258→pub struct UpdateResultPayload {\n 259→ /// Update ID (from the server)\n 260→ pub update_id: Uuid,\n 261→\n 262→ /// Update status\n 263→ pub status: UpdateStatus,\n 264→\n 265→ /// Old version before update\n 266→ pub old_version: String,\n 267→\n 268→ /// New version after update (if successful)\n 269→ pub new_version: Option<String>,\n 270→\n 271→ /// Error message if failed\n 272→ pub error: Option<String>,\n 273→}\n 274→\n 275→/// Update status codes\n 276→#[derive(Debug, Clone, Serialize, Deserialize)]\n 277→#[serde(rename_all = \"snake_case\")]\n 278→pub enum UpdateStatus {\n 279→ /// Update starting\n 280→ Starting,\n 281→\n 282→ /// Downloading new binary\n 283→ Downloading,\n 284→\n 285→ /// Download complete, verifying\n 286→ Verifying,\n 287→\n 288→ /// Installing (replacing binary)\n 289→ Installing,\n 290→\n 291→ /// Restarting service\n 292→ Restarting,\n 293→\n 294→ /// Update completed successfully\n 295→ Completed,\n 296→\n 297→ /// Update failed\n 298→ Failed,\n 299→\n 300→ /// Rolled back to previous version\n 301→ RolledBack,\n 302→}\n 303→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"fa36f185-d6e6-4ef9-a76b-668b272fdb9f","timestamp":"2025-12-21T21:34:46.700Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\transport\\mod.rs","content":"//! Transport layer for agent-server communication\n//!\n//! Handles WebSocket connection to the GuruRMM server with:\n//! - Auto-reconnection on disconnect\n//! - Authentication via API key\n//! - Sending metrics and receiving commands\n//! - Heartbeat to maintain connection\n\nmod websocket;\n\npub use websocket::WebSocketClient;\n\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\n/// Messages sent from agent to server\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\", content = \"payload\")]\n#[serde(rename_all = \"snake_case\")]\npub enum AgentMessage {\n /// Authentication message (sent on connect)\n Auth(AuthPayload),\n\n /// Metrics report\n Metrics(crate::metrics::SystemMetrics),\n\n /// Network state update (sent on connect and when interfaces change)\n NetworkState(crate::metrics::NetworkState),\n\n /// Command execution result\n CommandResult(CommandResultPayload),\n\n /// Watchdog event (service stopped, restarted, etc.)\n WatchdogEvent(WatchdogEventPayload),\n\n /// Update result (success, failure, rollback)\n UpdateResult(UpdateResultPayload),\n\n /// Heartbeat to keep connection alive\n Heartbeat,\n}\n\n/// Authentication payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AuthPayload {\n /// API key for this agent (or site)\n pub api_key: String,\n\n /// Unique device identifier (hardware-derived)\n pub device_id: String,\n\n /// Hostname of this machine\n pub hostname: String,\n\n /// Operating system type\n pub os_type: String,\n\n /// Operating system version\n pub os_version: String,\n\n /// Agent version\n pub agent_version: String,\n\n /// Architecture (amd64, arm64, etc.)\n #[serde(default = \"default_arch\")]\n pub architecture: String,\n\n /// Previous version if reconnecting after update\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub previous_version: Option<String>,\n\n /// Update ID if reconnecting after update\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub pending_update_id: Option<Uuid>,\n}\n\nfn default_arch() -> String {\n #[cfg(target_arch = \"x86_64\")]\n { \"amd64\".to_string() }\n #[cfg(target_arch = \"aarch64\")]\n { \"arm64\".to_string() }\n #[cfg(not(any(target_arch = \"x86_64\", target_arch = \"aarch64\")))]\n { \"unknown\".to_string() }\n}\n\n/// Command execution result payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CommandResultPayload {\n /// Command ID (from the server)\n pub command_id: Uuid,\n\n /// Exit code (0 = success)\n pub exit_code: i32,\n\n /// Standard output\n pub stdout: String,\n\n /// Standard error\n pub stderr: String,\n\n /// Execution duration in milliseconds\n pub duration_ms: u64,\n}\n\n/// Watchdog event payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct WatchdogEventPayload {\n /// Service or process name\n pub name: String,\n\n /// Event type\n pub event: WatchdogEvent,\n\n /// Additional details\n pub details: Option<String>,\n}\n\n/// Types of watchdog events\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum WatchdogEvent {\n /// Service/process was found stopped\n Stopped,\n\n /// Service/process was restarted by the agent\n Restarted,\n\n /// Restart attempt failed\n RestartFailed,\n\n /// Max restart attempts reached\n MaxRestartsReached,\n\n /// Service/process recovered on its own\n Recovered,\n}\n\n/// Messages sent from server to agent\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\", content = \"payload\")]\n#[serde(rename_all = \"snake_case\")]\npub enum ServerMessage {\n /// Authentication acknowledgment\n AuthAck(AuthAckPayload),\n\n /// Command to execute\n Command(CommandPayload),\n\n /// Configuration update\n ConfigUpdate(ConfigUpdatePayload),\n\n /// Agent update command\n Update(UpdatePayload),\n\n /// Tray policy update\n TrayPolicyUpdate(crate::ipc::TrayPolicy),\n\n /// Acknowledgment of received message\n Ack { message_id: Option<String> },\n\n /// Error message\n Error { code: String, message: String },\n}\n\n/// Authentication acknowledgment payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AuthAckPayload {\n /// Whether authentication was successful\n pub success: bool,\n\n /// Agent ID assigned by server\n pub agent_id: Option<Uuid>,\n\n /// Error message if authentication failed\n pub error: Option<String>,\n}\n\n/// Command payload from server\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CommandPayload {\n /// Unique command ID\n pub id: Uuid,\n\n /// Type of command\n pub command_type: CommandType,\n\n /// Command text to execute\n pub command: String,\n\n /// Optional timeout in seconds\n pub timeout_seconds: Option<u64>,\n\n /// Whether to run as elevated/admin\n pub elevated: bool,\n}\n\n/// Types of commands\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum CommandType {\n /// Shell command (cmd on Windows, bash on Unix)\n Shell,\n\n /// PowerShell command (Windows)\n PowerShell,\n\n /// Python script\n Python,\n\n /// Raw script (requires interpreter path)\n Script { interpreter: String },\n}\n\n/// Configuration update payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ConfigUpdatePayload {\n /// New metrics interval (if changed)\n pub metrics_interval_seconds: Option<u64>,\n\n /// Updated watchdog config\n pub watchdog: Option<WatchdogConfigUpdate>,\n}\n\n/// Watchdog configuration update\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct WatchdogConfigUpdate {\n /// Enable/disable watchdog\n pub enabled: Option<bool>,\n\n /// Check interval\n pub check_interval_seconds: Option<u64>,\n\n // Services and processes would be included here for remote config updates\n}\n\n/// Update command payload from server\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdatePayload {\n /// Unique update ID for tracking\n pub update_id: Uuid,\n\n /// Target version to update to\n pub target_version: String,\n\n /// Download URL for the new binary\n pub download_url: String,\n\n /// SHA256 checksum of the binary\n pub checksum_sha256: String,\n\n /// Whether to force update (skip version check)\n #[serde(default)]\n pub force: bool,\n}\n\n/// Update result payload sent back to server\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdateResultPayload {\n /// Update ID (from the server)\n pub update_id: Uuid,\n\n /// Update status\n pub status: UpdateStatus,\n\n /// Old version before update\n pub old_version: String,\n\n /// New version after update (if successful)\n pub new_version: Option<String>,\n\n /// Error message if failed\n pub error: Option<String>,\n}\n\n/// Update status codes\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum UpdateStatus {\n /// Update starting\n Starting,\n\n /// Downloading new binary\n Downloading,\n\n /// Download complete, verifying\n Verifying,\n\n /// Installing (replacing binary)\n Installing,\n\n /// Restarting service\n Restarting,\n\n /// Update completed successfully\n Completed,\n\n /// Update failed\n Failed,\n\n /// Rolled back to previous version\n RolledBack,\n}\n","numLines":303,"startLine":1,"totalLines":303}}}
|
||
{"parentUuid":"fa36f185-d6e6-4ef9-a76b-668b272fdb9f","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_011MDHrBVPWEKsBfHAz7CiEV","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me explore the existing database schema and API routes to understand the current patterns better."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":16767,"cache_read_input_tokens":12316,"cache_creation":{"ephemeral_5m_input_tokens":16767,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWLYtyvn57rrVutN8zBU3","type":"assistant","uuid":"3bb5841a-0362-4bfc-b696-dcc658c7c7db","timestamp":"2025-12-21T21:34:56.596Z"}
|
||
{"parentUuid":"3bb5841a-0362-4bfc-b696-dcc658c7c7db","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_011MDHrBVPWEKsBfHAz7CiEV","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019dsMf1c2UR7KCUaJMTR5Nd","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\db\\agents.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":16767,"cache_read_input_tokens":12316,"cache_creation":{"ephemeral_5m_input_tokens":16767,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWLYtyvn57rrVutN8zBU3","type":"assistant","uuid":"1c9ecc0b-48a3-4b9b-92bf-a45621377bfd","timestamp":"2025-12-21T21:34:57.477Z"}
|
||
{"parentUuid":"1c9ecc0b-48a3-4b9b-92bf-a45621377bfd","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_011MDHrBVPWEKsBfHAz7CiEV","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01X641ieF9rpuwQaLYHhijx1","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\db\\clients.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":16767,"cache_read_input_tokens":12316,"cache_creation":{"ephemeral_5m_input_tokens":16767,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWLYtyvn57rrVutN8zBU3","type":"assistant","uuid":"c1153e0c-e55b-432f-b883-f9e251bc4176","timestamp":"2025-12-21T21:34:57.969Z"}
|
||
{"parentUuid":"c1153e0c-e55b-432f-b883-f9e251bc4176","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_011MDHrBVPWEKsBfHAz7CiEV","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KJ9SvvF2xvsTSZXSTmQz8y","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\db\\sites.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":16767,"cache_read_input_tokens":12316,"cache_creation":{"ephemeral_5m_input_tokens":16767,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWLYtyvn57rrVutN8zBU3","type":"assistant","uuid":"d9961805-03c8-4644-afa3-df2659afa1da","timestamp":"2025-12-21T21:34:58.371Z"}
|
||
{"parentUuid":"d9961805-03c8-4644-afa3-df2659afa1da","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_011MDHrBVPWEKsBfHAz7CiEV","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RjYwxJDrkkiXrM4eytccxP","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\mod.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":16767,"cache_read_input_tokens":12316,"cache_creation":{"ephemeral_5m_input_tokens":16767,"ephemeral_1h_input_tokens":0},"output_tokens":292,"service_tier":"standard"}},"requestId":"req_011CWLYtyvn57rrVutN8zBU3","type":"assistant","uuid":"84c9ac43-bf58-4070-9db2-ff57051e6ea1","timestamp":"2025-12-21T21:34:58.837Z"}
|
||
{"parentUuid":"84c9ac43-bf58-4070-9db2-ff57051e6ea1","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019dsMf1c2UR7KCUaJMTR5Nd","type":"tool_result","content":" 1→//! Agent database operations\n 2→\n 3→use chrono::{DateTime, Utc};\n 4→use serde::{Deserialize, Serialize};\n 5→use sqlx::PgPool;\n 6→use uuid::Uuid;\n 7→\n 8→/// Agent record from database\n 9→#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]\n 10→pub struct Agent {\n 11→ pub id: Uuid,\n 12→ pub hostname: String,\n 13→ #[serde(skip_serializing)]\n 14→ pub api_key_hash: Option<String>, // Nullable: new agents use site's api_key\n 15→ pub os_type: String,\n 16→ pub os_version: Option<String>,\n 17→ pub agent_version: Option<String>,\n 18→ pub last_seen: Option<DateTime<Utc>>,\n 19→ pub status: String,\n 20→ pub created_at: DateTime<Utc>,\n 21→ pub updated_at: DateTime<Utc>,\n 22→ // New fields for site-based registration\n 23→ pub device_id: Option<String>, // Hardware-derived unique ID\n 24→ pub site_id: Option<Uuid>, // Which site this agent belongs to\n 25→}\n 26→\n 27→/// Agent without sensitive fields (for API responses)\n 28→#[derive(Debug, Clone, Serialize, Deserialize)]\n 29→pub struct AgentResponse {\n 30→ pub id: Uuid,\n 31→ pub hostname: String,\n 32→ pub os_type: String,\n 33→ pub os_version: Option<String>,\n 34→ pub agent_version: Option<String>,\n 35→ pub last_seen: Option<DateTime<Utc>>,\n 36→ pub status: String,\n 37→ pub created_at: DateTime<Utc>,\n 38→ pub device_id: Option<String>,\n 39→ pub site_id: Option<Uuid>,\n 40→ pub site_name: Option<String>,\n 41→ pub client_name: Option<String>,\n 42→}\n 43→\n 44→impl From<Agent> for AgentResponse {\n 45→ fn from(agent: Agent) -> Self {\n 46→ Self {\n 47→ id: agent.id,\n 48→ hostname: agent.hostname,\n 49→ os_type: agent.os_type,\n 50→ os_version: agent.os_version,\n 51→ agent_version: agent.agent_version,\n 52→ last_seen: agent.last_seen,\n 53→ status: agent.status,\n 54→ created_at: agent.created_at,\n 55→ device_id: agent.device_id,\n 56→ site_id: agent.site_id,\n 57→ site_name: None,\n 58→ client_name: None,\n 59→ }\n 60→ }\n 61→}\n 62→\n 63→/// Create a new agent registration\n 64→#[derive(Debug, Clone, Deserialize)]\n 65→pub struct CreateAgent {\n 66→ pub hostname: String,\n 67→ pub api_key_hash: String,\n 68→ pub os_type: String,\n 69→ pub os_version: Option<String>,\n 70→ pub agent_version: Option<String>,\n 71→}\n 72→\n 73→/// Insert a new agent into the database\n 74→pub async fn create_agent(pool: &PgPool, agent: CreateAgent) -> Result<Agent, sqlx::Error> {\n 75→ sqlx::query_as::<_, Agent>(\n 76→ r#\"\n 77→ INSERT INTO agents (hostname, api_key_hash, os_type, os_version, agent_version, status)\n 78→ VALUES ($1, $2, $3, $4, $5, 'offline')\n 79→ RETURNING *\n 80→ \"#,\n 81→ )\n 82→ .bind(&agent.hostname)\n 83→ .bind(&agent.api_key_hash)\n 84→ .bind(&agent.os_type)\n 85→ .bind(&agent.os_version)\n 86→ .bind(&agent.agent_version)\n 87→ .fetch_one(pool)\n 88→ .await\n 89→}\n 90→\n 91→/// Get an agent by ID\n 92→pub async fn get_agent_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Agent>, sqlx::Error> {\n 93→ sqlx::query_as::<_, Agent>(\"SELECT * FROM agents WHERE id = $1\")\n 94→ .bind(id)\n 95→ .fetch_optional(pool)\n 96→ .await\n 97→}\n 98→\n 99→/// Get an agent by API key hash\n 100→pub async fn get_agent_by_api_key_hash(\n 101→ pool: &PgPool,\n 102→ api_key_hash: &str,\n 103→) -> Result<Option<Agent>, sqlx::Error> {\n 104→ sqlx::query_as::<_, Agent>(\"SELECT * FROM agents WHERE api_key_hash = $1\")\n 105→ .bind(api_key_hash)\n 106→ .fetch_optional(pool)\n 107→ .await\n 108→}\n 109→\n 110→/// Get all agents\n 111→pub async fn get_all_agents(pool: &PgPool) -> Result<Vec<Agent>, sqlx::Error> {\n 112→ sqlx::query_as::<_, Agent>(\"SELECT * FROM agents ORDER BY hostname\")\n 113→ .fetch_all(pool)\n 114→ .await\n 115→}\n 116→\n 117→/// Get agents by status\n 118→pub async fn get_agents_by_status(\n 119→ pool: &PgPool,\n 120→ status: &str,\n 121→) -> Result<Vec<Agent>, sqlx::Error> {\n 122→ sqlx::query_as::<_, Agent>(\"SELECT * FROM agents WHERE status = $1 ORDER BY hostname\")\n 123→ .bind(status)\n 124→ .fetch_all(pool)\n 125→ .await\n 126→}\n 127→\n 128→/// Update agent status and last_seen\n 129→pub async fn update_agent_status(\n 130→ pool: &PgPool,\n 131→ id: Uuid,\n 132→ status: &str,\n 133→) -> Result<(), sqlx::Error> {\n 134→ sqlx::query(\"UPDATE agents SET status = $1, last_seen = NOW() WHERE id = $2\")\n 135→ .bind(status)\n 136→ .bind(id)\n 137→ .execute(pool)\n 138→ .await?;\n 139→ Ok(())\n 140→}\n 141→\n 142→/// Update agent info (on connection)\n 143→pub async fn update_agent_info(\n 144→ pool: &PgPool,\n 145→ id: Uuid,\n 146→ hostname: Option<&str>,\n 147→ os_version: Option<&str>,\n 148→ agent_version: Option<&str>,\n 149→) -> Result<(), sqlx::Error> {\n 150→ sqlx::query(\n 151→ r#\"\n 152→ UPDATE agents\n 153→ SET hostname = COALESCE($1, hostname),\n 154→ os_version = COALESCE($2, os_version),\n 155→ agent_version = COALESCE($3, agent_version),\n 156→ last_seen = NOW(),\n 157→ status = 'online'\n 158→ WHERE id = $4\n 159→ \"#,\n 160→ )\n 161→ .bind(hostname)\n 162→ .bind(os_version)\n 163→ .bind(agent_version)\n 164→ .bind(id)\n 165→ .execute(pool)\n 166→ .await?;\n 167→ Ok(())\n 168→}\n 169→\n 170→/// Delete an agent\n 171→pub async fn delete_agent(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {\n 172→ let result = sqlx::query(\"DELETE FROM agents WHERE id = $1\")\n 173→ .bind(id)\n 174→ .execute(pool)\n 175→ .await?;\n 176→ Ok(result.rows_affected() > 0)\n 177→}\n 178→\n 179→/// Get agent count statistics\n 180→#[derive(Debug, Clone, Serialize, Deserialize)]\n 181→pub struct AgentStats {\n 182→ pub total: i64,\n 183→ pub online: i64,\n 184→ pub offline: i64,\n 185→}\n 186→\n 187→pub async fn get_agent_stats(pool: &PgPool) -> Result<AgentStats, sqlx::Error> {\n 188→ let total: (i64,) = sqlx::query_as(\"SELECT COUNT(*) FROM agents\")\n 189→ .fetch_one(pool)\n 190→ .await?;\n 191→\n 192→ let online: (i64,) =\n 193→ sqlx::query_as(\"SELECT COUNT(*) FROM agents WHERE status = 'online'\")\n 194→ .fetch_one(pool)\n 195→ .await?;\n 196→\n 197→ Ok(AgentStats {\n 198→ total: total.0,\n 199→ online: online.0,\n 200→ offline: total.0 - online.0,\n 201→ })\n 202→}\n 203→\n 204→// ============================================================================\n 205→// Site-based agent operations\n 206→// ============================================================================\n 207→\n 208→/// Data for creating an agent via site registration\n 209→#[derive(Debug, Clone)]\n 210→pub struct CreateAgentWithSite {\n 211→ pub site_id: Uuid,\n 212→ pub device_id: String,\n 213→ pub hostname: String,\n 214→ pub os_type: String,\n 215→ pub os_version: Option<String>,\n 216→ pub agent_version: Option<String>,\n 217→}\n 218→\n 219→/// Create a new agent under a site (site-based registration)\n 220→pub async fn create_agent_with_site(\n 221→ pool: &PgPool,\n 222→ agent: CreateAgentWithSite,\n 223→) -> Result<Agent, sqlx::Error> {\n 224→ sqlx::query_as::<_, Agent>(\n 225→ r#\"\n 226→ INSERT INTO agents (site_id, device_id, hostname, os_type, os_version, agent_version, status)\n 227→ VALUES ($1, $2, $3, $4, $5, $6, 'offline')\n 228→ RETURNING *\n 229→ \"#,\n 230→ )\n 231→ .bind(&agent.site_id)\n 232→ .bind(&agent.device_id)\n 233→ .bind(&agent.hostname)\n 234→ .bind(&agent.os_type)\n 235→ .bind(&agent.os_version)\n 236→ .bind(&agent.agent_version)\n 237→ .fetch_one(pool)\n 238→ .await\n 239→}\n 240→\n 241→/// Get an agent by site_id and device_id (for site-based auth)\n 242→pub async fn get_agent_by_site_and_device(\n 243→ pool: &PgPool,\n 244→ site_id: Uuid,\n 245→ device_id: &str,\n 246→) -> Result<Option<Agent>, sqlx::Error> {\n 247→ sqlx::query_as::<_, Agent>(\n 248→ \"SELECT * FROM agents WHERE site_id = $1 AND device_id = $2\"\n 249→ )\n 250→ .bind(site_id)\n 251→ .bind(device_id)\n 252→ .fetch_optional(pool)\n 253→ .await\n 254→}\n 255→\n 256→/// Get all agents for a site\n 257→pub async fn get_agents_by_site(pool: &PgPool, site_id: Uuid) -> Result<Vec<Agent>, sqlx::Error> {\n 258→ sqlx::query_as::<_, Agent>(\n 259→ \"SELECT * FROM agents WHERE site_id = $1 ORDER BY hostname\"\n 260→ )\n 261→ .bind(site_id)\n 262→ .fetch_all(pool)\n 263→ .await\n 264→}\n 265→\n 266→/// Agent with site and client details for API responses\n 267→#[derive(Debug, Clone, sqlx::FromRow, Serialize)]\n 268→pub struct AgentWithDetails {\n 269→ pub id: Uuid,\n 270→ pub hostname: String,\n 271→ pub os_type: String,\n 272→ pub os_version: Option<String>,\n 273→ pub agent_version: Option<String>,\n 274→ pub last_seen: Option<DateTime<Utc>>,\n 275→ pub status: String,\n 276→ pub created_at: DateTime<Utc>,\n 277→ pub device_id: Option<String>,\n 278→ pub site_id: Option<Uuid>,\n 279→ pub site_name: Option<String>,\n 280→ pub client_id: Option<Uuid>,\n 281→ pub client_name: Option<String>,\n 282→}\n 283→\n 284→/// Get all agents with site/client details\n 285→pub async fn get_all_agents_with_details(pool: &PgPool) -> Result<Vec<AgentWithDetails>, sqlx::Error> {\n 286→ sqlx::query_as::<_, AgentWithDetails>(\n 287→ r#\"\n 288→ SELECT\n 289→ a.id, a.hostname, a.os_type, a.os_version, a.agent_version,\n 290→ a.last_seen, a.status, a.created_at, a.device_id, a.site_id,\n 291→ s.name as site_name,\n 292→ c.id as client_id,\n 293→ c.name as client_name\n 294→ FROM agents a\n 295→ LEFT JOIN sites s ON a.site_id = s.id\n 296→ LEFT JOIN clients c ON s.client_id = c.id\n 297→ ORDER BY c.name, s.name, a.hostname\n 298→ \"#,\n 299→ )\n 300→ .fetch_all(pool)\n 301→ .await\n 302→}\n 303→\n 304→/// Update agent info including device_id (on connection)\n 305→pub async fn update_agent_info_full(\n 306→ pool: &PgPool,\n 307→ id: Uuid,\n 308→ hostname: Option<&str>,\n 309→ device_id: Option<&str>,\n 310→ os_version: Option<&str>,\n 311→ agent_version: Option<&str>,\n 312→) -> Result<(), sqlx::Error> {\n 313→ sqlx::query(\n 314→ r#\"\n 315→ UPDATE agents\n 316→ SET hostname = COALESCE($1, hostname),\n 317→ device_id = COALESCE($2, device_id),\n 318→ os_version = COALESCE($3, os_version),\n 319→ agent_version = COALESCE($4, agent_version),\n 320→ last_seen = NOW(),\n 321→ status = 'online'\n 322→ WHERE id = $5\n 323→ \"#,\n 324→ )\n 325→ .bind(hostname)\n 326→ .bind(device_id)\n 327→ .bind(os_version)\n 328→ .bind(agent_version)\n 329→ .bind(id)\n 330→ .execute(pool)\n 331→ .await?;\n 332→ Ok(())\n 333→}\n 334→\n 335→/// Move an agent to a different site (or remove from site if site_id is None)\n 336→pub async fn move_agent_to_site(\n 337→ pool: &PgPool,\n 338→ agent_id: Uuid,\n 339→ site_id: Option<Uuid>,\n 340→) -> Result<Option<Agent>, sqlx::Error> {\n 341→ sqlx::query_as::<_, Agent>(\n 342→ r#\"\n 343→ UPDATE agents\n 344→ SET site_id = $1,\n 345→ updated_at = NOW()\n 346→ WHERE id = $2\n 347→ RETURNING *\n 348→ \"#,\n 349→ )\n 350→ .bind(site_id)\n 351→ .bind(agent_id)\n 352→ .fetch_optional(pool)\n 353→ .await\n 354→}\n 355→\n 356→/// Get agents that are not assigned to any site\n 357→pub async fn get_unassigned_agents(pool: &PgPool) -> Result<Vec<Agent>, sqlx::Error> {\n 358→ sqlx::query_as::<_, Agent>(\n 359→ \"SELECT * FROM agents WHERE site_id IS NULL ORDER BY hostname\"\n 360→ )\n 361→ .fetch_all(pool)\n 362→ .await\n 363→}\n 364→\n 365→/// Update agent last_seen timestamp (for heartbeat)\n 366→pub async fn update_agent_last_seen(\n 367→ pool: &PgPool,\n 368→ id: Uuid,\n 369→) -> Result<(), sqlx::Error> {\n 370→ sqlx::query(\"UPDATE agents SET last_seen = NOW(), status = 'online' WHERE id = $1\")\n 371→ .bind(id)\n 372→ .execute(pool)\n 373→ .await?;\n 374→ Ok(())\n 375→}\n 376→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"b5481838-4702-4723-813a-9b7a8ade1235","timestamp":"2025-12-21T21:35:18.152Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\db\\agents.rs","content":"//! Agent database operations\n\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\n/// Agent record from database\n#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]\npub struct Agent {\n pub id: Uuid,\n pub hostname: String,\n #[serde(skip_serializing)]\n pub api_key_hash: Option<String>, // Nullable: new agents use site's api_key\n pub os_type: String,\n pub os_version: Option<String>,\n pub agent_version: Option<String>,\n pub last_seen: Option<DateTime<Utc>>,\n pub status: String,\n pub created_at: DateTime<Utc>,\n pub updated_at: DateTime<Utc>,\n // New fields for site-based registration\n pub device_id: Option<String>, // Hardware-derived unique ID\n pub site_id: Option<Uuid>, // Which site this agent belongs to\n}\n\n/// Agent without sensitive fields (for API responses)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AgentResponse {\n pub id: Uuid,\n pub hostname: String,\n pub os_type: String,\n pub os_version: Option<String>,\n pub agent_version: Option<String>,\n pub last_seen: Option<DateTime<Utc>>,\n pub status: String,\n pub created_at: DateTime<Utc>,\n pub device_id: Option<String>,\n pub site_id: Option<Uuid>,\n pub site_name: Option<String>,\n pub client_name: Option<String>,\n}\n\nimpl From<Agent> for AgentResponse {\n fn from(agent: Agent) -> Self {\n Self {\n id: agent.id,\n hostname: agent.hostname,\n os_type: agent.os_type,\n os_version: agent.os_version,\n agent_version: agent.agent_version,\n last_seen: agent.last_seen,\n status: agent.status,\n created_at: agent.created_at,\n device_id: agent.device_id,\n site_id: agent.site_id,\n site_name: None,\n client_name: None,\n }\n }\n}\n\n/// Create a new agent registration\n#[derive(Debug, Clone, Deserialize)]\npub struct CreateAgent {\n pub hostname: String,\n pub api_key_hash: String,\n pub os_type: String,\n pub os_version: Option<String>,\n pub agent_version: Option<String>,\n}\n\n/// Insert a new agent into the database\npub async fn create_agent(pool: &PgPool, agent: CreateAgent) -> Result<Agent, sqlx::Error> {\n sqlx::query_as::<_, Agent>(\n r#\"\n INSERT INTO agents (hostname, api_key_hash, os_type, os_version, agent_version, status)\n VALUES ($1, $2, $3, $4, $5, 'offline')\n RETURNING *\n \"#,\n )\n .bind(&agent.hostname)\n .bind(&agent.api_key_hash)\n .bind(&agent.os_type)\n .bind(&agent.os_version)\n .bind(&agent.agent_version)\n .fetch_one(pool)\n .await\n}\n\n/// Get an agent by ID\npub async fn get_agent_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Agent>, sqlx::Error> {\n sqlx::query_as::<_, Agent>(\"SELECT * FROM agents WHERE id = $1\")\n .bind(id)\n .fetch_optional(pool)\n .await\n}\n\n/// Get an agent by API key hash\npub async fn get_agent_by_api_key_hash(\n pool: &PgPool,\n api_key_hash: &str,\n) -> Result<Option<Agent>, sqlx::Error> {\n sqlx::query_as::<_, Agent>(\"SELECT * FROM agents WHERE api_key_hash = $1\")\n .bind(api_key_hash)\n .fetch_optional(pool)\n .await\n}\n\n/// Get all agents\npub async fn get_all_agents(pool: &PgPool) -> Result<Vec<Agent>, sqlx::Error> {\n sqlx::query_as::<_, Agent>(\"SELECT * FROM agents ORDER BY hostname\")\n .fetch_all(pool)\n .await\n}\n\n/// Get agents by status\npub async fn get_agents_by_status(\n pool: &PgPool,\n status: &str,\n) -> Result<Vec<Agent>, sqlx::Error> {\n sqlx::query_as::<_, Agent>(\"SELECT * FROM agents WHERE status = $1 ORDER BY hostname\")\n .bind(status)\n .fetch_all(pool)\n .await\n}\n\n/// Update agent status and last_seen\npub async fn update_agent_status(\n pool: &PgPool,\n id: Uuid,\n status: &str,\n) -> Result<(), sqlx::Error> {\n sqlx::query(\"UPDATE agents SET status = $1, last_seen = NOW() WHERE id = $2\")\n .bind(status)\n .bind(id)\n .execute(pool)\n .await?;\n Ok(())\n}\n\n/// Update agent info (on connection)\npub async fn update_agent_info(\n pool: &PgPool,\n id: Uuid,\n hostname: Option<&str>,\n os_version: Option<&str>,\n agent_version: Option<&str>,\n) -> Result<(), sqlx::Error> {\n sqlx::query(\n r#\"\n UPDATE agents\n SET hostname = COALESCE($1, hostname),\n os_version = COALESCE($2, os_version),\n agent_version = COALESCE($3, agent_version),\n last_seen = NOW(),\n status = 'online'\n WHERE id = $4\n \"#,\n )\n .bind(hostname)\n .bind(os_version)\n .bind(agent_version)\n .bind(id)\n .execute(pool)\n .await?;\n Ok(())\n}\n\n/// Delete an agent\npub async fn delete_agent(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {\n let result = sqlx::query(\"DELETE FROM agents WHERE id = $1\")\n .bind(id)\n .execute(pool)\n .await?;\n Ok(result.rows_affected() > 0)\n}\n\n/// Get agent count statistics\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AgentStats {\n pub total: i64,\n pub online: i64,\n pub offline: i64,\n}\n\npub async fn get_agent_stats(pool: &PgPool) -> Result<AgentStats, sqlx::Error> {\n let total: (i64,) = sqlx::query_as(\"SELECT COUNT(*) FROM agents\")\n .fetch_one(pool)\n .await?;\n\n let online: (i64,) =\n sqlx::query_as(\"SELECT COUNT(*) FROM agents WHERE status = 'online'\")\n .fetch_one(pool)\n .await?;\n\n Ok(AgentStats {\n total: total.0,\n online: online.0,\n offline: total.0 - online.0,\n })\n}\n\n// ============================================================================\n// Site-based agent operations\n// ============================================================================\n\n/// Data for creating an agent via site registration\n#[derive(Debug, Clone)]\npub struct CreateAgentWithSite {\n pub site_id: Uuid,\n pub device_id: String,\n pub hostname: String,\n pub os_type: String,\n pub os_version: Option<String>,\n pub agent_version: Option<String>,\n}\n\n/// Create a new agent under a site (site-based registration)\npub async fn create_agent_with_site(\n pool: &PgPool,\n agent: CreateAgentWithSite,\n) -> Result<Agent, sqlx::Error> {\n sqlx::query_as::<_, Agent>(\n r#\"\n INSERT INTO agents (site_id, device_id, hostname, os_type, os_version, agent_version, status)\n VALUES ($1, $2, $3, $4, $5, $6, 'offline')\n RETURNING *\n \"#,\n )\n .bind(&agent.site_id)\n .bind(&agent.device_id)\n .bind(&agent.hostname)\n .bind(&agent.os_type)\n .bind(&agent.os_version)\n .bind(&agent.agent_version)\n .fetch_one(pool)\n .await\n}\n\n/// Get an agent by site_id and device_id (for site-based auth)\npub async fn get_agent_by_site_and_device(\n pool: &PgPool,\n site_id: Uuid,\n device_id: &str,\n) -> Result<Option<Agent>, sqlx::Error> {\n sqlx::query_as::<_, Agent>(\n \"SELECT * FROM agents WHERE site_id = $1 AND device_id = $2\"\n )\n .bind(site_id)\n .bind(device_id)\n .fetch_optional(pool)\n .await\n}\n\n/// Get all agents for a site\npub async fn get_agents_by_site(pool: &PgPool, site_id: Uuid) -> Result<Vec<Agent>, sqlx::Error> {\n sqlx::query_as::<_, Agent>(\n \"SELECT * FROM agents WHERE site_id = $1 ORDER BY hostname\"\n )\n .bind(site_id)\n .fetch_all(pool)\n .await\n}\n\n/// Agent with site and client details for API responses\n#[derive(Debug, Clone, sqlx::FromRow, Serialize)]\npub struct AgentWithDetails {\n pub id: Uuid,\n pub hostname: String,\n pub os_type: String,\n pub os_version: Option<String>,\n pub agent_version: Option<String>,\n pub last_seen: Option<DateTime<Utc>>,\n pub status: String,\n pub created_at: DateTime<Utc>,\n pub device_id: Option<String>,\n pub site_id: Option<Uuid>,\n pub site_name: Option<String>,\n pub client_id: Option<Uuid>,\n pub client_name: Option<String>,\n}\n\n/// Get all agents with site/client details\npub async fn get_all_agents_with_details(pool: &PgPool) -> Result<Vec<AgentWithDetails>, sqlx::Error> {\n sqlx::query_as::<_, AgentWithDetails>(\n r#\"\n SELECT\n a.id, a.hostname, a.os_type, a.os_version, a.agent_version,\n a.last_seen, a.status, a.created_at, a.device_id, a.site_id,\n s.name as site_name,\n c.id as client_id,\n c.name as client_name\n FROM agents a\n LEFT JOIN sites s ON a.site_id = s.id\n LEFT JOIN clients c ON s.client_id = c.id\n ORDER BY c.name, s.name, a.hostname\n \"#,\n )\n .fetch_all(pool)\n .await\n}\n\n/// Update agent info including device_id (on connection)\npub async fn update_agent_info_full(\n pool: &PgPool,\n id: Uuid,\n hostname: Option<&str>,\n device_id: Option<&str>,\n os_version: Option<&str>,\n agent_version: Option<&str>,\n) -> Result<(), sqlx::Error> {\n sqlx::query(\n r#\"\n UPDATE agents\n SET hostname = COALESCE($1, hostname),\n device_id = COALESCE($2, device_id),\n os_version = COALESCE($3, os_version),\n agent_version = COALESCE($4, agent_version),\n last_seen = NOW(),\n status = 'online'\n WHERE id = $5\n \"#,\n )\n .bind(hostname)\n .bind(device_id)\n .bind(os_version)\n .bind(agent_version)\n .bind(id)\n .execute(pool)\n .await?;\n Ok(())\n}\n\n/// Move an agent to a different site (or remove from site if site_id is None)\npub async fn move_agent_to_site(\n pool: &PgPool,\n agent_id: Uuid,\n site_id: Option<Uuid>,\n) -> Result<Option<Agent>, sqlx::Error> {\n sqlx::query_as::<_, Agent>(\n r#\"\n UPDATE agents\n SET site_id = $1,\n updated_at = NOW()\n WHERE id = $2\n RETURNING *\n \"#,\n )\n .bind(site_id)\n .bind(agent_id)\n .fetch_optional(pool)\n .await\n}\n\n/// Get agents that are not assigned to any site\npub async fn get_unassigned_agents(pool: &PgPool) -> Result<Vec<Agent>, sqlx::Error> {\n sqlx::query_as::<_, Agent>(\n \"SELECT * FROM agents WHERE site_id IS NULL ORDER BY hostname\"\n )\n .fetch_all(pool)\n .await\n}\n\n/// Update agent last_seen timestamp (for heartbeat)\npub async fn update_agent_last_seen(\n pool: &PgPool,\n id: Uuid,\n) -> Result<(), sqlx::Error> {\n sqlx::query(\"UPDATE agents SET last_seen = NOW(), status = 'online' WHERE id = $1\")\n .bind(id)\n .execute(pool)\n .await?;\n Ok(())\n}\n","numLines":376,"startLine":1,"totalLines":376}}}
|
||
{"parentUuid":"b5481838-4702-4723-813a-9b7a8ade1235","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01X641ieF9rpuwQaLYHhijx1","type":"tool_result","content":" 1→//! Client (organization) database operations\n 2→\n 3→use serde::{Deserialize, Serialize};\n 4→use sqlx::PgPool;\n 5→use uuid::Uuid;\n 6→\n 7→/// Client record from database\n 8→#[derive(Debug, Clone, sqlx::FromRow)]\n 9→pub struct Client {\n 10→ pub id: Uuid,\n 11→ pub name: String,\n 12→ pub code: Option<String>,\n 13→ pub notes: Option<String>,\n 14→ pub is_active: bool,\n 15→ pub created_at: chrono::DateTime<chrono::Utc>,\n 16→ pub updated_at: chrono::DateTime<chrono::Utc>,\n 17→}\n 18→\n 19→/// Client response for API\n 20→#[derive(Debug, Clone, Serialize)]\n 21→pub struct ClientResponse {\n 22→ pub id: Uuid,\n 23→ pub name: String,\n 24→ pub code: Option<String>,\n 25→ pub notes: Option<String>,\n 26→ pub is_active: bool,\n 27→ pub created_at: chrono::DateTime<chrono::Utc>,\n 28→ pub site_count: Option<i64>,\n 29→ pub agent_count: Option<i64>,\n 30→}\n 31→\n 32→impl From<Client> for ClientResponse {\n 33→ fn from(c: Client) -> Self {\n 34→ ClientResponse {\n 35→ id: c.id,\n 36→ name: c.name,\n 37→ code: c.code,\n 38→ notes: c.notes,\n 39→ is_active: c.is_active,\n 40→ created_at: c.created_at,\n 41→ site_count: None,\n 42→ agent_count: None,\n 43→ }\n 44→ }\n 45→}\n 46→\n 47→/// Data for creating a new client\n 48→#[derive(Debug, Deserialize)]\n 49→pub struct CreateClient {\n 50→ pub name: String,\n 51→ pub code: Option<String>,\n 52→ pub notes: Option<String>,\n 53→}\n 54→\n 55→/// Data for updating a client\n 56→#[derive(Debug, Deserialize)]\n 57→pub struct UpdateClient {\n 58→ pub name: Option<String>,\n 59→ pub code: Option<String>,\n 60→ pub notes: Option<String>,\n 61→ pub is_active: Option<bool>,\n 62→}\n 63→\n 64→/// Create a new client\n 65→pub async fn create_client(pool: &PgPool, client: CreateClient) -> Result<Client, sqlx::Error> {\n 66→ sqlx::query_as::<_, Client>(\n 67→ r#\"\n 68→ INSERT INTO clients (name, code, notes)\n 69→ VALUES ($1, $2, $3)\n 70→ RETURNING *\n 71→ \"#,\n 72→ )\n 73→ .bind(&client.name)\n 74→ .bind(&client.code)\n 75→ .bind(&client.notes)\n 76→ .fetch_one(pool)\n 77→ .await\n 78→}\n 79→\n 80→/// Get a client by ID\n 81→pub async fn get_client_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Client>, sqlx::Error> {\n 82→ sqlx::query_as::<_, Client>(\"SELECT * FROM clients WHERE id = $1\")\n 83→ .bind(id)\n 84→ .fetch_optional(pool)\n 85→ .await\n 86→}\n 87→\n 88→/// Get all clients\n 89→pub async fn get_all_clients(pool: &PgPool) -> Result<Vec<Client>, sqlx::Error> {\n 90→ sqlx::query_as::<_, Client>(\"SELECT * FROM clients ORDER BY name\")\n 91→ .fetch_all(pool)\n 92→ .await\n 93→}\n 94→\n 95→/// Get all clients with counts\n 96→#[derive(Debug, Clone, sqlx::FromRow)]\n 97→pub struct ClientWithCounts {\n 98→ pub id: Uuid,\n 99→ pub name: String,\n 100→ pub code: Option<String>,\n 101→ pub notes: Option<String>,\n 102→ pub is_active: bool,\n 103→ pub created_at: chrono::DateTime<chrono::Utc>,\n 104→ pub updated_at: chrono::DateTime<chrono::Utc>,\n 105→ pub site_count: i64,\n 106→ pub agent_count: i64,\n 107→}\n 108→\n 109→pub async fn get_all_clients_with_counts(pool: &PgPool) -> Result<Vec<ClientWithCounts>, sqlx::Error> {\n 110→ sqlx::query_as::<_, ClientWithCounts>(\n 111→ r#\"\n 112→ SELECT\n 113→ c.*,\n 114→ COALESCE((SELECT COUNT(*) FROM sites WHERE client_id = c.id), 0) as site_count,\n 115→ COALESCE((SELECT COUNT(*) FROM agents a JOIN sites s ON a.site_id = s.id WHERE s.client_id = c.id), 0) as agent_count\n 116→ FROM clients c\n 117→ ORDER BY c.name\n 118→ \"#,\n 119→ )\n 120→ .fetch_all(pool)\n 121→ .await\n 122→}\n 123→\n 124→/// Update a client\n 125→pub async fn update_client(\n 126→ pool: &PgPool,\n 127→ id: Uuid,\n 128→ update: UpdateClient,\n 129→) -> Result<Option<Client>, sqlx::Error> {\n 130→ sqlx::query_as::<_, Client>(\n 131→ r#\"\n 132→ UPDATE clients\n 133→ SET name = COALESCE($1, name),\n 134→ code = COALESCE($2, code),\n 135→ notes = COALESCE($3, notes),\n 136→ is_active = COALESCE($4, is_active)\n 137→ WHERE id = $5\n 138→ RETURNING *\n 139→ \"#,\n 140→ )\n 141→ .bind(&update.name)\n 142→ .bind(&update.code)\n 143→ .bind(&update.notes)\n 144→ .bind(&update.is_active)\n 145→ .bind(id)\n 146→ .fetch_optional(pool)\n 147→ .await\n 148→}\n 149→\n 150→/// Delete a client\n 151→pub async fn delete_client(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {\n 152→ let result = sqlx::query(\"DELETE FROM clients WHERE id = $1\")\n 153→ .bind(id)\n 154→ .execute(pool)\n 155→ .await?;\n 156→ Ok(result.rows_affected() > 0)\n 157→}\n 158→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"917a3675-32af-414c-aed5-97e88e0d23b4","timestamp":"2025-12-21T21:35:18.165Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\db\\clients.rs","content":"//! Client (organization) database operations\n\nuse serde::{Deserialize, Serialize};\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\n/// Client record from database\n#[derive(Debug, Clone, sqlx::FromRow)]\npub struct Client {\n pub id: Uuid,\n pub name: String,\n pub code: Option<String>,\n pub notes: Option<String>,\n pub is_active: bool,\n pub created_at: chrono::DateTime<chrono::Utc>,\n pub updated_at: chrono::DateTime<chrono::Utc>,\n}\n\n/// Client response for API\n#[derive(Debug, Clone, Serialize)]\npub struct ClientResponse {\n pub id: Uuid,\n pub name: String,\n pub code: Option<String>,\n pub notes: Option<String>,\n pub is_active: bool,\n pub created_at: chrono::DateTime<chrono::Utc>,\n pub site_count: Option<i64>,\n pub agent_count: Option<i64>,\n}\n\nimpl From<Client> for ClientResponse {\n fn from(c: Client) -> Self {\n ClientResponse {\n id: c.id,\n name: c.name,\n code: c.code,\n notes: c.notes,\n is_active: c.is_active,\n created_at: c.created_at,\n site_count: None,\n agent_count: None,\n }\n }\n}\n\n/// Data for creating a new client\n#[derive(Debug, Deserialize)]\npub struct CreateClient {\n pub name: String,\n pub code: Option<String>,\n pub notes: Option<String>,\n}\n\n/// Data for updating a client\n#[derive(Debug, Deserialize)]\npub struct UpdateClient {\n pub name: Option<String>,\n pub code: Option<String>,\n pub notes: Option<String>,\n pub is_active: Option<bool>,\n}\n\n/// Create a new client\npub async fn create_client(pool: &PgPool, client: CreateClient) -> Result<Client, sqlx::Error> {\n sqlx::query_as::<_, Client>(\n r#\"\n INSERT INTO clients (name, code, notes)\n VALUES ($1, $2, $3)\n RETURNING *\n \"#,\n )\n .bind(&client.name)\n .bind(&client.code)\n .bind(&client.notes)\n .fetch_one(pool)\n .await\n}\n\n/// Get a client by ID\npub async fn get_client_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Client>, sqlx::Error> {\n sqlx::query_as::<_, Client>(\"SELECT * FROM clients WHERE id = $1\")\n .bind(id)\n .fetch_optional(pool)\n .await\n}\n\n/// Get all clients\npub async fn get_all_clients(pool: &PgPool) -> Result<Vec<Client>, sqlx::Error> {\n sqlx::query_as::<_, Client>(\"SELECT * FROM clients ORDER BY name\")\n .fetch_all(pool)\n .await\n}\n\n/// Get all clients with counts\n#[derive(Debug, Clone, sqlx::FromRow)]\npub struct ClientWithCounts {\n pub id: Uuid,\n pub name: String,\n pub code: Option<String>,\n pub notes: Option<String>,\n pub is_active: bool,\n pub created_at: chrono::DateTime<chrono::Utc>,\n pub updated_at: chrono::DateTime<chrono::Utc>,\n pub site_count: i64,\n pub agent_count: i64,\n}\n\npub async fn get_all_clients_with_counts(pool: &PgPool) -> Result<Vec<ClientWithCounts>, sqlx::Error> {\n sqlx::query_as::<_, ClientWithCounts>(\n r#\"\n SELECT\n c.*,\n COALESCE((SELECT COUNT(*) FROM sites WHERE client_id = c.id), 0) as site_count,\n COALESCE((SELECT COUNT(*) FROM agents a JOIN sites s ON a.site_id = s.id WHERE s.client_id = c.id), 0) as agent_count\n FROM clients c\n ORDER BY c.name\n \"#,\n )\n .fetch_all(pool)\n .await\n}\n\n/// Update a client\npub async fn update_client(\n pool: &PgPool,\n id: Uuid,\n update: UpdateClient,\n) -> Result<Option<Client>, sqlx::Error> {\n sqlx::query_as::<_, Client>(\n r#\"\n UPDATE clients\n SET name = COALESCE($1, name),\n code = COALESCE($2, code),\n notes = COALESCE($3, notes),\n is_active = COALESCE($4, is_active)\n WHERE id = $5\n RETURNING *\n \"#,\n )\n .bind(&update.name)\n .bind(&update.code)\n .bind(&update.notes)\n .bind(&update.is_active)\n .bind(id)\n .fetch_optional(pool)\n .await\n}\n\n/// Delete a client\npub async fn delete_client(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {\n let result = sqlx::query(\"DELETE FROM clients WHERE id = $1\")\n .bind(id)\n .execute(pool)\n .await?;\n Ok(result.rows_affected() > 0)\n}\n","numLines":158,"startLine":1,"totalLines":158}}}
|
||
{"parentUuid":"917a3675-32af-414c-aed5-97e88e0d23b4","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KJ9SvvF2xvsTSZXSTmQz8y","type":"tool_result","content":" 1→//! Site database operations\n 2→\n 3→use rand::Rng;\n 4→use serde::{Deserialize, Serialize};\n 5→use sqlx::PgPool;\n 6→use uuid::Uuid;\n 7→\n 8→/// Word lists for generating site codes\n 9→const ADJECTIVES: &[&str] = &[\n 10→ \"BLUE\", \"GREEN\", \"RED\", \"GOLD\", \"SILVER\", \"IRON\", \"COPPER\", \"BRONZE\",\n 11→ \"SWIFT\", \"BRIGHT\", \"DARK\", \"LIGHT\", \"BOLD\", \"CALM\", \"WILD\", \"WARM\",\n 12→ \"NORTH\", \"SOUTH\", \"EAST\", \"WEST\", \"UPPER\", \"LOWER\", \"INNER\", \"OUTER\",\n 13→];\n 14→\n 15→const NOUNS: &[&str] = &[\n 16→ \"HAWK\", \"EAGLE\", \"TIGER\", \"LION\", \"WOLF\", \"BEAR\", \"FALCON\", \"PHOENIX\",\n 17→ \"PEAK\", \"VALLEY\", \"RIVER\", \"OCEAN\", \"STORM\", \"CLOUD\", \"STAR\", \"MOON\",\n 18→ \"TOWER\", \"BRIDGE\", \"GATE\", \"FORGE\", \"CASTLE\", \"HARBOR\", \"MEADOW\", \"GROVE\",\n 19→];\n 20→\n 21→/// Generate a human-friendly site code (e.g., \"BLUE-TIGER-4829\")\n 22→pub fn generate_site_code() -> String {\n 23→ let mut rng = rand::thread_rng();\n 24→ let adj = ADJECTIVES[rng.gen_range(0..ADJECTIVES.len())];\n 25→ let noun = NOUNS[rng.gen_range(0..NOUNS.len())];\n 26→ let num: u16 = rng.gen_range(1000..9999);\n 27→ format!(\"{}-{}-{}\", adj, noun, num)\n 28→}\n 29→\n 30→/// Site record from database\n 31→#[derive(Debug, Clone, sqlx::FromRow)]\n 32→pub struct Site {\n 33→ pub id: Uuid,\n 34→ pub client_id: Uuid,\n 35→ pub name: String,\n 36→ pub site_code: String,\n 37→ pub api_key_hash: String,\n 38→ pub address: Option<String>,\n 39→ pub notes: Option<String>,\n 40→ pub is_active: bool,\n 41→ pub created_at: chrono::DateTime<chrono::Utc>,\n 42→ pub updated_at: chrono::DateTime<chrono::Utc>,\n 43→}\n 44→\n 45→/// Site response for API\n 46→#[derive(Debug, Clone, Serialize)]\n 47→pub struct SiteResponse {\n 48→ pub id: Uuid,\n 49→ pub client_id: Uuid,\n 50→ pub client_name: Option<String>,\n 51→ pub name: String,\n 52→ pub site_code: String,\n 53→ pub address: Option<String>,\n 54→ pub notes: Option<String>,\n 55→ pub is_active: bool,\n 56→ pub created_at: chrono::DateTime<chrono::Utc>,\n 57→ pub agent_count: Option<i64>,\n 58→}\n 59→\n 60→impl From<Site> for SiteResponse {\n 61→ fn from(s: Site) -> Self {\n 62→ SiteResponse {\n 63→ id: s.id,\n 64→ client_id: s.client_id,\n 65→ client_name: None,\n 66→ name: s.name,\n 67→ site_code: s.site_code,\n 68→ address: s.address,\n 69→ notes: s.notes,\n 70→ is_active: s.is_active,\n 71→ created_at: s.created_at,\n 72→ agent_count: None,\n 73→ }\n 74→ }\n 75→}\n 76→\n 77→/// Data for creating a new site\n 78→#[derive(Debug, Deserialize)]\n 79→pub struct CreateSite {\n 80→ pub client_id: Uuid,\n 81→ pub name: String,\n 82→ pub address: Option<String>,\n 83→ pub notes: Option<String>,\n 84→}\n 85→\n 86→/// Internal create with all fields\n 87→pub struct CreateSiteInternal {\n 88→ pub client_id: Uuid,\n 89→ pub name: String,\n 90→ pub site_code: String,\n 91→ pub api_key_hash: String,\n 92→ pub address: Option<String>,\n 93→ pub notes: Option<String>,\n 94→}\n 95→\n 96→/// Data for updating a site\n 97→#[derive(Debug, Deserialize)]\n 98→pub struct UpdateSite {\n 99→ pub name: Option<String>,\n 100→ pub address: Option<String>,\n 101→ pub notes: Option<String>,\n 102→ pub is_active: Option<bool>,\n 103→}\n 104→\n 105→/// Create a new site\n 106→pub async fn create_site(pool: &PgPool, site: CreateSiteInternal) -> Result<Site, sqlx::Error> {\n 107→ sqlx::query_as::<_, Site>(\n 108→ r#\"\n 109→ INSERT INTO sites (client_id, name, site_code, api_key_hash, address, notes)\n 110→ VALUES ($1, $2, $3, $4, $5, $6)\n 111→ RETURNING *\n 112→ \"#,\n 113→ )\n 114→ .bind(&site.client_id)\n 115→ .bind(&site.name)\n 116→ .bind(&site.site_code)\n 117→ .bind(&site.api_key_hash)\n 118→ .bind(&site.address)\n 119→ .bind(&site.notes)\n 120→ .fetch_one(pool)\n 121→ .await\n 122→}\n 123→\n 124→/// Get a site by ID\n 125→pub async fn get_site_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Site>, sqlx::Error> {\n 126→ sqlx::query_as::<_, Site>(\"SELECT * FROM sites WHERE id = $1\")\n 127→ .bind(id)\n 128→ .fetch_optional(pool)\n 129→ .await\n 130→}\n 131→\n 132→/// Get a site by site code\n 133→pub async fn get_site_by_code(pool: &PgPool, site_code: &str) -> Result<Option<Site>, sqlx::Error> {\n 134→ sqlx::query_as::<_, Site>(\"SELECT * FROM sites WHERE site_code = $1 AND is_active = true\")\n 135→ .bind(site_code.to_uppercase())\n 136→ .fetch_optional(pool)\n 137→ .await\n 138→}\n 139→\n 140→/// Get a site by API key hash\n 141→pub async fn get_site_by_api_key_hash(pool: &PgPool, api_key_hash: &str) -> Result<Option<Site>, sqlx::Error> {\n 142→ sqlx::query_as::<_, Site>(\"SELECT * FROM sites WHERE api_key_hash = $1 AND is_active = true\")\n 143→ .bind(api_key_hash)\n 144→ .fetch_optional(pool)\n 145→ .await\n 146→}\n 147→\n 148→/// Get all sites for a client\n 149→pub async fn get_sites_by_client(pool: &PgPool, client_id: Uuid) -> Result<Vec<Site>, sqlx::Error> {\n 150→ sqlx::query_as::<_, Site>(\"SELECT * FROM sites WHERE client_id = $1 ORDER BY name\")\n 151→ .bind(client_id)\n 152→ .fetch_all(pool)\n 153→ .await\n 154→}\n 155→\n 156→/// Get all sites\n 157→pub async fn get_all_sites(pool: &PgPool) -> Result<Vec<Site>, sqlx::Error> {\n 158→ sqlx::query_as::<_, Site>(\"SELECT * FROM sites ORDER BY name\")\n 159→ .fetch_all(pool)\n 160→ .await\n 161→}\n 162→\n 163→/// Site with client name and agent count\n 164→#[derive(Debug, Clone, sqlx::FromRow)]\n 165→pub struct SiteWithDetails {\n 166→ pub id: Uuid,\n 167→ pub client_id: Uuid,\n 168→ pub name: String,\n 169→ pub site_code: String,\n 170→ pub api_key_hash: String,\n 171→ pub address: Option<String>,\n 172→ pub notes: Option<String>,\n 173→ pub is_active: bool,\n 174→ pub created_at: chrono::DateTime<chrono::Utc>,\n 175→ pub updated_at: chrono::DateTime<chrono::Utc>,\n 176→ pub client_name: String,\n 177→ pub agent_count: i64,\n 178→}\n 179→\n 180→pub async fn get_all_sites_with_details(pool: &PgPool) -> Result<Vec<SiteWithDetails>, sqlx::Error> {\n 181→ sqlx::query_as::<_, SiteWithDetails>(\n 182→ r#\"\n 183→ SELECT\n 184→ s.*,\n 185→ c.name as client_name,\n 186→ COALESCE((SELECT COUNT(*) FROM agents WHERE site_id = s.id), 0) as agent_count\n 187→ FROM sites s\n 188→ JOIN clients c ON s.client_id = c.id\n 189→ ORDER BY c.name, s.name\n 190→ \"#,\n 191→ )\n 192→ .fetch_all(pool)\n 193→ .await\n 194→}\n 195→\n 196→/// Update a site\n 197→pub async fn update_site(\n 198→ pool: &PgPool,\n 199→ id: Uuid,\n 200→ update: UpdateSite,\n 201→) -> Result<Option<Site>, sqlx::Error> {\n 202→ sqlx::query_as::<_, Site>(\n 203→ r#\"\n 204→ UPDATE sites\n 205→ SET name = COALESCE($1, name),\n 206→ address = COALESCE($2, address),\n 207→ notes = COALESCE($3, notes),\n 208→ is_active = COALESCE($4, is_active)\n 209→ WHERE id = $5\n 210→ RETURNING *\n 211→ \"#,\n 212→ )\n 213→ .bind(&update.name)\n 214→ .bind(&update.address)\n 215→ .bind(&update.notes)\n 216→ .bind(&update.is_active)\n 217→ .bind(id)\n 218→ .fetch_optional(pool)\n 219→ .await\n 220→}\n 221→\n 222→/// Regenerate API key for a site\n 223→pub async fn regenerate_site_api_key(\n 224→ pool: &PgPool,\n 225→ id: Uuid,\n 226→ new_api_key_hash: &str,\n 227→) -> Result<bool, sqlx::Error> {\n 228→ let result = sqlx::query(\"UPDATE sites SET api_key_hash = $1 WHERE id = $2\")\n 229→ .bind(new_api_key_hash)\n 230→ .bind(id)\n 231→ .execute(pool)\n 232→ .await?;\n 233→ Ok(result.rows_affected() > 0)\n 234→}\n 235→\n 236→/// Delete a site\n 237→pub async fn delete_site(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {\n 238→ let result = sqlx::query(\"DELETE FROM sites WHERE id = $1\")\n 239→ .bind(id)\n 240→ .execute(pool)\n 241→ .await?;\n 242→ Ok(result.rows_affected() > 0)\n 243→}\n 244→\n 245→/// Check if a site code is unique\n 246→pub async fn is_site_code_unique(pool: &PgPool, site_code: &str) -> Result<bool, sqlx::Error> {\n 247→ let result: (i64,) = sqlx::query_as(\"SELECT COUNT(*) FROM sites WHERE site_code = $1\")\n 248→ .bind(site_code.to_uppercase())\n 249→ .fetch_one(pool)\n 250→ .await?;\n 251→ Ok(result.0 == 0)\n 252→}\n 253→\n 254→/// Generate a unique site code (tries up to 10 times)\n 255→pub async fn generate_unique_site_code(pool: &PgPool) -> Result<String, sqlx::Error> {\n 256→ for _ in 0..10 {\n 257→ let code = generate_site_code();\n 258→ if is_site_code_unique(pool, &code).await? {\n 259→ return Ok(code);\n 260→ }\n 261→ }\n 262→ // Fallback: add random suffix\n 263→ Ok(format!(\"{}-{}\", generate_site_code(), rand::thread_rng().gen_range(100..999)))\n 264→}\n 265→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"f988987b-c62b-4b6d-a5ac-3e47d2c7c817","timestamp":"2025-12-21T21:35:18.174Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\db\\sites.rs","content":"//! Site database operations\n\nuse rand::Rng;\nuse serde::{Deserialize, Serialize};\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\n/// Word lists for generating site codes\nconst ADJECTIVES: &[&str] = &[\n \"BLUE\", \"GREEN\", \"RED\", \"GOLD\", \"SILVER\", \"IRON\", \"COPPER\", \"BRONZE\",\n \"SWIFT\", \"BRIGHT\", \"DARK\", \"LIGHT\", \"BOLD\", \"CALM\", \"WILD\", \"WARM\",\n \"NORTH\", \"SOUTH\", \"EAST\", \"WEST\", \"UPPER\", \"LOWER\", \"INNER\", \"OUTER\",\n];\n\nconst NOUNS: &[&str] = &[\n \"HAWK\", \"EAGLE\", \"TIGER\", \"LION\", \"WOLF\", \"BEAR\", \"FALCON\", \"PHOENIX\",\n \"PEAK\", \"VALLEY\", \"RIVER\", \"OCEAN\", \"STORM\", \"CLOUD\", \"STAR\", \"MOON\",\n \"TOWER\", \"BRIDGE\", \"GATE\", \"FORGE\", \"CASTLE\", \"HARBOR\", \"MEADOW\", \"GROVE\",\n];\n\n/// Generate a human-friendly site code (e.g., \"BLUE-TIGER-4829\")\npub fn generate_site_code() -> String {\n let mut rng = rand::thread_rng();\n let adj = ADJECTIVES[rng.gen_range(0..ADJECTIVES.len())];\n let noun = NOUNS[rng.gen_range(0..NOUNS.len())];\n let num: u16 = rng.gen_range(1000..9999);\n format!(\"{}-{}-{}\", adj, noun, num)\n}\n\n/// Site record from database\n#[derive(Debug, Clone, sqlx::FromRow)]\npub struct Site {\n pub id: Uuid,\n pub client_id: Uuid,\n pub name: String,\n pub site_code: String,\n pub api_key_hash: String,\n pub address: Option<String>,\n pub notes: Option<String>,\n pub is_active: bool,\n pub created_at: chrono::DateTime<chrono::Utc>,\n pub updated_at: chrono::DateTime<chrono::Utc>,\n}\n\n/// Site response for API\n#[derive(Debug, Clone, Serialize)]\npub struct SiteResponse {\n pub id: Uuid,\n pub client_id: Uuid,\n pub client_name: Option<String>,\n pub name: String,\n pub site_code: String,\n pub address: Option<String>,\n pub notes: Option<String>,\n pub is_active: bool,\n pub created_at: chrono::DateTime<chrono::Utc>,\n pub agent_count: Option<i64>,\n}\n\nimpl From<Site> for SiteResponse {\n fn from(s: Site) -> Self {\n SiteResponse {\n id: s.id,\n client_id: s.client_id,\n client_name: None,\n name: s.name,\n site_code: s.site_code,\n address: s.address,\n notes: s.notes,\n is_active: s.is_active,\n created_at: s.created_at,\n agent_count: None,\n }\n }\n}\n\n/// Data for creating a new site\n#[derive(Debug, Deserialize)]\npub struct CreateSite {\n pub client_id: Uuid,\n pub name: String,\n pub address: Option<String>,\n pub notes: Option<String>,\n}\n\n/// Internal create with all fields\npub struct CreateSiteInternal {\n pub client_id: Uuid,\n pub name: String,\n pub site_code: String,\n pub api_key_hash: String,\n pub address: Option<String>,\n pub notes: Option<String>,\n}\n\n/// Data for updating a site\n#[derive(Debug, Deserialize)]\npub struct UpdateSite {\n pub name: Option<String>,\n pub address: Option<String>,\n pub notes: Option<String>,\n pub is_active: Option<bool>,\n}\n\n/// Create a new site\npub async fn create_site(pool: &PgPool, site: CreateSiteInternal) -> Result<Site, sqlx::Error> {\n sqlx::query_as::<_, Site>(\n r#\"\n INSERT INTO sites (client_id, name, site_code, api_key_hash, address, notes)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING *\n \"#,\n )\n .bind(&site.client_id)\n .bind(&site.name)\n .bind(&site.site_code)\n .bind(&site.api_key_hash)\n .bind(&site.address)\n .bind(&site.notes)\n .fetch_one(pool)\n .await\n}\n\n/// Get a site by ID\npub async fn get_site_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Site>, sqlx::Error> {\n sqlx::query_as::<_, Site>(\"SELECT * FROM sites WHERE id = $1\")\n .bind(id)\n .fetch_optional(pool)\n .await\n}\n\n/// Get a site by site code\npub async fn get_site_by_code(pool: &PgPool, site_code: &str) -> Result<Option<Site>, sqlx::Error> {\n sqlx::query_as::<_, Site>(\"SELECT * FROM sites WHERE site_code = $1 AND is_active = true\")\n .bind(site_code.to_uppercase())\n .fetch_optional(pool)\n .await\n}\n\n/// Get a site by API key hash\npub async fn get_site_by_api_key_hash(pool: &PgPool, api_key_hash: &str) -> Result<Option<Site>, sqlx::Error> {\n sqlx::query_as::<_, Site>(\"SELECT * FROM sites WHERE api_key_hash = $1 AND is_active = true\")\n .bind(api_key_hash)\n .fetch_optional(pool)\n .await\n}\n\n/// Get all sites for a client\npub async fn get_sites_by_client(pool: &PgPool, client_id: Uuid) -> Result<Vec<Site>, sqlx::Error> {\n sqlx::query_as::<_, Site>(\"SELECT * FROM sites WHERE client_id = $1 ORDER BY name\")\n .bind(client_id)\n .fetch_all(pool)\n .await\n}\n\n/// Get all sites\npub async fn get_all_sites(pool: &PgPool) -> Result<Vec<Site>, sqlx::Error> {\n sqlx::query_as::<_, Site>(\"SELECT * FROM sites ORDER BY name\")\n .fetch_all(pool)\n .await\n}\n\n/// Site with client name and agent count\n#[derive(Debug, Clone, sqlx::FromRow)]\npub struct SiteWithDetails {\n pub id: Uuid,\n pub client_id: Uuid,\n pub name: String,\n pub site_code: String,\n pub api_key_hash: String,\n pub address: Option<String>,\n pub notes: Option<String>,\n pub is_active: bool,\n pub created_at: chrono::DateTime<chrono::Utc>,\n pub updated_at: chrono::DateTime<chrono::Utc>,\n pub client_name: String,\n pub agent_count: i64,\n}\n\npub async fn get_all_sites_with_details(pool: &PgPool) -> Result<Vec<SiteWithDetails>, sqlx::Error> {\n sqlx::query_as::<_, SiteWithDetails>(\n r#\"\n SELECT\n s.*,\n c.name as client_name,\n COALESCE((SELECT COUNT(*) FROM agents WHERE site_id = s.id), 0) as agent_count\n FROM sites s\n JOIN clients c ON s.client_id = c.id\n ORDER BY c.name, s.name\n \"#,\n )\n .fetch_all(pool)\n .await\n}\n\n/// Update a site\npub async fn update_site(\n pool: &PgPool,\n id: Uuid,\n update: UpdateSite,\n) -> Result<Option<Site>, sqlx::Error> {\n sqlx::query_as::<_, Site>(\n r#\"\n UPDATE sites\n SET name = COALESCE($1, name),\n address = COALESCE($2, address),\n notes = COALESCE($3, notes),\n is_active = COALESCE($4, is_active)\n WHERE id = $5\n RETURNING *\n \"#,\n )\n .bind(&update.name)\n .bind(&update.address)\n .bind(&update.notes)\n .bind(&update.is_active)\n .bind(id)\n .fetch_optional(pool)\n .await\n}\n\n/// Regenerate API key for a site\npub async fn regenerate_site_api_key(\n pool: &PgPool,\n id: Uuid,\n new_api_key_hash: &str,\n) -> Result<bool, sqlx::Error> {\n let result = sqlx::query(\"UPDATE sites SET api_key_hash = $1 WHERE id = $2\")\n .bind(new_api_key_hash)\n .bind(id)\n .execute(pool)\n .await?;\n Ok(result.rows_affected() > 0)\n}\n\n/// Delete a site\npub async fn delete_site(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {\n let result = sqlx::query(\"DELETE FROM sites WHERE id = $1\")\n .bind(id)\n .execute(pool)\n .await?;\n Ok(result.rows_affected() > 0)\n}\n\n/// Check if a site code is unique\npub async fn is_site_code_unique(pool: &PgPool, site_code: &str) -> Result<bool, sqlx::Error> {\n let result: (i64,) = sqlx::query_as(\"SELECT COUNT(*) FROM sites WHERE site_code = $1\")\n .bind(site_code.to_uppercase())\n .fetch_one(pool)\n .await?;\n Ok(result.0 == 0)\n}\n\n/// Generate a unique site code (tries up to 10 times)\npub async fn generate_unique_site_code(pool: &PgPool) -> Result<String, sqlx::Error> {\n for _ in 0..10 {\n let code = generate_site_code();\n if is_site_code_unique(pool, &code).await? {\n return Ok(code);\n }\n }\n // Fallback: add random suffix\n Ok(format!(\"{}-{}\", generate_site_code(), rand::thread_rng().gen_range(100..999)))\n}\n","numLines":265,"startLine":1,"totalLines":265}}}
|
||
{"parentUuid":"f988987b-c62b-4b6d-a5ac-3e47d2c7c817","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RjYwxJDrkkiXrM4eytccxP","type":"tool_result","content":" 1→//! REST API routes\n 2→//!\n 3→//! Provides endpoints for:\n 4→//! - Agent management (registration, listing, deletion)\n 5→//! - Client and site management\n 6→//! - Metrics retrieval\n 7→//! - Command execution\n 8→//! - User authentication\n 9→\n 10→pub mod agents;\n 11→pub mod auth;\n 12→pub mod clients;\n 13→pub mod commands;\n 14→pub mod metrics;\n 15→pub mod sites;\n 16→pub mod sso;\n 17→pub mod users;\n 18→\n 19→use axum::{\n 20→ routing::{delete, get, post, put},\n 21→ Router,\n 22→};\n 23→\n 24→use crate::AppState;\n 25→\n 26→/// Build all API routes\n 27→pub fn routes() -> Router<AppState> {\n 28→ Router::new()\n 29→ // Authentication\n 30→ .route(\"/auth/login\", post(auth::login))\n 31→ .route(\"/auth/register\", post(auth::register))\n 32→ .route(\"/auth/me\", get(auth::me))\n 33→ // SSO\n 34→ .route(\"/auth/sso/entra/config\", get(sso::entra_config))\n 35→ .route(\"/auth/sso/entra/callback\", post(sso::entra_callback))\n 36→ // Users (admin)\n 37→ .route(\"/users\", get(users::list_users))\n 38→ .route(\"/users/:id\", get(users::get_user))\n 39→ .route(\"/users/:id\", delete(users::delete_user))\n 40→ .route(\"/users/:id/role\", put(users::update_user_role))\n 41→ // Clients\n 42→ .route(\"/clients\", get(clients::list_clients))\n 43→ .route(\"/clients\", post(clients::create_client))\n 44→ .route(\"/clients/:id\", get(clients::get_client))\n 45→ .route(\"/clients/:id\", put(clients::update_client))\n 46→ .route(\"/clients/:id\", delete(clients::delete_client))\n 47→ .route(\"/clients/:id/sites\", get(sites::list_sites_by_client))\n 48→ // Sites\n 49→ .route(\"/sites\", get(sites::list_sites))\n 50→ .route(\"/sites\", post(sites::create_site))\n 51→ .route(\"/sites/:id\", get(sites::get_site))\n 52→ .route(\"/sites/:id\", put(sites::update_site))\n 53→ .route(\"/sites/:id\", delete(sites::delete_site))\n 54→ .route(\"/sites/:id/regenerate-key\", post(sites::regenerate_api_key))\n 55→ // Agents\n 56→ .route(\"/agents\", get(agents::list_agents_with_details))\n 57→ .route(\"/agents\", post(agents::register_agent))\n 58→ .route(\"/agents/stats\", get(agents::get_stats))\n 59→ .route(\"/agents/unassigned\", get(agents::list_unassigned_agents))\n 60→ .route(\"/agents/:id\", get(agents::get_agent))\n 61→ .route(\"/agents/:id\", delete(agents::delete_agent))\n 62→ .route(\"/agents/:id/move\", post(agents::move_agent))\n 63→ .route(\"/agents/:id/state\", get(agents::get_agent_state))\n 64→ .route(\"/agents/:id/update\", post(agents::trigger_update))\n 65→ // Metrics\n 66→ .route(\"/agents/:id/metrics\", get(metrics::get_agent_metrics))\n 67→ .route(\"/metrics/summary\", get(metrics::get_summary))\n 68→ // Commands\n 69→ .route(\"/agents/:id/command\", post(commands::send_command))\n 70→ .route(\"/commands\", get(commands::list_commands))\n 71→ .route(\"/commands/:id\", get(commands::get_command))\n 72→ // Legacy Agent (PowerShell for 2008 R2)\n 73→ .route(\"/agent/register-legacy\", post(agents::register_legacy))\n 74→ .route(\"/agent/heartbeat\", post(agents::heartbeat))\n 75→ .route(\"/agent/command-result\", post(agents::command_result))\n 76→}\n 77→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"95470bc3-9c71-4648-aacb-841c0747ad9e","timestamp":"2025-12-21T21:35:18.183Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\mod.rs","content":"//! REST API routes\n//!\n//! Provides endpoints for:\n//! - Agent management (registration, listing, deletion)\n//! - Client and site management\n//! - Metrics retrieval\n//! - Command execution\n//! - User authentication\n\npub mod agents;\npub mod auth;\npub mod clients;\npub mod commands;\npub mod metrics;\npub mod sites;\npub mod sso;\npub mod users;\n\nuse axum::{\n routing::{delete, get, post, put},\n Router,\n};\n\nuse crate::AppState;\n\n/// Build all API routes\npub fn routes() -> Router<AppState> {\n Router::new()\n // Authentication\n .route(\"/auth/login\", post(auth::login))\n .route(\"/auth/register\", post(auth::register))\n .route(\"/auth/me\", get(auth::me))\n // SSO\n .route(\"/auth/sso/entra/config\", get(sso::entra_config))\n .route(\"/auth/sso/entra/callback\", post(sso::entra_callback))\n // Users (admin)\n .route(\"/users\", get(users::list_users))\n .route(\"/users/:id\", get(users::get_user))\n .route(\"/users/:id\", delete(users::delete_user))\n .route(\"/users/:id/role\", put(users::update_user_role))\n // Clients\n .route(\"/clients\", get(clients::list_clients))\n .route(\"/clients\", post(clients::create_client))\n .route(\"/clients/:id\", get(clients::get_client))\n .route(\"/clients/:id\", put(clients::update_client))\n .route(\"/clients/:id\", delete(clients::delete_client))\n .route(\"/clients/:id/sites\", get(sites::list_sites_by_client))\n // Sites\n .route(\"/sites\", get(sites::list_sites))\n .route(\"/sites\", post(sites::create_site))\n .route(\"/sites/:id\", get(sites::get_site))\n .route(\"/sites/:id\", put(sites::update_site))\n .route(\"/sites/:id\", delete(sites::delete_site))\n .route(\"/sites/:id/regenerate-key\", post(sites::regenerate_api_key))\n // Agents\n .route(\"/agents\", get(agents::list_agents_with_details))\n .route(\"/agents\", post(agents::register_agent))\n .route(\"/agents/stats\", get(agents::get_stats))\n .route(\"/agents/unassigned\", get(agents::list_unassigned_agents))\n .route(\"/agents/:id\", get(agents::get_agent))\n .route(\"/agents/:id\", delete(agents::delete_agent))\n .route(\"/agents/:id/move\", post(agents::move_agent))\n .route(\"/agents/:id/state\", get(agents::get_agent_state))\n .route(\"/agents/:id/update\", post(agents::trigger_update))\n // Metrics\n .route(\"/agents/:id/metrics\", get(metrics::get_agent_metrics))\n .route(\"/metrics/summary\", get(metrics::get_summary))\n // Commands\n .route(\"/agents/:id/command\", post(commands::send_command))\n .route(\"/commands\", get(commands::list_commands))\n .route(\"/commands/:id\", get(commands::get_command))\n // Legacy Agent (PowerShell for 2008 R2)\n .route(\"/agent/register-legacy\", post(agents::register_legacy))\n .route(\"/agent/heartbeat\", post(agents::heartbeat))\n .route(\"/agent/command-result\", post(agents::command_result))\n}\n","numLines":77,"startLine":1,"totalLines":77}}}
|
||
{"parentUuid":"95470bc3-9c71-4648-aacb-841c0747ad9e","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XGavTjbTAbhvNiBBrXFN1j","type":"message","role":"assistant","content":[{"type":"text","text":"Let me also examine the existing metrics structure and the agent's IPC/tray module to understand the TrayPolicy pattern mentioned."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12310,"cache_read_input_tokens":29083,"cache_creation":{"ephemeral_5m_input_tokens":12310,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWLYwGrZgyu1ynKgw6FRf","type":"assistant","uuid":"0aa46180-156b-468b-9981-025724cce86f","timestamp":"2025-12-21T21:35:27.033Z"}
|
||
{"parentUuid":"0aa46180-156b-468b-9981-025724cce86f","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XGavTjbTAbhvNiBBrXFN1j","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QDYQ9p3Hd2a7rrVUwo7N8x","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\db\\metrics.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12310,"cache_read_input_tokens":29083,"cache_creation":{"ephemeral_5m_input_tokens":12310,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWLYwGrZgyu1ynKgw6FRf","type":"assistant","uuid":"679db70d-480e-47bd-a3a0-ca6e50960744","timestamp":"2025-12-21T21:35:28.205Z"}
|
||
{"parentUuid":"679db70d-480e-47bd-a3a0-ca6e50960744","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XGavTjbTAbhvNiBBrXFN1j","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018t6HrmdPa1rrDBQKTd2Z2T","name":"Glob","input":{"pattern":"**/ipc*.rs","path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12310,"cache_read_input_tokens":29083,"cache_creation":{"ephemeral_5m_input_tokens":12310,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWLYwGrZgyu1ynKgw6FRf","type":"assistant","uuid":"a2f0e451-6308-4a67-917a-50eac6b147e1","timestamp":"2025-12-21T21:35:29.121Z"}
|
||
{"parentUuid":"a2f0e451-6308-4a67-917a-50eac6b147e1","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XGavTjbTAbhvNiBBrXFN1j","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XgAe7d4uSjW3VWi2MDLzF2","name":"Glob","input":{"pattern":"**/*.sql","path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":12310,"cache_read_input_tokens":29083,"cache_creation":{"ephemeral_5m_input_tokens":12310,"ephemeral_1h_input_tokens":0},"output_tokens":256,"service_tier":"standard"},"context_management":{"applied_edits":[]}},"requestId":"req_011CWLYwGrZgyu1ynKgw6FRf","type":"assistant","uuid":"ea6b8b41-f107-4e3c-a075-964951041b73","timestamp":"2025-12-21T21:35:29.892Z"}
|
||
{"parentUuid":"ea6b8b41-f107-4e3c-a075-964951041b73","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QDYQ9p3Hd2a7rrVUwo7N8x","type":"tool_result","content":" 1→//! Metrics database operations\n 2→\n 3→use chrono::{DateTime, Utc};\n 4→use serde::{Deserialize, Serialize};\n 5→use sqlx::PgPool;\n 6→use uuid::Uuid;\n 7→\n 8→/// Metrics record from database\n 9→#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]\n 10→pub struct Metrics {\n 11→ pub id: i64,\n 12→ pub agent_id: Uuid,\n 13→ pub timestamp: DateTime<Utc>,\n 14→ pub cpu_percent: Option<f32>,\n 15→ pub memory_percent: Option<f32>,\n 16→ pub memory_used_bytes: Option<i64>,\n 17→ pub disk_percent: Option<f32>,\n 18→ pub disk_used_bytes: Option<i64>,\n 19→ pub network_rx_bytes: Option<i64>,\n 20→ pub network_tx_bytes: Option<i64>,\n 21→ // Extended metrics\n 22→ pub uptime_seconds: Option<i64>,\n 23→ pub boot_time: Option<i64>,\n 24→ pub logged_in_user: Option<String>,\n 25→ pub user_idle_seconds: Option<i64>,\n 26→ pub public_ip: Option<String>,\n 27→ pub memory_total_bytes: Option<i64>,\n 28→ pub disk_total_bytes: Option<i64>,\n 29→ // Temperature metrics\n 30→ pub cpu_temp_celsius: Option<f32>,\n 31→ pub gpu_temp_celsius: Option<f32>,\n 32→ pub temperatures: Option<serde_json::Value>,\n 33→}\n 34→\n 35→/// Create metrics data from agent\n 36→#[derive(Debug, Clone, Deserialize)]\n 37→pub struct CreateMetrics {\n 38→ pub agent_id: Uuid,\n 39→ pub cpu_percent: Option<f32>,\n 40→ pub memory_percent: Option<f32>,\n 41→ pub memory_used_bytes: Option<i64>,\n 42→ pub disk_percent: Option<f32>,\n 43→ pub disk_used_bytes: Option<i64>,\n 44→ pub network_rx_bytes: Option<i64>,\n 45→ pub network_tx_bytes: Option<i64>,\n 46→ // Extended metrics\n 47→ pub uptime_seconds: Option<i64>,\n 48→ pub boot_time: Option<i64>,\n 49→ pub logged_in_user: Option<String>,\n 50→ pub user_idle_seconds: Option<i64>,\n 51→ pub public_ip: Option<String>,\n 52→ pub memory_total_bytes: Option<i64>,\n 53→ pub disk_total_bytes: Option<i64>,\n 54→ // Temperature metrics\n 55→ pub cpu_temp_celsius: Option<f32>,\n 56→ pub gpu_temp_celsius: Option<f32>,\n 57→ pub temperatures: Option<serde_json::Value>,\n 58→}\n 59→\n 60→/// Insert metrics into the database\n 61→pub async fn insert_metrics(pool: &PgPool, metrics: CreateMetrics) -> Result<i64, sqlx::Error> {\n 62→ let result: (i64,) = sqlx::query_as(\n 63→ r#\"\n 64→ INSERT INTO metrics (\n 65→ agent_id, cpu_percent, memory_percent, memory_used_bytes,\n 66→ disk_percent, disk_used_bytes, network_rx_bytes, network_tx_bytes,\n 67→ uptime_seconds, boot_time, logged_in_user, user_idle_seconds,\n 68→ public_ip, memory_total_bytes, disk_total_bytes,\n 69→ cpu_temp_celsius, gpu_temp_celsius, temperatures\n 70→ )\n 71→ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)\n 72→ RETURNING id\n 73→ \"#,\n 74→ )\n 75→ .bind(metrics.agent_id)\n 76→ .bind(metrics.cpu_percent)\n 77→ .bind(metrics.memory_percent)\n 78→ .bind(metrics.memory_used_bytes)\n 79→ .bind(metrics.disk_percent)\n 80→ .bind(metrics.disk_used_bytes)\n 81→ .bind(metrics.network_rx_bytes)\n 82→ .bind(metrics.network_tx_bytes)\n 83→ .bind(metrics.uptime_seconds)\n 84→ .bind(metrics.boot_time)\n 85→ .bind(&metrics.logged_in_user)\n 86→ .bind(metrics.user_idle_seconds)\n 87→ .bind(&metrics.public_ip)\n 88→ .bind(metrics.memory_total_bytes)\n 89→ .bind(metrics.disk_total_bytes)\n 90→ .bind(metrics.cpu_temp_celsius)\n 91→ .bind(metrics.gpu_temp_celsius)\n 92→ .bind(&metrics.temperatures)\n 93→ .fetch_one(pool)\n 94→ .await?;\n 95→\n 96→ Ok(result.0)\n 97→}\n 98→\n 99→/// Get recent metrics for an agent\n 100→pub async fn get_agent_metrics(\n 101→ pool: &PgPool,\n 102→ agent_id: Uuid,\n 103→ limit: i64,\n 104→) -> Result<Vec<Metrics>, sqlx::Error> {\n 105→ sqlx::query_as::<_, Metrics>(\n 106→ r#\"\n 107→ SELECT * FROM metrics\n 108→ WHERE agent_id = $1\n 109→ ORDER BY timestamp DESC\n 110→ LIMIT $2\n 111→ \"#,\n 112→ )\n 113→ .bind(agent_id)\n 114→ .bind(limit)\n 115→ .fetch_all(pool)\n 116→ .await\n 117→}\n 118→\n 119→/// Get metrics for an agent within a time range\n 120→pub async fn get_agent_metrics_range(\n 121→ pool: &PgPool,\n 122→ agent_id: Uuid,\n 123→ start: DateTime<Utc>,\n 124→ end: DateTime<Utc>,\n 125→) -> Result<Vec<Metrics>, sqlx::Error> {\n 126→ sqlx::query_as::<_, Metrics>(\n 127→ r#\"\n 128→ SELECT * FROM metrics\n 129→ WHERE agent_id = $1 AND timestamp >= $2 AND timestamp <= $3\n 130→ ORDER BY timestamp ASC\n 131→ \"#,\n 132→ )\n 133→ .bind(agent_id)\n 134→ .bind(start)\n 135→ .bind(end)\n 136→ .fetch_all(pool)\n 137→ .await\n 138→}\n 139→\n 140→/// Get latest metrics for an agent\n 141→pub async fn get_latest_metrics(\n 142→ pool: &PgPool,\n 143→ agent_id: Uuid,\n 144→) -> Result<Option<Metrics>, sqlx::Error> {\n 145→ sqlx::query_as::<_, Metrics>(\n 146→ r#\"\n 147→ SELECT * FROM metrics\n 148→ WHERE agent_id = $1\n 149→ ORDER BY timestamp DESC\n 150→ LIMIT 1\n 151→ \"#,\n 152→ )\n 153→ .bind(agent_id)\n 154→ .fetch_optional(pool)\n 155→ .await\n 156→}\n 157→\n 158→/// Delete old metrics (for cleanup jobs)\n 159→pub async fn delete_old_metrics(\n 160→ pool: &PgPool,\n 161→ older_than: DateTime<Utc>,\n 162→) -> Result<u64, sqlx::Error> {\n 163→ let result = sqlx::query(\"DELETE FROM metrics WHERE timestamp < $1\")\n 164→ .bind(older_than)\n 165→ .execute(pool)\n 166→ .await?;\n 167→\n 168→ Ok(result.rows_affected())\n 169→}\n 170→\n 171→/// Summary statistics for dashboard\n 172→#[derive(Debug, Clone, Serialize, Deserialize)]\n 173→pub struct MetricsSummary {\n 174→ pub avg_cpu: f32,\n 175→ pub avg_memory: f32,\n 176→ pub avg_disk: f32,\n 177→ pub total_network_rx: i64,\n 178→ pub total_network_tx: i64,\n 179→}\n 180→\n 181→/// Get summary metrics across all agents (last hour)\n 182→pub async fn get_metrics_summary(pool: &PgPool) -> Result<MetricsSummary, sqlx::Error> {\n 183→ let result: (Option<f64>, Option<f64>, Option<f64>, Option<i64>, Option<i64>) = sqlx::query_as(\n 184→ r#\"\n 185→ SELECT\n 186→ AVG(cpu_percent)::float8,\n 187→ AVG(memory_percent)::float8,\n 188→ AVG(disk_percent)::float8,\n 189→ SUM(network_rx_bytes),\n 190→ SUM(network_tx_bytes)\n 191→ FROM metrics\n 192→ WHERE timestamp > NOW() - INTERVAL '1 hour'\n 193→ \"#,\n 194→ )\n 195→ .fetch_one(pool)\n 196→ .await?;\n 197→\n 198→ Ok(MetricsSummary {\n 199→ avg_cpu: result.0.unwrap_or(0.0) as f32,\n 200→ avg_memory: result.1.unwrap_or(0.0) as f32,\n 201→ avg_disk: result.2.unwrap_or(0.0) as f32,\n 202→ total_network_rx: result.3.unwrap_or(0),\n 203→ total_network_tx: result.4.unwrap_or(0),\n 204→ })\n 205→}\n 206→\n 207→// ============================================================================\n 208→// Agent State (network interfaces, extended info snapshot)\n 209→// ============================================================================\n 210→\n 211→/// Agent state record from database\n 212→#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]\n 213→pub struct AgentState {\n 214→ pub agent_id: Uuid,\n 215→ pub network_interfaces: Option<serde_json::Value>,\n 216→ pub network_state_hash: Option<String>,\n 217→ pub uptime_seconds: Option<i64>,\n 218→ pub boot_time: Option<i64>,\n 219→ pub logged_in_user: Option<String>,\n 220→ pub user_idle_seconds: Option<i64>,\n 221→ pub public_ip: Option<String>,\n 222→ pub network_updated_at: Option<DateTime<Utc>>,\n 223→ pub metrics_updated_at: Option<DateTime<Utc>>,\n 224→}\n 225→\n 226→/// Update or insert agent state (upsert)\n 227→pub async fn upsert_agent_state(\n 228→ pool: &PgPool,\n 229→ agent_id: Uuid,\n 230→ uptime_seconds: Option<i64>,\n 231→ boot_time: Option<i64>,\n 232→ logged_in_user: Option<&str>,\n 233→ user_idle_seconds: Option<i64>,\n 234→ public_ip: Option<&str>,\n 235→) -> Result<(), sqlx::Error> {\n 236→ sqlx::query(\n 237→ r#\"\n 238→ INSERT INTO agent_state (\n 239→ agent_id, uptime_seconds, boot_time, logged_in_user,\n 240→ user_idle_seconds, public_ip, metrics_updated_at\n 241→ )\n 242→ VALUES ($1, $2, $3, $4, $5, $6, NOW())\n 243→ ON CONFLICT (agent_id) DO UPDATE SET\n 244→ uptime_seconds = EXCLUDED.uptime_seconds,\n 245→ boot_time = EXCLUDED.boot_time,\n 246→ logged_in_user = EXCLUDED.logged_in_user,\n 247→ user_idle_seconds = EXCLUDED.user_idle_seconds,\n 248→ public_ip = EXCLUDED.public_ip,\n 249→ metrics_updated_at = NOW()\n 250→ \"#,\n 251→ )\n 252→ .bind(agent_id)\n 253→ .bind(uptime_seconds)\n 254→ .bind(boot_time)\n 255→ .bind(logged_in_user)\n 256→ .bind(user_idle_seconds)\n 257→ .bind(public_ip)\n 258→ .execute(pool)\n 259→ .await?;\n 260→\n 261→ Ok(())\n 262→}\n 263→\n 264→/// Update network state for an agent\n 265→pub async fn update_agent_network_state(\n 266→ pool: &PgPool,\n 267→ agent_id: Uuid,\n 268→ interfaces: &serde_json::Value,\n 269→ state_hash: &str,\n 270→) -> Result<(), sqlx::Error> {\n 271→ sqlx::query(\n 272→ r#\"\n 273→ INSERT INTO agent_state (agent_id, network_interfaces, network_state_hash, network_updated_at)\n 274→ VALUES ($1, $2, $3, NOW())\n 275→ ON CONFLICT (agent_id) DO UPDATE SET\n 276→ network_interfaces = EXCLUDED.network_interfaces,\n 277→ network_state_hash = EXCLUDED.network_state_hash,\n 278→ network_updated_at = NOW()\n 279→ \"#,\n 280→ )\n 281→ .bind(agent_id)\n 282→ .bind(interfaces)\n 283→ .bind(state_hash)\n 284→ .execute(pool)\n 285→ .await?;\n 286→\n 287→ Ok(())\n 288→}\n 289→\n 290→/// Get agent state by agent ID\n 291→pub async fn get_agent_state(pool: &PgPool, agent_id: Uuid) -> Result<Option<AgentState>, sqlx::Error> {\n 292→ sqlx::query_as::<_, AgentState>(\"SELECT * FROM agent_state WHERE agent_id = $1\")\n 293→ .bind(agent_id)\n 294→ .fetch_optional(pool)\n 295→ .await\n 296→}\n 297→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"91a19095-45b3-4af7-823f-f5508e40fc18","timestamp":"2025-12-21T21:35:44.224Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\db\\metrics.rs","content":"//! Metrics database operations\n\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\n/// Metrics record from database\n#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]\npub struct Metrics {\n pub id: i64,\n pub agent_id: Uuid,\n pub timestamp: DateTime<Utc>,\n pub cpu_percent: Option<f32>,\n pub memory_percent: Option<f32>,\n pub memory_used_bytes: Option<i64>,\n pub disk_percent: Option<f32>,\n pub disk_used_bytes: Option<i64>,\n pub network_rx_bytes: Option<i64>,\n pub network_tx_bytes: Option<i64>,\n // Extended metrics\n pub uptime_seconds: Option<i64>,\n pub boot_time: Option<i64>,\n pub logged_in_user: Option<String>,\n pub user_idle_seconds: Option<i64>,\n pub public_ip: Option<String>,\n pub memory_total_bytes: Option<i64>,\n pub disk_total_bytes: Option<i64>,\n // Temperature metrics\n pub cpu_temp_celsius: Option<f32>,\n pub gpu_temp_celsius: Option<f32>,\n pub temperatures: Option<serde_json::Value>,\n}\n\n/// Create metrics data from agent\n#[derive(Debug, Clone, Deserialize)]\npub struct CreateMetrics {\n pub agent_id: Uuid,\n pub cpu_percent: Option<f32>,\n pub memory_percent: Option<f32>,\n pub memory_used_bytes: Option<i64>,\n pub disk_percent: Option<f32>,\n pub disk_used_bytes: Option<i64>,\n pub network_rx_bytes: Option<i64>,\n pub network_tx_bytes: Option<i64>,\n // Extended metrics\n pub uptime_seconds: Option<i64>,\n pub boot_time: Option<i64>,\n pub logged_in_user: Option<String>,\n pub user_idle_seconds: Option<i64>,\n pub public_ip: Option<String>,\n pub memory_total_bytes: Option<i64>,\n pub disk_total_bytes: Option<i64>,\n // Temperature metrics\n pub cpu_temp_celsius: Option<f32>,\n pub gpu_temp_celsius: Option<f32>,\n pub temperatures: Option<serde_json::Value>,\n}\n\n/// Insert metrics into the database\npub async fn insert_metrics(pool: &PgPool, metrics: CreateMetrics) -> Result<i64, sqlx::Error> {\n let result: (i64,) = sqlx::query_as(\n r#\"\n INSERT INTO metrics (\n agent_id, cpu_percent, memory_percent, memory_used_bytes,\n disk_percent, disk_used_bytes, network_rx_bytes, network_tx_bytes,\n uptime_seconds, boot_time, logged_in_user, user_idle_seconds,\n public_ip, memory_total_bytes, disk_total_bytes,\n cpu_temp_celsius, gpu_temp_celsius, temperatures\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)\n RETURNING id\n \"#,\n )\n .bind(metrics.agent_id)\n .bind(metrics.cpu_percent)\n .bind(metrics.memory_percent)\n .bind(metrics.memory_used_bytes)\n .bind(metrics.disk_percent)\n .bind(metrics.disk_used_bytes)\n .bind(metrics.network_rx_bytes)\n .bind(metrics.network_tx_bytes)\n .bind(metrics.uptime_seconds)\n .bind(metrics.boot_time)\n .bind(&metrics.logged_in_user)\n .bind(metrics.user_idle_seconds)\n .bind(&metrics.public_ip)\n .bind(metrics.memory_total_bytes)\n .bind(metrics.disk_total_bytes)\n .bind(metrics.cpu_temp_celsius)\n .bind(metrics.gpu_temp_celsius)\n .bind(&metrics.temperatures)\n .fetch_one(pool)\n .await?;\n\n Ok(result.0)\n}\n\n/// Get recent metrics for an agent\npub async fn get_agent_metrics(\n pool: &PgPool,\n agent_id: Uuid,\n limit: i64,\n) -> Result<Vec<Metrics>, sqlx::Error> {\n sqlx::query_as::<_, Metrics>(\n r#\"\n SELECT * FROM metrics\n WHERE agent_id = $1\n ORDER BY timestamp DESC\n LIMIT $2\n \"#,\n )\n .bind(agent_id)\n .bind(limit)\n .fetch_all(pool)\n .await\n}\n\n/// Get metrics for an agent within a time range\npub async fn get_agent_metrics_range(\n pool: &PgPool,\n agent_id: Uuid,\n start: DateTime<Utc>,\n end: DateTime<Utc>,\n) -> Result<Vec<Metrics>, sqlx::Error> {\n sqlx::query_as::<_, Metrics>(\n r#\"\n SELECT * FROM metrics\n WHERE agent_id = $1 AND timestamp >= $2 AND timestamp <= $3\n ORDER BY timestamp ASC\n \"#,\n )\n .bind(agent_id)\n .bind(start)\n .bind(end)\n .fetch_all(pool)\n .await\n}\n\n/// Get latest metrics for an agent\npub async fn get_latest_metrics(\n pool: &PgPool,\n agent_id: Uuid,\n) -> Result<Option<Metrics>, sqlx::Error> {\n sqlx::query_as::<_, Metrics>(\n r#\"\n SELECT * FROM metrics\n WHERE agent_id = $1\n ORDER BY timestamp DESC\n LIMIT 1\n \"#,\n )\n .bind(agent_id)\n .fetch_optional(pool)\n .await\n}\n\n/// Delete old metrics (for cleanup jobs)\npub async fn delete_old_metrics(\n pool: &PgPool,\n older_than: DateTime<Utc>,\n) -> Result<u64, sqlx::Error> {\n let result = sqlx::query(\"DELETE FROM metrics WHERE timestamp < $1\")\n .bind(older_than)\n .execute(pool)\n .await?;\n\n Ok(result.rows_affected())\n}\n\n/// Summary statistics for dashboard\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MetricsSummary {\n pub avg_cpu: f32,\n pub avg_memory: f32,\n pub avg_disk: f32,\n pub total_network_rx: i64,\n pub total_network_tx: i64,\n}\n\n/// Get summary metrics across all agents (last hour)\npub async fn get_metrics_summary(pool: &PgPool) -> Result<MetricsSummary, sqlx::Error> {\n let result: (Option<f64>, Option<f64>, Option<f64>, Option<i64>, Option<i64>) = sqlx::query_as(\n r#\"\n SELECT\n AVG(cpu_percent)::float8,\n AVG(memory_percent)::float8,\n AVG(disk_percent)::float8,\n SUM(network_rx_bytes),\n SUM(network_tx_bytes)\n FROM metrics\n WHERE timestamp > NOW() - INTERVAL '1 hour'\n \"#,\n )\n .fetch_one(pool)\n .await?;\n\n Ok(MetricsSummary {\n avg_cpu: result.0.unwrap_or(0.0) as f32,\n avg_memory: result.1.unwrap_or(0.0) as f32,\n avg_disk: result.2.unwrap_or(0.0) as f32,\n total_network_rx: result.3.unwrap_or(0),\n total_network_tx: result.4.unwrap_or(0),\n })\n}\n\n// ============================================================================\n// Agent State (network interfaces, extended info snapshot)\n// ============================================================================\n\n/// Agent state record from database\n#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]\npub struct AgentState {\n pub agent_id: Uuid,\n pub network_interfaces: Option<serde_json::Value>,\n pub network_state_hash: Option<String>,\n pub uptime_seconds: Option<i64>,\n pub boot_time: Option<i64>,\n pub logged_in_user: Option<String>,\n pub user_idle_seconds: Option<i64>,\n pub public_ip: Option<String>,\n pub network_updated_at: Option<DateTime<Utc>>,\n pub metrics_updated_at: Option<DateTime<Utc>>,\n}\n\n/// Update or insert agent state (upsert)\npub async fn upsert_agent_state(\n pool: &PgPool,\n agent_id: Uuid,\n uptime_seconds: Option<i64>,\n boot_time: Option<i64>,\n logged_in_user: Option<&str>,\n user_idle_seconds: Option<i64>,\n public_ip: Option<&str>,\n) -> Result<(), sqlx::Error> {\n sqlx::query(\n r#\"\n INSERT INTO agent_state (\n agent_id, uptime_seconds, boot_time, logged_in_user,\n user_idle_seconds, public_ip, metrics_updated_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, NOW())\n ON CONFLICT (agent_id) DO UPDATE SET\n uptime_seconds = EXCLUDED.uptime_seconds,\n boot_time = EXCLUDED.boot_time,\n logged_in_user = EXCLUDED.logged_in_user,\n user_idle_seconds = EXCLUDED.user_idle_seconds,\n public_ip = EXCLUDED.public_ip,\n metrics_updated_at = NOW()\n \"#,\n )\n .bind(agent_id)\n .bind(uptime_seconds)\n .bind(boot_time)\n .bind(logged_in_user)\n .bind(user_idle_seconds)\n .bind(public_ip)\n .execute(pool)\n .await?;\n\n Ok(())\n}\n\n/// Update network state for an agent\npub async fn update_agent_network_state(\n pool: &PgPool,\n agent_id: Uuid,\n interfaces: &serde_json::Value,\n state_hash: &str,\n) -> Result<(), sqlx::Error> {\n sqlx::query(\n r#\"\n INSERT INTO agent_state (agent_id, network_interfaces, network_state_hash, network_updated_at)\n VALUES ($1, $2, $3, NOW())\n ON CONFLICT (agent_id) DO UPDATE SET\n network_interfaces = EXCLUDED.network_interfaces,\n network_state_hash = EXCLUDED.network_state_hash,\n network_updated_at = NOW()\n \"#,\n )\n .bind(agent_id)\n .bind(interfaces)\n .bind(state_hash)\n .execute(pool)\n .await?;\n\n Ok(())\n}\n\n/// Get agent state by agent ID\npub async fn get_agent_state(pool: &PgPool, agent_id: Uuid) -> Result<Option<AgentState>, sqlx::Error> {\n sqlx::query_as::<_, AgentState>(\"SELECT * FROM agent_state WHERE agent_id = $1\")\n .bind(agent_id)\n .fetch_optional(pool)\n .await\n}\n","numLines":297,"startLine":1,"totalLines":297}}}
|
||
{"parentUuid":"91a19095-45b3-4af7-823f-f5508e40fc18","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018t6HrmdPa1rrDBQKTd2Z2T","type":"tool_result","content":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\ipc.rs\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\tray\\src\\ipc.rs"}]},"uuid":"e514477c-9907-417e-b179-1ab710f4abdf","timestamp":"2025-12-21T21:35:45.151Z","toolUseResult":{"filenames":["C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\ipc.rs","C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\tray\\src\\ipc.rs"],"durationMs":1495,"numFiles":2,"truncated":false}}
|
||
{"parentUuid":"e514477c-9907-417e-b179-1ab710f4abdf","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XgAe7d4uSjW3VWi2MDLzF2","type":"tool_result","content":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\001_initial.sql\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\002_clients_sites.sql\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\003_extended_metrics.sql\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\004_agent_updates.sql\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\005_temperature_metrics.sql"}]},"uuid":"d4413a49-8974-476c-a2f8-e1ae45cfb86b","timestamp":"2025-12-21T21:35:45.218Z","toolUseResult":{"filenames":["C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\001_initial.sql","C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\002_clients_sites.sql","C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\003_extended_metrics.sql","C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\004_agent_updates.sql","C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\005_temperature_metrics.sql"],"durationMs":1560,"numFiles":5,"truncated":false}}
|
||
{"parentUuid":"d4413a49-8974-476c-a2f8-e1ae45cfb86b","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01PmrMkJyWvVKyaPW17N8rJF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015Z2ZUvmCf29fuoDMdsXjDb","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\ipc.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4658,"cache_read_input_tokens":41393,"cache_creation":{"ephemeral_5m_input_tokens":4658,"ephemeral_1h_input_tokens":0},"output_tokens":18,"service_tier":"standard"}},"requestId":"req_011CWLYy7PraekGMpgbGt8k6","type":"assistant","uuid":"42ec6d04-ac30-4dbd-918a-aa3587ff7a75","timestamp":"2025-12-21T21:35:55.113Z"}
|
||
{"parentUuid":"42ec6d04-ac30-4dbd-918a-aa3587ff7a75","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01PmrMkJyWvVKyaPW17N8rJF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01E1uvath9MggcRFLQMuujRi","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\001_initial.sql"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4658,"cache_read_input_tokens":41393,"cache_creation":{"ephemeral_5m_input_tokens":4658,"ephemeral_1h_input_tokens":0},"output_tokens":18,"service_tier":"standard"}},"requestId":"req_011CWLYy7PraekGMpgbGt8k6","type":"assistant","uuid":"52033f2f-be14-4f37-97b3-19a8004b8a4a","timestamp":"2025-12-21T21:35:55.483Z"}
|
||
{"parentUuid":"52033f2f-be14-4f37-97b3-19a8004b8a4a","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01PmrMkJyWvVKyaPW17N8rJF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JJxEGNrRiX9Rdfn1fyjiND","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\002_clients_sites.sql"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4658,"cache_read_input_tokens":41393,"cache_creation":{"ephemeral_5m_input_tokens":4658,"ephemeral_1h_input_tokens":0},"output_tokens":210,"service_tier":"standard"}},"requestId":"req_011CWLYy7PraekGMpgbGt8k6","type":"assistant","uuid":"501148ef-80f6-42c2-875f-81240a122045","timestamp":"2025-12-21T21:35:55.801Z"}
|
||
{"parentUuid":"501148ef-80f6-42c2-875f-81240a122045","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015Z2ZUvmCf29fuoDMdsXjDb","type":"tool_result","content":" 1→//! IPC (Inter-Process Communication) for tray application\n 2→//!\n 3→//! Provides a named pipe server (Windows) or Unix socket (Unix) for the\n 4→//! tray application to communicate with the agent service.\n 5→\n 6→use serde::{Deserialize, Serialize};\n 7→use std::sync::Arc;\n 8→use tokio::sync::{broadcast, RwLock};\n 9→use tracing::{debug, error, info, warn};\n 10→\n 11→/// Pipe name for Windows named pipe\n 12→#[cfg(windows)]\n 13→pub const PIPE_NAME: &str = r\"\\\\.\\pipe\\gururmm-agent\";\n 14→\n 15→/// Socket path for Unix domain socket\n 16→#[cfg(unix)]\n 17→pub const SOCKET_PATH: &str = \"/var/run/gururmm/agent.sock\";\n 18→\n 19→/// Tray policy - controls what the tray app can show and do\n 20→#[derive(Debug, Clone, Serialize, Deserialize, Default)]\n 21→pub struct TrayPolicy {\n 22→ /// Whether to show the tray icon at all\n 23→ pub enabled: bool,\n 24→\n 25→ /// Show connection status in tooltip/menu\n 26→ pub show_status: bool,\n 27→\n 28→ /// Show agent info (version, hostname, etc.) in menu\n 29→ pub show_info: bool,\n 30→\n 31→ /// Allow user to trigger manual check-in\n 32→ pub allow_force_checkin: bool,\n 33→\n 34→ /// Allow user to view agent logs\n 35→ pub allow_view_logs: bool,\n 36→\n 37→ /// Allow user to open web dashboard\n 38→ pub allow_open_dashboard: bool,\n 39→\n 40→ /// Allow user to stop the agent (dangerous!)\n 41→ pub allow_stop_agent: bool,\n 42→\n 43→ /// Dashboard URL (if allow_open_dashboard is true)\n 44→ pub dashboard_url: Option<String>,\n 45→\n 46→ /// Custom tooltip text\n 47→ pub tooltip_text: Option<String>,\n 48→\n 49→ /// Custom icon (base64 encoded PNG)\n 50→ pub custom_icon: Option<String>,\n 51→}\n 52→\n 53→impl TrayPolicy {\n 54→ /// Default permissive policy (for development/testing)\n 55→ pub fn default_permissive() -> Self {\n 56→ Self {\n 57→ enabled: true,\n 58→ show_status: true,\n 59→ show_info: true,\n 60→ allow_force_checkin: true,\n 61→ allow_view_logs: true,\n 62→ allow_open_dashboard: true,\n 63→ allow_stop_agent: false,\n 64→ dashboard_url: None,\n 65→ tooltip_text: None,\n 66→ custom_icon: None,\n 67→ }\n 68→ }\n 69→\n 70→ /// Restrictive policy (minimal visibility)\n 71→ pub fn default_restrictive() -> Self {\n 72→ Self {\n 73→ enabled: true,\n 74→ show_status: true,\n 75→ show_info: false,\n 76→ allow_force_checkin: false,\n 77→ allow_view_logs: false,\n 78→ allow_open_dashboard: false,\n 79→ allow_stop_agent: false,\n 80→ dashboard_url: None,\n 81→ tooltip_text: None,\n 82→ custom_icon: None,\n 83→ }\n 84→ }\n 85→}\n 86→\n 87→/// Agent status for tray display\n 88→#[derive(Debug, Clone, Serialize, Deserialize)]\n 89→pub struct AgentStatus {\n 90→ /// Whether connected to server\n 91→ pub connected: bool,\n 92→\n 93→ /// Last successful check-in time (ISO 8601)\n 94→ pub last_checkin: Option<String>,\n 95→\n 96→ /// Server URL we're connected to\n 97→ pub server_url: String,\n 98→\n 99→ /// Agent version\n 100→ pub agent_version: String,\n 101→\n 102→ /// Device ID\n 103→ pub device_id: String,\n 104→\n 105→ /// Hostname\n 106→ pub hostname: String,\n 107→\n 108→ /// Error message (if any)\n 109→ pub error: Option<String>,\n 110→}\n 111→\n 112→/// Request from tray to agent\n 113→#[derive(Debug, Clone, Serialize, Deserialize)]\n 114→#[serde(tag = \"type\", content = \"payload\")]\n 115→#[serde(rename_all = \"snake_case\")]\n 116→pub enum IpcRequest {\n 117→ /// Get current agent status\n 118→ GetStatus,\n 119→\n 120→ /// Get current tray policy\n 121→ GetPolicy,\n 122→\n 123→ /// Force immediate check-in\n 124→ ForceCheckin,\n 125→\n 126→ /// Stop the agent service\n 127→ StopAgent,\n 128→\n 129→ /// Subscribe to status updates\n 130→ Subscribe,\n 131→\n 132→ /// Unsubscribe from updates\n 133→ Unsubscribe,\n 134→\n 135→ /// Ping (health check)\n 136→ Ping,\n 137→}\n 138→\n 139→/// Response from agent to tray\n 140→#[derive(Debug, Clone, Serialize, Deserialize)]\n 141→#[serde(tag = \"type\", content = \"payload\")]\n 142→#[serde(rename_all = \"snake_case\")]\n 143→pub enum IpcResponse {\n 144→ /// Current agent status\n 145→ Status(AgentStatus),\n 146→\n 147→ /// Current tray policy\n 148→ Policy(TrayPolicy),\n 149→\n 150→ /// Success acknowledgment\n 151→ Ok,\n 152→\n 153→ /// Pong response to ping\n 154→ Pong,\n 155→\n 156→ /// Error\n 157→ Error { message: String },\n 158→\n 159→ /// Action denied by policy\n 160→ Denied { message: String },\n 161→\n 162→ /// Status update (pushed to subscribers)\n 163→ StatusUpdate(AgentStatus),\n 164→\n 165→ /// Policy update (pushed to subscribers)\n 166→ PolicyUpdate(TrayPolicy),\n 167→}\n 168→\n 169→/// Shared state for IPC server\n 170→pub struct IpcState {\n 171→ /// Current agent status\n 172→ pub status: RwLock<AgentStatus>,\n 173→\n 174→ /// Current tray policy\n 175→ pub policy: RwLock<TrayPolicy>,\n 176→\n 177→ /// Broadcast channel for status updates\n 178→ pub status_tx: broadcast::Sender<AgentStatus>,\n 179→\n 180→ /// Broadcast channel for policy updates\n 181→ pub policy_tx: broadcast::Sender<TrayPolicy>,\n 182→\n 183→ /// Channel to request force check-in\n 184→ pub force_checkin_tx: tokio::sync::mpsc::Sender<()>,\n 185→\n 186→ /// Channel to request agent stop\n 187→ pub stop_agent_tx: tokio::sync::mpsc::Sender<()>,\n 188→}\n 189→\n 190→impl IpcState {\n 191→ pub fn new(\n 192→ initial_status: AgentStatus,\n 193→ initial_policy: TrayPolicy,\n 194→ force_checkin_tx: tokio::sync::mpsc::Sender<()>,\n 195→ stop_agent_tx: tokio::sync::mpsc::Sender<()>,\n 196→ ) -> Self {\n 197→ let (status_tx, _) = broadcast::channel(16);\n 198→ let (policy_tx, _) = broadcast::channel(16);\n 199→\n 200→ Self {\n 201→ status: RwLock::new(initial_status),\n 202→ policy: RwLock::new(initial_policy),\n 203→ status_tx,\n 204→ policy_tx,\n 205→ force_checkin_tx,\n 206→ stop_agent_tx,\n 207→ }\n 208→ }\n 209→\n 210→ /// Update status and broadcast to subscribers\n 211→ pub async fn update_status(&self, status: AgentStatus) {\n 212→ *self.status.write().await = status.clone();\n 213→ let _ = self.status_tx.send(status);\n 214→ }\n 215→\n 216→ /// Update policy and broadcast to subscribers\n 217→ pub async fn update_policy(&self, policy: TrayPolicy) {\n 218→ *self.policy.write().await = policy.clone();\n 219→ let _ = self.policy_tx.send(policy);\n 220→ }\n 221→\n 222→ /// Handle an IPC request\n 223→ pub async fn handle_request(&self, request: IpcRequest) -> IpcResponse {\n 224→ match request {\n 225→ IpcRequest::GetStatus => {\n 226→ let status = self.status.read().await.clone();\n 227→ IpcResponse::Status(status)\n 228→ }\n 229→\n 230→ IpcRequest::GetPolicy => {\n 231→ let policy = self.policy.read().await.clone();\n 232→ IpcResponse::Policy(policy)\n 233→ }\n 234→\n 235→ IpcRequest::ForceCheckin => {\n 236→ let policy = self.policy.read().await;\n 237→ if !policy.allow_force_checkin {\n 238→ return IpcResponse::Denied {\n 239→ message: \"Force check-in not allowed by policy\".to_string(),\n 240→ };\n 241→ }\n 242→ drop(policy);\n 243→\n 244→ match self.force_checkin_tx.send(()).await {\n 245→ Ok(_) => IpcResponse::Ok,\n 246→ Err(_) => IpcResponse::Error {\n 247→ message: \"Failed to trigger check-in\".to_string(),\n 248→ },\n 249→ }\n 250→ }\n 251→\n 252→ IpcRequest::StopAgent => {\n 253→ let policy = self.policy.read().await;\n 254→ if !policy.allow_stop_agent {\n 255→ return IpcResponse::Denied {\n 256→ message: \"Stopping agent not allowed by policy\".to_string(),\n 257→ };\n 258→ }\n 259→ drop(policy);\n 260→\n 261→ match self.stop_agent_tx.send(()).await {\n 262→ Ok(_) => IpcResponse::Ok,\n 263→ Err(_) => IpcResponse::Error {\n 264→ message: \"Failed to request agent stop\".to_string(),\n 265→ },\n 266→ }\n 267→ }\n 268→\n 269→ IpcRequest::Subscribe => {\n 270→ // Subscription is handled at the connection level\n 271→ IpcResponse::Ok\n 272→ }\n 273→\n 274→ IpcRequest::Unsubscribe => {\n 275→ IpcResponse::Ok\n 276→ }\n 277→\n 278→ IpcRequest::Ping => IpcResponse::Pong,\n 279→ }\n 280→ }\n 281→}\n 282→\n 283→// ============================================================================\n 284→// Windows Named Pipe Server\n 285→// ============================================================================\n 286→\n 287→#[cfg(windows)]\n 288→pub mod server {\n 289→ use super::*;\n 290→ use std::ffi::OsStr;\n 291→ use std::os::windows::ffi::OsStrExt;\n 292→ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};\n 293→ use tokio::net::windows::named_pipe::{ServerOptions, NamedPipeServer};\n 294→\n 295→ /// Run the IPC server (Windows named pipe)\n 296→ pub async fn run_ipc_server(state: Arc<IpcState>) -> anyhow::Result<()> {\n 297→ info!(\"Starting IPC server on {}\", super::PIPE_NAME);\n 298→\n 299→ loop {\n 300→ // Create a new pipe instance\n 301→ let server = match ServerOptions::new()\n 302→ .first_pipe_instance(false)\n 303→ .create(super::PIPE_NAME)\n 304→ {\n 305→ Ok(s) => s,\n 306→ Err(e) => {\n 307→ // First instance needs different handling\n 308→ if e.kind() == std::io::ErrorKind::NotFound {\n 309→ ServerOptions::new()\n 310→ .first_pipe_instance(true)\n 311→ .create(super::PIPE_NAME)?\n 312→ } else {\n 313→ error!(\"Failed to create pipe: {}\", e);\n 314→ tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;\n 315→ continue;\n 316→ }\n 317→ }\n 318→ };\n 319→\n 320→ // Wait for a client to connect\n 321→ if let Err(e) = server.connect().await {\n 322→ error!(\"Failed to accept pipe connection: {}\", e);\n 323→ continue;\n 324→ }\n 325→\n 326→ info!(\"IPC client connected\");\n 327→\n 328→ // Spawn a task to handle this client\n 329→ let state = Arc::clone(&state);\n 330→ tokio::spawn(async move {\n 331→ if let Err(e) = handle_client(server, state).await {\n 332→ debug!(\"IPC client disconnected: {}\", e);\n 333→ }\n 334→ });\n 335→ }\n 336→ }\n 337→\n 338→ async fn handle_client(\n 339→ pipe: NamedPipeServer,\n 340→ state: Arc<IpcState>,\n 341→ ) -> anyhow::Result<()> {\n 342→ let (reader, mut writer) = tokio::io::split(pipe);\n 343→ let mut reader = BufReader::new(reader);\n 344→ let mut line = String::new();\n 345→\n 346→ // Subscribe to updates\n 347→ let mut status_rx = state.status_tx.subscribe();\n 348→ let mut policy_rx = state.policy_tx.subscribe();\n 349→ let mut subscribed = false;\n 350→\n 351→ loop {\n 352→ tokio::select! {\n 353→ // Read request from client\n 354→ result = reader.read_line(&mut line) => {\n 355→ match result {\n 356→ Ok(0) => break, // EOF\n 357→ Ok(_) => {\n 358→ let trimmed = line.trim();\n 359→ if trimmed.is_empty() {\n 360→ line.clear();\n 361→ continue;\n 362→ }\n 363→\n 364→ match serde_json::from_str::<IpcRequest>(trimmed) {\n 365→ Ok(request) => {\n 366→ if matches!(request, IpcRequest::Subscribe) {\n 367→ subscribed = true;\n 368→ } else if matches!(request, IpcRequest::Unsubscribe) {\n 369→ subscribed = false;\n 370→ }\n 371→\n 372→ let response = state.handle_request(request).await;\n 373→ let response_json = serde_json::to_string(&response)?;\n 374→ writer.write_all(response_json.as_bytes()).await?;\n 375→ writer.write_all(b\"\\n\").await?;\n 376→ writer.flush().await?;\n 377→ }\n 378→ Err(e) => {\n 379→ warn!(\"Invalid IPC request: {}\", e);\n 380→ let response = IpcResponse::Error {\n 381→ message: format!(\"Invalid request: {}\", e),\n 382→ };\n 383→ let response_json = serde_json::to_string(&response)?;\n 384→ writer.write_all(response_json.as_bytes()).await?;\n 385→ writer.write_all(b\"\\n\").await?;\n 386→ writer.flush().await?;\n 387→ }\n 388→ }\n 389→ line.clear();\n 390→ }\n 391→ Err(e) => {\n 392→ return Err(e.into());\n 393→ }\n 394→ }\n 395→ }\n 396→\n 397→ // Push status updates to subscribed clients\n 398→ result = status_rx.recv(), if subscribed => {\n 399→ if let Ok(status) = result {\n 400→ let response = IpcResponse::StatusUpdate(status);\n 401→ let response_json = serde_json::to_string(&response)?;\n 402→ writer.write_all(response_json.as_bytes()).await?;\n 403→ writer.write_all(b\"\\n\").await?;\n 404→ writer.flush().await?;\n 405→ }\n 406→ }\n 407→\n 408→ // Push policy updates to subscribed clients\n 409→ result = policy_rx.recv(), if subscribed => {\n 410→ if let Ok(policy) = result {\n 411→ let response = IpcResponse::PolicyUpdate(policy);\n 412→ let response_json = serde_json::to_string(&response)?;\n 413→ writer.write_all(response_json.as_bytes()).await?;\n 414→ writer.write_all(b\"\\n\").await?;\n 415→ writer.flush().await?;\n 416→ }\n 417→ }\n 418→ }\n 419→ }\n 420→\n 421→ Ok(())\n 422→ }\n 423→}\n 424→\n 425→// ============================================================================\n 426→// Unix Domain Socket Server (placeholder for future)\n 427→// ============================================================================\n 428→\n 429→#[cfg(unix)]\n 430→pub mod server {\n 431→ use super::*;\n 432→\n 433→ /// Run the IPC server (Unix domain socket)\n 434→ pub async fn run_ipc_server(_state: Arc<IpcState>) -> anyhow::Result<()> {\n 435→ // TODO: Implement Unix socket server for macOS/Linux\n 436→ info!(\"Unix IPC server not yet implemented\");\n 437→\n 438→ // For now, just sleep forever\n 439→ loop {\n 440→ tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;\n 441→ }\n 442→ }\n 443→}\n 444→\n 445→#[cfg(test)]\n 446→mod tests {\n 447→ use super::*;\n 448→\n 449→ #[test]\n 450→ fn test_tray_policy_serialization() {\n 451→ let policy = TrayPolicy::default_permissive();\n 452→ let json = serde_json::to_string(&policy).unwrap();\n 453→ let parsed: TrayPolicy = serde_json::from_str(&json).unwrap();\n 454→ assert_eq!(parsed.enabled, policy.enabled);\n 455→ assert_eq!(parsed.allow_stop_agent, policy.allow_stop_agent);\n 456→ }\n 457→\n 458→ #[test]\n 459→ fn test_ipc_request_serialization() {\n 460→ let request = IpcRequest::GetStatus;\n 461→ let json = serde_json::to_string(&request).unwrap();\n 462→ assert!(json.contains(\"get_status\"));\n 463→\n 464→ let parsed: IpcRequest = serde_json::from_str(&json).unwrap();\n 465→ assert!(matches!(parsed, IpcRequest::GetStatus));\n 466→ }\n 467→\n 468→ #[test]\n 469→ fn test_ipc_response_serialization() {\n 470→ let response = IpcResponse::Denied {\n 471→ message: \"Not allowed\".to_string(),\n 472→ };\n 473→ let json = serde_json::to_string(&response).unwrap();\n 474→ assert!(json.contains(\"denied\"));\n 475→\n 476→ let parsed: IpcResponse = serde_json::from_str(&json).unwrap();\n 477→ assert!(matches!(parsed, IpcResponse::Denied { .. }));\n 478→ }\n 479→}\n 480→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"a68de8c5-ac68-4447-84d9-bff6e64ad3a8","timestamp":"2025-12-21T21:36:10.020Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\ipc.rs","content":"//! IPC (Inter-Process Communication) for tray application\n//!\n//! Provides a named pipe server (Windows) or Unix socket (Unix) for the\n//! tray application to communicate with the agent service.\n\nuse serde::{Deserialize, Serialize};\nuse std::sync::Arc;\nuse tokio::sync::{broadcast, RwLock};\nuse tracing::{debug, error, info, warn};\n\n/// Pipe name for Windows named pipe\n#[cfg(windows)]\npub const PIPE_NAME: &str = r\"\\\\.\\pipe\\gururmm-agent\";\n\n/// Socket path for Unix domain socket\n#[cfg(unix)]\npub const SOCKET_PATH: &str = \"/var/run/gururmm/agent.sock\";\n\n/// Tray policy - controls what the tray app can show and do\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct TrayPolicy {\n /// Whether to show the tray icon at all\n pub enabled: bool,\n\n /// Show connection status in tooltip/menu\n pub show_status: bool,\n\n /// Show agent info (version, hostname, etc.) in menu\n pub show_info: bool,\n\n /// Allow user to trigger manual check-in\n pub allow_force_checkin: bool,\n\n /// Allow user to view agent logs\n pub allow_view_logs: bool,\n\n /// Allow user to open web dashboard\n pub allow_open_dashboard: bool,\n\n /// Allow user to stop the agent (dangerous!)\n pub allow_stop_agent: bool,\n\n /// Dashboard URL (if allow_open_dashboard is true)\n pub dashboard_url: Option<String>,\n\n /// Custom tooltip text\n pub tooltip_text: Option<String>,\n\n /// Custom icon (base64 encoded PNG)\n pub custom_icon: Option<String>,\n}\n\nimpl TrayPolicy {\n /// Default permissive policy (for development/testing)\n pub fn default_permissive() -> Self {\n Self {\n enabled: true,\n show_status: true,\n show_info: true,\n allow_force_checkin: true,\n allow_view_logs: true,\n allow_open_dashboard: true,\n allow_stop_agent: false,\n dashboard_url: None,\n tooltip_text: None,\n custom_icon: None,\n }\n }\n\n /// Restrictive policy (minimal visibility)\n pub fn default_restrictive() -> Self {\n Self {\n enabled: true,\n show_status: true,\n show_info: false,\n allow_force_checkin: false,\n allow_view_logs: false,\n allow_open_dashboard: false,\n allow_stop_agent: false,\n dashboard_url: None,\n tooltip_text: None,\n custom_icon: None,\n }\n }\n}\n\n/// Agent status for tray display\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AgentStatus {\n /// Whether connected to server\n pub connected: bool,\n\n /// Last successful check-in time (ISO 8601)\n pub last_checkin: Option<String>,\n\n /// Server URL we're connected to\n pub server_url: String,\n\n /// Agent version\n pub agent_version: String,\n\n /// Device ID\n pub device_id: String,\n\n /// Hostname\n pub hostname: String,\n\n /// Error message (if any)\n pub error: Option<String>,\n}\n\n/// Request from tray to agent\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\", content = \"payload\")]\n#[serde(rename_all = \"snake_case\")]\npub enum IpcRequest {\n /// Get current agent status\n GetStatus,\n\n /// Get current tray policy\n GetPolicy,\n\n /// Force immediate check-in\n ForceCheckin,\n\n /// Stop the agent service\n StopAgent,\n\n /// Subscribe to status updates\n Subscribe,\n\n /// Unsubscribe from updates\n Unsubscribe,\n\n /// Ping (health check)\n Ping,\n}\n\n/// Response from agent to tray\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\", content = \"payload\")]\n#[serde(rename_all = \"snake_case\")]\npub enum IpcResponse {\n /// Current agent status\n Status(AgentStatus),\n\n /// Current tray policy\n Policy(TrayPolicy),\n\n /// Success acknowledgment\n Ok,\n\n /// Pong response to ping\n Pong,\n\n /// Error\n Error { message: String },\n\n /// Action denied by policy\n Denied { message: String },\n\n /// Status update (pushed to subscribers)\n StatusUpdate(AgentStatus),\n\n /// Policy update (pushed to subscribers)\n PolicyUpdate(TrayPolicy),\n}\n\n/// Shared state for IPC server\npub struct IpcState {\n /// Current agent status\n pub status: RwLock<AgentStatus>,\n\n /// Current tray policy\n pub policy: RwLock<TrayPolicy>,\n\n /// Broadcast channel for status updates\n pub status_tx: broadcast::Sender<AgentStatus>,\n\n /// Broadcast channel for policy updates\n pub policy_tx: broadcast::Sender<TrayPolicy>,\n\n /// Channel to request force check-in\n pub force_checkin_tx: tokio::sync::mpsc::Sender<()>,\n\n /// Channel to request agent stop\n pub stop_agent_tx: tokio::sync::mpsc::Sender<()>,\n}\n\nimpl IpcState {\n pub fn new(\n initial_status: AgentStatus,\n initial_policy: TrayPolicy,\n force_checkin_tx: tokio::sync::mpsc::Sender<()>,\n stop_agent_tx: tokio::sync::mpsc::Sender<()>,\n ) -> Self {\n let (status_tx, _) = broadcast::channel(16);\n let (policy_tx, _) = broadcast::channel(16);\n\n Self {\n status: RwLock::new(initial_status),\n policy: RwLock::new(initial_policy),\n status_tx,\n policy_tx,\n force_checkin_tx,\n stop_agent_tx,\n }\n }\n\n /// Update status and broadcast to subscribers\n pub async fn update_status(&self, status: AgentStatus) {\n *self.status.write().await = status.clone();\n let _ = self.status_tx.send(status);\n }\n\n /// Update policy and broadcast to subscribers\n pub async fn update_policy(&self, policy: TrayPolicy) {\n *self.policy.write().await = policy.clone();\n let _ = self.policy_tx.send(policy);\n }\n\n /// Handle an IPC request\n pub async fn handle_request(&self, request: IpcRequest) -> IpcResponse {\n match request {\n IpcRequest::GetStatus => {\n let status = self.status.read().await.clone();\n IpcResponse::Status(status)\n }\n\n IpcRequest::GetPolicy => {\n let policy = self.policy.read().await.clone();\n IpcResponse::Policy(policy)\n }\n\n IpcRequest::ForceCheckin => {\n let policy = self.policy.read().await;\n if !policy.allow_force_checkin {\n return IpcResponse::Denied {\n message: \"Force check-in not allowed by policy\".to_string(),\n };\n }\n drop(policy);\n\n match self.force_checkin_tx.send(()).await {\n Ok(_) => IpcResponse::Ok,\n Err(_) => IpcResponse::Error {\n message: \"Failed to trigger check-in\".to_string(),\n },\n }\n }\n\n IpcRequest::StopAgent => {\n let policy = self.policy.read().await;\n if !policy.allow_stop_agent {\n return IpcResponse::Denied {\n message: \"Stopping agent not allowed by policy\".to_string(),\n };\n }\n drop(policy);\n\n match self.stop_agent_tx.send(()).await {\n Ok(_) => IpcResponse::Ok,\n Err(_) => IpcResponse::Error {\n message: \"Failed to request agent stop\".to_string(),\n },\n }\n }\n\n IpcRequest::Subscribe => {\n // Subscription is handled at the connection level\n IpcResponse::Ok\n }\n\n IpcRequest::Unsubscribe => {\n IpcResponse::Ok\n }\n\n IpcRequest::Ping => IpcResponse::Pong,\n }\n }\n}\n\n// ============================================================================\n// Windows Named Pipe Server\n// ============================================================================\n\n#[cfg(windows)]\npub mod server {\n use super::*;\n use std::ffi::OsStr;\n use std::os::windows::ffi::OsStrExt;\n use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};\n use tokio::net::windows::named_pipe::{ServerOptions, NamedPipeServer};\n\n /// Run the IPC server (Windows named pipe)\n pub async fn run_ipc_server(state: Arc<IpcState>) -> anyhow::Result<()> {\n info!(\"Starting IPC server on {}\", super::PIPE_NAME);\n\n loop {\n // Create a new pipe instance\n let server = match ServerOptions::new()\n .first_pipe_instance(false)\n .create(super::PIPE_NAME)\n {\n Ok(s) => s,\n Err(e) => {\n // First instance needs different handling\n if e.kind() == std::io::ErrorKind::NotFound {\n ServerOptions::new()\n .first_pipe_instance(true)\n .create(super::PIPE_NAME)?\n } else {\n error!(\"Failed to create pipe: {}\", e);\n tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;\n continue;\n }\n }\n };\n\n // Wait for a client to connect\n if let Err(e) = server.connect().await {\n error!(\"Failed to accept pipe connection: {}\", e);\n continue;\n }\n\n info!(\"IPC client connected\");\n\n // Spawn a task to handle this client\n let state = Arc::clone(&state);\n tokio::spawn(async move {\n if let Err(e) = handle_client(server, state).await {\n debug!(\"IPC client disconnected: {}\", e);\n }\n });\n }\n }\n\n async fn handle_client(\n pipe: NamedPipeServer,\n state: Arc<IpcState>,\n ) -> anyhow::Result<()> {\n let (reader, mut writer) = tokio::io::split(pipe);\n let mut reader = BufReader::new(reader);\n let mut line = String::new();\n\n // Subscribe to updates\n let mut status_rx = state.status_tx.subscribe();\n let mut policy_rx = state.policy_tx.subscribe();\n let mut subscribed = false;\n\n loop {\n tokio::select! {\n // Read request from client\n result = reader.read_line(&mut line) => {\n match result {\n Ok(0) => break, // EOF\n Ok(_) => {\n let trimmed = line.trim();\n if trimmed.is_empty() {\n line.clear();\n continue;\n }\n\n match serde_json::from_str::<IpcRequest>(trimmed) {\n Ok(request) => {\n if matches!(request, IpcRequest::Subscribe) {\n subscribed = true;\n } else if matches!(request, IpcRequest::Unsubscribe) {\n subscribed = false;\n }\n\n let response = state.handle_request(request).await;\n let response_json = serde_json::to_string(&response)?;\n writer.write_all(response_json.as_bytes()).await?;\n writer.write_all(b\"\\n\").await?;\n writer.flush().await?;\n }\n Err(e) => {\n warn!(\"Invalid IPC request: {}\", e);\n let response = IpcResponse::Error {\n message: format!(\"Invalid request: {}\", e),\n };\n let response_json = serde_json::to_string(&response)?;\n writer.write_all(response_json.as_bytes()).await?;\n writer.write_all(b\"\\n\").await?;\n writer.flush().await?;\n }\n }\n line.clear();\n }\n Err(e) => {\n return Err(e.into());\n }\n }\n }\n\n // Push status updates to subscribed clients\n result = status_rx.recv(), if subscribed => {\n if let Ok(status) = result {\n let response = IpcResponse::StatusUpdate(status);\n let response_json = serde_json::to_string(&response)?;\n writer.write_all(response_json.as_bytes()).await?;\n writer.write_all(b\"\\n\").await?;\n writer.flush().await?;\n }\n }\n\n // Push policy updates to subscribed clients\n result = policy_rx.recv(), if subscribed => {\n if let Ok(policy) = result {\n let response = IpcResponse::PolicyUpdate(policy);\n let response_json = serde_json::to_string(&response)?;\n writer.write_all(response_json.as_bytes()).await?;\n writer.write_all(b\"\\n\").await?;\n writer.flush().await?;\n }\n }\n }\n }\n\n Ok(())\n }\n}\n\n// ============================================================================\n// Unix Domain Socket Server (placeholder for future)\n// ============================================================================\n\n#[cfg(unix)]\npub mod server {\n use super::*;\n\n /// Run the IPC server (Unix domain socket)\n pub async fn run_ipc_server(_state: Arc<IpcState>) -> anyhow::Result<()> {\n // TODO: Implement Unix socket server for macOS/Linux\n info!(\"Unix IPC server not yet implemented\");\n\n // For now, just sleep forever\n loop {\n tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_tray_policy_serialization() {\n let policy = TrayPolicy::default_permissive();\n let json = serde_json::to_string(&policy).unwrap();\n let parsed: TrayPolicy = serde_json::from_str(&json).unwrap();\n assert_eq!(parsed.enabled, policy.enabled);\n assert_eq!(parsed.allow_stop_agent, policy.allow_stop_agent);\n }\n\n #[test]\n fn test_ipc_request_serialization() {\n let request = IpcRequest::GetStatus;\n let json = serde_json::to_string(&request).unwrap();\n assert!(json.contains(\"get_status\"));\n\n let parsed: IpcRequest = serde_json::from_str(&json).unwrap();\n assert!(matches!(parsed, IpcRequest::GetStatus));\n }\n\n #[test]\n fn test_ipc_response_serialization() {\n let response = IpcResponse::Denied {\n message: \"Not allowed\".to_string(),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"denied\"));\n\n let parsed: IpcResponse = serde_json::from_str(&json).unwrap();\n assert!(matches!(parsed, IpcResponse::Denied { .. }));\n }\n}\n","numLines":480,"startLine":1,"totalLines":480}}}
|
||
{"parentUuid":"a68de8c5-ac68-4447-84d9-bff6e64ad3a8","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01E1uvath9MggcRFLQMuujRi","type":"tool_result","content":" 1→-- GuruRMM Initial Schema\n 2→-- Creates tables for agents, metrics, commands, watchdog events, and users\n 3→\n 4→-- Enable UUID extension\n 5→CREATE EXTENSION IF NOT EXISTS \"pgcrypto\";\n 6→\n 7→-- Agents table\n 8→-- Stores registered agents and their current status\n 9→CREATE TABLE agents (\n 10→ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n 11→ hostname VARCHAR(255) NOT NULL,\n 12→ api_key_hash VARCHAR(255) NOT NULL,\n 13→ os_type VARCHAR(50) NOT NULL,\n 14→ os_version VARCHAR(100),\n 15→ agent_version VARCHAR(50),\n 16→ last_seen TIMESTAMPTZ,\n 17→ status VARCHAR(20) DEFAULT 'offline',\n 18→ created_at TIMESTAMPTZ DEFAULT NOW(),\n 19→ updated_at TIMESTAMPTZ DEFAULT NOW()\n 20→);\n 21→\n 22→-- Index for looking up agents by hostname\n 23→CREATE INDEX idx_agents_hostname ON agents(hostname);\n 24→\n 25→-- Index for finding online agents\n 26→CREATE INDEX idx_agents_status ON agents(status);\n 27→\n 28→-- Metrics table\n 29→-- Time-series data for system metrics from agents\n 30→CREATE TABLE metrics (\n 31→ id BIGSERIAL PRIMARY KEY,\n 32→ agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,\n 33→ timestamp TIMESTAMPTZ DEFAULT NOW(),\n 34→ cpu_percent REAL,\n 35→ memory_percent REAL,\n 36→ memory_used_bytes BIGINT,\n 37→ disk_percent REAL,\n 38→ disk_used_bytes BIGINT,\n 39→ network_rx_bytes BIGINT,\n 40→ network_tx_bytes BIGINT\n 41→);\n 42→\n 43→-- Index for querying metrics by agent and time\n 44→CREATE INDEX idx_metrics_agent_time ON metrics(agent_id, timestamp DESC);\n 45→\n 46→-- Index for finding recent metrics\n 47→CREATE INDEX idx_metrics_timestamp ON metrics(timestamp DESC);\n 48→\n 49→-- Users table\n 50→-- Dashboard users for authentication\n 51→CREATE TABLE users (\n 52→ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n 53→ email VARCHAR(255) UNIQUE NOT NULL,\n 54→ password_hash VARCHAR(255),\n 55→ name VARCHAR(255),\n 56→ role VARCHAR(50) DEFAULT 'user',\n 57→ sso_provider VARCHAR(50),\n 58→ sso_id VARCHAR(255),\n 59→ created_at TIMESTAMPTZ DEFAULT NOW(),\n 60→ last_login TIMESTAMPTZ\n 61→);\n 62→\n 63→-- Index for email lookups during login\n 64→CREATE INDEX idx_users_email ON users(email);\n 65→\n 66→-- Index for SSO lookups\n 67→CREATE INDEX idx_users_sso ON users(sso_provider, sso_id);\n 68→\n 69→-- Commands table\n 70→-- Commands sent to agents and their results\n 71→CREATE TABLE commands (\n 72→ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n 73→ agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,\n 74→ command_type VARCHAR(50) NOT NULL,\n 75→ command_text TEXT NOT NULL,\n 76→ status VARCHAR(20) DEFAULT 'pending',\n 77→ exit_code INTEGER,\n 78→ stdout TEXT,\n 79→ stderr TEXT,\n 80→ created_at TIMESTAMPTZ DEFAULT NOW(),\n 81→ started_at TIMESTAMPTZ,\n 82→ completed_at TIMESTAMPTZ,\n 83→ created_by UUID REFERENCES users(id) ON DELETE SET NULL\n 84→);\n 85→\n 86→-- Index for finding pending commands for an agent\n 87→CREATE INDEX idx_commands_agent_status ON commands(agent_id, status);\n 88→\n 89→-- Index for command history queries\n 90→CREATE INDEX idx_commands_created ON commands(created_at DESC);\n 91→\n 92→-- Watchdog events table\n 93→-- Events from agent watchdog monitoring\n 94→CREATE TABLE watchdog_events (\n 95→ id BIGSERIAL PRIMARY KEY,\n 96→ agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,\n 97→ timestamp TIMESTAMPTZ DEFAULT NOW(),\n 98→ service_name VARCHAR(255) NOT NULL,\n 99→ event_type VARCHAR(50) NOT NULL,\n 100→ details TEXT\n 101→);\n 102→\n 103→-- Index for querying events by agent and time\n 104→CREATE INDEX idx_watchdog_agent_time ON watchdog_events(agent_id, timestamp DESC);\n 105→\n 106→-- Index for finding recent events\n 107→CREATE INDEX idx_watchdog_timestamp ON watchdog_events(timestamp DESC);\n 108→\n 109→-- Function to update updated_at timestamp\n 110→CREATE OR REPLACE FUNCTION update_updated_at_column()\n 111→RETURNS TRIGGER AS $$\n 112→BEGIN\n 113→ NEW.updated_at = NOW();\n 114→ RETURN NEW;\n 115→END;\n 116→$$ LANGUAGE plpgsql;\n 117→\n 118→-- Trigger for agents table\n 119→CREATE TRIGGER update_agents_updated_at\n 120→ BEFORE UPDATE ON agents\n 121→ FOR EACH ROW\n 122→ EXECUTE FUNCTION update_updated_at_column();\n 123→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"b2d8f9e6-7eba-46ec-b824-c6a9fe3542a3","timestamp":"2025-12-21T21:36:10.028Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\001_initial.sql","content":"-- GuruRMM Initial Schema\n-- Creates tables for agents, metrics, commands, watchdog events, and users\n\n-- Enable UUID extension\nCREATE EXTENSION IF NOT EXISTS \"pgcrypto\";\n\n-- Agents table\n-- Stores registered agents and their current status\nCREATE TABLE agents (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n hostname VARCHAR(255) NOT NULL,\n api_key_hash VARCHAR(255) NOT NULL,\n os_type VARCHAR(50) NOT NULL,\n os_version VARCHAR(100),\n agent_version VARCHAR(50),\n last_seen TIMESTAMPTZ,\n status VARCHAR(20) DEFAULT 'offline',\n created_at TIMESTAMPTZ DEFAULT NOW(),\n updated_at TIMESTAMPTZ DEFAULT NOW()\n);\n\n-- Index for looking up agents by hostname\nCREATE INDEX idx_agents_hostname ON agents(hostname);\n\n-- Index for finding online agents\nCREATE INDEX idx_agents_status ON agents(status);\n\n-- Metrics table\n-- Time-series data for system metrics from agents\nCREATE TABLE metrics (\n id BIGSERIAL PRIMARY KEY,\n agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,\n timestamp TIMESTAMPTZ DEFAULT NOW(),\n cpu_percent REAL,\n memory_percent REAL,\n memory_used_bytes BIGINT,\n disk_percent REAL,\n disk_used_bytes BIGINT,\n network_rx_bytes BIGINT,\n network_tx_bytes BIGINT\n);\n\n-- Index for querying metrics by agent and time\nCREATE INDEX idx_metrics_agent_time ON metrics(agent_id, timestamp DESC);\n\n-- Index for finding recent metrics\nCREATE INDEX idx_metrics_timestamp ON metrics(timestamp DESC);\n\n-- Users table\n-- Dashboard users for authentication\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(255),\n name VARCHAR(255),\n role VARCHAR(50) DEFAULT 'user',\n sso_provider VARCHAR(50),\n sso_id VARCHAR(255),\n created_at TIMESTAMPTZ DEFAULT NOW(),\n last_login TIMESTAMPTZ\n);\n\n-- Index for email lookups during login\nCREATE INDEX idx_users_email ON users(email);\n\n-- Index for SSO lookups\nCREATE INDEX idx_users_sso ON users(sso_provider, sso_id);\n\n-- Commands table\n-- Commands sent to agents and their results\nCREATE TABLE commands (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,\n command_type VARCHAR(50) NOT NULL,\n command_text TEXT NOT NULL,\n status VARCHAR(20) DEFAULT 'pending',\n exit_code INTEGER,\n stdout TEXT,\n stderr TEXT,\n created_at TIMESTAMPTZ DEFAULT NOW(),\n started_at TIMESTAMPTZ,\n completed_at TIMESTAMPTZ,\n created_by UUID REFERENCES users(id) ON DELETE SET NULL\n);\n\n-- Index for finding pending commands for an agent\nCREATE INDEX idx_commands_agent_status ON commands(agent_id, status);\n\n-- Index for command history queries\nCREATE INDEX idx_commands_created ON commands(created_at DESC);\n\n-- Watchdog events table\n-- Events from agent watchdog monitoring\nCREATE TABLE watchdog_events (\n id BIGSERIAL PRIMARY KEY,\n agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,\n timestamp TIMESTAMPTZ DEFAULT NOW(),\n service_name VARCHAR(255) NOT NULL,\n event_type VARCHAR(50) NOT NULL,\n details TEXT\n);\n\n-- Index for querying events by agent and time\nCREATE INDEX idx_watchdog_agent_time ON watchdog_events(agent_id, timestamp DESC);\n\n-- Index for finding recent events\nCREATE INDEX idx_watchdog_timestamp ON watchdog_events(timestamp DESC);\n\n-- Function to update updated_at timestamp\nCREATE OR REPLACE FUNCTION update_updated_at_column()\nRETURNS TRIGGER AS $$\nBEGIN\n NEW.updated_at = NOW();\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\n-- Trigger for agents table\nCREATE TRIGGER update_agents_updated_at\n BEFORE UPDATE ON agents\n FOR EACH ROW\n EXECUTE FUNCTION update_updated_at_column();\n","numLines":123,"startLine":1,"totalLines":123}}}
|
||
{"parentUuid":"b2d8f9e6-7eba-46ec-b824-c6a9fe3542a3","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JJxEGNrRiX9Rdfn1fyjiND","type":"tool_result","content":" 1→-- GuruRMM Clients and Sites Schema\n 2→-- Adds multi-tenant support with clients, sites, and site-based agent registration\n 3→\n 4→-- Clients table (organizations/companies)\n 5→CREATE TABLE clients (\n 6→ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n 7→ name VARCHAR(255) NOT NULL,\n 8→ code VARCHAR(50) UNIQUE, -- Optional short code like \"ACME\"\n 9→ notes TEXT,\n 10→ is_active BOOLEAN DEFAULT TRUE,\n 11→ created_at TIMESTAMPTZ DEFAULT NOW(),\n 12→ updated_at TIMESTAMPTZ DEFAULT NOW()\n 13→);\n 14→\n 15→CREATE INDEX idx_clients_name ON clients(name);\n 16→CREATE INDEX idx_clients_code ON clients(code);\n 17→\n 18→-- Trigger for clients updated_at\n 19→CREATE TRIGGER update_clients_updated_at\n 20→ BEFORE UPDATE ON clients\n 21→ FOR EACH ROW\n 22→ EXECUTE FUNCTION update_updated_at_column();\n 23→\n 24→-- Sites table (locations under a client)\n 25→CREATE TABLE sites (\n 26→ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n 27→ client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,\n 28→ name VARCHAR(255) NOT NULL,\n 29→ -- Site code: human-friendly, used for agent registration (e.g., \"BLUE-TIGER-4829\")\n 30→ site_code VARCHAR(50) UNIQUE NOT NULL,\n 31→ -- API key hash for this site (all agents at site share this key)\n 32→ api_key_hash VARCHAR(255) NOT NULL,\n 33→ address TEXT,\n 34→ notes TEXT,\n 35→ is_active BOOLEAN DEFAULT TRUE,\n 36→ created_at TIMESTAMPTZ DEFAULT NOW(),\n 37→ updated_at TIMESTAMPTZ DEFAULT NOW()\n 38→);\n 39→\n 40→CREATE INDEX idx_sites_client ON sites(client_id);\n 41→CREATE INDEX idx_sites_code ON sites(site_code);\n 42→CREATE INDEX idx_sites_api_key ON sites(api_key_hash);\n 43→\n 44→-- Trigger for sites updated_at\n 45→CREATE TRIGGER update_sites_updated_at\n 46→ BEFORE UPDATE ON sites\n 47→ FOR EACH ROW\n 48→ EXECUTE FUNCTION update_updated_at_column();\n 49→\n 50→-- Add new columns to agents table\n 51→-- device_id: unique hardware-derived identifier for the machine\n 52→ALTER TABLE agents ADD COLUMN device_id VARCHAR(255);\n 53→\n 54→-- site_id: which site this agent belongs to (nullable for legacy agents)\n 55→ALTER TABLE agents ADD COLUMN site_id UUID REFERENCES sites(id) ON DELETE SET NULL;\n 56→\n 57→-- Make api_key_hash nullable (new agents will use site's api_key)\n 58→ALTER TABLE agents ALTER COLUMN api_key_hash DROP NOT NULL;\n 59→\n 60→-- Index for looking up agents by device_id within a site\n 61→CREATE UNIQUE INDEX idx_agents_site_device ON agents(site_id, device_id) WHERE site_id IS NOT NULL AND device_id IS NOT NULL;\n 62→\n 63→-- Index for site lookups\n 64→CREATE INDEX idx_agents_site ON agents(site_id);\n 65→\n 66→-- Registration tokens table (optional: for secure site code distribution)\n 67→CREATE TABLE registration_tokens (\n 68→ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n 69→ site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,\n 70→ token_hash VARCHAR(255) NOT NULL,\n 71→ description VARCHAR(255),\n 72→ uses_remaining INTEGER, -- NULL = unlimited\n 73→ expires_at TIMESTAMPTZ,\n 74→ created_at TIMESTAMPTZ DEFAULT NOW(),\n 75→ created_by UUID REFERENCES users(id) ON DELETE SET NULL\n 76→);\n 77→\n 78→CREATE INDEX idx_reg_tokens_site ON registration_tokens(site_id);\n 79→CREATE INDEX idx_reg_tokens_hash ON registration_tokens(token_hash);\n 80→\n 81→-- Function to generate a random site code (WORD-WORD-####)\n 82→-- This is just a helper; actual generation should be in application code\n 83→-- for better word lists\n 84→CREATE OR REPLACE FUNCTION generate_site_code() RETURNS VARCHAR(50) AS $$\n 85→DECLARE\n 86→ words TEXT[] := ARRAY['ALPHA', 'BETA', 'GAMMA', 'DELTA', 'ECHO', 'FOXTROT',\n 87→ 'BLUE', 'GREEN', 'RED', 'GOLD', 'SILVER', 'IRON',\n 88→ 'HAWK', 'EAGLE', 'TIGER', 'LION', 'WOLF', 'BEAR',\n 89→ 'NORTH', 'SOUTH', 'EAST', 'WEST', 'PEAK', 'VALLEY',\n 90→ 'RIVER', 'OCEAN', 'STORM', 'CLOUD', 'STAR', 'MOON'];\n 91→ word1 TEXT;\n 92→ word2 TEXT;\n 93→ num INTEGER;\n 94→BEGIN\n 95→ word1 := words[1 + floor(random() * array_length(words, 1))::int];\n 96→ word2 := words[1 + floor(random() * array_length(words, 1))::int];\n 97→ num := 1000 + floor(random() * 9000)::int;\n 98→ RETURN word1 || '-' || word2 || '-' || num::text;\n 99→END;\n 100→$$ LANGUAGE plpgsql;\n 101→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"517f7f6c-9c61-4115-89dd-55ac95cdbf4d","timestamp":"2025-12-21T21:36:10.029Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\002_clients_sites.sql","content":"-- GuruRMM Clients and Sites Schema\n-- Adds multi-tenant support with clients, sites, and site-based agent registration\n\n-- Clients table (organizations/companies)\nCREATE TABLE clients (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n name VARCHAR(255) NOT NULL,\n code VARCHAR(50) UNIQUE, -- Optional short code like \"ACME\"\n notes TEXT,\n is_active BOOLEAN DEFAULT TRUE,\n created_at TIMESTAMPTZ DEFAULT NOW(),\n updated_at TIMESTAMPTZ DEFAULT NOW()\n);\n\nCREATE INDEX idx_clients_name ON clients(name);\nCREATE INDEX idx_clients_code ON clients(code);\n\n-- Trigger for clients updated_at\nCREATE TRIGGER update_clients_updated_at\n BEFORE UPDATE ON clients\n FOR EACH ROW\n EXECUTE FUNCTION update_updated_at_column();\n\n-- Sites table (locations under a client)\nCREATE TABLE sites (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,\n name VARCHAR(255) NOT NULL,\n -- Site code: human-friendly, used for agent registration (e.g., \"BLUE-TIGER-4829\")\n site_code VARCHAR(50) UNIQUE NOT NULL,\n -- API key hash for this site (all agents at site share this key)\n api_key_hash VARCHAR(255) NOT NULL,\n address TEXT,\n notes TEXT,\n is_active BOOLEAN DEFAULT TRUE,\n created_at TIMESTAMPTZ DEFAULT NOW(),\n updated_at TIMESTAMPTZ DEFAULT NOW()\n);\n\nCREATE INDEX idx_sites_client ON sites(client_id);\nCREATE INDEX idx_sites_code ON sites(site_code);\nCREATE INDEX idx_sites_api_key ON sites(api_key_hash);\n\n-- Trigger for sites updated_at\nCREATE TRIGGER update_sites_updated_at\n BEFORE UPDATE ON sites\n FOR EACH ROW\n EXECUTE FUNCTION update_updated_at_column();\n\n-- Add new columns to agents table\n-- device_id: unique hardware-derived identifier for the machine\nALTER TABLE agents ADD COLUMN device_id VARCHAR(255);\n\n-- site_id: which site this agent belongs to (nullable for legacy agents)\nALTER TABLE agents ADD COLUMN site_id UUID REFERENCES sites(id) ON DELETE SET NULL;\n\n-- Make api_key_hash nullable (new agents will use site's api_key)\nALTER TABLE agents ALTER COLUMN api_key_hash DROP NOT NULL;\n\n-- Index for looking up agents by device_id within a site\nCREATE UNIQUE INDEX idx_agents_site_device ON agents(site_id, device_id) WHERE site_id IS NOT NULL AND device_id IS NOT NULL;\n\n-- Index for site lookups\nCREATE INDEX idx_agents_site ON agents(site_id);\n\n-- Registration tokens table (optional: for secure site code distribution)\nCREATE TABLE registration_tokens (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,\n token_hash VARCHAR(255) NOT NULL,\n description VARCHAR(255),\n uses_remaining INTEGER, -- NULL = unlimited\n expires_at TIMESTAMPTZ,\n created_at TIMESTAMPTZ DEFAULT NOW(),\n created_by UUID REFERENCES users(id) ON DELETE SET NULL\n);\n\nCREATE INDEX idx_reg_tokens_site ON registration_tokens(site_id);\nCREATE INDEX idx_reg_tokens_hash ON registration_tokens(token_hash);\n\n-- Function to generate a random site code (WORD-WORD-####)\n-- This is just a helper; actual generation should be in application code\n-- for better word lists\nCREATE OR REPLACE FUNCTION generate_site_code() RETURNS VARCHAR(50) AS $$\nDECLARE\n words TEXT[] := ARRAY['ALPHA', 'BETA', 'GAMMA', 'DELTA', 'ECHO', 'FOXTROT',\n 'BLUE', 'GREEN', 'RED', 'GOLD', 'SILVER', 'IRON',\n 'HAWK', 'EAGLE', 'TIGER', 'LION', 'WOLF', 'BEAR',\n 'NORTH', 'SOUTH', 'EAST', 'WEST', 'PEAK', 'VALLEY',\n 'RIVER', 'OCEAN', 'STORM', 'CLOUD', 'STAR', 'MOON'];\n word1 TEXT;\n word2 TEXT;\n num INTEGER;\nBEGIN\n word1 := words[1 + floor(random() * array_length(words, 1))::int];\n word2 := words[1 + floor(random() * array_length(words, 1))::int];\n num := 1000 + floor(random() * 9000)::int;\n RETURN word1 || '-' || word2 || '-' || num::text;\nEND;\n$$ LANGUAGE plpgsql;\n","numLines":101,"startLine":1,"totalLines":101}}}
|
||
{"parentUuid":"517f7f6c-9c61-4115-89dd-55ac95cdbf4d","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01AtrMX7YTLXZ13hJQbhFjie","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QNWfwDL2kkaM7er9yK5SYZ","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\003_extended_metrics.sql"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":9391,"cache_read_input_tokens":46051,"cache_creation":{"ephemeral_5m_input_tokens":9391,"ephemeral_1h_input_tokens":0},"output_tokens":18,"service_tier":"standard"}},"requestId":"req_011CWLZ13Xz4wgxoVot51Jdy","type":"assistant","uuid":"a48e5da0-e971-4dc9-900e-cf34913cd324","timestamp":"2025-12-21T21:36:17.668Z"}
|
||
{"parentUuid":"a48e5da0-e971-4dc9-900e-cf34913cd324","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01AtrMX7YTLXZ13hJQbhFjie","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01He6wiZFNSYpde6VWvr9vC2","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\lib.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":9391,"cache_read_input_tokens":46051,"cache_creation":{"ephemeral_5m_input_tokens":9391,"ephemeral_1h_input_tokens":0},"output_tokens":18,"service_tier":"standard"}},"requestId":"req_011CWLZ13Xz4wgxoVot51Jdy","type":"assistant","uuid":"0ae67207-7131-4780-a7f4-1773802b1876","timestamp":"2025-12-21T21:36:18.521Z"}
|
||
{"parentUuid":"0ae67207-7131-4780-a7f4-1773802b1876","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01AtrMX7YTLXZ13hJQbhFjie","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UUe5KkFtBUZvu4DWgsPNot","name":"Grep","input":{"pattern":"AppState","path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src","output_mode":"content","-n":true}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":9391,"cache_read_input_tokens":46051,"cache_creation":{"ephemeral_5m_input_tokens":9391,"ephemeral_1h_input_tokens":0},"output_tokens":257,"service_tier":"standard"}},"requestId":"req_011CWLZ13Xz4wgxoVot51Jdy","type":"assistant","uuid":"87ba7c94-f7c1-4656-95ed-c60908154e3a","timestamp":"2025-12-21T21:36:20.104Z"}
|
||
{"parentUuid":"87ba7c94-f7c1-4656-95ed-c60908154e3a","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>File does not exist.</tool_use_error>","is_error":true,"tool_use_id":"toolu_01He6wiZFNSYpde6VWvr9vC2"}]},"uuid":"957e58b4-5347-4e15-990d-ad35c85445a0","timestamp":"2025-12-21T21:36:20.843Z","toolUseResult":"Error: File does not exist."}
|
||
{"parentUuid":"957e58b4-5347-4e15-990d-ad35c85445a0","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QNWfwDL2kkaM7er9yK5SYZ","type":"tool_result","content":" 1→-- Extended metrics and agent state\n 2→-- Adds columns for uptime, user info, IPs, and network state storage\n 3→\n 4→-- Add extended columns to metrics table\n 5→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS uptime_seconds BIGINT;\n 6→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS boot_time BIGINT;\n 7→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS logged_in_user VARCHAR(255);\n 8→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS user_idle_seconds BIGINT;\n 9→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS public_ip VARCHAR(45); -- Supports IPv6\n 10→\n 11→-- Agent state table for current/latest agent information\n 12→-- This stores the latest snapshot of extended agent info (not time-series)\n 13→CREATE TABLE IF NOT EXISTS agent_state (\n 14→ agent_id UUID PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,\n 15→ -- Network state\n 16→ network_interfaces JSONB,\n 17→ network_state_hash VARCHAR(32),\n 18→ -- Latest extended metrics (cached for quick access)\n 19→ uptime_seconds BIGINT,\n 20→ boot_time BIGINT,\n 21→ logged_in_user VARCHAR(255),\n 22→ user_idle_seconds BIGINT,\n 23→ public_ip VARCHAR(45),\n 24→ -- Timestamps\n 25→ network_updated_at TIMESTAMPTZ,\n 26→ metrics_updated_at TIMESTAMPTZ DEFAULT NOW()\n 27→);\n 28→\n 29→-- Index for finding agents by public IP (useful for diagnostics)\n 30→CREATE INDEX IF NOT EXISTS idx_agent_state_public_ip ON agent_state(public_ip);\n 31→\n 32→-- Add memory_total_bytes and disk_total_bytes to metrics for completeness\n 33→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS memory_total_bytes BIGINT;\n 34→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS disk_total_bytes BIGINT;\n 35→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"07731a08-69a6-4b69-abce-e933af19607f","timestamp":"2025-12-21T21:36:30.666Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\migrations\\003_extended_metrics.sql","content":"-- Extended metrics and agent state\n-- Adds columns for uptime, user info, IPs, and network state storage\n\n-- Add extended columns to metrics table\nALTER TABLE metrics ADD COLUMN IF NOT EXISTS uptime_seconds BIGINT;\nALTER TABLE metrics ADD COLUMN IF NOT EXISTS boot_time BIGINT;\nALTER TABLE metrics ADD COLUMN IF NOT EXISTS logged_in_user VARCHAR(255);\nALTER TABLE metrics ADD COLUMN IF NOT EXISTS user_idle_seconds BIGINT;\nALTER TABLE metrics ADD COLUMN IF NOT EXISTS public_ip VARCHAR(45); -- Supports IPv6\n\n-- Agent state table for current/latest agent information\n-- This stores the latest snapshot of extended agent info (not time-series)\nCREATE TABLE IF NOT EXISTS agent_state (\n agent_id UUID PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,\n -- Network state\n network_interfaces JSONB,\n network_state_hash VARCHAR(32),\n -- Latest extended metrics (cached for quick access)\n uptime_seconds BIGINT,\n boot_time BIGINT,\n logged_in_user VARCHAR(255),\n user_idle_seconds BIGINT,\n public_ip VARCHAR(45),\n -- Timestamps\n network_updated_at TIMESTAMPTZ,\n metrics_updated_at TIMESTAMPTZ DEFAULT NOW()\n);\n\n-- Index for finding agents by public IP (useful for diagnostics)\nCREATE INDEX IF NOT EXISTS idx_agent_state_public_ip ON agent_state(public_ip);\n\n-- Add memory_total_bytes and disk_total_bytes to metrics for completeness\nALTER TABLE metrics ADD COLUMN IF NOT EXISTS memory_total_bytes BIGINT;\nALTER TABLE metrics ADD COLUMN IF NOT EXISTS disk_total_bytes BIGINT;\n","numLines":35,"startLine":1,"totalLines":35}}}
|
||
{"parentUuid":"07731a08-69a6-4b69-abce-e933af19607f","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UUe5KkFtBUZvu4DWgsPNot","type":"tool_result","content":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\main.rs:37:pub struct AppState {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\main.rs:110: let state = AppState {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\main.rs:131:fn build_router(state: AppState) -> Router {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\auth\\mod.rs:16:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\auth\\mod.rs:110:impl FromRequestParts<AppState> for AuthUser {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\auth\\mod.rs:113: async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:16:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:36: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:66: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:78: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:91: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:113: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:130: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:156: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:167: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:179: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:217: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:296: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:325: State(_state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:345: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\clients.rs:13:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\clients.rs:47: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\clients.rs:72: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\clients.rs:99: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\clients.rs:126: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\clients.rs:156: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs:26:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs:264:pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs:269:async fn handle_socket(socket: WebSocket, state: AppState) {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs:432: state: &AppState,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs:558: state: &AppState,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\auth.rs:12:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\auth.rs:46: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\auth.rs:85: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\auth.rs:142: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\mod.rs:24:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\mod.rs:27:pub fn routes() -> Router<AppState> {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sso.rs:16:use crate::AppState;\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sso.rs:33: State(state): State<AppState>,\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sso.rs:104: State(state): State<AppState>,\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\metrics.rs:13:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\metrics.rs:30: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\metrics.rs:58: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:14:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:61: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:89: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:118: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:169: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:203: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:242: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:268: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\commands.rs:18:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\commands.rs:54: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\commands.rs:121: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\commands.rs:138: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\users.rs:16:use crate::AppState;\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\users.rs:20: State(state): State<AppState>,\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\users.rs:51: State(state): State<AppState>,\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\users.rs:108: State(state): State<AppState>,\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\users.rs:127: State(state): State<AppState>,"}]},"uuid":"b3e88b34-325e-42eb-95b4-9e80b4d6ff48","timestamp":"2025-12-21T21:36:31.655Z","toolUseResult":{"mode":"content","numFiles":0,"filenames":[],"content":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\main.rs:37:pub struct AppState {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\main.rs:110: let state = AppState {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\main.rs:131:fn build_router(state: AppState) -> Router {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\auth\\mod.rs:16:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\auth\\mod.rs:110:impl FromRequestParts<AppState> for AuthUser {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\auth\\mod.rs:113: async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:16:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:36: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:66: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:78: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:91: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:113: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:130: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:156: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:167: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:179: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:217: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:296: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:325: State(_state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\agents.rs:345: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\clients.rs:13:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\clients.rs:47: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\clients.rs:72: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\clients.rs:99: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\clients.rs:126: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\clients.rs:156: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs:26:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs:264:pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs:269:async fn handle_socket(socket: WebSocket, state: AppState) {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs:432: state: &AppState,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs:558: state: &AppState,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\auth.rs:12:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\auth.rs:46: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\auth.rs:85: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\auth.rs:142: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\mod.rs:24:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\mod.rs:27:pub fn routes() -> Router<AppState> {\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sso.rs:16:use crate::AppState;\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sso.rs:33: State(state): State<AppState>,\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sso.rs:104: State(state): State<AppState>,\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\metrics.rs:13:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\metrics.rs:30: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\metrics.rs:58: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:14:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:61: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:89: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:118: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:169: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:203: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:242: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\sites.rs:268: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\commands.rs:18:use crate::AppState;\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\commands.rs:54: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\commands.rs:121: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\commands.rs:138: State(state): State<AppState>,\r\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\users.rs:16:use crate::AppState;\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\users.rs:20: State(state): State<AppState>,\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\users.rs:51: State(state): State<AppState>,\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\users.rs:108: State(state): State<AppState>,\nC:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\users.rs:127: State(state): State<AppState>,","numLines":60}}
|
||
{"parentUuid":"b3e88b34-325e-42eb-95b4-9e80b4d6ff48","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XjS4mat6RDps7k8t2ChzWo","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HSaXeiCaes18du3V4JPGNx","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\main.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3667,"cache_read_input_tokens":55442,"cache_creation":{"ephemeral_5m_input_tokens":3667,"ephemeral_1h_input_tokens":0},"output_tokens":22,"service_tier":"standard"}},"requestId":"req_011CWLZ2X8Sbs3rXmJ234crd","type":"assistant","uuid":"8761d465-8157-4e95-b212-bda66c8127ea","timestamp":"2025-12-21T21:36:37.244Z"}
|
||
{"parentUuid":"8761d465-8157-4e95-b212-bda66c8127ea","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XjS4mat6RDps7k8t2ChzWo","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01P8CGMxJJfnzpRWXUpzK5XK","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\transport\\websocket.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3667,"cache_read_input_tokens":55442,"cache_creation":{"ephemeral_5m_input_tokens":3667,"ephemeral_1h_input_tokens":0},"output_tokens":144,"service_tier":"standard"}},"requestId":"req_011CWLZ2X8Sbs3rXmJ234crd","type":"assistant","uuid":"4601bde5-cdce-4e1d-a90e-739ad10bd01f","timestamp":"2025-12-21T21:36:38.073Z"}
|
||
{"parentUuid":"4601bde5-cdce-4e1d-a90e-739ad10bd01f","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HSaXeiCaes18du3V4JPGNx","type":"tool_result","content":" 1→//! GuruRMM Server - RMM Management Server\n 2→//!\n 3→//! Provides the backend API and WebSocket endpoint for GuruRMM agents.\n 4→//! Features:\n 5→//! - Agent registration and management\n 6→//! - Real-time WebSocket communication with agents\n 7→//! - Metrics storage and retrieval\n 8→//! - Command execution via agents\n 9→//! - Dashboard authentication\n 10→\n 11→mod api;\n 12→mod auth;\n 13→mod config;\n 14→mod db;\n 15→mod updates;\n 16→mod ws;\n 17→\n 18→use std::sync::Arc;\n 19→\n 20→use anyhow::Result;\n 21→use axum::{\n 22→ routing::{get, post},\n 23→ Router,\n 24→};\n 25→use sqlx::postgres::PgPoolOptions;\n 26→use tokio::sync::RwLock;\n 27→use tower_http::cors::{Any, CorsLayer};\n 28→use tower_http::trace::TraceLayer;\n 29→use tracing::info;\n 30→\n 31→use crate::config::ServerConfig;\n 32→use crate::updates::UpdateManager;\n 33→use crate::ws::AgentConnections;\n 34→\n 35→/// Shared application state\n 36→#[derive(Clone)]\n 37→pub struct AppState {\n 38→ /// Database connection pool\n 39→ pub db: sqlx::PgPool,\n 40→\n 41→ /// Server configuration\n 42→ pub config: Arc<ServerConfig>,\n 43→\n 44→ /// Connected agents (WebSocket connections)\n 45→ pub agents: Arc<RwLock<AgentConnections>>,\n 46→\n 47→ /// Agent update manager\n 48→ pub updates: Arc<UpdateManager>,\n 49→}\n 50→\n 51→#[tokio::main]\n 52→async fn main() -> Result<()> {\n 53→ // Load environment variables from .env file\n 54→ dotenvy::dotenv().ok();\n 55→\n 56→ // Initialize logging\n 57→ tracing_subscriber::fmt()\n 58→ .with_env_filter(\n 59→ tracing_subscriber::EnvFilter::from_default_env()\n 60→ .add_directive(\"gururmm_server=info\".parse()?)\n 61→ .add_directive(\"tower_http=debug\".parse()?)\n 62→ .add_directive(\"info\".parse()?),\n 63→ )\n 64→ .init();\n 65→\n 66→ info!(\"GuruRMM Server starting...\");\n 67→\n 68→ // Load configuration\n 69→ let config = ServerConfig::from_env()?;\n 70→ info!(\"Server configuration loaded\");\n 71→\n 72→ // Connect to database\n 73→ info!(\"Connecting to database...\");\n 74→ let db = PgPoolOptions::new()\n 75→ .max_connections(config.database.max_connections)\n 76→ .connect(&config.database.url)\n 77→ .await?;\n 78→ info!(\"Database connected\");\n 79→\n 80→ // Run migrations\n 81→ info!(\"Running database migrations...\");\n 82→ sqlx::migrate!(\"./migrations\").run(&db).await?;\n 83→ info!(\"Migrations complete\");\n 84→\n 85→ // Initialize update manager\n 86→ let update_manager = UpdateManager::new(\n 87→ config.updates.downloads_dir.clone(),\n 88→ config.updates.downloads_base_url.clone(),\n 89→ config.updates.auto_update_enabled,\n 90→ config.updates.update_timeout_secs,\n 91→ );\n 92→\n 93→ // Initial scan for available versions\n 94→ info!(\"Scanning for available agent versions...\");\n 95→ if let Err(e) = update_manager.scan_versions().await {\n 96→ tracing::warn!(\"Failed to scan agent versions: {} (continuing without auto-update)\", e);\n 97→ }\n 98→\n 99→ let update_manager = Arc::new(update_manager);\n 100→\n 101→ // Spawn background scanner (handle is intentionally not awaited - runs until server shutdown)\n 102→ let _scanner_handle = update_manager.spawn_scanner(config.updates.scan_interval_secs);\n 103→ info!(\n 104→ \"Auto-update: {} (scan interval: {}s)\",\n 105→ if config.updates.auto_update_enabled { \"enabled\" } else { \"disabled\" },\n 106→ config.updates.scan_interval_secs\n 107→ );\n 108→\n 109→ // Create shared state\n 110→ let state = AppState {\n 111→ db,\n 112→ config: Arc::new(config.clone()),\n 113→ agents: Arc::new(RwLock::new(AgentConnections::new())),\n 114→ updates: update_manager,\n 115→ };\n 116→\n 117→ // Build router\n 118→ let app = build_router(state);\n 119→\n 120→ // Start server\n 121→ let addr = format!(\"{}:{}\", config.server.host, config.server.port);\n 122→ info!(\"Starting server on {}\", addr);\n 123→\n 124→ let listener = tokio::net::TcpListener::bind(&addr).await?;\n 125→ axum::serve(listener, app).await?;\n 126→\n 127→ Ok(())\n 128→}\n 129→\n 130→/// Build the application router\n 131→fn build_router(state: AppState) -> Router {\n 132→ // CORS configuration (allow dashboard access)\n 133→ let cors = CorsLayer::new()\n 134→ .allow_origin(Any)\n 135→ .allow_methods(Any)\n 136→ .allow_headers(Any);\n 137→\n 138→ Router::new()\n 139→ // Health check\n 140→ .route(\"/health\", get(health_check))\n 141→ // WebSocket endpoint for agents\n 142→ .route(\"/ws\", get(ws::ws_handler))\n 143→ // API routes\n 144→ .nest(\"/api\", api::routes())\n 145→ // Middleware\n 146→ .layer(TraceLayer::new_for_http())\n 147→ .layer(cors)\n 148→ // State\n 149→ .with_state(state)\n 150→}\n 151→\n 152→/// Health check endpoint\n 153→async fn health_check() -> &'static str {\n 154→ \"OK\"\n 155→}\n 156→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"d839820e-b36c-4f3c-bc0e-5f5ad8991599","timestamp":"2025-12-21T21:36:47.686Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\main.rs","content":"//! GuruRMM Server - RMM Management Server\n//!\n//! Provides the backend API and WebSocket endpoint for GuruRMM agents.\n//! Features:\n//! - Agent registration and management\n//! - Real-time WebSocket communication with agents\n//! - Metrics storage and retrieval\n//! - Command execution via agents\n//! - Dashboard authentication\n\nmod api;\nmod auth;\nmod config;\nmod db;\nmod updates;\nmod ws;\n\nuse std::sync::Arc;\n\nuse anyhow::Result;\nuse axum::{\n routing::{get, post},\n Router,\n};\nuse sqlx::postgres::PgPoolOptions;\nuse tokio::sync::RwLock;\nuse tower_http::cors::{Any, CorsLayer};\nuse tower_http::trace::TraceLayer;\nuse tracing::info;\n\nuse crate::config::ServerConfig;\nuse crate::updates::UpdateManager;\nuse crate::ws::AgentConnections;\n\n/// Shared application state\n#[derive(Clone)]\npub struct AppState {\n /// Database connection pool\n pub db: sqlx::PgPool,\n\n /// Server configuration\n pub config: Arc<ServerConfig>,\n\n /// Connected agents (WebSocket connections)\n pub agents: Arc<RwLock<AgentConnections>>,\n\n /// Agent update manager\n pub updates: Arc<UpdateManager>,\n}\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n // Load environment variables from .env file\n dotenvy::dotenv().ok();\n\n // Initialize logging\n tracing_subscriber::fmt()\n .with_env_filter(\n tracing_subscriber::EnvFilter::from_default_env()\n .add_directive(\"gururmm_server=info\".parse()?)\n .add_directive(\"tower_http=debug\".parse()?)\n .add_directive(\"info\".parse()?),\n )\n .init();\n\n info!(\"GuruRMM Server starting...\");\n\n // Load configuration\n let config = ServerConfig::from_env()?;\n info!(\"Server configuration loaded\");\n\n // Connect to database\n info!(\"Connecting to database...\");\n let db = PgPoolOptions::new()\n .max_connections(config.database.max_connections)\n .connect(&config.database.url)\n .await?;\n info!(\"Database connected\");\n\n // Run migrations\n info!(\"Running database migrations...\");\n sqlx::migrate!(\"./migrations\").run(&db).await?;\n info!(\"Migrations complete\");\n\n // Initialize update manager\n let update_manager = UpdateManager::new(\n config.updates.downloads_dir.clone(),\n config.updates.downloads_base_url.clone(),\n config.updates.auto_update_enabled,\n config.updates.update_timeout_secs,\n );\n\n // Initial scan for available versions\n info!(\"Scanning for available agent versions...\");\n if let Err(e) = update_manager.scan_versions().await {\n tracing::warn!(\"Failed to scan agent versions: {} (continuing without auto-update)\", e);\n }\n\n let update_manager = Arc::new(update_manager);\n\n // Spawn background scanner (handle is intentionally not awaited - runs until server shutdown)\n let _scanner_handle = update_manager.spawn_scanner(config.updates.scan_interval_secs);\n info!(\n \"Auto-update: {} (scan interval: {}s)\",\n if config.updates.auto_update_enabled { \"enabled\" } else { \"disabled\" },\n config.updates.scan_interval_secs\n );\n\n // Create shared state\n let state = AppState {\n db,\n config: Arc::new(config.clone()),\n agents: Arc::new(RwLock::new(AgentConnections::new())),\n updates: update_manager,\n };\n\n // Build router\n let app = build_router(state);\n\n // Start server\n let addr = format!(\"{}:{}\", config.server.host, config.server.port);\n info!(\"Starting server on {}\", addr);\n\n let listener = tokio::net::TcpListener::bind(&addr).await?;\n axum::serve(listener, app).await?;\n\n Ok(())\n}\n\n/// Build the application router\nfn build_router(state: AppState) -> Router {\n // CORS configuration (allow dashboard access)\n let cors = CorsLayer::new()\n .allow_origin(Any)\n .allow_methods(Any)\n .allow_headers(Any);\n\n Router::new()\n // Health check\n .route(\"/health\", get(health_check))\n // WebSocket endpoint for agents\n .route(\"/ws\", get(ws::ws_handler))\n // API routes\n .nest(\"/api\", api::routes())\n // Middleware\n .layer(TraceLayer::new_for_http())\n .layer(cors)\n // State\n .with_state(state)\n}\n\n/// Health check endpoint\nasync fn health_check() -> &'static str {\n \"OK\"\n}\n","numLines":156,"startLine":1,"totalLines":156}}}
|
||
{"parentUuid":"d839820e-b36c-4f3c-bc0e-5f5ad8991599","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01P8CGMxJJfnzpRWXUpzK5XK","type":"tool_result","content":" 1→//! WebSocket client for server communication\n 2→//!\n 3→//! Handles the WebSocket connection lifecycle including:\n 4→//! - Connection establishment\n 5→//! - Authentication handshake\n 6→//! - Message sending/receiving\n 7→//! - Heartbeat maintenance\n 8→//! - Command handling\n 9→\n 10→use std::sync::Arc;\n 11→use std::time::Duration;\n 12→\n 13→use anyhow::{Context, Result};\n 14→use futures_util::{SinkExt, StreamExt};\n 15→use tokio::sync::mpsc;\n 16→use tokio::time::{interval, timeout};\n 17→use tokio_tungstenite::{connect_async, tungstenite::Message};\n 18→use tracing::{debug, error, info, warn};\n 19→\n 20→use super::{AgentMessage, AuthPayload, CommandPayload, ServerMessage, UpdatePayload, UpdateResultPayload, UpdateStatus};\n 21→use crate::metrics::NetworkState;\n 22→use crate::updater::{AgentUpdater, UpdaterConfig};\n 23→use crate::AppState;\n 24→\n 25→/// WebSocket client for communicating with the GuruRMM server\n 26→pub struct WebSocketClient;\n 27→\n 28→impl WebSocketClient {\n 29→ /// Connect to the server and run the message loop\n 30→ ///\n 31→ /// This function will return when the connection is closed or an error occurs.\n 32→ /// The caller should handle reconnection logic.\n 33→ pub async fn connect_and_run(state: Arc<AppState>) -> Result<()> {\n 34→ let url = &state.config.server.url;\n 35→\n 36→ // Connect to WebSocket server\n 37→ info!(\"Connecting to {}\", url);\n 38→ let (ws_stream, response) = connect_async(url)\n 39→ .await\n 40→ .context(\"Failed to connect to WebSocket server\")?;\n 41→\n 42→ info!(\n 43→ \"WebSocket connected (HTTP status: {})\",\n 44→ response.status()\n 45→ );\n 46→\n 47→ let (mut write, mut read) = ws_stream.split();\n 48→\n 49→ // Check for pending update (from previous update attempt)\n 50→ let updater_config = UpdaterConfig::default();\n 51→ let pending_update = AgentUpdater::load_pending_update(&updater_config).await;\n 52→\n 53→ // If we have pending update info, we just restarted after an update\n 54→ let (previous_version, pending_update_id) = if let Some(ref info) = pending_update {\n 55→ info!(\n 56→ \"Found pending update info: {} -> {} (id: {})\",\n 57→ info.old_version, info.target_version, info.update_id\n 58→ );\n 59→ (Some(info.old_version.clone()), Some(info.update_id))\n 60→ } else {\n 61→ (None, None)\n 62→ };\n 63→\n 64→ // Send authentication message\n 65→ let auth_msg = AgentMessage::Auth(AuthPayload {\n 66→ api_key: state.config.server.api_key.clone(),\n 67→ device_id: crate::device_id::get_device_id(),\n 68→ hostname: state.config.get_hostname(),\n 69→ os_type: std::env::consts::OS.to_string(),\n 70→ os_version: sysinfo::System::os_version().unwrap_or_else(|| \"unknown\".to_string()),\n 71→ agent_version: env!(\"CARGO_PKG_VERSION\").to_string(),\n 72→ architecture: Self::get_architecture().to_string(),\n 73→ previous_version,\n 74→ pending_update_id,\n 75→ });\n 76→\n 77→ let auth_json = serde_json::to_string(&auth_msg)?;\n 78→ write.send(Message::Text(auth_json)).await?;\n 79→ debug!(\"Sent authentication message\");\n 80→\n 81→ // Wait for auth response with timeout\n 82→ let auth_response = timeout(Duration::from_secs(10), read.next())\n 83→ .await\n 84→ .context(\"Authentication timeout\")?\n 85→ .ok_or_else(|| anyhow::anyhow!(\"Connection closed before auth response\"))?\n 86→ .context(\"Failed to receive auth response\")?;\n 87→\n 88→ // Parse auth response\n 89→ if let Message::Text(text) = auth_response {\n 90→ let server_msg: ServerMessage =\n 91→ serde_json::from_str(&text).context(\"Failed to parse auth response\")?;\n 92→\n 93→ match server_msg {\n 94→ ServerMessage::AuthAck(ack) => {\n 95→ if ack.success {\n 96→ info!(\"Authentication successful, agent_id: {:?}\", ack.agent_id);\n 97→ *state.connected.write().await = true;\n 98→\n 99→ // Send initial network state immediately after auth\n 100→ let network_state = NetworkState::collect();\n 101→ info!(\n 102→ \"Sending initial network state ({} interfaces)\",\n 103→ network_state.interfaces.len()\n 104→ );\n 105→ let network_msg = AgentMessage::NetworkState(network_state);\n 106→ let network_json = serde_json::to_string(&network_msg)?;\n 107→ write.send(Message::Text(network_json)).await?;\n 108→ } else {\n 109→ error!(\"Authentication failed: {:?}\", ack.error);\n 110→ return Err(anyhow::anyhow!(\n 111→ \"Authentication failed: {}\",\n 112→ ack.error.unwrap_or_else(|| \"Unknown error\".to_string())\n 113→ ));\n 114→ }\n 115→ }\n 116→ ServerMessage::Error { code, message } => {\n 117→ error!(\"Server error during auth: {} - {}\", code, message);\n 118→ return Err(anyhow::anyhow!(\"Server error: {} - {}\", code, message));\n 119→ }\n 120→ _ => {\n 121→ warn!(\"Unexpected message during auth: {:?}\", server_msg);\n 122→ }\n 123→ }\n 124→ }\n 125→\n 126→ // Create channel for outgoing messages\n 127→ let (tx, mut rx) = mpsc::channel::<AgentMessage>(100);\n 128→\n 129→ // Spawn metrics sender task\n 130→ let metrics_tx = tx.clone();\n 131→ let metrics_state = Arc::clone(&state);\n 132→ let metrics_interval = state.config.metrics.interval_seconds;\n 133→\n 134→ let metrics_task = tokio::spawn(async move {\n 135→ let mut timer = interval(Duration::from_secs(metrics_interval));\n 136→\n 137→ loop {\n 138→ timer.tick().await;\n 139→\n 140→ let metrics = metrics_state.metrics_collector.collect().await;\n 141→ if metrics_tx.send(AgentMessage::Metrics(metrics)).await.is_err() {\n 142→ debug!(\"Metrics channel closed\");\n 143→ break;\n 144→ }\n 145→ }\n 146→ });\n 147→\n 148→ // Spawn network state monitor task (checks for changes every 30 seconds)\n 149→ let network_tx = tx.clone();\n 150→ let network_task = tokio::spawn(async move {\n 151→ // Check for network changes every 30 seconds\n 152→ let mut timer = interval(Duration::from_secs(30));\n 153→ let mut last_state = NetworkState::collect();\n 154→\n 155→ loop {\n 156→ timer.tick().await;\n 157→\n 158→ let current_state = NetworkState::collect();\n 159→ if current_state.has_changed(&last_state) {\n 160→ info!(\n 161→ \"Network state changed (hash: {} -> {}), sending update\",\n 162→ last_state.state_hash, current_state.state_hash\n 163→ );\n 164→\n 165→ // Log the changes for debugging\n 166→ for iface in ¤t_state.interfaces {\n 167→ debug!(\n 168→ \" Interface {}: IPv4={:?}\",\n 169→ iface.name, iface.ipv4_addresses\n 170→ );\n 171→ }\n 172→\n 173→ if network_tx\n 174→ .send(AgentMessage::NetworkState(current_state.clone()))\n 175→ .await\n 176→ .is_err()\n 177→ {\n 178→ debug!(\"Network channel closed\");\n 179→ break;\n 180→ }\n 181→ last_state = current_state;\n 182→ }\n 183→ }\n 184→ });\n 185→\n 186→ // Spawn heartbeat task\n 187→ let heartbeat_tx = tx.clone();\n 188→ let heartbeat_task = tokio::spawn(async move {\n 189→ let mut timer = interval(Duration::from_secs(30));\n 190→\n 191→ loop {\n 192→ timer.tick().await;\n 193→\n 194→ if heartbeat_tx.send(AgentMessage::Heartbeat).await.is_err() {\n 195→ debug!(\"Heartbeat channel closed\");\n 196→ break;\n 197→ }\n 198→ }\n 199→ });\n 200→\n 201→ // Main message loop\n 202→ let result: Result<()> = loop {\n 203→ tokio::select! {\n 204→ // Handle outgoing messages\n 205→ Some(msg) = rx.recv() => {\n 206→ let json = serde_json::to_string(&msg)?;\n 207→ if let Err(e) = write.send(Message::Text(json)).await {\n 208→ break Err(e.into());\n 209→ }\n 210→\n 211→ match &msg {\n 212→ AgentMessage::Metrics(m) => {\n 213→ debug!(\"Sent metrics: CPU={:.1}%\", m.cpu_percent);\n 214→ }\n 215→ AgentMessage::NetworkState(n) => {\n 216→ debug!(\"Sent network state: {} interfaces, hash={}\",\n 217→ n.interfaces.len(), n.state_hash);\n 218→ }\n 219→ AgentMessage::Heartbeat => {\n 220→ debug!(\"Sent heartbeat\");\n 221→ }\n 222→ _ => {\n 223→ debug!(\"Sent message: {:?}\", std::mem::discriminant(&msg));\n 224→ }\n 225→ }\n 226→ }\n 227→\n 228→ // Handle incoming messages\n 229→ Some(msg_result) = read.next() => {\n 230→ match msg_result {\n 231→ Ok(Message::Text(text)) => {\n 232→ if let Err(e) = Self::handle_server_message(&text, &tx).await {\n 233→ error!(\"Error handling message: {}\", e);\n 234→ }\n 235→ }\n 236→ Ok(Message::Ping(data)) => {\n 237→ if let Err(e) = write.send(Message::Pong(data)).await {\n 238→ break Err(e.into());\n 239→ }\n 240→ }\n 241→ Ok(Message::Pong(_)) => {\n 242→ debug!(\"Received pong\");\n 243→ }\n 244→ Ok(Message::Close(frame)) => {\n 245→ info!(\"Server closed connection: {:?}\", frame);\n 246→ break Ok(());\n 247→ }\n 248→ Ok(Message::Binary(_)) => {\n 249→ warn!(\"Received unexpected binary message\");\n 250→ }\n 251→ Ok(Message::Frame(_)) => {\n 252→ // Raw frame, usually not seen\n 253→ }\n 254→ Err(e) => {\n 255→ error!(\"WebSocket error: {}\", e);\n 256→ break Err(e.into());\n 257→ }\n 258→ }\n 259→ }\n 260→\n 261→ // Connection timeout (no activity)\n 262→ _ = tokio::time::sleep(Duration::from_secs(90)) => {\n 263→ warn!(\"Connection timeout, no activity for 90 seconds\");\n 264→ break Err(anyhow::anyhow!(\"Connection timeout\"));\n 265→ }\n 266→ }\n 267→ };\n 268→\n 269→ // Cleanup\n 270→ metrics_task.abort();\n 271→ network_task.abort();\n 272→ heartbeat_task.abort();\n 273→ *state.connected.write().await = false;\n 274→\n 275→ result\n 276→ }\n 277→\n 278→ /// Handle a message received from the server\n 279→ async fn handle_server_message(\n 280→ text: &str,\n 281→ tx: &mpsc::Sender<AgentMessage>,\n 282→ ) -> Result<()> {\n 283→ let msg: ServerMessage =\n 284→ serde_json::from_str(text).context(\"Failed to parse server message\")?;\n 285→\n 286→ match msg {\n 287→ ServerMessage::Command(cmd) => {\n 288→ info!(\"Received command: {:?} (id: {})\", cmd.command_type, cmd.id);\n 289→ Self::execute_command(cmd, tx.clone()).await;\n 290→ }\n 291→ ServerMessage::ConfigUpdate(update) => {\n 292→ info!(\"Received config update: {:?}\", update);\n 293→ // Config updates will be handled in a future phase\n 294→ }\n 295→ ServerMessage::Ack { message_id } => {\n 296→ debug!(\"Received ack for message: {:?}\", message_id);\n 297→ }\n 298→ ServerMessage::AuthAck(_) => {\n 299→ // Already handled during initial auth\n 300→ }\n 301→ ServerMessage::Error { code, message } => {\n 302→ error!(\"Server error: {} - {}\", code, message);\n 303→ }\n 304→ ServerMessage::Update(payload) => {\n 305→ info!(\n 306→ \"Received update command: {} -> {} (id: {})\",\n 307→ env!(\"CARGO_PKG_VERSION\"),\n 308→ payload.target_version,\n 309→ payload.update_id\n 310→ );\n 311→ Self::handle_update(payload, tx.clone()).await;\n 312→ }\n 313→ ServerMessage::TrayPolicyUpdate(policy) => {\n 314→ info!(\"Received tray policy update: enabled={}\", policy.enabled);\n 315→ // Tray policy updates are handled via IPC broadcast\n 316→ // The IPC server will pick this up and notify connected tray apps\n 317→ }\n 318→ }\n 319→\n 320→ Ok(())\n 321→ }\n 322→\n 323→ /// Handle an update command from the server\n 324→ async fn handle_update(payload: UpdatePayload, tx: mpsc::Sender<AgentMessage>) {\n 325→ // Send starting status\n 326→ let starting_result = UpdateResultPayload {\n 327→ update_id: payload.update_id,\n 328→ status: UpdateStatus::Starting,\n 329→ old_version: env!(\"CARGO_PKG_VERSION\").to_string(),\n 330→ new_version: None,\n 331→ error: None,\n 332→ };\n 333→ let _ = tx.send(AgentMessage::UpdateResult(starting_result)).await;\n 334→\n 335→ // Spawn update in background (it will restart the service)\n 336→ tokio::spawn(async move {\n 337→ let config = UpdaterConfig::default();\n 338→ let updater = AgentUpdater::new(config);\n 339→ let result = updater.perform_update(payload).await;\n 340→\n 341→ // If we reach here, the update failed (successful update restarts the process)\n 342→ let _ = tx.send(AgentMessage::UpdateResult(result)).await;\n 343→ });\n 344→ }\n 345→\n 346→ /// Get the current architecture\n 347→ fn get_architecture() -> &'static str {\n 348→ #[cfg(target_arch = \"x86_64\")]\n 349→ { \"amd64\" }\n 350→ #[cfg(target_arch = \"aarch64\")]\n 351→ { \"arm64\" }\n 352→ #[cfg(target_arch = \"x86\")]\n 353→ { \"386\" }\n 354→ #[cfg(target_arch = \"arm\")]\n 355→ { \"arm\" }\n 356→ #[cfg(not(any(target_arch = \"x86_64\", target_arch = \"aarch64\", target_arch = \"x86\", target_arch = \"arm\")))]\n 357→ { \"unknown\" }\n 358→ }\n 359→\n 360→ /// Execute a command received from the server\n 361→ async fn execute_command(cmd: CommandPayload, tx: mpsc::Sender<AgentMessage>) {\n 362→ let command_id = cmd.id;\n 363→\n 364→ // Spawn command execution in background\n 365→ tokio::spawn(async move {\n 366→ let start = std::time::Instant::now();\n 367→\n 368→ let result = Self::run_command(&cmd).await;\n 369→ let duration_ms = start.elapsed().as_millis() as u64;\n 370→\n 371→ let (exit_code, stdout, stderr) = match result {\n 372→ Ok((code, out, err)) => (code, out, err),\n 373→ Err(e) => (-1, String::new(), format!(\"Execution error: {}\", e)),\n 374→ };\n 375→\n 376→ let result_msg = AgentMessage::CommandResult(super::CommandResultPayload {\n 377→ command_id,\n 378→ exit_code,\n 379→ stdout,\n 380→ stderr,\n 381→ duration_ms,\n 382→ });\n 383→\n 384→ if tx.send(result_msg).await.is_err() {\n 385→ error!(\"Failed to send command result\");\n 386→ }\n 387→ });\n 388→ }\n 389→\n 390→ /// Run a command and capture output\n 391→ async fn run_command(cmd: &CommandPayload) -> Result<(i32, String, String)> {\n 392→ use tokio::process::Command;\n 393→\n 394→ let timeout_secs = cmd.timeout_seconds.unwrap_or(300); // 5 minute default\n 395→\n 396→ let mut command = match &cmd.command_type {\n 397→ super::CommandType::Shell => {\n 398→ #[cfg(windows)]\n 399→ {\n 400→ let mut c = Command::new(\"cmd\");\n 401→ c.args([\"/C\", &cmd.command]);\n 402→ c\n 403→ }\n 404→ #[cfg(unix)]\n 405→ {\n 406→ let mut c = Command::new(\"sh\");\n 407→ c.args([\"-c\", &cmd.command]);\n 408→ c\n 409→ }\n 410→ }\n 411→ super::CommandType::PowerShell => {\n 412→ let mut c = Command::new(\"powershell\");\n 413→ c.args([\"-NoProfile\", \"-NonInteractive\", \"-Command\", &cmd.command]);\n 414→ c\n 415→ }\n 416→ super::CommandType::Python => {\n 417→ let mut c = Command::new(\"python\");\n 418→ c.args([\"-c\", &cmd.command]);\n 419→ c\n 420→ }\n 421→ super::CommandType::Script { interpreter } => {\n 422→ let mut c = Command::new(interpreter);\n 423→ c.args([\"-c\", &cmd.command]);\n 424→ c\n 425→ }\n 426→ };\n 427→\n 428→ // Capture output\n 429→ command.stdout(std::process::Stdio::piped());\n 430→ command.stderr(std::process::Stdio::piped());\n 431→\n 432→ // Execute with timeout\n 433→ let output = timeout(Duration::from_secs(timeout_secs), command.output())\n 434→ .await\n 435→ .context(\"Command timeout\")?\n 436→ .context(\"Failed to execute command\")?;\n 437→\n 438→ let exit_code = output.status.code().unwrap_or(-1);\n 439→ let stdout = String::from_utf8_lossy(&output.stdout).to_string();\n 440→ let stderr = String::from_utf8_lossy(&output.stderr).to_string();\n 441→\n 442→ Ok((exit_code, stdout, stderr))\n 443→ }\n 444→}\n 445→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"cee3f522-478f-4395-94f8-21a5817b29a8","timestamp":"2025-12-21T21:36:47.700Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\transport\\websocket.rs","content":"//! WebSocket client for server communication\n//!\n//! Handles the WebSocket connection lifecycle including:\n//! - Connection establishment\n//! - Authentication handshake\n//! - Message sending/receiving\n//! - Heartbeat maintenance\n//! - Command handling\n\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse anyhow::{Context, Result};\nuse futures_util::{SinkExt, StreamExt};\nuse tokio::sync::mpsc;\nuse tokio::time::{interval, timeout};\nuse tokio_tungstenite::{connect_async, tungstenite::Message};\nuse tracing::{debug, error, info, warn};\n\nuse super::{AgentMessage, AuthPayload, CommandPayload, ServerMessage, UpdatePayload, UpdateResultPayload, UpdateStatus};\nuse crate::metrics::NetworkState;\nuse crate::updater::{AgentUpdater, UpdaterConfig};\nuse crate::AppState;\n\n/// WebSocket client for communicating with the GuruRMM server\npub struct WebSocketClient;\n\nimpl WebSocketClient {\n /// Connect to the server and run the message loop\n ///\n /// This function will return when the connection is closed or an error occurs.\n /// The caller should handle reconnection logic.\n pub async fn connect_and_run(state: Arc<AppState>) -> Result<()> {\n let url = &state.config.server.url;\n\n // Connect to WebSocket server\n info!(\"Connecting to {}\", url);\n let (ws_stream, response) = connect_async(url)\n .await\n .context(\"Failed to connect to WebSocket server\")?;\n\n info!(\n \"WebSocket connected (HTTP status: {})\",\n response.status()\n );\n\n let (mut write, mut read) = ws_stream.split();\n\n // Check for pending update (from previous update attempt)\n let updater_config = UpdaterConfig::default();\n let pending_update = AgentUpdater::load_pending_update(&updater_config).await;\n\n // If we have pending update info, we just restarted after an update\n let (previous_version, pending_update_id) = if let Some(ref info) = pending_update {\n info!(\n \"Found pending update info: {} -> {} (id: {})\",\n info.old_version, info.target_version, info.update_id\n );\n (Some(info.old_version.clone()), Some(info.update_id))\n } else {\n (None, None)\n };\n\n // Send authentication message\n let auth_msg = AgentMessage::Auth(AuthPayload {\n api_key: state.config.server.api_key.clone(),\n device_id: crate::device_id::get_device_id(),\n hostname: state.config.get_hostname(),\n os_type: std::env::consts::OS.to_string(),\n os_version: sysinfo::System::os_version().unwrap_or_else(|| \"unknown\".to_string()),\n agent_version: env!(\"CARGO_PKG_VERSION\").to_string(),\n architecture: Self::get_architecture().to_string(),\n previous_version,\n pending_update_id,\n });\n\n let auth_json = serde_json::to_string(&auth_msg)?;\n write.send(Message::Text(auth_json)).await?;\n debug!(\"Sent authentication message\");\n\n // Wait for auth response with timeout\n let auth_response = timeout(Duration::from_secs(10), read.next())\n .await\n .context(\"Authentication timeout\")?\n .ok_or_else(|| anyhow::anyhow!(\"Connection closed before auth response\"))?\n .context(\"Failed to receive auth response\")?;\n\n // Parse auth response\n if let Message::Text(text) = auth_response {\n let server_msg: ServerMessage =\n serde_json::from_str(&text).context(\"Failed to parse auth response\")?;\n\n match server_msg {\n ServerMessage::AuthAck(ack) => {\n if ack.success {\n info!(\"Authentication successful, agent_id: {:?}\", ack.agent_id);\n *state.connected.write().await = true;\n\n // Send initial network state immediately after auth\n let network_state = NetworkState::collect();\n info!(\n \"Sending initial network state ({} interfaces)\",\n network_state.interfaces.len()\n );\n let network_msg = AgentMessage::NetworkState(network_state);\n let network_json = serde_json::to_string(&network_msg)?;\n write.send(Message::Text(network_json)).await?;\n } else {\n error!(\"Authentication failed: {:?}\", ack.error);\n return Err(anyhow::anyhow!(\n \"Authentication failed: {}\",\n ack.error.unwrap_or_else(|| \"Unknown error\".to_string())\n ));\n }\n }\n ServerMessage::Error { code, message } => {\n error!(\"Server error during auth: {} - {}\", code, message);\n return Err(anyhow::anyhow!(\"Server error: {} - {}\", code, message));\n }\n _ => {\n warn!(\"Unexpected message during auth: {:?}\", server_msg);\n }\n }\n }\n\n // Create channel for outgoing messages\n let (tx, mut rx) = mpsc::channel::<AgentMessage>(100);\n\n // Spawn metrics sender task\n let metrics_tx = tx.clone();\n let metrics_state = Arc::clone(&state);\n let metrics_interval = state.config.metrics.interval_seconds;\n\n let metrics_task = tokio::spawn(async move {\n let mut timer = interval(Duration::from_secs(metrics_interval));\n\n loop {\n timer.tick().await;\n\n let metrics = metrics_state.metrics_collector.collect().await;\n if metrics_tx.send(AgentMessage::Metrics(metrics)).await.is_err() {\n debug!(\"Metrics channel closed\");\n break;\n }\n }\n });\n\n // Spawn network state monitor task (checks for changes every 30 seconds)\n let network_tx = tx.clone();\n let network_task = tokio::spawn(async move {\n // Check for network changes every 30 seconds\n let mut timer = interval(Duration::from_secs(30));\n let mut last_state = NetworkState::collect();\n\n loop {\n timer.tick().await;\n\n let current_state = NetworkState::collect();\n if current_state.has_changed(&last_state) {\n info!(\n \"Network state changed (hash: {} -> {}), sending update\",\n last_state.state_hash, current_state.state_hash\n );\n\n // Log the changes for debugging\n for iface in ¤t_state.interfaces {\n debug!(\n \" Interface {}: IPv4={:?}\",\n iface.name, iface.ipv4_addresses\n );\n }\n\n if network_tx\n .send(AgentMessage::NetworkState(current_state.clone()))\n .await\n .is_err()\n {\n debug!(\"Network channel closed\");\n break;\n }\n last_state = current_state;\n }\n }\n });\n\n // Spawn heartbeat task\n let heartbeat_tx = tx.clone();\n let heartbeat_task = tokio::spawn(async move {\n let mut timer = interval(Duration::from_secs(30));\n\n loop {\n timer.tick().await;\n\n if heartbeat_tx.send(AgentMessage::Heartbeat).await.is_err() {\n debug!(\"Heartbeat channel closed\");\n break;\n }\n }\n });\n\n // Main message loop\n let result: Result<()> = loop {\n tokio::select! {\n // Handle outgoing messages\n Some(msg) = rx.recv() => {\n let json = serde_json::to_string(&msg)?;\n if let Err(e) = write.send(Message::Text(json)).await {\n break Err(e.into());\n }\n\n match &msg {\n AgentMessage::Metrics(m) => {\n debug!(\"Sent metrics: CPU={:.1}%\", m.cpu_percent);\n }\n AgentMessage::NetworkState(n) => {\n debug!(\"Sent network state: {} interfaces, hash={}\",\n n.interfaces.len(), n.state_hash);\n }\n AgentMessage::Heartbeat => {\n debug!(\"Sent heartbeat\");\n }\n _ => {\n debug!(\"Sent message: {:?}\", std::mem::discriminant(&msg));\n }\n }\n }\n\n // Handle incoming messages\n Some(msg_result) = read.next() => {\n match msg_result {\n Ok(Message::Text(text)) => {\n if let Err(e) = Self::handle_server_message(&text, &tx).await {\n error!(\"Error handling message: {}\", e);\n }\n }\n Ok(Message::Ping(data)) => {\n if let Err(e) = write.send(Message::Pong(data)).await {\n break Err(e.into());\n }\n }\n Ok(Message::Pong(_)) => {\n debug!(\"Received pong\");\n }\n Ok(Message::Close(frame)) => {\n info!(\"Server closed connection: {:?}\", frame);\n break Ok(());\n }\n Ok(Message::Binary(_)) => {\n warn!(\"Received unexpected binary message\");\n }\n Ok(Message::Frame(_)) => {\n // Raw frame, usually not seen\n }\n Err(e) => {\n error!(\"WebSocket error: {}\", e);\n break Err(e.into());\n }\n }\n }\n\n // Connection timeout (no activity)\n _ = tokio::time::sleep(Duration::from_secs(90)) => {\n warn!(\"Connection timeout, no activity for 90 seconds\");\n break Err(anyhow::anyhow!(\"Connection timeout\"));\n }\n }\n };\n\n // Cleanup\n metrics_task.abort();\n network_task.abort();\n heartbeat_task.abort();\n *state.connected.write().await = false;\n\n result\n }\n\n /// Handle a message received from the server\n async fn handle_server_message(\n text: &str,\n tx: &mpsc::Sender<AgentMessage>,\n ) -> Result<()> {\n let msg: ServerMessage =\n serde_json::from_str(text).context(\"Failed to parse server message\")?;\n\n match msg {\n ServerMessage::Command(cmd) => {\n info!(\"Received command: {:?} (id: {})\", cmd.command_type, cmd.id);\n Self::execute_command(cmd, tx.clone()).await;\n }\n ServerMessage::ConfigUpdate(update) => {\n info!(\"Received config update: {:?}\", update);\n // Config updates will be handled in a future phase\n }\n ServerMessage::Ack { message_id } => {\n debug!(\"Received ack for message: {:?}\", message_id);\n }\n ServerMessage::AuthAck(_) => {\n // Already handled during initial auth\n }\n ServerMessage::Error { code, message } => {\n error!(\"Server error: {} - {}\", code, message);\n }\n ServerMessage::Update(payload) => {\n info!(\n \"Received update command: {} -> {} (id: {})\",\n env!(\"CARGO_PKG_VERSION\"),\n payload.target_version,\n payload.update_id\n );\n Self::handle_update(payload, tx.clone()).await;\n }\n ServerMessage::TrayPolicyUpdate(policy) => {\n info!(\"Received tray policy update: enabled={}\", policy.enabled);\n // Tray policy updates are handled via IPC broadcast\n // The IPC server will pick this up and notify connected tray apps\n }\n }\n\n Ok(())\n }\n\n /// Handle an update command from the server\n async fn handle_update(payload: UpdatePayload, tx: mpsc::Sender<AgentMessage>) {\n // Send starting status\n let starting_result = UpdateResultPayload {\n update_id: payload.update_id,\n status: UpdateStatus::Starting,\n old_version: env!(\"CARGO_PKG_VERSION\").to_string(),\n new_version: None,\n error: None,\n };\n let _ = tx.send(AgentMessage::UpdateResult(starting_result)).await;\n\n // Spawn update in background (it will restart the service)\n tokio::spawn(async move {\n let config = UpdaterConfig::default();\n let updater = AgentUpdater::new(config);\n let result = updater.perform_update(payload).await;\n\n // If we reach here, the update failed (successful update restarts the process)\n let _ = tx.send(AgentMessage::UpdateResult(result)).await;\n });\n }\n\n /// Get the current architecture\n fn get_architecture() -> &'static str {\n #[cfg(target_arch = \"x86_64\")]\n { \"amd64\" }\n #[cfg(target_arch = \"aarch64\")]\n { \"arm64\" }\n #[cfg(target_arch = \"x86\")]\n { \"386\" }\n #[cfg(target_arch = \"arm\")]\n { \"arm\" }\n #[cfg(not(any(target_arch = \"x86_64\", target_arch = \"aarch64\", target_arch = \"x86\", target_arch = \"arm\")))]\n { \"unknown\" }\n }\n\n /// Execute a command received from the server\n async fn execute_command(cmd: CommandPayload, tx: mpsc::Sender<AgentMessage>) {\n let command_id = cmd.id;\n\n // Spawn command execution in background\n tokio::spawn(async move {\n let start = std::time::Instant::now();\n\n let result = Self::run_command(&cmd).await;\n let duration_ms = start.elapsed().as_millis() as u64;\n\n let (exit_code, stdout, stderr) = match result {\n Ok((code, out, err)) => (code, out, err),\n Err(e) => (-1, String::new(), format!(\"Execution error: {}\", e)),\n };\n\n let result_msg = AgentMessage::CommandResult(super::CommandResultPayload {\n command_id,\n exit_code,\n stdout,\n stderr,\n duration_ms,\n });\n\n if tx.send(result_msg).await.is_err() {\n error!(\"Failed to send command result\");\n }\n });\n }\n\n /// Run a command and capture output\n async fn run_command(cmd: &CommandPayload) -> Result<(i32, String, String)> {\n use tokio::process::Command;\n\n let timeout_secs = cmd.timeout_seconds.unwrap_or(300); // 5 minute default\n\n let mut command = match &cmd.command_type {\n super::CommandType::Shell => {\n #[cfg(windows)]\n {\n let mut c = Command::new(\"cmd\");\n c.args([\"/C\", &cmd.command]);\n c\n }\n #[cfg(unix)]\n {\n let mut c = Command::new(\"sh\");\n c.args([\"-c\", &cmd.command]);\n c\n }\n }\n super::CommandType::PowerShell => {\n let mut c = Command::new(\"powershell\");\n c.args([\"-NoProfile\", \"-NonInteractive\", \"-Command\", &cmd.command]);\n c\n }\n super::CommandType::Python => {\n let mut c = Command::new(\"python\");\n c.args([\"-c\", &cmd.command]);\n c\n }\n super::CommandType::Script { interpreter } => {\n let mut c = Command::new(interpreter);\n c.args([\"-c\", &cmd.command]);\n c\n }\n };\n\n // Capture output\n command.stdout(std::process::Stdio::piped());\n command.stderr(std::process::Stdio::piped());\n\n // Execute with timeout\n let output = timeout(Duration::from_secs(timeout_secs), command.output())\n .await\n .context(\"Command timeout\")?\n .context(\"Failed to execute command\")?;\n\n let exit_code = output.status.code().unwrap_or(-1);\n let stdout = String::from_utf8_lossy(&output.stdout).to_string();\n let stderr = String::from_utf8_lossy(&output.stderr).to_string();\n\n Ok((exit_code, stdout, stderr))\n }\n}\n","numLines":445,"startLine":1,"totalLines":445}}}
|
||
{"parentUuid":"cee3f522-478f-4395-94f8-21a5817b29a8","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XWFe6DYS7ZKYFzkD4TqKBZ","type":"message","role":"assistant","content":[{"type":"text","text":"Now I have a comprehensive understanding of the codebase. Let me examine one more file to understand the agent's main AppState structure:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":8083,"cache_read_input_tokens":59109,"cache_creation":{"ephemeral_5m_input_tokens":8083,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWLZ3mRCjCpXZaP7U7NZm","type":"assistant","uuid":"759e1caa-7623-43ae-a57c-816cb626de41","timestamp":"2025-12-21T21:36:53.881Z"}
|
||
{"parentUuid":"759e1caa-7623-43ae-a57c-816cb626de41","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XWFe6DYS7ZKYFzkD4TqKBZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Jbqqgpv7EqKBhE6NCi7pjz","name":"Read","input":{"file_path":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\main.rs"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":8083,"cache_read_input_tokens":59109,"cache_creation":{"ephemeral_5m_input_tokens":8083,"ephemeral_1h_input_tokens":0},"output_tokens":108,"service_tier":"standard"}},"requestId":"req_011CWLZ3mRCjCpXZaP7U7NZm","type":"assistant","uuid":"331f86bc-bc53-4863-8eba-00ee14ba4513","timestamp":"2025-12-21T21:36:54.774Z"}
|
||
{"parentUuid":"331f86bc-bc53-4863-8eba-00ee14ba4513","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Jbqqgpv7EqKBhE6NCi7pjz","type":"tool_result","content":" 1→//! GuruRMM Agent - Cross-platform Remote Monitoring and Management Agent\n 2→//!\n 3→//! This agent connects to the GuruRMM server, reports system metrics,\n 4→//! monitors services (watchdog), and executes remote commands.\n 5→\n 6→mod config;\n 7→mod device_id;\n 8→mod ipc;\n 9→mod metrics;\n 10→mod service;\n 11→mod transport;\n 12→mod tray_launcher;\n 13→mod updater;\n 14→\n 15→use anyhow::{Context, Result};\n 16→use clap::{Parser, Subcommand};\n 17→use std::path::PathBuf;\n 18→use std::sync::Arc;\n 19→use tokio::sync::RwLock;\n 20→use tracing::{error, info, warn};\n 21→\n 22→use crate::config::AgentConfig;\n 23→use crate::metrics::MetricsCollector;\n 24→use crate::transport::WebSocketClient;\n 25→\n 26→/// GuruRMM Agent - Remote Monitoring and Management\n 27→#[derive(Parser)]\n 28→#[command(name = \"gururmm-agent\")]\n 29→#[command(author, version, about, long_about = None)]\n 30→struct Cli {\n 31→ /// Path to configuration file\n 32→ #[arg(short, long, default_value = \"agent.toml\")]\n 33→ config: PathBuf,\n 34→\n 35→ /// Subcommand to run\n 36→ #[command(subcommand)]\n 37→ command: Option<Commands>,\n 38→}\n 39→\n 40→#[derive(Subcommand)]\n 41→enum Commands {\n 42→ /// Run the agent (default)\n 43→ Run,\n 44→\n 45→ /// Install as a system service\n 46→ Install {\n 47→ /// Server WebSocket URL (e.g., wss://rmm-api.example.com/ws)\n 48→ #[arg(long)]\n 49→ server_url: Option<String>,\n 50→\n 51→ /// API key for authentication\n 52→ #[arg(long)]\n 53→ api_key: Option<String>,\n 54→\n 55→ /// Skip legacy service detection and cleanup\n 56→ #[arg(long, default_value = \"false\")]\n 57→ skip_legacy_check: bool,\n 58→ },\n 59→\n 60→ /// Uninstall the system service\n 61→ Uninstall,\n 62→\n 63→ /// Start the installed service\n 64→ Start,\n 65→\n 66→ /// Stop the installed service\n 67→ Stop,\n 68→\n 69→ /// Show agent status\n 70→ Status,\n 71→\n 72→ /// Generate a sample configuration file\n 73→ GenerateConfig {\n 74→ /// Output path for config file\n 75→ #[arg(short, long, default_value = \"agent.toml\")]\n 76→ output: PathBuf,\n 77→ },\n 78→\n 79→ /// Run as Windows service (called by SCM, not for manual use)\n 80→ #[command(hide = true)]\n 81→ Service,\n 82→}\n 83→\n 84→/// Shared application state\n 85→pub struct AppState {\n 86→ pub config: AgentConfig,\n 87→ pub metrics_collector: MetricsCollector,\n 88→ pub connected: RwLock<bool>,\n 89→ pub last_checkin: RwLock<Option<chrono::DateTime<chrono::Utc>>>,\n 90→ pub tray_policy: RwLock<ipc::TrayPolicy>,\n 91→}\n 92→\n 93→#[tokio::main]\n 94→async fn main() -> Result<()> {\n 95→ // Initialize logging\n 96→ tracing_subscriber::fmt()\n 97→ .with_env_filter(\n 98→ tracing_subscriber::EnvFilter::from_default_env()\n 99→ .add_directive(\"gururmm_agent=info\".parse()?)\n 100→ .add_directive(\"info\".parse()?),\n 101→ )\n 102→ .init();\n 103→\n 104→ let cli = Cli::parse();\n 105→\n 106→ match cli.command.unwrap_or(Commands::Run) {\n 107→ Commands::Run => run_agent(cli.config).await,\n 108→ Commands::Install { server_url, api_key, skip_legacy_check } => {\n 109→ install_service(server_url, api_key, skip_legacy_check).await\n 110→ }\n 111→ Commands::Uninstall => uninstall_service().await,\n 112→ Commands::Start => start_service().await,\n 113→ Commands::Stop => stop_service().await,\n 114→ Commands::Status => show_status(cli.config).await,\n 115→ Commands::GenerateConfig { output } => generate_config(output).await,\n 116→ Commands::Service => run_as_windows_service(),\n 117→ }\n 118→}\n 119→\n 120→/// Run as a Windows service (called by SCM)\n 121→fn run_as_windows_service() -> Result<()> {\n 122→ #[cfg(windows)]\n 123→ {\n 124→ service::windows::run_as_service()\n 125→ }\n 126→\n 127→ #[cfg(not(windows))]\n 128→ {\n 129→ anyhow::bail!(\"Windows service mode is only available on Windows\");\n 130→ }\n 131→}\n 132→\n 133→/// Main agent runtime loop\n 134→async fn run_agent(config_path: PathBuf) -> Result<()> {\n 135→ info!(\"GuruRMM Agent starting...\");\n 136→\n 137→ // Load configuration\n 138→ let config = AgentConfig::load(&config_path)?;\n 139→ info!(\"Loaded configuration from {:?}\", config_path);\n 140→ info!(\"Server URL: {}\", config.server.url);\n 141→\n 142→ // Initialize metrics collector\n 143→ let metrics_collector = MetricsCollector::new();\n 144→ info!(\"Metrics collector initialized\");\n 145→\n 146→ // Create channels for IPC commands\n 147→ let (force_checkin_tx, mut force_checkin_rx) = tokio::sync::mpsc::channel::<()>(8);\n 148→ let (stop_agent_tx, mut stop_agent_rx) = tokio::sync::mpsc::channel::<()>(1);\n 149→\n 150→ // Create shared state\n 151→ let state = Arc::new(AppState {\n 152→ config: config.clone(),\n 153→ metrics_collector,\n 154→ connected: RwLock::new(false),\n 155→ last_checkin: RwLock::new(None),\n 156→ tray_policy: RwLock::new(ipc::TrayPolicy::default_permissive()),\n 157→ });\n 158→\n 159→ // Create IPC state for tray communication\n 160→ let device_id = device_id::get_device_id();\n 161→ let hostname = hostname::get()\n 162→ .map(|h| h.to_string_lossy().to_string())\n 163→ .unwrap_or_else(|_| \"unknown\".to_string());\n 164→\n 165→ let initial_status = ipc::AgentStatus {\n 166→ connected: false,\n 167→ last_checkin: None,\n 168→ server_url: config.server.url.clone(),\n 169→ agent_version: env!(\"CARGO_PKG_VERSION\").to_string(),\n 170→ device_id,\n 171→ hostname,\n 172→ error: None,\n 173→ };\n 174→\n 175→ let ipc_state = Arc::new(ipc::IpcState::new(\n 176→ initial_status,\n 177→ ipc::TrayPolicy::default_permissive(),\n 178→ force_checkin_tx,\n 179→ stop_agent_tx,\n 180→ ));\n 181→\n 182→ // Start IPC server for tray application\n 183→ let ipc_server_state = Arc::clone(&ipc_state);\n 184→ let ipc_handle = tokio::spawn(async move {\n 185→ if let Err(e) = ipc::server::run_ipc_server(ipc_server_state).await {\n 186→ error!(\"IPC server error: {}\", e);\n 187→ }\n 188→ });\n 189→ info!(\"IPC server started\");\n 190→\n 191→ // Start tray launcher (Windows only for now)\n 192→ #[cfg(windows)]\n 193→ let tray_launcher = {\n 194→ let tray_exe_path = std::path::PathBuf::from(\n 195→ format!(\"{}\\\\gururmm-tray.exe\", service::windows::INSTALL_DIR)\n 196→ );\n 197→ let launcher = Arc::new(tray_launcher::TrayLauncher::new(tray_exe_path));\n 198→\n 199→ // Launch tray in existing sessions\n 200→ let launcher_init = Arc::clone(&launcher);\n 201→ tokio::spawn(async move {\n 202→ // Small delay to let IPC server fully start\n 203→ tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;\n 204→ launcher_init.ensure_running().await;\n 205→ });\n 206→\n 207→ // Periodic check for new sessions (every 30 seconds)\n 208→ let launcher_periodic = Arc::clone(&launcher);\n 209→ tokio::spawn(async move {\n 210→ let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(30));\n 211→ loop {\n 212→ interval.tick().await;\n 213→ launcher_periodic.ensure_running().await;\n 214→ }\n 215→ });\n 216→\n 217→ info!(\"Tray launcher initialized\");\n 218→ Some(launcher)\n 219→ };\n 220→\n 221→ #[cfg(not(windows))]\n 222→ let tray_launcher: Option<Arc<tray_launcher::TrayLauncher>> = None;\n 223→\n 224→ // Start the WebSocket client with auto-reconnect\n 225→ let ws_state = Arc::clone(&state);\n 226→ let ws_handle = tokio::spawn(async move {\n 227→ loop {\n 228→ info!(\"Connecting to server...\");\n 229→ match WebSocketClient::connect_and_run(Arc::clone(&ws_state)).await {\n 230→ Ok(_) => {\n 231→ warn!(\"WebSocket connection closed normally, reconnecting...\");\n 232→ }\n 233→ Err(e) => {\n 234→ error!(\"WebSocket error: {}, reconnecting in 10 seconds...\", e);\n 235→ }\n 236→ }\n 237→\n 238→ // Mark as disconnected\n 239→ *ws_state.connected.write().await = false;\n 240→\n 241→ // Wait before reconnecting\n 242→ tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;\n 243→ }\n 244→ });\n 245→\n 246→ // Start metrics collection loop\n 247→ let metrics_state = Arc::clone(&state);\n 248→ let metrics_handle = tokio::spawn(async move {\n 249→ let interval = metrics_state.config.metrics.interval_seconds;\n 250→ let mut interval_timer = tokio::time::interval(tokio::time::Duration::from_secs(interval));\n 251→\n 252→ loop {\n 253→ interval_timer.tick().await;\n 254→\n 255→ // Collect metrics (they'll be sent via WebSocket if connected)\n 256→ let metrics = metrics_state.metrics_collector.collect().await;\n 257→ if *metrics_state.connected.read().await {\n 258→ info!(\n 259→ \"Metrics: CPU={:.1}%, Mem={:.1}%, Disk={:.1}%\",\n 260→ metrics.cpu_percent, metrics.memory_percent, metrics.disk_percent\n 261→ );\n 262→ }\n 263→ }\n 264→ });\n 265→\n 266→ // Task to update IPC status periodically\n 267→ let ipc_update_state = Arc::clone(&state);\n 268→ let ipc_update_ipc = Arc::clone(&ipc_state);\n 269→ let ipc_update_handle = tokio::spawn(async move {\n 270→ let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));\n 271→ loop {\n 272→ interval.tick().await;\n 273→\n 274→ let connected = *ipc_update_state.connected.read().await;\n 275→ let last_checkin = ipc_update_state.last_checkin.read().await.map(|dt| dt.to_rfc3339());\n 276→\n 277→ let status = ipc::AgentStatus {\n 278→ connected,\n 279→ last_checkin,\n 280→ server_url: ipc_update_state.config.server.url.clone(),\n 281→ agent_version: env!(\"CARGO_PKG_VERSION\").to_string(),\n 282→ device_id: device_id::get_device_id(),\n 283→ hostname: hostname::get()\n 284→ .map(|h| h.to_string_lossy().to_string())\n 285→ .unwrap_or_else(|_| \"unknown\".to_string()),\n 286→ error: if connected { None } else { Some(\"Disconnected\".to_string()) },\n 287→ };\n 288→\n 289→ ipc_update_ipc.update_status(status).await;\n 290→ }\n 291→ });\n 292→\n 293→ // Wait for shutdown signal or stop request\n 294→ tokio::select! {\n 295→ _ = tokio::signal::ctrl_c() => {\n 296→ info!(\"Received shutdown signal\");\n 297→ }\n 298→ _ = stop_agent_rx.recv() => {\n 299→ info!(\"Received stop request from tray\");\n 300→ }\n 301→ _ = ws_handle => {\n 302→ error!(\"WebSocket task ended unexpectedly\");\n 303→ }\n 304→ _ = metrics_handle => {\n 305→ error!(\"Metrics task ended unexpectedly\");\n 306→ }\n 307→ _ = ipc_handle => {\n 308→ error!(\"IPC server ended unexpectedly\");\n 309→ }\n 310→ _ = ipc_update_handle => {\n 311→ error!(\"IPC update task ended unexpectedly\");\n 312→ }\n 313→ }\n 314→\n 315→ // Terminate tray processes on shutdown\n 316→ #[cfg(windows)]\n 317→ if let Some(launcher) = tray_launcher {\n 318→ info!(\"Terminating tray processes...\");\n 319→ launcher.terminate_all().await;\n 320→ }\n 321→\n 322→ #[cfg(not(windows))]\n 323→ let _ = tray_launcher; // Suppress unused warning\n 324→\n 325→ info!(\"GuruRMM Agent shutting down\");\n 326→ Ok(())\n 327→}\n 328→\n 329→/// Install the agent as a system service\n 330→async fn install_service(\n 331→ server_url: Option<String>,\n 332→ api_key: Option<String>,\n 333→ skip_legacy_check: bool,\n 334→) -> Result<()> {\n 335→ #[cfg(windows)]\n 336→ {\n 337→ service::windows::install(server_url, api_key, skip_legacy_check)\n 338→ }\n 339→\n 340→ #[cfg(target_os = \"linux\")]\n 341→ {\n 342→ install_systemd_service(server_url, api_key, skip_legacy_check).await\n 343→ }\n 344→\n 345→ #[cfg(target_os = \"macos\")]\n 346→ {\n 347→ let _ = (server_url, api_key, skip_legacy_check); // Suppress unused warnings\n 348→ info!(\"Installing GuruRMM Agent as launchd service...\");\n 349→ todo!(\"macOS launchd service installation not yet implemented\");\n 350→ }\n 351→}\n 352→\n 353→/// Legacy service names to check for and clean up (Linux)\n 354→#[cfg(target_os = \"linux\")]\n 355→const LINUX_LEGACY_SERVICE_NAMES: &[&str] = &[\n 356→ \"gururmm\", // Old name without -agent suffix\n 357→ \"guru-rmm-agent\", // Alternative naming\n 358→ \"GuruRMM-Agent\", // Case variant\n 359→];\n 360→\n 361→/// Clean up legacy Linux service installations\n 362→#[cfg(target_os = \"linux\")]\n 363→fn cleanup_legacy_linux_services() -> Result<()> {\n 364→ use std::process::Command;\n 365→\n 366→ info!(\"Checking for legacy service installations...\");\n 367→\n 368→ for legacy_name in LINUX_LEGACY_SERVICE_NAMES {\n 369→ // Check if service exists\n 370→ let status = Command::new(\"systemctl\")\n 371→ .args([\"status\", legacy_name])\n 372→ .output();\n 373→\n 374→ if let Ok(output) = status {\n 375→ if output.status.success() || String::from_utf8_lossy(&output.stderr).contains(\"Loaded:\") {\n 376→ info!(\"Found legacy service '{}', removing...\", legacy_name);\n 377→\n 378→ // Stop the service\n 379→ let _ = Command::new(\"systemctl\")\n 380→ .args([\"stop\", legacy_name])\n 381→ .status();\n 382→\n 383→ // Disable the service\n 384→ let _ = Command::new(\"systemctl\")\n 385→ .args([\"disable\", legacy_name])\n 386→ .status();\n 387→\n 388→ // Remove unit file\n 389→ let unit_file = format!(\"/etc/systemd/system/{}.service\", legacy_name);\n 390→ if std::path::Path::new(&unit_file).exists() {\n 391→ info!(\"Removing legacy unit file: {}\", unit_file);\n 392→ let _ = std::fs::remove_file(&unit_file);\n 393→ }\n 394→ }\n 395→ }\n 396→ }\n 397→\n 398→ // Check for legacy binaries in common locations\n 399→ let legacy_binary_locations = [\n 400→ \"/usr/local/bin/gururmm\",\n 401→ \"/usr/bin/gururmm\",\n 402→ \"/opt/gururmm/gururmm\",\n 403→ \"/opt/gururmm/agent\",\n 404→ ];\n 405→\n 406→ for legacy_path in legacy_binary_locations {\n 407→ if std::path::Path::new(legacy_path).exists() {\n 408→ info!(\"Found legacy binary at '{}', removing...\", legacy_path);\n 409→ let _ = std::fs::remove_file(legacy_path);\n 410→ }\n 411→ }\n 412→\n 413→ // Reload systemd to pick up removed unit files\n 414→ let _ = Command::new(\"systemctl\")\n 415→ .args([\"daemon-reload\"])\n 416→ .status();\n 417→\n 418→ Ok(())\n 419→}\n 420→\n 421→/// Install as a systemd service (Linux)\n 422→#[cfg(target_os = \"linux\")]\n 423→async fn install_systemd_service(\n 424→ server_url: Option<String>,\n 425→ api_key: Option<String>,\n 426→ skip_legacy_check: bool,\n 427→) -> Result<()> {\n 428→ use std::process::Command;\n 429→\n 430→ const SERVICE_NAME: &str = \"gururmm-agent\";\n 431→ const INSTALL_DIR: &str = \"/usr/local/bin\";\n 432→ const CONFIG_DIR: &str = \"/etc/gururmm\";\n 433→ const SYSTEMD_DIR: &str = \"/etc/systemd/system\";\n 434→\n 435→ info!(\"Installing GuruRMM Agent as systemd service...\");\n 436→\n 437→ // Check if running as root\n 438→ if !nix::unistd::geteuid().is_root() {\n 439→ anyhow::bail!(\"Installation requires root privileges. Please run with sudo.\");\n 440→ }\n 441→\n 442→ // Clean up legacy installations unless skipped\n 443→ if !skip_legacy_check {\n 444→ if let Err(e) = cleanup_legacy_linux_services() {\n 445→ warn!(\"Legacy cleanup warning: {}\", e);\n 446→ }\n 447→ }\n 448→\n 449→ // Get the current executable path\n 450→ let current_exe = std::env::current_exe()\n 451→ .context(\"Failed to get current executable path\")?;\n 452→\n 453→ let binary_dest = format!(\"{}/{}\", INSTALL_DIR, SERVICE_NAME);\n 454→ let config_dest = format!(\"{}/agent.toml\", CONFIG_DIR);\n 455→ let unit_file = format!(\"{}/{}.service\", SYSTEMD_DIR, SERVICE_NAME);\n 456→\n 457→ // Create config directory\n 458→ info!(\"Creating config directory: {}\", CONFIG_DIR);\n 459→ std::fs::create_dir_all(CONFIG_DIR)\n 460→ .context(\"Failed to create config directory\")?;\n 461→\n 462→ // Copy binary\n 463→ info!(\"Copying binary to: {}\", binary_dest);\n 464→ std::fs::copy(¤t_exe, &binary_dest)\n 465→ .context(\"Failed to copy binary\")?;\n 466→\n 467→ // Make binary executable\n 468→ Command::new(\"chmod\")\n 469→ .args([\"+x\", &binary_dest])\n 470→ .status()\n 471→ .context(\"Failed to set binary permissions\")?;\n 472→\n 473→ // Handle configuration\n 474→ let config_needs_manual_edit;\n 475→ if !std::path::Path::new(&config_dest).exists() {\n 476→ info!(\"Creating config: {}\", config_dest);\n 477→\n 478→ // Start with sample config\n 479→ let mut config = crate::config::AgentConfig::sample();\n 480→\n 481→ // Apply provided values\n 482→ if let Some(url) = &server_url {\n 483→ config.server.url = url.clone();\n 484→ }\n 485→ if let Some(key) = &api_key {\n 486→ config.server.api_key = key.clone();\n 487→ }\n 488→\n 489→ let toml_str = toml::to_string_pretty(&config)?;\n 490→ std::fs::write(&config_dest, toml_str)\n 491→ .context(\"Failed to write config file\")?;\n 492→\n 493→ // Set restrictive permissions on config (contains API key)\n 494→ Command::new(\"chmod\")\n 495→ .args([\"600\", &config_dest])\n 496→ .status()\n 497→ .context(\"Failed to set config permissions\")?;\n 498→\n 499→ config_needs_manual_edit = server_url.is_none() || api_key.is_none();\n 500→ } else {\n 501→ info!(\"Config already exists: {}\", config_dest);\n 502→ config_needs_manual_edit = false;\n 503→\n 504→ // If server_url or api_key provided, update existing config\n 505→ if server_url.is_some() || api_key.is_some() {\n 506→ info!(\"Updating existing configuration...\");\n 507→ let config_content = std::fs::read_to_string(&config_dest)?;\n 508→ let mut config: crate::config::AgentConfig = toml::from_str(&config_content)\n 509→ .context(\"Failed to parse existing config\")?;\n 510→\n 511→ if let Some(url) = &server_url {\n 512→ config.server.url = url.clone();\n 513→ }\n 514→ if let Some(key) = &api_key {\n 515→ config.server.api_key = key.clone();\n 516→ }\n 517→\n 518→ let toml_str = toml::to_string_pretty(&config)?;\n 519→ std::fs::write(&config_dest, toml_str)\n 520→ .context(\"Failed to update config file\")?;\n 521→ }\n 522→ }\n 523→\n 524→ // Create systemd unit file\n 525→ let unit_content = format!(r#\"[Unit]\n 526→Description=GuruRMM Agent - Remote Monitoring and Management\n 527→Documentation=https://github.com/azcomputerguru/gururmm\n 528→After=network-online.target\n 529→Wants=network-online.target\n 530→\n 531→[Service]\n 532→Type=simple\n 533→ExecStart={binary} --config {config} run\n 534→Restart=always\n 535→RestartSec=10\n 536→StandardOutput=journal\n 537→StandardError=journal\n 538→SyslogIdentifier={service}\n 539→\n 540→# Security hardening\n 541→NoNewPrivileges=true\n 542→ProtectSystem=strict\n 543→ProtectHome=read-only\n 544→PrivateTmp=true\n 545→ReadWritePaths=/var/log\n 546→\n 547→[Install]\n 548→WantedBy=multi-user.target\n 549→\"#,\n 550→ binary = binary_dest,\n 551→ config = config_dest,\n 552→ service = SERVICE_NAME\n 553→ );\n 554→\n 555→ info!(\"Creating systemd unit file: {}\", unit_file);\n 556→ std::fs::write(&unit_file, unit_content)\n 557→ .context(\"Failed to write systemd unit file\")?;\n 558→\n 559→ // Reload systemd daemon\n 560→ info!(\"Reloading systemd daemon...\");\n 561→ let status = Command::new(\"systemctl\")\n 562→ .args([\"daemon-reload\"])\n 563→ .status()\n 564→ .context(\"Failed to reload systemd\")?;\n 565→\n 566→ if !status.success() {\n 567→ anyhow::bail!(\"systemctl daemon-reload failed\");\n 568→ }\n 569→\n 570→ // Enable the service\n 571→ info!(\"Enabling service...\");\n 572→ let status = Command::new(\"systemctl\")\n 573→ .args([\"enable\", SERVICE_NAME])\n 574→ .status()\n 575→ .context(\"Failed to enable service\")?;\n 576→\n 577→ if !status.success() {\n 578→ anyhow::bail!(\"systemctl enable failed\");\n 579→ }\n 580→\n 581→ println!(\"\\n✓ GuruRMM Agent installed successfully!\");\n 582→ println!(\"\\nInstalled files:\");\n 583→ println!(\" Binary: {}\", binary_dest);\n 584→ println!(\" Config: {}\", config_dest);\n 585→ println!(\" Service: {}\", unit_file);\n 586→\n 587→ if config_needs_manual_edit {\n 588→ println!(\"\\n⚠️ IMPORTANT: Edit {} with your server URL and API key!\", config_dest);\n 589→ println!(\"\\nNext steps:\");\n 590→ println!(\" 1. Edit {} with your server URL and API key\", config_dest);\n 591→ println!(\" 2. Start the service: sudo systemctl start {}\", SERVICE_NAME);\n 592→ } else {\n 593→ println!(\"\\nStarting service...\");\n 594→ let status = Command::new(\"systemctl\")\n 595→ .args([\"start\", SERVICE_NAME])\n 596→ .status();\n 597→\n 598→ if status.is_ok() && status.unwrap().success() {\n 599→ println!(\"✓ Service started successfully!\");\n 600→ } else {\n 601→ println!(\"⚠️ Failed to start service. Check logs: sudo journalctl -u {} -f\", SERVICE_NAME);\n 602→ }\n 603→ }\n 604→\n 605→ println!(\"\\nUseful commands:\");\n 606→ println!(\" Status: sudo systemctl status {}\", SERVICE_NAME);\n 607→ println!(\" Logs: sudo journalctl -u {} -f\", SERVICE_NAME);\n 608→ println!(\" Stop: sudo systemctl stop {}\", SERVICE_NAME);\n 609→ println!(\" Start: sudo systemctl start {}\", SERVICE_NAME);\n 610→\n 611→ Ok(())\n 612→}\n 613→\n 614→/// Uninstall the system service\n 615→async fn uninstall_service() -> Result<()> {\n 616→ #[cfg(windows)]\n 617→ {\n 618→ service::windows::uninstall()\n 619→ }\n 620→\n 621→ #[cfg(target_os = \"linux\")]\n 622→ {\n 623→ uninstall_systemd_service().await\n 624→ }\n 625→\n 626→ #[cfg(target_os = \"macos\")]\n 627→ {\n 628→ todo!(\"macOS service uninstallation not yet implemented\");\n 629→ }\n 630→}\n 631→\n 632→/// Uninstall systemd service (Linux)\n 633→#[cfg(target_os = \"linux\")]\n 634→async fn uninstall_systemd_service() -> Result<()> {\n 635→ use std::process::Command;\n 636→\n 637→ const SERVICE_NAME: &str = \"gururmm-agent\";\n 638→ const INSTALL_DIR: &str = \"/usr/local/bin\";\n 639→ const CONFIG_DIR: &str = \"/etc/gururmm\";\n 640→ const SYSTEMD_DIR: &str = \"/etc/systemd/system\";\n 641→\n 642→ info!(\"Uninstalling GuruRMM Agent...\");\n 643→\n 644→ if !nix::unistd::geteuid().is_root() {\n 645→ anyhow::bail!(\"Uninstallation requires root privileges. Please run with sudo.\");\n 646→ }\n 647→\n 648→ let binary_path = format!(\"{}/{}\", INSTALL_DIR, SERVICE_NAME);\n 649→ let unit_file = format!(\"{}/{}.service\", SYSTEMD_DIR, SERVICE_NAME);\n 650→\n 651→ // Stop the service if running\n 652→ info!(\"Stopping service...\");\n 653→ let _ = Command::new(\"systemctl\")\n 654→ .args([\"stop\", SERVICE_NAME])\n 655→ .status();\n 656→\n 657→ // Disable the service\n 658→ info!(\"Disabling service...\");\n 659→ let _ = Command::new(\"systemctl\")\n 660→ .args([\"disable\", SERVICE_NAME])\n 661→ .status();\n 662→\n 663→ // Remove unit file\n 664→ if std::path::Path::new(&unit_file).exists() {\n 665→ info!(\"Removing unit file: {}\", unit_file);\n 666→ std::fs::remove_file(&unit_file)?;\n 667→ }\n 668→\n 669→ // Remove binary\n 670→ if std::path::Path::new(&binary_path).exists() {\n 671→ info!(\"Removing binary: {}\", binary_path);\n 672→ std::fs::remove_file(&binary_path)?;\n 673→ }\n 674→\n 675→ // Reload systemd\n 676→ let _ = Command::new(\"systemctl\")\n 677→ .args([\"daemon-reload\"])\n 678→ .status();\n 679→\n 680→ println!(\"\\n✓ GuruRMM Agent uninstalled successfully!\");\n 681→ println!(\"\\nNote: Config directory {} was preserved.\", CONFIG_DIR);\n 682→ println!(\"Remove it manually if no longer needed: sudo rm -rf {}\", CONFIG_DIR);\n 683→\n 684→ Ok(())\n 685→}\n 686→\n 687→/// Start the installed service\n 688→async fn start_service() -> Result<()> {\n 689→ #[cfg(windows)]\n 690→ {\n 691→ service::windows::start()\n 692→ }\n 693→\n 694→ #[cfg(target_os = \"linux\")]\n 695→ {\n 696→ use std::process::Command;\n 697→\n 698→ info!(\"Starting GuruRMM Agent service...\");\n 699→\n 700→ let status = Command::new(\"systemctl\")\n 701→ .args([\"start\", \"gururmm-agent\"])\n 702→ .status()\n 703→ .context(\"Failed to start service\")?;\n 704→\n 705→ if status.success() {\n 706→ println!(\"** Service started successfully\");\n 707→ println!(\"Check status: sudo systemctl status gururmm-agent\");\n 708→ } else {\n 709→ anyhow::bail!(\"Failed to start service. Check: sudo journalctl -u gururmm-agent -n 50\");\n 710→ }\n 711→\n 712→ Ok(())\n 713→ }\n 714→\n 715→ #[cfg(target_os = \"macos\")]\n 716→ {\n 717→ todo!(\"macOS service start not yet implemented\");\n 718→ }\n 719→}\n 720→\n 721→/// Stop the installed service\n 722→async fn stop_service() -> Result<()> {\n 723→ #[cfg(windows)]\n 724→ {\n 725→ service::windows::stop()\n 726→ }\n 727→\n 728→ #[cfg(target_os = \"linux\")]\n 729→ {\n 730→ use std::process::Command;\n 731→\n 732→ info!(\"Stopping GuruRMM Agent service...\");\n 733→\n 734→ let status = Command::new(\"systemctl\")\n 735→ .args([\"stop\", \"gururmm-agent\"])\n 736→ .status()\n 737→ .context(\"Failed to stop service\")?;\n 738→\n 739→ if status.success() {\n 740→ println!(\"** Service stopped successfully\");\n 741→ } else {\n 742→ anyhow::bail!(\"Failed to stop service\");\n 743→ }\n 744→\n 745→ Ok(())\n 746→ }\n 747→\n 748→ #[cfg(target_os = \"macos\")]\n 749→ {\n 750→ todo!(\"macOS service stop not yet implemented\");\n 751→ }\n 752→}\n 753→\n 754→/// Show agent status\n 755→async fn show_status(config_path: PathBuf) -> Result<()> {\n 756→ // On Windows, show service status\n 757→ #[cfg(windows)]\n 758→ {\n 759→ service::windows::status()?;\n 760→ println!();\n 761→ }\n 762→\n 763→ // Try to load config for additional info\n 764→ match AgentConfig::load(&config_path) {\n 765→ Ok(config) => {\n 766→ println!(\"Configuration\");\n 767→ println!(\"=============\");\n 768→ println!(\"Config file: {:?}\", config_path);\n 769→ println!(\"Server URL: {}\", config.server.url);\n 770→ println!(\"Metrics interval: {} seconds\", config.metrics.interval_seconds);\n 771→ println!(\"Watchdog enabled: {}\", config.watchdog.enabled);\n 772→\n 773→ // Collect current metrics\n 774→ let collector = MetricsCollector::new();\n 775→ let metrics = collector.collect().await;\n 776→\n 777→ println!(\"\\nCurrent System Metrics:\");\n 778→ println!(\" CPU Usage: {:.1}%\", metrics.cpu_percent);\n 779→ println!(\" Memory Usage: {:.1}%\", metrics.memory_percent);\n 780→ println!(\n 781→ \" Memory Used: {:.2} GB\",\n 782→ metrics.memory_used_bytes as f64 / 1_073_741_824.0\n 783→ );\n 784→ println!(\" Disk Usage: {:.1}%\", metrics.disk_percent);\n 785→ println!(\n 786→ \" Disk Used: {:.2} GB\",\n 787→ metrics.disk_used_bytes as f64 / 1_073_741_824.0\n 788→ );\n 789→ }\n 790→ Err(_) => {\n 791→ println!(\"\\nConfig file {:?} not found or invalid.\", config_path);\n 792→ #[cfg(windows)]\n 793→ println!(\"Service config location: {}\\\\agent.toml\", service::windows::CONFIG_DIR);\n 794→ }\n 795→ }\n 796→\n 797→ Ok(())\n 798→}\n 799→\n 800→/// Generate a sample configuration file\n 801→async fn generate_config(output: PathBuf) -> Result<()> {\n 802→ let sample_config = AgentConfig::sample();\n 803→ let toml_str = toml::to_string_pretty(&sample_config)?;\n 804→\n 805→ std::fs::write(&output, toml_str)?;\n 806→ println!(\"Sample configuration written to {:?}\", output);\n 807→ println!(\"\\nEdit this file with your server URL and API key, then run:\");\n 808→ println!(\" gururmm-agent --config {:?} run\", output);\n 809→\n 810→ Ok(())\n 811→}\n 812→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"8cb5cbea-93e8-4818-b9c0-447c6397f20a","timestamp":"2025-12-21T21:37:00.068Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\main.rs","content":"//! GuruRMM Agent - Cross-platform Remote Monitoring and Management Agent\n//!\n//! This agent connects to the GuruRMM server, reports system metrics,\n//! monitors services (watchdog), and executes remote commands.\n\nmod config;\nmod device_id;\nmod ipc;\nmod metrics;\nmod service;\nmod transport;\nmod tray_launcher;\nmod updater;\n\nuse anyhow::{Context, Result};\nuse clap::{Parser, Subcommand};\nuse std::path::PathBuf;\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\nuse tracing::{error, info, warn};\n\nuse crate::config::AgentConfig;\nuse crate::metrics::MetricsCollector;\nuse crate::transport::WebSocketClient;\n\n/// GuruRMM Agent - Remote Monitoring and Management\n#[derive(Parser)]\n#[command(name = \"gururmm-agent\")]\n#[command(author, version, about, long_about = None)]\nstruct Cli {\n /// Path to configuration file\n #[arg(short, long, default_value = \"agent.toml\")]\n config: PathBuf,\n\n /// Subcommand to run\n #[command(subcommand)]\n command: Option<Commands>,\n}\n\n#[derive(Subcommand)]\nenum Commands {\n /// Run the agent (default)\n Run,\n\n /// Install as a system service\n Install {\n /// Server WebSocket URL (e.g., wss://rmm-api.example.com/ws)\n #[arg(long)]\n server_url: Option<String>,\n\n /// API key for authentication\n #[arg(long)]\n api_key: Option<String>,\n\n /// Skip legacy service detection and cleanup\n #[arg(long, default_value = \"false\")]\n skip_legacy_check: bool,\n },\n\n /// Uninstall the system service\n Uninstall,\n\n /// Start the installed service\n Start,\n\n /// Stop the installed service\n Stop,\n\n /// Show agent status\n Status,\n\n /// Generate a sample configuration file\n GenerateConfig {\n /// Output path for config file\n #[arg(short, long, default_value = \"agent.toml\")]\n output: PathBuf,\n },\n\n /// Run as Windows service (called by SCM, not for manual use)\n #[command(hide = true)]\n Service,\n}\n\n/// Shared application state\npub struct AppState {\n pub config: AgentConfig,\n pub metrics_collector: MetricsCollector,\n pub connected: RwLock<bool>,\n pub last_checkin: RwLock<Option<chrono::DateTime<chrono::Utc>>>,\n pub tray_policy: RwLock<ipc::TrayPolicy>,\n}\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n // Initialize logging\n tracing_subscriber::fmt()\n .with_env_filter(\n tracing_subscriber::EnvFilter::from_default_env()\n .add_directive(\"gururmm_agent=info\".parse()?)\n .add_directive(\"info\".parse()?),\n )\n .init();\n\n let cli = Cli::parse();\n\n match cli.command.unwrap_or(Commands::Run) {\n Commands::Run => run_agent(cli.config).await,\n Commands::Install { server_url, api_key, skip_legacy_check } => {\n install_service(server_url, api_key, skip_legacy_check).await\n }\n Commands::Uninstall => uninstall_service().await,\n Commands::Start => start_service().await,\n Commands::Stop => stop_service().await,\n Commands::Status => show_status(cli.config).await,\n Commands::GenerateConfig { output } => generate_config(output).await,\n Commands::Service => run_as_windows_service(),\n }\n}\n\n/// Run as a Windows service (called by SCM)\nfn run_as_windows_service() -> Result<()> {\n #[cfg(windows)]\n {\n service::windows::run_as_service()\n }\n\n #[cfg(not(windows))]\n {\n anyhow::bail!(\"Windows service mode is only available on Windows\");\n }\n}\n\n/// Main agent runtime loop\nasync fn run_agent(config_path: PathBuf) -> Result<()> {\n info!(\"GuruRMM Agent starting...\");\n\n // Load configuration\n let config = AgentConfig::load(&config_path)?;\n info!(\"Loaded configuration from {:?}\", config_path);\n info!(\"Server URL: {}\", config.server.url);\n\n // Initialize metrics collector\n let metrics_collector = MetricsCollector::new();\n info!(\"Metrics collector initialized\");\n\n // Create channels for IPC commands\n let (force_checkin_tx, mut force_checkin_rx) = tokio::sync::mpsc::channel::<()>(8);\n let (stop_agent_tx, mut stop_agent_rx) = tokio::sync::mpsc::channel::<()>(1);\n\n // Create shared state\n let state = Arc::new(AppState {\n config: config.clone(),\n metrics_collector,\n connected: RwLock::new(false),\n last_checkin: RwLock::new(None),\n tray_policy: RwLock::new(ipc::TrayPolicy::default_permissive()),\n });\n\n // Create IPC state for tray communication\n let device_id = device_id::get_device_id();\n let hostname = hostname::get()\n .map(|h| h.to_string_lossy().to_string())\n .unwrap_or_else(|_| \"unknown\".to_string());\n\n let initial_status = ipc::AgentStatus {\n connected: false,\n last_checkin: None,\n server_url: config.server.url.clone(),\n agent_version: env!(\"CARGO_PKG_VERSION\").to_string(),\n device_id,\n hostname,\n error: None,\n };\n\n let ipc_state = Arc::new(ipc::IpcState::new(\n initial_status,\n ipc::TrayPolicy::default_permissive(),\n force_checkin_tx,\n stop_agent_tx,\n ));\n\n // Start IPC server for tray application\n let ipc_server_state = Arc::clone(&ipc_state);\n let ipc_handle = tokio::spawn(async move {\n if let Err(e) = ipc::server::run_ipc_server(ipc_server_state).await {\n error!(\"IPC server error: {}\", e);\n }\n });\n info!(\"IPC server started\");\n\n // Start tray launcher (Windows only for now)\n #[cfg(windows)]\n let tray_launcher = {\n let tray_exe_path = std::path::PathBuf::from(\n format!(\"{}\\\\gururmm-tray.exe\", service::windows::INSTALL_DIR)\n );\n let launcher = Arc::new(tray_launcher::TrayLauncher::new(tray_exe_path));\n\n // Launch tray in existing sessions\n let launcher_init = Arc::clone(&launcher);\n tokio::spawn(async move {\n // Small delay to let IPC server fully start\n tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;\n launcher_init.ensure_running().await;\n });\n\n // Periodic check for new sessions (every 30 seconds)\n let launcher_periodic = Arc::clone(&launcher);\n tokio::spawn(async move {\n let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(30));\n loop {\n interval.tick().await;\n launcher_periodic.ensure_running().await;\n }\n });\n\n info!(\"Tray launcher initialized\");\n Some(launcher)\n };\n\n #[cfg(not(windows))]\n let tray_launcher: Option<Arc<tray_launcher::TrayLauncher>> = None;\n\n // Start the WebSocket client with auto-reconnect\n let ws_state = Arc::clone(&state);\n let ws_handle = tokio::spawn(async move {\n loop {\n info!(\"Connecting to server...\");\n match WebSocketClient::connect_and_run(Arc::clone(&ws_state)).await {\n Ok(_) => {\n warn!(\"WebSocket connection closed normally, reconnecting...\");\n }\n Err(e) => {\n error!(\"WebSocket error: {}, reconnecting in 10 seconds...\", e);\n }\n }\n\n // Mark as disconnected\n *ws_state.connected.write().await = false;\n\n // Wait before reconnecting\n tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;\n }\n });\n\n // Start metrics collection loop\n let metrics_state = Arc::clone(&state);\n let metrics_handle = tokio::spawn(async move {\n let interval = metrics_state.config.metrics.interval_seconds;\n let mut interval_timer = tokio::time::interval(tokio::time::Duration::from_secs(interval));\n\n loop {\n interval_timer.tick().await;\n\n // Collect metrics (they'll be sent via WebSocket if connected)\n let metrics = metrics_state.metrics_collector.collect().await;\n if *metrics_state.connected.read().await {\n info!(\n \"Metrics: CPU={:.1}%, Mem={:.1}%, Disk={:.1}%\",\n metrics.cpu_percent, metrics.memory_percent, metrics.disk_percent\n );\n }\n }\n });\n\n // Task to update IPC status periodically\n let ipc_update_state = Arc::clone(&state);\n let ipc_update_ipc = Arc::clone(&ipc_state);\n let ipc_update_handle = tokio::spawn(async move {\n let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));\n loop {\n interval.tick().await;\n\n let connected = *ipc_update_state.connected.read().await;\n let last_checkin = ipc_update_state.last_checkin.read().await.map(|dt| dt.to_rfc3339());\n\n let status = ipc::AgentStatus {\n connected,\n last_checkin,\n server_url: ipc_update_state.config.server.url.clone(),\n agent_version: env!(\"CARGO_PKG_VERSION\").to_string(),\n device_id: device_id::get_device_id(),\n hostname: hostname::get()\n .map(|h| h.to_string_lossy().to_string())\n .unwrap_or_else(|_| \"unknown\".to_string()),\n error: if connected { None } else { Some(\"Disconnected\".to_string()) },\n };\n\n ipc_update_ipc.update_status(status).await;\n }\n });\n\n // Wait for shutdown signal or stop request\n tokio::select! {\n _ = tokio::signal::ctrl_c() => {\n info!(\"Received shutdown signal\");\n }\n _ = stop_agent_rx.recv() => {\n info!(\"Received stop request from tray\");\n }\n _ = ws_handle => {\n error!(\"WebSocket task ended unexpectedly\");\n }\n _ = metrics_handle => {\n error!(\"Metrics task ended unexpectedly\");\n }\n _ = ipc_handle => {\n error!(\"IPC server ended unexpectedly\");\n }\n _ = ipc_update_handle => {\n error!(\"IPC update task ended unexpectedly\");\n }\n }\n\n // Terminate tray processes on shutdown\n #[cfg(windows)]\n if let Some(launcher) = tray_launcher {\n info!(\"Terminating tray processes...\");\n launcher.terminate_all().await;\n }\n\n #[cfg(not(windows))]\n let _ = tray_launcher; // Suppress unused warning\n\n info!(\"GuruRMM Agent shutting down\");\n Ok(())\n}\n\n/// Install the agent as a system service\nasync fn install_service(\n server_url: Option<String>,\n api_key: Option<String>,\n skip_legacy_check: bool,\n) -> Result<()> {\n #[cfg(windows)]\n {\n service::windows::install(server_url, api_key, skip_legacy_check)\n }\n\n #[cfg(target_os = \"linux\")]\n {\n install_systemd_service(server_url, api_key, skip_legacy_check).await\n }\n\n #[cfg(target_os = \"macos\")]\n {\n let _ = (server_url, api_key, skip_legacy_check); // Suppress unused warnings\n info!(\"Installing GuruRMM Agent as launchd service...\");\n todo!(\"macOS launchd service installation not yet implemented\");\n }\n}\n\n/// Legacy service names to check for and clean up (Linux)\n#[cfg(target_os = \"linux\")]\nconst LINUX_LEGACY_SERVICE_NAMES: &[&str] = &[\n \"gururmm\", // Old name without -agent suffix\n \"guru-rmm-agent\", // Alternative naming\n \"GuruRMM-Agent\", // Case variant\n];\n\n/// Clean up legacy Linux service installations\n#[cfg(target_os = \"linux\")]\nfn cleanup_legacy_linux_services() -> Result<()> {\n use std::process::Command;\n\n info!(\"Checking for legacy service installations...\");\n\n for legacy_name in LINUX_LEGACY_SERVICE_NAMES {\n // Check if service exists\n let status = Command::new(\"systemctl\")\n .args([\"status\", legacy_name])\n .output();\n\n if let Ok(output) = status {\n if output.status.success() || String::from_utf8_lossy(&output.stderr).contains(\"Loaded:\") {\n info!(\"Found legacy service '{}', removing...\", legacy_name);\n\n // Stop the service\n let _ = Command::new(\"systemctl\")\n .args([\"stop\", legacy_name])\n .status();\n\n // Disable the service\n let _ = Command::new(\"systemctl\")\n .args([\"disable\", legacy_name])\n .status();\n\n // Remove unit file\n let unit_file = format!(\"/etc/systemd/system/{}.service\", legacy_name);\n if std::path::Path::new(&unit_file).exists() {\n info!(\"Removing legacy unit file: {}\", unit_file);\n let _ = std::fs::remove_file(&unit_file);\n }\n }\n }\n }\n\n // Check for legacy binaries in common locations\n let legacy_binary_locations = [\n \"/usr/local/bin/gururmm\",\n \"/usr/bin/gururmm\",\n \"/opt/gururmm/gururmm\",\n \"/opt/gururmm/agent\",\n ];\n\n for legacy_path in legacy_binary_locations {\n if std::path::Path::new(legacy_path).exists() {\n info!(\"Found legacy binary at '{}', removing...\", legacy_path);\n let _ = std::fs::remove_file(legacy_path);\n }\n }\n\n // Reload systemd to pick up removed unit files\n let _ = Command::new(\"systemctl\")\n .args([\"daemon-reload\"])\n .status();\n\n Ok(())\n}\n\n/// Install as a systemd service (Linux)\n#[cfg(target_os = \"linux\")]\nasync fn install_systemd_service(\n server_url: Option<String>,\n api_key: Option<String>,\n skip_legacy_check: bool,\n) -> Result<()> {\n use std::process::Command;\n\n const SERVICE_NAME: &str = \"gururmm-agent\";\n const INSTALL_DIR: &str = \"/usr/local/bin\";\n const CONFIG_DIR: &str = \"/etc/gururmm\";\n const SYSTEMD_DIR: &str = \"/etc/systemd/system\";\n\n info!(\"Installing GuruRMM Agent as systemd service...\");\n\n // Check if running as root\n if !nix::unistd::geteuid().is_root() {\n anyhow::bail!(\"Installation requires root privileges. Please run with sudo.\");\n }\n\n // Clean up legacy installations unless skipped\n if !skip_legacy_check {\n if let Err(e) = cleanup_legacy_linux_services() {\n warn!(\"Legacy cleanup warning: {}\", e);\n }\n }\n\n // Get the current executable path\n let current_exe = std::env::current_exe()\n .context(\"Failed to get current executable path\")?;\n\n let binary_dest = format!(\"{}/{}\", INSTALL_DIR, SERVICE_NAME);\n let config_dest = format!(\"{}/agent.toml\", CONFIG_DIR);\n let unit_file = format!(\"{}/{}.service\", SYSTEMD_DIR, SERVICE_NAME);\n\n // Create config directory\n info!(\"Creating config directory: {}\", CONFIG_DIR);\n std::fs::create_dir_all(CONFIG_DIR)\n .context(\"Failed to create config directory\")?;\n\n // Copy binary\n info!(\"Copying binary to: {}\", binary_dest);\n std::fs::copy(¤t_exe, &binary_dest)\n .context(\"Failed to copy binary\")?;\n\n // Make binary executable\n Command::new(\"chmod\")\n .args([\"+x\", &binary_dest])\n .status()\n .context(\"Failed to set binary permissions\")?;\n\n // Handle configuration\n let config_needs_manual_edit;\n if !std::path::Path::new(&config_dest).exists() {\n info!(\"Creating config: {}\", config_dest);\n\n // Start with sample config\n let mut config = crate::config::AgentConfig::sample();\n\n // Apply provided values\n if let Some(url) = &server_url {\n config.server.url = url.clone();\n }\n if let Some(key) = &api_key {\n config.server.api_key = key.clone();\n }\n\n let toml_str = toml::to_string_pretty(&config)?;\n std::fs::write(&config_dest, toml_str)\n .context(\"Failed to write config file\")?;\n\n // Set restrictive permissions on config (contains API key)\n Command::new(\"chmod\")\n .args([\"600\", &config_dest])\n .status()\n .context(\"Failed to set config permissions\")?;\n\n config_needs_manual_edit = server_url.is_none() || api_key.is_none();\n } else {\n info!(\"Config already exists: {}\", config_dest);\n config_needs_manual_edit = false;\n\n // If server_url or api_key provided, update existing config\n if server_url.is_some() || api_key.is_some() {\n info!(\"Updating existing configuration...\");\n let config_content = std::fs::read_to_string(&config_dest)?;\n let mut config: crate::config::AgentConfig = toml::from_str(&config_content)\n .context(\"Failed to parse existing config\")?;\n\n if let Some(url) = &server_url {\n config.server.url = url.clone();\n }\n if let Some(key) = &api_key {\n config.server.api_key = key.clone();\n }\n\n let toml_str = toml::to_string_pretty(&config)?;\n std::fs::write(&config_dest, toml_str)\n .context(\"Failed to update config file\")?;\n }\n }\n\n // Create systemd unit file\n let unit_content = format!(r#\"[Unit]\nDescription=GuruRMM Agent - Remote Monitoring and Management\nDocumentation=https://github.com/azcomputerguru/gururmm\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart={binary} --config {config} run\nRestart=always\nRestartSec=10\nStandardOutput=journal\nStandardError=journal\nSyslogIdentifier={service}\n\n# Security hardening\nNoNewPrivileges=true\nProtectSystem=strict\nProtectHome=read-only\nPrivateTmp=true\nReadWritePaths=/var/log\n\n[Install]\nWantedBy=multi-user.target\n\"#,\n binary = binary_dest,\n config = config_dest,\n service = SERVICE_NAME\n );\n\n info!(\"Creating systemd unit file: {}\", unit_file);\n std::fs::write(&unit_file, unit_content)\n .context(\"Failed to write systemd unit file\")?;\n\n // Reload systemd daemon\n info!(\"Reloading systemd daemon...\");\n let status = Command::new(\"systemctl\")\n .args([\"daemon-reload\"])\n .status()\n .context(\"Failed to reload systemd\")?;\n\n if !status.success() {\n anyhow::bail!(\"systemctl daemon-reload failed\");\n }\n\n // Enable the service\n info!(\"Enabling service...\");\n let status = Command::new(\"systemctl\")\n .args([\"enable\", SERVICE_NAME])\n .status()\n .context(\"Failed to enable service\")?;\n\n if !status.success() {\n anyhow::bail!(\"systemctl enable failed\");\n }\n\n println!(\"\\n✓ GuruRMM Agent installed successfully!\");\n println!(\"\\nInstalled files:\");\n println!(\" Binary: {}\", binary_dest);\n println!(\" Config: {}\", config_dest);\n println!(\" Service: {}\", unit_file);\n\n if config_needs_manual_edit {\n println!(\"\\n⚠️ IMPORTANT: Edit {} with your server URL and API key!\", config_dest);\n println!(\"\\nNext steps:\");\n println!(\" 1. Edit {} with your server URL and API key\", config_dest);\n println!(\" 2. Start the service: sudo systemctl start {}\", SERVICE_NAME);\n } else {\n println!(\"\\nStarting service...\");\n let status = Command::new(\"systemctl\")\n .args([\"start\", SERVICE_NAME])\n .status();\n\n if status.is_ok() && status.unwrap().success() {\n println!(\"✓ Service started successfully!\");\n } else {\n println!(\"⚠️ Failed to start service. Check logs: sudo journalctl -u {} -f\", SERVICE_NAME);\n }\n }\n\n println!(\"\\nUseful commands:\");\n println!(\" Status: sudo systemctl status {}\", SERVICE_NAME);\n println!(\" Logs: sudo journalctl -u {} -f\", SERVICE_NAME);\n println!(\" Stop: sudo systemctl stop {}\", SERVICE_NAME);\n println!(\" Start: sudo systemctl start {}\", SERVICE_NAME);\n\n Ok(())\n}\n\n/// Uninstall the system service\nasync fn uninstall_service() -> Result<()> {\n #[cfg(windows)]\n {\n service::windows::uninstall()\n }\n\n #[cfg(target_os = \"linux\")]\n {\n uninstall_systemd_service().await\n }\n\n #[cfg(target_os = \"macos\")]\n {\n todo!(\"macOS service uninstallation not yet implemented\");\n }\n}\n\n/// Uninstall systemd service (Linux)\n#[cfg(target_os = \"linux\")]\nasync fn uninstall_systemd_service() -> Result<()> {\n use std::process::Command;\n\n const SERVICE_NAME: &str = \"gururmm-agent\";\n const INSTALL_DIR: &str = \"/usr/local/bin\";\n const CONFIG_DIR: &str = \"/etc/gururmm\";\n const SYSTEMD_DIR: &str = \"/etc/systemd/system\";\n\n info!(\"Uninstalling GuruRMM Agent...\");\n\n if !nix::unistd::geteuid().is_root() {\n anyhow::bail!(\"Uninstallation requires root privileges. Please run with sudo.\");\n }\n\n let binary_path = format!(\"{}/{}\", INSTALL_DIR, SERVICE_NAME);\n let unit_file = format!(\"{}/{}.service\", SYSTEMD_DIR, SERVICE_NAME);\n\n // Stop the service if running\n info!(\"Stopping service...\");\n let _ = Command::new(\"systemctl\")\n .args([\"stop\", SERVICE_NAME])\n .status();\n\n // Disable the service\n info!(\"Disabling service...\");\n let _ = Command::new(\"systemctl\")\n .args([\"disable\", SERVICE_NAME])\n .status();\n\n // Remove unit file\n if std::path::Path::new(&unit_file).exists() {\n info!(\"Removing unit file: {}\", unit_file);\n std::fs::remove_file(&unit_file)?;\n }\n\n // Remove binary\n if std::path::Path::new(&binary_path).exists() {\n info!(\"Removing binary: {}\", binary_path);\n std::fs::remove_file(&binary_path)?;\n }\n\n // Reload systemd\n let _ = Command::new(\"systemctl\")\n .args([\"daemon-reload\"])\n .status();\n\n println!(\"\\n✓ GuruRMM Agent uninstalled successfully!\");\n println!(\"\\nNote: Config directory {} was preserved.\", CONFIG_DIR);\n println!(\"Remove it manually if no longer needed: sudo rm -rf {}\", CONFIG_DIR);\n\n Ok(())\n}\n\n/// Start the installed service\nasync fn start_service() -> Result<()> {\n #[cfg(windows)]\n {\n service::windows::start()\n }\n\n #[cfg(target_os = \"linux\")]\n {\n use std::process::Command;\n\n info!(\"Starting GuruRMM Agent service...\");\n\n let status = Command::new(\"systemctl\")\n .args([\"start\", \"gururmm-agent\"])\n .status()\n .context(\"Failed to start service\")?;\n\n if status.success() {\n println!(\"** Service started successfully\");\n println!(\"Check status: sudo systemctl status gururmm-agent\");\n } else {\n anyhow::bail!(\"Failed to start service. Check: sudo journalctl -u gururmm-agent -n 50\");\n }\n\n Ok(())\n }\n\n #[cfg(target_os = \"macos\")]\n {\n todo!(\"macOS service start not yet implemented\");\n }\n}\n\n/// Stop the installed service\nasync fn stop_service() -> Result<()> {\n #[cfg(windows)]\n {\n service::windows::stop()\n }\n\n #[cfg(target_os = \"linux\")]\n {\n use std::process::Command;\n\n info!(\"Stopping GuruRMM Agent service...\");\n\n let status = Command::new(\"systemctl\")\n .args([\"stop\", \"gururmm-agent\"])\n .status()\n .context(\"Failed to stop service\")?;\n\n if status.success() {\n println!(\"** Service stopped successfully\");\n } else {\n anyhow::bail!(\"Failed to stop service\");\n }\n\n Ok(())\n }\n\n #[cfg(target_os = \"macos\")]\n {\n todo!(\"macOS service stop not yet implemented\");\n }\n}\n\n/// Show agent status\nasync fn show_status(config_path: PathBuf) -> Result<()> {\n // On Windows, show service status\n #[cfg(windows)]\n {\n service::windows::status()?;\n println!();\n }\n\n // Try to load config for additional info\n match AgentConfig::load(&config_path) {\n Ok(config) => {\n println!(\"Configuration\");\n println!(\"=============\");\n println!(\"Config file: {:?}\", config_path);\n println!(\"Server URL: {}\", config.server.url);\n println!(\"Metrics interval: {} seconds\", config.metrics.interval_seconds);\n println!(\"Watchdog enabled: {}\", config.watchdog.enabled);\n\n // Collect current metrics\n let collector = MetricsCollector::new();\n let metrics = collector.collect().await;\n\n println!(\"\\nCurrent System Metrics:\");\n println!(\" CPU Usage: {:.1}%\", metrics.cpu_percent);\n println!(\" Memory Usage: {:.1}%\", metrics.memory_percent);\n println!(\n \" Memory Used: {:.2} GB\",\n metrics.memory_used_bytes as f64 / 1_073_741_824.0\n );\n println!(\" Disk Usage: {:.1}%\", metrics.disk_percent);\n println!(\n \" Disk Used: {:.2} GB\",\n metrics.disk_used_bytes as f64 / 1_073_741_824.0\n );\n }\n Err(_) => {\n println!(\"\\nConfig file {:?} not found or invalid.\", config_path);\n #[cfg(windows)]\n println!(\"Service config location: {}\\\\agent.toml\", service::windows::CONFIG_DIR);\n }\n }\n\n Ok(())\n}\n\n/// Generate a sample configuration file\nasync fn generate_config(output: PathBuf) -> Result<()> {\n let sample_config = AgentConfig::sample();\n let toml_str = toml::to_string_pretty(&sample_config)?;\n\n std::fs::write(&output, toml_str)?;\n println!(\"Sample configuration written to {:?}\", output);\n println!(\"\\nEdit this file with your server URL and API key, then run:\");\n println!(\" gururmm-agent --config {:?} run\", output);\n\n Ok(())\n}\n","numLines":812,"startLine":1,"totalLines":812}}}
|
||
{"parentUuid":"8cb5cbea-93e8-4818-b9c0-447c6397f20a","isSidechain":true,"userType":"external","cwd":"C:\\Users\\MikeSwanson\\claude-projects","sessionId":"99918cbf-1f1c-4e49-ace0-f7a73ae40c80","version":"2.0.75","gitBranch":"main","agentId":"aeb9495","slug":"tranquil-bouncing-twilight","message":{"model":"claude-opus-4-5-20251101","id":"msg_01U3pMGyj9jEXQvEPG25XLWW","type":"message","role":"assistant","content":[{"type":"text","text":"Now I have a complete picture of the codebase. Let me provide a comprehensive implementation plan for the stackable policy system.\n\n---\n\n# Stackable Policy System for GuruRMM - Implementation Plan\n\n## Executive Summary\n\nThis plan designs a hierarchical policy system where policies can be assigned at Client, Site, and Agent levels, with a \"last wins\" merge strategy (Agent overrides Site overrides Client). The system will use the existing WebSocket `ConfigUpdate` message for policy delivery and evaluate thresholds on the server during metrics ingestion.\n\n---\n\n## 1. Database Schema\n\n### 1.1 New Tables\n\n#### `policies` Table\nStores policy definitions as JSON documents. Each policy is a reusable template that can be assigned to multiple targets.\n\n```sql\n-- Policy definitions (reusable policy templates)\nCREATE TABLE policies (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n name VARCHAR(255) NOT NULL,\n description TEXT,\n -- Policy content stored as JSONB for flexibility\n policy_data JSONB NOT NULL DEFAULT '{}',\n is_default BOOLEAN DEFAULT FALSE, -- Mark as system default\n created_at TIMESTAMPTZ DEFAULT NOW(),\n updated_at TIMESTAMPTZ DEFAULT NOW(),\n created_by UUID REFERENCES users(id) ON DELETE SET NULL\n);\n\nCREATE INDEX idx_policies_name ON policies(name);\nCREATE INDEX idx_policies_default ON policies(is_default) WHERE is_default = TRUE;\n\n-- Trigger for updated_at\nCREATE TRIGGER update_policies_updated_at\n BEFORE UPDATE ON policies\n FOR EACH ROW\n EXECUTE FUNCTION update_updated_at_column();\n```\n\n#### `policy_assignments` Table\nLinks policies to targets (clients, sites, or agents) with a priority system.\n\n```sql\n-- Policy assignments (links policies to targets)\nCREATE TABLE policy_assignments (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n policy_id UUID NOT NULL REFERENCES policies(id) ON DELETE CASCADE,\n -- Target: exactly one of these should be set\n client_id UUID REFERENCES clients(id) ON DELETE CASCADE,\n site_id UUID REFERENCES sites(id) ON DELETE CASCADE,\n agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,\n -- Priority within same level (higher = takes precedence)\n priority INTEGER DEFAULT 0,\n created_at TIMESTAMPTZ DEFAULT NOW(),\n created_by UUID REFERENCES users(id) ON DELETE SET NULL,\n \n -- Ensure exactly one target is set\n CONSTRAINT policy_assignment_target_check CHECK (\n (client_id IS NOT NULL)::int + \n (site_id IS NOT NULL)::int + \n (agent_id IS NOT NULL)::int = 1\n )\n);\n\nCREATE INDEX idx_policy_assignments_policy ON policy_assignments(policy_id);\nCREATE INDEX idx_policy_assignments_client ON policy_assignments(client_id) WHERE client_id IS NOT NULL;\nCREATE INDEX idx_policy_assignments_site ON policy_assignments(site_id) WHERE site_id IS NOT NULL;\nCREATE INDEX idx_policy_assignments_agent ON policy_assignments(agent_id) WHERE agent_id IS NOT NULL;\n```\n\n#### `alerts` Table\nStores triggered alerts for dashboard display.\n\n```sql\n-- Alerts triggered by threshold evaluation\nCREATE TABLE alerts (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,\n -- Alert categorization\n alert_type VARCHAR(50) NOT NULL, -- 'cpu', 'memory', 'disk', 'temperature', 'service', 'custom'\n severity VARCHAR(20) NOT NULL DEFAULT 'warning', -- 'info', 'warning', 'critical'\n -- Alert content\n title VARCHAR(255) NOT NULL,\n message TEXT,\n metric_value REAL, -- The value that triggered the alert\n threshold_value REAL, -- The threshold that was exceeded\n -- State management\n status VARCHAR(20) DEFAULT 'active', -- 'active', 'acknowledged', 'resolved'\n acknowledged_at TIMESTAMPTZ,\n acknowledged_by UUID REFERENCES users(id) ON DELETE SET NULL,\n resolved_at TIMESTAMPTZ,\n -- Deduplication: hash of agent_id + alert_type + relevant identifiers\n dedup_key VARCHAR(255),\n -- Timestamps\n triggered_at TIMESTAMPTZ DEFAULT NOW(),\n last_seen_at TIMESTAMPTZ DEFAULT NOW(),\n created_at TIMESTAMPTZ DEFAULT NOW()\n);\n\nCREATE INDEX idx_alerts_agent ON alerts(agent_id);\nCREATE INDEX idx_alerts_status ON alerts(status);\nCREATE INDEX idx_alerts_type_status ON alerts(alert_type, status);\nCREATE INDEX idx_alerts_triggered ON alerts(triggered_at DESC);\nCREATE INDEX idx_alerts_dedup ON alerts(dedup_key) WHERE status = 'active';\n```\n\n#### `alert_thresholds_state` Table\nTracks threshold violation state for \"X% for Y minutes\" logic.\n\n```sql\n-- Threshold violation state tracking (for duration-based alerts)\nCREATE TABLE alert_threshold_state (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,\n threshold_key VARCHAR(100) NOT NULL, -- e.g., 'cpu_warning', 'memory_critical'\n -- State tracking\n violation_started_at TIMESTAMPTZ,\n consecutive_violations INTEGER DEFAULT 0,\n last_value REAL,\n last_checked_at TIMESTAMPTZ DEFAULT NOW(),\n \n UNIQUE(agent_id, threshold_key)\n);\n\nCREATE INDEX idx_threshold_state_agent ON alert_threshold_state(agent_id);\n```\n\n---\n\n## 2. Policy Data Structure\n\n### 2.1 Rust Structs (Server-side)\n\n```rust\n// server/src/db/policies.rs\n\nuse serde::{Deserialize, Serialize};\nuse sqlx::types::Json;\nuse uuid::Uuid;\n\n/// Complete policy structure stored as JSONB\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct PolicyData {\n /// Metrics collection settings\n #[serde(default)]\n pub metrics: Option<MetricsPolicy>,\n \n /// Alert thresholds\n #[serde(default)]\n pub thresholds: Option<ThresholdsPolicy>,\n \n /// System tray settings\n #[serde(default)]\n pub tray: Option<TrayPolicy>,\n \n /// Auto-update settings\n #[serde(default)]\n pub updates: Option<UpdatesPolicy>,\n \n /// Watchdog configuration\n #[serde(default)]\n pub watchdog: Option<WatchdogPolicy>,\n}\n\n/// Metrics collection policy\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MetricsPolicy {\n /// Collection interval in seconds (10-3600)\n pub interval_seconds: Option<u64>,\n /// Which metrics to collect\n pub collect_cpu: Option<bool>,\n pub collect_memory: Option<bool>,\n pub collect_disk: Option<bool>,\n pub collect_network: Option<bool>,\n pub collect_temperatures: Option<bool>,\n /// Extended metrics\n pub collect_user_info: Option<bool>,\n pub collect_public_ip: Option<bool>,\n}\n\n/// Alert thresholds policy\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ThresholdsPolicy {\n /// CPU thresholds\n pub cpu: Option<ResourceThreshold>,\n /// Memory thresholds\n pub memory: Option<ResourceThreshold>,\n /// Disk thresholds \n pub disk: Option<ResourceThreshold>,\n /// Temperature thresholds (Celsius)\n pub cpu_temp: Option<TemperatureThreshold>,\n pub gpu_temp: Option<TemperatureThreshold>,\n}\n\n/// Threshold for a resource (CPU/Memory/Disk)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ResourceThreshold {\n /// Warning threshold percentage\n pub warning_percent: Option<f32>,\n /// Critical threshold percentage\n pub critical_percent: Option<f32>,\n /// Duration in seconds before triggering (0 = immediate)\n pub duration_seconds: Option<u64>,\n}\n\n/// Temperature threshold\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TemperatureThreshold {\n pub warning_celsius: Option<f32>,\n pub critical_celsius: Option<f32>,\n}\n\n/// Tray application policy (matches existing TrayPolicy)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TrayPolicy {\n pub enabled: Option<bool>,\n pub show_status: Option<bool>,\n pub show_info: Option<bool>,\n pub allow_force_checkin: Option<bool>,\n pub allow_view_logs: Option<bool>,\n pub allow_open_dashboard: Option<bool>,\n pub allow_stop_agent: Option<bool>,\n pub dashboard_url: Option<String>,\n pub tooltip_text: Option<String>,\n pub custom_icon: Option<String>,\n}\n\n/// Auto-update policy\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdatesPolicy {\n /// Enable automatic updates\n pub auto_update: Option<bool>,\n /// Maintenance window (24h format, e.g., \"02:00-06:00\")\n pub maintenance_window: Option<String>,\n /// Channels: stable, beta, dev\n pub channel: Option<String>,\n /// Defer updates for N hours after release\n pub defer_hours: Option<u32>,\n}\n\n/// Watchdog policy\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct WatchdogPolicy {\n pub enabled: Option<bool>,\n pub check_interval_seconds: Option<u64>,\n pub services: Option<Vec<ServiceWatchPolicy>>,\n pub processes: Option<Vec<ProcessWatchPolicy>>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ServiceWatchPolicy {\n pub name: String,\n pub action: String, // \"alert\", \"restart\", \"ignore\"\n pub max_restarts: Option<u32>,\n pub restart_cooldown_seconds: Option<u64>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProcessWatchPolicy {\n pub name: String,\n pub action: String,\n pub start_command: Option<String>,\n}\n```\n\n### 2.2 Database Record Structs\n\n```rust\n/// Policy record from database\n#[derive(Debug, Clone, sqlx::FromRow)]\npub struct Policy {\n pub id: Uuid,\n pub name: String,\n pub description: Option<String>,\n pub policy_data: sqlx::types::Json<PolicyData>,\n pub is_default: bool,\n pub created_at: chrono::DateTime<chrono::Utc>,\n pub updated_at: chrono::DateTime<chrono::Utc>,\n pub created_by: Option<Uuid>,\n}\n\n/// Policy assignment record\n#[derive(Debug, Clone, sqlx::FromRow)]\npub struct PolicyAssignment {\n pub id: Uuid,\n pub policy_id: Uuid,\n pub client_id: Option<Uuid>,\n pub site_id: Option<Uuid>,\n pub agent_id: Option<Uuid>,\n pub priority: i32,\n pub created_at: chrono::DateTime<chrono::Utc>,\n pub created_by: Option<Uuid>,\n}\n\n/// Assignment level for merge ordering\n#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]\npub enum AssignmentLevel {\n Default = 0,\n Client = 1,\n Site = 2,\n Agent = 3,\n}\n```\n\n---\n\n## 3. Policy Merge Logic\n\n### 3.1 Merge Strategy\n\nThe merge follows a \"deep merge with last wins\" strategy:\n\n1. Start with system defaults\n2. Apply Client-level policies (sorted by priority)\n3. Apply Site-level policies (sorted by priority)\n4. Apply Agent-level policies (sorted by priority)\n\nFor each field:\n- `None` values are skipped (no override)\n- `Some` values replace previous values\n- Arrays (services/processes) are concatenated, with later entries overriding same-name entries\n\n### 3.2 Implementation\n\n```rust\n// server/src/policy/merge.rs\n\nimpl PolicyData {\n /// Create default policy with sensible defaults\n pub fn system_defaults() -> Self {\n Self {\n metrics: Some(MetricsPolicy {\n interval_seconds: Some(60),\n collect_cpu: Some(true),\n collect_memory: Some(true),\n collect_disk: Some(true),\n collect_network: Some(true),\n collect_temperatures: Some(true),\n collect_user_info: Some(true),\n collect_public_ip: Some(true),\n }),\n thresholds: Some(ThresholdsPolicy {\n cpu: Some(ResourceThreshold {\n warning_percent: Some(80.0),\n critical_percent: Some(95.0),\n duration_seconds: Some(300), // 5 minutes\n }),\n memory: Some(ResourceThreshold {\n warning_percent: Some(85.0),\n critical_percent: Some(95.0),\n duration_seconds: Some(300),\n }),\n disk: Some(ResourceThreshold {\n warning_percent: Some(85.0),\n critical_percent: Some(95.0),\n duration_seconds: Some(0), // Immediate\n }),\n cpu_temp: Some(TemperatureThreshold {\n warning_celsius: Some(80.0),\n critical_celsius: Some(95.0),\n }),\n gpu_temp: Some(TemperatureThreshold {\n warning_celsius: Some(85.0),\n critical_celsius: Some(100.0),\n }),\n }),\n tray: Some(TrayPolicy {\n enabled: Some(true),\n show_status: Some(true),\n show_info: Some(true),\n allow_force_checkin: Some(true),\n allow_view_logs: Some(false),\n allow_open_dashboard: Some(true),\n allow_stop_agent: Some(false),\n dashboard_url: None,\n tooltip_text: None,\n custom_icon: None,\n }),\n updates: Some(UpdatesPolicy {\n auto_update: Some(true),\n maintenance_window: None,\n channel: Some(\"stable\".to_string()),\n defer_hours: Some(24),\n }),\n watchdog: Some(WatchdogPolicy {\n enabled: Some(false),\n check_interval_seconds: Some(30),\n services: Some(vec![]),\n processes: Some(vec![]),\n }),\n }\n }\n\n /// Merge another policy into this one (other takes precedence)\n pub fn merge_from(&mut self, other: &PolicyData) {\n if let Some(ref metrics) = other.metrics {\n self.metrics = Some(self.metrics.take().unwrap_or_default().merge(metrics));\n }\n if let Some(ref thresholds) = other.thresholds {\n self.thresholds = Some(self.thresholds.take().unwrap_or_default().merge(thresholds));\n }\n if let Some(ref tray) = other.tray {\n self.tray = Some(self.tray.take().unwrap_or_default().merge(tray));\n }\n if let Some(ref updates) = other.updates {\n self.updates = Some(self.updates.take().unwrap_or_default().merge(updates));\n }\n if let Some(ref watchdog) = other.watchdog {\n self.watchdog = Some(self.watchdog.take().unwrap_or_default().merge(watchdog));\n }\n }\n}\n\n// Example merge trait implementation for a sub-policy\nimpl MetricsPolicy {\n pub fn merge(&self, other: &Self) -> Self {\n Self {\n interval_seconds: other.interval_seconds.or(self.interval_seconds),\n collect_cpu: other.collect_cpu.or(self.collect_cpu),\n collect_memory: other.collect_memory.or(self.collect_memory),\n collect_disk: other.collect_disk.or(self.collect_disk),\n collect_network: other.collect_network.or(self.collect_network),\n collect_temperatures: other.collect_temperatures.or(self.collect_temperatures),\n collect_user_info: other.collect_user_info.or(self.collect_user_info),\n collect_public_ip: other.collect_public_ip.or(self.collect_public_ip),\n }\n }\n}\n```\n\n### 3.3 Effective Policy Calculation\n\n```rust\n// server/src/policy/effective.rs\n\n/// Calculate effective policy for an agent\npub async fn get_effective_policy(\n pool: &PgPool,\n agent_id: Uuid,\n) -> Result<PolicyData, sqlx::Error> {\n // Get agent's site and client\n let agent_context = sqlx::query_as::<_, AgentPolicyContext>(\n r#\"\n SELECT \n a.id as agent_id,\n a.site_id,\n s.client_id\n FROM agents a\n LEFT JOIN sites s ON a.site_id = s.id\n WHERE a.id = $1\n \"#,\n )\n .bind(agent_id)\n .fetch_one(pool)\n .await?;\n\n // Start with system defaults\n let mut effective = PolicyData::system_defaults();\n\n // Get default policy if exists\n if let Some(default_policy) = get_default_policy(pool).await? {\n effective.merge_from(&default_policy.policy_data.0);\n }\n\n // Apply client-level policies\n if let Some(client_id) = agent_context.client_id {\n let client_policies = get_policies_for_target(pool, \"client\", client_id).await?;\n for policy in client_policies {\n effective.merge_from(&policy.policy_data.0);\n }\n }\n\n // Apply site-level policies\n if let Some(site_id) = agent_context.site_id {\n let site_policies = get_policies_for_target(pool, \"site\", site_id).await?;\n for policy in site_policies {\n effective.merge_from(&policy.policy_data.0);\n }\n }\n\n // Apply agent-level policies\n let agent_policies = get_policies_for_target(pool, \"agent\", agent_id).await?;\n for policy in agent_policies {\n effective.merge_from(&policy.policy_data.0);\n }\n\n Ok(effective)\n}\n\nasync fn get_policies_for_target(\n pool: &PgPool,\n target_type: &str,\n target_id: Uuid,\n) -> Result<Vec<Policy>, sqlx::Error> {\n let query = match target_type {\n \"client\" => r#\"\n SELECT p.* FROM policies p\n JOIN policy_assignments pa ON p.id = pa.policy_id\n WHERE pa.client_id = $1\n ORDER BY pa.priority ASC\n \"#,\n \"site\" => r#\"\n SELECT p.* FROM policies p\n JOIN policy_assignments pa ON p.id = pa.policy_id\n WHERE pa.site_id = $1\n ORDER BY pa.priority ASC\n \"#,\n \"agent\" => r#\"\n SELECT p.* FROM policies p\n JOIN policy_assignments pa ON p.id = pa.policy_id\n WHERE pa.agent_id = $1\n ORDER BY pa.priority ASC\n \"#,\n _ => return Err(sqlx::Error::Protocol(\"Invalid target type\".into())),\n };\n\n sqlx::query_as::<_, Policy>(query)\n .bind(target_id)\n .fetch_all(pool)\n .await\n}\n```\n\n---\n\n## 4. API Endpoints\n\n### 4.1 Policy CRUD Routes\n\nAdd to `server/src/api/mod.rs`:\n\n```rust\n// Policies\n.route(\"/policies\", get(policies::list_policies))\n.route(\"/policies\", post(policies::create_policy))\n.route(\"/policies/:id\", get(policies::get_policy))\n.route(\"/policies/:id\", put(policies::update_policy))\n.route(\"/policies/:id\", delete(policies::delete_policy))\n// Policy assignments\n.route(\"/policies/:id/assignments\", get(policies::list_assignments))\n.route(\"/policies/:id/assign\", post(policies::assign_policy))\n.route(\"/policy-assignments/:id\", delete(policies::remove_assignment))\n// Effective policy for an agent\n.route(\"/agents/:id/effective-policy\", get(policies::get_effective_policy))\n// Deploy policy to connected agents\n.route(\"/policies/:id/deploy\", post(policies::deploy_policy))\n// Alerts\n.route(\"/alerts\", get(alerts::list_alerts))\n.route(\"/alerts/:id\", get(alerts::get_alert))\n.route(\"/alerts/:id/acknowledge\", post(alerts::acknowledge_alert))\n.route(\"/alerts/:id/resolve\", post(alerts::resolve_alert))\n.route(\"/alerts/summary\", get(alerts::get_summary))\n```\n\n### 4.2 API Implementation (Key Endpoints)\n\n```rust\n// server/src/api/policies.rs\n\n/// Assign a policy to a target\n#[derive(Debug, Deserialize)]\npub struct AssignPolicyRequest {\n pub target_type: String, // \"client\", \"site\", or \"agent\"\n pub target_id: Uuid,\n pub priority: Option<i32>,\n}\n\npub async fn assign_policy(\n State(state): State<AppState>,\n Path(policy_id): Path<Uuid>,\n _auth: AuthUser,\n Json(request): Json<AssignPolicyRequest>,\n) -> Result<Json<PolicyAssignment>, ApiError> {\n let assignment = db::create_policy_assignment(\n &state.db,\n policy_id,\n &request.target_type,\n request.target_id,\n request.priority.unwrap_or(0),\n ).await?;\n\n // Trigger policy push to affected agents\n push_policy_to_affected_agents(&state, &request.target_type, request.target_id).await;\n\n Ok(Json(assignment))\n}\n\n/// Get effective policy for an agent\npub async fn get_effective_policy(\n State(state): State<AppState>,\n Path(agent_id): Path<Uuid>,\n _auth: AuthUser,\n) -> Result<Json<PolicyData>, ApiError> {\n let policy = policy::get_effective_policy(&state.db, agent_id).await?;\n Ok(Json(policy))\n}\n\n/// Deploy policy to all affected connected agents\nasync fn push_policy_to_affected_agents(\n state: &AppState,\n target_type: &str,\n target_id: Uuid,\n) {\n // Get all affected agent IDs\n let agent_ids = match target_type {\n \"client\" => db::get_agent_ids_by_client(&state.db, target_id).await,\n \"site\" => db::get_agent_ids_by_site(&state.db, target_id).await,\n \"agent\" => Ok(vec![target_id]),\n _ => return,\n };\n\n if let Ok(agent_ids) = agent_ids {\n let agents = state.agents.read().await;\n for agent_id in agent_ids {\n if agents.is_connected(&agent_id) {\n // Calculate and send effective policy\n if let Ok(policy) = policy::get_effective_policy(&state.db, agent_id).await {\n let config_msg = ServerMessage::ConfigUpdate(serde_json::to_value(&policy).unwrap());\n let _ = agents.send_to(&agent_id, config_msg).await;\n }\n }\n }\n }\n}\n```\n\n---\n\n## 5. Alert Evaluation System\n\n### 5.1 Threshold Evaluation on Metrics Ingestion\n\nModify `handle_agent_message` in `server/src/ws/mod.rs`:\n\n```rust\nAgentMessage::Metrics(metrics) => {\n debug!(\"Received metrics from agent {}: CPU={:.1}%\", agent_id, metrics.cpu_percent);\n\n // Store metrics in database (existing code)\n let create_metrics = db::CreateMetrics { /* ... */ };\n db::insert_metrics(&state.db, create_metrics).await?;\n\n // NEW: Evaluate thresholds and create alerts\n evaluate_thresholds(&state.db, agent_id, &metrics).await;\n \n // Update last_seen (existing code)\n db::update_agent_status(&state.db, agent_id, \"online\").await?;\n}\n```\n\n### 5.2 Threshold Evaluator\n\n```rust\n// server/src/alerts/evaluator.rs\n\npub async fn evaluate_thresholds(\n pool: &PgPool,\n agent_id: Uuid,\n metrics: &MetricsPayload,\n) {\n // Get effective policy for this agent\n let policy = match policy::get_effective_policy(pool, agent_id).await {\n Ok(p) => p,\n Err(_) => return, // Use defaults or skip\n };\n\n let thresholds = match policy.thresholds {\n Some(t) => t,\n None => return,\n };\n\n // Evaluate each threshold\n if let Some(ref cpu_threshold) = thresholds.cpu {\n evaluate_resource_threshold(\n pool,\n agent_id,\n \"cpu\",\n metrics.cpu_percent,\n cpu_threshold,\n ).await;\n }\n\n if let Some(ref memory_threshold) = thresholds.memory {\n evaluate_resource_threshold(\n pool,\n agent_id,\n \"memory\",\n metrics.memory_percent,\n memory_threshold,\n ).await;\n }\n\n if let Some(ref disk_threshold) = thresholds.disk {\n evaluate_resource_threshold(\n pool,\n agent_id,\n \"disk\",\n metrics.disk_percent,\n disk_threshold,\n ).await;\n }\n\n // Temperature thresholds\n if let (Some(cpu_temp), Some(ref threshold)) = (metrics.cpu_temp_celsius, &thresholds.cpu_temp) {\n evaluate_temperature_threshold(pool, agent_id, \"cpu_temp\", cpu_temp, threshold).await;\n }\n}\n\nasync fn evaluate_resource_threshold(\n pool: &PgPool,\n agent_id: Uuid,\n resource: &str,\n current_value: f32,\n threshold: &ResourceThreshold,\n) {\n let critical_exceeded = threshold.critical_percent\n .map(|t| current_value >= t)\n .unwrap_or(false);\n let warning_exceeded = threshold.warning_percent\n .map(|t| current_value >= t)\n .unwrap_or(false);\n\n let severity = if critical_exceeded {\n \"critical\"\n } else if warning_exceeded {\n \"warning\"\n } else {\n // Below threshold - check if we should resolve existing alert\n resolve_if_below_threshold(pool, agent_id, resource).await;\n return;\n };\n\n let threshold_key = format!(\"{}_{}\", resource, severity);\n let duration_seconds = threshold.duration_seconds.unwrap_or(0);\n\n if duration_seconds == 0 {\n // Immediate alert\n create_or_update_alert(pool, agent_id, resource, severity, current_value, \n threshold.critical_percent.or(threshold.warning_percent).unwrap_or(0.0)).await;\n } else {\n // Duration-based alert\n check_duration_threshold(\n pool, agent_id, &threshold_key, current_value, \n duration_seconds, severity, resource,\n threshold.critical_percent.or(threshold.warning_percent).unwrap_or(0.0)\n ).await;\n }\n}\n\nasync fn check_duration_threshold(\n pool: &PgPool,\n agent_id: Uuid,\n threshold_key: &str,\n current_value: f32,\n required_duration_seconds: u64,\n severity: &str,\n resource: &str,\n threshold_value: f32,\n) {\n // Get or create threshold state\n let state = db::get_or_create_threshold_state(pool, agent_id, threshold_key).await;\n\n if let Ok(mut state) = state {\n let now = chrono::Utc::now();\n \n if state.violation_started_at.is_some() {\n // We're in a violation period - check duration\n let violation_start = state.violation_started_at.unwrap();\n let elapsed = (now - violation_start).num_seconds() as u64;\n\n if elapsed >= required_duration_seconds {\n // Duration exceeded - create alert\n create_or_update_alert(pool, agent_id, resource, severity, current_value, threshold_value).await;\n }\n \n // Update state\n state.consecutive_violations += 1;\n state.last_value = current_value;\n state.last_checked_at = now;\n } else {\n // Start new violation period\n state.violation_started_at = Some(now);\n state.consecutive_violations = 1;\n state.last_value = current_value;\n state.last_checked_at = now;\n }\n\n let _ = db::update_threshold_state(pool, &state).await;\n }\n}\n\nasync fn create_or_update_alert(\n pool: &PgPool,\n agent_id: Uuid,\n alert_type: &str,\n severity: &str,\n metric_value: f32,\n threshold_value: f32,\n) {\n let dedup_key = format!(\"{}:{}:{}\", agent_id, alert_type, severity);\n let title = format!(\"{} {} - {:.1}%\", \n alert_type.to_uppercase(), \n severity.to_uppercase(),\n metric_value\n );\n let message = format!(\n \"{} usage at {:.1}% exceeds {} threshold of {:.1}%\",\n alert_type, metric_value, severity, threshold_value\n );\n\n // Try to find existing active alert with same dedup_key\n let existing = db::get_active_alert_by_dedup_key(pool, &dedup_key).await;\n\n match existing {\n Ok(Some(alert)) => {\n // Update existing alert\n let _ = db::update_alert_last_seen(pool, alert.id, metric_value).await;\n }\n _ => {\n // Create new alert\n let _ = db::create_alert(pool, db::CreateAlert {\n agent_id,\n alert_type: alert_type.to_string(),\n severity: severity.to_string(),\n title,\n message: Some(message),\n metric_value: Some(metric_value),\n threshold_value: Some(threshold_value),\n dedup_key: Some(dedup_key),\n }).await;\n }\n }\n}\n```\n\n---\n\n## 6. Agent-Side Policy Application\n\n### 6.1 Enhanced ConfigUpdatePayload\n\nUpdate `agent/src/transport/mod.rs`:\n\n```rust\n/// Configuration update payload - full effective policy\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ConfigUpdatePayload {\n /// Metrics collection settings\n #[serde(default)]\n pub metrics: Option<MetricsPolicyAgent>,\n \n /// Alert thresholds (for reference/display only - evaluated server-side)\n #[serde(default)]\n pub thresholds: Option<serde_json::Value>,\n \n /// System tray settings\n #[serde(default)]\n pub tray: Option<crate::ipc::TrayPolicy>,\n \n /// Auto-update settings\n #[serde(default)]\n pub updates: Option<UpdatesPolicyAgent>,\n \n /// Watchdog configuration\n #[serde(default)]\n pub watchdog: Option<WatchdogPolicyAgent>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MetricsPolicyAgent {\n pub interval_seconds: Option<u64>,\n pub collect_cpu: Option<bool>,\n pub collect_memory: Option<bool>,\n pub collect_disk: Option<bool>,\n pub collect_network: Option<bool>,\n pub collect_temperatures: Option<bool>,\n pub collect_user_info: Option<bool>,\n pub collect_public_ip: Option<bool>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdatesPolicyAgent {\n pub auto_update: Option<bool>,\n pub maintenance_window: Option<String>,\n pub channel: Option<String>,\n pub defer_hours: Option<u32>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct WatchdogPolicyAgent {\n pub enabled: Option<bool>,\n pub check_interval_seconds: Option<u64>,\n pub services: Option<Vec<serde_json::Value>>,\n pub processes: Option<Vec<serde_json::Value>>,\n}\n```\n\n### 6.2 Agent Runtime Policy Application\n\nIn `agent/src/transport/websocket.rs`, update `handle_server_message`:\n\n```rust\nServerMessage::ConfigUpdate(update) => {\n info!(\"Received config update\");\n \n // Parse the update payload\n if let Ok(policy) = serde_json::from_value::<ConfigUpdatePayload>(update.clone()) {\n // Apply metrics settings\n if let Some(ref metrics) = policy.metrics {\n apply_metrics_policy(metrics).await;\n }\n \n // Apply tray policy via IPC\n if let Some(ref tray) = policy.tray {\n // Update tray policy in state and broadcast to connected tray apps\n if let Some(ipc_state) = get_ipc_state() {\n ipc_state.update_policy(tray.clone()).await;\n }\n }\n \n // Apply watchdog settings\n if let Some(ref watchdog) = policy.watchdog {\n apply_watchdog_policy(watchdog).await;\n }\n \n // Store update settings for auto-updater reference\n if let Some(ref updates) = policy.updates {\n store_update_policy(updates).await;\n }\n }\n}\n```\n\n---\n\n## 7. WebSocket ConfigUpdate Enhancement\n\n### 7.1 Server-Side: Send Policy on Connection\n\nIn `server/src/ws/mod.rs`, after successful authentication:\n\n```rust\n// After: state.agents.write().await.add(result.agent_id, tx.clone());\n\n// Send initial effective policy\nif let Ok(policy) = policy::get_effective_policy(&state.db, result.agent_id).await {\n let config_msg = ServerMessage::ConfigUpdate(serde_json::to_value(&policy).unwrap());\n if let Err(e) = tx.send(config_msg).await {\n warn!(\"Failed to send initial policy: {}\", e);\n }\n}\n```\n\n### 7.2 Trigger Policy Push on Assignment Changes\n\nThe `push_policy_to_affected_agents` function (shown in section 4.2) handles this when policies are assigned, updated, or removed.\n\n---\n\n## 8. Implementation Sequence\n\n### Phase 1: Database & Core Structures\n1. Create migration file `006_policies.sql` with all tables\n2. Add `server/src/db/policies.rs` with CRUD operations\n3. Add `server/src/db/alerts.rs` with alert operations\n4. Create `PolicyData` and related structs\n\n### Phase 2: Policy Merge Logic\n1. Create `server/src/policy/mod.rs` module\n2. Implement `merge.rs` with merge logic\n3. Implement `effective.rs` for effective policy calculation\n4. Add unit tests for merge logic\n\n### Phase 3: API Endpoints\n1. Create `server/src/api/policies.rs`\n2. Create `server/src/api/alerts.rs`\n3. Add routes to `server/src/api/mod.rs`\n4. Test CRUD operations via API\n\n### Phase 4: Alert Evaluation\n1. Create `server/src/alerts/mod.rs` module\n2. Implement `evaluator.rs` with threshold logic\n3. Integrate into metrics ingestion handler\n4. Add threshold state tracking\n\n### Phase 5: Agent Integration\n1. Update `ConfigUpdatePayload` in agent\n2. Implement policy application in agent runtime\n3. Update tray policy handling\n4. Send policy on agent connection\n\n### Phase 6: Dashboard Integration\n1. Policy management UI (create, edit, assign)\n2. Alert dashboard with filtering\n3. Effective policy viewer per agent\n4. Policy deployment status\n\n---\n\n## 9. Critical Files for Implementation\n\n### Critical Files for Implementation\n\n- **`C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\db\\mod.rs`** - Add policies.rs and alerts.rs module exports\n- **`C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\ws\\mod.rs`** - Integrate threshold evaluation into metrics handler, send policy on connection\n- **`C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\server\\src\\api\\mod.rs`** - Add policy and alert API routes\n- **`C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\transport\\mod.rs`** - Enhance ConfigUpdatePayload structure\n- **`C:\\Users\\MikeSwanson\\claude-projects\\gururmm\\agent\\src\\transport\\websocket.rs`** - Implement policy application in handle_server_message"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":10414,"cache_read_input_tokens":67192,"cache_creation":{"ephemeral_5m_input_tokens":10414,"ephemeral_1h_input_tokens":0},"output_tokens":9491,"service_tier":"standard"}},"requestId":"req_011CWLZ4cM2sswVVEqJQNMrD","type":"assistant","uuid":"1f611b40-e39e-48ce-b3da-6063a1b5c622","timestamp":"2025-12-21T21:39:29.467Z"}
|