Merge branch 'main' of ssh://172.16.3.20:2222/azcomputerguru/guru-connect

This commit is contained in:
AZ Computer Guru
2025-12-28 12:39:47 -07:00
10 changed files with 2203 additions and 17 deletions

19
.cargo/config.toml Normal file
View File

@@ -0,0 +1,19 @@
# GuruConnect Cargo Configuration (Windows Development)
# Default to 64-bit Windows MSVC for local dev
[build]
target = "x86_64-pc-windows-msvc"
# Build aliases for convenience
[alias]
# Build 64-bit release
b64 = "build --release --target x86_64-pc-windows-msvc"
# Build 32-bit release
b32 = "build --release --target i686-pc-windows-msvc"
# Target-specific settings - static CRT for standalone binaries
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
[target.i686-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]

30
Cargo.lock generated
View File

@@ -691,6 +691,7 @@ dependencies = [
"prost",
"prost-build",
"prost-types",
"rand",
"ring",
"serde",
"serde_json",
@@ -814,6 +815,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1155,6 +1162,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2419,8 +2436,15 @@ dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
@@ -2528,6 +2552,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-bidi"
version = "0.3.18"

692
REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,692 @@
# GuruConnect Requirements
## Design Principles
1. **End-user simplicity** - One-click or code-based session joining
2. **Standalone capable** - Works independently, integrates with GuruRMM optionally
3. **Technician-centric** - Built for MSP workflows
---
## End-User Portal (connect.azcomputerguru.com)
### Unauthenticated View
When a user visits the portal without being logged in:
```
┌─────────────────────────────────────────────────────┐
│ │
│ [Company Logo] │
│ │
│ Enter your support code: │
│ ┌─────────────────────────┐ │
│ │ 8 4 7 2 9 1 │ │
│ └─────────────────────────┘ │
│ │
│ [ Connect ] │
│ │
│ ───────────────────────────────────────────────── │
│ │
│ Instructions will appear here after clicking │
│ Connect, based on your browser. │
│ │
└─────────────────────────────────────────────────────┘
```
### Connection Flow
1. **User enters code** → Click "Connect"
2. **Server validates code** → Returns session info or error
3. **Attempt app launch** via custom protocol:
- `guruconnect://session/{code}`
- If app is installed, it launches and connects
4. **If app doesn't launch** (timeout ~3 seconds):
- Auto-download small EXE (`GuruConnect-{code}.exe`)
- Show browser-specific instructions
### Browser-Specific Instructions
Detect browser via User-Agent and show appropriate guidance:
**Chrome:**
> "Click the download in the bottom-left corner of your screen, then click 'Open'"
> [Screenshot of Chrome download bar]
**Firefox:**
> "Click 'Save File', then open your Downloads folder and double-click the file"
> [Screenshot of Firefox download dialog]
**Edge:**
> "Click 'Open file' in the download notification at the top of your screen"
> [Screenshot of Edge download prompt]
**Safari:**
> "Click the download icon in the toolbar, then double-click the file"
> [Screenshot of Safari downloads]
**Generic/Unknown:**
> "Your download should start automatically. Look for the file in your Downloads folder and double-click to run it."
### Custom Protocol Handler
**Protocol:** `guruconnect://`
**Format:** `guruconnect://session/{code}`
**Registration:**
- Permanent agent registers protocol handler on install
- One-time agent does NOT register (to avoid clutter)
**Behavior:**
- If registered: OS launches installed agent with session code
- If not registered: Browser shows "nothing happened" → triggers download fallback
### One-Time Session Agent (Temp/Support)
**Key Requirements:**
- Runs in **user space** - NO admin elevation required
- Downloads as `GuruConnect-{code}.exe` (code baked in)
- ~3-5MB executable
- Self-contained (no installer, no dependencies)
- Connects directly to session on launch
- Self-deletes after session ends (or on next reboot)
**Elevation Note:**
- Basic screen sharing works without admin
- Some features (input to elevated windows, UAC dialogs) need admin
- Show optional "Run as Administrator" button for full access
---
## Technician Dashboard (Logged-In View)
### Visual Style
Follow GuruRMM dashboard design:
- HSL CSS variables for theming (dark/light mode support)
- Sidebar navigation with lucide-react icons
- Card-based content areas
- Responsive layout (mobile hamburger menu)
- Consistent component library (Button, Card, Input)
### Navigation Structure
```
┌──────────────────────────────────────────────────────────────┐
│ ┌──────────┐ │
│ │GuruConnect│ │
│ └──────────┘ │
│ │
│ 📋 Support ← Active temp sessions │
│ 🖥️ Access ← Unattended/permanent sessions │
│ 🔧 Build ← Installer builder │
│ ⚙️ Settings ← Preferences, groupings, appearance │
│ │
│ ───────────── │
│ 👤 Mike S. │
│ Admin │
│ [Sign out] │
└──────────────────────────────────────────────────────────────┘
```
### Support Tab (Active Temporary Sessions)
**Layout:**
```
┌─────────────────────────────────────────────────────────────────────┐
│ Support Sessions [ + Generate Code ] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ▼ My Sessions (3) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 847291 │ John's PC │ Connected │ 00:15:32 │ [Join] [End] │ │
│ │ 293847 │ Waiting │ Pending │ - │ [Cancel] │ │
│ │ 182736 │ Sarah-PC │ Connected │ 00:45:10 │ [Join] [End] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ▼ Team Sessions (2) [Howard's sessions] │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 928374 │ DESKTOP-A │ Connected │ 00:05:22 │ [View] [Join] │ │
│ │ 746382 │ Laptop-01 │ Connected │ 01:20:15 │ [View] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ▶ Support Requests (1) [End-user initiated] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
**Features:**
- Sessions grouped by technician (own first, then team)
- Real-time status updates (WebSocket)
- Duration timer for active sessions
- Quick actions: Join, View (spectate), End, Cancel
- Support request queue from end-user tray icon requests
### Access Tab (Unattended/Permanent Sessions)
**Layout:**
```
┌─────────────────────────────────────────────────────────────────────┐
│ Access 🔍 [Search...] [ + Build ] │
├──────────────┬──────────────────────────────────────────────────────┤
│ │ │
│ ▼ By Company │ All Machines by Company 1083 machines │
│ (empty) 120│ ─────────────────────────────────────────────────── │
│ 4 Paws 1│ ┌──────────────────────────────────────────────┐ │
│ ACG 10│ │ ● PC-FRONT01 │ Glaztech │ Win 11 │ Online │ │
│ Glaztech 224 │ ● SERVER-DC01 │ Glaztech │ Svr 22 │ Online │ │
│ AirPros 2│ │ ○ LAPTOP-SALES │ Glaztech │ Win 10 │ 2h ago │ │
│ ... │ │ ● WORKSTATION-3 │ ACG │ Win 11 │ Online │ │
│ │ │ ... │ │
│ ▶ By Site │ └──────────────────────────────────────────────┘ │
│ ▶ By OS │ │
│ ▶ By Tag │ ──────────────── Machine Detail ───────────────── │
│ │ Name: PC-FRONT01 │
│ ──────────── │ Company: Glaztech Industries │
│ Smart Groups │ Site: Phoenix Office │
│ ──────────── │ OS: Windows 11 Pro (23H2) │
│ Attention 1│ User: jsmith │
│ Online 847 IP: 192.168.1.45 / 72.194.62.4 │
│ Offline 30d 241 Serial: 8XKJF93 │
│ Offline 1yr 238 Last Seen: Now │
│ Outdated 516│ │
│ Recent 5│ [ Connect ] [ Wake ] [ Tools ▼ ] │
│ │ │
│ ▶ My Filters │ │
│ + New Filter│ │
└──────────────┴──────────────────────────────────────────────────────┘
```
**Left Sidebar - Groupings:**
- By Company (with counts, expandable)
- By Site
- By OS
- By Tag
- By Device Type
- Smart Groups (auto-generated)
- Custom Filters (user-created)
**Main Panel:**
- Machine list with status indicators (● online, ○ offline)
- Quick info columns (configurable)
- Click to select → shows detail panel
**Right Panel - Machine Detail:**
- Full machine info (Session, Device, Network sections)
- Action buttons: Connect, Wake (if offline), Tools dropdown
### Build Tab (Installer Builder)
**Layout:**
```
┌─────────────────────────────────────────────────────────────────────┐
│ Build Installer │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Name: [ Use Machine Name ▼ ] │
│ Company: [ __________________________ ] (autocomplete) │
│ Site: [ __________________________ ] (autocomplete) │
│ Department: [ __________________________ ] │
│ Device Type: [ Workstation ▼ ] │
│ Tag: [ __________________________ ] │
│ │
│ Platform: ○ Windows 64-bit (recommended) │
│ ○ Windows 32-bit │
│ ○ Linux (coming soon) │
│ │
│ ───────────────────────────────────────────────────────────────── │
│ │
│ [ Download EXE ] [ Download MSI ] [ Copy URL ] [ Send Link ] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Settings Tab
**Sections:**
**Appearance:**
- Theme: Light / Dark / System
- Sidebar: Expanded / Collapsed by default
- Default landing tab: Support / Access
**Groupings:**
- Default grouping for Access tab
- Show/hide specific smart groups
- Configure custom filter defaults
**Notifications:**
- Browser notifications: On/Off
- Sound alerts: On/Off
- Email alerts for support requests: On/Off
**Session Defaults:**
- Default session visibility: Private / Team / Company
- Auto-accept from specific companies
**Account:**
- Change password
- Two-factor authentication
- API keys (for integrations)
---
## Session Types
### 1. Support Sessions (Attended/One-Time)
**End-User Experience:**
- User visits portal (e.g., `support.azcomputerguru.com`)
- Portal generates a 5-6 digit numeric code (e.g., `847291`)
- User enters code OR clicks generated link
- Small executable downloads and runs (no install required)
- Session connects to assigned technician
**Technician Experience:**
- Generate session codes from dashboard
- Codes can be pre-assigned to specific tech or first-come
- Session appears on assigned tech's dashboard
**Code Management:**
- Codes remain active until used (no automatic expiration)
- Anti-collision: Active codes tracked in database, never reissued while active
- Once session completes, code is released back to pool
- Manual code cancellation available
- Optional: Tech can set manual expiration if desired
- 6 digits = 1M codes, plenty of headroom for concurrent active codes
### 2. Unattended Sessions (Permanent/MSP)
**Installer Builder:**
Build custom installers with pre-defined metadata fields:
| Field | Description | Example |
|-------|-------------|---------|
| Name | Machine identifier | "Use Machine Name" (auto) or custom |
| Company | Client/organization | "Glaztech Industries" |
| Site | Physical location | "Phoenix Office" |
| Department | Business unit | "Accounting" |
| Device Type | Machine category | "Workstation", "Server", "Laptop" |
| Tag | Custom label | "VIP", "Critical", "Testing" |
**Installer Output Options:**
- Download EXE directly
- Download MSI (for GPO deployment)
- Copy installer URL (for deployment scripts)
- Send link via email
**Server-Built Installers:**
- Server generates installers on-demand
- All metadata (Company, Site, etc.) baked into binary
- Unique installer per configuration
- No manual config file editing required
- Server URL and auth token embedded
**MSI Support:**
- MSI wrapper for Group Policy deployment
- Silent install support: `msiexec /i guruconnect.msi /qn`
- Uninstall via Add/Remove Programs or GPO
- Transform files (.mst) for custom configurations (optional)
**End-User Reconfiguration:**
- Re-run installer with flags to modify settings
- `--reconfigure` flag enters config mode instead of reinstall
- User can change: Name, Site, Tag, Department
- Changes sync to server on next check-in
- Useful for when machine moves to different site/department
Example:
```
guruconnect-agent.exe --reconfigure --site "New York Office" --tag "Laptop"
```
**Deployment:**
- Installed as Windows service
- Persists across reboots
- Auto-reconnects on network changes
- Can be bundled with GuruRMM agent OR standalone
- Metadata fields baked into agent at build time
**Management:**
- Assigned to client/site hierarchy
- Always available for remote access (when machine is on)
- Background service, no user interaction required
---
## Technician Dashboard
### Session Visibility & Permissions
| Role | Own Sessions | Team Sessions | All Sessions |
|------|--------------|---------------|--------------|
| Technician | Full access | View if permitted | No |
| Senior Tech | Full access | View + join | View |
| Admin | Full access | Full access | Full access |
**Permission Model:**
- Sessions created by a tech default to their dashboard
- Configurable visibility: Private, Team, Company-wide
- "Snoop" capability for supervisors (view session list, optionally join)
- Session handoff between technicians
### Auto-Generated Groups (Sidebar)
The dashboard automatically generates navigable groups based on metadata and status:
**By Metadata Field:**
- All Machines by Company (with counts per company)
- All Machines by Site
- All Machines by OS
- All Machines by Tag
- All Machines by Device Type
**Smart Status Groups:**
| Group | Definition |
|-------|------------|
| Attention | Machines flagged for follow-up |
| Host Connected | Tech currently connected |
| Guest Connected | End-user currently at machine |
| Recently Accessed | Connected within last 24 hours |
| Offline 30 Days | No check-in for 30+ days |
| Offline 1 Year | Stale agents, cleanup candidates |
| Outdated Clients | Agent version behind current |
| Powered on last 10 min | Just came online |
**Custom Session Groups:**
- Create saved filter combinations
- Name and organize custom groups
- Share groups with team (optional)
### Machine Detail Panel
When a machine is selected, show comprehensive info in side panel:
**Session Info:**
- Name, Company, Site, Department
- Device Type, Tag
- Hosts Connected (tech count)
- Guests Connected (user present)
- Guest Last Connected
- Logged On User
- Idle Time
- Pending Activity
- Custom Attributes
**Device Info:**
- Machine name
- Operating System + Version
- OS Install Date
- Processor
- Available Memory
- Manufacturer & Model
- Serial Number / Service Tag
- Machine Description
**Network Info:**
- Public IP Address
- Private IP Address(es)
- MAC Address(es)
**Other:**
- Agent Version
- Last Check-in
- First Seen
- Screenshot thumbnail (optional)
### Unattended Session Search
**Searchable Fields:**
- Hostname / Computer name
- Internal IP address
- External/Public IP address
- Currently logged-in user
- OS type (Windows 10, 11, Server 2019, etc.)
- OS version/build number
- Serial number
- Service tag (Dell, HP, Lenovo tags)
- Client/Site assignment
- Custom tags/labels
- Last check-in time
- Agent version
**Filter Capabilities:**
- Last check-in: < 1 hour, < 24 hours, < 7 days, > 30 days (stale)
- OS type grouping
- Client/Site hierarchy
- Online/Offline status
- Custom saved filters (user-defined queries)
**Saved Searches:**
- Create and name custom filter combinations
- Share saved searches with team
- Pin frequently used searches
---
## Remote Control Features
### Screen Control
- Real-time screen viewing
- Mouse control (click, drag, scroll)
- Keyboard input
- Multi-monitor support (switch displays, view all)
### Clipboard Integration
**Priority Feature - Full Bidirectional Clipboard:**
| Direction | Content Types |
|-----------|---------------|
| Local → Remote | Text, Files, Images, Rich text |
| Remote → Local | Text, Files, Images, Rich text |
**Special Capabilities:**
- **Keystroke injection from clipboard** - Paste local clipboard as keystrokes (for login screens, BIOS, pre-OS environments)
- Drag-and-drop file transfer
- Large file support (chunked transfer with progress)
### File Transfer
- Browse remote filesystem
- Upload files to remote
- Download files from remote
- Drag-and-drop support
- Transfer queue with progress
### Backstage Tools (No Screen Required)
- Remote command prompt / PowerShell
- Task manager view
- Services manager
- Registry editor (future)
- Event log viewer (future)
- System info panel
### Chat / Messaging
**Bidirectional Chat:**
- Tech can message end user during session
- End user can message tech
- Chat persists across session reconnects
- Chat history viewable in session log
**End-User Initiated Contact:**
- System tray icon for permanent agents
- "Request Support" option in tray menu
- User can type message/description of issue
- Creates support request visible to assigned technicians
**Technician Notifications:**
- Dashboard shows pending support requests
- Optional: Desktop/browser notifications for new requests
- Optional: Email/webhook alerts for after-hours requests
- Request queue with timestamps and user messages
### Credential Management (Future)
**Credential Injection:**
- Integration with ITGlue for credential lookup
- Integration with GuruRMM credential vault
- Tech selects credential from dropdown, never sees actual password
- Credential injected directly as keystrokes to remote session
- Audit log of which credential was used, by whom, when
**Local Credential Capture (Future):**
- Optional feature to capture credentials entered during session
- Stored encrypted, accessible only to admins
- For scenarios where client provides password verbally
---
## Security Requirements
### Authentication
- Technician login with username/password
- MFA/2FA support (TOTP)
- SSO integration (future - Azure AD, Google)
- API key auth for programmatic access
### Session Security
- All traffic over TLS/WSS
- End-to-end encryption for screen data
- Session consent prompt (attended sessions)
- Configurable session timeout
### Audit & Compliance
- Full audit log: who, when, what machine, duration
- Optional session recording
- Action logging (file transfers, commands run)
- Exportable audit reports
---
## Integration
### GuruRMM Integration
- Launch remote session from RMM agent list
- Share agent data (hostname, IP, user, etc.)
- Single authentication
- Unified dashboard option
### Standalone Mode
- Fully functional without GuruRMM
- Own user management
- Own agent deployment
- Can be licensed/sold separately
---
## Agent Requirements
### Support Session Agent (One-Time)
- Single executable, no installation
- Downloads and runs from portal
- Self-deletes after session ends
- Minimal footprint (<5MB)
- No admin rights required for basic screen share
- Admin rights optional for elevated access
### Unattended Agent (Permanent)
- Windows service installation
- Auto-start on boot
- Runs as SYSTEM for full access
- Configurable check-in interval
- Resilient reconnection
**Auto-Update:**
- Agent checks for updates on configurable interval
- Silent background update (no user interaction)
- Rollback capability if update fails
- Version reported to server for "Outdated Clients" filtering
**Lightweight Performance:**
- Minimal CPU/RAM footprint when idle
- No performance impact during normal operation
- Screen capture only active during remote session
- Target: <10MB RAM idle, <1% CPU idle
**Survival & Recovery:**
- Survives reboots (Windows service auto-start)
- Works in Safe Mode with Networking
- Registers as safe-mode-capable service
- Remote-initiated Safe Mode reboot (with networking)
- Auto-reconnects after safe mode boot
**Safe Mode Reboot Feature:**
- Tech can trigger safe mode reboot from dashboard
- Options: Safe Mode, Safe Mode with Networking, Safe Mode with Command Prompt
- Agent persists through safe mode boot
- Useful for malware removal, driver issues, repairs
**Emergency Reboot:**
- Force immediate reboot without waiting for processes
- Bypasses "program not responding" dialogs
- Equivalent to holding power button, but cleaner
- Use case: Frozen system, hung updates, unresponsive machine
- Confirmation required to prevent accidental use
**Wake-on-LAN:**
- Store MAC address for each agent
- Send WoL magic packet to wake offline machines
- Works within same broadcast domain (LAN)
- For remote WoL: requires WoL relay/proxy on local network
- Dashboard shows "Wake" button for offline machines with known MAC
- Optional: Integration with GuruRMM agent as WoL relay
### Reported Metrics (Unattended)
- Hostname
- Internal IP(s)
- External IP
- Current user
- OS type and version
- Serial number
- Service tag
- CPU, RAM, Disk (basic)
- Last boot time
- Agent version
- Custom properties (extensible)
---
## Platform Support
### Build Targets
| Target | Architecture | Priority | Notes |
|--------|--------------|----------|-------|
| `x86_64-pc-windows-msvc` | 64-bit | Primary | Default build, Win7+ |
| `i686-pc-windows-msvc` | 32-bit | Secondary | Legacy outliers |
### Phase 1 (MVP)
- Windows 10/11 agents (64-bit)
- Windows Server 2016+ agents (64-bit)
- Web dashboard (any browser)
### Phase 2
- 32-bit agent builds for legacy systems
- Windows 7/8.1 support
### Future Phases
- macOS agent
- Linux agent
- Mobile viewer (iOS/Android)
---
## Non-Functional Requirements
### Performance
- Screen updates: 30+ FPS on LAN, 15+ FPS on WAN
- Input latency: <100ms on LAN, <200ms on WAN
- Support 50+ concurrent unattended agents per server (scalable)
### Reliability
- Agent auto-reconnect on network change
- Server clustering for HA (future)
- Graceful degradation on poor networks
### Deployment
- Single binary server (Docker or native)
- Single binary agent (MSI installer + standalone EXE)
- Cloud-hostable or on-premises

View File

@@ -12,7 +12,7 @@ tokio = { version = "1", features = ["full", "sync", "time", "rt-multi-thread",
# Web framework
axum = { version = "0.7", features = ["ws", "macros"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs"] }
# WebSocket
futures-util = "0.3"
@@ -52,6 +52,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
# Time
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"
[build-dependencies]
prost-build = "0.13"

View File

@@ -9,6 +9,7 @@ mod session;
mod auth;
mod api;
mod db;
mod support_codes;
pub mod proto {
include!(concat!(env!("OUT_DIR"), "/guruconnect.rs"));
@@ -17,13 +18,27 @@ pub mod proto {
use anyhow::Result;
use axum::{
Router,
routing::get,
routing::{get, post},
extract::{Path, State, Json},
response::{Html, IntoResponse},
http::StatusCode,
};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tower_http::services::ServeDir;
use tracing::{info, Level};
use tracing_subscriber::FmtSubscriber;
use serde::Deserialize;
use support_codes::{SupportCodeManager, CreateCodeRequest, SupportCode, CodeValidation};
/// Application state
#[derive(Clone)]
pub struct AppState {
sessions: session::SessionManager,
support_codes: SupportCodeManager,
}
#[tokio::main]
async fn main() -> Result<()> {
@@ -37,26 +52,46 @@ async fn main() -> Result<()> {
// Load configuration
let config = config::Config::load()?;
info!("Loaded configuration, listening on {}", config.listen_addr);
// Initialize database connection (optional for MVP)
// let db = db::init(&config.database_url).await?;
// Use port 3002 for GuruConnect
let listen_addr = std::env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:3002".to_string());
info!("Loaded configuration, listening on {}", listen_addr);
// Create session manager
let sessions = session::SessionManager::new();
// Create application state
let state = AppState {
sessions: session::SessionManager::new(),
support_codes: SupportCodeManager::new(),
};
// Build router
let app = Router::new()
// Health check
.route("/health", get(health))
// Portal API - Support codes
.route("/api/codes", post(create_code))
.route("/api/codes", get(list_codes))
.route("/api/codes/:code/validate", get(validate_code))
.route("/api/codes/:code/cancel", post(cancel_code))
// WebSocket endpoints
.route("/ws/agent", get(relay::agent_ws_handler))
.route("/ws/viewer", get(relay::viewer_ws_handler))
// REST API
.route("/api/sessions", get(api::list_sessions))
.route("/api/sessions/:id", get(api::get_session))
// REST API - Sessions
.route("/api/sessions", get(list_sessions))
.route("/api/sessions/:id", get(get_session))
// HTML page routes (clean URLs)
.route("/login", get(serve_login))
.route("/dashboard", get(serve_dashboard))
// State
.with_state(sessions)
.with_state(state)
// Serve static files for portal (fallback)
.fallback_service(ServeDir::new("static").append_index_html_on_directories(true))
// Middleware
.layer(TraceLayer::new_for_http())
.layer(
@@ -67,7 +102,7 @@ async fn main() -> Result<()> {
);
// Start server
let addr: SocketAddr = config.listen_addr.parse()?;
let addr: SocketAddr = listen_addr.parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("Server listening on {}", addr);
@@ -80,3 +115,80 @@ async fn main() -> Result<()> {
async fn health() -> &'static str {
"OK"
}
// Support code API handlers
async fn create_code(
State(state): State<AppState>,
Json(request): Json<CreateCodeRequest>,
) -> Json<SupportCode> {
let code = state.support_codes.create_code(request).await;
info!("Created support code: {}", code.code);
Json(code)
}
async fn list_codes(
State(state): State<AppState>,
) -> Json<Vec<SupportCode>> {
Json(state.support_codes.list_active_codes().await)
}
#[derive(Deserialize)]
struct ValidateParams {
code: String,
}
async fn validate_code(
State(state): State<AppState>,
Path(code): Path<String>,
) -> Json<CodeValidation> {
Json(state.support_codes.validate_code(&code).await)
}
async fn cancel_code(
State(state): State<AppState>,
Path(code): Path<String>,
) -> impl IntoResponse {
if state.support_codes.cancel_code(&code).await {
(StatusCode::OK, "Code cancelled")
} else {
(StatusCode::BAD_REQUEST, "Cannot cancel code")
}
}
// Session API handlers (updated to use AppState)
async fn list_sessions(
State(state): State<AppState>,
) -> Json<Vec<api::SessionInfo>> {
let sessions = state.sessions.list_sessions().await;
Json(sessions.into_iter().map(api::SessionInfo::from).collect())
}
async fn get_session(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<api::SessionInfo>, (StatusCode, &'static str)> {
let session_id = uuid::Uuid::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID"))?;
let session = state.sessions.get_session(session_id).await
.ok_or((StatusCode::NOT_FOUND, "Session not found"))?;
Ok(Json(api::SessionInfo::from(session)))
}
// Static page handlers
async fn serve_login() -> impl IntoResponse {
match tokio::fs::read_to_string("static/login.html").await {
Ok(content) => Html(content).into_response(),
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
}
}
async fn serve_dashboard() -> impl IntoResponse {
match tokio::fs::read_to_string("static/dashboard.html").await {
Ok(content) => Html(content).into_response(),
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
}
}

View File

@@ -17,6 +17,7 @@ use tracing::{error, info, warn};
use crate::proto;
use crate::session::SessionManager;
use crate::AppState;
#[derive(Debug, Deserialize)]
pub struct AgentParams {
@@ -33,11 +34,12 @@ pub struct ViewerParams {
/// WebSocket handler for agent connections
pub async fn agent_ws_handler(
ws: WebSocketUpgrade,
State(sessions): State<SessionManager>,
State(state): State<AppState>,
Query(params): Query<AgentParams>,
) -> impl IntoResponse {
let agent_id = params.agent_id;
let agent_name = params.agent_name.unwrap_or_else(|| agent_id.clone());
let sessions = state.sessions.clone();
ws.on_upgrade(move |socket| handle_agent_connection(socket, sessions, agent_id, agent_name))
}
@@ -45,10 +47,11 @@ pub async fn agent_ws_handler(
/// WebSocket handler for viewer connections
pub async fn viewer_ws_handler(
ws: WebSocketUpgrade,
State(sessions): State<SessionManager>,
State(state): State<AppState>,
Query(params): Query<ViewerParams>,
) -> impl IntoResponse {
let session_id = params.session_id;
let sessions = state.sessions.clone();
ws.on_upgrade(move |socket| handle_viewer_connection(socket, sessions, session_id))
}
@@ -78,6 +81,8 @@ async fn handle_agent_connection(
}
});
let sessions_cleanup = sessions.clone();
// Main loop: receive frames from agent and broadcast to viewers
while let Some(msg) = ws_receiver.next().await {
match msg {
@@ -113,7 +118,7 @@ async fn handle_agent_connection(
// Cleanup
input_forward.abort();
sessions.remove_session(session_id).await;
sessions_cleanup.remove_session(session_id).await;
info!("Session {} ended", session_id);
}
@@ -154,6 +159,8 @@ async fn handle_viewer_connection(
}
});
let sessions_cleanup = sessions.clone();
// Main loop: receive input from viewer and forward to agent
while let Some(msg) = ws_receiver.next().await {
match msg {
@@ -189,6 +196,6 @@ async fn handle_viewer_connection(
// Cleanup
frame_forward.abort();
sessions.leave_session(session_id).await;
sessions_cleanup.leave_session(session_id).await;
info!("Viewer left session: {}", session_id);
}

199
server/src/support_codes.rs Normal file
View File

@@ -0,0 +1,199 @@
//! Support session codes management
//!
//! Handles generation and validation of 6-digit support codes
//! for one-time remote support sessions.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use chrono::{DateTime, Utc};
use rand::Rng;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A support session code
#[derive(Debug, Clone, Serialize)]
pub struct SupportCode {
pub code: String,
pub session_id: Uuid,
pub created_by: String,
pub created_at: DateTime<Utc>,
pub status: CodeStatus,
pub client_name: Option<String>,
pub client_machine: Option<String>,
pub connected_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum CodeStatus {
Pending, // Waiting for client to connect
Connected, // Client connected, session active
Completed, // Session ended normally
Cancelled, // Code cancelled by tech
}
/// Request to create a new support code
#[derive(Debug, Deserialize)]
pub struct CreateCodeRequest {
pub technician_id: Option<String>,
pub technician_name: Option<String>,
}
/// Response when a code is validated
#[derive(Debug, Serialize)]
pub struct CodeValidation {
pub valid: bool,
pub session_id: Option<String>,
pub server_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// Manages support codes
#[derive(Clone)]
pub struct SupportCodeManager {
codes: Arc<RwLock<HashMap<String, SupportCode>>>,
session_to_code: Arc<RwLock<HashMap<Uuid, String>>>,
}
impl SupportCodeManager {
pub fn new() -> Self {
Self {
codes: Arc::new(RwLock::new(HashMap::new())),
session_to_code: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Generate a unique 6-digit code
async fn generate_unique_code(&self) -> String {
let codes = self.codes.read().await;
let mut rng = rand::thread_rng();
loop {
let code: u32 = rng.gen_range(100000..999999);
let code_str = code.to_string();
if !codes.contains_key(&code_str) {
return code_str;
}
}
}
/// Create a new support code
pub async fn create_code(&self, request: CreateCodeRequest) -> SupportCode {
let code = self.generate_unique_code().await;
let session_id = Uuid::new_v4();
let support_code = SupportCode {
code: code.clone(),
session_id,
created_by: request.technician_name.unwrap_or_else(|| "Unknown".to_string()),
created_at: Utc::now(),
status: CodeStatus::Pending,
client_name: None,
client_machine: None,
connected_at: None,
};
let mut codes = self.codes.write().await;
codes.insert(code.clone(), support_code.clone());
let mut session_to_code = self.session_to_code.write().await;
session_to_code.insert(session_id, code);
support_code
}
/// Validate a code and return session info
pub async fn validate_code(&self, code: &str) -> CodeValidation {
let codes = self.codes.read().await;
match codes.get(code) {
Some(support_code) => {
if support_code.status == CodeStatus::Pending || support_code.status == CodeStatus::Connected {
CodeValidation {
valid: true,
session_id: Some(support_code.session_id.to_string()),
server_url: Some("wss://connect.azcomputerguru.com/ws/support".to_string()),
error: None,
}
} else {
CodeValidation {
valid: false,
session_id: None,
server_url: None,
error: Some("This code has expired or been used".to_string()),
}
}
}
None => CodeValidation {
valid: false,
session_id: None,
server_url: None,
error: Some("Invalid code".to_string()),
},
}
}
/// Mark a code as connected
pub async fn mark_connected(&self, code: &str, client_name: Option<String>, client_machine: Option<String>) {
let mut codes = self.codes.write().await;
if let Some(support_code) = codes.get_mut(code) {
support_code.status = CodeStatus::Connected;
support_code.client_name = client_name;
support_code.client_machine = client_machine;
support_code.connected_at = Some(Utc::now());
}
}
/// Mark a code as completed
pub async fn mark_completed(&self, code: &str) {
let mut codes = self.codes.write().await;
if let Some(support_code) = codes.get_mut(code) {
support_code.status = CodeStatus::Completed;
}
}
/// Cancel a code
pub async fn cancel_code(&self, code: &str) -> bool {
let mut codes = self.codes.write().await;
if let Some(support_code) = codes.get_mut(code) {
if support_code.status == CodeStatus::Pending {
support_code.status = CodeStatus::Cancelled;
return true;
}
}
false
}
/// List all codes (for dashboard)
pub async fn list_codes(&self) -> Vec<SupportCode> {
let codes = self.codes.read().await;
codes.values().cloned().collect()
}
/// List active codes only
pub async fn list_active_codes(&self) -> Vec<SupportCode> {
let codes = self.codes.read().await;
codes.values()
.filter(|c| c.status == CodeStatus::Pending || c.status == CodeStatus::Connected)
.cloned()
.collect()
}
/// Get code by session ID
pub async fn get_by_session(&self, session_id: Uuid) -> Option<SupportCode> {
let session_to_code = self.session_to_code.read().await;
let code = session_to_code.get(&session_id)?;
let codes = self.codes.read().await;
codes.get(code).cloned()
}
}
impl Default for SupportCodeManager {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,481 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GuruConnect - Dashboard</title>
<style>
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--accent: 217.2 32.6% 17.5%;
--destructive: 0 62.8% 30.6%;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
min-height: 100vh;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--card));
}
.header-left { display: flex; align-items: center; gap: 24px; }
.logo { font-size: 20px; font-weight: 700; color: hsl(var(--foreground)); }
.header-right { display: flex; align-items: center; gap: 16px; }
.user-info { font-size: 14px; color: hsl(var(--muted-foreground)); }
.logout-btn {
padding: 8px 16px;
font-size: 14px;
background: transparent;
color: hsl(var(--muted-foreground));
border: 1px solid hsl(var(--border));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.logout-btn:hover { background: hsl(var(--accent)); color: hsl(var(--foreground)); }
.tabs {
display: flex;
gap: 4px;
padding: 0 24px;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--card));
}
.tab {
padding: 12px 20px;
font-size: 14px;
font-weight: 500;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
margin-bottom: -1px;
}
.tab:hover { color: hsl(var(--foreground)); }
.tab.active { color: hsl(var(--primary)); border-bottom-color: hsl(var(--primary)); }
.content { padding: 24px; max-width: 1400px; margin: 0 auto; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.card {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title { font-size: 18px; font-weight: 600; }
.card-description { color: hsl(var(--muted-foreground)); font-size: 14px; margin-top: 4px; }
.btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); }
.btn-primary:hover { opacity: 0.9; }
.btn-outline { background: transparent; color: hsl(var(--foreground)); border: 1px solid hsl(var(--border)); }
.btn-outline:hover { background: hsl(var(--accent)); }
.table-container { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid hsl(var(--border)); }
th { font-size: 12px; font-weight: 600; text-transform: uppercase; color: hsl(var(--muted-foreground)); }
td { font-size: 14px; }
tr:hover { background: hsla(var(--muted), 0.3); }
.empty-state { text-align: center; padding: 48px 24px; color: hsl(var(--muted-foreground)); }
.empty-state h3 { font-size: 16px; margin-bottom: 8px; color: hsl(var(--foreground)); }
.badge { display: inline-block; padding: 4px 10px; font-size: 12px; font-weight: 500; border-radius: 9999px; }
.badge-success { background: hsla(142, 76%, 36%, 0.2); color: hsl(142, 76%, 50%); }
.badge-warning { background: hsla(45, 93%, 47%, 0.2); color: hsl(45, 93%, 55%); }
.badge-muted { background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; }
.form-group { display: flex; flex-direction: column; gap: 8px; }
.form-group label { font-size: 14px; font-weight: 500; }
.form-group input, .form-group select {
padding: 10px 14px;
font-size: 14px;
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--foreground));
outline: none;
}
.form-group input:focus, .form-group select:focus {
border-color: hsl(var(--ring));
box-shadow: 0 0 0 3px hsla(var(--ring), 0.3);
}
.access-layout {
display: grid;
grid-template-columns: 250px 1fr 350px;
gap: 16px;
height: calc(100vh - 180px);
}
.sidebar-panel {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
overflow-y: auto;
}
.sidebar-section { padding: 12px 16px; border-bottom: 1px solid hsl(var(--border)); }
.sidebar-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; color: hsl(var(--muted-foreground)); margin-bottom: 8px; }
.sidebar-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 14px;
color: hsl(var(--foreground));
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.sidebar-item:hover { background: hsl(var(--accent)); }
.sidebar-item.active { background: hsl(var(--accent)); color: hsl(var(--primary)); }
.sidebar-count { font-size: 12px; color: hsl(var(--muted-foreground)); }
.main-panel, .detail-panel {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
overflow-y: auto;
}
.detail-panel { padding: 20px; }
.detail-section { margin-bottom: 20px; }
.detail-section-title { font-size: 12px; font-weight: 600; text-transform: uppercase; color: hsl(var(--muted-foreground)); margin-bottom: 12px; }
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 14px; border-bottom: 1px solid hsl(var(--border)); }
.detail-label { color: hsl(var(--muted-foreground)); }
.detail-value { color: hsl(var(--foreground)); text-align: right; }
</style>
</head>
<body>
<header class="header">
<div class="header-left">
<div class="logo">GuruConnect</div>
</div>
<div class="header-right">
<span class="user-info" id="userInfo">Loading...</span>
<button class="logout-btn" id="logoutBtn">Sign Out</button>
</div>
</header>
<nav class="tabs">
<button class="tab active" data-tab="support">Support</button>
<button class="tab" data-tab="access">Access</button>
<button class="tab" data-tab="build">Build</button>
<button class="tab" data-tab="settings">Settings</button>
</nav>
<main class="content">
<!-- Support Tab -->
<div class="tab-panel active" id="support-panel">
<div class="card">
<div class="card-header">
<div>
<h2 class="card-title">Active Support Sessions</h2>
<p class="card-description">Temporary sessions initiated by support codes</p>
</div>
<button class="btn btn-primary" id="generateCodeBtn">Generate Code</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Code</th>
<th>Status</th>
<th>Created</th>
<th>Technician</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="sessionsTable">
<tr>
<td colspan="5">
<div class="empty-state">
<h3>No active sessions</h3>
<p>Generate a code to start a support session</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Access Tab -->
<div class="tab-panel" id="access-panel">
<div class="access-layout">
<div class="sidebar-panel">
<div class="sidebar-section">
<div class="sidebar-section-title">Status</div>
<div class="sidebar-item active">
<span>All Machines</span>
<span class="sidebar-count">0</span>
</div>
<div class="sidebar-item">
<span>Online</span>
<span class="sidebar-count">0</span>
</div>
<div class="sidebar-item">
<span>Offline</span>
<span class="sidebar-count">0</span>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-section-title">By Company</div>
<div class="empty-state" style="padding: 16px;">
<p style="font-size: 12px;">No machines installed</p>
</div>
</div>
</div>
<div class="main-panel">
<div class="empty-state">
<h3>No machines</h3>
<p>Install the agent on a machine to see it here</p>
</div>
</div>
<div class="detail-panel">
<div class="empty-state">
<h3>Select a machine</h3>
<p>Click a machine to view details</p>
</div>
</div>
</div>
</div>
<!-- Build Tab -->
<div class="tab-panel" id="build-panel">
<div class="card">
<div class="card-header">
<div>
<h2 class="card-title">Installer Builder</h2>
<p class="card-description">Create customized agent installers for unattended access</p>
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label for="buildName">Name</label>
<input type="text" id="buildName" placeholder="Machine name (auto if blank)">
</div>
<div class="form-group">
<label for="buildCompany">Company</label>
<input type="text" id="buildCompany" placeholder="Client organization">
</div>
<div class="form-group">
<label for="buildSite">Site</label>
<input type="text" id="buildSite" placeholder="Physical location">
</div>
<div class="form-group">
<label for="buildDepartment">Department</label>
<input type="text" id="buildDepartment" placeholder="Business unit">
</div>
<div class="form-group">
<label for="buildDeviceType">Device Type</label>
<select id="buildDeviceType">
<option value="workstation">Workstation</option>
<option value="laptop">Laptop</option>
<option value="server">Server</option>
</select>
</div>
<div class="form-group">
<label for="buildTag">Tag</label>
<input type="text" id="buildTag" placeholder="Custom label">
</div>
</div>
<div style="margin-top: 24px; display: flex; gap: 12px;">
<button class="btn btn-primary" disabled>Build EXE (64-bit)</button>
<button class="btn btn-outline" disabled>Build EXE (32-bit)</button>
<button class="btn btn-outline" disabled>Build MSI</button>
</div>
<p style="margin-top: 16px; font-size: 13px; color: hsl(var(--muted-foreground));">
Agent builds will be available once the agent is compiled.
</p>
</div>
</div>
<!-- Settings Tab -->
<div class="tab-panel" id="settings-panel">
<div class="card">
<div class="card-header">
<div>
<h2 class="card-title">Settings</h2>
<p class="card-description">Configure your GuruConnect preferences</p>
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label>Theme</label>
<select disabled>
<option>Dark (Default)</option>
<option>Light</option>
<option>System</option>
</select>
</div>
<div class="form-group">
<label>Notifications</label>
<select disabled>
<option>All notifications</option>
<option>Important only</option>
<option>None</option>
</select>
</div>
</div>
<p style="margin-top: 24px; font-size: 13px; color: hsl(var(--muted-foreground));">
Additional settings coming soon.
</p>
</div>
</div>
</main>
<script>
// Tab switching
document.querySelectorAll(".tab").forEach(tab => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
document.querySelectorAll(".tab-panel").forEach(p => p.classList.remove("active"));
tab.classList.add("active");
document.getElementById(tab.dataset.tab + "-panel").classList.add("active");
});
});
// Check auth
const token = localStorage.getItem("token");
const user = JSON.parse(localStorage.getItem("user") || "null");
if (!token) {
document.getElementById("userInfo").textContent = "Demo Mode";
} else if (user) {
document.getElementById("userInfo").textContent = user.email || user.name || "Technician";
}
// Logout
document.getElementById("logoutBtn").addEventListener("click", () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/login";
});
// Generate code
document.getElementById("generateCodeBtn").addEventListener("click", async () => {
try {
const response = await fetch("/api/codes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ technician_name: user?.name || "Technician" })
});
const data = await response.json();
if (response.ok) {
alert("Support Code Generated: " + data.code + "\n\nShare this with the customer.");
loadSessions();
} else {
alert("Error: " + (data.error || "Failed to generate code"));
}
} catch (err) {
alert("Connection error");
}
});
// Load sessions
async function loadSessions() {
try {
const response = await fetch("/api/codes");
const codes = await response.json();
const tbody = document.getElementById("sessionsTable");
if (codes.length === 0) {
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-state"><h3>No active sessions</h3><p>Generate a code to start a support session</p></div></td></tr>';
return;
}
tbody.innerHTML = codes.map(code => {
const created = new Date(code.created_at).toLocaleString();
const statusClass = code.status === "pending" ? "badge-warning" :
code.status === "connected" ? "badge-success" : "badge-muted";
return '<tr>' +
'<td><strong>' + code.code + '</strong></td>' +
'<td><span class="badge ' + statusClass + '">' + code.status + '</span></td>' +
'<td>' + created + '</td>' +
'<td>' + (code.created_by || "Unknown") + '</td>' +
'<td><button class="btn btn-outline" onclick="cancelCode(\'' + code.code + '\')">Cancel</button></td>' +
'</tr>';
}).join("");
} catch (err) {
console.error("Failed to load sessions:", err);
}
}
async function cancelCode(code) {
if (!confirm("Cancel code " + code + "?")) return;
try {
await fetch("/api/codes/" + code + "/cancel", { method: "POST" });
loadSessions();
} catch (err) {
alert("Error cancelling code");
}
}
loadSessions();
</script>
</body>
</html>

415
server/static/index.html Normal file
View File

@@ -0,0 +1,415 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GuruConnect - Remote Support</title>
<style>
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
width: 100%;
max-width: 440px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
padding: 40px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.logo {
text-align: center;
margin-bottom: 32px;
}
.logo h1 {
font-size: 28px;
font-weight: 700;
color: hsl(var(--foreground));
}
.logo p {
color: hsl(var(--muted-foreground));
margin-top: 8px;
font-size: 14px;
}
.code-form {
display: flex;
flex-direction: column;
gap: 16px;
}
label {
font-size: 14px;
font-weight: 500;
color: hsl(var(--foreground));
}
.code-input-wrapper {
position: relative;
}
.code-input {
width: 100%;
padding: 16px 20px;
font-size: 32px;
font-weight: 600;
letter-spacing: 8px;
text-align: center;
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 8px;
color: hsl(var(--foreground));
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.code-input:focus {
border-color: hsl(var(--ring));
box-shadow: 0 0 0 3px hsla(var(--ring), 0.3);
}
.code-input::placeholder {
color: hsl(var(--muted-foreground));
letter-spacing: 4px;
}
.connect-btn {
width: 100%;
padding: 14px 24px;
font-size: 16px;
font-weight: 600;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
}
.connect-btn:hover {
opacity: 0.9;
}
.connect-btn:active {
transform: scale(0.98);
}
.connect-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
background: hsla(0, 70%, 50%, 0.1);
border: 1px solid hsla(0, 70%, 50%, 0.3);
color: hsl(0, 70%, 70%);
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.error-message.visible {
display: block;
}
.divider {
border-top: 1px solid hsl(var(--border));
margin: 24px 0;
}
.instructions {
display: none;
text-align: left;
}
.instructions.visible {
display: block;
}
.instructions h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: hsl(var(--foreground));
}
.instructions ol {
padding-left: 20px;
color: hsl(var(--muted-foreground));
font-size: 14px;
line-height: 1.8;
}
.instructions li {
margin-bottom: 8px;
}
.footer {
margin-top: 24px;
text-align: center;
color: hsl(var(--muted-foreground));
font-size: 12px;
}
.footer a {
color: hsl(var(--primary));
text-decoration: none;
}
.spinner {
display: none;
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading .spinner {
display: inline-block;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">
<h1>GuruConnect</h1>
<p>Remote Support Portal</p>
</div>
<form class="code-form" id="codeForm">
<label for="codeInput">Enter your support code:</label>
<div class="code-input-wrapper">
<input
type="text"
id="codeInput"
class="code-input"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
inputmode="numeric"
autocomplete="off"
required
>
</div>
<div class="error-message" id="errorMessage"></div>
<button type="submit" class="connect-btn" id="connectBtn">
<span class="spinner"></span>
<span class="btn-text">Connect</span>
</button>
</form>
<div class="divider"></div>
<div class="instructions" id="instructions">
<h3>How to connect:</h3>
<ol id="instructionsList">
<li>Enter the 6-digit code provided by your technician</li>
<li>Click "Connect" to start the session</li>
<li>If prompted, allow the download and run the file</li>
</ol>
</div>
<div class="footer">
<p>Need help? Contact <a href="mailto:support@azcomputerguru.com">support@azcomputerguru.com</a></p>
<p style="margin-top: 12px;"><a href="/login" style="color: hsl(var(--muted-foreground)); font-size: 11px;">Technician Login</a></p>
</div>
</div>
<script>
const form = document.getElementById('codeForm');
const codeInput = document.getElementById('codeInput');
const connectBtn = document.getElementById('connectBtn');
const errorMessage = document.getElementById('errorMessage');
const instructions = document.getElementById('instructions');
const instructionsList = document.getElementById('instructionsList');
// Auto-format input (numbers only)
codeInput.addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/[^0-9]/g, '').slice(0, 6);
errorMessage.classList.remove('visible');
});
// Detect browser
function detectBrowser() {
const ua = navigator.userAgent;
if (ua.includes('Edg/')) return 'edge';
if (ua.includes('Chrome/')) return 'chrome';
if (ua.includes('Firefox/')) return 'firefox';
if (ua.includes('Safari/') && !ua.includes('Chrome')) return 'safari';
return 'unknown';
}
// Browser-specific instructions
function getBrowserInstructions(browser) {
const instrs = {
chrome: [
'Click the download in the <strong>bottom-left corner</strong> of your screen',
'Click <strong>"Open"</strong> or <strong>"Keep"</strong> if prompted',
'The support session will start automatically'
],
firefox: [
'Click <strong>"Save File"</strong> in the download dialog',
'Open your <strong>Downloads folder</strong>',
'Double-click <strong>GuruConnect.exe</strong> to start'
],
edge: [
'Click <strong>"Open file"</strong> in the download notification at the top',
'If you see "Keep" button, click it first, then "Open file"',
'The support session will start automatically'
],
safari: [
'Click the <strong>download icon</strong> in the toolbar',
'Double-click the downloaded file',
'Click <strong>"Open"</strong> if macOS asks for confirmation'
],
unknown: [
'Your download should start automatically',
'Look for the file in your <strong>Downloads folder</strong>',
'Double-click the file to start the support session'
]
};
return instrs[browser] || instrs.unknown;
}
// Show browser-specific instructions
function showInstructions() {
const browser = detectBrowser();
const steps = getBrowserInstructions(browser);
instructionsList.innerHTML = steps.map(step => '<li>' + step + '</li>').join('');
instructions.classList.add('visible');
}
// Handle form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
const code = codeInput.value.trim();
if (code.length !== 6) {
showError('Please enter a 6-digit code');
return;
}
setLoading(true);
try {
// Validate code with server
const response = await fetch('/api/codes/' + code + '/validate');
const data = await response.json();
if (!data.valid) {
showError(data.error || 'Invalid code');
setLoading(false);
return;
}
// Try to launch via custom protocol
const protocolUrl = 'guruconnect://session/' + code;
// Attempt protocol launch with timeout fallback
let protocolLaunched = false;
const protocolTimeout = setTimeout(() => {
if (!protocolLaunched) {
// Protocol didn't work, trigger download
triggerDownload(code, data.session_id);
}
}, 2500);
// Try the protocol
window.location.href = protocolUrl;
// Check if we're still here after a moment
setTimeout(() => {
protocolLaunched = document.hidden;
if (protocolLaunched) {
clearTimeout(protocolTimeout);
}
}, 500);
} catch (err) {
showError('Connection error. Please try again.');
setLoading(false);
}
});
function triggerDownload(code, sessionId) {
// Show instructions
showInstructions();
// For now, show a message that download will be available soon
// TODO: Implement actual download endpoint
setLoading(false);
connectBtn.querySelector('.btn-text').textContent = 'Download Starting...';
// Placeholder - in production this will download the agent
setTimeout(() => {
alert('Agent download will be available once the agent is built.\n\nSession ID: ' + sessionId);
connectBtn.querySelector('.btn-text').textContent = 'Connect';
}, 1000);
}
function showError(message) {
errorMessage.textContent = message;
errorMessage.classList.add('visible');
}
function setLoading(loading) {
connectBtn.disabled = loading;
connectBtn.classList.toggle('loading', loading);
if (loading) {
connectBtn.querySelector('.btn-text').textContent = 'Connecting...';
} else if (!instructions.classList.contains('visible')) {
connectBtn.querySelector('.btn-text').textContent = 'Connect';
}
}
// Focus input on load
codeInput.focus();
</script>
</body>
</html>

230
server/static/login.html Normal file
View File

@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GuruConnect - Technician Login</title>
<style>
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
width: 100%;
max-width: 400px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
padding: 40px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.logo { text-align: center; margin-bottom: 32px; }
.logo h1 { font-size: 28px; font-weight: 700; color: hsl(var(--foreground)); }
.logo p { color: hsl(var(--muted-foreground)); margin-top: 8px; font-size: 14px; }
.login-form { display: flex; flex-direction: column; gap: 20px; }
.form-group { display: flex; flex-direction: column; gap: 8px; }
label { font-size: 14px; font-weight: 500; color: hsl(var(--foreground)); }
input[type="email"], input[type="password"] {
width: 100%;
padding: 12px 16px;
font-size: 14px;
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 8px;
color: hsl(var(--foreground));
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
input:focus {
border-color: hsl(var(--ring));
box-shadow: 0 0 0 3px hsla(var(--ring), 0.3);
}
input::placeholder { color: hsl(var(--muted-foreground)); }
.login-btn {
width: 100%;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
margin-top: 8px;
}
.login-btn:hover { opacity: 0.9; }
.login-btn:active { transform: scale(0.98); }
.login-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.error-message {
background: hsla(0, 70%, 50%, 0.1);
border: 1px solid hsla(0, 70%, 50%, 0.3);
color: hsl(0, 70%, 70%);
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.error-message.visible { display: block; }
.footer { margin-top: 24px; text-align: center; color: hsl(var(--muted-foreground)); font-size: 12px; }
.footer a { color: hsl(var(--primary)); text-decoration: none; }
.spinner {
display: none;
width: 18px;
height: 18px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading .spinner { display: inline-block; }
.demo-hint {
margin-top: 16px;
padding: 12px;
background: hsla(var(--primary), 0.1);
border-radius: 8px;
font-size: 13px;
color: hsl(var(--muted-foreground));
text-align: center;
}
.demo-hint a {
color: hsl(var(--primary));
text-decoration: none;
font-weight: 500;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">
<h1>GuruConnect</h1>
<p>Technician Login</p>
</div>
<form class="login-form" id="loginForm">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" placeholder="you@company.com" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" placeholder="Enter your password" required>
</div>
<div class="error-message" id="errorMessage"></div>
<button type="submit" class="login-btn" id="loginBtn">
<span class="spinner"></span>
<span class="btn-text">Sign In</span>
</button>
</form>
<div class="demo-hint">
<p>Auth not yet configured. <a href="/dashboard">Skip to Dashboard</a></p>
</div>
<div class="footer">
<p><a href="/">Back to Support Portal</a></p>
</div>
</div>
<script>
const form = document.getElementById("loginForm");
const loginBtn = document.getElementById("loginBtn");
const errorMessage = document.getElementById("errorMessage");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const email = document.getElementById("email").value;
const password = document.getElementById("password").value;
setLoading(true);
errorMessage.classList.remove("visible");
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (!response.ok) {
showError(data.error || "Login failed");
setLoading(false);
return;
}
localStorage.setItem("token", data.token);
localStorage.setItem("user", JSON.stringify(data.user));
window.location.href = "/dashboard";
} catch (err) {
showError("Auth not configured yet. Use the demo link below.");
setLoading(false);
}
});
function showError(message) {
errorMessage.textContent = message;
errorMessage.classList.add("visible");
}
function setLoading(loading) {
loginBtn.disabled = loading;
loginBtn.classList.toggle("loading", loading);
loginBtn.querySelector(".btn-text").textContent = loading ? "Signing in..." : "Sign In";
}
if (localStorage.getItem("token")) {
window.location.href = "/dashboard";
}
</script>
</body>
</html>