Implement GuruRMM Phase 1: Real-time tunnel infrastructure

Complete bidirectional tunnel communication between server and agents,
enabling persistent secure channels for future command execution and
file operations. Agents transition from heartbeat mode to tunnel mode
on-demand while maintaining WebSocket connection.

Server Implementation:
- Database layer (db/tunnel.rs): Session CRUD, ownership validation,
  cleanup on disconnect (prevents orphaned sessions)
- API endpoints (api/tunnel.rs): POST /open, POST /close, GET /status
  with JWT auth, UUID validation, proper HTTP status codes
- Protocol extension (ws/mod.rs): TunnelOpen/Close/Data messages,
  agent response handlers (TunnelReady/Data/Error)
- Migration (006_tunnel_sessions.sql): tech_sessions table with
  partial unique constraint, foreign keys with CASCADE, audit table

Agent Implementation:
- State machine (tunnel/mod.rs): AgentMode (Heartbeat ↔ Tunnel),
  channel multiplexing, concurrent session prevention
- WebSocket handlers (transport/websocket.rs): Open/close tunnel,
  mode switching without dropping connection, cleanup on disconnect
- Protocol extension (transport/mod.rs): TunnelReady/Data/Error
  messages matching server definitions
- Unit tests: Lifecycle and channel management coverage

Key Features:
- Security: JWT auth, session ownership verification, SQL injection
  prevention, constraint-based duplicate session blocking
- Cleanup: Automatic session closure on agent disconnect (both sides),
  channel cleanup, graceful state transitions
- Error handling: Proper HTTP status codes (400/403/404/409/500),
  comprehensive Result types, detailed logging
- Extensibility: Channel types ready (Terminal/File/Registry/Service),
  TunnelDataPayload enum for Phase 2+ expansion

Phase 1 Scope (Implemented):
- Tunnel session lifecycle management
- Mode switching (heartbeat ↔ tunnel)
- Protocol message routing
- Database session tracking

Phase 2 Next Steps:
- Terminal command execution (tokio::process::Command)
- Client WebSocket connections for output streaming
- Command audit logging
- File transfer operations

Verification:
- Server compiles successfully (0 errors)
- Agent unit tests pass (tunnel lifecycle, channel management)
- Code review approved (protocol alignment verified)
- Database constraints enforce referential integrity
- Cleanup tested (session closure on disconnect)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 07:10:09 -07:00
parent 9940faf34a
commit 2e6d1a67dd
14 changed files with 2293 additions and 4 deletions

View File

@@ -7,6 +7,7 @@ pub mod clients;
pub mod commands;
pub mod metrics;
pub mod sites;
pub mod tunnel;
pub mod updates;
pub mod users;
@@ -15,5 +16,6 @@ pub use clients::*;
pub use commands::*;
pub use metrics::*;
pub use sites::*;
pub use tunnel::*;
pub use updates::*;
pub use users::*;

View File

@@ -0,0 +1,151 @@
//! Database operations for tunnel sessions
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TechSession {
pub id: i32,
pub session_id: String,
pub tech_id: Uuid,
pub agent_id: Uuid,
pub opened_at: DateTime<Utc>,
pub last_activity: DateTime<Utc>,
pub closed_at: Option<DateTime<Utc>>,
pub status: String,
}
/// Create a new tech session
pub async fn create_tech_session(
pool: &PgPool,
session_id: &str,
tech_id: Uuid,
agent_id: Uuid,
) -> Result<TechSession, sqlx::Error> {
sqlx::query_as::<_, TechSession>(
r#"
INSERT INTO tech_sessions (session_id, tech_id, agent_id, status)
VALUES ($1, $2, $3, 'active')
RETURNING *
"#,
)
.bind(session_id)
.bind(tech_id)
.bind(agent_id)
.fetch_one(pool)
.await
}
/// Get tech session by session_id
pub async fn get_tech_session(
pool: &PgPool,
session_id: &str,
) -> Result<Option<TechSession>, sqlx::Error> {
sqlx::query_as::<_, TechSession>(
r#"
SELECT * FROM tech_sessions
WHERE session_id = $1
"#,
)
.bind(session_id)
.fetch_optional(pool)
.await
}
/// Update last_activity timestamp
pub async fn update_session_activity(
pool: &PgPool,
session_id: &str,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
r#"
UPDATE tech_sessions
SET last_activity = NOW()
WHERE session_id = $1
"#,
)
.bind(session_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
/// Close a session
pub async fn close_tech_session(
pool: &PgPool,
session_id: &str,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
r#"
UPDATE tech_sessions
SET status = 'closed', closed_at = NOW()
WHERE session_id = $1
"#,
)
.bind(session_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
/// Check if tech owns session (for authorization)
pub async fn verify_session_ownership(
pool: &PgPool,
session_id: &str,
tech_id: Uuid,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS(
SELECT 1 FROM tech_sessions
WHERE session_id = $1 AND tech_id = $2 AND status = 'active'
)
"#,
)
.bind(session_id)
.bind(tech_id)
.fetch_one(pool)
.await?;
Ok(result)
}
/// Check if there's an active session for tech + agent pair
pub async fn has_active_session(
pool: &PgPool,
tech_id: Uuid,
agent_id: Uuid,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS(
SELECT 1 FROM tech_sessions
WHERE tech_id = $1 AND agent_id = $2 AND status = 'active'
)
"#,
)
.bind(tech_id)
.bind(agent_id)
.fetch_one(pool)
.await?;
Ok(result)
}
/// Close all active sessions for an agent (when agent disconnects)
pub async fn close_agent_tunnel_sessions(
pool: &PgPool,
agent_id: Uuid,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
r#"
UPDATE tech_sessions
SET status = 'closed', closed_at = NOW()
WHERE agent_id = $1 AND status = 'active'
"#,
)
.bind(agent_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}