Add VPN configuration tools and agent documentation

Created comprehensive VPN setup tooling for Peaceful Spirit L2TP/IPsec connection
and enhanced agent documentation framework.

VPN Configuration (PST-NW-VPN):
- Setup-PST-L2TP-VPN.ps1: Automated L2TP/IPsec setup with split-tunnel and DNS
- Connect-PST-VPN.ps1: Connection helper with PPP adapter detection, DNS (192.168.0.2), and route config (192.168.0.0/24)
- Connect-PST-VPN-Standalone.ps1: Self-contained connection script for remote deployment
- Fix-PST-VPN-Auth.ps1: Authentication troubleshooting for CHAP/MSChapv2
- Diagnose-VPN-Interface.ps1: Comprehensive VPN interface and routing diagnostic
- Quick-Test-VPN.ps1: Fast connectivity verification (DNS/router/routes)
- Add-PST-VPN-Route-Manual.ps1: Manual route configuration helper
- vpn-connect.bat, vpn-disconnect.bat: Simple batch file shortcuts
- OpenVPN config files (Windows-compatible, abandoned for L2TP)

Key VPN Implementation Details:
- L2TP creates PPP adapter with connection name as interface description
- UniFi auto-configures DNS (192.168.0.2) but requires manual route to 192.168.0.0/24
- Split-tunnel enabled (only remote traffic through VPN)
- All-user connection for pre-login auto-connect via scheduled task
- Authentication: CHAP + MSChapv2 for UniFi compatibility

Agent Documentation:
- AGENT_QUICK_REFERENCE.md: Quick reference for all specialized agents
- documentation-squire.md: Documentation and task management specialist agent
- Updated all agent markdown files with standardized formatting

Project Organization:
- Moved conversation logs to dedicated directories (guru-connect-conversation-logs, guru-rmm-conversation-logs)
- Cleaned up old session JSONL files from projects/msp-tools/
- Added guru-connect infrastructure (agent, dashboard, proto, scripts, .gitea workflows)
- Added guru-rmm server components and deployment configs

Technical Notes:
- VPN IP pool: 192.168.4.x (client gets 192.168.4.6)
- Remote network: 192.168.0.0/24 (router at 192.168.0.10)
- PSK: rrClvnmUeXEFo90Ol+z7tfsAZHeSK6w7
- Credentials: pst-admin / 24Hearts$

Files: 15 VPN scripts, 2 agent docs, conversation log reorganization,
guru-connect/guru-rmm infrastructure additions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-18 11:51:47 -07:00
parent b0a68d89bf
commit 6c316aa701
272 changed files with 37068 additions and 2 deletions

View File

@@ -0,0 +1,299 @@
//! Transport layer for agent-server communication
//!
//! Handles WebSocket connection to the GuruRMM server with:
//! - Auto-reconnection on disconnect
//! - Authentication via API key
//! - Sending metrics and receiving commands
//! - Heartbeat to maintain connection
mod websocket;
pub use websocket::WebSocketClient;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Messages sent from agent to server
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "payload")]
#[serde(rename_all = "snake_case")]
pub enum AgentMessage {
/// Authentication message (sent on connect)
Auth(AuthPayload),
/// Metrics report
Metrics(crate::metrics::SystemMetrics),
/// Network state update (sent on connect and when interfaces change)
NetworkState(crate::metrics::NetworkState),
/// Command execution result
CommandResult(CommandResultPayload),
/// Watchdog event (service stopped, restarted, etc.)
WatchdogEvent(WatchdogEventPayload),
/// Update result (success, failure, rollback)
UpdateResult(UpdateResultPayload),
/// Heartbeat to keep connection alive
Heartbeat,
}
/// Authentication payload
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthPayload {
/// API key for this agent (or site)
pub api_key: String,
/// Unique device identifier (hardware-derived)
pub device_id: String,
/// Hostname of this machine
pub hostname: String,
/// Operating system type
pub os_type: String,
/// Operating system version
pub os_version: String,
/// Agent version
pub agent_version: String,
/// Architecture (amd64, arm64, etc.)
#[serde(default = "default_arch")]
pub architecture: String,
/// Previous version if reconnecting after update
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_version: Option<String>,
/// Update ID if reconnecting after update
#[serde(skip_serializing_if = "Option::is_none")]
pub pending_update_id: Option<Uuid>,
}
fn default_arch() -> String {
#[cfg(target_arch = "x86_64")]
{ "amd64".to_string() }
#[cfg(target_arch = "aarch64")]
{ "arm64".to_string() }
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
{ "unknown".to_string() }
}
/// Command execution result payload
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandResultPayload {
/// Command ID (from the server)
pub command_id: Uuid,
/// Exit code (0 = success)
pub exit_code: i32,
/// Standard output
pub stdout: String,
/// Standard error
pub stderr: String,
/// Execution duration in milliseconds
pub duration_ms: u64,
}
/// Watchdog event payload
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchdogEventPayload {
/// Service or process name
pub name: String,
/// Event type
pub event: WatchdogEvent,
/// Additional details
pub details: Option<String>,
}
/// Types of watchdog events
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WatchdogEvent {
/// Service/process was found stopped
Stopped,
/// Service/process was restarted by the agent
Restarted,
/// Restart attempt failed
RestartFailed,
/// Max restart attempts reached
MaxRestartsReached,
/// Service/process recovered on its own
Recovered,
}
/// Messages sent from server to agent
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "payload")]
#[serde(rename_all = "snake_case")]
pub enum ServerMessage {
/// Authentication acknowledgment
AuthAck(AuthAckPayload),
/// Command to execute
Command(CommandPayload),
/// Configuration update
ConfigUpdate(ConfigUpdatePayload),
/// Agent update command
Update(UpdatePayload),
/// Acknowledgment of received message
Ack { message_id: Option<String> },
/// Error message
Error { code: String, message: String },
}
/// Authentication acknowledgment payload
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthAckPayload {
/// Whether authentication was successful
pub success: bool,
/// Agent ID assigned by server
pub agent_id: Option<Uuid>,
/// Error message if authentication failed
pub error: Option<String>,
}
/// Command payload from server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandPayload {
/// Unique command ID
pub id: Uuid,
/// Type of command
pub command_type: CommandType,
/// Command text to execute
pub command: String,
/// Optional timeout in seconds
pub timeout_seconds: Option<u64>,
/// Whether to run as elevated/admin
pub elevated: bool,
}
/// Types of commands
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandType {
/// Shell command (cmd on Windows, bash on Unix)
Shell,
/// PowerShell command (Windows)
PowerShell,
/// Python script
Python,
/// Raw script (requires interpreter path)
Script { interpreter: String },
}
/// Configuration update payload
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigUpdatePayload {
/// New metrics interval (if changed)
pub metrics_interval_seconds: Option<u64>,
/// Updated watchdog config
pub watchdog: Option<WatchdogConfigUpdate>,
}
/// Watchdog configuration update
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchdogConfigUpdate {
/// Enable/disable watchdog
pub enabled: Option<bool>,
/// Check interval
pub check_interval_seconds: Option<u64>,
// Services and processes would be included here for remote config updates
}
/// Update command payload from server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdatePayload {
/// Unique update ID for tracking
pub update_id: Uuid,
/// Target version to update to
pub target_version: String,
/// Download URL for the new binary
pub download_url: String,
/// SHA256 checksum of the binary
pub checksum_sha256: String,
/// Whether to force update (skip version check)
#[serde(default)]
pub force: bool,
}
/// Update result payload sent back to server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateResultPayload {
/// Update ID (from the server)
pub update_id: Uuid,
/// Update status
pub status: UpdateStatus,
/// Old version before update
pub old_version: String,
/// New version after update (if successful)
pub new_version: Option<String>,
/// Error message if failed
pub error: Option<String>,
}
/// Update status codes
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UpdateStatus {
/// Update starting
Starting,
/// Downloading new binary
Downloading,
/// Download complete, verifying
Verifying,
/// Installing (replacing binary)
Installing,
/// Restarting service
Restarting,
/// Update completed successfully
Completed,
/// Update failed
Failed,
/// Rolled back to previous version
RolledBack,
}

View File

@@ -0,0 +1,439 @@
//! WebSocket client for server communication
//!
//! Handles the WebSocket connection lifecycle including:
//! - Connection establishment
//! - Authentication handshake
//! - Message sending/receiving
//! - Heartbeat maintenance
//! - Command handling
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use tokio::time::{interval, timeout};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tracing::{debug, error, info, warn};
use super::{AgentMessage, AuthPayload, CommandPayload, ServerMessage, UpdatePayload, UpdateResultPayload, UpdateStatus};
use crate::metrics::NetworkState;
use crate::updater::{AgentUpdater, UpdaterConfig};
use crate::AppState;
/// WebSocket client for communicating with the GuruRMM server
pub struct WebSocketClient;
impl WebSocketClient {
/// Connect to the server and run the message loop
///
/// This function will return when the connection is closed or an error occurs.
/// The caller should handle reconnection logic.
pub async fn connect_and_run(state: Arc<AppState>) -> Result<()> {
let url = &state.config.server.url;
// Connect to WebSocket server
info!("Connecting to {}", url);
let (ws_stream, response) = connect_async(url)
.await
.context("Failed to connect to WebSocket server")?;
info!(
"WebSocket connected (HTTP status: {})",
response.status()
);
let (mut write, mut read) = ws_stream.split();
// Check for pending update (from previous update attempt)
let updater_config = UpdaterConfig::default();
let pending_update = AgentUpdater::load_pending_update(&updater_config).await;
// If we have pending update info, we just restarted after an update
let (previous_version, pending_update_id) = if let Some(ref info) = pending_update {
info!(
"Found pending update info: {} -> {} (id: {})",
info.old_version, info.target_version, info.update_id
);
(Some(info.old_version.clone()), Some(info.update_id))
} else {
(None, None)
};
// Send authentication message
let auth_msg = AgentMessage::Auth(AuthPayload {
api_key: state.config.server.api_key.clone(),
device_id: crate::device_id::get_device_id(),
hostname: state.config.get_hostname(),
os_type: std::env::consts::OS.to_string(),
os_version: sysinfo::System::os_version().unwrap_or_else(|| "unknown".to_string()),
agent_version: env!("CARGO_PKG_VERSION").to_string(),
architecture: Self::get_architecture().to_string(),
previous_version,
pending_update_id,
});
let auth_json = serde_json::to_string(&auth_msg)?;
write.send(Message::Text(auth_json)).await?;
debug!("Sent authentication message");
// Wait for auth response with timeout
let auth_response = timeout(Duration::from_secs(10), read.next())
.await
.context("Authentication timeout")?
.ok_or_else(|| anyhow::anyhow!("Connection closed before auth response"))?
.context("Failed to receive auth response")?;
// Parse auth response
if let Message::Text(text) = auth_response {
let server_msg: ServerMessage =
serde_json::from_str(&text).context("Failed to parse auth response")?;
match server_msg {
ServerMessage::AuthAck(ack) => {
if ack.success {
info!("Authentication successful, agent_id: {:?}", ack.agent_id);
*state.connected.write().await = true;
// Send initial network state immediately after auth
let network_state = NetworkState::collect();
info!(
"Sending initial network state ({} interfaces)",
network_state.interfaces.len()
);
let network_msg = AgentMessage::NetworkState(network_state);
let network_json = serde_json::to_string(&network_msg)?;
write.send(Message::Text(network_json)).await?;
} else {
error!("Authentication failed: {:?}", ack.error);
return Err(anyhow::anyhow!(
"Authentication failed: {}",
ack.error.unwrap_or_else(|| "Unknown error".to_string())
));
}
}
ServerMessage::Error { code, message } => {
error!("Server error during auth: {} - {}", code, message);
return Err(anyhow::anyhow!("Server error: {} - {}", code, message));
}
_ => {
warn!("Unexpected message during auth: {:?}", server_msg);
}
}
}
// Create channel for outgoing messages
let (tx, mut rx) = mpsc::channel::<AgentMessage>(100);
// Spawn metrics sender task
let metrics_tx = tx.clone();
let metrics_state = Arc::clone(&state);
let metrics_interval = state.config.metrics.interval_seconds;
let metrics_task = tokio::spawn(async move {
let mut timer = interval(Duration::from_secs(metrics_interval));
loop {
timer.tick().await;
let metrics = metrics_state.metrics_collector.collect().await;
if metrics_tx.send(AgentMessage::Metrics(metrics)).await.is_err() {
debug!("Metrics channel closed");
break;
}
}
});
// Spawn network state monitor task (checks for changes every 30 seconds)
let network_tx = tx.clone();
let network_task = tokio::spawn(async move {
// Check for network changes every 30 seconds
let mut timer = interval(Duration::from_secs(30));
let mut last_state = NetworkState::collect();
loop {
timer.tick().await;
let current_state = NetworkState::collect();
if current_state.has_changed(&last_state) {
info!(
"Network state changed (hash: {} -> {}), sending update",
last_state.state_hash, current_state.state_hash
);
// Log the changes for debugging
for iface in &current_state.interfaces {
debug!(
" Interface {}: IPv4={:?}",
iface.name, iface.ipv4_addresses
);
}
if network_tx
.send(AgentMessage::NetworkState(current_state.clone()))
.await
.is_err()
{
debug!("Network channel closed");
break;
}
last_state = current_state;
}
}
});
// Spawn heartbeat task
let heartbeat_tx = tx.clone();
let heartbeat_task = tokio::spawn(async move {
let mut timer = interval(Duration::from_secs(30));
loop {
timer.tick().await;
if heartbeat_tx.send(AgentMessage::Heartbeat).await.is_err() {
debug!("Heartbeat channel closed");
break;
}
}
});
// Main message loop
let result: Result<()> = loop {
tokio::select! {
// Handle outgoing messages
Some(msg) = rx.recv() => {
let json = serde_json::to_string(&msg)?;
if let Err(e) = write.send(Message::Text(json)).await {
break Err(e.into());
}
match &msg {
AgentMessage::Metrics(m) => {
debug!("Sent metrics: CPU={:.1}%", m.cpu_percent);
}
AgentMessage::NetworkState(n) => {
debug!("Sent network state: {} interfaces, hash={}",
n.interfaces.len(), n.state_hash);
}
AgentMessage::Heartbeat => {
debug!("Sent heartbeat");
}
_ => {
debug!("Sent message: {:?}", std::mem::discriminant(&msg));
}
}
}
// Handle incoming messages
Some(msg_result) = read.next() => {
match msg_result {
Ok(Message::Text(text)) => {
if let Err(e) = Self::handle_server_message(&text, &tx).await {
error!("Error handling message: {}", e);
}
}
Ok(Message::Ping(data)) => {
if let Err(e) = write.send(Message::Pong(data)).await {
break Err(e.into());
}
}
Ok(Message::Pong(_)) => {
debug!("Received pong");
}
Ok(Message::Close(frame)) => {
info!("Server closed connection: {:?}", frame);
break Ok(());
}
Ok(Message::Binary(_)) => {
warn!("Received unexpected binary message");
}
Ok(Message::Frame(_)) => {
// Raw frame, usually not seen
}
Err(e) => {
error!("WebSocket error: {}", e);
break Err(e.into());
}
}
}
// Connection timeout (no activity)
_ = tokio::time::sleep(Duration::from_secs(90)) => {
warn!("Connection timeout, no activity for 90 seconds");
break Err(anyhow::anyhow!("Connection timeout"));
}
}
};
// Cleanup
metrics_task.abort();
network_task.abort();
heartbeat_task.abort();
*state.connected.write().await = false;
result
}
/// Handle a message received from the server
async fn handle_server_message(
text: &str,
tx: &mpsc::Sender<AgentMessage>,
) -> Result<()> {
let msg: ServerMessage =
serde_json::from_str(text).context("Failed to parse server message")?;
match msg {
ServerMessage::Command(cmd) => {
info!("Received command: {:?} (id: {})", cmd.command_type, cmd.id);
Self::execute_command(cmd, tx.clone()).await;
}
ServerMessage::ConfigUpdate(update) => {
info!("Received config update: {:?}", update);
// Config updates will be handled in a future phase
}
ServerMessage::Ack { message_id } => {
debug!("Received ack for message: {:?}", message_id);
}
ServerMessage::AuthAck(_) => {
// Already handled during initial auth
}
ServerMessage::Error { code, message } => {
error!("Server error: {} - {}", code, message);
}
ServerMessage::Update(payload) => {
info!(
"Received update command: {} -> {} (id: {})",
env!("CARGO_PKG_VERSION"),
payload.target_version,
payload.update_id
);
Self::handle_update(payload, tx.clone()).await;
}
}
Ok(())
}
/// Handle an update command from the server
async fn handle_update(payload: UpdatePayload, tx: mpsc::Sender<AgentMessage>) {
// Send starting status
let starting_result = UpdateResultPayload {
update_id: payload.update_id,
status: UpdateStatus::Starting,
old_version: env!("CARGO_PKG_VERSION").to_string(),
new_version: None,
error: None,
};
let _ = tx.send(AgentMessage::UpdateResult(starting_result)).await;
// Spawn update in background (it will restart the service)
tokio::spawn(async move {
let config = UpdaterConfig::default();
let updater = AgentUpdater::new(config);
let result = updater.perform_update(payload).await;
// If we reach here, the update failed (successful update restarts the process)
let _ = tx.send(AgentMessage::UpdateResult(result)).await;
});
}
/// Get the current architecture
fn get_architecture() -> &'static str {
#[cfg(target_arch = "x86_64")]
{ "amd64" }
#[cfg(target_arch = "aarch64")]
{ "arm64" }
#[cfg(target_arch = "x86")]
{ "386" }
#[cfg(target_arch = "arm")]
{ "arm" }
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "x86", target_arch = "arm")))]
{ "unknown" }
}
/// Execute a command received from the server
async fn execute_command(cmd: CommandPayload, tx: mpsc::Sender<AgentMessage>) {
let command_id = cmd.id;
// Spawn command execution in background
tokio::spawn(async move {
let start = std::time::Instant::now();
let result = Self::run_command(&cmd).await;
let duration_ms = start.elapsed().as_millis() as u64;
let (exit_code, stdout, stderr) = match result {
Ok((code, out, err)) => (code, out, err),
Err(e) => (-1, String::new(), format!("Execution error: {}", e)),
};
let result_msg = AgentMessage::CommandResult(super::CommandResultPayload {
command_id,
exit_code,
stdout,
stderr,
duration_ms,
});
if tx.send(result_msg).await.is_err() {
error!("Failed to send command result");
}
});
}
/// Run a command and capture output
async fn run_command(cmd: &CommandPayload) -> Result<(i32, String, String)> {
use tokio::process::Command;
let timeout_secs = cmd.timeout_seconds.unwrap_or(300); // 5 minute default
let mut command = match &cmd.command_type {
super::CommandType::Shell => {
#[cfg(windows)]
{
let mut c = Command::new("cmd");
c.args(["/C", &cmd.command]);
c
}
#[cfg(unix)]
{
let mut c = Command::new("sh");
c.args(["-c", &cmd.command]);
c
}
}
super::CommandType::PowerShell => {
let mut c = Command::new("powershell");
c.args(["-NoProfile", "-NonInteractive", "-Command", &cmd.command]);
c
}
super::CommandType::Python => {
let mut c = Command::new("python");
c.args(["-c", &cmd.command]);
c
}
super::CommandType::Script { interpreter } => {
let mut c = Command::new(interpreter);
c.args(["-c", &cmd.command]);
c
}
};
// Capture output
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
// Execute with timeout
let output = timeout(Duration::from_secs(timeout_secs), command.output())
.await
.context("Command timeout")?
.context("Failed to execute command")?;
let exit_code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok((exit_code, stdout, stderr))
}
}