Add PostgreSQL database persistence

- Add connect_machines, connect_sessions, connect_session_events, connect_support_codes tables
- Implement db module with connection pooling (sqlx)
- Add machine persistence across server restarts
- Add audit logging for session/viewer events
- Support codes now persisted to database

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 19:51:01 -07:00
parent 448d3b75ac
commit f6bf0cfd26
10 changed files with 788 additions and 36 deletions

107
server/src/db/events.rs Normal file
View File

@@ -0,0 +1,107 @@
//! Audit event logging
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use sqlx::PgPool;
use std::net::IpAddr;
use uuid::Uuid;
/// Session event record from database
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SessionEvent {
pub id: i64,
pub session_id: Uuid,
pub event_type: String,
pub timestamp: DateTime<Utc>,
pub viewer_id: Option<String>,
pub viewer_name: Option<String>,
pub details: Option<JsonValue>,
pub ip_address: Option<String>,
}
/// Event types for session audit logging
pub struct EventTypes;
impl EventTypes {
pub const SESSION_STARTED: &'static str = "session_started";
pub const SESSION_ENDED: &'static str = "session_ended";
pub const SESSION_TIMEOUT: &'static str = "session_timeout";
pub const VIEWER_JOINED: &'static str = "viewer_joined";
pub const VIEWER_LEFT: &'static str = "viewer_left";
pub const STREAMING_STARTED: &'static str = "streaming_started";
pub const STREAMING_STOPPED: &'static str = "streaming_stopped";
}
/// Log a session event
pub async fn log_event(
pool: &PgPool,
session_id: Uuid,
event_type: &str,
viewer_id: Option<&str>,
viewer_name: Option<&str>,
details: Option<JsonValue>,
ip_address: Option<IpAddr>,
) -> Result<i64, sqlx::Error> {
let ip_str = ip_address.map(|ip| ip.to_string());
let result = sqlx::query_scalar::<_, i64>(
r#"
INSERT INTO connect_session_events
(session_id, event_type, viewer_id, viewer_name, details, ip_address)
VALUES ($1, $2, $3, $4, $5, $6::inet)
RETURNING id
"#,
)
.bind(session_id)
.bind(event_type)
.bind(viewer_id)
.bind(viewer_name)
.bind(details)
.bind(ip_str)
.fetch_one(pool)
.await?;
Ok(result)
}
/// Get events for a session
pub async fn get_session_events(
pool: &PgPool,
session_id: Uuid,
) -> Result<Vec<SessionEvent>, sqlx::Error> {
sqlx::query_as::<_, SessionEvent>(
"SELECT id, session_id, event_type, timestamp, viewer_id, viewer_name, details, ip_address::text as ip_address FROM connect_session_events WHERE session_id = $1 ORDER BY timestamp"
)
.bind(session_id)
.fetch_all(pool)
.await
}
/// Get recent events (for dashboard)
pub async fn get_recent_events(
pool: &PgPool,
limit: i64,
) -> Result<Vec<SessionEvent>, sqlx::Error> {
sqlx::query_as::<_, SessionEvent>(
"SELECT id, session_id, event_type, timestamp, viewer_id, viewer_name, details, ip_address::text as ip_address FROM connect_session_events ORDER BY timestamp DESC LIMIT $1"
)
.bind(limit)
.fetch_all(pool)
.await
}
/// Get events by type
pub async fn get_events_by_type(
pool: &PgPool,
event_type: &str,
limit: i64,
) -> Result<Vec<SessionEvent>, sqlx::Error> {
sqlx::query_as::<_, SessionEvent>(
"SELECT id, session_id, event_type, timestamp, viewer_id, viewer_name, details, ip_address::text as ip_address FROM connect_session_events WHERE event_type = $1 ORDER BY timestamp DESC LIMIT $2"
)
.bind(event_type)
.bind(limit)
.fetch_all(pool)
.await
}

118
server/src/db/machines.rs Normal file
View File

@@ -0,0 +1,118 @@
//! Machine/Agent database operations
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
/// Machine record from database
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Machine {
pub id: Uuid,
pub agent_id: String,
pub hostname: String,
pub os_version: Option<String>,
pub is_elevated: bool,
pub is_persistent: bool,
pub first_seen: DateTime<Utc>,
pub last_seen: DateTime<Utc>,
pub last_session_id: Option<Uuid>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Get or create a machine by agent_id (upsert)
pub async fn upsert_machine(
pool: &PgPool,
agent_id: &str,
hostname: &str,
is_persistent: bool,
) -> Result<Machine, sqlx::Error> {
sqlx::query_as::<_, Machine>(
r#"
INSERT INTO connect_machines (agent_id, hostname, is_persistent, status, last_seen)
VALUES ($1, $2, $3, 'online', NOW())
ON CONFLICT (agent_id) DO UPDATE SET
hostname = EXCLUDED.hostname,
status = 'online',
last_seen = NOW()
RETURNING *
"#,
)
.bind(agent_id)
.bind(hostname)
.bind(is_persistent)
.fetch_one(pool)
.await
}
/// Update machine status and info
pub async fn update_machine_status(
pool: &PgPool,
agent_id: &str,
status: &str,
os_version: Option<&str>,
is_elevated: bool,
session_id: Option<Uuid>,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE connect_machines SET
status = $1,
os_version = COALESCE($2, os_version),
is_elevated = $3,
last_seen = NOW(),
last_session_id = COALESCE($4, last_session_id)
WHERE agent_id = $5
"#,
)
.bind(status)
.bind(os_version)
.bind(is_elevated)
.bind(session_id)
.bind(agent_id)
.execute(pool)
.await?;
Ok(())
}
/// Get all persistent machines (for restore on startup)
pub async fn get_all_machines(pool: &PgPool) -> Result<Vec<Machine>, sqlx::Error> {
sqlx::query_as::<_, Machine>(
"SELECT * FROM connect_machines WHERE is_persistent = true ORDER BY hostname"
)
.fetch_all(pool)
.await
}
/// Get machine by agent_id
pub async fn get_machine_by_agent_id(
pool: &PgPool,
agent_id: &str,
) -> Result<Option<Machine>, sqlx::Error> {
sqlx::query_as::<_, Machine>(
"SELECT * FROM connect_machines WHERE agent_id = $1"
)
.bind(agent_id)
.fetch_optional(pool)
.await
}
/// Mark machine as offline
pub async fn mark_machine_offline(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE connect_machines SET status = 'offline', last_seen = NOW() WHERE agent_id = $1")
.bind(agent_id)
.execute(pool)
.await?;
Ok(())
}
/// Delete a machine record
pub async fn delete_machine(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM connect_machines WHERE agent_id = $1")
.bind(agent_id)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -1,45 +1,52 @@
//! Database module
//! Database module for GuruConnect
//!
//! Handles session logging and persistence.
//! Optional for MVP - sessions are kept in memory only.
//! Handles persistence for machines, sessions, and audit logging.
//! Optional - server works without database if DATABASE_URL not set.
pub mod machines;
pub mod sessions;
pub mod events;
pub mod support_codes;
use anyhow::Result;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tracing::info;
/// Database connection pool (placeholder)
pub use machines::*;
pub use sessions::*;
pub use events::*;
pub use support_codes::*;
/// Database connection pool wrapper
#[derive(Clone)]
pub struct Database {
// TODO: Add sqlx pool when PostgreSQL is needed
_placeholder: (),
pool: PgPool,
}
impl Database {
/// Initialize database connection
pub async fn init(_database_url: &str) -> Result<Self> {
// TODO: Initialize PostgreSQL connection pool
Ok(Self { _placeholder: () })
/// Initialize database connection pool
pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self> {
info!("Connecting to database...");
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await?;
info!("Database connection established");
Ok(Self { pool })
}
}
/// Session event for audit logging
#[derive(Debug)]
pub struct SessionEvent {
pub session_id: String,
pub event_type: SessionEventType,
pub details: Option<String>,
}
#[derive(Debug)]
pub enum SessionEventType {
Started,
ViewerJoined,
ViewerLeft,
Ended,
}
impl Database {
/// Log a session event (placeholder)
pub async fn log_session_event(&self, _event: SessionEvent) -> Result<()> {
// TODO: Insert into connect_session_events table
/// Run database migrations
pub async fn migrate(&self) -> Result<()> {
info!("Running database migrations...");
sqlx::migrate!("./migrations").run(&self.pool).await?;
info!("Migrations complete");
Ok(())
}
/// Get reference to the connection pool
pub fn pool(&self) -> &PgPool {
&self.pool
}
}

98
server/src/db/sessions.rs Normal file
View File

@@ -0,0 +1,98 @@
//! Session database operations
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
/// Session record from database
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct DbSession {
pub id: Uuid,
pub machine_id: Option<Uuid>,
pub started_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub duration_secs: Option<i32>,
pub is_support_session: bool,
pub support_code: Option<String>,
pub status: String,
}
/// Create a new session record
pub async fn create_session(
pool: &PgPool,
session_id: Uuid,
machine_id: Uuid,
is_support_session: bool,
support_code: Option<&str>,
) -> Result<DbSession, sqlx::Error> {
sqlx::query_as::<_, DbSession>(
r#"
INSERT INTO connect_sessions (id, machine_id, is_support_session, support_code, status)
VALUES ($1, $2, $3, $4, 'active')
RETURNING *
"#,
)
.bind(session_id)
.bind(machine_id)
.bind(is_support_session)
.bind(support_code)
.fetch_one(pool)
.await
}
/// End a session
pub async fn end_session(
pool: &PgPool,
session_id: Uuid,
status: &str, // 'ended' or 'disconnected' or 'timeout'
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE connect_sessions SET
ended_at = NOW(),
duration_secs = EXTRACT(EPOCH FROM (NOW() - started_at))::INTEGER,
status = $1
WHERE id = $2
"#,
)
.bind(status)
.bind(session_id)
.execute(pool)
.await?;
Ok(())
}
/// Get session by ID
pub async fn get_session(pool: &PgPool, session_id: Uuid) -> Result<Option<DbSession>, sqlx::Error> {
sqlx::query_as::<_, DbSession>("SELECT * FROM connect_sessions WHERE id = $1")
.bind(session_id)
.fetch_optional(pool)
.await
}
/// Get active sessions for a machine
pub async fn get_active_sessions_for_machine(
pool: &PgPool,
machine_id: Uuid,
) -> Result<Vec<DbSession>, sqlx::Error> {
sqlx::query_as::<_, DbSession>(
"SELECT * FROM connect_sessions WHERE machine_id = $1 AND status = 'active' ORDER BY started_at DESC"
)
.bind(machine_id)
.fetch_all(pool)
.await
}
/// Get recent sessions (for dashboard)
pub async fn get_recent_sessions(
pool: &PgPool,
limit: i64,
) -> Result<Vec<DbSession>, sqlx::Error> {
sqlx::query_as::<_, DbSession>(
"SELECT * FROM connect_sessions ORDER BY started_at DESC LIMIT $1"
)
.bind(limit)
.fetch_all(pool)
.await
}

View File

@@ -0,0 +1,141 @@
//! Support code database operations
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
/// Support code record from database
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct DbSupportCode {
pub id: Uuid,
pub code: String,
pub session_id: Option<Uuid>,
pub created_by: String,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub status: String,
pub client_name: Option<String>,
pub client_machine: Option<String>,
pub connected_at: Option<DateTime<Utc>>,
}
/// Create a new support code
pub async fn create_support_code(
pool: &PgPool,
code: &str,
created_by: &str,
) -> Result<DbSupportCode, sqlx::Error> {
sqlx::query_as::<_, DbSupportCode>(
r#"
INSERT INTO connect_support_codes (code, created_by, status)
VALUES ($1, $2, 'pending')
RETURNING *
"#,
)
.bind(code)
.bind(created_by)
.fetch_one(pool)
.await
}
/// Get support code by code string
pub async fn get_support_code(pool: &PgPool, code: &str) -> Result<Option<DbSupportCode>, sqlx::Error> {
sqlx::query_as::<_, DbSupportCode>(
"SELECT * FROM connect_support_codes WHERE code = $1"
)
.bind(code)
.fetch_optional(pool)
.await
}
/// Update support code when client connects
pub async fn mark_code_connected(
pool: &PgPool,
code: &str,
session_id: Option<Uuid>,
client_name: Option<&str>,
client_machine: Option<&str>,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE connect_support_codes SET
status = 'connected',
session_id = $1,
client_name = $2,
client_machine = $3,
connected_at = NOW()
WHERE code = $4
"#,
)
.bind(session_id)
.bind(client_name)
.bind(client_machine)
.bind(code)
.execute(pool)
.await?;
Ok(())
}
/// Mark support code as completed
pub async fn mark_code_completed(pool: &PgPool, code: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE connect_support_codes SET status = 'completed' WHERE code = $1")
.bind(code)
.execute(pool)
.await?;
Ok(())
}
/// Mark support code as cancelled
pub async fn mark_code_cancelled(pool: &PgPool, code: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE connect_support_codes SET status = 'cancelled' WHERE code = $1")
.bind(code)
.execute(pool)
.await?;
Ok(())
}
/// Get active support codes (pending or connected)
pub async fn get_active_support_codes(pool: &PgPool) -> Result<Vec<DbSupportCode>, sqlx::Error> {
sqlx::query_as::<_, DbSupportCode>(
"SELECT * FROM connect_support_codes WHERE status IN ('pending', 'connected') ORDER BY created_at DESC"
)
.fetch_all(pool)
.await
}
/// Check if code exists and is valid for connection
pub async fn is_code_valid(pool: &PgPool, code: &str) -> Result<bool, sqlx::Error> {
let result = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM connect_support_codes WHERE code = $1 AND status = 'pending')"
)
.bind(code)
.fetch_one(pool)
.await?;
Ok(result)
}
/// Check if code is cancelled
pub async fn is_code_cancelled(pool: &PgPool, code: &str) -> Result<bool, sqlx::Error> {
let result = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM connect_support_codes WHERE code = $1 AND status = 'cancelled')"
)
.bind(code)
.fetch_one(pool)
.await?;
Ok(result)
}
/// Link session to support code
pub async fn link_session_to_code(
pool: &PgPool,
code: &str,
session_id: Uuid,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE connect_support_codes SET session_id = $1 WHERE code = $2")
.bind(session_id)
.bind(code)
.execute(pool)
.await?;
Ok(())
}