Initial GuruConnect implementation - Phase 1 MVP

- Agent: DXGI/GDI screen capture, mouse/keyboard input, WebSocket transport
- Server: Axum relay, session management, REST API
- Dashboard: React viewer components with TypeScript
- Protocol: Protobuf definitions for all message types

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AZ Computer Guru
2025-12-21 17:18:05 -07:00
commit 33893ea73b
38 changed files with 7724 additions and 0 deletions

62
server/Cargo.toml Normal file
View File

@@ -0,0 +1,62 @@
[package]
name = "guruconnect-server"
version = "0.1.0"
edition = "2021"
authors = ["AZ Computer Guru"]
description = "GuruConnect Remote Desktop Relay Server"
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full", "sync", "time", "rt-multi-thread", "macros"] }
# Web framework
axum = { version = "0.7", features = ["ws", "macros"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
# WebSocket
futures-util = "0.3"
# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }
# Protocol (protobuf)
prost = "0.13"
prost-types = "0.13"
bytes = "1"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Error handling
anyhow = "1"
thiserror = "1"
# Configuration
toml = "0.8"
# Auth
jsonwebtoken = "9"
argon2 = "0.5"
# Crypto
ring = "0.17"
# UUID
uuid = { version = "1", features = ["v4", "serde"] }
# Time
chrono = { version = "0.4", features = ["serde"] }
[build-dependencies]
prost-build = "0.13"
[profile.release]
lto = true
codegen-units = 1
strip = true

11
server/build.rs Normal file
View File

@@ -0,0 +1,11 @@
use std::io::Result;
fn main() -> Result<()> {
// Compile protobuf definitions
prost_build::compile_protos(&["../proto/guruconnect.proto"], &["../proto/"])?;
// Rerun if proto changes
println!("cargo:rerun-if-changed=../proto/guruconnect.proto");
Ok(())
}

54
server/src/api/mod.rs Normal file
View File

@@ -0,0 +1,54 @@
//! REST API endpoints
use axum::{
extract::{Path, State},
Json,
};
use serde::Serialize;
use uuid::Uuid;
use crate::session::SessionManager;
/// Session info returned by API
#[derive(Debug, Serialize)]
pub struct SessionInfo {
pub id: String,
pub agent_id: String,
pub agent_name: String,
pub started_at: String,
pub viewer_count: usize,
}
impl From<crate::session::Session> for SessionInfo {
fn from(s: crate::session::Session) -> Self {
Self {
id: s.id.to_string(),
agent_id: s.agent_id,
agent_name: s.agent_name,
started_at: s.started_at.to_rfc3339(),
viewer_count: s.viewer_count,
}
}
}
/// List all active sessions
pub async fn list_sessions(
State(sessions): State<SessionManager>,
) -> Json<Vec<SessionInfo>> {
let sessions = sessions.list_sessions().await;
Json(sessions.into_iter().map(SessionInfo::from).collect())
}
/// Get a specific session by ID
pub async fn get_session(
State(sessions): State<SessionManager>,
Path(id): Path<String>,
) -> Result<Json<SessionInfo>, (axum::http::StatusCode, &'static str)> {
let session_id = Uuid::parse_str(&id)
.map_err(|_| (axum::http::StatusCode::BAD_REQUEST, "Invalid session ID"))?;
let session = sessions.get_session(session_id).await
.ok_or((axum::http::StatusCode::NOT_FOUND, "Session not found"))?;
Ok(Json(SessionInfo::from(session)))
}

61
server/src/auth/mod.rs Normal file
View File

@@ -0,0 +1,61 @@
//! Authentication module
//!
//! Handles JWT validation for dashboard users and API key
//! validation for agents.
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
/// Authenticated user from JWT
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
pub user_id: String,
pub email: String,
pub roles: Vec<String>,
}
/// Authenticated agent from API key
#[derive(Debug, Clone)]
pub struct AuthenticatedAgent {
pub agent_id: String,
pub org_id: String,
}
/// Extract authenticated user from request (placeholder for MVP)
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// TODO: Implement JWT validation
// For MVP, accept any request
// Look for Authorization header
let _auth_header = parts
.headers
.get("Authorization")
.and_then(|v| v.to_str().ok());
// Placeholder - in production, validate JWT
Ok(AuthenticatedUser {
user_id: "mvp-user".to_string(),
email: "mvp@example.com".to_string(),
roles: vec!["admin".to_string()],
})
}
}
/// Validate an agent API key (placeholder for MVP)
pub fn validate_agent_key(_api_key: &str) -> Option<AuthenticatedAgent> {
// TODO: Implement actual API key validation
// For MVP, accept any key
Some(AuthenticatedAgent {
agent_id: "mvp-agent".to_string(),
org_id: "mvp-org".to_string(),
})
}

45
server/src/config.rs Normal file
View File

@@ -0,0 +1,45 @@
//! Server configuration
use anyhow::Result;
use serde::Deserialize;
use std::env;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
/// Address to listen on (e.g., "0.0.0.0:8080")
pub listen_addr: String,
/// Database URL (optional for MVP)
pub database_url: Option<String>,
/// JWT secret for authentication
pub jwt_secret: Option<String>,
/// Enable debug logging
pub debug: bool,
}
impl Config {
/// Load configuration from environment variables
pub fn load() -> Result<Self> {
Ok(Self {
listen_addr: env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()),
database_url: env::var("DATABASE_URL").ok(),
jwt_secret: env::var("JWT_SECRET").ok(),
debug: env::var("DEBUG")
.map(|v| v == "1" || v.to_lowercase() == "true")
.unwrap_or(false),
})
}
}
impl Default for Config {
fn default() -> Self {
Self {
listen_addr: "0.0.0.0:8080".to_string(),
database_url: None,
jwt_secret: None,
debug: false,
}
}
}

45
server/src/db/mod.rs Normal file
View File

@@ -0,0 +1,45 @@
//! Database module
//!
//! Handles session logging and persistence.
//! Optional for MVP - sessions are kept in memory only.
use anyhow::Result;
/// Database connection pool (placeholder)
#[derive(Clone)]
pub struct Database {
// TODO: Add sqlx pool when PostgreSQL is needed
_placeholder: (),
}
impl Database {
/// Initialize database connection
pub async fn init(_database_url: &str) -> Result<Self> {
// TODO: Initialize PostgreSQL connection pool
Ok(Self { _placeholder: () })
}
}
/// 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
Ok(())
}
}

82
server/src/main.rs Normal file
View File

@@ -0,0 +1,82 @@
//! GuruConnect Server - WebSocket Relay Server
//!
//! Handles connections from both agents and dashboard viewers,
//! relaying video frames and input events between them.
mod config;
mod relay;
mod session;
mod auth;
mod api;
mod db;
pub mod proto {
include!(concat!(env!("OUT_DIR"), "/guruconnect.rs"));
}
use anyhow::Result;
use axum::{
Router,
routing::get,
};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing::{info, Level};
use tracing_subscriber::FmtSubscriber;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
let _subscriber = FmtSubscriber::builder()
.with_max_level(Level::INFO)
.with_target(true)
.init();
info!("GuruConnect Server v{}", env!("CARGO_PKG_VERSION"));
// Load configuration
let config = config::Config::load()?;
info!("Loaded configuration, listening on {}", config.listen_addr);
// Initialize database connection (optional for MVP)
// let db = db::init(&config.database_url).await?;
// Create session manager
let sessions = session::SessionManager::new();
// Build router
let app = Router::new()
// Health check
.route("/health", get(health))
// WebSocket endpoints
.route("/ws/agent", get(relay::agent_ws_handler))
.route("/ws/viewer", get(relay::viewer_ws_handler))
// REST API
.route("/api/sessions", get(api::list_sessions))
.route("/api/sessions/:id", get(api::get_session))
// State
.with_state(sessions)
// Middleware
.layer(TraceLayer::new_for_http())
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
);
// Start server
let addr: SocketAddr = config.listen_addr.parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("Server listening on {}", addr);
axum::serve(listener, app).await?;
Ok(())
}
async fn health() -> &'static str {
"OK"
}

194
server/src/relay/mod.rs Normal file
View File

@@ -0,0 +1,194 @@
//! WebSocket relay handlers
//!
//! Handles WebSocket connections from agents and viewers,
//! relaying video frames and input events between them.
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
Query, State,
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use prost::Message as ProstMessage;
use serde::Deserialize;
use tracing::{error, info, warn};
use crate::proto;
use crate::session::SessionManager;
#[derive(Debug, Deserialize)]
pub struct AgentParams {
agent_id: String,
#[serde(default)]
agent_name: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ViewerParams {
session_id: String,
}
/// WebSocket handler for agent connections
pub async fn agent_ws_handler(
ws: WebSocketUpgrade,
State(sessions): State<SessionManager>,
Query(params): Query<AgentParams>,
) -> impl IntoResponse {
let agent_id = params.agent_id;
let agent_name = params.agent_name.unwrap_or_else(|| agent_id.clone());
ws.on_upgrade(move |socket| handle_agent_connection(socket, sessions, agent_id, agent_name))
}
/// WebSocket handler for viewer connections
pub async fn viewer_ws_handler(
ws: WebSocketUpgrade,
State(sessions): State<SessionManager>,
Query(params): Query<ViewerParams>,
) -> impl IntoResponse {
let session_id = params.session_id;
ws.on_upgrade(move |socket| handle_viewer_connection(socket, sessions, session_id))
}
/// Handle an agent WebSocket connection
async fn handle_agent_connection(
socket: WebSocket,
sessions: SessionManager,
agent_id: String,
agent_name: String,
) {
info!("Agent connected: {} ({})", agent_name, agent_id);
// Register the agent and get channels
let (session_id, frame_tx, mut input_rx) = sessions.register_agent(agent_id.clone(), agent_name.clone()).await;
info!("Session created: {}", session_id);
let (mut ws_sender, mut ws_receiver) = socket.split();
// Task to forward input events from viewers to agent
let input_forward = tokio::spawn(async move {
while let Some(input_data) = input_rx.recv().await {
if ws_sender.send(Message::Binary(input_data.into())).await.is_err() {
break;
}
}
});
// Main loop: receive frames from agent and broadcast to viewers
while let Some(msg) = ws_receiver.next().await {
match msg {
Ok(Message::Binary(data)) => {
// Try to decode as protobuf message
match proto::Message::decode(data.as_ref()) {
Ok(proto_msg) => {
if let Some(proto::message::Payload::VideoFrame(_)) = &proto_msg.payload {
// Broadcast frame to all viewers
let _ = frame_tx.send(data.to_vec());
}
}
Err(e) => {
warn!("Failed to decode agent message: {}", e);
}
}
}
Ok(Message::Close(_)) => {
info!("Agent disconnected: {}", agent_id);
break;
}
Ok(Message::Ping(data)) => {
// Pong is handled automatically by axum
let _ = data;
}
Ok(_) => {}
Err(e) => {
error!("WebSocket error from agent {}: {}", agent_id, e);
break;
}
}
}
// Cleanup
input_forward.abort();
sessions.remove_session(session_id).await;
info!("Session {} ended", session_id);
}
/// Handle a viewer WebSocket connection
async fn handle_viewer_connection(
socket: WebSocket,
sessions: SessionManager,
session_id_str: String,
) {
// Parse session ID
let session_id = match uuid::Uuid::parse_str(&session_id_str) {
Ok(id) => id,
Err(_) => {
warn!("Invalid session ID: {}", session_id_str);
return;
}
};
// Join the session
let (mut frame_rx, input_tx) = match sessions.join_session(session_id).await {
Some(channels) => channels,
None => {
warn!("Session not found: {}", session_id);
return;
}
};
info!("Viewer joined session: {}", session_id);
let (mut ws_sender, mut ws_receiver) = socket.split();
// Task to forward frames from agent to this viewer
let frame_forward = tokio::spawn(async move {
while let Ok(frame_data) = frame_rx.recv().await {
if ws_sender.send(Message::Binary(frame_data.into())).await.is_err() {
break;
}
}
});
// Main loop: receive input from viewer and forward to agent
while let Some(msg) = ws_receiver.next().await {
match msg {
Ok(Message::Binary(data)) => {
// Try to decode as protobuf message
match proto::Message::decode(data.as_ref()) {
Ok(proto_msg) => {
match &proto_msg.payload {
Some(proto::message::Payload::MouseEvent(_)) |
Some(proto::message::Payload::KeyEvent(_)) => {
// Forward input to agent
let _ = input_tx.send(data.to_vec()).await;
}
_ => {}
}
}
Err(e) => {
warn!("Failed to decode viewer message: {}", e);
}
}
}
Ok(Message::Close(_)) => {
info!("Viewer disconnected from session: {}", session_id);
break;
}
Ok(_) => {}
Err(e) => {
error!("WebSocket error from viewer: {}", e);
break;
}
}
}
// Cleanup
frame_forward.abort();
sessions.leave_session(session_id).await;
info!("Viewer left session: {}", session_id);
}

148
server/src/session/mod.rs Normal file
View File

@@ -0,0 +1,148 @@
//! Session management for GuruConnect
//!
//! Manages active remote desktop sessions, tracking which agents
//! are connected and which viewers are watching them.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
use uuid::Uuid;
/// Unique identifier for a session
pub type SessionId = Uuid;
/// Unique identifier for an agent
pub type AgentId = String;
/// Session state
#[derive(Debug, Clone)]
pub struct Session {
pub id: SessionId,
pub agent_id: AgentId,
pub agent_name: String,
pub started_at: chrono::DateTime<chrono::Utc>,
pub viewer_count: usize,
}
/// Channel for sending frames from agent to viewers
pub type FrameSender = broadcast::Sender<Vec<u8>>;
pub type FrameReceiver = broadcast::Receiver<Vec<u8>>;
/// Channel for sending input events from viewer to agent
pub type InputSender = tokio::sync::mpsc::Sender<Vec<u8>>;
pub type InputReceiver = tokio::sync::mpsc::Receiver<Vec<u8>>;
/// Internal session data with channels
struct SessionData {
info: Session,
/// Channel for video frames (agent -> viewers)
frame_tx: FrameSender,
/// Channel for input events (viewer -> agent)
input_tx: InputSender,
input_rx: Option<InputReceiver>,
}
/// Manages all active sessions
#[derive(Clone)]
pub struct SessionManager {
sessions: Arc<RwLock<HashMap<SessionId, SessionData>>>,
agents: Arc<RwLock<HashMap<AgentId, SessionId>>>,
}
impl SessionManager {
pub fn new() -> Self {
Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
agents: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Register a new agent and create a session
pub async fn register_agent(&self, agent_id: AgentId, agent_name: String) -> (SessionId, FrameSender, InputReceiver) {
let session_id = Uuid::new_v4();
// Create channels
let (frame_tx, _) = broadcast::channel(16); // Buffer 16 frames
let (input_tx, input_rx) = tokio::sync::mpsc::channel(64); // Buffer 64 input events
let session = Session {
id: session_id,
agent_id: agent_id.clone(),
agent_name,
started_at: chrono::Utc::now(),
viewer_count: 0,
};
let session_data = SessionData {
info: session,
frame_tx: frame_tx.clone(),
input_tx,
input_rx: None, // Will be taken by the agent handler
};
let mut sessions = self.sessions.write().await;
sessions.insert(session_id, session_data);
let mut agents = self.agents.write().await;
agents.insert(agent_id, session_id);
(session_id, frame_tx, input_rx)
}
/// Get a session by agent ID
pub async fn get_session_by_agent(&self, agent_id: &str) -> Option<Session> {
let agents = self.agents.read().await;
let session_id = agents.get(agent_id)?;
let sessions = self.sessions.read().await;
sessions.get(session_id).map(|s| s.info.clone())
}
/// Get a session by session ID
pub async fn get_session(&self, session_id: SessionId) -> Option<Session> {
let sessions = self.sessions.read().await;
sessions.get(&session_id).map(|s| s.info.clone())
}
/// Join a session as a viewer
pub async fn join_session(&self, session_id: SessionId) -> Option<(FrameReceiver, InputSender)> {
let mut sessions = self.sessions.write().await;
let session_data = sessions.get_mut(&session_id)?;
session_data.info.viewer_count += 1;
let frame_rx = session_data.frame_tx.subscribe();
let input_tx = session_data.input_tx.clone();
Some((frame_rx, input_tx))
}
/// Leave a session as a viewer
pub async fn leave_session(&self, session_id: SessionId) {
let mut sessions = self.sessions.write().await;
if let Some(session_data) = sessions.get_mut(&session_id) {
session_data.info.viewer_count = session_data.info.viewer_count.saturating_sub(1);
}
}
/// Remove a session (when agent disconnects)
pub async fn remove_session(&self, session_id: SessionId) {
let mut sessions = self.sessions.write().await;
if let Some(session_data) = sessions.remove(&session_id) {
let mut agents = self.agents.write().await;
agents.remove(&session_data.info.agent_id);
}
}
/// List all active sessions
pub async fn list_sessions(&self) -> Vec<Session> {
let sessions = self.sessions.read().await;
sessions.values().map(|s| s.info.clone()).collect()
}
}
impl Default for SessionManager {
fn default() -> Self {
Self::new()
}
}