Compare commits
50 Commits
3f1fd8f20d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b7cf5fb07 | |||
| 3292ca4275 | |||
| 22f592dd27 | |||
| 5a82637a04 | |||
| 0387295401 | |||
| 4e5328fe4a | |||
| 7df824c2ca | |||
| 48076e12b0 | |||
| 4614df04fb | |||
| 56a9496f98 | |||
| 9f36686ea1 | |||
| 1b810a5f0a | |||
| 0c3435fa99 | |||
| 3fc4e1f96a | |||
|
|
743b73dfe7 | ||
|
|
dc7b7427ce | ||
|
|
05ab8a8bf4 | ||
|
|
a8ffa4bd83 | ||
| e3fbba4d6b | |||
| 598a6737de | |||
| 68eab236bf | |||
| f6bf0cfd26 | |||
| 448d3b75ac | |||
| f3b76b7b62 | |||
| d01fb4173f | |||
| 1cc94c61e7 | |||
| 3c2e0708ef | |||
| cc35d1112f | |||
| 4417fdfb6e | |||
| 5bb5116b92 | |||
| d7c272dabc | |||
| 52c47b2de1 | |||
| 4b29dbe6c8 | |||
| aa03a87c7c | |||
| 0dcbae69a0 | |||
| 43f15b0b1a | |||
| c57429f26a | |||
| dea96bd300 | |||
| 8246d135f9 | |||
| f408667a3f | |||
| 90ac39a0bc | |||
| 1d2ca47771 | |||
| 9af59158b2 | |||
| e118fe6698 | |||
|
|
7c16b2bf4d | ||
|
|
d14fa5880f | ||
|
|
d2c8cf1c0b | ||
|
|
b1de7be632 | ||
|
|
582387f60e | ||
|
|
09223cf97a |
145
.gitea/workflows/build-and-test.yml
Normal file
145
.gitea/workflows/build-and-test.yml
Normal file
@@ -0,0 +1,145 @@
|
||||
name: Build and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-server:
|
||||
name: Build Server (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
target: x86_64-unknown-linux-gnu
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-server-${{ hashFiles('server/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-server-
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libssl-dev protobuf-compiler
|
||||
|
||||
- name: Check formatting
|
||||
run: cd server && cargo fmt --all -- --check
|
||||
|
||||
- name: Run Clippy
|
||||
run: cd server && cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Build server
|
||||
run: |
|
||||
cd server
|
||||
cargo build --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd server
|
||||
cargo test --release
|
||||
|
||||
- name: Upload server binary
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: guruconnect-server-linux
|
||||
path: server/target/x86_64-unknown-linux-gnu/release/guruconnect-server
|
||||
retention-days: 30
|
||||
|
||||
build-agent:
|
||||
name: Build Agent (Windows)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
target: x86_64-pc-windows-msvc
|
||||
override: true
|
||||
|
||||
- name: Install cross-compilation tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y mingw-w64
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-agent-${{ hashFiles('agent/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-agent-
|
||||
|
||||
- name: Build agent (cross-compile for Windows)
|
||||
run: |
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
cd agent
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
|
||||
- name: Upload agent binary
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: guruconnect-agent-windows
|
||||
path: agent/target/x86_64-pc-windows-gnu/release/guruconnect.exe
|
||||
retention-days: 30
|
||||
|
||||
security-audit:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Run security audit on server
|
||||
run: cd server && cargo audit
|
||||
|
||||
- name: Run security audit on agent
|
||||
run: cd agent && cargo audit
|
||||
|
||||
build-summary:
|
||||
name: Build Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-server, build-agent, security-audit]
|
||||
steps:
|
||||
- name: Build succeeded
|
||||
run: |
|
||||
echo "All builds completed successfully"
|
||||
echo "Server: Linux x86_64"
|
||||
echo "Agent: Windows x86_64"
|
||||
echo "Security: Passed"
|
||||
88
.gitea/workflows/deploy.yml
Normal file
88
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,88 @@
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Deployment environment'
|
||||
required: true
|
||||
default: 'production'
|
||||
type: choice
|
||||
options:
|
||||
- production
|
||||
- staging
|
||||
|
||||
jobs:
|
||||
deploy-server:
|
||||
name: Deploy Server
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.environment || 'production' }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
target: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Build server
|
||||
run: |
|
||||
cd server
|
||||
cargo build --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Create deployment package
|
||||
run: |
|
||||
mkdir -p deploy
|
||||
cp server/target/x86_64-unknown-linux-gnu/release/guruconnect-server deploy/
|
||||
cp -r server/static deploy/
|
||||
cp -r server/migrations deploy/
|
||||
cp server/.env.example deploy/.env.example
|
||||
tar -czf guruconnect-server-${{ github.ref_name }}.tar.gz -C deploy .
|
||||
|
||||
- name: Upload deployment package
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: deployment-package
|
||||
path: guruconnect-server-${{ github.ref_name }}.tar.gz
|
||||
retention-days: 90
|
||||
|
||||
- name: Deploy to server (production)
|
||||
if: github.event.inputs.environment == 'production' || startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
echo "Deployment command would run here"
|
||||
echo "SSH to 172.16.3.30 and deploy"
|
||||
# Actual deployment would use SSH keys and run:
|
||||
# scp guruconnect-server-*.tar.gz guru@172.16.3.30:/tmp/
|
||||
# ssh guru@172.16.3.30 'bash /home/guru/guru-connect/scripts/deploy.sh'
|
||||
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: deploy-server
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
|
||||
- name: Create Release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
release_name: Release ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Upload Release Assets
|
||||
run: |
|
||||
echo "Upload server and agent binaries to release"
|
||||
# Would attach artifacts to the release here
|
||||
124
.gitea/workflows/test.yml
Normal file
124
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,124 @@
|
||||
name: Run Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- 'feature/**'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test-server:
|
||||
name: Test Server
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
target: x86_64-unknown-linux-gnu
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-test-${{ hashFiles('server/Cargo.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libssl-dev protobuf-compiler
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
cd server
|
||||
cargo test --lib --release
|
||||
|
||||
- name: Run integration tests
|
||||
run: |
|
||||
cd server
|
||||
cargo test --test '*' --release
|
||||
|
||||
- name: Run doc tests
|
||||
run: |
|
||||
cd server
|
||||
cargo test --doc --release
|
||||
|
||||
test-agent:
|
||||
name: Test Agent
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Run agent tests
|
||||
run: |
|
||||
cd agent
|
||||
cargo test --release
|
||||
|
||||
code-coverage:
|
||||
name: Code Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: llvm-tools-preview
|
||||
|
||||
- name: Install tarpaulin
|
||||
run: cargo install cargo-tarpaulin
|
||||
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
cd server
|
||||
cargo tarpaulin --out Xml --output-dir ../coverage
|
||||
|
||||
- name: Upload coverage to artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
|
||||
lint:
|
||||
name: Lint and Format Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Check formatting (server)
|
||||
run: cd server && cargo fmt --all -- --check
|
||||
|
||||
- name: Check formatting (agent)
|
||||
run: cd agent && cargo fmt --all -- --check
|
||||
|
||||
- name: Run clippy (server)
|
||||
run: cd server && cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Run clippy (agent)
|
||||
run: cd agent && cargo clippy --all-targets --all-features -- -D warnings
|
||||
251
CLAUDE.md
251
CLAUDE.md
@@ -1,117 +1,200 @@
|
||||
# GuruConnect
|
||||
# GuruConnect - Project Guidelines
|
||||
|
||||
Remote desktop solution similar to ScreenConnect, integrated with GuruRMM.
|
||||
## Overview
|
||||
|
||||
## Project Overview
|
||||
|
||||
GuruConnect provides remote screen control and backstage tools for Windows systems.
|
||||
It's designed to be fast, secure, and enterprise-ready.
|
||||
GuruConnect is a remote desktop solution for MSPs, similar to ConnectWise ScreenConnect. It provides real-time screen sharing, remote control, and support session management.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Dashboard │◄───────►│ GuruConnect │◄───────►│ GuruConnect │
|
||||
│ (React) │ WSS │ Server (Rust) │ WSS │ Agent (Rust) │
|
||||
│ (HTML/JS) │ WSS │ Server (Rust) │ WSS │ Agent (Rust) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────────┐
|
||||
└──────────────────►│ PostgreSQL │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
## Design Constraints
|
||||
|
||||
- `agent/` - Windows remote desktop agent (Rust)
|
||||
- `server/` - Relay server (Rust + Axum)
|
||||
- `dashboard/` - Web viewer (React, to be integrated with GuruRMM)
|
||||
- `proto/` - Protobuf protocol definitions
|
||||
### Agent (Windows)
|
||||
- **Target OS:** Windows 7 SP1 and later (including Server 2008 R2+)
|
||||
- **Single binary:** Agent and viewer in one executable
|
||||
- **No runtime dependencies:** Statically linked, no .NET or VC++ redistributables
|
||||
- **Protocol handler:** `guruconnect://` URL scheme for launching viewer
|
||||
- **Tray icon:** System tray presence with status and exit option
|
||||
- **UAC aware:** Graceful handling of elevated/non-elevated contexts
|
||||
- **Auto-install:** Detects if not installed and offers installation
|
||||
|
||||
## Building
|
||||
### Server (Linux)
|
||||
- **Target OS:** Ubuntu 22.04 LTS
|
||||
- **Framework:** Axum for HTTP/WebSocket
|
||||
- **Database:** PostgreSQL with sqlx (compile-time checked queries)
|
||||
- **Static files:** Served from `server/static/`
|
||||
- **No containers required:** Runs as systemd service or direct binary
|
||||
|
||||
### Prerequisites
|
||||
### Protocol
|
||||
- **Wire format:** Protocol Buffers (protobuf) for ALL client-server messages
|
||||
- **Transport:** WebSocket over TLS (wss://)
|
||||
- **Compression:** Zstd for video frames
|
||||
- **Schema:** `proto/guruconnect.proto` is the source of truth
|
||||
|
||||
- Rust 1.75+ (install via rustup)
|
||||
- Windows SDK (for agent)
|
||||
- protoc (Protocol Buffers compiler)
|
||||
## Security Rules
|
||||
|
||||
### Build Commands
|
||||
### Authentication
|
||||
- **Dashboard/API:** JWT tokens required for all endpoints except `/health` and `/api/auth/login`
|
||||
- **Viewer WebSocket:** JWT token required in `token` query parameter
|
||||
- **Agent WebSocket:** Must provide either:
|
||||
- Valid support code (for ad-hoc support sessions)
|
||||
- Valid API key (for persistent/managed agents)
|
||||
- **Never** accept unauthenticated agent connections
|
||||
|
||||
### Credentials
|
||||
- **Never** hardcode secrets in source code
|
||||
- **Never** commit credentials to git
|
||||
- Use environment variables for all secrets:
|
||||
- `JWT_SECRET` - JWT signing key
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `AGENT_API_KEY` - Optional shared key for agents
|
||||
|
||||
### Password Storage
|
||||
- Use Argon2id for password hashing
|
||||
- Never store plaintext passwords
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### Rust
|
||||
- Use `tracing` crate for logging (not `println!` or `log`)
|
||||
- Use `anyhow` for error handling in binaries
|
||||
- Use `thiserror` for library error types
|
||||
- Prefer `async`/`await` over blocking code
|
||||
- Run `cargo clippy` before commits
|
||||
|
||||
### Logging Levels
|
||||
- `error!` - Failures that need attention
|
||||
- `warn!` - Unexpected but handled situations
|
||||
- `info!` - Normal operational messages (startup, connections, sessions)
|
||||
- `debug!` - Detailed debugging info
|
||||
- `trace!` - Very verbose, message-level tracing
|
||||
|
||||
### Naming
|
||||
- Rust: `snake_case` for functions/variables, `PascalCase` for types
|
||||
- Protobuf: `PascalCase` for messages, `snake_case` for fields
|
||||
- Database: `snake_case` for tables and columns
|
||||
|
||||
## Build & Version
|
||||
|
||||
### Version Format
|
||||
- Semantic versioning: `MAJOR.MINOR.PATCH`
|
||||
- Build identification: `VERSION-GITHASH[-dirty]`
|
||||
- Example: `0.1.0-48076e1` or `0.1.0-48076e1-dirty`
|
||||
|
||||
### Build Info (Agent)
|
||||
The agent embeds at compile time:
|
||||
- `VERSION` - Cargo.toml version
|
||||
- `GIT_HASH` - Short commit hash (8 chars)
|
||||
- `GIT_BRANCH` - Branch name
|
||||
- `GIT_DIRTY` - "clean" or "dirty"
|
||||
- `BUILD_TIMESTAMP` - UTC build time
|
||||
- `BUILD_TARGET` - Target triple
|
||||
|
||||
### Commands
|
||||
```bash
|
||||
# Build all (from workspace root)
|
||||
cargo build --release
|
||||
# Build agent (Windows)
|
||||
cargo build -p guruconnect --release
|
||||
|
||||
# Build agent only
|
||||
cargo build -p guruconnect-agent --release
|
||||
# Build server (Linux, from Linux or cross-compile)
|
||||
cargo build -p guruconnect-server --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
# Build server only
|
||||
cargo build -p guruconnect-server --release
|
||||
# Check version
|
||||
./guruconnect --version # Short: 0.1.0-48076e1
|
||||
./guruconnect version-info # Full details
|
||||
```
|
||||
|
||||
### Cross-compilation (Agent for Windows)
|
||||
## Database Schema
|
||||
|
||||
From Linux build server:
|
||||
```bash
|
||||
# Install Windows target
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
### Key Tables
|
||||
- `users` - Dashboard users (admin-created only)
|
||||
- `machines` - Registered agents (persistent)
|
||||
- `sessions` - Connection sessions (historical)
|
||||
- `events` - Audit log
|
||||
- `support_codes` - One-time support codes
|
||||
|
||||
# Build (requires cross or appropriate linker)
|
||||
cross build -p guruconnect-agent --target x86_64-pc-windows-msvc --release
|
||||
### Conventions
|
||||
- Primary keys: `id UUID DEFAULT gen_random_uuid()`
|
||||
- Timestamps: `created_at TIMESTAMPTZ DEFAULT NOW()`
|
||||
- Soft deletes: Prefer `deleted_at` over hard deletes for audit trail
|
||||
- Foreign keys: Always with `ON DELETE CASCADE` or explicit handling
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
guru-connect/
|
||||
├── agent/ # Windows agent + viewer
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # CLI entry point
|
||||
│ │ ├── capture/ # Screen capture (DXGI, GDI)
|
||||
│ │ ├── encoder/ # Video encoding
|
||||
│ │ ├── input/ # Mouse/keyboard injection
|
||||
│ │ ├── viewer/ # Native viewer window
|
||||
│ │ ├── transport/ # WebSocket client
|
||||
│ │ ├── session/ # Session management
|
||||
│ │ ├── tray/ # System tray
|
||||
│ │ └── install.rs # Installation & protocol handler
|
||||
│ ├── build.rs # Build script (protobuf, version info)
|
||||
│ └── Cargo.toml
|
||||
├── server/ # Linux relay server
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # Server entry point
|
||||
│ │ ├── relay/ # WebSocket relay handlers
|
||||
│ │ ├── session/ # Session state management
|
||||
│ │ ├── auth/ # JWT authentication
|
||||
│ │ ├── api/ # REST API handlers
|
||||
│ │ └── db/ # Database operations
|
||||
│ ├── static/ # Dashboard HTML/JS/CSS
|
||||
│ │ ├── login.html
|
||||
│ │ ├── dashboard.html
|
||||
│ │ ├── viewer.html
|
||||
│ │ └── downloads/ # Agent binaries
|
||||
│ ├── migrations/ # SQL migrations
|
||||
│ └── Cargo.toml
|
||||
├── proto/ # Protocol definitions
|
||||
│ └── guruconnect.proto
|
||||
└── CLAUDE.md # This file
|
||||
```
|
||||
|
||||
## Development
|
||||
## Deployment
|
||||
|
||||
### Running the Server
|
||||
### Server (172.16.3.30)
|
||||
- **Binary:** `/home/guru/guru-connect/target/x86_64-unknown-linux-gnu/release/guruconnect-server`
|
||||
- **Static:** `/home/guru/guru-connect/server/static/`
|
||||
- **Startup:** `~/guru-connect/start-server.sh`
|
||||
- **Port:** 3002 (proxied via NPM to connect.azcomputerguru.com)
|
||||
|
||||
```bash
|
||||
# Development
|
||||
cargo run -p guruconnect-server
|
||||
### Agent Distribution
|
||||
- **Download URL:** https://connect.azcomputerguru.com/downloads/guruconnect.exe
|
||||
- **Auto-update:** Not yet implemented (future feature)
|
||||
|
||||
# With environment variables
|
||||
DATABASE_URL=postgres://... JWT_SECRET=... cargo run -p guruconnect-server
|
||||
```
|
||||
## Issue Tracking
|
||||
|
||||
### Testing the Agent
|
||||
Use Gitea issues: https://git.azcomputerguru.com/azcomputerguru/guru-connect/issues
|
||||
|
||||
The agent must be run on Windows:
|
||||
```powershell
|
||||
# Run from Windows
|
||||
.\target\release\guruconnect-agent.exe
|
||||
```
|
||||
Reference issues in commits:
|
||||
- `Fixes #1` - Closes the issue
|
||||
- `Related to #1` - Links without closing
|
||||
|
||||
## Protocol
|
||||
## Testing Checklist
|
||||
|
||||
Uses Protocol Buffers for efficient message serialization.
|
||||
See `proto/guruconnect.proto` for message definitions.
|
||||
|
||||
Key message types:
|
||||
- `VideoFrame` - Screen frames (raw+zstd, VP9, H264)
|
||||
- `MouseEvent` - Mouse input
|
||||
- `KeyEvent` - Keyboard input
|
||||
- `SessionRequest/Response` - Session management
|
||||
|
||||
## Encoding Strategy
|
||||
|
||||
| Scenario | Encoding |
|
||||
|----------|----------|
|
||||
| LAN (<20ms RTT) | Raw BGRA + Zstd + dirty rects |
|
||||
| WAN + GPU | H264 hardware |
|
||||
| WAN - GPU | VP9 software |
|
||||
|
||||
## Key References
|
||||
|
||||
- RustDesk source: `~/claude-projects/reference/rustdesk/`
|
||||
- GuruRMM: `~/claude-projects/gururmm/`
|
||||
- Plan: `~/.claude/plans/shimmering-wandering-crane.md`
|
||||
|
||||
## Phase 1 MVP Goals
|
||||
|
||||
1. DXGI screen capture with GDI fallback
|
||||
2. Raw + Zstd encoding with dirty rectangle detection
|
||||
3. Mouse and keyboard input injection
|
||||
4. WebSocket relay through server
|
||||
5. Basic React viewer
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- All connections use TLS
|
||||
- JWT authentication for dashboard users
|
||||
- API key authentication for agents
|
||||
- Session audit logging
|
||||
- Optional session recording (Phase 4)
|
||||
Before releasing:
|
||||
- [ ] Agent connects with support code
|
||||
- [ ] Agent connects with API key
|
||||
- [ ] Viewer connects with JWT token
|
||||
- [ ] Unauthenticated connections rejected
|
||||
- [ ] Screen capture works (DXGI primary, GDI fallback)
|
||||
- [ ] Mouse/keyboard input works
|
||||
- [ ] Chat messages relay correctly
|
||||
- [ ] Protocol handler launches viewer
|
||||
- [ ] Tray icon shows correct status
|
||||
|
||||
2567
Cargo.lock
generated
2567
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
109
REQUIREMENTS.md
109
REQUIREMENTS.md
@@ -690,3 +690,112 @@ When a machine is selected, show comprehensive info in side panel:
|
||||
- Single binary server (Docker or native)
|
||||
- Single binary agent (MSI installer + standalone EXE)
|
||||
- Cloud-hostable or on-premises
|
||||
|
||||
---
|
||||
|
||||
## Team Feedback (2025-12-28)
|
||||
|
||||
### Howard's Requirements
|
||||
|
||||
#### Core Remote Support & Access Capabilities
|
||||
|
||||
1. **Screen Sharing & Remote Control**
|
||||
- View and interact with the end-user's desktop in real time
|
||||
- Technicians can control mouse and keyboard, just like sitting at the remote machine
|
||||
|
||||
2. **Attended & Unattended Access**
|
||||
- Attended support: on-demand support sessions where the user connects via a session code or link
|
||||
- Unattended access: persistent remote connections that allow access anytime without user presence
|
||||
|
||||
3. **Session Management**
|
||||
- Initiate, pause, transfer, and end remote sessions
|
||||
- Session transfer: pass control of a session to another technician
|
||||
- Session pause and idle timeout controls
|
||||
|
||||
4. **File & Clipboard Sharing**
|
||||
- Drag-and-drop file transfer between local and remote systems
|
||||
- Clipboard sharing for copy/paste between devices
|
||||
|
||||
5. **Multi-Session Handling**
|
||||
- Technicians can manage multiple concurrent remote sessions
|
||||
|
||||
6. **Multi-Monitor Support**
|
||||
- Seamlessly switch between multiple monitors on the remote system
|
||||
|
||||
#### Advanced Support & Administrative Functions
|
||||
|
||||
7. **Backstage / Silent Support Mode**
|
||||
- Execute tasks, run scripts, and troubleshoot without disrupting the user's screen (background session)
|
||||
|
||||
8. **Shared & Personal Toolboxes**
|
||||
- Save commonly used tools, scripts, or executables
|
||||
- Share them with team members for reuse in sessions
|
||||
|
||||
9. **Custom Scripts & Automation**
|
||||
- Automate repetitive tasks during remote sessions
|
||||
|
||||
10. **Diagnostic & Command Tools**
|
||||
- Run PowerShell, Command Prompt, view system event logs, uninstall apps, start/stop services, kill processes, etc.
|
||||
- Better PowerShell/CMD running abilities with configurable timeouts (checkboxes/text boxes instead of typing every time)
|
||||
|
||||
#### Security & Access Control Features
|
||||
|
||||
11. **Encryption**
|
||||
- All traffic is secured with AES-256 encryption
|
||||
|
||||
12. **Role-Based Permissions**
|
||||
- Create granular technician roles and permissions to control who can do what
|
||||
|
||||
13. **Two-Factor & Login Security**
|
||||
- Support for multi-factor authentication (MFA) and other secure login methodologies
|
||||
|
||||
14. **Session Consent & Alerts**
|
||||
- Require end-user consent before connecting (configurable)
|
||||
- Alerts notify users of maintenance or work in progress
|
||||
|
||||
15. **Audit Logs & Session Recording**
|
||||
- Automatically record sessions
|
||||
- Maintain detailed logs of connections and actions for compliance
|
||||
|
||||
#### Communication & Collaboration Tools
|
||||
|
||||
16. **Real-Time Chat**
|
||||
- Text chat between technician and end user during sessions
|
||||
|
||||
17. **Screen Annotations**
|
||||
- Draw and highlight areas on the user's screen for clearer instructions
|
||||
|
||||
#### Cross-Platform & Mobile Support
|
||||
|
||||
18. **Cross-Platform Support**
|
||||
- Remote control across Windows, macOS, Linux, iOS, and Android
|
||||
|
||||
19. **Mobile Technician Support**
|
||||
- Technicians can support clients from mobile devices (view screens, send Ctrl-Alt-Delete, reboot)
|
||||
|
||||
20. **Guest Mobile Support**
|
||||
- Remote assistance for user Android and iOS devices
|
||||
|
||||
#### Integration & Customization
|
||||
|
||||
21. **PSA & Ticketing Integrations**
|
||||
- Launch support sessions from RMM/PSA and other ticketing systems
|
||||
|
||||
22. **Custom Branding & Interface**
|
||||
- White-labeling, logos, colors, and custom client titles
|
||||
|
||||
23. **Machine Organization & Search**
|
||||
- Dynamic grouping of devices and custom property filtering to locate machines quickly
|
||||
|
||||
#### Reporting & Monitoring
|
||||
|
||||
24. **Session & System Reports**
|
||||
- Audit logs, session histories, technician performance data, etc.
|
||||
|
||||
25. **Diagnostic Reporting**
|
||||
- Collect performance and diagnostic information during or after sessions
|
||||
|
||||
### Additional Notes from Howard
|
||||
|
||||
- **64-bit client requirement** - ScreenConnect doesn't have a 64-bit client, which limits deployment options
|
||||
- **PowerShell timeout controls** - Should have UI controls (checkboxes/text boxes) for timeouts rather than typing commands every time
|
||||
|
||||
230
TODO.md
Normal file
230
TODO.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# GuruConnect Feature Tracking
|
||||
|
||||
## Status Legend
|
||||
- [ ] Not started
|
||||
- [~] In progress
|
||||
- [x] Complete
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Core MVP
|
||||
|
||||
### Infrastructure
|
||||
- [x] WebSocket relay server (Axum)
|
||||
- [x] Agent WebSocket client
|
||||
- [x] Protobuf message protocol
|
||||
- [x] Agent authentication (agent_id, api_key)
|
||||
- [x] Session management (create, join, leave)
|
||||
- [x] Systemd service deployment
|
||||
- [x] NPM proxy (connect.azcomputerguru.com)
|
||||
|
||||
### Support Codes
|
||||
- [x] Generate 6-digit codes
|
||||
- [x] Code validation API
|
||||
- [x] Code status tracking (pending, connected, completed, cancelled)
|
||||
- [~] Link support codes to agent sessions
|
||||
- [ ] Code expiration (auto-expire after X minutes)
|
||||
- [ ] Support code in agent download URL
|
||||
|
||||
### Dashboard
|
||||
- [x] Technician login page
|
||||
- [x] Support tab with code generation
|
||||
- [x] Access tab with connected agents
|
||||
- [ ] Session detail panel with tabs
|
||||
- [ ] Screenshot thumbnails
|
||||
- [ ] Join/Connect button
|
||||
|
||||
### Agent (Windows)
|
||||
- [x] DXGI screen capture
|
||||
- [x] GDI fallback capture
|
||||
- [x] WebSocket connection
|
||||
- [x] Config persistence (agent_id)
|
||||
- [ ] Support code parameter
|
||||
- [ ] Hostname/machine info reporting
|
||||
- [ ] Screenshot-only mode (for thumbnails)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Remote Control
|
||||
|
||||
### Screen Viewing
|
||||
- [ ] Web-based viewer (canvas)
|
||||
- [ ] Raw frame decoding
|
||||
- [ ] Dirty rectangle optimization
|
||||
- [ ] Frame rate adaptation
|
||||
|
||||
### Input Control
|
||||
- [x] Mouse event handling (agent)
|
||||
- [x] Keyboard event handling (agent)
|
||||
- [ ] Input relay through server
|
||||
- [ ] Multi-monitor support
|
||||
|
||||
### Encoding
|
||||
- [ ] VP9 software encoding
|
||||
- [ ] H.264 hardware encoding (NVENC/QSV)
|
||||
- [ ] Adaptive quality based on bandwidth
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Backstage Tools (like ScreenConnect)
|
||||
|
||||
### Device Information
|
||||
- [ ] OS version, hostname, domain
|
||||
- [ ] Logged-in user
|
||||
- [ ] Public/private IP addresses
|
||||
- [ ] MAC address
|
||||
- [ ] CPU, RAM, disk info
|
||||
- [ ] Uptime
|
||||
|
||||
### Toolbox APIs
|
||||
- [ ] Process list (name, PID, memory)
|
||||
- [ ] Installed software list
|
||||
- [ ] Windows services list
|
||||
- [ ] Event log viewer
|
||||
- [ ] Registry browser
|
||||
|
||||
### Remote Commands
|
||||
- [ ] Run shell commands
|
||||
- [ ] PowerShell execution
|
||||
- [ ] Command output streaming
|
||||
- [ ] Command history per session
|
||||
|
||||
### Chat/Messaging
|
||||
- [ ] Technician → Client messages
|
||||
- [ ] Client → Technician messages
|
||||
- [ ] Message history
|
||||
|
||||
### File Transfer
|
||||
- [ ] Upload files to remote
|
||||
- [ ] Download files from remote
|
||||
- [ ] Progress tracking
|
||||
- [ ] Folder browsing
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Session Management
|
||||
|
||||
### Timeline/History
|
||||
- [ ] Connection events
|
||||
- [ ] Session duration tracking
|
||||
- [ ] Guest connection history
|
||||
- [ ] Activity log
|
||||
|
||||
### Session Recording
|
||||
- [ ] Record session video
|
||||
- [ ] Playback interface
|
||||
- [ ] Storage management
|
||||
|
||||
### Notes
|
||||
- [ ] Per-session notes
|
||||
- [ ] Session tagging
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Access Mode (Unattended)
|
||||
|
||||
### Persistent Agent
|
||||
- [ ] Windows service installation
|
||||
- [ ] Auto-start on boot
|
||||
- [ ] Silent/background mode
|
||||
- [ ] Automatic reconnection
|
||||
|
||||
### Machine Groups
|
||||
- [ ] Company/client organization
|
||||
- [ ] Site/location grouping
|
||||
- [ ] Custom tags
|
||||
- [ ] Filtering/search
|
||||
|
||||
### Installer Builder
|
||||
- [ ] Customized agent builds
|
||||
- [ ] Pre-configured company/site
|
||||
- [ ] Silent install options
|
||||
- [ ] MSI packaging
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Security & Authentication
|
||||
|
||||
### Technician Auth
|
||||
- [ ] User accounts
|
||||
- [ ] Password hashing
|
||||
- [ ] JWT tokens
|
||||
- [ ] Session management
|
||||
|
||||
### MFA
|
||||
- [ ] TOTP (Google Authenticator)
|
||||
- [ ] Email verification
|
||||
|
||||
### Audit Logging
|
||||
- [ ] Login attempts
|
||||
- [ ] Session access
|
||||
- [ ] Command execution
|
||||
- [ ] File transfers
|
||||
|
||||
### Permissions
|
||||
- [ ] Role-based access
|
||||
- [ ] Per-client permissions
|
||||
- [ ] Feature restrictions
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Integrations
|
||||
|
||||
### PSA Integration
|
||||
- [ ] HaloPSA
|
||||
- [ ] Autotask
|
||||
- [ ] ConnectWise
|
||||
|
||||
### GuruRMM Integration
|
||||
- [ ] Dashboard embedding
|
||||
- [ ] Single sign-on
|
||||
- [ ] Asset linking
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish
|
||||
|
||||
### Branding
|
||||
- [ ] White-label support
|
||||
- [ ] Custom logos
|
||||
- [ ] Custom colors
|
||||
|
||||
### Mobile Support
|
||||
- [ ] Responsive viewer
|
||||
- [ ] Touch input handling
|
||||
|
||||
### Annotations
|
||||
- [ ] Draw on screen
|
||||
- [ ] Pointer highlighting
|
||||
- [ ] Screenshot annotations
|
||||
|
||||
---
|
||||
|
||||
## Current Sprint
|
||||
|
||||
### In Progress
|
||||
1. Link support codes to agent sessions
|
||||
2. Show connected status in dashboard
|
||||
|
||||
### Next Up
|
||||
1. Support code in agent download/config
|
||||
2. Device info reporting from agent
|
||||
3. Screenshot thumbnails
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
### ScreenConnect Feature Reference (from screenshots)
|
||||
- Support session list with idle times and connection bars
|
||||
- Detail panel with tabbed interface:
|
||||
- Join/Screen (thumbnail, Join button)
|
||||
- Info (device details)
|
||||
- Timeline (connection history)
|
||||
- Chat (messaging)
|
||||
- Commands (shell execution)
|
||||
- Notes
|
||||
- Toolbox (processes, software, events, services)
|
||||
- File transfer
|
||||
- Logs
|
||||
- Settings
|
||||
@@ -1,11 +1,14 @@
|
||||
[package]
|
||||
name = "guruconnect-agent"
|
||||
name = "guruconnect"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["AZ Computer Guru"]
|
||||
description = "GuruConnect Remote Desktop Agent"
|
||||
description = "GuruConnect Remote Desktop - Agent and Viewer"
|
||||
|
||||
[dependencies]
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full", "sync", "time", "rt-multi-thread", "macros"] }
|
||||
|
||||
@@ -13,6 +16,11 @@ tokio = { version = "1", features = ["full", "sync", "time", "rt-multi-thread",
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Windowing (for viewer)
|
||||
winit = { version = "0.30", features = ["rwh_06"] }
|
||||
softbuffer = "0.4"
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
# Compression
|
||||
zstd = "0.13"
|
||||
|
||||
@@ -38,6 +46,10 @@ toml = "0.8"
|
||||
|
||||
# Crypto
|
||||
ring = "0.17"
|
||||
sha2 = "0.10"
|
||||
|
||||
# HTTP client for updates
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
@@ -48,8 +60,21 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
# Hostname
|
||||
hostname = "0.4"
|
||||
|
||||
# URL encoding
|
||||
urlencoding = "2"
|
||||
|
||||
# System tray (Windows)
|
||||
tray-icon = "0.19"
|
||||
muda = "0.15" # Menu for tray icon
|
||||
|
||||
# Image handling for tray icon
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
# URL parsing
|
||||
url = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Windows APIs for screen capture and input
|
||||
# Windows APIs for screen capture, input, and shell operations
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
@@ -59,9 +84,17 @@ windows = { version = "0.58", features = [
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Registry",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Environment",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_SystemServices",
|
||||
"Win32_System_IO",
|
||||
]}
|
||||
|
||||
# Windows service support
|
||||
@@ -69,10 +102,13 @@ windows-service = "0.7"
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
winres = "0.1"
|
||||
chrono = "0.4"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
opt-level = "z"
|
||||
strip = true
|
||||
panic = "abort"
|
||||
[[bin]]
|
||||
name = "guruconnect"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "guruconnect-sas-service"
|
||||
path = "src/bin/sas_service.rs"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::io::Result;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Compile protobuf definitions
|
||||
@@ -7,5 +8,91 @@ fn main() -> Result<()> {
|
||||
// Rerun if proto changes
|
||||
println!("cargo:rerun-if-changed=../proto/guruconnect.proto");
|
||||
|
||||
// Rerun if git HEAD changes (new commits)
|
||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../.git/index");
|
||||
|
||||
// Build timestamp (UTC)
|
||||
let build_timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||
println!("cargo:rustc-env=BUILD_TIMESTAMP={}", build_timestamp);
|
||||
|
||||
// Git commit hash (short)
|
||||
let git_hash = Command::new("git")
|
||||
.args(["rev-parse", "--short=8", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
println!("cargo:rustc-env=GIT_HASH={}", git_hash);
|
||||
|
||||
// Git commit hash (full)
|
||||
let git_hash_full = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
println!("cargo:rustc-env=GIT_HASH_FULL={}", git_hash_full);
|
||||
|
||||
// Git branch name
|
||||
let git_branch = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
println!("cargo:rustc-env=GIT_BRANCH={}", git_branch);
|
||||
|
||||
// Git dirty state (uncommitted changes)
|
||||
let git_dirty = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| !o.stdout.is_empty())
|
||||
.unwrap_or(false);
|
||||
println!("cargo:rustc-env=GIT_DIRTY={}", if git_dirty { "dirty" } else { "clean" });
|
||||
|
||||
// Git commit date
|
||||
let git_commit_date = Command::new("git")
|
||||
.args(["log", "-1", "--format=%ci"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
println!("cargo:rustc-env=GIT_COMMIT_DATE={}", git_commit_date);
|
||||
|
||||
// Build profile (debug/release)
|
||||
let profile = std::env::var("PROFILE").unwrap_or_else(|_| "unknown".to_string());
|
||||
println!("cargo:rustc-env=BUILD_PROFILE={}", profile);
|
||||
|
||||
// Target triple
|
||||
let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
|
||||
println!("cargo:rustc-env=BUILD_TARGET={}", target);
|
||||
|
||||
// On Windows, embed the manifest for UAC elevation
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
println!("cargo:rerun-if-changed=guruconnect.manifest");
|
||||
|
||||
let mut res = winres::WindowsResource::new();
|
||||
res.set_manifest_file("guruconnect.manifest");
|
||||
res.set("ProductName", "GuruConnect Agent");
|
||||
res.set("FileDescription", "GuruConnect Remote Desktop Agent");
|
||||
res.set("LegalCopyright", "Copyright (c) AZ Computer Guru");
|
||||
res.set_icon("guruconnect.ico"); // Optional: add icon if available
|
||||
|
||||
// Only compile if the manifest exists
|
||||
if std::path::Path::new("guruconnect.manifest").exists() {
|
||||
if let Err(e) = res.compile() {
|
||||
// Don't fail the build if resource compilation fails
|
||||
eprintln!("Warning: Failed to compile Windows resources: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
36
agent/guruconnect.manifest
Normal file
36
agent/guruconnect.manifest
Normal file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity
|
||||
version="1.0.0.0"
|
||||
processorArchitecture="*"
|
||||
name="GuruConnect.Agent"
|
||||
type="win32"
|
||||
/>
|
||||
<description>GuruConnect Remote Desktop Agent</description>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<!-- Request highest available privileges (admin if possible, user otherwise) -->
|
||||
<requestedExecutionLevel level="highestAvailable" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 and Windows 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
<!-- Windows 8.1 -->
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||
<!-- Windows 8 -->
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||
<!-- Windows 7 -->
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
638
agent/src/bin/sas_service.rs
Normal file
638
agent/src/bin/sas_service.rs
Normal file
@@ -0,0 +1,638 @@
|
||||
//! GuruConnect SAS Service
|
||||
//!
|
||||
//! Windows Service running as SYSTEM to handle Ctrl+Alt+Del (Secure Attention Sequence).
|
||||
//! The agent communicates with this service via named pipe IPC.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::{Read, Write as IoWrite};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use windows::core::{s, w};
|
||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
|
||||
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},
|
||||
};
|
||||
|
||||
// Service configuration
|
||||
const SERVICE_NAME: &str = "GuruConnectSAS";
|
||||
const SERVICE_DISPLAY_NAME: &str = "GuruConnect SAS Service";
|
||||
const SERVICE_DESCRIPTION: &str = "Handles Secure Attention Sequence (Ctrl+Alt+Del) for GuruConnect remote sessions";
|
||||
const PIPE_NAME: &str = r"\\.\pipe\guruconnect-sas";
|
||||
const INSTALL_DIR: &str = r"C:\Program Files\GuruConnect";
|
||||
|
||||
// Windows named pipe constants
|
||||
const PIPE_ACCESS_DUPLEX: u32 = 0x00000003;
|
||||
const PIPE_TYPE_MESSAGE: u32 = 0x00000004;
|
||||
const PIPE_READMODE_MESSAGE: u32 = 0x00000002;
|
||||
const PIPE_WAIT: u32 = 0x00000000;
|
||||
const PIPE_UNLIMITED_INSTANCES: u32 = 255;
|
||||
const INVALID_HANDLE_VALUE: isize = -1;
|
||||
const SECURITY_DESCRIPTOR_REVISION: u32 = 1;
|
||||
|
||||
// FFI declarations for named pipe operations
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn CreateNamedPipeW(
|
||||
lpName: *const u16,
|
||||
dwOpenMode: u32,
|
||||
dwPipeMode: u32,
|
||||
nMaxInstances: u32,
|
||||
nOutBufferSize: u32,
|
||||
nInBufferSize: u32,
|
||||
nDefaultTimeOut: u32,
|
||||
lpSecurityAttributes: *mut SECURITY_ATTRIBUTES,
|
||||
) -> isize;
|
||||
|
||||
fn ConnectNamedPipe(hNamedPipe: isize, lpOverlapped: *mut std::ffi::c_void) -> i32;
|
||||
fn DisconnectNamedPipe(hNamedPipe: isize) -> i32;
|
||||
fn CloseHandle(hObject: isize) -> i32;
|
||||
fn ReadFile(
|
||||
hFile: isize,
|
||||
lpBuffer: *mut u8,
|
||||
nNumberOfBytesToRead: u32,
|
||||
lpNumberOfBytesRead: *mut u32,
|
||||
lpOverlapped: *mut std::ffi::c_void,
|
||||
) -> i32;
|
||||
fn WriteFile(
|
||||
hFile: isize,
|
||||
lpBuffer: *const u8,
|
||||
nNumberOfBytesToWrite: u32,
|
||||
lpNumberOfBytesWritten: *mut u32,
|
||||
lpOverlapped: *mut std::ffi::c_void,
|
||||
) -> i32;
|
||||
fn FlushFileBuffers(hFile: isize) -> i32;
|
||||
}
|
||||
|
||||
#[link(name = "advapi32")]
|
||||
extern "system" {
|
||||
fn InitializeSecurityDescriptor(pSecurityDescriptor: *mut u8, dwRevision: u32) -> i32;
|
||||
fn SetSecurityDescriptorDacl(
|
||||
pSecurityDescriptor: *mut u8,
|
||||
bDaclPresent: i32,
|
||||
pDacl: *mut std::ffi::c_void,
|
||||
bDaclDefaulted: i32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct SECURITY_ATTRIBUTES {
|
||||
nLength: u32,
|
||||
lpSecurityDescriptor: *mut u8,
|
||||
bInheritHandle: i32,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Set up logging
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::INFO)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
match std::env::args().nth(1).as_deref() {
|
||||
Some("install") => {
|
||||
if let Err(e) = install_service() {
|
||||
eprintln!("Failed to install service: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some("uninstall") => {
|
||||
if let Err(e) = uninstall_service() {
|
||||
eprintln!("Failed to uninstall service: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some("start") => {
|
||||
if let Err(e) = start_service() {
|
||||
eprintln!("Failed to start service: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some("stop") => {
|
||||
if let Err(e) = stop_service() {
|
||||
eprintln!("Failed to stop service: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some("status") => {
|
||||
if let Err(e) = query_status() {
|
||||
eprintln!("Failed to query status: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some("service") => {
|
||||
// Called by SCM when service starts
|
||||
if let Err(e) = run_as_service() {
|
||||
eprintln!("Service error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some("test") => {
|
||||
// Test mode: run pipe server directly (for debugging)
|
||||
println!("Running in test mode (not as service)...");
|
||||
if let Err(e) = run_pipe_server() {
|
||||
eprintln!("Pipe server error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
print_usage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
println!("GuruConnect SAS Service");
|
||||
println!();
|
||||
println!("Usage: guruconnect-sas-service <command>");
|
||||
println!();
|
||||
println!("Commands:");
|
||||
println!(" install Install the service");
|
||||
println!(" uninstall Remove the service");
|
||||
println!(" start Start the service");
|
||||
println!(" stop Stop the service");
|
||||
println!(" status Query service status");
|
||||
println!(" test Run in test mode (not as service)");
|
||||
}
|
||||
|
||||
// Generate the Windows service boilerplate
|
||||
define_windows_service!(ffi_service_main, service_main);
|
||||
|
||||
/// Entry point called by the Windows Service Control Manager
|
||||
fn run_as_service() -> Result<()> {
|
||||
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() {
|
||||
tracing::error!("Service error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// The actual service implementation
|
||||
fn run_service() -> 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 | ServiceControl::Shutdown => {
|
||||
tracing::info!("Received stop/shutdown command");
|
||||
let _ = shutdown_tx.send(());
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
ServiceControl::Interrogate => 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(5),
|
||||
process_id: None,
|
||||
})
|
||||
.ok();
|
||||
|
||||
// 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,
|
||||
})
|
||||
.ok();
|
||||
|
||||
tracing::info!("GuruConnect SAS Service started");
|
||||
|
||||
// Run the pipe server in a separate thread
|
||||
let pipe_handle = std::thread::spawn(|| {
|
||||
if let Err(e) = run_pipe_server() {
|
||||
tracing::error!("Pipe server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for shutdown signal
|
||||
let _ = shutdown_rx.recv();
|
||||
|
||||
tracing::info!("Shutting down...");
|
||||
|
||||
// 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(3),
|
||||
process_id: None,
|
||||
})
|
||||
.ok();
|
||||
|
||||
// The pipe thread will exit when the service stops
|
||||
drop(pipe_handle);
|
||||
|
||||
// Report stopped
|
||||
status_handle
|
||||
.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::Stopped,
|
||||
controls_accepted: ServiceControlAccept::empty(),
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
process_id: None,
|
||||
})
|
||||
.ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the named pipe server
|
||||
fn run_pipe_server() -> Result<()> {
|
||||
tracing::info!("Starting pipe server on {}", PIPE_NAME);
|
||||
|
||||
loop {
|
||||
// Create security descriptor that allows everyone
|
||||
let mut sd = [0u8; 256];
|
||||
unsafe {
|
||||
if InitializeSecurityDescriptor(sd.as_mut_ptr(), SECURITY_DESCRIPTOR_REVISION) == 0 {
|
||||
tracing::error!("Failed to initialize security descriptor");
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set NULL DACL = allow everyone
|
||||
if SetSecurityDescriptorDacl(sd.as_mut_ptr(), 1, std::ptr::null_mut(), 0) == 0 {
|
||||
tracing::error!("Failed to set security descriptor DACL");
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let mut sa = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: sd.as_mut_ptr(),
|
||||
bInheritHandle: 0,
|
||||
};
|
||||
|
||||
// Create the pipe name as wide string
|
||||
let pipe_name: Vec<u16> = PIPE_NAME.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
// Create the named pipe
|
||||
let pipe = unsafe {
|
||||
CreateNamedPipeW(
|
||||
pipe_name.as_ptr(),
|
||||
PIPE_ACCESS_DUPLEX,
|
||||
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
|
||||
PIPE_UNLIMITED_INSTANCES,
|
||||
512,
|
||||
512,
|
||||
0,
|
||||
&mut sa,
|
||||
)
|
||||
};
|
||||
|
||||
if pipe == INVALID_HANDLE_VALUE {
|
||||
tracing::error!("Failed to create named pipe");
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!("Waiting for client connection...");
|
||||
|
||||
// Wait for a client to connect
|
||||
let connected = unsafe { ConnectNamedPipe(pipe, std::ptr::null_mut()) };
|
||||
if connected == 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
// ERROR_PIPE_CONNECTED (535) means client connected between Create and Connect
|
||||
if err.raw_os_error() != Some(535) {
|
||||
tracing::warn!("ConnectNamedPipe error: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Client connected");
|
||||
|
||||
// Read command from pipe
|
||||
let mut buffer = [0u8; 512];
|
||||
let mut bytes_read = 0u32;
|
||||
|
||||
let read_result = unsafe {
|
||||
ReadFile(
|
||||
pipe,
|
||||
buffer.as_mut_ptr(),
|
||||
buffer.len() as u32,
|
||||
&mut bytes_read,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if read_result != 0 && bytes_read > 0 {
|
||||
let command = String::from_utf8_lossy(&buffer[..bytes_read as usize]);
|
||||
let command = command.trim();
|
||||
|
||||
tracing::info!("Received command: {}", command);
|
||||
|
||||
let response = match command {
|
||||
"sas" => {
|
||||
match send_sas() {
|
||||
Ok(()) => {
|
||||
tracing::info!("SendSAS executed successfully");
|
||||
"ok\n"
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("SendSAS failed: {}", e);
|
||||
"error\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
"ping" => {
|
||||
tracing::info!("Ping received");
|
||||
"pong\n"
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Unknown command: {}", command);
|
||||
"unknown\n"
|
||||
}
|
||||
};
|
||||
|
||||
// Write response
|
||||
let mut bytes_written = 0u32;
|
||||
unsafe {
|
||||
WriteFile(
|
||||
pipe,
|
||||
response.as_ptr(),
|
||||
response.len() as u32,
|
||||
&mut bytes_written,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
FlushFileBuffers(pipe);
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect and close the pipe
|
||||
unsafe {
|
||||
DisconnectNamedPipe(pipe);
|
||||
CloseHandle(pipe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Call SendSAS via sas.dll
|
||||
fn send_sas() -> Result<()> {
|
||||
unsafe {
|
||||
let lib = LoadLibraryW(w!("sas.dll")).context("Failed to load sas.dll")?;
|
||||
|
||||
let proc = GetProcAddress(lib, s!("SendSAS"));
|
||||
if proc.is_none() {
|
||||
anyhow::bail!("SendSAS function not found in sas.dll");
|
||||
}
|
||||
|
||||
// SendSAS takes a BOOL parameter: FALSE (0) = Ctrl+Alt+Del
|
||||
type SendSASFn = unsafe extern "system" fn(i32);
|
||||
let send_sas_fn: SendSASFn = std::mem::transmute(proc.unwrap());
|
||||
|
||||
tracing::info!("Calling SendSAS(0)...");
|
||||
send_sas_fn(0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the service
|
||||
fn install_service() -> Result<()> {
|
||||
println!("Installing GuruConnect SAS Service...");
|
||||
|
||||
// Get current executable path
|
||||
let current_exe = std::env::current_exe().context("Failed to get current executable")?;
|
||||
|
||||
let binary_dest = std::path::PathBuf::from(format!(r"{}\\guruconnect-sas-service.exe", INSTALL_DIR));
|
||||
|
||||
// Create install directory
|
||||
std::fs::create_dir_all(INSTALL_DIR).context("Failed to create install directory")?;
|
||||
|
||||
// Copy binary
|
||||
println!("Copying binary to: {:?}", binary_dest);
|
||||
std::fs::copy(¤t_exe, &binary_dest).context("Failed to copy binary")?;
|
||||
|
||||
// Open 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 exists and remove it
|
||||
if let Ok(service) = manager.open_service(
|
||||
SERVICE_NAME,
|
||||
ServiceAccess::QUERY_STATUS | ServiceAccess::DELETE | ServiceAccess::STOP,
|
||||
) {
|
||||
println!("Removing existing service...");
|
||||
|
||||
if let Ok(status) = service.query_status() {
|
||||
if status.current_state != ServiceState::Stopped {
|
||||
let _ = service.stop();
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
service.delete().context("Failed to delete existing service")?;
|
||||
drop(service);
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
|
||||
// Create the service
|
||||
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
|
||||
let _ = std::process::Command::new("sc")
|
||||
.args([
|
||||
"failure",
|
||||
SERVICE_NAME,
|
||||
"reset=86400",
|
||||
"actions=restart/5000/restart/5000/restart/5000",
|
||||
])
|
||||
.output();
|
||||
|
||||
println!("\n** GuruConnect SAS Service installed successfully!");
|
||||
println!("\nBinary: {:?}", binary_dest);
|
||||
println!("\nStarting service...");
|
||||
|
||||
// Start the service
|
||||
start_service()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstall the service
|
||||
fn uninstall_service() -> Result<()> {
|
||||
println!("Uninstalling GuruConnect SAS Service...");
|
||||
|
||||
let binary_path = std::path::PathBuf::from(format!(r"{}\\guruconnect-sas-service.exe", INSTALL_DIR));
|
||||
|
||||
let manager = ServiceManager::local_computer(
|
||||
None::<&str>,
|
||||
ServiceManagerAccess::CONNECT,
|
||||
)
|
||||
.context("Failed to connect to Service Control Manager. Run as Administrator.")?;
|
||||
|
||||
match manager.open_service(
|
||||
SERVICE_NAME,
|
||||
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
|
||||
) {
|
||||
Ok(service) => {
|
||||
if let Ok(status) = service.query_status() {
|
||||
if status.current_state != ServiceState::Stopped {
|
||||
println!("Stopping service...");
|
||||
let _ = service.stop();
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
|
||||
println!("Deleting service...");
|
||||
service.delete().context("Failed to delete service")?;
|
||||
}
|
||||
Err(_) => {
|
||||
println!("Service was not installed");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove binary
|
||||
if binary_path.exists() {
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
if let Err(e) = std::fs::remove_file(&binary_path) {
|
||||
println!("Warning: Failed to remove binary: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n** GuruConnect SAS Service uninstalled successfully!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start the service
|
||||
fn start_service() -> Result<()> {
|
||||
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")?;
|
||||
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
|
||||
let status = service.query_status()?;
|
||||
match status.current_state {
|
||||
ServiceState::Running => println!("** Service started successfully"),
|
||||
ServiceState::StartPending => println!("** Service is starting..."),
|
||||
other => println!("Service state: {:?}", other),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the service
|
||||
fn stop_service() -> Result<()> {
|
||||
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")?;
|
||||
|
||||
service.stop().context("Failed to stop service")?;
|
||||
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
|
||||
let status = service.query_status()?;
|
||||
match status.current_state {
|
||||
ServiceState::Stopped => println!("** Service stopped"),
|
||||
ServiceState::StopPending => println!("** Service is stopping..."),
|
||||
other => println!("Service state: {:?}", other),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Query service status
|
||||
fn query_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!("GuruConnect SAS Service");
|
||||
println!("=======================");
|
||||
println!("Name: {}", SERVICE_NAME);
|
||||
println!("State: {:?}", status.current_state);
|
||||
println!("Binary: {}\\guruconnect-sas-service.exe", INSTALL_DIR);
|
||||
println!("Pipe: {}", PIPE_NAME);
|
||||
}
|
||||
Err(_) => {
|
||||
println!("GuruConnect SAS Service");
|
||||
println!("=======================");
|
||||
println!("Status: NOT INSTALLED");
|
||||
println!("\nTo install: guruconnect-sas-service install");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -78,12 +78,15 @@ pub fn enumerate_displays() -> Result<Vec<Display>> {
|
||||
// Collect all monitor handles
|
||||
let mut monitors: Vec<(windows::Win32::Graphics::Gdi::HMONITOR, u32)> = Vec::new();
|
||||
unsafe {
|
||||
EnumDisplayMonitors(
|
||||
let result = EnumDisplayMonitors(
|
||||
None,
|
||||
None,
|
||||
Some(enum_callback),
|
||||
LPARAM(&mut monitors as *mut _ as isize),
|
||||
)?;
|
||||
);
|
||||
if !result.as_bool() {
|
||||
anyhow::bail!("EnumDisplayMonitors failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Get detailed info for each monitor
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::time::Instant;
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
|
||||
D3D11_CPU_ACCESS_READ, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
|
||||
D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
|
||||
D3D11_USAGE_STAGING, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
@@ -55,7 +55,7 @@ impl DxgiCapturer {
|
||||
|
||||
/// Create D3D device and output duplication
|
||||
fn create_duplication(
|
||||
display: &Display,
|
||||
target_display: &Display,
|
||||
) -> Result<(ID3D11Device, ID3D11DeviceContext, IDXGIOutputDuplication, DXGI_OUTDUPL_DESC)> {
|
||||
unsafe {
|
||||
// Create DXGI factory
|
||||
@@ -63,7 +63,7 @@ impl DxgiCapturer {
|
||||
.context("Failed to create DXGI factory")?;
|
||||
|
||||
// Find the adapter and output for this display
|
||||
let (adapter, output) = Self::find_adapter_output(&factory, display)?;
|
||||
let (adapter, output) = Self::find_adapter_output(&factory, target_display)?;
|
||||
|
||||
// Create D3D11 device
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
@@ -94,14 +94,13 @@ impl DxgiCapturer {
|
||||
.context("Failed to create output duplication")?;
|
||||
|
||||
// Get duplication description
|
||||
let mut desc = DXGI_OUTDUPL_DESC::default();
|
||||
duplication.GetDesc(&mut desc);
|
||||
let desc = duplication.GetDesc();
|
||||
|
||||
tracing::info!(
|
||||
"Created DXGI duplication: {}x{}, display: {}",
|
||||
desc.ModeDesc.Width,
|
||||
desc.ModeDesc.Height,
|
||||
display.name
|
||||
target_display.name
|
||||
);
|
||||
|
||||
Ok((device, context, duplication, desc))
|
||||
@@ -133,8 +132,7 @@ impl DxgiCapturer {
|
||||
};
|
||||
|
||||
// Check if this is the display we want
|
||||
let mut desc = Default::default();
|
||||
output.GetDesc(&mut desc)?;
|
||||
let desc = output.GetDesc()?;
|
||||
|
||||
let name = String::from_utf16_lossy(
|
||||
&desc.DeviceName[..desc.DeviceName.iter().position(|&c| c == 0).unwrap_or(desc.DeviceName.len())]
|
||||
@@ -169,15 +167,18 @@ impl DxgiCapturer {
|
||||
|
||||
desc.Usage = D3D11_USAGE_STAGING;
|
||||
desc.BindFlags = Default::default();
|
||||
desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
|
||||
desc.CPUAccessFlags = 0x20000; // D3D11_CPU_ACCESS_READ
|
||||
desc.MiscFlags = Default::default();
|
||||
|
||||
let staging = self.device.CreateTexture2D(&desc, None)
|
||||
let mut staging: Option<ID3D11Texture2D> = None;
|
||||
self.device.CreateTexture2D(&desc, None, Some(&mut staging))
|
||||
.context("Failed to create staging texture")?;
|
||||
|
||||
let staging = staging.context("Staging texture is None")?;
|
||||
|
||||
// Set high priority
|
||||
let resource: IDXGIResource = staging.cast()?;
|
||||
resource.SetEvictionPriority(DXGI_RESOURCE_PRIORITY_MAXIMUM.0)?;
|
||||
resource.SetEvictionPriority(DXGI_RESOURCE_PRIORITY_MAXIMUM)?;
|
||||
|
||||
self.staging_texture = Some(staging);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Slower than DXGI but works on older systems and edge cases.
|
||||
|
||||
use super::{CapturedFrame, Capturer, Display};
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
use std::time::Instant;
|
||||
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
@@ -58,7 +58,7 @@ impl GdiCapturer {
|
||||
let old_bitmap = SelectObject(mem_dc, bitmap);
|
||||
|
||||
// Copy screen to memory DC
|
||||
let result = BitBlt(
|
||||
if let Err(e) = BitBlt(
|
||||
mem_dc,
|
||||
0,
|
||||
0,
|
||||
@@ -68,14 +68,12 @@ impl GdiCapturer {
|
||||
self.display.x,
|
||||
self.display.y,
|
||||
SRCCOPY,
|
||||
);
|
||||
|
||||
if !result.as_bool() {
|
||||
) {
|
||||
SelectObject(mem_dc, old_bitmap);
|
||||
DeleteObject(bitmap);
|
||||
DeleteDC(mem_dc);
|
||||
ReleaseDC(HWND::default(), screen_dc);
|
||||
anyhow::bail!("BitBlt failed");
|
||||
anyhow::bail!("BitBlt failed: {}", e);
|
||||
}
|
||||
|
||||
// Prepare bitmap info for GetDIBits
|
||||
|
||||
172
agent/src/chat/mod.rs
Normal file
172
agent/src/chat/mod.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! Chat window for the agent
|
||||
//!
|
||||
//! Provides a simple chat interface for communication between
|
||||
//! the technician and the end user.
|
||||
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use tracing::{info, warn, error};
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::Foundation::*;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::Graphics::Gdi::*;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
#[cfg(windows)]
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
/// A chat message
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatMessage {
|
||||
pub id: String,
|
||||
pub sender: String,
|
||||
pub content: String,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
/// Commands that can be sent to the chat window
|
||||
#[derive(Debug)]
|
||||
pub enum ChatCommand {
|
||||
Show,
|
||||
Hide,
|
||||
AddMessage(ChatMessage),
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Controller for the chat window
|
||||
pub struct ChatController {
|
||||
command_tx: Sender<ChatCommand>,
|
||||
message_rx: Arc<Mutex<Receiver<ChatMessage>>>,
|
||||
_handle: thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl ChatController {
|
||||
/// Create a new chat controller (spawns chat window thread)
|
||||
#[cfg(windows)]
|
||||
pub fn new() -> Option<Self> {
|
||||
let (command_tx, command_rx) = mpsc::channel::<ChatCommand>();
|
||||
let (message_tx, message_rx) = mpsc::channel::<ChatMessage>();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
run_chat_window(command_rx, message_tx);
|
||||
});
|
||||
|
||||
Some(Self {
|
||||
command_tx,
|
||||
message_rx: Arc::new(Mutex::new(message_rx)),
|
||||
_handle: handle,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn new() -> Option<Self> {
|
||||
warn!("Chat window not supported on this platform");
|
||||
None
|
||||
}
|
||||
|
||||
/// Show the chat window
|
||||
pub fn show(&self) {
|
||||
let _ = self.command_tx.send(ChatCommand::Show);
|
||||
}
|
||||
|
||||
/// Hide the chat window
|
||||
pub fn hide(&self) {
|
||||
let _ = self.command_tx.send(ChatCommand::Hide);
|
||||
}
|
||||
|
||||
/// Add a message to the chat window
|
||||
pub fn add_message(&self, msg: ChatMessage) {
|
||||
let _ = self.command_tx.send(ChatCommand::AddMessage(msg));
|
||||
}
|
||||
|
||||
/// Check for outgoing messages from the user
|
||||
pub fn poll_outgoing(&self) -> Option<ChatMessage> {
|
||||
if let Ok(rx) = self.message_rx.lock() {
|
||||
rx.try_recv().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the chat window
|
||||
pub fn close(&self) {
|
||||
let _ = self.command_tx.send(ChatCommand::Close);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn run_chat_window(command_rx: Receiver<ChatCommand>, message_tx: Sender<ChatMessage>) {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
info!("Starting chat window thread");
|
||||
|
||||
// For now, we'll use a simple message box approach
|
||||
// A full implementation would create a proper window with a text input
|
||||
|
||||
// Process commands
|
||||
loop {
|
||||
match command_rx.recv() {
|
||||
Ok(ChatCommand::Show) => {
|
||||
info!("Chat window: Show requested");
|
||||
// Show a simple notification that chat is available
|
||||
}
|
||||
Ok(ChatCommand::Hide) => {
|
||||
info!("Chat window: Hide requested");
|
||||
}
|
||||
Ok(ChatCommand::AddMessage(msg)) => {
|
||||
info!("Chat message received: {} - {}", msg.sender, msg.content);
|
||||
|
||||
// Show the message to the user via a message box (simple implementation)
|
||||
let title = format!("Message from {}", msg.sender);
|
||||
let content = msg.content.clone();
|
||||
|
||||
// Spawn a thread to show the message box (non-blocking)
|
||||
thread::spawn(move || {
|
||||
show_message_box_internal(&title, &content);
|
||||
});
|
||||
}
|
||||
Ok(ChatCommand::Close) => {
|
||||
info!("Chat window: Close requested");
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
// Channel closed
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn show_message_box_internal(title: &str, message: &str) {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
let title_wide: Vec<u16> = OsStr::new(title)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let message_wide: Vec<u16> = OsStr::new(message)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
None,
|
||||
PCWSTR(message_wide.as_ptr()),
|
||||
PCWSTR(title_wide.as_ptr()),
|
||||
MB_OK | MB_ICONINFORMATION | MB_TOPMOST | MB_SETFOREGROUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn run_chat_window(_command_rx: Receiver<ChatCommand>, _message_tx: Sender<ChatMessage>) {
|
||||
// No-op on non-Windows
|
||||
}
|
||||
@@ -1,8 +1,50 @@
|
||||
//! Agent configuration management
|
||||
//!
|
||||
//! Supports three configuration sources (in priority order):
|
||||
//! 1. Embedded config (magic bytes appended to executable)
|
||||
//! 2. Config file (guruconnect.toml or %ProgramData%\GuruConnect\agent.toml)
|
||||
//! 3. Environment variables (fallback)
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Magic marker for embedded configuration (10 bytes)
|
||||
const MAGIC_MARKER: &[u8] = b"GURUCONFIG";
|
||||
|
||||
/// Embedded configuration data (appended to executable)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbeddedConfig {
|
||||
/// Server WebSocket URL
|
||||
pub server_url: String,
|
||||
/// API key for authentication
|
||||
pub api_key: String,
|
||||
/// Company/organization name
|
||||
#[serde(default)]
|
||||
pub company: Option<String>,
|
||||
/// Site/location name
|
||||
#[serde(default)]
|
||||
pub site: Option<String>,
|
||||
/// Tags for categorization
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Detected run mode based on filename
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RunMode {
|
||||
/// Viewer-only installation (filename contains "Viewer")
|
||||
Viewer,
|
||||
/// Temporary support session (filename contains 6-digit code)
|
||||
TempSupport(String),
|
||||
/// Permanent agent with embedded config
|
||||
PermanentAgent,
|
||||
/// Unknown/default mode
|
||||
Default,
|
||||
}
|
||||
|
||||
/// Agent configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -13,9 +55,29 @@ pub struct Config {
|
||||
/// Agent API key for authentication
|
||||
pub api_key: String,
|
||||
|
||||
/// Unique agent identifier (generated on first run)
|
||||
#[serde(default = "generate_agent_id")]
|
||||
pub agent_id: String,
|
||||
|
||||
/// Optional hostname override
|
||||
pub hostname_override: Option<String>,
|
||||
|
||||
/// Company/organization name (from embedded config)
|
||||
#[serde(default)]
|
||||
pub company: Option<String>,
|
||||
|
||||
/// Site/location name (from embedded config)
|
||||
#[serde(default)]
|
||||
pub site: Option<String>,
|
||||
|
||||
/// Tags for categorization (from embedded config)
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
|
||||
/// Support code for one-time support sessions (set via command line or filename)
|
||||
#[serde(skip)]
|
||||
pub support_code: Option<String>,
|
||||
|
||||
/// Capture settings
|
||||
#[serde(default)]
|
||||
pub capture: CaptureConfig,
|
||||
@@ -25,6 +87,10 @@ pub struct Config {
|
||||
pub encoding: EncodingConfig,
|
||||
}
|
||||
|
||||
fn generate_agent_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CaptureConfig {
|
||||
/// Target frames per second (1-60)
|
||||
@@ -92,35 +158,228 @@ impl Default for EncodingConfig {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load configuration from file or environment
|
||||
/// Detect run mode from executable filename
|
||||
pub fn detect_run_mode() -> RunMode {
|
||||
let exe_path = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return RunMode::Default,
|
||||
};
|
||||
|
||||
let filename = match exe_path.file_stem() {
|
||||
Some(s) => s.to_string_lossy().to_string(),
|
||||
None => return RunMode::Default,
|
||||
};
|
||||
|
||||
let filename_lower = filename.to_lowercase();
|
||||
|
||||
// Check for viewer mode
|
||||
if filename_lower.contains("viewer") {
|
||||
info!("Detected viewer mode from filename: {}", filename);
|
||||
return RunMode::Viewer;
|
||||
}
|
||||
|
||||
// Check for support code in filename (6-digit number)
|
||||
if let Some(code) = Self::extract_support_code(&filename) {
|
||||
info!("Detected support code from filename: {}", code);
|
||||
return RunMode::TempSupport(code);
|
||||
}
|
||||
|
||||
// Check for embedded config
|
||||
if Self::has_embedded_config() {
|
||||
info!("Detected embedded config in executable");
|
||||
return RunMode::PermanentAgent;
|
||||
}
|
||||
|
||||
RunMode::Default
|
||||
}
|
||||
|
||||
/// Extract 6-digit support code from filename
|
||||
fn extract_support_code(filename: &str) -> Option<String> {
|
||||
// Look for patterns like "GuruConnect-123456" or "GuruConnect_123456"
|
||||
for part in filename.split(|c| c == '-' || c == '_' || c == '.') {
|
||||
let trimmed = part.trim();
|
||||
if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Check if last 6 characters are all digits
|
||||
if filename.len() >= 6 {
|
||||
let last_six = &filename[filename.len() - 6..];
|
||||
if last_six.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(last_six.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if embedded configuration exists in the executable
|
||||
pub fn has_embedded_config() -> bool {
|
||||
Self::read_embedded_config().is_ok()
|
||||
}
|
||||
|
||||
/// Read embedded configuration from the executable
|
||||
pub fn read_embedded_config() -> Result<EmbeddedConfig> {
|
||||
let exe_path = std::env::current_exe()
|
||||
.context("Failed to get current executable path")?;
|
||||
|
||||
let mut file = std::fs::File::open(&exe_path)
|
||||
.context("Failed to open executable for reading")?;
|
||||
|
||||
let file_size = file.metadata()?.len();
|
||||
if file_size < (MAGIC_MARKER.len() + 4) as u64 {
|
||||
return Err(anyhow!("File too small to contain embedded config"));
|
||||
}
|
||||
|
||||
// Read the last part of the file to find magic marker
|
||||
// Structure: [PE binary][GURUCONFIG][length:u32][json config]
|
||||
// We need to search backwards from the end
|
||||
|
||||
// Read last 64KB (should be more than enough for config)
|
||||
let search_size = std::cmp::min(65536, file_size as usize);
|
||||
let search_start = file_size - search_size as u64;
|
||||
|
||||
file.seek(SeekFrom::Start(search_start))?;
|
||||
let mut buffer = vec![0u8; search_size];
|
||||
file.read_exact(&mut buffer)?;
|
||||
|
||||
// Find magic marker
|
||||
let marker_pos = buffer.windows(MAGIC_MARKER.len())
|
||||
.rposition(|window| window == MAGIC_MARKER)
|
||||
.ok_or_else(|| anyhow!("Magic marker not found"))?;
|
||||
|
||||
// Read config length (4 bytes after marker)
|
||||
let length_start = marker_pos + MAGIC_MARKER.len();
|
||||
if length_start + 4 > buffer.len() {
|
||||
return Err(anyhow!("Invalid embedded config: length field truncated"));
|
||||
}
|
||||
|
||||
let config_length = u32::from_le_bytes([
|
||||
buffer[length_start],
|
||||
buffer[length_start + 1],
|
||||
buffer[length_start + 2],
|
||||
buffer[length_start + 3],
|
||||
]) as usize;
|
||||
|
||||
// Read config data
|
||||
let config_start = length_start + 4;
|
||||
if config_start + config_length > buffer.len() {
|
||||
return Err(anyhow!("Invalid embedded config: data truncated"));
|
||||
}
|
||||
|
||||
let config_bytes = &buffer[config_start..config_start + config_length];
|
||||
let config: EmbeddedConfig = serde_json::from_slice(config_bytes)
|
||||
.context("Failed to parse embedded config JSON")?;
|
||||
|
||||
info!("Loaded embedded config: server={}, company={:?}",
|
||||
config.server_url, config.company);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Check if an explicit agent configuration file exists
|
||||
/// This returns true only if there's a real config file, not generated defaults
|
||||
pub fn has_agent_config() -> bool {
|
||||
// Check for embedded config first
|
||||
if Self::has_embedded_config() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for config in current directory
|
||||
let local_config = PathBuf::from("guruconnect.toml");
|
||||
if local_config.exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check in program data directory (Windows)
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(program_data) = std::env::var("ProgramData") {
|
||||
let path = PathBuf::from(program_data)
|
||||
.join("GuruConnect")
|
||||
.join("agent.toml");
|
||||
if path.exists() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Load configuration from embedded config, file, or environment
|
||||
pub fn load() -> Result<Self> {
|
||||
// Try loading from config file
|
||||
// Priority 1: Try loading from embedded config
|
||||
if let Ok(embedded) = Self::read_embedded_config() {
|
||||
info!("Using embedded configuration");
|
||||
let config = Config {
|
||||
server_url: embedded.server_url,
|
||||
api_key: embedded.api_key,
|
||||
agent_id: generate_agent_id(),
|
||||
hostname_override: None,
|
||||
company: embedded.company,
|
||||
site: embedded.site,
|
||||
tags: embedded.tags,
|
||||
support_code: None,
|
||||
capture: CaptureConfig::default(),
|
||||
encoding: EncodingConfig::default(),
|
||||
};
|
||||
|
||||
// Save to file for persistence (so agent_id is preserved)
|
||||
let _ = config.save();
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// Priority 2: Try loading from config file
|
||||
let config_path = Self::config_path();
|
||||
|
||||
if config_path.exists() {
|
||||
let contents = std::fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("Failed to read config from {:?}", config_path))?;
|
||||
|
||||
let config: Config = toml::from_str(&contents)
|
||||
let mut config: Config = toml::from_str(&contents)
|
||||
.with_context(|| "Failed to parse config file")?;
|
||||
|
||||
// Ensure agent_id is set and saved
|
||||
if config.agent_id.is_empty() {
|
||||
config.agent_id = generate_agent_id();
|
||||
let _ = config.save();
|
||||
}
|
||||
|
||||
// support_code is always None when loading from file (set via CLI)
|
||||
config.support_code = None;
|
||||
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// Fall back to environment variables
|
||||
// Priority 3: Fall back to environment variables
|
||||
let server_url = std::env::var("GURUCONNECT_SERVER_URL")
|
||||
.unwrap_or_else(|_| "wss://localhost:3002/ws".to_string());
|
||||
.unwrap_or_else(|_| "wss://connect.azcomputerguru.com/ws/agent".to_string());
|
||||
|
||||
let api_key = std::env::var("GURUCONNECT_API_KEY")
|
||||
.unwrap_or_else(|_| "dev-key".to_string());
|
||||
|
||||
Ok(Config {
|
||||
let agent_id = std::env::var("GURUCONNECT_AGENT_ID")
|
||||
.unwrap_or_else(|_| generate_agent_id());
|
||||
|
||||
let config = Config {
|
||||
server_url,
|
||||
api_key,
|
||||
agent_id,
|
||||
hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(),
|
||||
company: None,
|
||||
site: None,
|
||||
tags: Vec::new(),
|
||||
support_code: None,
|
||||
capture: CaptureConfig::default(),
|
||||
encoding: EncodingConfig::default(),
|
||||
})
|
||||
};
|
||||
|
||||
// Save config with generated agent_id for persistence
|
||||
let _ = config.save();
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Get the configuration file path
|
||||
@@ -182,6 +441,7 @@ pub fn example_config() -> &'static str {
|
||||
# Server connection
|
||||
server_url = "wss://connect.example.com/ws"
|
||||
api_key = "your-agent-api-key"
|
||||
agent_id = "auto-generated-uuid"
|
||||
|
||||
# Optional: override hostname
|
||||
# hostname_override = "custom-hostname"
|
||||
|
||||
@@ -81,9 +81,11 @@ impl KeyboardController {
|
||||
#[cfg(windows)]
|
||||
pub fn type_char(&mut self, ch: char) -> Result<()> {
|
||||
let mut inputs = Vec::new();
|
||||
let mut buf = [0u16; 2];
|
||||
let encoded = ch.encode_utf16(&mut buf);
|
||||
|
||||
// For characters that fit in a single u16
|
||||
for code_unit in ch.encode_utf16(&mut [0; 2]) {
|
||||
for &code_unit in encoded.iter() {
|
||||
// Key down
|
||||
inputs.push(INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
@@ -127,15 +129,21 @@ impl KeyboardController {
|
||||
|
||||
/// Send Secure Attention Sequence (Ctrl+Alt+Delete)
|
||||
///
|
||||
/// Note: This requires special privileges on Windows.
|
||||
/// The agent typically needs to run as SYSTEM or use SAS API.
|
||||
/// This uses a multi-tier approach:
|
||||
/// 1. Try the GuruConnect SAS Service (runs as SYSTEM, handles via named pipe)
|
||||
/// 2. Try the sas.dll directly (requires SYSTEM privileges)
|
||||
/// 3. Fallback to key simulation (won't work on secure desktop)
|
||||
#[cfg(windows)]
|
||||
pub fn send_sas(&mut self) -> Result<()> {
|
||||
// Try using the SAS library if available
|
||||
// For now, we'll attempt to send the key combination
|
||||
// This won't work in all contexts due to Windows security
|
||||
// Tier 1: Try the SAS service (named pipe IPC to SYSTEM service)
|
||||
if let Ok(()) = crate::sas_client::request_sas() {
|
||||
tracing::info!("SAS sent via GuruConnect SAS Service");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Load the sas.dll and call SendSAS if available
|
||||
tracing::info!("SAS service not available, trying direct sas.dll...");
|
||||
|
||||
// Tier 2: Try using the sas.dll directly (requires SYSTEM privileges)
|
||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
@@ -149,13 +157,14 @@ impl KeyboardController {
|
||||
// SendSAS takes a BOOL parameter: FALSE for Ctrl+Alt+Del
|
||||
let send_sas: extern "system" fn(i32) = std::mem::transmute(proc);
|
||||
send_sas(0); // FALSE = Ctrl+Alt+Del
|
||||
tracing::info!("SAS sent via direct sas.dll call");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try sending the keys (won't work without proper privileges)
|
||||
tracing::warn!("SAS library not available, Ctrl+Alt+Del may not work");
|
||||
// Tier 3: Fallback - try sending the keys (won't work on secure desktop)
|
||||
tracing::warn!("SAS service and sas.dll not available, Ctrl+Alt+Del may not work");
|
||||
|
||||
// VK codes
|
||||
const VK_CONTROL: u16 = 0x11;
|
||||
|
||||
@@ -8,9 +8,15 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
SendInput, INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL,
|
||||
MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP,
|
||||
MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK,
|
||||
MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, XBUTTON1, XBUTTON2,
|
||||
MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT,
|
||||
};
|
||||
|
||||
// X button constants (not exported in windows crate 0.58+)
|
||||
#[cfg(windows)]
|
||||
const XBUTTON1: u32 = 0x0001;
|
||||
#[cfg(windows)]
|
||||
const XBUTTON2: u32 = 0x0002;
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
|
||||
@@ -86,8 +92,8 @@ impl MouseController {
|
||||
MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, 0),
|
||||
MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, 0),
|
||||
MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, 0),
|
||||
MouseButton::X1 => (MOUSEEVENTF_XDOWN, XBUTTON1 as u32),
|
||||
MouseButton::X2 => (MOUSEEVENTF_XDOWN, XBUTTON2 as u32),
|
||||
MouseButton::X1 => (MOUSEEVENTF_XDOWN, XBUTTON1),
|
||||
MouseButton::X2 => (MOUSEEVENTF_XDOWN, XBUTTON2),
|
||||
};
|
||||
|
||||
let input = INPUT {
|
||||
@@ -114,8 +120,8 @@ impl MouseController {
|
||||
MouseButton::Left => (MOUSEEVENTF_LEFTUP, 0),
|
||||
MouseButton::Right => (MOUSEEVENTF_RIGHTUP, 0),
|
||||
MouseButton::Middle => (MOUSEEVENTF_MIDDLEUP, 0),
|
||||
MouseButton::X1 => (MOUSEEVENTF_XUP, XBUTTON1 as u32),
|
||||
MouseButton::X2 => (MOUSEEVENTF_XUP, XBUTTON2 as u32),
|
||||
MouseButton::X1 => (MOUSEEVENTF_XUP, XBUTTON1),
|
||||
MouseButton::X2 => (MOUSEEVENTF_XUP, XBUTTON2),
|
||||
};
|
||||
|
||||
let input = INPUT {
|
||||
|
||||
416
agent/src/install.rs
Normal file
416
agent/src/install.rs
Normal file
@@ -0,0 +1,416 @@
|
||||
//! Installation and protocol handler registration
|
||||
//!
|
||||
//! Handles:
|
||||
//! - Self-installation to Program Files (with UAC) or LocalAppData (fallback)
|
||||
//! - Protocol handler registration (guruconnect://)
|
||||
//! - UAC elevation with graceful fallback
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use tracing::{info, warn, error};
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::{
|
||||
core::PCWSTR,
|
||||
Win32::Foundation::HANDLE,
|
||||
Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY},
|
||||
Win32::System::Threading::{GetCurrentProcess, OpenProcessToken},
|
||||
Win32::System::Registry::{
|
||||
RegCreateKeyExW, RegSetValueExW, RegCloseKey, HKEY, HKEY_CLASSES_ROOT,
|
||||
HKEY_CURRENT_USER, KEY_WRITE, REG_SZ, REG_OPTION_NON_VOLATILE,
|
||||
},
|
||||
Win32::UI::Shell::ShellExecuteW,
|
||||
Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::ffi::OsStr;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
/// Install locations
|
||||
pub const SYSTEM_INSTALL_PATH: &str = r"C:\Program Files\GuruConnect";
|
||||
pub const USER_INSTALL_PATH: &str = r"GuruConnect"; // Relative to %LOCALAPPDATA%
|
||||
|
||||
/// Check if running with elevated privileges
|
||||
#[cfg(windows)]
|
||||
pub fn is_elevated() -> bool {
|
||||
unsafe {
|
||||
let mut token_handle = HANDLE::default();
|
||||
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle).is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut elevation = TOKEN_ELEVATION::default();
|
||||
let mut size = std::mem::size_of::<TOKEN_ELEVATION>() as u32;
|
||||
|
||||
let result = GetTokenInformation(
|
||||
token_handle,
|
||||
TokenElevation,
|
||||
Some(&mut elevation as *mut _ as *mut _),
|
||||
size,
|
||||
&mut size,
|
||||
);
|
||||
|
||||
let _ = windows::Win32::Foundation::CloseHandle(token_handle);
|
||||
|
||||
result.is_ok() && elevation.TokenIsElevated != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn is_elevated() -> bool {
|
||||
unsafe { libc::geteuid() == 0 }
|
||||
}
|
||||
|
||||
/// Get the install path based on elevation status
|
||||
pub fn get_install_path(elevated: bool) -> std::path::PathBuf {
|
||||
if elevated {
|
||||
std::path::PathBuf::from(SYSTEM_INSTALL_PATH)
|
||||
} else {
|
||||
let local_app_data = std::env::var("LOCALAPPDATA")
|
||||
.unwrap_or_else(|_| {
|
||||
let home = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string());
|
||||
format!(r"{}\AppData\Local", home)
|
||||
});
|
||||
std::path::PathBuf::from(local_app_data).join(USER_INSTALL_PATH)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the executable path
|
||||
pub fn get_exe_path(install_path: &std::path::Path) -> std::path::PathBuf {
|
||||
install_path.join("guruconnect.exe")
|
||||
}
|
||||
|
||||
/// Attempt to elevate and re-run with install command
|
||||
#[cfg(windows)]
|
||||
pub fn try_elevate_and_install() -> Result<bool> {
|
||||
let exe_path = std::env::current_exe()?;
|
||||
let exe_path_wide: Vec<u16> = OsStr::new(exe_path.as_os_str())
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
let verb: Vec<u16> = OsStr::new("runas")
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
let params: Vec<u16> = OsStr::new("install --elevated")
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
unsafe {
|
||||
let result = ShellExecuteW(
|
||||
None,
|
||||
PCWSTR(verb.as_ptr()),
|
||||
PCWSTR(exe_path_wide.as_ptr()),
|
||||
PCWSTR(params.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
SW_SHOWNORMAL,
|
||||
);
|
||||
|
||||
// ShellExecuteW returns > 32 on success
|
||||
if result.0 as usize > 32 {
|
||||
info!("UAC elevation requested");
|
||||
Ok(true)
|
||||
} else {
|
||||
warn!("UAC elevation denied or failed");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn try_elevate_and_install() -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Register the guruconnect:// protocol handler
|
||||
#[cfg(windows)]
|
||||
pub fn register_protocol_handler(elevated: bool) -> Result<()> {
|
||||
let install_path = get_install_path(elevated);
|
||||
let exe_path = get_exe_path(&install_path);
|
||||
let exe_path_str = exe_path.to_string_lossy();
|
||||
|
||||
// Command to execute: "C:\...\guruconnect.exe" "launch" "%1"
|
||||
let command = format!("\"{}\" launch \"%1\"", exe_path_str);
|
||||
|
||||
// Choose registry root based on elevation
|
||||
let root_key = if elevated {
|
||||
HKEY_CLASSES_ROOT
|
||||
} else {
|
||||
// User-level registration under Software\Classes
|
||||
HKEY_CURRENT_USER
|
||||
};
|
||||
|
||||
let base_path = if elevated {
|
||||
"guruconnect"
|
||||
} else {
|
||||
r"Software\Classes\guruconnect"
|
||||
};
|
||||
|
||||
unsafe {
|
||||
// Create guruconnect key
|
||||
let mut protocol_key = HKEY::default();
|
||||
let key_path = to_wide(base_path);
|
||||
let result = RegCreateKeyExW(
|
||||
root_key,
|
||||
PCWSTR(key_path.as_ptr()),
|
||||
0,
|
||||
PCWSTR::null(),
|
||||
REG_OPTION_NON_VOLATILE,
|
||||
KEY_WRITE,
|
||||
None,
|
||||
&mut protocol_key,
|
||||
None,
|
||||
);
|
||||
if result.is_err() {
|
||||
return Err(anyhow!("Failed to create protocol key: {:?}", result));
|
||||
}
|
||||
|
||||
// Set default value (protocol description)
|
||||
let description = to_wide("GuruConnect Protocol");
|
||||
let result = RegSetValueExW(
|
||||
protocol_key,
|
||||
PCWSTR::null(),
|
||||
0,
|
||||
REG_SZ,
|
||||
Some(&description_to_bytes(&description)),
|
||||
);
|
||||
if result.is_err() {
|
||||
let _ = RegCloseKey(protocol_key);
|
||||
return Err(anyhow!("Failed to set protocol description: {:?}", result));
|
||||
}
|
||||
|
||||
// Set URL Protocol (empty string indicates this is a protocol handler)
|
||||
let url_protocol = to_wide("URL Protocol");
|
||||
let empty = to_wide("");
|
||||
let result = RegSetValueExW(
|
||||
protocol_key,
|
||||
PCWSTR(url_protocol.as_ptr()),
|
||||
0,
|
||||
REG_SZ,
|
||||
Some(&description_to_bytes(&empty)),
|
||||
);
|
||||
if result.is_err() {
|
||||
let _ = RegCloseKey(protocol_key);
|
||||
return Err(anyhow!("Failed to set URL Protocol: {:?}", result));
|
||||
}
|
||||
|
||||
let _ = RegCloseKey(protocol_key);
|
||||
|
||||
// Create shell\open\command key
|
||||
let command_path = if elevated {
|
||||
r"guruconnect\shell\open\command"
|
||||
} else {
|
||||
r"Software\Classes\guruconnect\shell\open\command"
|
||||
};
|
||||
let command_key_path = to_wide(command_path);
|
||||
let mut command_key = HKEY::default();
|
||||
let result = RegCreateKeyExW(
|
||||
root_key,
|
||||
PCWSTR(command_key_path.as_ptr()),
|
||||
0,
|
||||
PCWSTR::null(),
|
||||
REG_OPTION_NON_VOLATILE,
|
||||
KEY_WRITE,
|
||||
None,
|
||||
&mut command_key,
|
||||
None,
|
||||
);
|
||||
if result.is_err() {
|
||||
return Err(anyhow!("Failed to create command key: {:?}", result));
|
||||
}
|
||||
|
||||
// Set the command
|
||||
let command_wide = to_wide(&command);
|
||||
let result = RegSetValueExW(
|
||||
command_key,
|
||||
PCWSTR::null(),
|
||||
0,
|
||||
REG_SZ,
|
||||
Some(&description_to_bytes(&command_wide)),
|
||||
);
|
||||
if result.is_err() {
|
||||
let _ = RegCloseKey(command_key);
|
||||
return Err(anyhow!("Failed to set command: {:?}", result));
|
||||
}
|
||||
|
||||
let _ = RegCloseKey(command_key);
|
||||
}
|
||||
|
||||
info!("Protocol handler registered: guruconnect://");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn register_protocol_handler(_elevated: bool) -> Result<()> {
|
||||
warn!("Protocol handler registration not supported on this platform");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the application
|
||||
pub fn install(force_user_install: bool) -> Result<()> {
|
||||
let elevated = is_elevated();
|
||||
|
||||
// If not elevated and not forcing user install, try to elevate
|
||||
if !elevated && !force_user_install {
|
||||
info!("Attempting UAC elevation for system-wide install...");
|
||||
match try_elevate_and_install() {
|
||||
Ok(true) => {
|
||||
// Elevation was requested, exit this instance
|
||||
// The elevated instance will continue the install
|
||||
info!("Elevated process started, exiting current instance");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Ok(false) => {
|
||||
info!("UAC denied, falling back to user install");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Elevation failed: {}, falling back to user install", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let install_path = get_install_path(elevated);
|
||||
let exe_path = get_exe_path(&install_path);
|
||||
|
||||
info!("Installing to: {}", install_path.display());
|
||||
|
||||
// Create install directory
|
||||
std::fs::create_dir_all(&install_path)?;
|
||||
|
||||
// Copy ourselves to install location
|
||||
let current_exe = std::env::current_exe()?;
|
||||
if current_exe != exe_path {
|
||||
std::fs::copy(¤t_exe, &exe_path)?;
|
||||
info!("Copied executable to: {}", exe_path.display());
|
||||
}
|
||||
|
||||
// Register protocol handler
|
||||
register_protocol_handler(elevated)?;
|
||||
|
||||
info!("Installation complete!");
|
||||
if elevated {
|
||||
info!("Installed system-wide to: {}", install_path.display());
|
||||
} else {
|
||||
info!("Installed for current user to: {}", install_path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the guruconnect:// protocol handler is registered
|
||||
#[cfg(windows)]
|
||||
pub fn is_protocol_handler_registered() -> bool {
|
||||
use windows::Win32::System::Registry::{
|
||||
RegOpenKeyExW, RegCloseKey, HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, KEY_READ,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
// Check system-wide registration (HKCR\guruconnect)
|
||||
let mut key = HKEY::default();
|
||||
let key_path = to_wide("guruconnect");
|
||||
if RegOpenKeyExW(
|
||||
HKEY_CLASSES_ROOT,
|
||||
PCWSTR(key_path.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut key,
|
||||
).is_ok() {
|
||||
let _ = RegCloseKey(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check user-level registration (HKCU\Software\Classes\guruconnect)
|
||||
let key_path = to_wide(r"Software\Classes\guruconnect");
|
||||
if RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR(key_path.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut key,
|
||||
).is_ok() {
|
||||
let _ = RegCloseKey(key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn is_protocol_handler_registered() -> bool {
|
||||
// On non-Windows, assume not registered (or check ~/.local/share/applications)
|
||||
false
|
||||
}
|
||||
|
||||
/// Parse a guruconnect:// URL and extract session parameters
|
||||
pub fn parse_protocol_url(url: &str) -> Result<(String, String, Option<String>)> {
|
||||
// Expected formats:
|
||||
// guruconnect://view/SESSION_ID
|
||||
// guruconnect://view/SESSION_ID?token=API_KEY
|
||||
// guruconnect://connect/SESSION_ID?server=wss://...&token=API_KEY
|
||||
//
|
||||
// Note: In URL parsing, "view" becomes the host, SESSION_ID is the path
|
||||
|
||||
let url = url::Url::parse(url)
|
||||
.map_err(|e| anyhow!("Invalid URL: {}", e))?;
|
||||
|
||||
if url.scheme() != "guruconnect" {
|
||||
return Err(anyhow!("Invalid scheme: expected guruconnect://"));
|
||||
}
|
||||
|
||||
// The "action" (view/connect) is parsed as the host
|
||||
let action = url.host_str()
|
||||
.ok_or_else(|| anyhow!("Missing action in URL"))?;
|
||||
|
||||
// The session ID is the first path segment
|
||||
let path = url.path().trim_start_matches('/');
|
||||
let session_id = if path.is_empty() {
|
||||
return Err(anyhow!("Missing session ID"));
|
||||
} else {
|
||||
path.split('/').next().unwrap_or("").to_string()
|
||||
};
|
||||
|
||||
if session_id.is_empty() {
|
||||
return Err(anyhow!("Missing session ID"));
|
||||
}
|
||||
|
||||
// Extract query parameters
|
||||
let mut server = None;
|
||||
let mut token = None;
|
||||
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"server" => server = Some(value.to_string()),
|
||||
"token" | "api_key" => token = Some(value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Default server if not specified
|
||||
let server = server.unwrap_or_else(|| "wss://connect.azcomputerguru.com/ws/viewer".to_string());
|
||||
|
||||
match action {
|
||||
"view" | "connect" => Ok((server, session_id, token)),
|
||||
_ => Err(anyhow!("Unknown action: {}", action)),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for Windows registry operations
|
||||
#[cfg(windows)]
|
||||
fn to_wide(s: &str) -> Vec<u16> {
|
||||
OsStr::new(s)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn description_to_bytes(wide: &[u16]) -> Vec<u8> {
|
||||
wide.iter()
|
||||
.flat_map(|w| w.to_le_bytes())
|
||||
.collect()
|
||||
}
|
||||
@@ -1,69 +1,570 @@
|
||||
//! GuruConnect Agent - Remote Desktop Agent for Windows
|
||||
//! GuruConnect - Remote Desktop Agent and Viewer
|
||||
//!
|
||||
//! Provides screen capture, input injection, and remote control capabilities.
|
||||
//! Single binary for both agent (receiving connections) and viewer (initiating connections).
|
||||
//!
|
||||
//! Usage:
|
||||
//! guruconnect agent - Run as background agent
|
||||
//! guruconnect view <session_id> - View a remote session
|
||||
//! guruconnect install - Install and register protocol handler
|
||||
//! guruconnect launch <url> - Handle guruconnect:// URL
|
||||
//! guruconnect [support_code] - Legacy: run agent with support code
|
||||
|
||||
// Hide console window by default on Windows (release builds)
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod capture;
|
||||
mod chat;
|
||||
mod config;
|
||||
mod encoder;
|
||||
mod input;
|
||||
mod install;
|
||||
mod sas_client;
|
||||
mod session;
|
||||
mod startup;
|
||||
mod transport;
|
||||
mod tray;
|
||||
mod update;
|
||||
mod viewer;
|
||||
|
||||
pub mod proto {
|
||||
include!(concat!(env!("OUT_DIR"), "/guruconnect.rs"));
|
||||
}
|
||||
|
||||
/// Build information embedded at compile time
|
||||
pub mod build_info {
|
||||
/// Cargo package version (from Cargo.toml)
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Git commit hash (short, 8 chars)
|
||||
pub const GIT_HASH: &str = env!("GIT_HASH");
|
||||
|
||||
/// Git commit hash (full)
|
||||
pub const GIT_HASH_FULL: &str = env!("GIT_HASH_FULL");
|
||||
|
||||
/// Git branch name
|
||||
pub const GIT_BRANCH: &str = env!("GIT_BRANCH");
|
||||
|
||||
/// Git dirty state ("clean" or "dirty")
|
||||
pub const GIT_DIRTY: &str = env!("GIT_DIRTY");
|
||||
|
||||
/// Git commit date
|
||||
pub const GIT_COMMIT_DATE: &str = env!("GIT_COMMIT_DATE");
|
||||
|
||||
/// Build timestamp (UTC)
|
||||
pub const BUILD_TIMESTAMP: &str = env!("BUILD_TIMESTAMP");
|
||||
|
||||
/// Build profile (debug/release)
|
||||
pub const BUILD_PROFILE: &str = env!("BUILD_PROFILE");
|
||||
|
||||
/// Target triple (e.g., x86_64-pc-windows-msvc)
|
||||
pub const BUILD_TARGET: &str = env!("BUILD_TARGET");
|
||||
|
||||
/// Short version string for display (version + git hash)
|
||||
pub fn short_version() -> String {
|
||||
if GIT_DIRTY == "dirty" {
|
||||
format!("{}-{}-dirty", VERSION, GIT_HASH)
|
||||
} else {
|
||||
format!("{}-{}", VERSION, GIT_HASH)
|
||||
}
|
||||
}
|
||||
|
||||
/// Full version string with all details
|
||||
pub fn full_version() -> String {
|
||||
format!(
|
||||
"GuruConnect v{}\n\
|
||||
Git: {} ({})\n\
|
||||
Branch: {}\n\
|
||||
Commit: {}\n\
|
||||
Built: {}\n\
|
||||
Profile: {}\n\
|
||||
Target: {}",
|
||||
VERSION,
|
||||
GIT_HASH,
|
||||
GIT_DIRTY,
|
||||
GIT_BRANCH,
|
||||
GIT_COMMIT_DATE,
|
||||
BUILD_TIMESTAMP,
|
||||
BUILD_PROFILE,
|
||||
BUILD_TARGET
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::{info, error, Level};
|
||||
use clap::{Parser, Subcommand};
|
||||
use tracing::{info, error, warn, Level};
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONINFORMATION, MB_ICONERROR};
|
||||
#[cfg(windows)]
|
||||
use windows::core::PCWSTR;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
|
||||
|
||||
/// GuruConnect Remote Desktop
|
||||
#[derive(Parser)]
|
||||
#[command(name = "guruconnect")]
|
||||
#[command(version = concat!(env!("CARGO_PKG_VERSION"), "-", env!("GIT_HASH")), about = "Remote desktop agent and viewer")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
|
||||
/// Support code for legacy mode (runs agent with code)
|
||||
#[arg(value_name = "SUPPORT_CODE")]
|
||||
support_code: Option<String>,
|
||||
|
||||
/// Enable verbose logging
|
||||
#[arg(short, long, global = true)]
|
||||
verbose: bool,
|
||||
|
||||
/// Internal flag: set after auto-update to trigger cleanup
|
||||
#[arg(long, hide = true)]
|
||||
post_update: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Run as background agent (receive remote connections)
|
||||
Agent {
|
||||
/// Support code for one-time session
|
||||
#[arg(short, long)]
|
||||
code: Option<String>,
|
||||
},
|
||||
|
||||
/// View a remote session (connect to an agent)
|
||||
View {
|
||||
/// Session ID to connect to
|
||||
session_id: String,
|
||||
|
||||
/// Server URL
|
||||
#[arg(short, long, default_value = "wss://connect.azcomputerguru.com/ws/viewer")]
|
||||
server: String,
|
||||
|
||||
/// API key for authentication
|
||||
#[arg(short, long, default_value = "")]
|
||||
api_key: String,
|
||||
},
|
||||
|
||||
/// Install GuruConnect and register protocol handler
|
||||
Install {
|
||||
/// Skip UAC elevation, install for current user only
|
||||
#[arg(long)]
|
||||
user_only: bool,
|
||||
|
||||
/// Called internally when running elevated
|
||||
#[arg(long, hide = true)]
|
||||
elevated: bool,
|
||||
},
|
||||
|
||||
/// Uninstall GuruConnect
|
||||
Uninstall,
|
||||
|
||||
/// Handle a guruconnect:// protocol URL
|
||||
Launch {
|
||||
/// The guruconnect:// URL to handle
|
||||
url: String,
|
||||
},
|
||||
|
||||
/// Show detailed version and build information
|
||||
#[command(name = "version-info")]
|
||||
VersionInfo,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Initialize logging
|
||||
let subscriber = FmtSubscriber::builder()
|
||||
.with_max_level(Level::INFO)
|
||||
let level = if cli.verbose { Level::DEBUG } else { Level::INFO };
|
||||
FmtSubscriber::builder()
|
||||
.with_max_level(level)
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.init();
|
||||
|
||||
info!("GuruConnect Agent v{}", env!("CARGO_PKG_VERSION"));
|
||||
info!("GuruConnect {} ({})", build_info::short_version(), build_info::BUILD_TARGET);
|
||||
info!("Built: {} | Commit: {}", build_info::BUILD_TIMESTAMP, build_info::GIT_COMMIT_DATE);
|
||||
|
||||
// Load configuration
|
||||
let config = config::Config::load()?;
|
||||
info!("Loaded configuration for server: {}", config.server_url);
|
||||
|
||||
// Run the agent
|
||||
if let Err(e) = run_agent(config).await {
|
||||
error!("Agent error: {}", e);
|
||||
return Err(e);
|
||||
// Handle post-update cleanup
|
||||
if cli.post_update {
|
||||
info!("Post-update mode: cleaning up old executable");
|
||||
update::cleanup_post_update();
|
||||
}
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::Agent { code }) => {
|
||||
run_agent_mode(code)
|
||||
}
|
||||
Some(Commands::View { session_id, server, api_key }) => {
|
||||
run_viewer_mode(&server, &session_id, &api_key)
|
||||
}
|
||||
Some(Commands::Install { user_only, elevated }) => {
|
||||
run_install(user_only || elevated)
|
||||
}
|
||||
Some(Commands::Uninstall) => {
|
||||
run_uninstall()
|
||||
}
|
||||
Some(Commands::Launch { url }) => {
|
||||
run_launch(&url)
|
||||
}
|
||||
Some(Commands::VersionInfo) => {
|
||||
// Show detailed version info (allocate console on Windows for visibility)
|
||||
#[cfg(windows)]
|
||||
show_debug_console();
|
||||
println!("{}", build_info::full_version());
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
// No subcommand - detect mode from filename or embedded config
|
||||
// Legacy: if support_code arg provided, use that
|
||||
if let Some(code) = cli.support_code {
|
||||
return run_agent_mode(Some(code));
|
||||
}
|
||||
|
||||
// Detect run mode from filename
|
||||
use config::RunMode;
|
||||
match config::Config::detect_run_mode() {
|
||||
RunMode::Viewer => {
|
||||
// Filename indicates viewer-only (e.g., "GuruConnect-Viewer.exe")
|
||||
info!("Viewer mode detected from filename");
|
||||
if !install::is_protocol_handler_registered() {
|
||||
info!("Installing protocol handler for viewer");
|
||||
run_install(false)
|
||||
} else {
|
||||
info!("Viewer already installed, nothing to do");
|
||||
show_message_box("GuruConnect Viewer", "GuruConnect viewer is installed.\n\nUse guruconnect:// links to connect to remote sessions.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
RunMode::TempSupport(code) => {
|
||||
// Filename contains support code (e.g., "GuruConnect-123456.exe")
|
||||
info!("Temp support session detected from filename: {}", code);
|
||||
run_agent_mode(Some(code))
|
||||
}
|
||||
RunMode::PermanentAgent => {
|
||||
// Embedded config found - run as permanent agent
|
||||
info!("Permanent agent mode detected (embedded config)");
|
||||
if !install::is_protocol_handler_registered() {
|
||||
// First run - install then run as agent
|
||||
info!("First run - installing agent");
|
||||
if let Err(e) = install::install(false) {
|
||||
warn!("Installation failed: {}", e);
|
||||
}
|
||||
}
|
||||
run_agent_mode(None)
|
||||
}
|
||||
RunMode::Default => {
|
||||
// No special mode detected - use legacy logic
|
||||
if !install::is_protocol_handler_registered() {
|
||||
// Protocol handler not registered - user likely downloaded from web
|
||||
info!("Protocol handler not registered, running installer");
|
||||
run_install(false)
|
||||
} else if config::Config::has_agent_config() {
|
||||
// Has agent config - run as agent
|
||||
info!("Agent config found, running as agent");
|
||||
run_agent_mode(None)
|
||||
} else {
|
||||
// Viewer-only installation - just exit silently
|
||||
info!("Viewer-only installation, exiting");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run in agent mode (receive remote connections)
|
||||
fn run_agent_mode(support_code: Option<String>) -> Result<()> {
|
||||
info!("Running in agent mode");
|
||||
|
||||
// Check elevation status
|
||||
if install::is_elevated() {
|
||||
info!("Running with elevated (administrator) privileges");
|
||||
} else {
|
||||
info!("Running with standard user privileges");
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
let mut config = config::Config::load()?;
|
||||
|
||||
// Set support code if provided
|
||||
if let Some(code) = support_code {
|
||||
info!("Support code: {}", code);
|
||||
config.support_code = Some(code);
|
||||
}
|
||||
|
||||
info!("Server: {}", config.server_url);
|
||||
if let Some(ref company) = config.company {
|
||||
info!("Company: {}", company);
|
||||
}
|
||||
if let Some(ref site) = config.site {
|
||||
info!("Site: {}", site);
|
||||
}
|
||||
|
||||
// Run the agent
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(run_agent(config))
|
||||
}
|
||||
|
||||
/// Run in viewer mode (connect to remote session)
|
||||
fn run_viewer_mode(server: &str, session_id: &str, api_key: &str) -> Result<()> {
|
||||
info!("Running in viewer mode");
|
||||
info!("Connecting to session: {}", session_id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(viewer::run(server, session_id, api_key))
|
||||
}
|
||||
|
||||
/// Handle guruconnect:// URL launch
|
||||
fn run_launch(url: &str) -> Result<()> {
|
||||
info!("Handling protocol URL: {}", url);
|
||||
|
||||
match install::parse_protocol_url(url) {
|
||||
Ok((server, session_id, token)) => {
|
||||
let api_key = token.unwrap_or_default();
|
||||
run_viewer_mode(&server, &session_id, &api_key)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to parse URL: {}", e);
|
||||
show_error_box("GuruConnect", &format!("Invalid URL: {}", e));
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Install GuruConnect
|
||||
fn run_install(force_user_install: bool) -> Result<()> {
|
||||
info!("Installing GuruConnect...");
|
||||
|
||||
match install::install(force_user_install) {
|
||||
Ok(()) => {
|
||||
show_message_box("GuruConnect", "Installation complete!\n\nYou can now use guruconnect:// links.");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Installation failed: {}", e);
|
||||
show_error_box("GuruConnect", &format!("Installation failed: {}", e));
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uninstall GuruConnect
|
||||
fn run_uninstall() -> Result<()> {
|
||||
info!("Uninstalling GuruConnect...");
|
||||
|
||||
// Remove from startup
|
||||
if let Err(e) = startup::remove_from_startup() {
|
||||
warn!("Failed to remove from startup: {}", e);
|
||||
}
|
||||
|
||||
// TODO: Remove registry keys for protocol handler
|
||||
// TODO: Remove install directory
|
||||
|
||||
show_message_box("GuruConnect", "Uninstall complete.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show a message box (Windows only)
|
||||
#[cfg(windows)]
|
||||
fn show_message_box(title: &str, message: &str) {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
let title_wide: Vec<u16> = OsStr::new(title)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let message_wide: Vec<u16> = OsStr::new(message)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
None,
|
||||
PCWSTR(message_wide.as_ptr()),
|
||||
PCWSTR(title_wide.as_ptr()),
|
||||
MB_OK | MB_ICONINFORMATION,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn show_message_box(_title: &str, message: &str) {
|
||||
println!("{}", message);
|
||||
}
|
||||
|
||||
/// Show an error message box (Windows only)
|
||||
#[cfg(windows)]
|
||||
fn show_error_box(title: &str, message: &str) {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
let title_wide: Vec<u16> = OsStr::new(title)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let message_wide: Vec<u16> = OsStr::new(message)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
None,
|
||||
PCWSTR(message_wide.as_ptr()),
|
||||
PCWSTR(title_wide.as_ptr()),
|
||||
MB_OK | MB_ICONERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn show_error_box(_title: &str, message: &str) {
|
||||
eprintln!("ERROR: {}", message);
|
||||
}
|
||||
|
||||
/// Show debug console window (Windows only)
|
||||
#[cfg(windows)]
|
||||
#[allow(dead_code)]
|
||||
fn show_debug_console() {
|
||||
unsafe {
|
||||
let hwnd = GetConsoleWindow();
|
||||
if hwnd.0 == std::ptr::null_mut() {
|
||||
let _ = AllocConsole();
|
||||
} else {
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
fn show_debug_console() {}
|
||||
|
||||
/// Clean up before exiting
|
||||
fn cleanup_on_exit() {
|
||||
info!("Cleaning up before exit");
|
||||
if let Err(e) = startup::remove_from_startup() {
|
||||
warn!("Failed to remove from startup: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the agent main loop
|
||||
async fn run_agent(config: config::Config) -> Result<()> {
|
||||
// Create session manager
|
||||
let mut session = session::SessionManager::new(config.clone());
|
||||
let elevated = install::is_elevated();
|
||||
let mut session = session::SessionManager::new(config.clone(), elevated);
|
||||
let is_support_session = config.support_code.is_some();
|
||||
let hostname = config.hostname();
|
||||
|
||||
// Add to startup
|
||||
if let Err(e) = startup::add_to_startup() {
|
||||
warn!("Failed to add to startup: {}", e);
|
||||
}
|
||||
|
||||
// Create tray icon
|
||||
let tray = match tray::TrayController::new(&hostname, config.support_code.as_deref(), is_support_session) {
|
||||
Ok(t) => {
|
||||
info!("Tray icon created");
|
||||
Some(t)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create tray icon: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Create chat controller
|
||||
let chat_ctrl = chat::ChatController::new();
|
||||
|
||||
// Connect to server and run main loop
|
||||
loop {
|
||||
info!("Connecting to server...");
|
||||
|
||||
if is_support_session {
|
||||
if let Some(ref t) = tray {
|
||||
if t.exit_requested() {
|
||||
info!("Exit requested by user");
|
||||
cleanup_on_exit();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match session.connect().await {
|
||||
Ok(_) => {
|
||||
info!("Connected to server");
|
||||
|
||||
// Run session until disconnect
|
||||
if let Err(e) = session.run().await {
|
||||
error!("Session error: {}", e);
|
||||
if let Some(ref t) = tray {
|
||||
t.update_status("Status: Connected");
|
||||
}
|
||||
|
||||
if let Err(e) = session.run_with_tray(tray.as_ref(), chat_ctrl.as_ref()).await {
|
||||
let error_msg = e.to_string();
|
||||
|
||||
if error_msg.contains("USER_EXIT") {
|
||||
info!("Session ended by user");
|
||||
cleanup_on_exit();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if error_msg.contains("SESSION_CANCELLED") {
|
||||
info!("Session was cancelled by technician");
|
||||
cleanup_on_exit();
|
||||
show_message_box("Support Session Ended", "The support session was cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if error_msg.contains("ADMIN_DISCONNECT") {
|
||||
info!("Session disconnected by administrator - uninstalling");
|
||||
if let Err(e) = startup::uninstall() {
|
||||
warn!("Uninstall failed: {}", e);
|
||||
}
|
||||
show_message_box("Remote Session Ended", "The session was ended by the administrator.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if error_msg.contains("ADMIN_UNINSTALL") {
|
||||
info!("Uninstall command received from server - uninstalling");
|
||||
if let Err(e) = startup::uninstall() {
|
||||
warn!("Uninstall failed: {}", e);
|
||||
}
|
||||
show_message_box("GuruConnect Removed", "This computer has been removed from remote management.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if error_msg.contains("ADMIN_RESTART") {
|
||||
info!("Restart command received - will reconnect");
|
||||
// Don't exit, just let the loop continue to reconnect
|
||||
} else {
|
||||
error!("Session error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
|
||||
if error_msg.contains("cancelled") {
|
||||
info!("Support code was cancelled");
|
||||
cleanup_on_exit();
|
||||
show_message_box("Support Session Cancelled", "This support session has been cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
error!("Connection failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before reconnecting
|
||||
if is_support_session {
|
||||
info!("Support session ended, not reconnecting");
|
||||
cleanup_on_exit();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Reconnecting in 5 seconds...");
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
|
||||
106
agent/src/sas_client.rs
Normal file
106
agent/src/sas_client.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
//! SAS Client - Named pipe client for communicating with GuruConnect SAS Service
|
||||
//!
|
||||
//! The SAS Service runs as SYSTEM and handles Ctrl+Alt+Del requests.
|
||||
//! This client sends commands to the service via named pipe.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{Read, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
const PIPE_NAME: &str = r"\\.\pipe\guruconnect-sas";
|
||||
const TIMEOUT_MS: u64 = 5000;
|
||||
|
||||
/// Request Ctrl+Alt+Del (Secure Attention Sequence) via the SAS service
|
||||
pub fn request_sas() -> Result<()> {
|
||||
info!("Requesting SAS via service pipe...");
|
||||
|
||||
// Try to connect to the pipe
|
||||
let mut pipe = match OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(PIPE_NAME)
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("Failed to connect to SAS service pipe: {}", e);
|
||||
return Err(anyhow::anyhow!(
|
||||
"SAS service not available. Install with: guruconnect-sas-service install"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
debug!("Connected to SAS service pipe");
|
||||
|
||||
// Send the command
|
||||
pipe.write_all(b"sas\n")
|
||||
.context("Failed to send command to SAS service")?;
|
||||
|
||||
// Read the response
|
||||
let mut response = [0u8; 64];
|
||||
let n = pipe.read(&mut response)
|
||||
.context("Failed to read response from SAS service")?;
|
||||
|
||||
let response_str = String::from_utf8_lossy(&response[..n]);
|
||||
let response_str = response_str.trim();
|
||||
|
||||
debug!("SAS service response: {}", response_str);
|
||||
|
||||
match response_str {
|
||||
"ok" => {
|
||||
info!("SAS request successful");
|
||||
Ok(())
|
||||
}
|
||||
"error" => {
|
||||
error!("SAS service reported an error");
|
||||
Err(anyhow::anyhow!("SAS service failed to send Ctrl+Alt+Del"))
|
||||
}
|
||||
_ => {
|
||||
error!("Unexpected response from SAS service: {}", response_str);
|
||||
Err(anyhow::anyhow!("Unexpected SAS service response: {}", response_str))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the SAS service is available
|
||||
pub fn is_service_available() -> bool {
|
||||
// Try to open the pipe
|
||||
if let Ok(mut pipe) = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(PIPE_NAME)
|
||||
{
|
||||
// Send a ping command
|
||||
if pipe.write_all(b"ping\n").is_ok() {
|
||||
let mut response = [0u8; 64];
|
||||
if let Ok(n) = pipe.read(&mut response) {
|
||||
let response_str = String::from_utf8_lossy(&response[..n]);
|
||||
return response_str.trim() == "pong";
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Get information about SAS service status
|
||||
pub fn get_service_status() -> String {
|
||||
if is_service_available() {
|
||||
"SAS service is running and responding".to_string()
|
||||
} else {
|
||||
"SAS service is not available".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_service_check() {
|
||||
// This test just checks the function runs without panicking
|
||||
let available = is_service_available();
|
||||
println!("SAS service available: {}", available);
|
||||
}
|
||||
}
|
||||
@@ -2,43 +2,94 @@
|
||||
//!
|
||||
//! Handles the lifecycle of a remote session including:
|
||||
//! - Connection to server
|
||||
//! - Authentication
|
||||
//! - Frame capture and encoding loop
|
||||
//! - Idle mode (heartbeat only, minimal resources)
|
||||
//! - Active/streaming mode (capture and send frames)
|
||||
//! - Input event handling
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
|
||||
|
||||
use crate::capture::{self, Capturer, Display};
|
||||
use crate::chat::{ChatController, ChatMessage as ChatMsg};
|
||||
use crate::config::Config;
|
||||
use crate::encoder::{self, Encoder};
|
||||
use crate::input::InputController;
|
||||
use crate::proto::{Message, message};
|
||||
|
||||
/// Show the debug console window (Windows only)
|
||||
#[cfg(windows)]
|
||||
fn show_debug_console() {
|
||||
unsafe {
|
||||
let hwnd = GetConsoleWindow();
|
||||
if hwnd.0 == std::ptr::null_mut() {
|
||||
let _ = AllocConsole();
|
||||
tracing::info!("Debug console window opened");
|
||||
} else {
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
tracing::info!("Debug console window shown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn show_debug_console() {
|
||||
// No-op on non-Windows platforms
|
||||
}
|
||||
|
||||
use crate::proto::{Message, message, ChatMessage, AgentStatus, Heartbeat, HeartbeatAck};
|
||||
use crate::transport::WebSocketTransport;
|
||||
use crate::tray::{TrayController, TrayAction};
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// Heartbeat interval (30 seconds)
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
|
||||
// Status report interval (60 seconds)
|
||||
const STATUS_INTERVAL: Duration = Duration::from_secs(60);
|
||||
// Update check interval (1 hour)
|
||||
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Session manager handles the remote control session
|
||||
pub struct SessionManager {
|
||||
config: Config,
|
||||
transport: Option<WebSocketTransport>,
|
||||
state: SessionState,
|
||||
// Lazy-initialized streaming resources
|
||||
capturer: Option<Box<dyn Capturer>>,
|
||||
encoder: Option<Box<dyn Encoder>>,
|
||||
input: Option<InputController>,
|
||||
// Streaming state
|
||||
current_viewer_id: Option<String>,
|
||||
// System info for status reports
|
||||
hostname: String,
|
||||
is_elevated: bool,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum SessionState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Active,
|
||||
Idle, // Connected but not streaming - minimal resource usage
|
||||
Streaming, // Actively capturing and sending frames
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
/// Create a new session manager
|
||||
pub fn new(config: Config) -> Self {
|
||||
pub fn new(config: Config, is_elevated: bool) -> Self {
|
||||
let hostname = config.hostname();
|
||||
Self {
|
||||
config,
|
||||
transport: None,
|
||||
state: SessionState::Disconnected,
|
||||
capturer: None,
|
||||
encoder: None,
|
||||
input: None,
|
||||
current_viewer_id: None,
|
||||
hostname,
|
||||
is_elevated,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,139 +99,436 @@ impl SessionManager {
|
||||
|
||||
let transport = WebSocketTransport::connect(
|
||||
&self.config.server_url,
|
||||
&self.config.agent_id,
|
||||
&self.config.api_key,
|
||||
Some(&self.hostname),
|
||||
self.config.support_code.as_deref(),
|
||||
).await?;
|
||||
|
||||
self.transport = Some(transport);
|
||||
self.state = SessionState::Connected;
|
||||
self.state = SessionState::Idle; // Start in idle mode
|
||||
|
||||
tracing::info!("Connected to server, entering idle mode");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the session main loop
|
||||
pub async fn run(&mut self) -> Result<()> {
|
||||
let transport = self.transport.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
||||
/// Initialize streaming resources (capturer, encoder, input)
|
||||
fn init_streaming(&mut self) -> Result<()> {
|
||||
if self.capturer.is_some() {
|
||||
return Ok(()); // Already initialized
|
||||
}
|
||||
|
||||
self.state = SessionState::Active;
|
||||
tracing::info!("Initializing streaming resources...");
|
||||
|
||||
// Get primary display
|
||||
let display = capture::primary_display()?;
|
||||
tracing::info!("Using display: {} ({}x{})", display.name, display.width, display.height);
|
||||
let primary_display = capture::primary_display()?;
|
||||
tracing::info!("Using display: {} ({}x{})",
|
||||
primary_display.name, primary_display.width, primary_display.height);
|
||||
|
||||
// Create capturer
|
||||
let mut capturer = capture::create_capturer(
|
||||
display.clone(),
|
||||
let capturer = capture::create_capturer(
|
||||
primary_display.clone(),
|
||||
self.config.capture.use_dxgi,
|
||||
self.config.capture.gdi_fallback,
|
||||
)?;
|
||||
self.capturer = Some(capturer);
|
||||
|
||||
// Create encoder
|
||||
let mut encoder = encoder::create_encoder(
|
||||
let encoder = encoder::create_encoder(
|
||||
&self.config.encoding.codec,
|
||||
self.config.encoding.quality,
|
||||
)?;
|
||||
self.encoder = Some(encoder);
|
||||
|
||||
// Create input controller
|
||||
let mut input = InputController::new()?;
|
||||
let input = InputController::new()?;
|
||||
self.input = Some(input);
|
||||
|
||||
// Calculate frame interval
|
||||
let frame_interval = Duration::from_millis(1000 / self.config.capture.fps as u64);
|
||||
tracing::info!("Streaming resources initialized");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release streaming resources to save CPU/memory when idle
|
||||
fn release_streaming(&mut self) {
|
||||
if self.capturer.is_some() {
|
||||
tracing::info!("Releasing streaming resources");
|
||||
self.capturer = None;
|
||||
self.encoder = None;
|
||||
self.input = None;
|
||||
self.current_viewer_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get display count for status reports
|
||||
fn get_display_count(&self) -> i32 {
|
||||
capture::enumerate_displays().map(|d| d.len() as i32).unwrap_or(1)
|
||||
}
|
||||
|
||||
/// Send agent status to server
|
||||
async fn send_status(&mut self) -> Result<()> {
|
||||
let status = AgentStatus {
|
||||
hostname: self.hostname.clone(),
|
||||
os_version: std::env::consts::OS.to_string(),
|
||||
is_elevated: self.is_elevated,
|
||||
uptime_secs: self.start_time.elapsed().as_secs() as i64,
|
||||
display_count: self.get_display_count(),
|
||||
is_streaming: self.state == SessionState::Streaming,
|
||||
agent_version: crate::build_info::short_version(),
|
||||
organization: self.config.company.clone().unwrap_or_default(),
|
||||
site: self.config.site.clone().unwrap_or_default(),
|
||||
tags: self.config.tags.clone(),
|
||||
};
|
||||
|
||||
let msg = Message {
|
||||
payload: Some(message::Payload::AgentStatus(status)),
|
||||
};
|
||||
|
||||
if let Some(transport) = self.transport.as_mut() {
|
||||
transport.send(msg).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send heartbeat to server
|
||||
async fn send_heartbeat(&mut self) -> Result<()> {
|
||||
let heartbeat = Heartbeat {
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
};
|
||||
|
||||
let msg = Message {
|
||||
payload: Some(message::Payload::Heartbeat(heartbeat)),
|
||||
};
|
||||
|
||||
if let Some(transport) = self.transport.as_mut() {
|
||||
transport.send(msg).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the session main loop with tray and chat event processing
|
||||
pub async fn run_with_tray(&mut self, tray: Option<&TrayController>, chat: Option<&ChatController>) -> Result<()> {
|
||||
if self.transport.is_none() {
|
||||
anyhow::bail!("Not connected");
|
||||
}
|
||||
|
||||
// Send initial status
|
||||
self.send_status().await?;
|
||||
|
||||
// Timing for heartbeat and status
|
||||
let mut last_heartbeat = Instant::now();
|
||||
let mut last_status = Instant::now();
|
||||
let mut last_frame_time = Instant::now();
|
||||
let mut last_update_check = Instant::now();
|
||||
let frame_interval = Duration::from_millis(1000 / self.config.capture.fps as u64);
|
||||
|
||||
// Main loop
|
||||
loop {
|
||||
// Check for incoming messages (non-blocking)
|
||||
while let Some(msg) = transport.try_recv()? {
|
||||
self.handle_message(&mut input, msg)?;
|
||||
}
|
||||
|
||||
// Capture and send frame if interval elapsed
|
||||
if last_frame_time.elapsed() >= frame_interval {
|
||||
last_frame_time = Instant::now();
|
||||
|
||||
if let Some(frame) = capturer.capture()? {
|
||||
let encoded = encoder.encode(&frame)?;
|
||||
|
||||
// Skip empty frames (no changes)
|
||||
if encoded.size > 0 {
|
||||
let msg = Message {
|
||||
payload: Some(message::Payload::VideoFrame(encoded.frame)),
|
||||
};
|
||||
transport.send(msg).await?;
|
||||
// Process tray events
|
||||
if let Some(t) = tray {
|
||||
if let Some(action) = t.process_events() {
|
||||
match action {
|
||||
TrayAction::EndSession => {
|
||||
tracing::info!("User requested session end via tray");
|
||||
return Err(anyhow::anyhow!("USER_EXIT: Session ended by user"));
|
||||
}
|
||||
TrayAction::ShowDetails => {
|
||||
tracing::info!("User requested details (not yet implemented)");
|
||||
}
|
||||
TrayAction::ShowDebugWindow => {
|
||||
show_debug_console();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.exit_requested() {
|
||||
tracing::info!("Exit requested via tray");
|
||||
return Err(anyhow::anyhow!("USER_EXIT: Exit requested by user"));
|
||||
}
|
||||
}
|
||||
|
||||
// Small sleep to prevent busy loop
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
// Process incoming messages
|
||||
let messages: Vec<Message> = {
|
||||
let transport = self.transport.as_mut().unwrap();
|
||||
let mut msgs = Vec::new();
|
||||
while let Some(msg) = transport.try_recv()? {
|
||||
msgs.push(msg);
|
||||
}
|
||||
msgs
|
||||
};
|
||||
|
||||
for msg in messages {
|
||||
// Handle chat messages specially
|
||||
if let Some(message::Payload::ChatMessage(chat_msg)) = &msg.payload {
|
||||
if let Some(c) = chat {
|
||||
c.add_message(ChatMsg {
|
||||
id: chat_msg.id.clone(),
|
||||
sender: chat_msg.sender.clone(),
|
||||
content: chat_msg.content.clone(),
|
||||
timestamp: chat_msg.timestamp,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle control messages that affect state
|
||||
if let Some(ref payload) = msg.payload {
|
||||
match payload {
|
||||
message::Payload::StartStream(start) => {
|
||||
tracing::info!("StartStream received from viewer: {}", start.viewer_id);
|
||||
if let Err(e) = self.init_streaming() {
|
||||
tracing::error!("Failed to init streaming: {}", e);
|
||||
} else {
|
||||
self.state = SessionState::Streaming;
|
||||
self.current_viewer_id = Some(start.viewer_id.clone());
|
||||
tracing::info!("Now streaming to viewer {}", start.viewer_id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
message::Payload::StopStream(stop) => {
|
||||
tracing::info!("StopStream received for viewer: {}", stop.viewer_id);
|
||||
// Only stop if it matches current viewer
|
||||
if self.current_viewer_id.as_ref() == Some(&stop.viewer_id) {
|
||||
self.release_streaming();
|
||||
self.state = SessionState::Idle;
|
||||
tracing::info!("Stopped streaming, returning to idle mode");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
message::Payload::Heartbeat(hb) => {
|
||||
// Respond to server heartbeat with ack
|
||||
let ack = HeartbeatAck {
|
||||
client_timestamp: hb.timestamp,
|
||||
server_timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
};
|
||||
let ack_msg = Message {
|
||||
payload: Some(message::Payload::HeartbeatAck(ack)),
|
||||
};
|
||||
if let Some(transport) = self.transport.as_mut() {
|
||||
let _ = transport.send(ack_msg).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other messages (input events, disconnect, etc.)
|
||||
self.handle_message(msg).await?;
|
||||
}
|
||||
|
||||
// Check for outgoing chat messages
|
||||
if let Some(c) = chat {
|
||||
if let Some(outgoing) = c.poll_outgoing() {
|
||||
let chat_proto = ChatMessage {
|
||||
id: outgoing.id,
|
||||
sender: "client".to_string(),
|
||||
content: outgoing.content,
|
||||
timestamp: outgoing.timestamp,
|
||||
};
|
||||
let msg = Message {
|
||||
payload: Some(message::Payload::ChatMessage(chat_proto)),
|
||||
};
|
||||
let transport = self.transport.as_mut().unwrap();
|
||||
transport.send(msg).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// State-specific behavior
|
||||
match self.state {
|
||||
SessionState::Idle => {
|
||||
// In idle mode, just send heartbeats and status periodically
|
||||
if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL {
|
||||
last_heartbeat = Instant::now();
|
||||
if let Err(e) = self.send_heartbeat().await {
|
||||
tracing::warn!("Failed to send heartbeat: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if last_status.elapsed() >= STATUS_INTERVAL {
|
||||
last_status = Instant::now();
|
||||
if let Err(e) = self.send_status().await {
|
||||
tracing::warn!("Failed to send status: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic update check (only for persistent agents, not support sessions)
|
||||
if self.config.support_code.is_none() && last_update_check.elapsed() >= UPDATE_CHECK_INTERVAL {
|
||||
last_update_check = Instant::now();
|
||||
let server_url = self.config.server_url.replace("/ws/agent", "").replace("wss://", "https://").replace("ws://", "http://");
|
||||
match crate::update::check_for_update(&server_url).await {
|
||||
Ok(Some(version_info)) => {
|
||||
tracing::info!("Update available: {} -> {}", crate::build_info::VERSION, version_info.latest_version);
|
||||
if let Err(e) = crate::update::perform_update(&version_info).await {
|
||||
tracing::error!("Auto-update failed: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!("No update available");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Update check failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Longer sleep in idle mode to reduce CPU usage
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
SessionState::Streaming => {
|
||||
// In streaming mode, capture and send frames
|
||||
if last_frame_time.elapsed() >= frame_interval {
|
||||
last_frame_time = Instant::now();
|
||||
|
||||
if let (Some(capturer), Some(encoder)) =
|
||||
(self.capturer.as_mut(), self.encoder.as_mut())
|
||||
{
|
||||
if let Ok(Some(frame)) = capturer.capture() {
|
||||
if let Ok(encoded) = encoder.encode(&frame) {
|
||||
if encoded.size > 0 {
|
||||
let msg = Message {
|
||||
payload: Some(message::Payload::VideoFrame(encoded.frame)),
|
||||
};
|
||||
let transport = self.transport.as_mut().unwrap();
|
||||
if let Err(e) = transport.send(msg).await {
|
||||
tracing::warn!("Failed to send frame: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Short sleep in streaming mode
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
}
|
||||
_ => {
|
||||
// Disconnected or connecting - shouldn't be in main loop
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if still connected
|
||||
if !transport.is_connected() {
|
||||
tracing::warn!("Connection lost");
|
||||
if let Some(transport) = self.transport.as_ref() {
|
||||
if !transport.is_connected() {
|
||||
tracing::warn!("Connection lost");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Transport is None");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
self.release_streaming();
|
||||
self.state = SessionState::Disconnected;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle incoming message from server
|
||||
fn handle_message(&mut self, input: &mut InputController, msg: Message) -> Result<()> {
|
||||
async fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||
match msg.payload {
|
||||
Some(message::Payload::MouseEvent(mouse)) => {
|
||||
// Handle mouse event
|
||||
use crate::proto::MouseEventType;
|
||||
use crate::input::MouseButton;
|
||||
if let Some(input) = self.input.as_mut() {
|
||||
use crate::proto::MouseEventType;
|
||||
use crate::input::MouseButton;
|
||||
|
||||
match MouseEventType::try_from(mouse.event_type).unwrap_or(MouseEventType::MouseMove) {
|
||||
MouseEventType::MouseMove => {
|
||||
input.mouse_move(mouse.x, mouse.y)?;
|
||||
}
|
||||
MouseEventType::MouseDown => {
|
||||
input.mouse_move(mouse.x, mouse.y)?;
|
||||
if let Some(ref buttons) = mouse.buttons {
|
||||
if buttons.left { input.mouse_click(MouseButton::Left, true)?; }
|
||||
if buttons.right { input.mouse_click(MouseButton::Right, true)?; }
|
||||
if buttons.middle { input.mouse_click(MouseButton::Middle, true)?; }
|
||||
match MouseEventType::try_from(mouse.event_type).unwrap_or(MouseEventType::MouseMove) {
|
||||
MouseEventType::MouseMove => {
|
||||
input.mouse_move(mouse.x, mouse.y)?;
|
||||
}
|
||||
}
|
||||
MouseEventType::MouseUp => {
|
||||
if let Some(ref buttons) = mouse.buttons {
|
||||
if buttons.left { input.mouse_click(MouseButton::Left, false)?; }
|
||||
if buttons.right { input.mouse_click(MouseButton::Right, false)?; }
|
||||
if buttons.middle { input.mouse_click(MouseButton::Middle, false)?; }
|
||||
MouseEventType::MouseDown => {
|
||||
input.mouse_move(mouse.x, mouse.y)?;
|
||||
if let Some(ref buttons) = mouse.buttons {
|
||||
if buttons.left { input.mouse_click(MouseButton::Left, true)?; }
|
||||
if buttons.right { input.mouse_click(MouseButton::Right, true)?; }
|
||||
if buttons.middle { input.mouse_click(MouseButton::Middle, true)?; }
|
||||
}
|
||||
}
|
||||
MouseEventType::MouseUp => {
|
||||
if let Some(ref buttons) = mouse.buttons {
|
||||
if buttons.left { input.mouse_click(MouseButton::Left, false)?; }
|
||||
if buttons.right { input.mouse_click(MouseButton::Right, false)?; }
|
||||
if buttons.middle { input.mouse_click(MouseButton::Middle, false)?; }
|
||||
}
|
||||
}
|
||||
MouseEventType::MouseWheel => {
|
||||
input.mouse_scroll(mouse.wheel_delta_x, mouse.wheel_delta_y)?;
|
||||
}
|
||||
}
|
||||
MouseEventType::MouseWheel => {
|
||||
input.mouse_scroll(mouse.wheel_delta_x, mouse.wheel_delta_y)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(message::Payload::KeyEvent(key)) => {
|
||||
// Handle keyboard event
|
||||
input.key_event(key.vk_code as u16, key.down)?;
|
||||
}
|
||||
|
||||
Some(message::Payload::SpecialKey(special)) => {
|
||||
use crate::proto::SpecialKey;
|
||||
match SpecialKey::try_from(special.key).ok() {
|
||||
Some(SpecialKey::CtrlAltDel) => {
|
||||
input.send_ctrl_alt_del()?;
|
||||
}
|
||||
_ => {}
|
||||
if let Some(input) = self.input.as_mut() {
|
||||
input.key_event(key.vk_code as u16, key.down)?;
|
||||
}
|
||||
}
|
||||
|
||||
Some(message::Payload::Heartbeat(_)) => {
|
||||
// Respond to heartbeat
|
||||
// TODO: Send heartbeat ack
|
||||
Some(message::Payload::SpecialKey(special)) => {
|
||||
if let Some(input) = self.input.as_mut() {
|
||||
use crate::proto::SpecialKey;
|
||||
match SpecialKey::try_from(special.key).ok() {
|
||||
Some(SpecialKey::CtrlAltDel) => {
|
||||
input.send_ctrl_alt_del()?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(message::Payload::AdminCommand(cmd)) => {
|
||||
use crate::proto::AdminCommandType;
|
||||
tracing::info!("Admin command received: {:?} - {}", cmd.command, cmd.reason);
|
||||
|
||||
match AdminCommandType::try_from(cmd.command).ok() {
|
||||
Some(AdminCommandType::AdminUninstall) => {
|
||||
tracing::warn!("Uninstall command received from server");
|
||||
// Return special error to trigger uninstall in main loop
|
||||
return Err(anyhow::anyhow!("ADMIN_UNINSTALL: {}", cmd.reason));
|
||||
}
|
||||
Some(AdminCommandType::AdminRestart) => {
|
||||
tracing::info!("Restart command received from server");
|
||||
// For now, just disconnect - the auto-restart logic will handle it
|
||||
return Err(anyhow::anyhow!("ADMIN_RESTART: {}", cmd.reason));
|
||||
}
|
||||
Some(AdminCommandType::AdminUpdate) => {
|
||||
tracing::info!("Update command received from server: {}", cmd.reason);
|
||||
// Trigger update check and perform update if available
|
||||
// The server URL is derived from the config
|
||||
let server_url = self.config.server_url.replace("/ws/agent", "").replace("wss://", "https://").replace("ws://", "http://");
|
||||
match crate::update::check_for_update(&server_url).await {
|
||||
Ok(Some(version_info)) => {
|
||||
tracing::info!("Update available: {} -> {}", crate::build_info::VERSION, version_info.latest_version);
|
||||
if let Err(e) = crate::update::perform_update(&version_info).await {
|
||||
tracing::error!("Update failed: {}", e);
|
||||
}
|
||||
// If we get here, the update failed (perform_update exits on success)
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::info!("Already running latest version");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check for updates: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("Unknown admin command: {}", cmd.command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(message::Payload::Disconnect(disc)) => {
|
||||
tracing::info!("Disconnect requested: {}", disc.reason);
|
||||
if disc.reason.contains("cancelled") {
|
||||
return Err(anyhow::anyhow!("SESSION_CANCELLED: {}", disc.reason));
|
||||
}
|
||||
if disc.reason.contains("administrator") || disc.reason.contains("Disconnected") {
|
||||
return Err(anyhow::anyhow!("ADMIN_DISCONNECT: {}", disc.reason));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Disconnect: {}", disc.reason));
|
||||
}
|
||||
|
||||
|
||||
298
agent/src/startup.rs
Normal file
298
agent/src/startup.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
//! Startup persistence for the agent
|
||||
//!
|
||||
//! Handles adding/removing the agent from Windows startup.
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::{info, warn, error};
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Registry::{
|
||||
RegOpenKeyExW, RegSetValueExW, RegDeleteValueW, RegCloseKey,
|
||||
HKEY_CURRENT_USER, KEY_WRITE, REG_SZ,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
const STARTUP_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
const STARTUP_VALUE_NAME: &str = "GuruConnect";
|
||||
|
||||
/// Add the current executable to Windows startup
|
||||
#[cfg(windows)]
|
||||
pub fn add_to_startup() -> Result<()> {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
// Get the path to the current executable
|
||||
let exe_path = std::env::current_exe()?;
|
||||
let exe_path_str = exe_path.to_string_lossy();
|
||||
|
||||
info!("Adding to startup: {}", exe_path_str);
|
||||
|
||||
// Convert strings to wide strings
|
||||
let key_path: Vec<u16> = OsStr::new(STARTUP_KEY)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let value_name: Vec<u16> = OsStr::new(STARTUP_VALUE_NAME)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let value_data: Vec<u16> = OsStr::new(&*exe_path_str)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
unsafe {
|
||||
let mut hkey = windows::Win32::Foundation::HANDLE::default();
|
||||
|
||||
// Open the Run key
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR(key_path.as_ptr()),
|
||||
0,
|
||||
KEY_WRITE,
|
||||
&mut hkey as *mut _ as *mut _,
|
||||
);
|
||||
|
||||
if result.is_err() {
|
||||
anyhow::bail!("Failed to open registry key: {:?}", result);
|
||||
}
|
||||
|
||||
let hkey_raw = std::mem::transmute::<_, windows::Win32::System::Registry::HKEY>(hkey);
|
||||
|
||||
// Set the value
|
||||
let data_bytes = std::slice::from_raw_parts(
|
||||
value_data.as_ptr() as *const u8,
|
||||
value_data.len() * 2,
|
||||
);
|
||||
|
||||
let set_result = RegSetValueExW(
|
||||
hkey_raw,
|
||||
PCWSTR(value_name.as_ptr()),
|
||||
0,
|
||||
REG_SZ,
|
||||
Some(data_bytes),
|
||||
);
|
||||
|
||||
let _ = RegCloseKey(hkey_raw);
|
||||
|
||||
if set_result.is_err() {
|
||||
anyhow::bail!("Failed to set registry value: {:?}", set_result);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Successfully added to startup");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the agent from Windows startup
|
||||
#[cfg(windows)]
|
||||
pub fn remove_from_startup() -> Result<()> {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
info!("Removing from startup");
|
||||
|
||||
let key_path: Vec<u16> = OsStr::new(STARTUP_KEY)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let value_name: Vec<u16> = OsStr::new(STARTUP_VALUE_NAME)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
unsafe {
|
||||
let mut hkey = windows::Win32::Foundation::HANDLE::default();
|
||||
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR(key_path.as_ptr()),
|
||||
0,
|
||||
KEY_WRITE,
|
||||
&mut hkey as *mut _ as *mut _,
|
||||
);
|
||||
|
||||
if result.is_err() {
|
||||
warn!("Failed to open registry key for removal: {:?}", result);
|
||||
return Ok(()); // Not an error if key doesn't exist
|
||||
}
|
||||
|
||||
let hkey_raw = std::mem::transmute::<_, windows::Win32::System::Registry::HKEY>(hkey);
|
||||
|
||||
let delete_result = RegDeleteValueW(hkey_raw, PCWSTR(value_name.as_ptr()));
|
||||
|
||||
let _ = RegCloseKey(hkey_raw);
|
||||
|
||||
if delete_result.is_err() {
|
||||
warn!("Registry value may not exist: {:?}", delete_result);
|
||||
} else {
|
||||
info!("Successfully removed from startup");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full uninstall: remove from startup and delete the executable
|
||||
#[cfg(windows)]
|
||||
pub fn uninstall() -> Result<()> {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT};
|
||||
|
||||
info!("Uninstalling agent");
|
||||
|
||||
// First remove from startup
|
||||
let _ = remove_from_startup();
|
||||
|
||||
// Get the path to the current executable
|
||||
let exe_path = std::env::current_exe()?;
|
||||
let exe_path_str = exe_path.to_string_lossy();
|
||||
|
||||
info!("Scheduling deletion of: {}", exe_path_str);
|
||||
|
||||
// Convert path to wide string
|
||||
let exe_wide: Vec<u16> = OsStr::new(&*exe_path_str)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
// Schedule the file for deletion on next reboot
|
||||
// This is necessary because the executable is currently running
|
||||
unsafe {
|
||||
let result = MoveFileExW(
|
||||
PCWSTR(exe_wide.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
MOVEFILE_DELAY_UNTIL_REBOOT,
|
||||
);
|
||||
|
||||
if result.is_err() {
|
||||
warn!("Failed to schedule file deletion: {:?}. File may need manual removal.", result);
|
||||
} else {
|
||||
info!("Executable scheduled for deletion on reboot");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the SAS service if the binary is available
|
||||
/// This allows the agent to send Ctrl+Alt+Del even without SYSTEM privileges
|
||||
#[cfg(windows)]
|
||||
pub fn install_sas_service() -> Result<()> {
|
||||
info!("Attempting to install SAS service...");
|
||||
|
||||
// Check if the SAS service binary exists alongside the agent
|
||||
let exe_path = std::env::current_exe()?;
|
||||
let exe_dir = exe_path.parent().ok_or_else(|| anyhow::anyhow!("No parent directory"))?;
|
||||
let sas_binary = exe_dir.join("guruconnect-sas-service.exe");
|
||||
|
||||
if !sas_binary.exists() {
|
||||
// Also check in Program Files
|
||||
let program_files = std::path::PathBuf::from(r"C:\Program Files\GuruConnect\guruconnect-sas-service.exe");
|
||||
if !program_files.exists() {
|
||||
warn!("SAS service binary not found");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Run the install command
|
||||
let sas_path = if sas_binary.exists() {
|
||||
sas_binary
|
||||
} else {
|
||||
std::path::PathBuf::from(r"C:\Program Files\GuruConnect\guruconnect-sas-service.exe")
|
||||
};
|
||||
|
||||
let output = std::process::Command::new(&sas_path)
|
||||
.arg("install")
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(result) => {
|
||||
if result.status.success() {
|
||||
info!("SAS service installed successfully");
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
warn!("SAS service install failed: {}", stderr);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to run SAS service installer: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstall the SAS service
|
||||
#[cfg(windows)]
|
||||
pub fn uninstall_sas_service() -> Result<()> {
|
||||
info!("Attempting to uninstall SAS service...");
|
||||
|
||||
// Try to find and run the uninstall command
|
||||
let paths = [
|
||||
std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.join("guruconnect-sas-service.exe"))),
|
||||
Some(std::path::PathBuf::from(r"C:\Program Files\GuruConnect\guruconnect-sas-service.exe")),
|
||||
];
|
||||
|
||||
for path_opt in paths.iter() {
|
||||
if let Some(ref path) = path_opt {
|
||||
if path.exists() {
|
||||
let output = std::process::Command::new(path)
|
||||
.arg("uninstall")
|
||||
.output();
|
||||
|
||||
if let Ok(result) = output {
|
||||
if result.status.success() {
|
||||
info!("SAS service uninstalled successfully");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("SAS service binary not found for uninstall");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the SAS service is installed and running
|
||||
#[cfg(windows)]
|
||||
pub fn check_sas_service() -> bool {
|
||||
use crate::sas_client;
|
||||
sas_client::is_service_available()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn add_to_startup() -> Result<()> {
|
||||
warn!("Startup persistence not implemented for this platform");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn remove_from_startup() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn uninstall() -> Result<()> {
|
||||
warn!("Uninstall not implemented for this platform");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn install_sas_service() -> Result<()> {
|
||||
warn!("SAS service only available on Windows");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn uninstall_sas_service() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn check_sas_service() -> bool {
|
||||
false
|
||||
}
|
||||
@@ -29,17 +29,37 @@ pub struct WebSocketTransport {
|
||||
|
||||
impl WebSocketTransport {
|
||||
/// Connect to the server
|
||||
pub async fn connect(url: &str, api_key: &str) -> Result<Self> {
|
||||
// Append API key as query parameter
|
||||
let url_with_auth = if url.contains('?') {
|
||||
format!("{}&api_key={}", url, api_key)
|
||||
pub async fn connect(
|
||||
url: &str,
|
||||
agent_id: &str,
|
||||
api_key: &str,
|
||||
hostname: Option<&str>,
|
||||
support_code: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
// Build query parameters
|
||||
let mut params = format!("agent_id={}&api_key={}", agent_id, api_key);
|
||||
|
||||
if let Some(hostname) = hostname {
|
||||
params.push_str(&format!("&hostname={}", urlencoding::encode(hostname)));
|
||||
}
|
||||
|
||||
if let Some(code) = support_code {
|
||||
params.push_str(&format!("&support_code={}", code));
|
||||
}
|
||||
|
||||
// Append parameters to URL
|
||||
let url_with_params = if url.contains('?') {
|
||||
format!("{}&{}", url, params)
|
||||
} else {
|
||||
format!("{}?api_key={}", url, api_key)
|
||||
format!("{}?{}", url, params)
|
||||
};
|
||||
|
||||
tracing::info!("Connecting to {}", url);
|
||||
tracing::info!("Connecting to {} as agent {}", url, agent_id);
|
||||
if let Some(code) = support_code {
|
||||
tracing::info!("Using support code: {}", code);
|
||||
}
|
||||
|
||||
let (ws_stream, response) = connect_async(&url_with_auth)
|
||||
let (ws_stream, response) = connect_async(&url_with_params)
|
||||
.await
|
||||
.context("Failed to connect to WebSocket server")?;
|
||||
|
||||
@@ -122,9 +142,12 @@ impl WebSocketTransport {
|
||||
return Ok(Some(msg));
|
||||
}
|
||||
|
||||
let mut stream = self.stream.lock().await;
|
||||
let result = {
|
||||
let mut stream = self.stream.lock().await;
|
||||
stream.next().await
|
||||
};
|
||||
|
||||
match stream.next().await {
|
||||
match result {
|
||||
Some(Ok(ws_msg)) => self.parse_message(ws_msg),
|
||||
Some(Err(e)) => {
|
||||
self.connected = false;
|
||||
|
||||
197
agent/src/tray/mod.rs
Normal file
197
agent/src/tray/mod.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! System tray icon and menu for the agent
|
||||
//!
|
||||
//! Provides a tray icon with menu options:
|
||||
//! - Connection status
|
||||
//! - Machine name
|
||||
//! - End session
|
||||
|
||||
use anyhow::Result;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
PeekMessageW, TranslateMessage, DispatchMessageW, MSG, PM_REMOVE,
|
||||
};
|
||||
|
||||
/// Events that can be triggered from the tray menu
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TrayAction {
|
||||
EndSession,
|
||||
ShowDetails,
|
||||
ShowDebugWindow,
|
||||
}
|
||||
|
||||
/// Tray icon controller
|
||||
pub struct TrayController {
|
||||
_tray_icon: TrayIcon,
|
||||
menu: Menu,
|
||||
end_session_item: MenuItem,
|
||||
debug_item: MenuItem,
|
||||
status_item: MenuItem,
|
||||
exit_requested: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl TrayController {
|
||||
/// Create a new tray controller
|
||||
/// `allow_end_session` - If true, show "End Session" menu item (only for support sessions)
|
||||
pub fn new(machine_name: &str, support_code: Option<&str>, allow_end_session: bool) -> Result<Self> {
|
||||
// Create menu items
|
||||
let status_text = if let Some(code) = support_code {
|
||||
format!("Support Session: {}", code)
|
||||
} else {
|
||||
"Persistent Agent".to_string()
|
||||
};
|
||||
|
||||
let status_item = MenuItem::new(&status_text, false, None);
|
||||
let machine_item = MenuItem::new(format!("Machine: {}", machine_name), false, None);
|
||||
let separator = PredefinedMenuItem::separator();
|
||||
|
||||
// Only show "End Session" for support sessions
|
||||
// Persistent agents can only be removed by admin
|
||||
let end_session_item = if allow_end_session {
|
||||
MenuItem::new("End Session", true, None)
|
||||
} else {
|
||||
MenuItem::new("Managed by Administrator", false, None)
|
||||
};
|
||||
|
||||
// Debug window option (always available)
|
||||
let debug_item = MenuItem::new("Show Debug Window", true, None);
|
||||
|
||||
// Build menu
|
||||
let menu = Menu::new();
|
||||
menu.append(&status_item)?;
|
||||
menu.append(&machine_item)?;
|
||||
menu.append(&separator)?;
|
||||
menu.append(&debug_item)?;
|
||||
menu.append(&end_session_item)?;
|
||||
|
||||
// Create tray icon
|
||||
let icon = create_default_icon()?;
|
||||
|
||||
let tray_icon = TrayIconBuilder::new()
|
||||
.with_menu(Box::new(menu.clone()))
|
||||
.with_tooltip(format!("GuruConnect - {}", machine_name))
|
||||
.with_icon(icon)
|
||||
.build()?;
|
||||
|
||||
let exit_requested = Arc::new(AtomicBool::new(false));
|
||||
|
||||
Ok(Self {
|
||||
_tray_icon: tray_icon,
|
||||
menu,
|
||||
end_session_item,
|
||||
debug_item,
|
||||
status_item,
|
||||
exit_requested,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if exit has been requested
|
||||
pub fn exit_requested(&self) -> bool {
|
||||
self.exit_requested.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Update the connection status display
|
||||
pub fn update_status(&self, status: &str) {
|
||||
self.status_item.set_text(status);
|
||||
}
|
||||
|
||||
/// Process pending menu events (call this from the main loop)
|
||||
pub fn process_events(&self) -> Option<TrayAction> {
|
||||
// Pump Windows message queue to process tray icon events
|
||||
#[cfg(windows)]
|
||||
pump_windows_messages();
|
||||
|
||||
// Check for menu events
|
||||
if let Ok(event) = MenuEvent::receiver().try_recv() {
|
||||
if event.id == self.end_session_item.id() {
|
||||
info!("End session requested from tray menu");
|
||||
self.exit_requested.store(true, Ordering::SeqCst);
|
||||
return Some(TrayAction::EndSession);
|
||||
}
|
||||
if event.id == self.debug_item.id() {
|
||||
info!("Debug window requested from tray menu");
|
||||
return Some(TrayAction::ShowDebugWindow);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for tray icon events (like double-click)
|
||||
if let Ok(event) = TrayIconEvent::receiver().try_recv() {
|
||||
match event {
|
||||
TrayIconEvent::DoubleClick { .. } => {
|
||||
info!("Tray icon double-clicked");
|
||||
return Some(TrayAction::ShowDetails);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Pump the Windows message queue to process tray icon events
|
||||
#[cfg(windows)]
|
||||
fn pump_windows_messages() {
|
||||
unsafe {
|
||||
let mut msg = MSG::default();
|
||||
// Process all pending messages
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a simple default icon (green circle for connected)
|
||||
fn create_default_icon() -> Result<Icon> {
|
||||
// Create a simple 32x32 green icon
|
||||
let size = 32u32;
|
||||
let mut rgba = vec![0u8; (size * size * 4) as usize];
|
||||
|
||||
let center = size as f32 / 2.0;
|
||||
let radius = size as f32 / 2.0 - 2.0;
|
||||
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let dx = x as f32 - center;
|
||||
let dy = y as f32 - center;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
let idx = ((y * size + x) * 4) as usize;
|
||||
|
||||
if dist <= radius {
|
||||
// Green circle
|
||||
rgba[idx] = 76; // R
|
||||
rgba[idx + 1] = 175; // G
|
||||
rgba[idx + 2] = 80; // B
|
||||
rgba[idx + 3] = 255; // A
|
||||
} else if dist <= radius + 1.0 {
|
||||
// Anti-aliased edge
|
||||
let alpha = ((radius + 1.0 - dist) * 255.0) as u8;
|
||||
rgba[idx] = 76;
|
||||
rgba[idx + 1] = 175;
|
||||
rgba[idx + 2] = 80;
|
||||
rgba[idx + 3] = alpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let icon = Icon::from_rgba(rgba, size, size)?;
|
||||
Ok(icon)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_icon() {
|
||||
let icon = create_default_icon();
|
||||
assert!(icon.is_ok());
|
||||
}
|
||||
}
|
||||
318
agent/src/update.rs
Normal file
318
agent/src/update.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
//! Auto-update module for GuruConnect agent
|
||||
//!
|
||||
//! Handles checking for updates, downloading new versions, and performing
|
||||
//! in-place binary replacement with restart.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use sha2::{Sha256, Digest};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{info, warn, error};
|
||||
|
||||
use crate::build_info;
|
||||
|
||||
/// Version information from the server
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct VersionInfo {
|
||||
pub latest_version: String,
|
||||
pub download_url: String,
|
||||
pub checksum_sha256: String,
|
||||
pub is_mandatory: bool,
|
||||
pub release_notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Update state tracking
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum UpdateState {
|
||||
Idle,
|
||||
Checking,
|
||||
Downloading,
|
||||
Verifying,
|
||||
Installing,
|
||||
Restarting,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Check if an update is available
|
||||
pub async fn check_for_update(server_base_url: &str) -> Result<Option<VersionInfo>> {
|
||||
let url = format!("{}/api/version", server_base_url.trim_end_matches('/'));
|
||||
info!("Checking for updates at {}", url);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true) // For self-signed certs in dev
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
info!("No stable release available on server");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("Version check failed: HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
let version_info: VersionInfo = response.json().await?;
|
||||
|
||||
// Compare versions
|
||||
let current = build_info::VERSION;
|
||||
if is_newer_version(&version_info.latest_version, current) {
|
||||
info!(
|
||||
"Update available: {} -> {} (mandatory: {})",
|
||||
current, version_info.latest_version, version_info.is_mandatory
|
||||
);
|
||||
Ok(Some(version_info))
|
||||
} else {
|
||||
info!("Already running latest version: {}", current);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple semantic version comparison
|
||||
/// Returns true if `available` is newer than `current`
|
||||
fn is_newer_version(available: &str, current: &str) -> bool {
|
||||
// Strip any git hash suffix (e.g., "0.1.0-abc123" -> "0.1.0")
|
||||
let available_clean = available.split('-').next().unwrap_or(available);
|
||||
let current_clean = current.split('-').next().unwrap_or(current);
|
||||
|
||||
let parse_version = |s: &str| -> Vec<u32> {
|
||||
s.split('.')
|
||||
.filter_map(|p| p.parse().ok())
|
||||
.collect()
|
||||
};
|
||||
|
||||
let av = parse_version(available_clean);
|
||||
let cv = parse_version(current_clean);
|
||||
|
||||
// Compare component by component
|
||||
for i in 0..av.len().max(cv.len()) {
|
||||
let a = av.get(i).copied().unwrap_or(0);
|
||||
let c = cv.get(i).copied().unwrap_or(0);
|
||||
if a > c {
|
||||
return true;
|
||||
}
|
||||
if a < c {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Download update to temporary file
|
||||
pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
|
||||
info!("Downloading update from {}", version_info.download_url);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&version_info.download_url)
|
||||
.timeout(std::time::Duration::from_secs(300)) // 5 minutes for large files
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("Download failed: HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
// Get temp directory
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_path = temp_dir.join("guruconnect-update.exe");
|
||||
|
||||
// Download to file
|
||||
let bytes = response.bytes().await?;
|
||||
std::fs::write(&temp_path, &bytes)?;
|
||||
|
||||
info!("Downloaded {} bytes to {:?}", bytes.len(), temp_path);
|
||||
Ok(temp_path)
|
||||
}
|
||||
|
||||
/// Verify downloaded file checksum
|
||||
pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<bool> {
|
||||
info!("Verifying checksum...");
|
||||
|
||||
let contents = std::fs::read(file_path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&contents);
|
||||
let result = hasher.finalize();
|
||||
let computed = format!("{:x}", result);
|
||||
|
||||
let matches = computed.eq_ignore_ascii_case(expected_sha256);
|
||||
|
||||
if matches {
|
||||
info!("Checksum verified: {}", computed);
|
||||
} else {
|
||||
error!("Checksum mismatch! Expected: {}, Got: {}", expected_sha256, computed);
|
||||
}
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// Perform the actual update installation
|
||||
/// This renames the current executable and copies the new one in place
|
||||
pub fn install_update(temp_path: &PathBuf) -> Result<PathBuf> {
|
||||
info!("Installing update...");
|
||||
|
||||
// Get current executable path
|
||||
let current_exe = std::env::current_exe()?;
|
||||
let exe_dir = current_exe.parent()
|
||||
.ok_or_else(|| anyhow!("Cannot get executable directory"))?;
|
||||
|
||||
// Create paths for backup and new executable
|
||||
let backup_path = exe_dir.join("guruconnect.exe.old");
|
||||
|
||||
// Delete any existing backup
|
||||
if backup_path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&backup_path) {
|
||||
warn!("Could not remove old backup: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Rename current executable to .old (this works even while running)
|
||||
info!("Renaming current exe to backup: {:?}", backup_path);
|
||||
std::fs::rename(¤t_exe, &backup_path)?;
|
||||
|
||||
// Copy new executable to original location
|
||||
info!("Copying new exe to: {:?}", current_exe);
|
||||
std::fs::copy(temp_path, ¤t_exe)?;
|
||||
|
||||
// Clean up temp file
|
||||
let _ = std::fs::remove_file(temp_path);
|
||||
|
||||
info!("Update installed successfully");
|
||||
Ok(current_exe)
|
||||
}
|
||||
|
||||
/// Spawn new process and exit current one
|
||||
pub fn restart_with_new_version(exe_path: &PathBuf, args: &[String]) -> Result<()> {
|
||||
info!("Restarting with new version...");
|
||||
|
||||
// Build command with --post-update flag
|
||||
let mut cmd_args = vec!["--post-update".to_string()];
|
||||
cmd_args.extend(args.iter().cloned());
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
|
||||
std::process::Command::new(exe_path)
|
||||
.args(&cmd_args)
|
||||
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.spawn()?;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::process::Command::new(exe_path)
|
||||
.args(&cmd_args)
|
||||
.spawn()?;
|
||||
}
|
||||
|
||||
info!("New process spawned, exiting current process");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up old executable after successful update
|
||||
pub fn cleanup_post_update() {
|
||||
let current_exe = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("Could not get current exe path for cleanup: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let exe_dir = match current_exe.parent() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
warn!("Could not get executable directory for cleanup");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let backup_path = exe_dir.join("guruconnect.exe.old");
|
||||
|
||||
if backup_path.exists() {
|
||||
info!("Cleaning up old executable: {:?}", backup_path);
|
||||
match std::fs::remove_file(&backup_path) {
|
||||
Ok(_) => info!("Old executable removed successfully"),
|
||||
Err(e) => {
|
||||
warn!("Could not remove old executable (may be in use): {}", e);
|
||||
// On Windows, we might need to schedule deletion on reboot
|
||||
#[cfg(windows)]
|
||||
schedule_delete_on_reboot(&backup_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule file deletion on reboot (Windows)
|
||||
#[cfg(windows)]
|
||||
fn schedule_delete_on_reboot(path: &PathBuf) {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT};
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
let path_wide: Vec<u16> = path.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
unsafe {
|
||||
let result = MoveFileExW(
|
||||
PCWSTR(path_wide.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
MOVEFILE_DELAY_UNTIL_REBOOT,
|
||||
);
|
||||
if result.is_ok() {
|
||||
info!("Scheduled {:?} for deletion on reboot", path);
|
||||
} else {
|
||||
warn!("Failed to schedule {:?} for deletion on reboot", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform complete update process
|
||||
pub async fn perform_update(version_info: &VersionInfo) -> Result<()> {
|
||||
// Download
|
||||
let temp_path = download_update(version_info).await?;
|
||||
|
||||
// Verify
|
||||
if !verify_checksum(&temp_path, &version_info.checksum_sha256)? {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(anyhow!("Update verification failed: checksum mismatch"));
|
||||
}
|
||||
|
||||
// Install
|
||||
let exe_path = install_update(&temp_path)?;
|
||||
|
||||
// Restart
|
||||
// Get current args (without the current executable name)
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
restart_with_new_version(&exe_path, &args)?;
|
||||
|
||||
// Exit current process
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version_comparison() {
|
||||
assert!(is_newer_version("0.2.0", "0.1.0"));
|
||||
assert!(is_newer_version("1.0.0", "0.9.9"));
|
||||
assert!(is_newer_version("0.1.1", "0.1.0"));
|
||||
assert!(!is_newer_version("0.1.0", "0.1.0"));
|
||||
assert!(!is_newer_version("0.1.0", "0.2.0"));
|
||||
assert!(is_newer_version("0.2.0-abc123", "0.1.0-def456"));
|
||||
}
|
||||
}
|
||||
173
agent/src/viewer/input.rs
Normal file
173
agent/src/viewer/input.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! Low-level keyboard hook for capturing all keys including Win key
|
||||
|
||||
use super::InputEvent;
|
||||
#[cfg(windows)]
|
||||
use crate::proto;
|
||||
use anyhow::Result;
|
||||
use tokio::sync::mpsc;
|
||||
#[cfg(windows)]
|
||||
use tracing::trace;
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::{
|
||||
Win32::Foundation::{LPARAM, LRESULT, WPARAM},
|
||||
Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, DispatchMessageW, GetMessageW, PeekMessageW, SetWindowsHookExW,
|
||||
TranslateMessage, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, MSG, PM_REMOVE,
|
||||
WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[cfg(windows)]
|
||||
static INPUT_TX: OnceLock<mpsc::Sender<InputEvent>> = OnceLock::new();
|
||||
|
||||
#[cfg(windows)]
|
||||
static mut HOOK_HANDLE: HHOOK = HHOOK(std::ptr::null_mut());
|
||||
|
||||
/// Virtual key codes for special keys
|
||||
#[cfg(windows)]
|
||||
mod vk {
|
||||
pub const VK_LWIN: u32 = 0x5B;
|
||||
pub const VK_RWIN: u32 = 0x5C;
|
||||
pub const VK_APPS: u32 = 0x5D;
|
||||
pub const VK_LSHIFT: u32 = 0xA0;
|
||||
pub const VK_RSHIFT: u32 = 0xA1;
|
||||
pub const VK_LCONTROL: u32 = 0xA2;
|
||||
pub const VK_RCONTROL: u32 = 0xA3;
|
||||
pub const VK_LMENU: u32 = 0xA4; // Left Alt
|
||||
pub const VK_RMENU: u32 = 0xA5; // Right Alt
|
||||
pub const VK_TAB: u32 = 0x09;
|
||||
pub const VK_ESCAPE: u32 = 0x1B;
|
||||
pub const VK_SNAPSHOT: u32 = 0x2C; // Print Screen
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub struct KeyboardHook {
|
||||
_hook: HHOOK,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl KeyboardHook {
|
||||
pub fn new(input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
|
||||
// Store the sender globally for the hook callback
|
||||
INPUT_TX.set(input_tx).map_err(|_| anyhow::anyhow!("Input TX already set"))?;
|
||||
|
||||
unsafe {
|
||||
let hook = SetWindowsHookExW(
|
||||
WH_KEYBOARD_LL,
|
||||
Some(keyboard_hook_proc),
|
||||
None,
|
||||
0,
|
||||
)?;
|
||||
|
||||
HOOK_HANDLE = hook;
|
||||
Ok(Self { _hook: hook })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for KeyboardHook {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
if !HOOK_HANDLE.0.is_null() {
|
||||
let _ = UnhookWindowsHookEx(HOOK_HANDLE);
|
||||
HOOK_HANDLE = HHOOK(std::ptr::null_mut());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
unsafe extern "system" fn keyboard_hook_proc(
|
||||
code: i32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
if code >= 0 {
|
||||
let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
|
||||
let vk_code = kb_struct.vkCode;
|
||||
let scan_code = kb_struct.scanCode;
|
||||
|
||||
let is_down = wparam.0 as u32 == WM_KEYDOWN || wparam.0 as u32 == WM_SYSKEYDOWN;
|
||||
let is_up = wparam.0 as u32 == WM_KEYUP || wparam.0 as u32 == WM_SYSKEYUP;
|
||||
|
||||
if is_down || is_up {
|
||||
// Check if this is a key we want to intercept (Win key, Alt+Tab, etc.)
|
||||
let should_intercept = matches!(
|
||||
vk_code,
|
||||
vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS
|
||||
);
|
||||
|
||||
// Send the key event to the remote
|
||||
if let Some(tx) = INPUT_TX.get() {
|
||||
let event = proto::KeyEvent {
|
||||
down: is_down,
|
||||
key_type: proto::KeyEventType::KeyVk as i32,
|
||||
vk_code,
|
||||
scan_code,
|
||||
unicode: String::new(),
|
||||
modifiers: Some(get_current_modifiers()),
|
||||
};
|
||||
|
||||
let _ = tx.try_send(InputEvent::Key(event));
|
||||
trace!("Key hook: vk={:#x} scan={} down={}", vk_code, scan_code, is_down);
|
||||
}
|
||||
|
||||
// For Win key, consume the event so it doesn't open Start menu locally
|
||||
if should_intercept {
|
||||
return LRESULT(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CallNextHookEx(HOOK_HANDLE, code, wparam, lparam)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_current_modifiers() -> proto::Modifiers {
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
|
||||
|
||||
unsafe {
|
||||
proto::Modifiers {
|
||||
ctrl: GetAsyncKeyState(0x11) < 0, // VK_CONTROL
|
||||
alt: GetAsyncKeyState(0x12) < 0, // VK_MENU
|
||||
shift: GetAsyncKeyState(0x10) < 0, // VK_SHIFT
|
||||
meta: GetAsyncKeyState(0x5B) < 0 || GetAsyncKeyState(0x5C) < 0, // VK_LWIN/RWIN
|
||||
caps_lock: GetAsyncKeyState(0x14) & 1 != 0, // VK_CAPITAL
|
||||
num_lock: GetAsyncKeyState(0x90) & 1 != 0, // VK_NUMLOCK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pump Windows message queue (required for hooks to work)
|
||||
#[cfg(windows)]
|
||||
pub fn pump_messages() {
|
||||
unsafe {
|
||||
let mut msg = MSG::default();
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-Windows stubs
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
pub struct KeyboardHook;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
impl KeyboardHook {
|
||||
pub fn new(_input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
pub fn pump_messages() {}
|
||||
121
agent/src/viewer/mod.rs
Normal file
121
agent/src/viewer/mod.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! Viewer module - Native remote desktop viewer with full keyboard capture
|
||||
//!
|
||||
//! This module provides the viewer functionality for connecting to remote
|
||||
//! GuruConnect sessions with low-level keyboard hooks for Win key capture.
|
||||
|
||||
mod input;
|
||||
mod render;
|
||||
mod transport;
|
||||
|
||||
use crate::proto;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tracing::{info, error, warn};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ViewerEvent {
|
||||
Connected,
|
||||
Disconnected(String),
|
||||
Frame(render::FrameData),
|
||||
CursorPosition(i32, i32, bool),
|
||||
CursorShape(proto::CursorShape),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InputEvent {
|
||||
Mouse(proto::MouseEvent),
|
||||
Key(proto::KeyEvent),
|
||||
SpecialKey(proto::SpecialKeyEvent),
|
||||
}
|
||||
|
||||
/// Run the viewer to connect to a remote session
|
||||
pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()> {
|
||||
info!("GuruConnect Viewer starting");
|
||||
info!("Server: {}", server_url);
|
||||
info!("Session: {}", session_id);
|
||||
|
||||
// Create channels for communication between components
|
||||
let (viewer_tx, viewer_rx) = mpsc::channel::<ViewerEvent>(100);
|
||||
let (input_tx, input_rx) = mpsc::channel::<InputEvent>(100);
|
||||
|
||||
// Connect to server
|
||||
let ws_url = format!("{}?session_id={}", server_url, session_id);
|
||||
info!("Connecting to {}", ws_url);
|
||||
|
||||
let (ws_sender, mut ws_receiver) = transport::connect(&ws_url, api_key).await?;
|
||||
let ws_sender = Arc::new(Mutex::new(ws_sender));
|
||||
|
||||
info!("Connected to server");
|
||||
let _ = viewer_tx.send(ViewerEvent::Connected).await;
|
||||
|
||||
// Clone sender for input forwarding
|
||||
let ws_sender_input = ws_sender.clone();
|
||||
|
||||
// Spawn task to forward input events to server
|
||||
let mut input_rx = input_rx;
|
||||
let input_task = tokio::spawn(async move {
|
||||
while let Some(event) = input_rx.recv().await {
|
||||
let msg = match event {
|
||||
InputEvent::Mouse(m) => proto::Message {
|
||||
payload: Some(proto::message::Payload::MouseEvent(m)),
|
||||
},
|
||||
InputEvent::Key(k) => proto::Message {
|
||||
payload: Some(proto::message::Payload::KeyEvent(k)),
|
||||
},
|
||||
InputEvent::SpecialKey(s) => proto::Message {
|
||||
payload: Some(proto::message::Payload::SpecialKey(s)),
|
||||
},
|
||||
};
|
||||
|
||||
if let Err(e) = transport::send_message(&ws_sender_input, &msg).await {
|
||||
error!("Failed to send input: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn task to receive messages from server
|
||||
let viewer_tx_recv = viewer_tx.clone();
|
||||
let receive_task = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_receiver.recv().await {
|
||||
match msg.payload {
|
||||
Some(proto::message::Payload::VideoFrame(frame)) => {
|
||||
if let Some(proto::video_frame::Encoding::Raw(raw)) = frame.encoding {
|
||||
let frame_data = render::FrameData {
|
||||
width: raw.width as u32,
|
||||
height: raw.height as u32,
|
||||
data: raw.data,
|
||||
compressed: raw.compressed,
|
||||
is_keyframe: raw.is_keyframe,
|
||||
};
|
||||
let _ = viewer_tx_recv.send(ViewerEvent::Frame(frame_data)).await;
|
||||
}
|
||||
}
|
||||
Some(proto::message::Payload::CursorPosition(pos)) => {
|
||||
let _ = viewer_tx_recv.send(ViewerEvent::CursorPosition(
|
||||
pos.x, pos.y, pos.visible
|
||||
)).await;
|
||||
}
|
||||
Some(proto::message::Payload::CursorShape(shape)) => {
|
||||
let _ = viewer_tx_recv.send(ViewerEvent::CursorShape(shape)).await;
|
||||
}
|
||||
Some(proto::message::Payload::Disconnect(d)) => {
|
||||
warn!("Server disconnected: {}", d.reason);
|
||||
let _ = viewer_tx_recv.send(ViewerEvent::Disconnected(d.reason)).await;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Run the window (this blocks until window closes)
|
||||
render::run_window(viewer_rx, input_tx).await?;
|
||||
|
||||
// Cleanup
|
||||
input_task.abort();
|
||||
receive_task.abort();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
508
agent/src/viewer/render.rs
Normal file
508
agent/src/viewer/render.rs
Normal file
@@ -0,0 +1,508 @@
|
||||
//! Window rendering and frame display
|
||||
|
||||
use super::{ViewerEvent, InputEvent};
|
||||
use crate::proto;
|
||||
#[cfg(windows)]
|
||||
use super::input;
|
||||
use anyhow::Result;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
dpi::LogicalSize,
|
||||
event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent},
|
||||
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
window::{Window, WindowId},
|
||||
};
|
||||
|
||||
/// Frame data received from server
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FrameData {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub data: Vec<u8>,
|
||||
pub compressed: bool,
|
||||
pub is_keyframe: bool,
|
||||
}
|
||||
|
||||
struct ViewerApp {
|
||||
window: Option<Arc<Window>>,
|
||||
surface: Option<softbuffer::Surface<Arc<Window>, Arc<Window>>>,
|
||||
frame_buffer: Vec<u32>,
|
||||
frame_width: u32,
|
||||
frame_height: u32,
|
||||
viewer_rx: mpsc::Receiver<ViewerEvent>,
|
||||
input_tx: mpsc::Sender<InputEvent>,
|
||||
mouse_x: i32,
|
||||
mouse_y: i32,
|
||||
#[cfg(windows)]
|
||||
keyboard_hook: Option<input::KeyboardHook>,
|
||||
}
|
||||
|
||||
impl ViewerApp {
|
||||
fn new(
|
||||
viewer_rx: mpsc::Receiver<ViewerEvent>,
|
||||
input_tx: mpsc::Sender<InputEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
window: None,
|
||||
surface: None,
|
||||
frame_buffer: Vec::new(),
|
||||
frame_width: 0,
|
||||
frame_height: 0,
|
||||
viewer_rx,
|
||||
input_tx,
|
||||
mouse_x: 0,
|
||||
mouse_y: 0,
|
||||
#[cfg(windows)]
|
||||
keyboard_hook: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn process_frame(&mut self, frame: FrameData) {
|
||||
let data = if frame.compressed {
|
||||
// Decompress zstd
|
||||
match zstd::decode_all(frame.data.as_slice()) {
|
||||
Ok(decompressed) => decompressed,
|
||||
Err(e) => {
|
||||
error!("Failed to decompress frame: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
frame.data
|
||||
};
|
||||
|
||||
// Convert BGRA to ARGB (softbuffer expects 0RGB format on little-endian)
|
||||
let pixel_count = (frame.width * frame.height) as usize;
|
||||
if data.len() < pixel_count * 4 {
|
||||
error!("Frame data too small: {} < {}", data.len(), pixel_count * 4);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize frame buffer if needed
|
||||
if self.frame_width != frame.width || self.frame_height != frame.height {
|
||||
self.frame_width = frame.width;
|
||||
self.frame_height = frame.height;
|
||||
self.frame_buffer.resize(pixel_count, 0);
|
||||
|
||||
// Resize window to match frame
|
||||
if let Some(window) = &self.window {
|
||||
let _ = window.request_inner_size(LogicalSize::new(frame.width, frame.height));
|
||||
}
|
||||
}
|
||||
|
||||
// Convert BGRA to 0RGB (ignore alpha, swap B and R)
|
||||
for i in 0..pixel_count {
|
||||
let offset = i * 4;
|
||||
let b = data[offset] as u32;
|
||||
let g = data[offset + 1] as u32;
|
||||
let r = data[offset + 2] as u32;
|
||||
// 0RGB format: 0x00RRGGBB
|
||||
self.frame_buffer[i] = (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
// Request redraw
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self) {
|
||||
let Some(surface) = &mut self.surface else { return };
|
||||
let Some(window) = &self.window else { return };
|
||||
|
||||
if self.frame_buffer.is_empty() || self.frame_width == 0 || self.frame_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let size = window.inner_size();
|
||||
if size.width == 0 || size.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize surface if needed
|
||||
let width = NonZeroU32::new(size.width).unwrap();
|
||||
let height = NonZeroU32::new(size.height).unwrap();
|
||||
|
||||
if let Err(e) = surface.resize(width, height) {
|
||||
error!("Failed to resize surface: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut buffer = match surface.buffer_mut() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
error!("Failed to get surface buffer: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Simple nearest-neighbor scaling
|
||||
let scale_x = self.frame_width as f32 / size.width as f32;
|
||||
let scale_y = self.frame_height as f32 / size.height as f32;
|
||||
|
||||
for y in 0..size.height {
|
||||
for x in 0..size.width {
|
||||
let src_x = ((x as f32 * scale_x) as u32).min(self.frame_width - 1);
|
||||
let src_y = ((y as f32 * scale_y) as u32).min(self.frame_height - 1);
|
||||
let src_idx = (src_y * self.frame_width + src_x) as usize;
|
||||
let dst_idx = (y * size.width + x) as usize;
|
||||
|
||||
if src_idx < self.frame_buffer.len() && dst_idx < buffer.len() {
|
||||
buffer[dst_idx] = self.frame_buffer[src_idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = buffer.present() {
|
||||
error!("Failed to present buffer: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_mouse_event(&self, event_type: proto::MouseEventType, x: i32, y: i32) {
|
||||
let event = proto::MouseEvent {
|
||||
x,
|
||||
y,
|
||||
buttons: Some(proto::MouseButtons::default()),
|
||||
wheel_delta_x: 0,
|
||||
wheel_delta_y: 0,
|
||||
event_type: event_type as i32,
|
||||
};
|
||||
|
||||
let _ = self.input_tx.try_send(InputEvent::Mouse(event));
|
||||
}
|
||||
|
||||
fn send_mouse_button(&self, button: MouseButton, state: ElementState) {
|
||||
let event_type = match state {
|
||||
ElementState::Pressed => proto::MouseEventType::MouseDown,
|
||||
ElementState::Released => proto::MouseEventType::MouseUp,
|
||||
};
|
||||
|
||||
let mut buttons = proto::MouseButtons::default();
|
||||
match button {
|
||||
MouseButton::Left => buttons.left = true,
|
||||
MouseButton::Right => buttons.right = true,
|
||||
MouseButton::Middle => buttons.middle = true,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let event = proto::MouseEvent {
|
||||
x: self.mouse_x,
|
||||
y: self.mouse_y,
|
||||
buttons: Some(buttons),
|
||||
wheel_delta_x: 0,
|
||||
wheel_delta_y: 0,
|
||||
event_type: event_type as i32,
|
||||
};
|
||||
|
||||
let _ = self.input_tx.try_send(InputEvent::Mouse(event));
|
||||
}
|
||||
|
||||
fn send_mouse_wheel(&self, delta_x: i32, delta_y: i32) {
|
||||
let event = proto::MouseEvent {
|
||||
x: self.mouse_x,
|
||||
y: self.mouse_y,
|
||||
buttons: Some(proto::MouseButtons::default()),
|
||||
wheel_delta_x: delta_x,
|
||||
wheel_delta_y: delta_y,
|
||||
event_type: proto::MouseEventType::MouseWheel as i32,
|
||||
};
|
||||
|
||||
let _ = self.input_tx.try_send(InputEvent::Mouse(event));
|
||||
}
|
||||
|
||||
fn send_key_event(&self, key: PhysicalKey, state: ElementState) {
|
||||
let vk_code = match key {
|
||||
PhysicalKey::Code(code) => keycode_to_vk(code),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let event = proto::KeyEvent {
|
||||
down: state == ElementState::Pressed,
|
||||
key_type: proto::KeyEventType::KeyVk as i32,
|
||||
vk_code,
|
||||
scan_code: 0,
|
||||
unicode: String::new(),
|
||||
modifiers: Some(proto::Modifiers::default()),
|
||||
};
|
||||
|
||||
let _ = self.input_tx.try_send(InputEvent::Key(event));
|
||||
}
|
||||
|
||||
fn screen_to_frame_coords(&self, x: f64, y: f64) -> (i32, i32) {
|
||||
let Some(window) = &self.window else {
|
||||
return (x as i32, y as i32);
|
||||
};
|
||||
|
||||
let size = window.inner_size();
|
||||
if size.width == 0 || size.height == 0 || self.frame_width == 0 || self.frame_height == 0 {
|
||||
return (x as i32, y as i32);
|
||||
}
|
||||
|
||||
// Scale from window coordinates to frame coordinates
|
||||
let scale_x = self.frame_width as f64 / size.width as f64;
|
||||
let scale_y = self.frame_height as f64 / size.height as f64;
|
||||
|
||||
let frame_x = (x * scale_x) as i32;
|
||||
let frame_y = (y * scale_y) as i32;
|
||||
|
||||
(frame_x, frame_y)
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for ViewerApp {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let window_attrs = Window::default_attributes()
|
||||
.with_title("GuruConnect Viewer")
|
||||
.with_inner_size(LogicalSize::new(1280, 720));
|
||||
|
||||
let window = Arc::new(event_loop.create_window(window_attrs).unwrap());
|
||||
|
||||
// Create software rendering surface
|
||||
let context = softbuffer::Context::new(window.clone()).unwrap();
|
||||
let surface = softbuffer::Surface::new(&context, window.clone()).unwrap();
|
||||
|
||||
self.window = Some(window.clone());
|
||||
self.surface = Some(surface);
|
||||
|
||||
// Install keyboard hook
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let input_tx = self.input_tx.clone();
|
||||
match input::KeyboardHook::new(input_tx) {
|
||||
Ok(hook) => {
|
||||
info!("Keyboard hook installed");
|
||||
self.keyboard_hook = Some(hook);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to install keyboard hook: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Window created");
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
|
||||
// Check for incoming viewer events (non-blocking)
|
||||
while let Ok(viewer_event) = self.viewer_rx.try_recv() {
|
||||
match viewer_event {
|
||||
ViewerEvent::Frame(frame) => {
|
||||
self.process_frame(frame);
|
||||
}
|
||||
ViewerEvent::Connected => {
|
||||
info!("Connected to remote session");
|
||||
}
|
||||
ViewerEvent::Disconnected(reason) => {
|
||||
warn!("Disconnected: {}", reason);
|
||||
event_loop.exit();
|
||||
}
|
||||
ViewerEvent::CursorPosition(_x, _y, _visible) => {
|
||||
// Could update cursor display here
|
||||
}
|
||||
ViewerEvent::CursorShape(_shape) => {
|
||||
// Could update cursor shape here
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
info!("Window close requested");
|
||||
event_loop.exit();
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
self.render();
|
||||
}
|
||||
WindowEvent::Resized(size) => {
|
||||
debug!("Window resized to {}x{}", size.width, size.height);
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
let (x, y) = self.screen_to_frame_coords(position.x, position.y);
|
||||
self.mouse_x = x;
|
||||
self.mouse_y = y;
|
||||
self.send_mouse_event(proto::MouseEventType::MouseMove, x, y);
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
self.send_mouse_button(button, state);
|
||||
}
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let (dx, dy) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => (x as i32 * 120, y as i32 * 120),
|
||||
MouseScrollDelta::PixelDelta(pos) => (pos.x as i32, pos.y as i32),
|
||||
};
|
||||
self.send_mouse_wheel(dx, dy);
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
// Note: This handles keys that aren't captured by the low-level hook
|
||||
// The hook handles Win key and other special keys
|
||||
if !event.repeat {
|
||||
self.send_key_event(event.physical_key, event.state);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// Keep checking for events
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
|
||||
// Process Windows messages for keyboard hook
|
||||
#[cfg(windows)]
|
||||
input::pump_messages();
|
||||
|
||||
// Request redraw periodically to check for new frames
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the viewer window
|
||||
pub async fn run_window(
|
||||
viewer_rx: mpsc::Receiver<ViewerEvent>,
|
||||
input_tx: mpsc::Sender<InputEvent>,
|
||||
) -> Result<()> {
|
||||
let event_loop = EventLoop::new()?;
|
||||
let mut app = ViewerApp::new(viewer_rx, input_tx);
|
||||
|
||||
event_loop.run_app(&mut app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert winit KeyCode to Windows virtual key code
|
||||
fn keycode_to_vk(code: KeyCode) -> u32 {
|
||||
match code {
|
||||
// Letters
|
||||
KeyCode::KeyA => 0x41,
|
||||
KeyCode::KeyB => 0x42,
|
||||
KeyCode::KeyC => 0x43,
|
||||
KeyCode::KeyD => 0x44,
|
||||
KeyCode::KeyE => 0x45,
|
||||
KeyCode::KeyF => 0x46,
|
||||
KeyCode::KeyG => 0x47,
|
||||
KeyCode::KeyH => 0x48,
|
||||
KeyCode::KeyI => 0x49,
|
||||
KeyCode::KeyJ => 0x4A,
|
||||
KeyCode::KeyK => 0x4B,
|
||||
KeyCode::KeyL => 0x4C,
|
||||
KeyCode::KeyM => 0x4D,
|
||||
KeyCode::KeyN => 0x4E,
|
||||
KeyCode::KeyO => 0x4F,
|
||||
KeyCode::KeyP => 0x50,
|
||||
KeyCode::KeyQ => 0x51,
|
||||
KeyCode::KeyR => 0x52,
|
||||
KeyCode::KeyS => 0x53,
|
||||
KeyCode::KeyT => 0x54,
|
||||
KeyCode::KeyU => 0x55,
|
||||
KeyCode::KeyV => 0x56,
|
||||
KeyCode::KeyW => 0x57,
|
||||
KeyCode::KeyX => 0x58,
|
||||
KeyCode::KeyY => 0x59,
|
||||
KeyCode::KeyZ => 0x5A,
|
||||
|
||||
// Numbers
|
||||
KeyCode::Digit0 => 0x30,
|
||||
KeyCode::Digit1 => 0x31,
|
||||
KeyCode::Digit2 => 0x32,
|
||||
KeyCode::Digit3 => 0x33,
|
||||
KeyCode::Digit4 => 0x34,
|
||||
KeyCode::Digit5 => 0x35,
|
||||
KeyCode::Digit6 => 0x36,
|
||||
KeyCode::Digit7 => 0x37,
|
||||
KeyCode::Digit8 => 0x38,
|
||||
KeyCode::Digit9 => 0x39,
|
||||
|
||||
// Function keys
|
||||
KeyCode::F1 => 0x70,
|
||||
KeyCode::F2 => 0x71,
|
||||
KeyCode::F3 => 0x72,
|
||||
KeyCode::F4 => 0x73,
|
||||
KeyCode::F5 => 0x74,
|
||||
KeyCode::F6 => 0x75,
|
||||
KeyCode::F7 => 0x76,
|
||||
KeyCode::F8 => 0x77,
|
||||
KeyCode::F9 => 0x78,
|
||||
KeyCode::F10 => 0x79,
|
||||
KeyCode::F11 => 0x7A,
|
||||
KeyCode::F12 => 0x7B,
|
||||
|
||||
// Special keys
|
||||
KeyCode::Escape => 0x1B,
|
||||
KeyCode::Tab => 0x09,
|
||||
KeyCode::CapsLock => 0x14,
|
||||
KeyCode::ShiftLeft => 0x10,
|
||||
KeyCode::ShiftRight => 0x10,
|
||||
KeyCode::ControlLeft => 0x11,
|
||||
KeyCode::ControlRight => 0x11,
|
||||
KeyCode::AltLeft => 0x12,
|
||||
KeyCode::AltRight => 0x12,
|
||||
KeyCode::Space => 0x20,
|
||||
KeyCode::Enter => 0x0D,
|
||||
KeyCode::Backspace => 0x08,
|
||||
KeyCode::Delete => 0x2E,
|
||||
KeyCode::Insert => 0x2D,
|
||||
KeyCode::Home => 0x24,
|
||||
KeyCode::End => 0x23,
|
||||
KeyCode::PageUp => 0x21,
|
||||
KeyCode::PageDown => 0x22,
|
||||
|
||||
// Arrow keys
|
||||
KeyCode::ArrowUp => 0x26,
|
||||
KeyCode::ArrowDown => 0x28,
|
||||
KeyCode::ArrowLeft => 0x25,
|
||||
KeyCode::ArrowRight => 0x27,
|
||||
|
||||
// Numpad
|
||||
KeyCode::NumLock => 0x90,
|
||||
KeyCode::Numpad0 => 0x60,
|
||||
KeyCode::Numpad1 => 0x61,
|
||||
KeyCode::Numpad2 => 0x62,
|
||||
KeyCode::Numpad3 => 0x63,
|
||||
KeyCode::Numpad4 => 0x64,
|
||||
KeyCode::Numpad5 => 0x65,
|
||||
KeyCode::Numpad6 => 0x66,
|
||||
KeyCode::Numpad7 => 0x67,
|
||||
KeyCode::Numpad8 => 0x68,
|
||||
KeyCode::Numpad9 => 0x69,
|
||||
KeyCode::NumpadAdd => 0x6B,
|
||||
KeyCode::NumpadSubtract => 0x6D,
|
||||
KeyCode::NumpadMultiply => 0x6A,
|
||||
KeyCode::NumpadDivide => 0x6F,
|
||||
KeyCode::NumpadDecimal => 0x6E,
|
||||
KeyCode::NumpadEnter => 0x0D,
|
||||
|
||||
// Punctuation
|
||||
KeyCode::Semicolon => 0xBA,
|
||||
KeyCode::Equal => 0xBB,
|
||||
KeyCode::Comma => 0xBC,
|
||||
KeyCode::Minus => 0xBD,
|
||||
KeyCode::Period => 0xBE,
|
||||
KeyCode::Slash => 0xBF,
|
||||
KeyCode::Backquote => 0xC0,
|
||||
KeyCode::BracketLeft => 0xDB,
|
||||
KeyCode::Backslash => 0xDC,
|
||||
KeyCode::BracketRight => 0xDD,
|
||||
KeyCode::Quote => 0xDE,
|
||||
|
||||
// Other
|
||||
KeyCode::PrintScreen => 0x2C,
|
||||
KeyCode::ScrollLock => 0x91,
|
||||
KeyCode::Pause => 0x13,
|
||||
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
102
agent/src/viewer/transport.rs
Normal file
102
agent/src/viewer/transport.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! WebSocket transport for viewer-server communication
|
||||
|
||||
use crate::proto;
|
||||
use anyhow::{anyhow, Result};
|
||||
use bytes::Bytes;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use prost::Message as ProstMessage;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::{
|
||||
connect_async,
|
||||
tungstenite::protocol::Message as WsMessage,
|
||||
MaybeTlsStream, WebSocketStream,
|
||||
};
|
||||
use tokio::net::TcpStream;
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
pub type WsSender = futures_util::stream::SplitSink<
|
||||
WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
WsMessage,
|
||||
>;
|
||||
|
||||
pub type WsReceiver = futures_util::stream::SplitStream<
|
||||
WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
>;
|
||||
|
||||
/// Receiver wrapper that parses protobuf messages
|
||||
pub struct MessageReceiver {
|
||||
inner: WsReceiver,
|
||||
}
|
||||
|
||||
impl MessageReceiver {
|
||||
pub async fn recv(&mut self) -> Option<proto::Message> {
|
||||
loop {
|
||||
match self.inner.next().await {
|
||||
Some(Ok(WsMessage::Binary(data))) => {
|
||||
match proto::Message::decode(Bytes::from(data)) {
|
||||
Ok(msg) => return Some(msg),
|
||||
Err(e) => {
|
||||
error!("Failed to decode message: {}", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(WsMessage::Close(_))) => {
|
||||
debug!("WebSocket closed");
|
||||
return None;
|
||||
}
|
||||
Some(Ok(WsMessage::Ping(_))) => {
|
||||
trace!("Received ping");
|
||||
continue;
|
||||
}
|
||||
Some(Ok(WsMessage::Pong(_))) => {
|
||||
trace!("Received pong");
|
||||
continue;
|
||||
}
|
||||
Some(Ok(_)) => continue,
|
||||
Some(Err(e)) => {
|
||||
error!("WebSocket error: {}", e);
|
||||
return None;
|
||||
}
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the GuruConnect server
|
||||
pub async fn connect(url: &str, token: &str) -> Result<(WsSender, MessageReceiver)> {
|
||||
// Add auth token to URL
|
||||
let full_url = if token.is_empty() {
|
||||
url.to_string()
|
||||
} else if url.contains('?') {
|
||||
format!("{}&token={}", url, urlencoding::encode(token))
|
||||
} else {
|
||||
format!("{}?token={}", url, urlencoding::encode(token))
|
||||
};
|
||||
|
||||
debug!("Connecting to {}", full_url);
|
||||
|
||||
let (ws_stream, _) = connect_async(&full_url)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to connect: {}", e))?;
|
||||
|
||||
let (sender, receiver) = ws_stream.split();
|
||||
|
||||
Ok((sender, MessageReceiver { inner: receiver }))
|
||||
}
|
||||
|
||||
/// Send a protobuf message over the WebSocket
|
||||
pub async fn send_message(
|
||||
sender: &Arc<Mutex<WsSender>>,
|
||||
msg: &proto::Message,
|
||||
) -> Result<()> {
|
||||
let mut buf = Vec::with_capacity(msg.encoded_len());
|
||||
msg.encode(&mut buf)?;
|
||||
|
||||
let mut sender = sender.lock().await;
|
||||
sender.send(WsMessage::Binary(buf)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -229,6 +229,17 @@ message LatencyReport {
|
||||
int32 bitrate_kbps = 3;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Chat Messages
|
||||
// ============================================================================
|
||||
|
||||
message ChatMessage {
|
||||
string id = 1; // Unique message ID
|
||||
string sender = 2; // "technician" or "client"
|
||||
string content = 3; // Message text
|
||||
int64 timestamp = 4; // Unix timestamp
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Control Messages
|
||||
// ============================================================================
|
||||
@@ -246,6 +257,74 @@ message Disconnect {
|
||||
string reason = 1;
|
||||
}
|
||||
|
||||
// Server commands agent to start streaming video
|
||||
message StartStream {
|
||||
string viewer_id = 1; // ID of viewer requesting stream
|
||||
int32 display_id = 2; // Which display to stream (0 = primary)
|
||||
}
|
||||
|
||||
// Server commands agent to stop streaming
|
||||
message StopStream {
|
||||
string viewer_id = 1; // Which viewer disconnected
|
||||
}
|
||||
|
||||
// Agent reports its status periodically when idle
|
||||
message AgentStatus {
|
||||
string hostname = 1;
|
||||
string os_version = 2;
|
||||
bool is_elevated = 3;
|
||||
int64 uptime_secs = 4;
|
||||
int32 display_count = 5;
|
||||
bool is_streaming = 6;
|
||||
string agent_version = 7; // Agent version (e.g., "0.1.0-abc123")
|
||||
string organization = 8; // Company/organization name
|
||||
string site = 9; // Site/location name
|
||||
repeated string tags = 10; // Tags for categorization
|
||||
}
|
||||
|
||||
// Server commands agent to uninstall itself
|
||||
message AdminCommand {
|
||||
AdminCommandType command = 1;
|
||||
string reason = 2; // Why the command was issued
|
||||
}
|
||||
|
||||
enum AdminCommandType {
|
||||
ADMIN_UNINSTALL = 0; // Uninstall agent and remove from startup
|
||||
ADMIN_RESTART = 1; // Restart the agent process
|
||||
ADMIN_UPDATE = 2; // Download and install update
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-Update Messages
|
||||
// ============================================================================
|
||||
|
||||
// Update command details (sent with AdminCommand or standalone)
|
||||
message UpdateInfo {
|
||||
string version = 1; // Target version (e.g., "0.2.0")
|
||||
string download_url = 2; // HTTPS URL to download new binary
|
||||
string checksum_sha256 = 3; // SHA-256 hash for verification
|
||||
bool mandatory = 4; // If true, agent must update immediately
|
||||
}
|
||||
|
||||
// Update status report (agent -> server)
|
||||
message UpdateStatus {
|
||||
string current_version = 1; // Current running version
|
||||
UpdateState state = 2; // Current update state
|
||||
string error_message = 3; // Error details if state is FAILED
|
||||
int32 progress_percent = 4; // Download progress (0-100)
|
||||
}
|
||||
|
||||
enum UpdateState {
|
||||
UPDATE_IDLE = 0; // No update in progress
|
||||
UPDATE_CHECKING = 1; // Checking for updates
|
||||
UPDATE_DOWNLOADING = 2; // Downloading new binary
|
||||
UPDATE_VERIFYING = 3; // Verifying checksum
|
||||
UPDATE_INSTALLING = 4; // Installing (rename/copy)
|
||||
UPDATE_RESTARTING = 5; // About to restart
|
||||
UPDATE_COMPLETE = 6; // Update successful (after restart)
|
||||
UPDATE_FAILED = 7; // Update failed
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Top-Level Message Wrapper
|
||||
// ============================================================================
|
||||
@@ -282,5 +361,18 @@ message Message {
|
||||
Heartbeat heartbeat = 50;
|
||||
HeartbeatAck heartbeat_ack = 51;
|
||||
Disconnect disconnect = 52;
|
||||
StartStream start_stream = 53;
|
||||
StopStream stop_stream = 54;
|
||||
AgentStatus agent_status = 55;
|
||||
|
||||
// Chat
|
||||
ChatMessage chat_message = 60;
|
||||
|
||||
// Admin commands (server -> agent)
|
||||
AdminCommand admin_command = 70;
|
||||
|
||||
// Auto-update messages
|
||||
UpdateInfo update_info = 75; // Server -> Agent: update available
|
||||
UpdateStatus update_status = 76; // Agent -> Server: update progress
|
||||
}
|
||||
}
|
||||
|
||||
169
scripts/deploy.sh
Executable file
169
scripts/deploy.sh
Executable file
@@ -0,0 +1,169 @@
|
||||
#!/bin/bash
|
||||
# Automated deployment script for GuruConnect
|
||||
# Called by CI/CD pipeline or manually
|
||||
# Usage: ./deploy.sh [package_file.tar.gz]
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "========================================="
|
||||
echo "GuruConnect Deployment Script"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Configuration
|
||||
DEPLOY_DIR="/home/guru/guru-connect"
|
||||
BACKUP_DIR="/home/guru/deployments/backups"
|
||||
ARTIFACT_DIR="/home/guru/deployments/artifacts"
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
|
||||
# Detect package file
|
||||
if [ -n "$1" ]; then
|
||||
PACKAGE_FILE="$1"
|
||||
elif [ -f "/tmp/guruconnect-server-latest.tar.gz" ]; then
|
||||
PACKAGE_FILE="/tmp/guruconnect-server-latest.tar.gz"
|
||||
else
|
||||
echo -e "${RED}ERROR: No deployment package specified${NC}"
|
||||
echo "Usage: $0 <package_file.tar.gz>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$PACKAGE_FILE" ]; then
|
||||
echo -e "${RED}ERROR: Package file not found: $PACKAGE_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Package: $PACKAGE_FILE"
|
||||
echo "Target: $DEPLOY_DIR"
|
||||
echo ""
|
||||
|
||||
# Create backup and artifact directories
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
|
||||
# Backup current binary
|
||||
echo "Creating backup..."
|
||||
if [ -f "$DEPLOY_DIR/target/x86_64-unknown-linux-gnu/release/guruconnect-server" ]; then
|
||||
cp "$DEPLOY_DIR/target/x86_64-unknown-linux-gnu/release/guruconnect-server" \
|
||||
"$BACKUP_DIR/guruconnect-server-${TIMESTAMP}"
|
||||
echo -e "${GREEN}Backup created: ${BACKUP_DIR}/guruconnect-server-${TIMESTAMP}${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}No existing binary to backup${NC}"
|
||||
fi
|
||||
|
||||
# Stop service
|
||||
echo ""
|
||||
echo "Stopping GuruConnect service..."
|
||||
if sudo systemctl is-active --quiet guruconnect; then
|
||||
sudo systemctl stop guruconnect
|
||||
echo -e "${GREEN}Service stopped${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}Service not running${NC}"
|
||||
fi
|
||||
|
||||
# Extract new binary
|
||||
echo ""
|
||||
echo "Extracting deployment package..."
|
||||
TEMP_EXTRACT="/tmp/guruconnect-deploy-${TIMESTAMP}"
|
||||
mkdir -p "$TEMP_EXTRACT"
|
||||
tar -xzf "$PACKAGE_FILE" -C "$TEMP_EXTRACT"
|
||||
|
||||
# Deploy binary
|
||||
echo "Deploying new binary..."
|
||||
if [ -f "$TEMP_EXTRACT/guruconnect-server" ]; then
|
||||
mkdir -p "$DEPLOY_DIR/target/x86_64-unknown-linux-gnu/release"
|
||||
cp "$TEMP_EXTRACT/guruconnect-server" \
|
||||
"$DEPLOY_DIR/target/x86_64-unknown-linux-gnu/release/guruconnect-server"
|
||||
chmod +x "$DEPLOY_DIR/target/x86_64-unknown-linux-gnu/release/guruconnect-server"
|
||||
echo -e "${GREEN}Binary deployed${NC}"
|
||||
else
|
||||
echo -e "${RED}ERROR: Binary not found in package${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deploy static files if present
|
||||
if [ -d "$TEMP_EXTRACT/static" ]; then
|
||||
echo "Deploying static files..."
|
||||
cp -r "$TEMP_EXTRACT/static" "$DEPLOY_DIR/server/"
|
||||
echo -e "${GREEN}Static files deployed${NC}"
|
||||
fi
|
||||
|
||||
# Deploy migrations if present
|
||||
if [ -d "$TEMP_EXTRACT/migrations" ]; then
|
||||
echo "Deploying database migrations..."
|
||||
cp -r "$TEMP_EXTRACT/migrations" "$DEPLOY_DIR/server/"
|
||||
echo -e "${GREEN}Migrations deployed${NC}"
|
||||
fi
|
||||
|
||||
# Save artifact
|
||||
echo ""
|
||||
echo "Archiving deployment package..."
|
||||
cp "$PACKAGE_FILE" "$ARTIFACT_DIR/guruconnect-server-${TIMESTAMP}.tar.gz"
|
||||
ln -sf "$ARTIFACT_DIR/guruconnect-server-${TIMESTAMP}.tar.gz" \
|
||||
"$ARTIFACT_DIR/guruconnect-server-latest.tar.gz"
|
||||
echo -e "${GREEN}Artifact saved${NC}"
|
||||
|
||||
# Cleanup temp directory
|
||||
rm -rf "$TEMP_EXTRACT"
|
||||
|
||||
# Start service
|
||||
echo ""
|
||||
echo "Starting GuruConnect service..."
|
||||
sudo systemctl start guruconnect
|
||||
sleep 2
|
||||
|
||||
# Verify service started
|
||||
if sudo systemctl is-active --quiet guruconnect; then
|
||||
echo -e "${GREEN}Service started successfully${NC}"
|
||||
else
|
||||
echo -e "${RED}ERROR: Service failed to start${NC}"
|
||||
echo "Rolling back to previous version..."
|
||||
|
||||
# Rollback
|
||||
if [ -f "$BACKUP_DIR/guruconnect-server-${TIMESTAMP}" ]; then
|
||||
cp "$BACKUP_DIR/guruconnect-server-${TIMESTAMP}" \
|
||||
"$DEPLOY_DIR/target/x86_64-unknown-linux-gnu/release/guruconnect-server"
|
||||
sudo systemctl start guruconnect
|
||||
echo -e "${YELLOW}Rolled back to previous version${NC}"
|
||||
fi
|
||||
|
||||
echo "Check logs: sudo journalctl -u guruconnect -n 50"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Health check
|
||||
echo ""
|
||||
echo "Running health check..."
|
||||
sleep 2
|
||||
if curl -s http://172.16.3.30:3002/health | grep -q "OK"; then
|
||||
echo -e "${GREEN}Health check: PASSED${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}WARNING: Health check failed${NC}"
|
||||
echo "Service may still be starting up..."
|
||||
fi
|
||||
|
||||
# Get version info
|
||||
echo ""
|
||||
echo "Deployment version information:"
|
||||
VERSION=$($DEPLOY_DIR/target/x86_64-unknown-linux-gnu/release/guruconnect-server --version 2>/dev/null || echo "Version info not available")
|
||||
echo "$VERSION"
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Deployment Complete!"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "Deployment time: $TIMESTAMP"
|
||||
echo "Backup location: $BACKUP_DIR/guruconnect-server-${TIMESTAMP}"
|
||||
echo "Artifact location: $ARTIFACT_DIR/guruconnect-server-${TIMESTAMP}.tar.gz"
|
||||
echo ""
|
||||
echo "Service status:"
|
||||
sudo systemctl status guruconnect --no-pager | head -15
|
||||
echo ""
|
||||
echo "To view logs: sudo journalctl -u guruconnect -f"
|
||||
echo "To rollback: cp $BACKUP_DIR/guruconnect-server-${TIMESTAMP} target/x86_64-unknown-linux-gnu/release/guruconnect-server && sudo systemctl restart guruconnect"
|
||||
echo ""
|
||||
113
scripts/install-gitea-runner.sh
Executable file
113
scripts/install-gitea-runner.sh
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
# Install and configure Gitea Actions Runner
|
||||
# Run as: sudo bash install-gitea-runner.sh
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "========================================="
|
||||
echo "Gitea Actions Runner Installation"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}ERROR: This script must be run as root (sudo)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Variables
|
||||
RUNNER_VERSION="0.2.11"
|
||||
RUNNER_USER="gitea-runner"
|
||||
RUNNER_HOME="/home/${RUNNER_USER}"
|
||||
GITEA_URL="https://git.azcomputerguru.com"
|
||||
RUNNER_NAME="gururmm-runner"
|
||||
|
||||
echo "Installing Gitea Actions Runner v${RUNNER_VERSION}"
|
||||
echo "Target: ${GITEA_URL}"
|
||||
echo ""
|
||||
|
||||
# Create runner user
|
||||
if ! id "${RUNNER_USER}" &>/dev/null; then
|
||||
echo "Creating ${RUNNER_USER} user..."
|
||||
useradd -m -s /bin/bash "${RUNNER_USER}"
|
||||
echo -e "${GREEN}User created${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}User ${RUNNER_USER} already exists${NC}"
|
||||
fi
|
||||
|
||||
# Download runner binary
|
||||
echo "Downloading Gitea Actions Runner..."
|
||||
cd /tmp
|
||||
wget -q "https://dl.gitea.com/act_runner/${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-amd64" -O act_runner
|
||||
|
||||
# Install binary
|
||||
echo "Installing binary..."
|
||||
chmod +x act_runner
|
||||
mv act_runner /usr/local/bin/
|
||||
chown root:root /usr/local/bin/act_runner
|
||||
|
||||
# Create runner directory
|
||||
echo "Creating runner directory..."
|
||||
mkdir -p "${RUNNER_HOME}/.runner"
|
||||
chown -R "${RUNNER_USER}:${RUNNER_USER}" "${RUNNER_HOME}/.runner"
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Runner Registration"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "To complete setup, you need to register the runner with Gitea:"
|
||||
echo ""
|
||||
echo "1. Go to: ${GITEA_URL}/admin/actions/runners"
|
||||
echo "2. Click 'Create new Runner'"
|
||||
echo "3. Copy the registration token"
|
||||
echo "4. Run as ${RUNNER_USER}:"
|
||||
echo ""
|
||||
echo " sudo -u ${RUNNER_USER} act_runner register \\"
|
||||
echo " --instance ${GITEA_URL} \\"
|
||||
echo " --token YOUR_REGISTRATION_TOKEN \\"
|
||||
echo " --name ${RUNNER_NAME} \\"
|
||||
echo " --labels ubuntu-latest,ubuntu-22.04"
|
||||
echo ""
|
||||
echo "5. Then create systemd service:"
|
||||
echo ""
|
||||
cat > /etc/systemd/system/gitea-runner.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=gitea-runner
|
||||
WorkingDirectory=/home/gitea-runner/.runner
|
||||
ExecStart=/usr/local/bin/act_runner daemon
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment="HOME=/home/gitea-runner"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo "Systemd service created at /etc/systemd/system/gitea-runner.service"
|
||||
echo ""
|
||||
echo "After registration, enable and start the service:"
|
||||
echo " sudo systemctl daemon-reload"
|
||||
echo " sudo systemctl enable gitea-runner"
|
||||
echo " sudo systemctl start gitea-runner"
|
||||
echo " sudo systemctl status gitea-runner"
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Installation Complete!"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next Steps:${NC}"
|
||||
echo "1. Register the runner (see instructions above)"
|
||||
echo "2. Start the systemd service"
|
||||
echo "3. Verify runner shows up in Gitea Admin > Actions > Runners"
|
||||
echo ""
|
||||
120
scripts/version-tag.sh
Executable file
120
scripts/version-tag.sh
Executable file
@@ -0,0 +1,120 @@
|
||||
#!/bin/bash
|
||||
# Automated version tagging script
|
||||
# Creates git tags based on semantic versioning
|
||||
# Usage: ./version-tag.sh [major|minor|patch]
|
||||
|
||||
set -e
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
BUMP_TYPE="${1:-patch}"
|
||||
|
||||
echo "========================================="
|
||||
echo "GuruConnect Version Tagging"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Validate bump type
|
||||
if [[ ! "$BUMP_TYPE" =~ ^(major|minor|patch)$ ]]; then
|
||||
echo -e "${RED}ERROR: Invalid bump type: $BUMP_TYPE${NC}"
|
||||
echo "Usage: $0 [major|minor|patch]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get current version from latest tag
|
||||
CURRENT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||
echo "Current version: $CURRENT_TAG"
|
||||
|
||||
# Parse version
|
||||
if [[ $CURRENT_TAG =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
MAJOR="${BASH_REMATCH[1]}"
|
||||
MINOR="${BASH_REMATCH[2]}"
|
||||
PATCH="${BASH_REMATCH[3]}"
|
||||
else
|
||||
echo -e "${YELLOW}No valid version tag found, starting from v0.1.0${NC}"
|
||||
MAJOR=0
|
||||
MINOR=1
|
||||
PATCH=0
|
||||
fi
|
||||
|
||||
# Bump version
|
||||
case $BUMP_TYPE in
|
||||
major)
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
;;
|
||||
minor)
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
;;
|
||||
patch)
|
||||
PATCH=$((PATCH + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
|
||||
|
||||
echo "New version: $NEW_TAG"
|
||||
echo ""
|
||||
|
||||
# Check if tag already exists
|
||||
if git rev-parse "$NEW_TAG" >/dev/null 2>&1; then
|
||||
echo -e "${RED}ERROR: Tag $NEW_TAG already exists${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Show changes since last tag
|
||||
echo "Changes since $CURRENT_TAG:"
|
||||
echo "-------------------------------------------"
|
||||
git log --oneline "${CURRENT_TAG}..HEAD" | head -20
|
||||
echo "-------------------------------------------"
|
||||
echo ""
|
||||
|
||||
# Confirm
|
||||
read -p "Create tag $NEW_TAG? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Update Cargo.toml versions
|
||||
echo ""
|
||||
echo "Updating Cargo.toml versions..."
|
||||
if [ -f "server/Cargo.toml" ]; then
|
||||
sed -i.bak "s/^version = .*/version = \"${MAJOR}.${MINOR}.${PATCH}\"/" server/Cargo.toml
|
||||
rm server/Cargo.toml.bak 2>/dev/null || true
|
||||
echo -e "${GREEN}Updated server/Cargo.toml${NC}"
|
||||
fi
|
||||
|
||||
if [ -f "agent/Cargo.toml" ]; then
|
||||
sed -i.bak "s/^version = .*/version = \"${MAJOR}.${MINOR}.${PATCH}\"/" agent/Cargo.toml
|
||||
rm agent/Cargo.toml.bak 2>/dev/null || true
|
||||
echo -e "${GREEN}Updated agent/Cargo.toml${NC}"
|
||||
fi
|
||||
|
||||
# Commit version bump
|
||||
echo ""
|
||||
echo "Committing version bump..."
|
||||
git add server/Cargo.toml agent/Cargo.toml 2>/dev/null || true
|
||||
git commit -m "chore: bump version to ${NEW_TAG}" || echo "No changes to commit"
|
||||
|
||||
# Create tag
|
||||
echo ""
|
||||
echo "Creating tag $NEW_TAG..."
|
||||
git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
|
||||
|
||||
echo -e "${GREEN}Tag created successfully${NC}"
|
||||
echo ""
|
||||
echo "To push tag to remote:"
|
||||
echo " git push origin $NEW_TAG"
|
||||
echo ""
|
||||
echo "To push all changes and tag:"
|
||||
echo " git push origin main && git push origin $NEW_TAG"
|
||||
echo ""
|
||||
echo "This will trigger the deployment workflow in CI/CD"
|
||||
echo ""
|
||||
88
server/migrations/001_initial_schema.sql
Normal file
88
server/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
-- GuruConnect Initial Schema
|
||||
-- Machine persistence, session audit logging, and support codes
|
||||
|
||||
-- Enable UUID generation
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- Machines table - persistent agent records that survive server restarts
|
||||
CREATE TABLE connect_machines (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
hostname VARCHAR(255) NOT NULL,
|
||||
os_version VARCHAR(255),
|
||||
is_elevated BOOLEAN DEFAULT FALSE,
|
||||
is_persistent BOOLEAN DEFAULT TRUE,
|
||||
first_seen TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_seen TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_session_id UUID,
|
||||
status VARCHAR(20) DEFAULT 'offline',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_connect_machines_agent_id ON connect_machines(agent_id);
|
||||
CREATE INDEX idx_connect_machines_status ON connect_machines(status);
|
||||
|
||||
-- Sessions table - connection history
|
||||
CREATE TABLE connect_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
machine_id UUID REFERENCES connect_machines(id) ON DELETE CASCADE,
|
||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
duration_secs INTEGER,
|
||||
is_support_session BOOLEAN DEFAULT FALSE,
|
||||
support_code VARCHAR(10),
|
||||
status VARCHAR(20) DEFAULT 'active'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_connect_sessions_machine ON connect_sessions(machine_id);
|
||||
CREATE INDEX idx_connect_sessions_started ON connect_sessions(started_at DESC);
|
||||
CREATE INDEX idx_connect_sessions_support_code ON connect_sessions(support_code);
|
||||
|
||||
-- Session events - comprehensive audit log
|
||||
CREATE TABLE connect_session_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id UUID REFERENCES connect_sessions(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
||||
viewer_id VARCHAR(255),
|
||||
viewer_name VARCHAR(255),
|
||||
details JSONB,
|
||||
ip_address INET
|
||||
);
|
||||
|
||||
CREATE INDEX idx_connect_events_session ON connect_session_events(session_id);
|
||||
CREATE INDEX idx_connect_events_time ON connect_session_events(timestamp DESC);
|
||||
CREATE INDEX idx_connect_events_type ON connect_session_events(event_type);
|
||||
|
||||
-- Support codes - persistent across restarts
|
||||
CREATE TABLE connect_support_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(10) UNIQUE NOT NULL,
|
||||
session_id UUID,
|
||||
created_by VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
client_name VARCHAR(255),
|
||||
client_machine VARCHAR(255),
|
||||
connected_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_support_codes_code ON connect_support_codes(code);
|
||||
CREATE INDEX idx_support_codes_status ON connect_support_codes(status);
|
||||
CREATE INDEX idx_support_codes_session ON connect_support_codes(session_id);
|
||||
|
||||
-- Trigger to auto-update updated_at on machines
|
||||
CREATE OR REPLACE FUNCTION update_connect_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER update_connect_machines_updated_at
|
||||
BEFORE UPDATE ON connect_machines
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_connect_updated_at();
|
||||
44
server/migrations/002_user_management.sql
Normal file
44
server/migrations/002_user_management.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- GuruConnect User Management Schema
|
||||
-- User authentication, roles, and per-client access control
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(64) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
role VARCHAR(32) NOT NULL DEFAULT 'viewer',
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_login TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Granular permissions (what actions a user can perform)
|
||||
CREATE TABLE user_permissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
permission VARCHAR(64) NOT NULL,
|
||||
UNIQUE(user_id, permission)
|
||||
);
|
||||
|
||||
-- Per-client access (which machines a user can access)
|
||||
-- No entries = access to all clients (for admins)
|
||||
CREATE TABLE user_client_access (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
client_id UUID NOT NULL REFERENCES connect_machines(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, client_id)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_users_enabled ON users(enabled);
|
||||
CREATE INDEX idx_user_permissions_user ON user_permissions(user_id);
|
||||
CREATE INDEX idx_user_client_access_user ON user_client_access(user_id);
|
||||
|
||||
-- Trigger for updated_at
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_connect_updated_at();
|
||||
35
server/migrations/003_auto_update.sql
Normal file
35
server/migrations/003_auto_update.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Migration: 003_auto_update.sql
|
||||
-- Purpose: Add auto-update infrastructure (releases table and machine version tracking)
|
||||
|
||||
-- ============================================================================
|
||||
-- Releases Table
|
||||
-- ============================================================================
|
||||
|
||||
-- Track available agent releases
|
||||
CREATE TABLE IF NOT EXISTS releases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
version VARCHAR(32) NOT NULL UNIQUE,
|
||||
download_url TEXT NOT NULL,
|
||||
checksum_sha256 VARCHAR(64) NOT NULL,
|
||||
release_notes TEXT,
|
||||
is_stable BOOLEAN NOT NULL DEFAULT false,
|
||||
is_mandatory BOOLEAN NOT NULL DEFAULT false,
|
||||
min_version VARCHAR(32), -- Minimum version that can update to this
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for finding latest stable release
|
||||
CREATE INDEX IF NOT EXISTS idx_releases_stable ON releases(is_stable, created_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- Machine Version Tracking
|
||||
-- ============================================================================
|
||||
|
||||
-- Add version tracking columns to existing machines table
|
||||
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS agent_version VARCHAR(32);
|
||||
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS update_status VARCHAR(32);
|
||||
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS last_update_check TIMESTAMPTZ;
|
||||
|
||||
-- Index for finding machines needing updates
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_version ON connect_machines(agent_version);
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_update_status ON connect_machines(update_status);
|
||||
317
server/src/api/auth.rs
Normal file
317
server/src/api/auth.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
//! Authentication API endpoints
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::{
|
||||
verify_password, AuthenticatedUser, JwtConfig,
|
||||
};
|
||||
use crate::db;
|
||||
use crate::AppState;
|
||||
|
||||
/// Login request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Login response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub user: UserResponse,
|
||||
}
|
||||
|
||||
/// User info in response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Error response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// POST /api/auth/login
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(request): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Get user by username
|
||||
let user = db::get_user_by_username(db.pool(), &request.username)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error during login: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid username or password".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Check if user is enabled
|
||||
if !user.enabled {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Account is disabled".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Verify password
|
||||
let password_valid = verify_password(&request.password, &user.password_hash)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Password verification error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !password_valid {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid username or password".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Get user permissions
|
||||
let permissions = db::get_user_permissions(db.pool(), user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Update last login
|
||||
let _ = db::update_last_login(db.pool(), user.id).await;
|
||||
|
||||
// Create JWT token
|
||||
let token = state.jwt_config.create_token(
|
||||
user.id,
|
||||
&user.username,
|
||||
&user.role,
|
||||
permissions.clone(),
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Token creation error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to create token".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!("User {} logged in successfully", user.username);
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
token,
|
||||
user: UserResponse {
|
||||
id: user.id.to_string(),
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
permissions,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/auth/me - Get current user info
|
||||
pub async fn get_me(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<UserResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = uuid::Uuid::parse_str(&user.user_id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid user ID".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let db_user = db::get_user_by_id(db.pool(), user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let permissions = db::get_user_permissions(db.pool(), db_user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(UserResponse {
|
||||
id: db_user.id.to_string(),
|
||||
username: db_user.username,
|
||||
email: db_user.email,
|
||||
role: db_user.role,
|
||||
permissions,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Change password request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// POST /api/auth/change-password
|
||||
pub async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(request): Json<ChangePasswordRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = uuid::Uuid::parse_str(&user.user_id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid user ID".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Get current user
|
||||
let db_user = db::get_user_by_id(db.pool(), user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Verify current password
|
||||
let password_valid = verify_password(&request.current_password, &db_user.password_hash)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Password verification error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !password_valid {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Current password is incorrect".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if request.new_password.len() < 8 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Password must be at least 8 characters".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
let new_hash = crate::auth::hash_password(&request.new_password)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Password hashing error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to hash password".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Update password
|
||||
db::update_user_password(db.pool(), user_id, &new_hash)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to update password".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!("User {} changed their password", user.username);
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
268
server/src/api/downloads.rs
Normal file
268
server/src/api/downloads.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
//! Download endpoints for generating configured agent binaries
|
||||
//!
|
||||
//! Provides endpoints for:
|
||||
//! - Viewer-only downloads
|
||||
//! - Temp support session downloads (with embedded code)
|
||||
//! - Permanent agent downloads (with embedded config)
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{info, warn, error};
|
||||
|
||||
/// Magic marker for embedded configuration (must match agent)
|
||||
const MAGIC_MARKER: &[u8] = b"GURUCONFIG";
|
||||
|
||||
/// Embedded configuration data structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbeddedConfig {
|
||||
/// Server WebSocket URL
|
||||
pub server_url: String,
|
||||
/// API key for authentication
|
||||
pub api_key: String,
|
||||
/// Company/organization name
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub company: Option<String>,
|
||||
/// Site/location name
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub site: Option<String>,
|
||||
/// Tags for categorization
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Query parameters for agent download
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentDownloadParams {
|
||||
/// Company/organization name
|
||||
pub company: Option<String>,
|
||||
/// Site/location name
|
||||
pub site: Option<String>,
|
||||
/// Comma-separated tags
|
||||
pub tags: Option<String>,
|
||||
/// API key (optional, will use default if not provided)
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Query parameters for support session download
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SupportDownloadParams {
|
||||
/// 6-digit support code
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// Get path to base agent binary
|
||||
fn get_base_binary_path() -> PathBuf {
|
||||
// Check for static/downloads/guruconnect.exe relative to working dir
|
||||
let static_path = PathBuf::from("static/downloads/guruconnect.exe");
|
||||
if static_path.exists() {
|
||||
return static_path;
|
||||
}
|
||||
|
||||
// Also check without static prefix (in case running from server dir)
|
||||
let downloads_path = PathBuf::from("downloads/guruconnect.exe");
|
||||
if downloads_path.exists() {
|
||||
return downloads_path;
|
||||
}
|
||||
|
||||
// Fallback to static path
|
||||
static_path
|
||||
}
|
||||
|
||||
/// Download viewer-only binary (no embedded config, "Viewer" in filename)
|
||||
pub async fn download_viewer() -> impl IntoResponse {
|
||||
let binary_path = get_base_binary_path();
|
||||
|
||||
match std::fs::read(&binary_path) {
|
||||
Ok(binary_data) => {
|
||||
info!("Serving viewer download ({} bytes)", binary_data.len());
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"GuruConnect-Viewer.exe\""
|
||||
)
|
||||
.header(header::CONTENT_LENGTH, binary_data.len())
|
||||
.body(Body::from(binary_data))
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read base binary from {:?}: {}", binary_path, e);
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("Agent binary not found"))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Download support session binary (code embedded in filename)
|
||||
pub async fn download_support(
|
||||
Query(params): Query<SupportDownloadParams>,
|
||||
) -> impl IntoResponse {
|
||||
// Validate support code (must be 6 digits)
|
||||
let code = params.code.trim();
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::from("Invalid support code: must be 6 digits"))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let binary_path = get_base_binary_path();
|
||||
|
||||
match std::fs::read(&binary_path) {
|
||||
Ok(binary_data) => {
|
||||
info!("Serving support session download for code {} ({} bytes)", code, binary_data.len());
|
||||
|
||||
// Filename includes the support code
|
||||
let filename = format!("GuruConnect-{}.exe", code);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename)
|
||||
)
|
||||
.header(header::CONTENT_LENGTH, binary_data.len())
|
||||
.body(Body::from(binary_data))
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read base binary: {}", e);
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("Agent binary not found"))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Download permanent agent binary with embedded configuration
|
||||
pub async fn download_agent(
|
||||
Query(params): Query<AgentDownloadParams>,
|
||||
) -> impl IntoResponse {
|
||||
let binary_path = get_base_binary_path();
|
||||
|
||||
// Read base binary
|
||||
let mut binary_data = match std::fs::read(&binary_path) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!("Failed to read base binary: {}", e);
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("Agent binary not found"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
// Build embedded config
|
||||
let config = EmbeddedConfig {
|
||||
server_url: "wss://connect.azcomputerguru.com/ws/agent".to_string(),
|
||||
api_key: params.api_key.unwrap_or_else(|| "managed-agent".to_string()),
|
||||
company: params.company.clone(),
|
||||
site: params.site.clone(),
|
||||
tags: params.tags
|
||||
.as_ref()
|
||||
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
|
||||
// Serialize config to JSON
|
||||
let config_json = match serde_json::to_vec(&config) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
error!("Failed to serialize config: {}", e);
|
||||
return Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(Body::from("Failed to generate config"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
// Append magic marker + length + config to binary
|
||||
// Structure: [PE binary][GURUCONFIG][length:u32 LE][json config]
|
||||
binary_data.extend_from_slice(MAGIC_MARKER);
|
||||
binary_data.extend_from_slice(&(config_json.len() as u32).to_le_bytes());
|
||||
binary_data.extend_from_slice(&config_json);
|
||||
|
||||
info!(
|
||||
"Serving permanent agent download: company={:?}, site={:?}, tags={:?} ({} bytes)",
|
||||
config.company, config.site, config.tags, binary_data.len()
|
||||
);
|
||||
|
||||
// Generate filename based on company/site
|
||||
let filename = match (¶ms.company, ¶ms.site) {
|
||||
(Some(company), Some(site)) => {
|
||||
format!("GuruConnect-{}-{}-Setup.exe", sanitize_filename(company), sanitize_filename(site))
|
||||
}
|
||||
(Some(company), None) => {
|
||||
format!("GuruConnect-{}-Setup.exe", sanitize_filename(company))
|
||||
}
|
||||
_ => "GuruConnect-Setup.exe".to_string()
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename)
|
||||
)
|
||||
.header(header::CONTENT_LENGTH, binary_data.len())
|
||||
.body(Body::from(binary_data))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Sanitize a string for use in a filename
|
||||
fn sanitize_filename(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else if c == ' ' {
|
||||
'-'
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.take(32) // Limit length
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename() {
|
||||
assert_eq!(sanitize_filename("Acme Corp"), "Acme-Corp");
|
||||
assert_eq!(sanitize_filename("My Company!"), "My-Company_");
|
||||
assert_eq!(sanitize_filename("Test/Site"), "Test_Site");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embedded_config_serialization() {
|
||||
let config = EmbeddedConfig {
|
||||
server_url: "wss://example.com/ws".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
company: Some("Test Corp".to_string()),
|
||||
site: None,
|
||||
tags: vec!["windows".to_string()],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
assert!(json.contains("Test Corp"));
|
||||
assert!(json.contains("windows"));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,37 @@
|
||||
//! REST API endpoints
|
||||
|
||||
pub mod auth;
|
||||
pub mod users;
|
||||
pub mod releases;
|
||||
pub mod downloads;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, State, Query},
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::session::SessionManager;
|
||||
use crate::db;
|
||||
|
||||
/// Viewer info returned by API
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ViewerInfoApi {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub connected_at: String,
|
||||
}
|
||||
|
||||
impl From<crate::session::ViewerInfo> for ViewerInfoApi {
|
||||
fn from(v: crate::session::ViewerInfo) -> Self {
|
||||
Self {
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
connected_at: v.connected_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session info returned by API
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -17,6 +41,16 @@ pub struct SessionInfo {
|
||||
pub agent_name: String,
|
||||
pub started_at: String,
|
||||
pub viewer_count: usize,
|
||||
pub viewers: Vec<ViewerInfoApi>,
|
||||
pub is_streaming: bool,
|
||||
pub is_online: bool,
|
||||
pub is_persistent: bool,
|
||||
pub last_heartbeat: String,
|
||||
pub os_version: Option<String>,
|
||||
pub is_elevated: bool,
|
||||
pub uptime_secs: i64,
|
||||
pub display_count: i32,
|
||||
pub agent_version: Option<String>,
|
||||
}
|
||||
|
||||
impl From<crate::session::Session> for SessionInfo {
|
||||
@@ -27,6 +61,16 @@ impl From<crate::session::Session> for SessionInfo {
|
||||
agent_name: s.agent_name,
|
||||
started_at: s.started_at.to_rfc3339(),
|
||||
viewer_count: s.viewer_count,
|
||||
viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
|
||||
is_streaming: s.is_streaming,
|
||||
is_online: s.is_online,
|
||||
is_persistent: s.is_persistent,
|
||||
last_heartbeat: s.last_heartbeat.to_rfc3339(),
|
||||
os_version: s.os_version,
|
||||
is_elevated: s.is_elevated,
|
||||
uptime_secs: s.uptime_secs,
|
||||
display_count: s.display_count,
|
||||
agent_version: s.agent_version,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,3 +96,120 @@ pub async fn get_session(
|
||||
|
||||
Ok(Json(SessionInfo::from(session)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Machine API Types
|
||||
// ============================================================================
|
||||
|
||||
/// Machine info returned by API
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MachineInfo {
|
||||
pub id: String,
|
||||
pub agent_id: String,
|
||||
pub hostname: String,
|
||||
pub os_version: Option<String>,
|
||||
pub is_elevated: bool,
|
||||
pub is_persistent: bool,
|
||||
pub first_seen: String,
|
||||
pub last_seen: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl From<db::machines::Machine> for MachineInfo {
|
||||
fn from(m: db::machines::Machine) -> Self {
|
||||
Self {
|
||||
id: m.id.to_string(),
|
||||
agent_id: m.agent_id,
|
||||
hostname: m.hostname,
|
||||
os_version: m.os_version,
|
||||
is_elevated: m.is_elevated,
|
||||
is_persistent: m.is_persistent,
|
||||
first_seen: m.first_seen.to_rfc3339(),
|
||||
last_seen: m.last_seen.to_rfc3339(),
|
||||
status: m.status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session record for history
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SessionRecord {
|
||||
pub id: String,
|
||||
pub started_at: String,
|
||||
pub ended_at: Option<String>,
|
||||
pub duration_secs: Option<i32>,
|
||||
pub is_support_session: bool,
|
||||
pub support_code: Option<String>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl From<db::sessions::DbSession> for SessionRecord {
|
||||
fn from(s: db::sessions::DbSession) -> Self {
|
||||
Self {
|
||||
id: s.id.to_string(),
|
||||
started_at: s.started_at.to_rfc3339(),
|
||||
ended_at: s.ended_at.map(|t| t.to_rfc3339()),
|
||||
duration_secs: s.duration_secs,
|
||||
is_support_session: s.is_support_session,
|
||||
support_code: s.support_code,
|
||||
status: s.status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Event record for history
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EventRecord {
|
||||
pub id: i64,
|
||||
pub session_id: String,
|
||||
pub event_type: String,
|
||||
pub timestamp: String,
|
||||
pub viewer_id: Option<String>,
|
||||
pub viewer_name: Option<String>,
|
||||
pub details: Option<serde_json::Value>,
|
||||
pub ip_address: Option<String>,
|
||||
}
|
||||
|
||||
impl From<db::events::SessionEvent> for EventRecord {
|
||||
fn from(e: db::events::SessionEvent) -> Self {
|
||||
Self {
|
||||
id: e.id,
|
||||
session_id: e.session_id.to_string(),
|
||||
event_type: e.event_type,
|
||||
timestamp: e.timestamp.to_rfc3339(),
|
||||
viewer_id: e.viewer_id,
|
||||
viewer_name: e.viewer_name,
|
||||
details: e.details,
|
||||
ip_address: e.ip_address,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full machine history (for export)
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MachineHistory {
|
||||
pub machine: MachineInfo,
|
||||
pub sessions: Vec<SessionRecord>,
|
||||
pub events: Vec<EventRecord>,
|
||||
pub exported_at: String,
|
||||
}
|
||||
|
||||
/// Query parameters for machine deletion
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeleteMachineParams {
|
||||
/// If true, send uninstall command to agent (if online)
|
||||
#[serde(default)]
|
||||
pub uninstall: bool,
|
||||
/// If true, include history in response before deletion
|
||||
#[serde(default)]
|
||||
pub export: bool,
|
||||
}
|
||||
|
||||
/// Response for machine deletion
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeleteMachineResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub uninstall_sent: bool,
|
||||
pub history: Option<MachineHistory>,
|
||||
}
|
||||
|
||||
375
server/src/api/releases.rs
Normal file
375
server/src/api/releases.rs
Normal file
@@ -0,0 +1,375 @@
|
||||
//! Release management API endpoints (admin only)
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::AdminUser;
|
||||
use crate::db;
|
||||
use crate::AppState;
|
||||
|
||||
use super::auth::ErrorResponse;
|
||||
|
||||
/// Release info response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReleaseInfo {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub download_url: String,
|
||||
pub checksum_sha256: String,
|
||||
pub release_notes: Option<String>,
|
||||
pub is_stable: bool,
|
||||
pub is_mandatory: bool,
|
||||
pub min_version: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl From<db::Release> for ReleaseInfo {
|
||||
fn from(r: db::Release) -> Self {
|
||||
Self {
|
||||
id: r.id.to_string(),
|
||||
version: r.version,
|
||||
download_url: r.download_url,
|
||||
checksum_sha256: r.checksum_sha256,
|
||||
release_notes: r.release_notes,
|
||||
is_stable: r.is_stable,
|
||||
is_mandatory: r.is_mandatory,
|
||||
min_version: r.min_version,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Version info for unauthenticated endpoint
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct VersionInfo {
|
||||
pub latest_version: String,
|
||||
pub download_url: String,
|
||||
pub checksum_sha256: String,
|
||||
pub is_mandatory: bool,
|
||||
pub release_notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Create release request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateReleaseRequest {
|
||||
pub version: String,
|
||||
pub download_url: String,
|
||||
pub checksum_sha256: String,
|
||||
pub release_notes: Option<String>,
|
||||
pub is_stable: bool,
|
||||
pub is_mandatory: bool,
|
||||
pub min_version: Option<String>,
|
||||
}
|
||||
|
||||
/// Update release request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateReleaseRequest {
|
||||
pub release_notes: Option<String>,
|
||||
pub is_stable: bool,
|
||||
pub is_mandatory: bool,
|
||||
}
|
||||
|
||||
/// GET /api/version - Get latest version info (no auth required)
|
||||
pub async fn get_version(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<VersionInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let release = db::get_latest_stable_release(db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to fetch version".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "No stable release available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(VersionInfo {
|
||||
latest_version: release.version,
|
||||
download_url: release.download_url,
|
||||
checksum_sha256: release.checksum_sha256,
|
||||
is_mandatory: release.is_mandatory,
|
||||
release_notes: release.release_notes,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/releases - List all releases (admin only)
|
||||
pub async fn list_releases(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
) -> Result<Json<Vec<ReleaseInfo>>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let releases = db::get_all_releases(db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to fetch releases".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(releases.into_iter().map(ReleaseInfo::from).collect()))
|
||||
}
|
||||
|
||||
/// POST /api/releases - Create new release (admin only)
|
||||
pub async fn create_release(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Json(request): Json<CreateReleaseRequest>,
|
||||
) -> Result<Json<ReleaseInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Validate version format (basic check)
|
||||
if request.version.is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Version cannot be empty".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate checksum format (64 hex chars for SHA-256)
|
||||
if request.checksum_sha256.len() != 64
|
||||
|| !request.checksum_sha256.chars().all(|c| c.is_ascii_hexdigit())
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid SHA-256 checksum format (expected 64 hex characters)".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
if !request.download_url.starts_with("https://") {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Download URL must use HTTPS".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if version already exists
|
||||
if db::get_release_by_version(db.pool(), &request.version)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.is_some()
|
||||
{
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(ErrorResponse {
|
||||
error: "Version already exists".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let release = db::create_release(
|
||||
db.pool(),
|
||||
&request.version,
|
||||
&request.download_url,
|
||||
&request.checksum_sha256,
|
||||
request.release_notes.as_deref(),
|
||||
request.is_stable,
|
||||
request.is_mandatory,
|
||||
request.min_version.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to create release: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to create release".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"Created release: {} (stable={}, mandatory={})",
|
||||
release.version,
|
||||
release.is_stable,
|
||||
release.is_mandatory
|
||||
);
|
||||
|
||||
Ok(Json(ReleaseInfo::from(release)))
|
||||
}
|
||||
|
||||
/// GET /api/releases/:version - Get release by version (admin only)
|
||||
pub async fn get_release(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Path(version): Path<String>,
|
||||
) -> Result<Json<ReleaseInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let release = db::get_release_by_version(db.pool(), &version)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "Release not found".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(ReleaseInfo::from(release)))
|
||||
}
|
||||
|
||||
/// PUT /api/releases/:version - Update release (admin only)
|
||||
pub async fn update_release(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Path(version): Path<String>,
|
||||
Json(request): Json<UpdateReleaseRequest>,
|
||||
) -> Result<Json<ReleaseInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let release = db::update_release(
|
||||
db.pool(),
|
||||
&version,
|
||||
request.release_notes.as_deref(),
|
||||
request.is_stable,
|
||||
request.is_mandatory,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to update release".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "Release not found".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"Updated release: {} (stable={}, mandatory={})",
|
||||
release.version,
|
||||
release.is_stable,
|
||||
release.is_mandatory
|
||||
);
|
||||
|
||||
Ok(Json(ReleaseInfo::from(release)))
|
||||
}
|
||||
|
||||
/// DELETE /api/releases/:version - Delete release (admin only)
|
||||
pub async fn delete_release(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Path(version): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let deleted = db::delete_release(db.pool(), &version)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to delete release".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if deleted {
|
||||
tracing::info!("Deleted release: {}", version);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "Release not found".to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
592
server/src/api/users.rs
Normal file
592
server/src/api/users.rs
Normal file
@@ -0,0 +1,592 @@
|
||||
//! User management API endpoints (admin only)
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{hash_password, AdminUser};
|
||||
use crate::db;
|
||||
use crate::AppState;
|
||||
|
||||
use super::auth::ErrorResponse;
|
||||
|
||||
/// User info response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserInfo {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub role: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_login: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Create user request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub email: Option<String>,
|
||||
pub role: String,
|
||||
pub permissions: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Update user request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateUserRequest {
|
||||
pub email: Option<String>,
|
||||
pub role: String,
|
||||
pub enabled: bool,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
/// Set permissions request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetPermissionsRequest {
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Set client access request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetClientAccessRequest {
|
||||
pub client_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// GET /api/users - List all users
|
||||
pub async fn list_users(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
) -> Result<Json<Vec<UserInfo>>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let users = db::get_all_users(db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to fetch users".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for user in users {
|
||||
let permissions = db::get_user_permissions(db.pool(), user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
result.push(UserInfo {
|
||||
id: user.id.to_string(),
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
enabled: user.enabled,
|
||||
created_at: user.created_at.to_rfc3339(),
|
||||
last_login: user.last_login.map(|t| t.to_rfc3339()),
|
||||
permissions,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /api/users - Create new user
|
||||
pub async fn create_user(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Json(request): Json<CreateUserRequest>,
|
||||
) -> Result<Json<UserInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Validate role
|
||||
let valid_roles = ["admin", "operator", "viewer"];
|
||||
if !valid_roles.contains(&request.role.as_str()) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Invalid role. Must be one of: {:?}", valid_roles),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate password
|
||||
if request.password.len() < 8 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Password must be at least 8 characters".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if username exists
|
||||
if db::get_user_by_username(db.pool(), &request.username)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.is_some()
|
||||
{
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(ErrorResponse {
|
||||
error: "Username already exists".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Hash password
|
||||
let password_hash = hash_password(&request.password).map_err(|e| {
|
||||
tracing::error!("Password hashing error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to hash password".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Create user
|
||||
let user = db::create_user(
|
||||
db.pool(),
|
||||
&request.username,
|
||||
&password_hash,
|
||||
request.email.as_deref(),
|
||||
&request.role,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to create user: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to create user".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Set initial permissions if provided
|
||||
let permissions = if let Some(perms) = request.permissions {
|
||||
db::set_user_permissions(db.pool(), user.id, &perms)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to set permissions: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to set permissions".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
perms
|
||||
} else {
|
||||
// Default permissions based on role
|
||||
let default_perms = match request.role.as_str() {
|
||||
"admin" => vec!["view", "control", "transfer", "manage_users", "manage_clients"],
|
||||
"operator" => vec!["view", "control", "transfer"],
|
||||
"viewer" => vec!["view"],
|
||||
_ => vec!["view"],
|
||||
};
|
||||
let perms: Vec<String> = default_perms.into_iter().map(String::from).collect();
|
||||
db::set_user_permissions(db.pool(), user.id, &perms)
|
||||
.await
|
||||
.ok();
|
||||
perms
|
||||
};
|
||||
|
||||
tracing::info!("Created user: {} ({})", user.username, user.role);
|
||||
|
||||
Ok(Json(UserInfo {
|
||||
id: user.id.to_string(),
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
enabled: user.enabled,
|
||||
created_at: user.created_at.to_rfc3339(),
|
||||
last_login: None,
|
||||
permissions,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/users/:id - Get user details
|
||||
pub async fn get_user(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<UserInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = Uuid::parse_str(&id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid user ID".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user = db::get_user_by_id(db.pool(), user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let permissions = db::get_user_permissions(db.pool(), user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(UserInfo {
|
||||
id: user.id.to_string(),
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
enabled: user.enabled,
|
||||
created_at: user.created_at.to_rfc3339(),
|
||||
last_login: user.last_login.map(|t| t.to_rfc3339()),
|
||||
permissions,
|
||||
}))
|
||||
}
|
||||
|
||||
/// PUT /api/users/:id - Update user
|
||||
pub async fn update_user(
|
||||
State(state): State<AppState>,
|
||||
admin: AdminUser,
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<UpdateUserRequest>,
|
||||
) -> Result<Json<UserInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = Uuid::parse_str(&id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid user ID".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Prevent admin from disabling themselves
|
||||
if user_id.to_string() == admin.0.user_id && !request.enabled {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Cannot disable your own account".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate role
|
||||
let valid_roles = ["admin", "operator", "viewer"];
|
||||
if !valid_roles.contains(&request.role.as_str()) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Invalid role. Must be one of: {:?}", valid_roles),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Update user
|
||||
let user = db::update_user(
|
||||
db.pool(),
|
||||
user_id,
|
||||
request.email.as_deref(),
|
||||
&request.role,
|
||||
request.enabled,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to update user".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Update password if provided
|
||||
if let Some(password) = request.password {
|
||||
if password.len() < 8 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Password must be at least 8 characters".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&password).map_err(|e| {
|
||||
tracing::error!("Password hashing error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to hash password".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
db::update_user_password(db.pool(), user_id, &password_hash)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to update password".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let permissions = db::get_user_permissions(db.pool(), user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
tracing::info!("Updated user: {}", user.username);
|
||||
|
||||
Ok(Json(UserInfo {
|
||||
id: user.id.to_string(),
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
enabled: user.enabled,
|
||||
created_at: user.created_at.to_rfc3339(),
|
||||
last_login: user.last_login.map(|t| t.to_rfc3339()),
|
||||
permissions,
|
||||
}))
|
||||
}
|
||||
|
||||
/// DELETE /api/users/:id - Delete user
|
||||
pub async fn delete_user(
|
||||
State(state): State<AppState>,
|
||||
admin: AdminUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = Uuid::parse_str(&id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid user ID".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Prevent admin from deleting themselves
|
||||
if user_id.to_string() == admin.0.user_id {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Cannot delete your own account".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let deleted = db::delete_user(db.pool(), user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to delete user".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if deleted {
|
||||
tracing::info!("Deleted user: {}", id);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /api/users/:id/permissions - Set user permissions
|
||||
pub async fn set_permissions(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<SetPermissionsRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = Uuid::parse_str(&id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid user ID".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Validate permissions
|
||||
let valid_permissions = ["view", "control", "transfer", "manage_users", "manage_clients"];
|
||||
for perm in &request.permissions {
|
||||
if !valid_permissions.contains(&perm.as_str()) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Invalid permission: {}. Valid: {:?}", perm, valid_permissions),
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
db::set_user_permissions(db.pool(), user_id, &request.permissions)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to set permissions".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!("Updated permissions for user: {}", id);
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// PUT /api/users/:id/clients - Set user client access
|
||||
pub async fn set_client_access(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<SetClientAccessRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = Uuid::parse_str(&id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid user ID".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Parse client IDs
|
||||
let client_ids: Result<Vec<Uuid>, _> = request
|
||||
.client_ids
|
||||
.iter()
|
||||
.map(|s| Uuid::parse_str(s))
|
||||
.collect();
|
||||
|
||||
let client_ids = client_ids.map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid client ID format".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
db::set_user_client_access(db.pool(), user_id, &client_ids)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to set client access".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!("Updated client access for user: {}", id);
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
140
server/src/auth/jwt.rs
Normal file
140
server/src/auth/jwt.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! JWT token handling
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// JWT claims
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Claims {
|
||||
/// Subject (user ID)
|
||||
pub sub: String,
|
||||
/// Username
|
||||
pub username: String,
|
||||
/// Role (admin, operator, viewer)
|
||||
pub role: String,
|
||||
/// Permissions list
|
||||
pub permissions: Vec<String>,
|
||||
/// Expiration time (unix timestamp)
|
||||
pub exp: i64,
|
||||
/// Issued at (unix timestamp)
|
||||
pub iat: i64,
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
/// Check if user has a specific permission
|
||||
pub fn has_permission(&self, permission: &str) -> bool {
|
||||
// Admins have all permissions
|
||||
if self.role == "admin" {
|
||||
return true;
|
||||
}
|
||||
self.permissions.contains(&permission.to_string())
|
||||
}
|
||||
|
||||
/// Check if user is admin
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.role == "admin"
|
||||
}
|
||||
|
||||
/// Get user ID as UUID
|
||||
pub fn user_id(&self) -> Result<Uuid> {
|
||||
Uuid::parse_str(&self.sub).map_err(|e| anyhow!("Invalid user ID in token: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/// JWT configuration
|
||||
#[derive(Clone)]
|
||||
pub struct JwtConfig {
|
||||
secret: String,
|
||||
expiry_hours: i64,
|
||||
}
|
||||
|
||||
impl JwtConfig {
|
||||
/// Create new JWT config
|
||||
pub fn new(secret: String, expiry_hours: i64) -> Self {
|
||||
Self { secret, expiry_hours }
|
||||
}
|
||||
|
||||
/// Create a JWT token for a user
|
||||
pub fn create_token(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
username: &str,
|
||||
role: &str,
|
||||
permissions: Vec<String>,
|
||||
) -> Result<String> {
|
||||
let now = Utc::now();
|
||||
let exp = now + Duration::hours(self.expiry_hours);
|
||||
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
username: username.to_string(),
|
||||
role: role.to_string(),
|
||||
permissions,
|
||||
exp: exp.timestamp(),
|
||||
iat: now.timestamp(),
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(self.secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| anyhow!("Failed to create token: {}", e))?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Validate and decode a JWT token
|
||||
pub fn validate_token(&self, token: &str) -> Result<Claims> {
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|e| anyhow!("Invalid token: {}", e))?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
}
|
||||
|
||||
/// Default JWT secret if not configured (NOT for production!)
|
||||
pub fn default_jwt_secret() -> String {
|
||||
// In production, this should come from environment variable
|
||||
std::env::var("JWT_SECRET").unwrap_or_else(|_| {
|
||||
tracing::warn!("JWT_SECRET not set, using default (INSECURE!)");
|
||||
"guruconnect-dev-secret-change-me-in-production".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_and_validate_token() {
|
||||
let config = JwtConfig::new("test-secret".to_string(), 24);
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let token = config.create_token(
|
||||
user_id,
|
||||
"testuser",
|
||||
"admin",
|
||||
vec!["view".to_string(), "control".to_string()],
|
||||
).unwrap();
|
||||
|
||||
let claims = config.validate_token(&token).unwrap();
|
||||
assert_eq!(claims.username, "testuser");
|
||||
assert_eq!(claims.role, "admin");
|
||||
assert!(claims.has_permission("view"));
|
||||
assert!(claims.has_permission("manage_users")); // admin has all
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_token() {
|
||||
let config = JwtConfig::new("test-secret".to_string(), 24);
|
||||
assert!(config.validate_token("invalid.token.here").is_err());
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,51 @@
|
||||
//! Handles JWT validation for dashboard users and API key
|
||||
//! validation for agents.
|
||||
|
||||
pub mod jwt;
|
||||
pub mod password;
|
||||
|
||||
pub use jwt::{Claims, JwtConfig};
|
||||
pub use password::{hash_password, verify_password, generate_random_password};
|
||||
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Authenticated user from JWT
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub user_id: String,
|
||||
pub email: String,
|
||||
pub roles: Vec<String>,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
impl AuthenticatedUser {
|
||||
/// Check if user has a specific permission
|
||||
pub fn has_permission(&self, permission: &str) -> bool {
|
||||
if self.role == "admin" {
|
||||
return true;
|
||||
}
|
||||
self.permissions.contains(&permission.to_string())
|
||||
}
|
||||
|
||||
/// Check if user is admin
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.role == "admin"
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Claims> for AuthenticatedUser {
|
||||
fn from(claims: Claims) -> Self {
|
||||
Self {
|
||||
user_id: claims.sub,
|
||||
username: claims.username,
|
||||
role: claims.role,
|
||||
permissions: claims.permissions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticated agent from API key
|
||||
@@ -23,7 +57,21 @@ pub struct AuthenticatedAgent {
|
||||
pub org_id: String,
|
||||
}
|
||||
|
||||
/// Extract authenticated user from request (placeholder for MVP)
|
||||
/// JWT configuration stored in app state
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub jwt_config: Arc<JwtConfig>,
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
pub fn new(jwt_secret: String, expiry_hours: i64) -> Self {
|
||||
Self {
|
||||
jwt_config: Arc::new(JwtConfig::new(jwt_secret, expiry_hours)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract authenticated user from request
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||
where
|
||||
@@ -32,28 +80,77 @@ where
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
// TODO: Implement JWT validation
|
||||
// For MVP, accept any request
|
||||
|
||||
// Look for Authorization header
|
||||
let _auth_header = parts
|
||||
// Get Authorization header
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
|
||||
|
||||
// Placeholder - in production, validate JWT
|
||||
Ok(AuthenticatedUser {
|
||||
user_id: "mvp-user".to_string(),
|
||||
email: "mvp@example.com".to_string(),
|
||||
roles: vec!["admin".to_string()],
|
||||
})
|
||||
// Extract Bearer token
|
||||
let token = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Invalid Authorization format"))?;
|
||||
|
||||
// Get JWT config from extensions (set by middleware)
|
||||
let jwt_config = parts
|
||||
.extensions
|
||||
.get::<Arc<JwtConfig>>()
|
||||
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Auth not configured"))?;
|
||||
|
||||
// Validate token
|
||||
let claims = jwt_config
|
||||
.validate_token(token)
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid or expired token"))?;
|
||||
|
||||
Ok(AuthenticatedUser::from(claims))
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional authenticated user (doesn't reject if not authenticated)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OptionalUser(pub Option<AuthenticatedUser>);
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for OptionalUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match AuthenticatedUser::from_request_parts(parts, state).await {
|
||||
Ok(user) => Ok(OptionalUser(Some(user))),
|
||||
Err(_) => Ok(OptionalUser(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Require admin role
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdminUser(pub AuthenticatedUser);
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for AdminUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let user = AuthenticatedUser::from_request_parts(parts, state).await?;
|
||||
if user.is_admin() {
|
||||
Ok(AdminUser(user))
|
||||
} else {
|
||||
Err((StatusCode::FORBIDDEN, "Admin access required"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an agent API key (placeholder for MVP)
|
||||
pub fn validate_agent_key(_api_key: &str) -> Option<AuthenticatedAgent> {
|
||||
// TODO: Implement actual API key validation
|
||||
// For MVP, accept any key
|
||||
// TODO: Implement actual API key validation against database
|
||||
// For now, accept any key for agent connections
|
||||
Some(AuthenticatedAgent {
|
||||
agent_id: "mvp-agent".to_string(),
|
||||
org_id: "mvp-org".to_string(),
|
||||
|
||||
57
server/src/auth/password.rs
Normal file
57
server/src/auth/password.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
//! Password hashing using Argon2id
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
/// Hash a password using Argon2id
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow!("Failed to hash password: {}", e))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify a password against a stored hash
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||
let parsed_hash = PasswordHash::new(hash)
|
||||
.map_err(|e| anyhow!("Invalid password hash format: {}", e))?;
|
||||
let argon2 = Argon2::default();
|
||||
Ok(argon2.verify_password(password.as_bytes(), &parsed_hash).is_ok())
|
||||
}
|
||||
|
||||
/// Generate a random password (for initial admin)
|
||||
pub fn generate_random_password(length: usize) -> String {
|
||||
use rand::Rng;
|
||||
const CHARSET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%";
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..length)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..CHARSET.len());
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify() {
|
||||
let password = "test_password_123";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(verify_password(password, &hash).unwrap());
|
||||
assert!(!verify_password("wrong_password", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_random_password() {
|
||||
let password = generate_random_password(16);
|
||||
assert_eq!(password.len(), 16);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,12 @@ pub struct Config {
|
||||
/// Address to listen on (e.g., "0.0.0.0:8080")
|
||||
pub listen_addr: String,
|
||||
|
||||
/// Database URL (optional for MVP)
|
||||
/// Database URL (optional - server works without it)
|
||||
pub database_url: Option<String>,
|
||||
|
||||
/// Maximum database connections in pool
|
||||
pub database_max_connections: u32,
|
||||
|
||||
/// JWT secret for authentication
|
||||
pub jwt_secret: Option<String>,
|
||||
|
||||
@@ -25,6 +28,10 @@ impl Config {
|
||||
Ok(Self {
|
||||
listen_addr: env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()),
|
||||
database_url: env::var("DATABASE_URL").ok(),
|
||||
database_max_connections: env::var("DATABASE_MAX_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5),
|
||||
jwt_secret: env::var("JWT_SECRET").ok(),
|
||||
debug: env::var("DEBUG")
|
||||
.map(|v| v == "1" || v.to_lowercase() == "true")
|
||||
@@ -38,6 +45,7 @@ impl Default for Config {
|
||||
Self {
|
||||
listen_addr: "0.0.0.0:8080".to_string(),
|
||||
database_url: None,
|
||||
database_max_connections: 5,
|
||||
jwt_secret: None,
|
||||
debug: false,
|
||||
}
|
||||
|
||||
126
server/src/db/events.rs
Normal file
126
server/src/db/events.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
//! Audit event logging
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use sqlx::PgPool;
|
||||
use std::net::IpAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Session event record from database
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct SessionEvent {
|
||||
pub id: i64,
|
||||
pub session_id: Uuid,
|
||||
pub event_type: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub viewer_id: Option<String>,
|
||||
pub viewer_name: Option<String>,
|
||||
pub details: Option<JsonValue>,
|
||||
pub ip_address: Option<String>,
|
||||
}
|
||||
|
||||
/// Event types for session audit logging
|
||||
pub struct EventTypes;
|
||||
|
||||
impl EventTypes {
|
||||
pub const SESSION_STARTED: &'static str = "session_started";
|
||||
pub const SESSION_ENDED: &'static str = "session_ended";
|
||||
pub const SESSION_TIMEOUT: &'static str = "session_timeout";
|
||||
pub const VIEWER_JOINED: &'static str = "viewer_joined";
|
||||
pub const VIEWER_LEFT: &'static str = "viewer_left";
|
||||
pub const STREAMING_STARTED: &'static str = "streaming_started";
|
||||
pub const STREAMING_STOPPED: &'static str = "streaming_stopped";
|
||||
}
|
||||
|
||||
/// Log a session event
|
||||
pub async fn log_event(
|
||||
pool: &PgPool,
|
||||
session_id: Uuid,
|
||||
event_type: &str,
|
||||
viewer_id: Option<&str>,
|
||||
viewer_name: Option<&str>,
|
||||
details: Option<JsonValue>,
|
||||
ip_address: Option<IpAddr>,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let ip_str = ip_address.map(|ip| ip.to_string());
|
||||
|
||||
let result = sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
INSERT INTO connect_session_events
|
||||
(session_id, event_type, viewer_id, viewer_name, details, ip_address)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::inet)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(event_type)
|
||||
.bind(viewer_id)
|
||||
.bind(viewer_name)
|
||||
.bind(details)
|
||||
.bind(ip_str)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get events for a session
|
||||
pub async fn get_session_events(
|
||||
pool: &PgPool,
|
||||
session_id: Uuid,
|
||||
) -> Result<Vec<SessionEvent>, sqlx::Error> {
|
||||
sqlx::query_as::<_, SessionEvent>(
|
||||
"SELECT id, session_id, event_type, timestamp, viewer_id, viewer_name, details, ip_address::text as ip_address FROM connect_session_events WHERE session_id = $1 ORDER BY timestamp"
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get recent events (for dashboard)
|
||||
pub async fn get_recent_events(
|
||||
pool: &PgPool,
|
||||
limit: i64,
|
||||
) -> Result<Vec<SessionEvent>, sqlx::Error> {
|
||||
sqlx::query_as::<_, SessionEvent>(
|
||||
"SELECT id, session_id, event_type, timestamp, viewer_id, viewer_name, details, ip_address::text as ip_address FROM connect_session_events ORDER BY timestamp DESC LIMIT $1"
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get events by type
|
||||
pub async fn get_events_by_type(
|
||||
pool: &PgPool,
|
||||
event_type: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<SessionEvent>, sqlx::Error> {
|
||||
sqlx::query_as::<_, SessionEvent>(
|
||||
"SELECT id, session_id, event_type, timestamp, viewer_id, viewer_name, details, ip_address::text as ip_address FROM connect_session_events WHERE event_type = $1 ORDER BY timestamp DESC LIMIT $2"
|
||||
)
|
||||
.bind(event_type)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get all events for a machine (by joining through sessions)
|
||||
pub async fn get_events_for_machine(
|
||||
pool: &PgPool,
|
||||
machine_id: Uuid,
|
||||
) -> Result<Vec<SessionEvent>, sqlx::Error> {
|
||||
sqlx::query_as::<_, SessionEvent>(
|
||||
r#"
|
||||
SELECT e.id, e.session_id, e.event_type, e.timestamp, e.viewer_id, e.viewer_name, e.details, e.ip_address::text as ip_address
|
||||
FROM connect_session_events e
|
||||
JOIN connect_sessions s ON e.session_id = s.id
|
||||
WHERE s.machine_id = $1
|
||||
ORDER BY e.timestamp DESC
|
||||
"#
|
||||
)
|
||||
.bind(machine_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
149
server/src/db/machines.rs
Normal file
149
server/src/db/machines.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
//! Machine/Agent database operations
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Machine record from database
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Machine {
|
||||
pub id: Uuid,
|
||||
pub agent_id: String,
|
||||
pub hostname: String,
|
||||
pub os_version: Option<String>,
|
||||
pub is_elevated: bool,
|
||||
pub is_persistent: bool,
|
||||
pub first_seen: DateTime<Utc>,
|
||||
pub last_seen: DateTime<Utc>,
|
||||
pub last_session_id: Option<Uuid>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Get or create a machine by agent_id (upsert)
|
||||
pub async fn upsert_machine(
|
||||
pool: &PgPool,
|
||||
agent_id: &str,
|
||||
hostname: &str,
|
||||
is_persistent: bool,
|
||||
) -> Result<Machine, sqlx::Error> {
|
||||
sqlx::query_as::<_, Machine>(
|
||||
r#"
|
||||
INSERT INTO connect_machines (agent_id, hostname, is_persistent, status, last_seen)
|
||||
VALUES ($1, $2, $3, 'online', NOW())
|
||||
ON CONFLICT (agent_id) DO UPDATE SET
|
||||
hostname = EXCLUDED.hostname,
|
||||
status = 'online',
|
||||
last_seen = NOW()
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(hostname)
|
||||
.bind(is_persistent)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update machine status and info
|
||||
pub async fn update_machine_status(
|
||||
pool: &PgPool,
|
||||
agent_id: &str,
|
||||
status: &str,
|
||||
os_version: Option<&str>,
|
||||
is_elevated: bool,
|
||||
session_id: Option<Uuid>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE connect_machines SET
|
||||
status = $1,
|
||||
os_version = COALESCE($2, os_version),
|
||||
is_elevated = $3,
|
||||
last_seen = NOW(),
|
||||
last_session_id = COALESCE($4, last_session_id)
|
||||
WHERE agent_id = $5
|
||||
"#,
|
||||
)
|
||||
.bind(status)
|
||||
.bind(os_version)
|
||||
.bind(is_elevated)
|
||||
.bind(session_id)
|
||||
.bind(agent_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all persistent machines (for restore on startup)
|
||||
pub async fn get_all_machines(pool: &PgPool) -> Result<Vec<Machine>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Machine>(
|
||||
"SELECT * FROM connect_machines WHERE is_persistent = true ORDER BY hostname"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get machine by agent_id
|
||||
pub async fn get_machine_by_agent_id(
|
||||
pool: &PgPool,
|
||||
agent_id: &str,
|
||||
) -> Result<Option<Machine>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Machine>(
|
||||
"SELECT * FROM connect_machines WHERE agent_id = $1"
|
||||
)
|
||||
.bind(agent_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Mark machine as offline
|
||||
pub async fn mark_machine_offline(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE connect_machines SET status = 'offline', last_seen = NOW() WHERE agent_id = $1")
|
||||
.bind(agent_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a machine record
|
||||
pub async fn delete_machine(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("DELETE FROM connect_machines WHERE agent_id = $1")
|
||||
.bind(agent_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update machine organization, site, and tags
|
||||
pub async fn update_machine_metadata(
|
||||
pool: &PgPool,
|
||||
agent_id: &str,
|
||||
organization: Option<&str>,
|
||||
site: Option<&str>,
|
||||
tags: &[String],
|
||||
) -> Result<(), sqlx::Error> {
|
||||
// Only update if at least one value is provided
|
||||
if organization.is_none() && site.is_none() && tags.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE connect_machines SET
|
||||
organization = COALESCE($1, organization),
|
||||
site = COALESCE($2, site),
|
||||
tags = CASE WHEN $3::text[] = '{}' THEN tags ELSE $3 END
|
||||
WHERE agent_id = $4
|
||||
"#,
|
||||
)
|
||||
.bind(organization)
|
||||
.bind(site)
|
||||
.bind(tags)
|
||||
.bind(agent_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,45 +1,56 @@
|
||||
//! Database module
|
||||
//! Database module for GuruConnect
|
||||
//!
|
||||
//! Handles session logging and persistence.
|
||||
//! Optional for MVP - sessions are kept in memory only.
|
||||
//! Handles persistence for machines, sessions, and audit logging.
|
||||
//! Optional - server works without database if DATABASE_URL not set.
|
||||
|
||||
pub mod machines;
|
||||
pub mod sessions;
|
||||
pub mod events;
|
||||
pub mod support_codes;
|
||||
pub mod users;
|
||||
pub mod releases;
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use tracing::info;
|
||||
|
||||
/// Database connection pool (placeholder)
|
||||
pub use machines::*;
|
||||
pub use sessions::*;
|
||||
pub use events::*;
|
||||
pub use support_codes::*;
|
||||
pub use users::*;
|
||||
pub use releases::*;
|
||||
|
||||
/// Database connection pool wrapper
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
// TODO: Add sqlx pool when PostgreSQL is needed
|
||||
_placeholder: (),
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Initialize database connection
|
||||
pub async fn init(_database_url: &str) -> Result<Self> {
|
||||
// TODO: Initialize PostgreSQL connection pool
|
||||
Ok(Self { _placeholder: () })
|
||||
/// Initialize database connection pool
|
||||
pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self> {
|
||||
info!("Connecting to database...");
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
|
||||
info!("Database connection established");
|
||||
Ok(Self { pool })
|
||||
}
|
||||
}
|
||||
|
||||
/// Session event for audit logging
|
||||
#[derive(Debug)]
|
||||
pub struct SessionEvent {
|
||||
pub session_id: String,
|
||||
pub event_type: SessionEventType,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SessionEventType {
|
||||
Started,
|
||||
ViewerJoined,
|
||||
ViewerLeft,
|
||||
Ended,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Log a session event (placeholder)
|
||||
pub async fn log_session_event(&self, _event: SessionEvent) -> Result<()> {
|
||||
// TODO: Insert into connect_session_events table
|
||||
/// Run database migrations
|
||||
pub async fn migrate(&self) -> Result<()> {
|
||||
info!("Running database migrations...");
|
||||
sqlx::migrate!("./migrations").run(&self.pool).await?;
|
||||
info!("Migrations complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get reference to the connection pool
|
||||
pub fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
}
|
||||
|
||||
179
server/src/db/releases.rs
Normal file
179
server/src/db/releases.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! Release management database operations
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Release record from database
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Release {
|
||||
pub id: Uuid,
|
||||
pub version: String,
|
||||
pub download_url: String,
|
||||
pub checksum_sha256: String,
|
||||
pub release_notes: Option<String>,
|
||||
pub is_stable: bool,
|
||||
pub is_mandatory: bool,
|
||||
pub min_version: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Create a new release
|
||||
pub async fn create_release(
|
||||
pool: &PgPool,
|
||||
version: &str,
|
||||
download_url: &str,
|
||||
checksum_sha256: &str,
|
||||
release_notes: Option<&str>,
|
||||
is_stable: bool,
|
||||
is_mandatory: bool,
|
||||
min_version: Option<&str>,
|
||||
) -> Result<Release, sqlx::Error> {
|
||||
sqlx::query_as::<_, Release>(
|
||||
r#"
|
||||
INSERT INTO releases (version, download_url, checksum_sha256, release_notes, is_stable, is_mandatory, min_version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(version)
|
||||
.bind(download_url)
|
||||
.bind(checksum_sha256)
|
||||
.bind(release_notes)
|
||||
.bind(is_stable)
|
||||
.bind(is_mandatory)
|
||||
.bind(min_version)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get the latest stable release
|
||||
pub async fn get_latest_stable_release(pool: &PgPool) -> Result<Option<Release>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Release>(
|
||||
r#"
|
||||
SELECT * FROM releases
|
||||
WHERE is_stable = true
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a release by version
|
||||
pub async fn get_release_by_version(
|
||||
pool: &PgPool,
|
||||
version: &str,
|
||||
) -> Result<Option<Release>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Release>("SELECT * FROM releases WHERE version = $1")
|
||||
.bind(version)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get all releases (ordered by creation date, newest first)
|
||||
pub async fn get_all_releases(pool: &PgPool) -> Result<Vec<Release>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Release>("SELECT * FROM releases ORDER BY created_at DESC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update a release
|
||||
pub async fn update_release(
|
||||
pool: &PgPool,
|
||||
version: &str,
|
||||
release_notes: Option<&str>,
|
||||
is_stable: bool,
|
||||
is_mandatory: bool,
|
||||
) -> Result<Option<Release>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Release>(
|
||||
r#"
|
||||
UPDATE releases SET
|
||||
release_notes = COALESCE($2, release_notes),
|
||||
is_stable = $3,
|
||||
is_mandatory = $4
|
||||
WHERE version = $1
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(version)
|
||||
.bind(release_notes)
|
||||
.bind(is_stable)
|
||||
.bind(is_mandatory)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a release
|
||||
pub async fn delete_release(pool: &PgPool, version: &str) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query("DELETE FROM releases WHERE version = $1")
|
||||
.bind(version)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Update machine version info
|
||||
pub async fn update_machine_version(
|
||||
pool: &PgPool,
|
||||
agent_id: &str,
|
||||
agent_version: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE connect_machines SET
|
||||
agent_version = $1,
|
||||
last_update_check = NOW()
|
||||
WHERE agent_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(agent_version)
|
||||
.bind(agent_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update machine update status
|
||||
pub async fn update_machine_update_status(
|
||||
pool: &PgPool,
|
||||
agent_id: &str,
|
||||
update_status: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE connect_machines SET
|
||||
update_status = $1
|
||||
WHERE agent_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(update_status)
|
||||
.bind(agent_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get machines that need updates (version < latest stable)
|
||||
pub async fn get_machines_needing_update(
|
||||
pool: &PgPool,
|
||||
latest_version: &str,
|
||||
) -> Result<Vec<String>, sqlx::Error> {
|
||||
// Note: This does simple string comparison which works for semver if formatted consistently
|
||||
// For production, you might want a more robust version comparison
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT agent_id FROM connect_machines
|
||||
WHERE status = 'online'
|
||||
AND is_persistent = true
|
||||
AND (agent_version IS NULL OR agent_version < $1)
|
||||
"#,
|
||||
)
|
||||
.bind(latest_version)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|(id,)| id).collect())
|
||||
}
|
||||
111
server/src/db/sessions.rs
Normal file
111
server/src/db/sessions.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! Session database operations
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Session record from database
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct DbSession {
|
||||
pub id: Uuid,
|
||||
pub machine_id: Option<Uuid>,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
pub duration_secs: Option<i32>,
|
||||
pub is_support_session: bool,
|
||||
pub support_code: Option<String>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Create a new session record
|
||||
pub async fn create_session(
|
||||
pool: &PgPool,
|
||||
session_id: Uuid,
|
||||
machine_id: Uuid,
|
||||
is_support_session: bool,
|
||||
support_code: Option<&str>,
|
||||
) -> Result<DbSession, sqlx::Error> {
|
||||
sqlx::query_as::<_, DbSession>(
|
||||
r#"
|
||||
INSERT INTO connect_sessions (id, machine_id, is_support_session, support_code, status)
|
||||
VALUES ($1, $2, $3, $4, 'active')
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(machine_id)
|
||||
.bind(is_support_session)
|
||||
.bind(support_code)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// End a session
|
||||
pub async fn end_session(
|
||||
pool: &PgPool,
|
||||
session_id: Uuid,
|
||||
status: &str, // 'ended' or 'disconnected' or 'timeout'
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE connect_sessions SET
|
||||
ended_at = NOW(),
|
||||
duration_secs = EXTRACT(EPOCH FROM (NOW() - started_at))::INTEGER,
|
||||
status = $1
|
||||
WHERE id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(status)
|
||||
.bind(session_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get session by ID
|
||||
pub async fn get_session(pool: &PgPool, session_id: Uuid) -> Result<Option<DbSession>, sqlx::Error> {
|
||||
sqlx::query_as::<_, DbSession>("SELECT * FROM connect_sessions WHERE id = $1")
|
||||
.bind(session_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get active sessions for a machine
|
||||
pub async fn get_active_sessions_for_machine(
|
||||
pool: &PgPool,
|
||||
machine_id: Uuid,
|
||||
) -> Result<Vec<DbSession>, sqlx::Error> {
|
||||
sqlx::query_as::<_, DbSession>(
|
||||
"SELECT * FROM connect_sessions WHERE machine_id = $1 AND status = 'active' ORDER BY started_at DESC"
|
||||
)
|
||||
.bind(machine_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get recent sessions (for dashboard)
|
||||
pub async fn get_recent_sessions(
|
||||
pool: &PgPool,
|
||||
limit: i64,
|
||||
) -> Result<Vec<DbSession>, sqlx::Error> {
|
||||
sqlx::query_as::<_, DbSession>(
|
||||
"SELECT * FROM connect_sessions ORDER BY started_at DESC LIMIT $1"
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get all sessions for a machine (for history export)
|
||||
pub async fn get_sessions_for_machine(
|
||||
pool: &PgPool,
|
||||
machine_id: Uuid,
|
||||
) -> Result<Vec<DbSession>, sqlx::Error> {
|
||||
sqlx::query_as::<_, DbSession>(
|
||||
"SELECT * FROM connect_sessions WHERE machine_id = $1 ORDER BY started_at DESC"
|
||||
)
|
||||
.bind(machine_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
141
server/src/db/support_codes.rs
Normal file
141
server/src/db/support_codes.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! Support code database operations
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Support code record from database
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct DbSupportCode {
|
||||
pub id: Uuid,
|
||||
pub code: String,
|
||||
pub session_id: Option<Uuid>,
|
||||
pub created_by: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub status: String,
|
||||
pub client_name: Option<String>,
|
||||
pub client_machine: Option<String>,
|
||||
pub connected_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Create a new support code
|
||||
pub async fn create_support_code(
|
||||
pool: &PgPool,
|
||||
code: &str,
|
||||
created_by: &str,
|
||||
) -> Result<DbSupportCode, sqlx::Error> {
|
||||
sqlx::query_as::<_, DbSupportCode>(
|
||||
r#"
|
||||
INSERT INTO connect_support_codes (code, created_by, status)
|
||||
VALUES ($1, $2, 'pending')
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(code)
|
||||
.bind(created_by)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get support code by code string
|
||||
pub async fn get_support_code(pool: &PgPool, code: &str) -> Result<Option<DbSupportCode>, sqlx::Error> {
|
||||
sqlx::query_as::<_, DbSupportCode>(
|
||||
"SELECT * FROM connect_support_codes WHERE code = $1"
|
||||
)
|
||||
.bind(code)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update support code when client connects
|
||||
pub async fn mark_code_connected(
|
||||
pool: &PgPool,
|
||||
code: &str,
|
||||
session_id: Option<Uuid>,
|
||||
client_name: Option<&str>,
|
||||
client_machine: Option<&str>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE connect_support_codes SET
|
||||
status = 'connected',
|
||||
session_id = $1,
|
||||
client_name = $2,
|
||||
client_machine = $3,
|
||||
connected_at = NOW()
|
||||
WHERE code = $4
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(client_name)
|
||||
.bind(client_machine)
|
||||
.bind(code)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark support code as completed
|
||||
pub async fn mark_code_completed(pool: &PgPool, code: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE connect_support_codes SET status = 'completed' WHERE code = $1")
|
||||
.bind(code)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark support code as cancelled
|
||||
pub async fn mark_code_cancelled(pool: &PgPool, code: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE connect_support_codes SET status = 'cancelled' WHERE code = $1")
|
||||
.bind(code)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get active support codes (pending or connected)
|
||||
pub async fn get_active_support_codes(pool: &PgPool) -> Result<Vec<DbSupportCode>, sqlx::Error> {
|
||||
sqlx::query_as::<_, DbSupportCode>(
|
||||
"SELECT * FROM connect_support_codes WHERE status IN ('pending', 'connected') ORDER BY created_at DESC"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Check if code exists and is valid for connection
|
||||
pub async fn is_code_valid(pool: &PgPool, code: &str) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM connect_support_codes WHERE code = $1 AND status = 'pending')"
|
||||
)
|
||||
.bind(code)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Check if code is cancelled
|
||||
pub async fn is_code_cancelled(pool: &PgPool, code: &str) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM connect_support_codes WHERE code = $1 AND status = 'cancelled')"
|
||||
)
|
||||
.bind(code)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Link session to support code
|
||||
pub async fn link_session_to_code(
|
||||
pool: &PgPool,
|
||||
code: &str,
|
||||
session_id: Uuid,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE connect_support_codes SET session_id = $1 WHERE code = $2")
|
||||
.bind(session_id)
|
||||
.bind(code)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
283
server/src/db/users.rs
Normal file
283
server/src/db/users.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
//! User database operations
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// User record from database
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub email: Option<String>,
|
||||
pub role: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// User without password hash (for API responses)
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct UserInfo {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub role: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_login: Option<DateTime<Utc>>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<User> for UserInfo {
|
||||
fn from(u: User) -> Self {
|
||||
Self {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
enabled: u.enabled,
|
||||
created_at: u.created_at,
|
||||
last_login: u.last_login,
|
||||
permissions: Vec::new(), // Filled in by caller
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user by username
|
||||
pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result<Option<User>> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"SELECT * FROM users WHERE username = $1"
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Get user by ID
|
||||
pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"SELECT * FROM users WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Get all users
|
||||
pub async fn get_all_users(pool: &PgPool) -> Result<Vec<User>> {
|
||||
let users = sqlx::query_as::<_, User>(
|
||||
"SELECT * FROM users ORDER BY username"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
/// Create a new user
|
||||
pub async fn create_user(
|
||||
pool: &PgPool,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
email: Option<&str>,
|
||||
role: &str,
|
||||
) -> Result<User> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
INSERT INTO users (username, password_hash, email, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.bind(email)
|
||||
.bind(role)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Update user
|
||||
pub async fn update_user(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
email: Option<&str>,
|
||||
role: &str,
|
||||
enabled: bool,
|
||||
) -> Result<Option<User>> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
UPDATE users
|
||||
SET email = $2, role = $3, enabled = $4, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(id)
|
||||
.bind(email)
|
||||
.bind(role)
|
||||
.bind(enabled)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Update user password
|
||||
pub async fn update_user_password(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
password_hash: &str,
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.bind(password_hash)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Update last login timestamp
|
||||
pub async fn update_last_login(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
sqlx::query("UPDATE users SET last_login = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete user
|
||||
pub async fn delete_user(pool: &PgPool, id: Uuid) -> Result<bool> {
|
||||
let result = sqlx::query("DELETE FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Count users (for initial admin check)
|
||||
pub async fn count_users(pool: &PgPool) -> Result<i64> {
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(count.0)
|
||||
}
|
||||
|
||||
/// Get user permissions
|
||||
pub async fn get_user_permissions(pool: &PgPool, user_id: Uuid) -> Result<Vec<String>> {
|
||||
let perms: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT permission FROM user_permissions WHERE user_id = $1"
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(perms.into_iter().map(|p| p.0).collect())
|
||||
}
|
||||
|
||||
/// Set user permissions (replaces all)
|
||||
pub async fn set_user_permissions(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
permissions: &[String],
|
||||
) -> Result<()> {
|
||||
// Delete existing
|
||||
sqlx::query("DELETE FROM user_permissions WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Insert new
|
||||
for perm in permissions {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_permissions (user_id, permission) VALUES ($1, $2)"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(perm)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get user's accessible client IDs (empty = all access)
|
||||
pub async fn get_user_client_access(pool: &PgPool, user_id: Uuid) -> Result<Vec<Uuid>> {
|
||||
let clients: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT client_id FROM user_client_access WHERE user_id = $1"
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(clients.into_iter().map(|c| c.0).collect())
|
||||
}
|
||||
|
||||
/// Set user's client access (replaces all)
|
||||
pub async fn set_user_client_access(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
client_ids: &[Uuid],
|
||||
) -> Result<()> {
|
||||
// Delete existing
|
||||
sqlx::query("DELETE FROM user_client_access WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Insert new
|
||||
for client_id in client_ids {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_client_access (user_id, client_id) VALUES ($1, $2)"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(client_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if user has access to a specific client
|
||||
pub async fn user_has_client_access(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
client_id: Uuid,
|
||||
) -> Result<bool> {
|
||||
// Admins have access to all
|
||||
let user = get_user_by_id(pool, user_id).await?;
|
||||
if let Some(u) = user {
|
||||
if u.role == "admin" {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Check explicit access
|
||||
let access: Option<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT client_id FROM user_client_access WHERE user_id = $1 AND client_id = $2"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(client_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
// If no explicit access entries exist, user has access to all (legacy behavior)
|
||||
if access.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Check if user has ANY access restrictions
|
||||
let count: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM user_client_access WHERE user_id = $1"
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
// No restrictions means access to all
|
||||
Ok(count.0 == 0)
|
||||
}
|
||||
@@ -18,12 +18,14 @@ pub mod proto {
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
extract::{Path, State, Json},
|
||||
routing::{get, post, put, delete},
|
||||
extract::{Path, State, Json, Query, Request},
|
||||
response::{Html, IntoResponse},
|
||||
http::StatusCode,
|
||||
middleware::{self, Next},
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tower_http::services::ServeDir;
|
||||
@@ -32,12 +34,27 @@ use tracing_subscriber::FmtSubscriber;
|
||||
use serde::Deserialize;
|
||||
|
||||
use support_codes::{SupportCodeManager, CreateCodeRequest, SupportCode, CodeValidation};
|
||||
use auth::{JwtConfig, hash_password, generate_random_password, AuthenticatedUser};
|
||||
|
||||
/// Application state
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
sessions: session::SessionManager,
|
||||
support_codes: SupportCodeManager,
|
||||
db: Option<db::Database>,
|
||||
pub jwt_config: Arc<JwtConfig>,
|
||||
/// Optional API key for persistent agents (env: AGENT_API_KEY)
|
||||
pub agent_api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Middleware to inject JWT config into request extensions
|
||||
async fn auth_layer(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> impl IntoResponse {
|
||||
request.extensions_mut().insert(state.jwt_config.clone());
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -52,46 +69,187 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Load configuration
|
||||
let config = config::Config::load()?;
|
||||
|
||||
|
||||
// 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);
|
||||
|
||||
// JWT configuration
|
||||
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| {
|
||||
tracing::warn!("JWT_SECRET not set, using default (INSECURE for production!)");
|
||||
"guruconnect-dev-secret-change-me-in-production".to_string()
|
||||
});
|
||||
let jwt_expiry_hours = std::env::var("JWT_EXPIRY_HOURS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(24i64);
|
||||
let jwt_config = Arc::new(JwtConfig::new(jwt_secret, jwt_expiry_hours));
|
||||
|
||||
// Initialize database if configured
|
||||
let database = if let Some(ref db_url) = config.database_url {
|
||||
match db::Database::connect(db_url, config.database_max_connections).await {
|
||||
Ok(db) => {
|
||||
// Run migrations
|
||||
if let Err(e) = db.migrate().await {
|
||||
tracing::error!("Failed to run migrations: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
Some(db)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to connect to database: {}. Running without persistence.", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("No DATABASE_URL set, running without persistence");
|
||||
None
|
||||
};
|
||||
|
||||
// Create initial admin user if no users exist
|
||||
if let Some(ref db) = database {
|
||||
match db::count_users(db.pool()).await {
|
||||
Ok(0) => {
|
||||
info!("No users found, creating initial admin user...");
|
||||
let password = generate_random_password(16);
|
||||
let password_hash = hash_password(&password)?;
|
||||
|
||||
match db::create_user(db.pool(), "admin", &password_hash, None, "admin").await {
|
||||
Ok(user) => {
|
||||
// Set admin permissions
|
||||
let perms = vec![
|
||||
"view".to_string(),
|
||||
"control".to_string(),
|
||||
"transfer".to_string(),
|
||||
"manage_users".to_string(),
|
||||
"manage_clients".to_string(),
|
||||
];
|
||||
let _ = db::set_user_permissions(db.pool(), user.id, &perms).await;
|
||||
|
||||
info!("========================================");
|
||||
info!(" INITIAL ADMIN USER CREATED");
|
||||
info!(" Username: admin");
|
||||
info!(" Password: {}", password);
|
||||
info!(" (Change this password after first login!)");
|
||||
info!("========================================");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create initial admin user: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(count) => {
|
||||
info!("{} user(s) in database", count);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not check user count: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create session manager
|
||||
let sessions = session::SessionManager::new();
|
||||
|
||||
// Restore persistent machines from database
|
||||
if let Some(ref db) = database {
|
||||
match db::machines::get_all_machines(db.pool()).await {
|
||||
Ok(machines) => {
|
||||
info!("Restoring {} persistent machines from database", machines.len());
|
||||
for machine in machines {
|
||||
sessions.restore_offline_machine(&machine.agent_id, &machine.hostname).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to restore machines: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Agent API key for persistent agents (optional)
|
||||
let agent_api_key = std::env::var("AGENT_API_KEY").ok();
|
||||
if agent_api_key.is_some() {
|
||||
info!("AGENT_API_KEY configured for persistent agents");
|
||||
} else {
|
||||
info!("No AGENT_API_KEY set - persistent agents will need JWT token or support code");
|
||||
}
|
||||
|
||||
// Create application state
|
||||
let state = AppState {
|
||||
sessions: session::SessionManager::new(),
|
||||
sessions,
|
||||
support_codes: SupportCodeManager::new(),
|
||||
db: database,
|
||||
jwt_config,
|
||||
agent_api_key,
|
||||
};
|
||||
|
||||
// Build router
|
||||
let app = Router::new()
|
||||
// Health check
|
||||
// Health check (no auth required)
|
||||
.route("/health", get(health))
|
||||
|
||||
|
||||
// Auth endpoints (no auth required for login)
|
||||
.route("/api/auth/login", post(api::auth::login))
|
||||
|
||||
// Auth endpoints (auth required)
|
||||
.route("/api/auth/me", get(api::auth::get_me))
|
||||
.route("/api/auth/change-password", post(api::auth::change_password))
|
||||
|
||||
// User management (admin only)
|
||||
.route("/api/users", get(api::users::list_users))
|
||||
.route("/api/users", post(api::users::create_user))
|
||||
.route("/api/users/:id", get(api::users::get_user))
|
||||
.route("/api/users/:id", put(api::users::update_user))
|
||||
.route("/api/users/:id", delete(api::users::delete_user))
|
||||
.route("/api/users/:id/permissions", put(api::users::set_permissions))
|
||||
.route("/api/users/:id/clients", put(api::users::set_client_access))
|
||||
|
||||
// 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 - Sessions
|
||||
.route("/api/sessions", get(list_sessions))
|
||||
.route("/api/sessions/:id", get(get_session))
|
||||
|
||||
.route("/api/sessions/:id", delete(disconnect_session))
|
||||
|
||||
// REST API - Machines
|
||||
.route("/api/machines", get(list_machines))
|
||||
.route("/api/machines/:agent_id", get(get_machine))
|
||||
.route("/api/machines/:agent_id", delete(delete_machine))
|
||||
.route("/api/machines/:agent_id/history", get(get_machine_history))
|
||||
.route("/api/machines/:agent_id/update", post(trigger_machine_update))
|
||||
|
||||
// REST API - Releases and Version
|
||||
.route("/api/version", get(api::releases::get_version)) // No auth - for agent polling
|
||||
.route("/api/releases", get(api::releases::list_releases))
|
||||
.route("/api/releases", post(api::releases::create_release))
|
||||
.route("/api/releases/:version", get(api::releases::get_release))
|
||||
.route("/api/releases/:version", put(api::releases::update_release))
|
||||
.route("/api/releases/:version", delete(api::releases::delete_release))
|
||||
|
||||
// Agent downloads (no auth - public download links)
|
||||
.route("/api/download/viewer", get(api::downloads::download_viewer))
|
||||
.route("/api/download/support", get(api::downloads::download_support))
|
||||
.route("/api/download/agent", get(api::downloads::download_agent))
|
||||
|
||||
// HTML page routes (clean URLs)
|
||||
.route("/login", get(serve_login))
|
||||
.route("/dashboard", get(serve_dashboard))
|
||||
.route("/users", get(serve_users))
|
||||
|
||||
// State and middleware
|
||||
.with_state(state.clone())
|
||||
.layer(middleware::from_fn_with_state(state, auth_layer))
|
||||
|
||||
// State
|
||||
.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(
|
||||
@@ -119,6 +277,7 @@ async fn health() -> &'static str {
|
||||
// Support code API handlers
|
||||
|
||||
async fn create_code(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Json(request): Json<CreateCodeRequest>,
|
||||
) -> Json<SupportCode> {
|
||||
@@ -128,6 +287,7 @@ async fn create_code(
|
||||
}
|
||||
|
||||
async fn list_codes(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
) -> Json<Vec<SupportCode>> {
|
||||
Json(state.support_codes.list_active_codes().await)
|
||||
@@ -146,6 +306,7 @@ async fn validate_code(
|
||||
}
|
||||
|
||||
async fn cancel_code(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Path(code): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
@@ -159,6 +320,7 @@ async fn cancel_code(
|
||||
// Session API handlers (updated to use AppState)
|
||||
|
||||
async fn list_sessions(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
) -> Json<Vec<api::SessionInfo>> {
|
||||
let sessions = state.sessions.list_sessions().await;
|
||||
@@ -166,6 +328,7 @@ async fn list_sessions(
|
||||
}
|
||||
|
||||
async fn get_session(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<api::SessionInfo>, (StatusCode, &'static str)> {
|
||||
@@ -178,6 +341,207 @@ async fn get_session(
|
||||
Ok(Json(api::SessionInfo::from(session)))
|
||||
}
|
||||
|
||||
async fn disconnect_session(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let session_id = match uuid::Uuid::parse_str(&id) {
|
||||
Ok(id) => id,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid session ID"),
|
||||
};
|
||||
|
||||
if state.sessions.disconnect_session(session_id, "Disconnected by administrator").await {
|
||||
info!("Session {} disconnected by admin", session_id);
|
||||
(StatusCode::OK, "Session disconnected")
|
||||
} else {
|
||||
(StatusCode::NOT_FOUND, "Session not found")
|
||||
}
|
||||
}
|
||||
|
||||
// Machine API handlers
|
||||
|
||||
async fn list_machines(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<api::MachineInfo>>, (StatusCode, &'static str)> {
|
||||
let db = state.db.as_ref()
|
||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||
|
||||
let machines = db::machines::get_all_machines(db.pool()).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||
|
||||
Ok(Json(machines.into_iter().map(api::MachineInfo::from).collect()))
|
||||
}
|
||||
|
||||
async fn get_machine(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Path(agent_id): Path<String>,
|
||||
) -> Result<Json<api::MachineInfo>, (StatusCode, &'static str)> {
|
||||
let db = state.db.as_ref()
|
||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||
|
||||
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
|
||||
|
||||
Ok(Json(api::MachineInfo::from(machine)))
|
||||
}
|
||||
|
||||
async fn get_machine_history(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Path(agent_id): Path<String>,
|
||||
) -> Result<Json<api::MachineHistory>, (StatusCode, &'static str)> {
|
||||
let db = state.db.as_ref()
|
||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||
|
||||
// Get machine
|
||||
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
|
||||
|
||||
// Get sessions for this machine
|
||||
let sessions = db::sessions::get_sessions_for_machine(db.pool(), machine.id).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||
|
||||
// Get events for this machine
|
||||
let events = db::events::get_events_for_machine(db.pool(), machine.id).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||
|
||||
let history = api::MachineHistory {
|
||||
machine: api::MachineInfo::from(machine),
|
||||
sessions: sessions.into_iter().map(api::SessionRecord::from).collect(),
|
||||
events: events.into_iter().map(api::EventRecord::from).collect(),
|
||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
Ok(Json(history))
|
||||
}
|
||||
|
||||
async fn delete_machine(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Path(agent_id): Path<String>,
|
||||
Query(params): Query<api::DeleteMachineParams>,
|
||||
) -> Result<Json<api::DeleteMachineResponse>, (StatusCode, &'static str)> {
|
||||
let db = state.db.as_ref()
|
||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||
|
||||
// Get machine first
|
||||
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
|
||||
|
||||
// Export history if requested
|
||||
let history = if params.export {
|
||||
let sessions = db::sessions::get_sessions_for_machine(db.pool(), machine.id).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||
let events = db::events::get_events_for_machine(db.pool(), machine.id).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||
|
||||
Some(api::MachineHistory {
|
||||
machine: api::MachineInfo::from(machine.clone()),
|
||||
sessions: sessions.into_iter().map(api::SessionRecord::from).collect(),
|
||||
events: events.into_iter().map(api::EventRecord::from).collect(),
|
||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Send uninstall command if requested and agent is online
|
||||
let mut uninstall_sent = false;
|
||||
if params.uninstall {
|
||||
// Find session for this agent
|
||||
if let Some(session) = state.sessions.get_session_by_agent(&agent_id).await {
|
||||
if session.is_online {
|
||||
uninstall_sent = state.sessions.send_admin_command(
|
||||
session.id,
|
||||
proto::AdminCommandType::AdminUninstall,
|
||||
"Deleted by administrator",
|
||||
).await;
|
||||
if uninstall_sent {
|
||||
info!("Sent uninstall command to agent {}", agent_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from session manager
|
||||
state.sessions.remove_agent(&agent_id).await;
|
||||
|
||||
// Delete from database (cascades to sessions and events)
|
||||
db::machines::delete_machine(db.pool(), &agent_id).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to delete machine"))?;
|
||||
|
||||
info!("Deleted machine {} (uninstall_sent: {})", agent_id, uninstall_sent);
|
||||
|
||||
Ok(Json(api::DeleteMachineResponse {
|
||||
success: true,
|
||||
message: format!("Machine {} deleted", machine.hostname),
|
||||
uninstall_sent,
|
||||
history,
|
||||
}))
|
||||
}
|
||||
|
||||
// Update trigger request
|
||||
#[derive(Deserialize)]
|
||||
struct TriggerUpdateRequest {
|
||||
/// Target version (optional, defaults to latest stable)
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
/// Trigger update on a specific machine
|
||||
async fn trigger_machine_update(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Path(agent_id): Path<String>,
|
||||
Json(request): Json<TriggerUpdateRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, &'static str)> {
|
||||
let db = state.db.as_ref()
|
||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||
|
||||
// Get the target release (either specified or latest stable)
|
||||
let release = if let Some(version) = request.version {
|
||||
db::releases::get_release_by_version(db.pool(), &version).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Release version not found"))?
|
||||
} else {
|
||||
db::releases::get_latest_stable_release(db.pool()).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "No stable release available"))?
|
||||
};
|
||||
|
||||
// Find session for this agent
|
||||
let session = state.sessions.get_session_by_agent(&agent_id).await
|
||||
.ok_or((StatusCode::NOT_FOUND, "Agent not found or offline"))?;
|
||||
|
||||
if !session.is_online {
|
||||
return Err((StatusCode::BAD_REQUEST, "Agent is offline"));
|
||||
}
|
||||
|
||||
// Send update command via WebSocket
|
||||
// For now, we send admin command - later we'll include UpdateInfo in the message
|
||||
let sent = state.sessions.send_admin_command(
|
||||
session.id,
|
||||
proto::AdminCommandType::AdminUpdate,
|
||||
&format!("Update to version {}", release.version),
|
||||
).await;
|
||||
|
||||
if sent {
|
||||
info!("Sent update command to agent {} (version {})", agent_id, release.version);
|
||||
|
||||
// Update machine update status in database
|
||||
let _ = db::releases::update_machine_update_status(db.pool(), &agent_id, "downloading").await;
|
||||
|
||||
Ok((StatusCode::OK, "Update command sent"))
|
||||
} else {
|
||||
Err((StatusCode::INTERNAL_SERVER_ERROR, "Failed to send update command"))
|
||||
}
|
||||
}
|
||||
|
||||
// Static page handlers
|
||||
async fn serve_login() -> impl IntoResponse {
|
||||
match tokio::fs::read_to_string("static/login.html").await {
|
||||
@@ -192,3 +556,10 @@ async fn serve_dashboard() -> impl IntoResponse {
|
||||
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_users() -> impl IntoResponse {
|
||||
match tokio::fs::read_to_string("static/users.html").await {
|
||||
Ok(content) => Html(content).into_response(),
|
||||
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,17 @@ use axum::{
|
||||
Query, State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
http::StatusCode,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use prost::Message as ProstMessage;
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::proto;
|
||||
use crate::session::SessionManager;
|
||||
use crate::db::{self, Database};
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -24,11 +27,27 @@ pub struct AgentParams {
|
||||
agent_id: String,
|
||||
#[serde(default)]
|
||||
agent_name: Option<String>,
|
||||
#[serde(default)]
|
||||
support_code: Option<String>,
|
||||
#[serde(default)]
|
||||
hostname: Option<String>,
|
||||
/// API key for persistent (managed) agents
|
||||
#[serde(default)]
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ViewerParams {
|
||||
session_id: String,
|
||||
#[serde(default = "default_viewer_name")]
|
||||
viewer_name: String,
|
||||
/// JWT token for authentication (required)
|
||||
#[serde(default)]
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
fn default_viewer_name() -> String {
|
||||
"Technician".to_string()
|
||||
}
|
||||
|
||||
/// WebSocket handler for agent connections
|
||||
@@ -36,12 +55,71 @@ pub async fn agent_ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
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();
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let agent_id = params.agent_id.clone();
|
||||
let agent_name = params.hostname.clone().or(params.agent_name.clone()).unwrap_or_else(|| agent_id.clone());
|
||||
let support_code = params.support_code.clone();
|
||||
let api_key = params.api_key.clone();
|
||||
|
||||
ws.on_upgrade(move |socket| handle_agent_connection(socket, sessions, agent_id, agent_name))
|
||||
// SECURITY: Agent must provide either a support code OR an API key
|
||||
// Support code = ad-hoc support session (technician generated code)
|
||||
// API key = persistent managed agent
|
||||
|
||||
if support_code.is_none() && api_key.is_none() {
|
||||
warn!("Agent connection rejected: {} - no support code or API key", agent_id);
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Validate support code if provided
|
||||
if let Some(ref code) = support_code {
|
||||
// Check if it's a valid, pending support code
|
||||
let code_info = state.support_codes.get_status(code).await;
|
||||
if code_info.is_none() {
|
||||
warn!("Agent connection rejected: {} - invalid support code {}", agent_id, code);
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
let status = code_info.unwrap();
|
||||
if status != "pending" && status != "connected" {
|
||||
warn!("Agent connection rejected: {} - support code {} has status {}", agent_id, code, status);
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
info!("Agent {} authenticated via support code {}", agent_id, code);
|
||||
}
|
||||
|
||||
// Validate API key if provided (for persistent agents)
|
||||
if let Some(ref key) = api_key {
|
||||
// For now, we'll accept API keys that match the JWT secret or a configured agent key
|
||||
// In production, this should validate against a database of registered agents
|
||||
if !validate_agent_api_key(&state, key).await {
|
||||
warn!("Agent connection rejected: {} - invalid API key", agent_id);
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
info!("Agent {} authenticated via API key", agent_id);
|
||||
}
|
||||
|
||||
let sessions = state.sessions.clone();
|
||||
let support_codes = state.support_codes.clone();
|
||||
let db = state.db.clone();
|
||||
|
||||
Ok(ws.on_upgrade(move |socket| handle_agent_connection(socket, sessions, support_codes, db, agent_id, agent_name, support_code)))
|
||||
}
|
||||
|
||||
/// Validate an agent API key
|
||||
async fn validate_agent_api_key(state: &AppState, api_key: &str) -> bool {
|
||||
// Check if API key is a valid JWT (allows using dashboard token for testing)
|
||||
if state.jwt_config.validate_token(api_key).is_ok() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check against configured agent API key if set
|
||||
if let Some(ref configured_key) = state.agent_api_key {
|
||||
if api_key == configured_key {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// In future: validate against database of registered agents
|
||||
false
|
||||
}
|
||||
|
||||
/// WebSocket handler for viewer connections
|
||||
@@ -49,50 +127,243 @@ pub async fn viewer_ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ViewerParams>,
|
||||
) -> impl IntoResponse {
|
||||
let session_id = params.session_id;
|
||||
let sessions = state.sessions.clone();
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
// Require JWT token for viewers
|
||||
let token = params.token.ok_or_else(|| {
|
||||
warn!("Viewer connection rejected: missing token");
|
||||
StatusCode::UNAUTHORIZED
|
||||
})?;
|
||||
|
||||
ws.on_upgrade(move |socket| handle_viewer_connection(socket, sessions, session_id))
|
||||
// Validate the token
|
||||
let claims = state.jwt_config.validate_token(&token).map_err(|e| {
|
||||
warn!("Viewer connection rejected: invalid token: {}", e);
|
||||
StatusCode::UNAUTHORIZED
|
||||
})?;
|
||||
|
||||
info!("Viewer {} authenticated via JWT", claims.username);
|
||||
|
||||
let session_id = params.session_id;
|
||||
let viewer_name = params.viewer_name;
|
||||
let sessions = state.sessions.clone();
|
||||
let db = state.db.clone();
|
||||
|
||||
Ok(ws.on_upgrade(move |socket| handle_viewer_connection(socket, sessions, db, session_id, viewer_name)))
|
||||
}
|
||||
|
||||
/// Handle an agent WebSocket connection
|
||||
async fn handle_agent_connection(
|
||||
socket: WebSocket,
|
||||
sessions: SessionManager,
|
||||
support_codes: crate::support_codes::SupportCodeManager,
|
||||
db: Option<Database>,
|
||||
agent_id: String,
|
||||
agent_name: String,
|
||||
support_code: Option<String>,
|
||||
) {
|
||||
info!("Agent connected: {} ({})", agent_name, agent_id);
|
||||
|
||||
// Register the agent and get channels
|
||||
let (session_id, frame_tx, mut input_rx) = sessions.register_agent(agent_id.clone(), agent_name.clone()).await;
|
||||
|
||||
info!("Session created: {}", session_id);
|
||||
|
||||
let (mut ws_sender, mut ws_receiver) = socket.split();
|
||||
|
||||
// If a support code was provided, check if it's valid
|
||||
if let Some(ref code) = support_code {
|
||||
// Check if the code is cancelled or invalid
|
||||
if support_codes.is_cancelled(code).await {
|
||||
warn!("Agent tried to connect with cancelled code: {}", code);
|
||||
// Send disconnect message to agent
|
||||
let disconnect_msg = proto::Message {
|
||||
payload: Some(proto::message::Payload::Disconnect(proto::Disconnect {
|
||||
reason: "Support session was cancelled by technician".to_string(),
|
||||
})),
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
if prost::Message::encode(&disconnect_msg, &mut buf).is_ok() {
|
||||
let _ = ws_sender.send(Message::Binary(buf.into())).await;
|
||||
}
|
||||
let _ = ws_sender.close().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Register the agent and get channels
|
||||
// Persistent agents (no support code) keep their session when disconnected
|
||||
let is_persistent = support_code.is_none();
|
||||
let (session_id, frame_tx, mut input_rx) = sessions.register_agent(agent_id.clone(), agent_name.clone(), is_persistent).await;
|
||||
|
||||
info!("Session created: {} (agent in idle mode)", session_id);
|
||||
|
||||
// Database: upsert machine and create session record
|
||||
let machine_id = if let Some(ref db) = db {
|
||||
match db::machines::upsert_machine(db.pool(), &agent_id, &agent_name, is_persistent).await {
|
||||
Ok(machine) => {
|
||||
// Create session record
|
||||
let _ = db::sessions::create_session(
|
||||
db.pool(),
|
||||
session_id,
|
||||
machine.id,
|
||||
support_code.is_some(),
|
||||
support_code.as_deref(),
|
||||
).await;
|
||||
|
||||
// Log session started event
|
||||
let _ = db::events::log_event(
|
||||
db.pool(),
|
||||
session_id,
|
||||
db::events::EventTypes::SESSION_STARTED,
|
||||
None, None, None, None,
|
||||
).await;
|
||||
|
||||
Some(machine.id)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to upsert machine in database: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// If a support code was provided, mark it as connected
|
||||
if let Some(ref code) = support_code {
|
||||
info!("Linking support code {} to session {}", code, session_id);
|
||||
support_codes.mark_connected(code, Some(agent_name.clone()), Some(agent_id.clone())).await;
|
||||
support_codes.link_session(code, session_id).await;
|
||||
|
||||
// Database: update support code
|
||||
if let Some(ref db) = db {
|
||||
let _ = db::support_codes::mark_code_connected(
|
||||
db.pool(),
|
||||
code,
|
||||
Some(session_id),
|
||||
Some(&agent_name),
|
||||
Some(&agent_id),
|
||||
).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Use Arc<Mutex> for sender so we can use it from multiple places
|
||||
let ws_sender = std::sync::Arc::new(tokio::sync::Mutex::new(ws_sender));
|
||||
let ws_sender_input = ws_sender.clone();
|
||||
let ws_sender_cancel = ws_sender.clone();
|
||||
|
||||
// Task to forward input events from viewers to agent
|
||||
let input_forward = tokio::spawn(async move {
|
||||
while let Some(input_data) = input_rx.recv().await {
|
||||
if ws_sender.send(Message::Binary(input_data.into())).await.is_err() {
|
||||
let mut sender = ws_sender_input.lock().await;
|
||||
if sender.send(Message::Binary(input_data.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let sessions_cleanup = sessions.clone();
|
||||
let sessions_status = sessions.clone();
|
||||
let support_codes_cleanup = support_codes.clone();
|
||||
let support_code_cleanup = support_code.clone();
|
||||
let support_code_check = support_code.clone();
|
||||
let support_codes_check = support_codes.clone();
|
||||
|
||||
// Main loop: receive frames from agent and broadcast to viewers
|
||||
// Task to check for cancellation every 2 seconds
|
||||
let cancel_check = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Some(ref code) = support_code_check {
|
||||
if support_codes_check.is_cancelled(code).await {
|
||||
info!("Support code {} was cancelled, disconnecting agent", code);
|
||||
// Send disconnect message
|
||||
let disconnect_msg = proto::Message {
|
||||
payload: Some(proto::message::Payload::Disconnect(proto::Disconnect {
|
||||
reason: "Support session was cancelled by technician".to_string(),
|
||||
})),
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
if prost::Message::encode(&disconnect_msg, &mut buf).is_ok() {
|
||||
let mut sender = ws_sender_cancel.lock().await;
|
||||
let _ = sender.send(Message::Binary(buf.into())).await;
|
||||
let _ = sender.close().await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Main loop: receive messages from agent
|
||||
while let Some(msg) = ws_receiver.next().await {
|
||||
match msg {
|
||||
Ok(Message::Binary(data)) => {
|
||||
// Try to decode as protobuf message
|
||||
match proto::Message::decode(data.as_ref()) {
|
||||
Ok(proto_msg) => {
|
||||
if let Some(proto::message::Payload::VideoFrame(_)) = &proto_msg.payload {
|
||||
// Broadcast frame to all viewers
|
||||
let _ = frame_tx.send(data.to_vec());
|
||||
match &proto_msg.payload {
|
||||
Some(proto::message::Payload::VideoFrame(_)) => {
|
||||
// Broadcast frame to all viewers (only sent when streaming)
|
||||
let _ = frame_tx.send(data.to_vec());
|
||||
}
|
||||
Some(proto::message::Payload::ChatMessage(chat)) => {
|
||||
// Broadcast chat message to all viewers
|
||||
info!("Chat from client: {}", chat.content);
|
||||
let _ = frame_tx.send(data.to_vec());
|
||||
}
|
||||
Some(proto::message::Payload::AgentStatus(status)) => {
|
||||
// Update session with agent status
|
||||
let agent_version = if status.agent_version.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(status.agent_version.clone())
|
||||
};
|
||||
let organization = if status.organization.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(status.organization.clone())
|
||||
};
|
||||
let site = if status.site.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(status.site.clone())
|
||||
};
|
||||
sessions_status.update_agent_status(
|
||||
session_id,
|
||||
Some(status.os_version.clone()),
|
||||
status.is_elevated,
|
||||
status.uptime_secs,
|
||||
status.display_count,
|
||||
status.is_streaming,
|
||||
agent_version.clone(),
|
||||
organization.clone(),
|
||||
site.clone(),
|
||||
status.tags.clone(),
|
||||
).await;
|
||||
|
||||
// Update version in database if present
|
||||
if let (Some(ref db), Some(ref version)) = (&db, &agent_version) {
|
||||
let _ = crate::db::releases::update_machine_version(db.pool(), &agent_id, version).await;
|
||||
}
|
||||
|
||||
// Update organization/site/tags in database if present
|
||||
if let Some(ref db) = db {
|
||||
let _ = crate::db::machines::update_machine_metadata(
|
||||
db.pool(),
|
||||
&agent_id,
|
||||
organization.as_deref(),
|
||||
site.as_deref(),
|
||||
&status.tags,
|
||||
).await;
|
||||
}
|
||||
|
||||
info!("Agent status update: {} - streaming={}, uptime={}s, version={:?}, org={:?}, site={:?}",
|
||||
status.hostname, status.is_streaming, status.uptime_secs, agent_version, organization, site);
|
||||
}
|
||||
Some(proto::message::Payload::Heartbeat(_)) => {
|
||||
// Update heartbeat timestamp
|
||||
sessions_status.update_heartbeat(session_id).await;
|
||||
}
|
||||
Some(proto::message::Payload::HeartbeatAck(_)) => {
|
||||
// Agent acknowledged our heartbeat
|
||||
sessions_status.update_heartbeat(session_id).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -118,7 +389,41 @@ async fn handle_agent_connection(
|
||||
|
||||
// Cleanup
|
||||
input_forward.abort();
|
||||
sessions_cleanup.remove_session(session_id).await;
|
||||
cancel_check.abort();
|
||||
// Mark agent as disconnected (persistent agents stay in list as offline)
|
||||
sessions_cleanup.mark_agent_disconnected(session_id).await;
|
||||
|
||||
// Database: end session and mark machine offline
|
||||
if let Some(ref db) = db {
|
||||
// End the session record
|
||||
let _ = db::sessions::end_session(db.pool(), session_id, "ended").await;
|
||||
|
||||
// Mark machine as offline
|
||||
let _ = db::machines::mark_machine_offline(db.pool(), &agent_id).await;
|
||||
|
||||
// Log session ended event
|
||||
let _ = db::events::log_event(
|
||||
db.pool(),
|
||||
session_id,
|
||||
db::events::EventTypes::SESSION_ENDED,
|
||||
None, None, None, None,
|
||||
).await;
|
||||
}
|
||||
|
||||
// Mark support code as completed if one was used (unless cancelled)
|
||||
if let Some(ref code) = support_code_cleanup {
|
||||
if !support_codes_cleanup.is_cancelled(code).await {
|
||||
support_codes_cleanup.mark_completed(code).await;
|
||||
|
||||
// Database: mark code as completed
|
||||
if let Some(ref db) = db {
|
||||
let _ = db::support_codes::mark_code_completed(db.pool(), code).await;
|
||||
}
|
||||
|
||||
info!("Support code {} marked as completed", code);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Session {} ended", session_id);
|
||||
}
|
||||
|
||||
@@ -126,7 +431,9 @@ async fn handle_agent_connection(
|
||||
async fn handle_viewer_connection(
|
||||
socket: WebSocket,
|
||||
sessions: SessionManager,
|
||||
db: Option<Database>,
|
||||
session_id_str: String,
|
||||
viewer_name: String,
|
||||
) {
|
||||
// Parse session ID
|
||||
let session_id = match uuid::Uuid::parse_str(&session_id_str) {
|
||||
@@ -137,8 +444,11 @@ async fn handle_viewer_connection(
|
||||
}
|
||||
};
|
||||
|
||||
// Join the session
|
||||
let (mut frame_rx, input_tx) = match sessions.join_session(session_id).await {
|
||||
// Generate unique viewer ID
|
||||
let viewer_id = Uuid::new_v4().to_string();
|
||||
|
||||
// Join the session (this sends StartStream to agent if first viewer)
|
||||
let (mut frame_rx, input_tx) = match sessions.join_session(session_id, viewer_id.clone(), viewer_name.clone()).await {
|
||||
Some(channels) => channels,
|
||||
None => {
|
||||
warn!("Session not found: {}", session_id);
|
||||
@@ -146,7 +456,19 @@ async fn handle_viewer_connection(
|
||||
}
|
||||
};
|
||||
|
||||
info!("Viewer joined session: {}", session_id);
|
||||
info!("Viewer {} ({}) joined session: {}", viewer_name, viewer_id, session_id);
|
||||
|
||||
// Database: log viewer joined event
|
||||
if let Some(ref db) = db {
|
||||
let _ = db::events::log_event(
|
||||
db.pool(),
|
||||
session_id,
|
||||
db::events::EventTypes::VIEWER_JOINED,
|
||||
Some(&viewer_id),
|
||||
Some(&viewer_name),
|
||||
None, None,
|
||||
).await;
|
||||
}
|
||||
|
||||
let (mut ws_sender, mut ws_receiver) = socket.split();
|
||||
|
||||
@@ -160,6 +482,8 @@ async fn handle_viewer_connection(
|
||||
});
|
||||
|
||||
let sessions_cleanup = sessions.clone();
|
||||
let viewer_id_cleanup = viewer_id.clone();
|
||||
let viewer_name_cleanup = viewer_name.clone();
|
||||
|
||||
// Main loop: receive input from viewer and forward to agent
|
||||
while let Some(msg) = ws_receiver.next().await {
|
||||
@@ -170,10 +494,16 @@ async fn handle_viewer_connection(
|
||||
Ok(proto_msg) => {
|
||||
match &proto_msg.payload {
|
||||
Some(proto::message::Payload::MouseEvent(_)) |
|
||||
Some(proto::message::Payload::KeyEvent(_)) => {
|
||||
Some(proto::message::Payload::KeyEvent(_)) |
|
||||
Some(proto::message::Payload::SpecialKey(_)) => {
|
||||
// Forward input to agent
|
||||
let _ = input_tx.send(data.to_vec()).await;
|
||||
}
|
||||
Some(proto::message::Payload::ChatMessage(chat)) => {
|
||||
// Forward chat message to agent
|
||||
info!("Chat from technician: {}", chat.content);
|
||||
let _ = input_tx.send(data.to_vec()).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -183,19 +513,32 @@ async fn handle_viewer_connection(
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
info!("Viewer disconnected from session: {}", session_id);
|
||||
info!("Viewer {} disconnected from session: {}", viewer_id, session_id);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("WebSocket error from viewer: {}", e);
|
||||
error!("WebSocket error from viewer {}: {}", viewer_id, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
// Cleanup (this sends StopStream to agent if last viewer)
|
||||
frame_forward.abort();
|
||||
sessions_cleanup.leave_session(session_id).await;
|
||||
info!("Viewer left session: {}", session_id);
|
||||
sessions_cleanup.leave_session(session_id, &viewer_id_cleanup).await;
|
||||
|
||||
// Database: log viewer left event
|
||||
if let Some(ref db) = db {
|
||||
let _ = db::events::log_event(
|
||||
db.pool(),
|
||||
session_id,
|
||||
db::events::EventTypes::VIEWER_LEFT,
|
||||
Some(&viewer_id_cleanup),
|
||||
Some(&viewer_name_cleanup),
|
||||
None, None,
|
||||
).await;
|
||||
}
|
||||
|
||||
info!("Viewer {} left session: {}", viewer_id_cleanup, session_id);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -14,6 +15,20 @@ pub type SessionId = Uuid;
|
||||
/// Unique identifier for an agent
|
||||
pub type AgentId = String;
|
||||
|
||||
/// Unique identifier for a viewer
|
||||
pub type ViewerId = String;
|
||||
|
||||
/// Information about a connected viewer/technician
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ViewerInfo {
|
||||
pub id: ViewerId,
|
||||
pub name: String,
|
||||
pub connected_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Heartbeat timeout (90 seconds - 3x the agent's 30 second interval)
|
||||
const HEARTBEAT_TIMEOUT_SECS: u64 = 90;
|
||||
|
||||
/// Session state
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
@@ -22,6 +37,20 @@ pub struct Session {
|
||||
pub agent_name: String,
|
||||
pub started_at: chrono::DateTime<chrono::Utc>,
|
||||
pub viewer_count: usize,
|
||||
pub viewers: Vec<ViewerInfo>, // List of connected technicians
|
||||
pub is_streaming: bool,
|
||||
pub is_online: bool, // Whether agent is currently connected
|
||||
pub is_persistent: bool, // Persistent agent (no support code) vs support session
|
||||
pub last_heartbeat: chrono::DateTime<chrono::Utc>,
|
||||
// Agent status info
|
||||
pub os_version: Option<String>,
|
||||
pub is_elevated: bool,
|
||||
pub uptime_secs: i64,
|
||||
pub display_count: i32,
|
||||
pub agent_version: Option<String>, // Agent software version
|
||||
pub organization: Option<String>, // Company/organization name
|
||||
pub site: Option<String>, // Site/location name
|
||||
pub tags: Vec<String>, // Tags for categorization
|
||||
}
|
||||
|
||||
/// Channel for sending frames from agent to viewers
|
||||
@@ -40,6 +69,10 @@ struct SessionData {
|
||||
/// Channel for input events (viewer -> agent)
|
||||
input_tx: InputSender,
|
||||
input_rx: Option<InputReceiver>,
|
||||
/// Map of connected viewers (id -> info)
|
||||
viewers: HashMap<ViewerId, ViewerInfo>,
|
||||
/// Instant for heartbeat tracking
|
||||
last_heartbeat_instant: Instant,
|
||||
}
|
||||
|
||||
/// Manages all active sessions
|
||||
@@ -58,26 +91,70 @@ impl SessionManager {
|
||||
}
|
||||
|
||||
/// Register a new agent and create a session
|
||||
pub async fn register_agent(&self, agent_id: AgentId, agent_name: String) -> (SessionId, FrameSender, InputReceiver) {
|
||||
/// If agent was previously connected (offline session exists), reuse that session
|
||||
pub async fn register_agent(&self, agent_id: AgentId, agent_name: String, is_persistent: bool) -> (SessionId, FrameSender, InputReceiver) {
|
||||
// Check if this agent already has an offline session (reconnecting)
|
||||
{
|
||||
let agents = self.agents.read().await;
|
||||
if let Some(&existing_session_id) = agents.get(&agent_id) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session_data) = sessions.get_mut(&existing_session_id) {
|
||||
if !session_data.info.is_online {
|
||||
// Reuse existing session - mark as online and create new channels
|
||||
tracing::info!("Agent {} reconnecting to existing session {}", agent_id, existing_session_id);
|
||||
|
||||
let (frame_tx, _) = broadcast::channel(16);
|
||||
let (input_tx, input_rx) = tokio::sync::mpsc::channel(64);
|
||||
|
||||
session_data.info.is_online = true;
|
||||
session_data.info.last_heartbeat = chrono::Utc::now();
|
||||
session_data.info.agent_name = agent_name; // Update name in case it changed
|
||||
session_data.frame_tx = frame_tx.clone();
|
||||
session_data.input_tx = input_tx;
|
||||
session_data.last_heartbeat_instant = Instant::now();
|
||||
|
||||
return (existing_session_id, frame_tx, input_rx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session
|
||||
let session_id = Uuid::new_v4();
|
||||
|
||||
// Create channels
|
||||
let (frame_tx, _) = broadcast::channel(16); // Buffer 16 frames
|
||||
let (input_tx, input_rx) = tokio::sync::mpsc::channel(64); // Buffer 64 input events
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let session = Session {
|
||||
id: session_id,
|
||||
agent_id: agent_id.clone(),
|
||||
agent_name,
|
||||
started_at: chrono::Utc::now(),
|
||||
started_at: now,
|
||||
viewer_count: 0,
|
||||
viewers: Vec::new(),
|
||||
is_streaming: false,
|
||||
is_online: true,
|
||||
is_persistent,
|
||||
last_heartbeat: now,
|
||||
os_version: None,
|
||||
is_elevated: false,
|
||||
uptime_secs: 0,
|
||||
display_count: 1,
|
||||
agent_version: None,
|
||||
organization: None,
|
||||
site: None,
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
let session_data = SessionData {
|
||||
info: session,
|
||||
frame_tx: frame_tx.clone(),
|
||||
input_tx,
|
||||
input_rx: None, // Will be taken by the agent handler
|
||||
input_rx: None,
|
||||
viewers: HashMap::new(),
|
||||
last_heartbeat_instant: Instant::now(),
|
||||
};
|
||||
|
||||
let mut sessions = self.sessions.write().await;
|
||||
@@ -89,6 +166,75 @@ impl SessionManager {
|
||||
(session_id, frame_tx, input_rx)
|
||||
}
|
||||
|
||||
/// Update agent status from heartbeat or status message
|
||||
pub async fn update_agent_status(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
os_version: Option<String>,
|
||||
is_elevated: bool,
|
||||
uptime_secs: i64,
|
||||
display_count: i32,
|
||||
is_streaming: bool,
|
||||
agent_version: Option<String>,
|
||||
organization: Option<String>,
|
||||
site: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session_data) = sessions.get_mut(&session_id) {
|
||||
session_data.info.last_heartbeat = chrono::Utc::now();
|
||||
session_data.last_heartbeat_instant = Instant::now();
|
||||
session_data.info.is_streaming = is_streaming;
|
||||
if let Some(os) = os_version {
|
||||
session_data.info.os_version = Some(os);
|
||||
}
|
||||
session_data.info.is_elevated = is_elevated;
|
||||
session_data.info.uptime_secs = uptime_secs;
|
||||
session_data.info.display_count = display_count;
|
||||
if let Some(version) = agent_version {
|
||||
session_data.info.agent_version = Some(version);
|
||||
}
|
||||
if let Some(org) = organization {
|
||||
session_data.info.organization = Some(org);
|
||||
}
|
||||
if let Some(s) = site {
|
||||
session_data.info.site = Some(s);
|
||||
}
|
||||
if !tags.is_empty() {
|
||||
session_data.info.tags = tags;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update heartbeat timestamp
|
||||
pub async fn update_heartbeat(&self, session_id: SessionId) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session_data) = sessions.get_mut(&session_id) {
|
||||
session_data.info.last_heartbeat = chrono::Utc::now();
|
||||
session_data.last_heartbeat_instant = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a session has timed out (no heartbeat for too long)
|
||||
pub async fn is_session_timed_out(&self, session_id: SessionId) -> bool {
|
||||
let sessions = self.sessions.read().await;
|
||||
if let Some(session_data) = sessions.get(&session_id) {
|
||||
session_data.last_heartbeat_instant.elapsed().as_secs() > HEARTBEAT_TIMEOUT_SECS
|
||||
} else {
|
||||
true // Non-existent sessions are considered timed out
|
||||
}
|
||||
}
|
||||
|
||||
/// Get sessions that have timed out
|
||||
pub async fn get_timed_out_sessions(&self) -> Vec<SessionId> {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions
|
||||
.iter()
|
||||
.filter(|(_, data)| data.last_heartbeat_instant.elapsed().as_secs() > HEARTBEAT_TIMEOUT_SECS)
|
||||
.map(|(id, _)| *id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get a session by agent ID
|
||||
pub async fn get_session_by_agent(&self, agent_id: &str) -> Option<Session> {
|
||||
let agents = self.agents.read().await;
|
||||
@@ -104,41 +250,205 @@ impl SessionManager {
|
||||
sessions.get(&session_id).map(|s| s.info.clone())
|
||||
}
|
||||
|
||||
/// Join a session as a viewer
|
||||
pub async fn join_session(&self, session_id: SessionId) -> Option<(FrameReceiver, InputSender)> {
|
||||
/// Join a session as a viewer, returns channels and sends StartStream to agent
|
||||
pub async fn join_session(&self, session_id: SessionId, viewer_id: ViewerId, viewer_name: String) -> Option<(FrameReceiver, InputSender)> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
let session_data = sessions.get_mut(&session_id)?;
|
||||
|
||||
session_data.info.viewer_count += 1;
|
||||
let was_empty = session_data.viewers.is_empty();
|
||||
|
||||
// Add viewer info
|
||||
let viewer_info = ViewerInfo {
|
||||
id: viewer_id.clone(),
|
||||
name: viewer_name.clone(),
|
||||
connected_at: chrono::Utc::now(),
|
||||
};
|
||||
session_data.viewers.insert(viewer_id.clone(), viewer_info);
|
||||
|
||||
// Update session info
|
||||
session_data.info.viewer_count = session_data.viewers.len();
|
||||
session_data.info.viewers = session_data.viewers.values().cloned().collect();
|
||||
|
||||
let frame_rx = session_data.frame_tx.subscribe();
|
||||
let input_tx = session_data.input_tx.clone();
|
||||
|
||||
// If this is the first viewer, send StartStream to agent
|
||||
if was_empty {
|
||||
tracing::info!("Viewer {} ({}) joined session {}, sending StartStream", viewer_name, viewer_id, session_id);
|
||||
Self::send_start_stream_internal(session_data, &viewer_id).await;
|
||||
} else {
|
||||
tracing::info!("Viewer {} ({}) joined session {}", viewer_name, viewer_id, session_id);
|
||||
}
|
||||
|
||||
Some((frame_rx, input_tx))
|
||||
}
|
||||
|
||||
/// Leave a session as a viewer
|
||||
pub async fn leave_session(&self, session_id: SessionId) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session_data) = sessions.get_mut(&session_id) {
|
||||
session_data.info.viewer_count = session_data.info.viewer_count.saturating_sub(1);
|
||||
/// Internal helper to send StartStream message
|
||||
async fn send_start_stream_internal(session_data: &SessionData, viewer_id: &str) {
|
||||
use crate::proto;
|
||||
use prost::Message;
|
||||
|
||||
let start_stream = proto::Message {
|
||||
payload: Some(proto::message::Payload::StartStream(proto::StartStream {
|
||||
viewer_id: viewer_id.to_string(),
|
||||
display_id: 0, // Primary display
|
||||
})),
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
if start_stream.encode(&mut buf).is_ok() {
|
||||
let _ = session_data.input_tx.send(buf).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a session (when agent disconnects)
|
||||
/// Leave a session as a viewer, sends StopStream if no viewers left
|
||||
pub async fn leave_session(&self, session_id: SessionId, viewer_id: &ViewerId) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session_data) = sessions.get_mut(&session_id) {
|
||||
let viewer_name = session_data.viewers.get(viewer_id).map(|v| v.name.clone());
|
||||
session_data.viewers.remove(viewer_id);
|
||||
session_data.info.viewer_count = session_data.viewers.len();
|
||||
session_data.info.viewers = session_data.viewers.values().cloned().collect();
|
||||
|
||||
// If no more viewers, send StopStream to agent
|
||||
if session_data.viewers.is_empty() {
|
||||
tracing::info!("Last viewer {} ({}) left session {}, sending StopStream",
|
||||
viewer_name.as_deref().unwrap_or("unknown"), viewer_id, session_id);
|
||||
Self::send_stop_stream_internal(session_data, viewer_id).await;
|
||||
} else {
|
||||
tracing::info!("Viewer {} ({}) left session {}",
|
||||
viewer_name.as_deref().unwrap_or("unknown"), viewer_id, session_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal helper to send StopStream message
|
||||
async fn send_stop_stream_internal(session_data: &SessionData, viewer_id: &str) {
|
||||
use crate::proto;
|
||||
use prost::Message;
|
||||
|
||||
let stop_stream = proto::Message {
|
||||
payload: Some(proto::message::Payload::StopStream(proto::StopStream {
|
||||
viewer_id: viewer_id.to_string(),
|
||||
})),
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
if stop_stream.encode(&mut buf).is_ok() {
|
||||
let _ = session_data.input_tx.send(buf).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark agent as disconnected
|
||||
/// For persistent agents: keep session but mark as offline
|
||||
/// For support sessions: remove session entirely
|
||||
pub async fn mark_agent_disconnected(&self, session_id: SessionId) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session_data) = sessions.get_mut(&session_id) {
|
||||
if session_data.info.is_persistent {
|
||||
// Persistent agent - keep session but mark as offline
|
||||
tracing::info!("Persistent agent {} marked offline (session {} preserved)",
|
||||
session_data.info.agent_id, session_id);
|
||||
session_data.info.is_online = false;
|
||||
session_data.info.is_streaming = false;
|
||||
session_data.info.viewer_count = 0;
|
||||
session_data.info.viewers.clear();
|
||||
session_data.viewers.clear();
|
||||
} else {
|
||||
// Support session - remove entirely
|
||||
let agent_id = session_data.info.agent_id.clone();
|
||||
sessions.remove(&session_id);
|
||||
drop(sessions); // Release sessions lock before acquiring agents lock
|
||||
let mut agents = self.agents.write().await;
|
||||
agents.remove(&agent_id);
|
||||
tracing::info!("Support session {} removed", session_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a session entirely (for cleanup)
|
||||
pub async fn remove_session(&self, session_id: SessionId) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session_data) = sessions.remove(&session_id) {
|
||||
drop(sessions);
|
||||
let mut agents = self.agents.write().await;
|
||||
agents.remove(&session_data.info.agent_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect a session by sending a disconnect message to the agent
|
||||
/// Returns true if the message was sent successfully
|
||||
pub async fn disconnect_session(&self, session_id: SessionId, reason: &str) -> bool {
|
||||
let sessions = self.sessions.read().await;
|
||||
if let Some(session_data) = sessions.get(&session_id) {
|
||||
// Create disconnect message
|
||||
use crate::proto;
|
||||
use prost::Message;
|
||||
|
||||
let disconnect_msg = proto::Message {
|
||||
payload: Some(proto::message::Payload::Disconnect(proto::Disconnect {
|
||||
reason: reason.to_string(),
|
||||
})),
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
if disconnect_msg.encode(&mut buf).is_ok() {
|
||||
// Send via input channel (will be forwarded to agent's WebSocket)
|
||||
if session_data.input_tx.send(buf).await.is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// List all active sessions
|
||||
pub async fn list_sessions(&self) -> Vec<Session> {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions.values().map(|s| s.info.clone()).collect()
|
||||
}
|
||||
|
||||
/// Send an admin command to an agent (uninstall, restart, etc.)
|
||||
/// Returns true if the message was sent successfully
|
||||
pub async fn send_admin_command(&self, session_id: SessionId, command: crate::proto::AdminCommandType, reason: &str) -> bool {
|
||||
let sessions = self.sessions.read().await;
|
||||
if let Some(session_data) = sessions.get(&session_id) {
|
||||
if !session_data.info.is_online {
|
||||
tracing::warn!("Cannot send admin command to offline agent");
|
||||
return false;
|
||||
}
|
||||
|
||||
use crate::proto;
|
||||
use prost::Message;
|
||||
|
||||
let admin_cmd = proto::Message {
|
||||
payload: Some(proto::message::Payload::AdminCommand(proto::AdminCommand {
|
||||
command: command as i32,
|
||||
reason: reason.to_string(),
|
||||
})),
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
if admin_cmd.encode(&mut buf).is_ok() {
|
||||
if session_data.input_tx.send(buf).await.is_ok() {
|
||||
tracing::info!("Sent admin command {:?} to session {}", command, session_id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Remove an agent/machine from the session manager (for deletion)
|
||||
/// Returns the agent_id if found
|
||||
pub async fn remove_agent(&self, agent_id: &str) -> Option<SessionId> {
|
||||
let agents = self.agents.read().await;
|
||||
let session_id = agents.get(agent_id).copied()?;
|
||||
drop(agents);
|
||||
|
||||
self.remove_session(session_id).await;
|
||||
Some(session_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionManager {
|
||||
@@ -146,3 +456,54 @@ impl Default for SessionManager {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
/// Restore a machine as an offline session (called on startup from database)
|
||||
pub async fn restore_offline_machine(&self, agent_id: &str, hostname: &str) -> SessionId {
|
||||
let session_id = Uuid::new_v4();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let session = Session {
|
||||
id: session_id,
|
||||
agent_id: agent_id.to_string(),
|
||||
agent_name: hostname.to_string(),
|
||||
started_at: now,
|
||||
viewer_count: 0,
|
||||
viewers: Vec::new(),
|
||||
is_streaming: false,
|
||||
is_online: false, // Offline until agent reconnects
|
||||
is_persistent: true,
|
||||
last_heartbeat: now,
|
||||
os_version: None,
|
||||
is_elevated: false,
|
||||
uptime_secs: 0,
|
||||
display_count: 1,
|
||||
agent_version: None,
|
||||
organization: None,
|
||||
site: None,
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
// Create placeholder channels (will be replaced on reconnect)
|
||||
let (frame_tx, _) = broadcast::channel(16);
|
||||
let (input_tx, input_rx) = tokio::sync::mpsc::channel(64);
|
||||
|
||||
let session_data = SessionData {
|
||||
info: session,
|
||||
frame_tx,
|
||||
input_tx,
|
||||
input_rx: Some(input_rx),
|
||||
viewers: HashMap::new(),
|
||||
last_heartbeat_instant: Instant::now(),
|
||||
};
|
||||
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(session_id, session_data);
|
||||
|
||||
let mut agents = self.agents.write().await;
|
||||
agents.insert(agent_id.to_string(), session_id);
|
||||
|
||||
tracing::info!("Restored offline machine: {} ({})", hostname, agent_id);
|
||||
session_id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,27 @@ impl SupportCodeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Link a support code to an actual WebSocket session
|
||||
pub async fn link_session(&self, code: &str, real_session_id: Uuid) {
|
||||
let mut codes = self.codes.write().await;
|
||||
if let Some(support_code) = codes.get_mut(code) {
|
||||
// Update session_to_code mapping with real session ID
|
||||
let old_session_id = support_code.session_id;
|
||||
support_code.session_id = real_session_id;
|
||||
|
||||
// Update the reverse mapping
|
||||
let mut session_to_code = self.session_to_code.write().await;
|
||||
session_to_code.remove(&old_session_id);
|
||||
session_to_code.insert(real_session_id, code.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Get code by its code string
|
||||
pub async fn get_code(&self, code: &str) -> Option<SupportCode> {
|
||||
let codes = self.codes.read().await;
|
||||
codes.get(code).cloned()
|
||||
}
|
||||
|
||||
/// Mark a code as completed
|
||||
pub async fn mark_completed(&self, code: &str) {
|
||||
let mut codes = self.codes.write().await;
|
||||
@@ -155,11 +176,11 @@ impl SupportCodeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a code
|
||||
/// Cancel a code (works for both pending and connected)
|
||||
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 {
|
||||
if support_code.status == CodeStatus::Pending || support_code.status == CodeStatus::Connected {
|
||||
support_code.status = CodeStatus::Cancelled;
|
||||
return true;
|
||||
}
|
||||
@@ -167,6 +188,18 @@ impl SupportCodeManager {
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a code is cancelled
|
||||
pub async fn is_cancelled(&self, code: &str) -> bool {
|
||||
let codes = self.codes.read().await;
|
||||
codes.get(code).map(|c| c.status == CodeStatus::Cancelled).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if a code is valid for connection (exists and is pending)
|
||||
pub async fn is_valid_for_connection(&self, code: &str) -> bool {
|
||||
let codes = self.codes.read().await;
|
||||
codes.get(code).map(|c| c.status == CodeStatus::Pending).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// List all codes (for dashboard)
|
||||
pub async fn list_codes(&self) -> Vec<SupportCode> {
|
||||
let codes = self.codes.read().await;
|
||||
@@ -186,10 +219,21 @@ impl SupportCodeManager {
|
||||
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()
|
||||
}
|
||||
|
||||
/// Get the status of a code as a string (for auth checks)
|
||||
pub async fn get_status(&self, code: &str) -> Option<String> {
|
||||
let codes = self.codes.read().await;
|
||||
codes.get(code).map(|c| match c.status {
|
||||
CodeStatus::Pending => "pending".to_string(),
|
||||
CodeStatus::Connected => "connected".to_string(),
|
||||
CodeStatus::Completed => "completed".to_string(),
|
||||
CodeStatus::Cancelled => "cancelled".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SupportCodeManager {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
BIN
server/static/downloads/guruconnect.exe
Normal file
BIN
server/static/downloads/guruconnect.exe
Normal file
Binary file not shown.
@@ -381,16 +381,26 @@
|
||||
// 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
|
||||
// Create a temporary link to download the agent
|
||||
// The agent will be run with the code as argument
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.href = '/guruconnect-agent.exe';
|
||||
downloadLink.download = 'GuruConnect-' + code + '.exe';
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
|
||||
// Show instructions with the code reminder
|
||||
setTimeout(() => {
|
||||
alert('Agent download will be available once the agent is built.\n\nSession ID: ' + sessionId);
|
||||
connectBtn.querySelector('.btn-text').textContent = 'Connect';
|
||||
}, 1000);
|
||||
connectBtn.querySelector('.btn-text').textContent = 'Run the Downloaded File';
|
||||
|
||||
// Update instructions to include the code
|
||||
instructionsList.innerHTML = getBrowserInstructions(detectBrowser()).map(step => '<li>' + step + '</li>').join('') +
|
||||
'<li><strong>Important:</strong> When prompted, enter code: <strong style="color: hsl(var(--primary)); font-size: 18px;">' + code + '</strong></li>';
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GuruConnect - Technician Login</title>
|
||||
<title>GuruConnect - Login</title>
|
||||
<style>
|
||||
:root {
|
||||
--background: 222.2 84% 4.9%;
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
label { font-size: 14px; font-weight: 500; color: hsl(var(--foreground)); }
|
||||
|
||||
input[type="email"], input[type="password"] {
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
@@ -119,40 +119,24 @@
|
||||
|
||||
@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>
|
||||
<p>Sign in to your account</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>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" placeholder="Enter your username" autocomplete="username" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" placeholder="Enter your password" required>
|
||||
<input type="password" id="password" placeholder="Enter your password" autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="errorMessage"></div>
|
||||
@@ -163,10 +147,6 @@
|
||||
</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>
|
||||
@@ -177,10 +157,29 @@
|
||||
const loginBtn = document.getElementById("loginBtn");
|
||||
const errorMessage = document.getElementById("errorMessage");
|
||||
|
||||
// Check if already logged in
|
||||
const token = localStorage.getItem("guruconnect_token");
|
||||
if (token) {
|
||||
// Verify token is still valid
|
||||
fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
}).then(res => {
|
||||
if (res.ok) {
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
localStorage.removeItem('guruconnect_token');
|
||||
localStorage.removeItem('guruconnect_user');
|
||||
}
|
||||
}).catch(() => {
|
||||
localStorage.removeItem('guruconnect_token');
|
||||
localStorage.removeItem('guruconnect_user');
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const email = document.getElementById("email").value;
|
||||
const username = document.getElementById("username").value;
|
||||
const password = document.getElementById("password").value;
|
||||
|
||||
setLoading(true);
|
||||
@@ -190,7 +189,7 @@
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -201,12 +200,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
// Store token and user info
|
||||
localStorage.setItem("guruconnect_token", data.token);
|
||||
localStorage.setItem("guruconnect_user", JSON.stringify(data.user));
|
||||
window.location.href = "/dashboard";
|
||||
|
||||
} catch (err) {
|
||||
showError("Auth not configured yet. Use the demo link below.");
|
||||
showError("Connection error. Please try again.");
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
@@ -222,9 +222,8 @@
|
||||
loginBtn.querySelector(".btn-text").textContent = loading ? "Signing in..." : "Sign In";
|
||||
}
|
||||
|
||||
if (localStorage.getItem("token")) {
|
||||
window.location.href = "/dashboard";
|
||||
}
|
||||
// Focus username field
|
||||
document.getElementById("username").focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
602
server/static/users.html
Normal file
602
server/static/users.html
Normal file
@@ -0,0 +1,602 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GuruConnect - User Management</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)); }
|
||||
.back-link { color: hsl(var(--muted-foreground)); text-decoration: none; font-size: 14px; }
|
||||
.back-link:hover { color: hsl(var(--foreground)); }
|
||||
|
||||
.content { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
.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)); }
|
||||
.btn-danger { background: hsl(var(--destructive)); color: white; }
|
||||
.btn-danger:hover { opacity: 0.9; }
|
||||
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
||||
|
||||
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); }
|
||||
|
||||
.badge { display: inline-block; padding: 4px 10px; font-size: 12px; font-weight: 500; border-radius: 9999px; }
|
||||
.badge-admin { background: hsla(270, 76%, 50%, 0.2); color: hsl(270, 76%, 60%); }
|
||||
.badge-operator { background: hsla(45, 93%, 47%, 0.2); color: hsl(45, 93%, 55%); }
|
||||
.badge-viewer { background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); }
|
||||
.badge-enabled { background: hsla(142, 76%, 36%, 0.2); color: hsl(142, 76%, 50%); }
|
||||
.badge-disabled { background: hsla(0, 70%, 50%, 0.2); color: hsl(0, 70%, 60%); }
|
||||
|
||||
.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)); }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
|
||||
.modal {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.modal-title { font-size: 18px; font-weight: 600; }
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
.modal-close:hover { color: hsl(var(--foreground)); }
|
||||
|
||||
.modal-body { padding: 20px; }
|
||||
.modal-footer { padding: 16px 20px; border-top: 1px solid hsl(var(--border)); display: flex; gap: 12px; justify-content: flex-end; }
|
||||
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 8px; }
|
||||
.form-group input, .form-group select {
|
||||
width: 100%;
|
||||
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);
|
||||
}
|
||||
|
||||
.permissions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.permission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.permission-item input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-bottom: 16px;
|
||||
display: none;
|
||||
}
|
||||
.error-message.visible { display: block; }
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
.loading-overlay.active { display: flex; }
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid hsl(var(--muted));
|
||||
border-top-color: hsl(var(--primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo">GuruConnect</div>
|
||||
<a href="/dashboard" class="back-link">← Back to Dashboard</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2 class="card-title">User Management</h2>
|
||||
<p class="card-description">Create and manage user accounts</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openCreateModal()">Create User</button>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="errorMessage"></div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Last Login</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTable">
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<div class="empty-state">
|
||||
<h3>Loading users...</h3>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Create/Edit User Modal -->
|
||||
<div class="modal-overlay" id="userModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="modalTitle">Create User</div>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="userForm">
|
||||
<input type="hidden" id="userId">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" required minlength="3">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="passwordGroup">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" minlength="8">
|
||||
<small style="color: hsl(var(--muted-foreground)); font-size: 12px;">Minimum 8 characters. Leave blank to keep existing password.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email (optional)</label>
|
||||
<input type="email" id="email">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="role">Role</label>
|
||||
<select id="role">
|
||||
<option value="viewer">Viewer - View only access</option>
|
||||
<option value="operator">Operator - Can control machines</option>
|
||||
<option value="admin">Admin - Full access</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="enabled" checked style="width: auto; margin-right: 8px;">
|
||||
Account Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Permissions</label>
|
||||
<div class="permissions-grid">
|
||||
<label class="permission-item">
|
||||
<input type="checkbox" id="perm-view" checked>
|
||||
View
|
||||
</label>
|
||||
<label class="permission-item">
|
||||
<input type="checkbox" id="perm-control">
|
||||
Control
|
||||
</label>
|
||||
<label class="permission-item">
|
||||
<input type="checkbox" id="perm-transfer">
|
||||
Transfer
|
||||
</label>
|
||||
<label class="permission-item">
|
||||
<input type="checkbox" id="perm-manage_users">
|
||||
Manage Users
|
||||
</label>
|
||||
<label class="permission-item">
|
||||
<input type="checkbox" id="perm-manage_clients">
|
||||
Manage Clients
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="formError"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveUser()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem("guruconnect_token");
|
||||
let users = [];
|
||||
let editingUser = null;
|
||||
|
||||
// Check auth
|
||||
if (!token) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
// Verify admin access
|
||||
async function checkAdmin() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/me", {
|
||||
headers: { "Authorization": `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
window.location.href = "/login";
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await response.json();
|
||||
if (user.role !== "admin") {
|
||||
alert("Admin access required");
|
||||
window.location.href = "/dashboard";
|
||||
return;
|
||||
}
|
||||
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
console.error("Auth check failed:", err);
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
checkAdmin();
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const response = await fetch("/api/users", {
|
||||
headers: { "Authorization": `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load users");
|
||||
}
|
||||
|
||||
users = await response.json();
|
||||
renderUsers();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
const tbody = document.getElementById("usersTable");
|
||||
|
||||
if (users.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><h3>No users found</h3></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = users.map(user => {
|
||||
const roleClass = user.role === "admin" ? "badge-admin" :
|
||||
user.role === "operator" ? "badge-operator" : "badge-viewer";
|
||||
const statusClass = user.enabled ? "badge-enabled" : "badge-disabled";
|
||||
const lastLogin = user.last_login ? new Date(user.last_login).toLocaleString() : "Never";
|
||||
|
||||
return `<tr>
|
||||
<td><strong>${escapeHtml(user.username)}</strong></td>
|
||||
<td>${escapeHtml(user.email || "-")}</td>
|
||||
<td><span class="badge ${roleClass}">${user.role}</span></td>
|
||||
<td><span class="badge ${statusClass}">${user.enabled ? "Enabled" : "Disabled"}</span></td>
|
||||
<td>${lastLogin}</td>
|
||||
<td>
|
||||
<button class="btn btn-outline btn-sm" onclick="editUser('${user.id}')">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteUser('${user.id}', '${escapeHtml(user.username)}')" style="margin-left: 4px;">Delete</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingUser = null;
|
||||
document.getElementById("modalTitle").textContent = "Create User";
|
||||
document.getElementById("userForm").reset();
|
||||
document.getElementById("userId").value = "";
|
||||
document.getElementById("username").disabled = false;
|
||||
document.getElementById("password").required = true;
|
||||
document.getElementById("perm-view").checked = true;
|
||||
document.getElementById("formError").classList.remove("visible");
|
||||
document.getElementById("userModal").classList.add("active");
|
||||
}
|
||||
|
||||
function editUser(id) {
|
||||
editingUser = users.find(u => u.id === id);
|
||||
if (!editingUser) return;
|
||||
|
||||
document.getElementById("modalTitle").textContent = "Edit User";
|
||||
document.getElementById("userId").value = editingUser.id;
|
||||
document.getElementById("username").value = editingUser.username;
|
||||
document.getElementById("username").disabled = true;
|
||||
document.getElementById("password").value = "";
|
||||
document.getElementById("password").required = false;
|
||||
document.getElementById("email").value = editingUser.email || "";
|
||||
document.getElementById("role").value = editingUser.role;
|
||||
document.getElementById("enabled").checked = editingUser.enabled;
|
||||
|
||||
// Set permissions
|
||||
["view", "control", "transfer", "manage_users", "manage_clients"].forEach(perm => {
|
||||
document.getElementById("perm-" + perm).checked = editingUser.permissions.includes(perm);
|
||||
});
|
||||
|
||||
document.getElementById("formError").classList.remove("visible");
|
||||
document.getElementById("userModal").classList.add("active");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById("userModal").classList.remove("active");
|
||||
editingUser = null;
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
const userId = document.getElementById("userId").value;
|
||||
const username = document.getElementById("username").value;
|
||||
const password = document.getElementById("password").value;
|
||||
const email = document.getElementById("email").value || null;
|
||||
const role = document.getElementById("role").value;
|
||||
const enabled = document.getElementById("enabled").checked;
|
||||
|
||||
const permissions = [];
|
||||
["view", "control", "transfer", "manage_users", "manage_clients"].forEach(perm => {
|
||||
if (document.getElementById("perm-" + perm).checked) {
|
||||
permissions.push(perm);
|
||||
}
|
||||
});
|
||||
|
||||
// Validation
|
||||
if (!username || username.length < 3) {
|
||||
showFormError("Username must be at least 3 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userId && (!password || password.length < 8)) {
|
||||
showFormError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (userId) {
|
||||
// Update existing user
|
||||
const updateData = { email, role, enabled };
|
||||
if (password) updateData.password = password;
|
||||
|
||||
response = await fetch("/api/users/" + userId, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
if (response.ok && permissions.length > 0) {
|
||||
// Update permissions separately
|
||||
await fetch("/api/users/" + userId + "/permissions", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ permissions })
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create new user
|
||||
response = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ username, password, email, role, permissions })
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Operation failed");
|
||||
}
|
||||
|
||||
closeModal();
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
showFormError(err.message);
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id, username) {
|
||||
if (!confirm(`Delete user "${username}"?\n\nThis action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/users/" + id, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Delete failed");
|
||||
}
|
||||
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const el = document.getElementById("errorMessage");
|
||||
el.textContent = message;
|
||||
el.classList.add("visible");
|
||||
}
|
||||
|
||||
function showFormError(message) {
|
||||
const el = document.getElementById("formError");
|
||||
el.textContent = message;
|
||||
el.classList.add("visible");
|
||||
}
|
||||
|
||||
function showLoading(show) {
|
||||
document.getElementById("loadingOverlay").classList.toggle("active", show);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
694
server/static/viewer.html
Normal file
694
server/static/viewer.html
Normal file
@@ -0,0 +1,694 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GuruConnect Viewer</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fzstd@0.1.1/umd/index.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
background: #16213e;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
background: #0f3460;
|
||||
color: #eee;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: #1a4a7a;
|
||||
}
|
||||
|
||||
.toolbar button.danger {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
.toolbar button.danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.toolbar .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar .status {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.toolbar .status.connected {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.toolbar .status.connecting {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.toolbar .status.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#viewer-canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.overlay-content .spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #333;
|
||||
border-top-color: #4caf50;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats span {
|
||||
color: #aaa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<button class="danger" onclick="disconnect()">Disconnect</button>
|
||||
<button onclick="toggleFullscreen()">Fullscreen</button>
|
||||
<button onclick="sendCtrlAltDel()">Ctrl+Alt+Del</button>
|
||||
<div class="spacer"></div>
|
||||
<div class="stats">
|
||||
<div>FPS: <span id="fps">0</span></div>
|
||||
<div>Resolution: <span id="resolution">-</span></div>
|
||||
<div>Frames: <span id="frame-count">0</span></div>
|
||||
</div>
|
||||
<div class="status connecting" id="status">Connecting...</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container" id="canvas-container">
|
||||
<canvas id="viewer-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="overlay">
|
||||
<div class="overlay-content">
|
||||
<div class="spinner"></div>
|
||||
<div id="overlay-text">Connecting to remote desktop...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Get session ID from URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const sessionId = urlParams.get('session_id');
|
||||
|
||||
if (!sessionId) {
|
||||
alert('No session ID provided');
|
||||
window.close();
|
||||
}
|
||||
|
||||
// Get viewer name from localStorage (same as dashboard)
|
||||
const user = JSON.parse(localStorage.getItem('guruconnect_user') || 'null');
|
||||
const viewerName = user?.name || user?.email || 'Technician';
|
||||
|
||||
// State
|
||||
let ws = null;
|
||||
let canvas = document.getElementById('viewer-canvas');
|
||||
let ctx = canvas.getContext('2d');
|
||||
let imageData = null;
|
||||
let frameCount = 0;
|
||||
let lastFpsTime = Date.now();
|
||||
let fpsFrames = 0;
|
||||
let remoteWidth = 0;
|
||||
let remoteHeight = 0;
|
||||
|
||||
// ============================================================
|
||||
// Protobuf Parsing Utilities
|
||||
// ============================================================
|
||||
|
||||
function parseVarint(buffer, offset) {
|
||||
let result = 0;
|
||||
let shift = 0;
|
||||
while (offset < buffer.length) {
|
||||
const byte = buffer[offset++];
|
||||
result |= (byte & 0x7f) << shift;
|
||||
if ((byte & 0x80) === 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
return { value: result, offset };
|
||||
}
|
||||
|
||||
function parseSignedVarint(buffer, offset) {
|
||||
const { value, offset: newOffset } = parseVarint(buffer, offset);
|
||||
// ZigZag decode
|
||||
return { value: (value >>> 1) ^ -(value & 1), offset: newOffset };
|
||||
}
|
||||
|
||||
function parseField(buffer, offset) {
|
||||
if (offset >= buffer.length) return null;
|
||||
const { value: tag, offset: newOffset } = parseVarint(buffer, offset);
|
||||
const fieldNumber = tag >>> 3;
|
||||
const wireType = tag & 0x7;
|
||||
return { fieldNumber, wireType, offset: newOffset };
|
||||
}
|
||||
|
||||
function skipField(buffer, offset, wireType) {
|
||||
switch (wireType) {
|
||||
case 0: // Varint
|
||||
while (offset < buffer.length && (buffer[offset++] & 0x80)) {}
|
||||
return offset;
|
||||
case 1: // 64-bit
|
||||
return offset + 8;
|
||||
case 2: // Length-delimited
|
||||
const { value: len, offset: newOffset } = parseVarint(buffer, offset);
|
||||
return newOffset + len;
|
||||
case 5: // 32-bit
|
||||
return offset + 4;
|
||||
default:
|
||||
throw new Error(`Unknown wire type: ${wireType}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLengthDelimited(buffer, offset) {
|
||||
const { value: len, offset: dataStart } = parseVarint(buffer, offset);
|
||||
const data = buffer.slice(dataStart, dataStart + len);
|
||||
return { data, offset: dataStart + len };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VideoFrame Parsing
|
||||
// ============================================================
|
||||
|
||||
function parseVideoFrame(data) {
|
||||
const buffer = new Uint8Array(data);
|
||||
let offset = 0;
|
||||
|
||||
// Parse Message wrapper
|
||||
let videoFrameData = null;
|
||||
|
||||
while (offset < buffer.length) {
|
||||
const field = parseField(buffer, offset);
|
||||
if (!field) break;
|
||||
offset = field.offset;
|
||||
|
||||
if (field.fieldNumber === 10 && field.wireType === 2) {
|
||||
// video_frame field
|
||||
const { data: vfData, offset: newOffset } = parseLengthDelimited(buffer, offset);
|
||||
videoFrameData = vfData;
|
||||
offset = newOffset;
|
||||
} else {
|
||||
offset = skipField(buffer, offset, field.wireType);
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoFrameData) return null;
|
||||
|
||||
// Parse VideoFrame
|
||||
let rawFrameData = null;
|
||||
offset = 0;
|
||||
|
||||
while (offset < videoFrameData.length) {
|
||||
const field = parseField(videoFrameData, offset);
|
||||
if (!field) break;
|
||||
offset = field.offset;
|
||||
|
||||
if (field.fieldNumber === 10 && field.wireType === 2) {
|
||||
// raw frame (oneof encoding = 10)
|
||||
const { data: rfData, offset: newOffset } = parseLengthDelimited(videoFrameData, offset);
|
||||
rawFrameData = rfData;
|
||||
offset = newOffset;
|
||||
} else {
|
||||
offset = skipField(videoFrameData, offset, field.wireType);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawFrameData) return null;
|
||||
|
||||
// Parse RawFrame
|
||||
let width = 0, height = 0, compressedData = null, isKeyframe = true;
|
||||
offset = 0;
|
||||
|
||||
while (offset < rawFrameData.length) {
|
||||
const field = parseField(rawFrameData, offset);
|
||||
if (!field) break;
|
||||
offset = field.offset;
|
||||
|
||||
switch (field.fieldNumber) {
|
||||
case 1: // width
|
||||
const w = parseVarint(rawFrameData, offset);
|
||||
width = w.value;
|
||||
offset = w.offset;
|
||||
break;
|
||||
case 2: // height
|
||||
const h = parseVarint(rawFrameData, offset);
|
||||
height = h.value;
|
||||
offset = h.offset;
|
||||
break;
|
||||
case 3: // data (compressed BGRA)
|
||||
const d = parseLengthDelimited(rawFrameData, offset);
|
||||
compressedData = d.data;
|
||||
offset = d.offset;
|
||||
break;
|
||||
case 6: // is_keyframe
|
||||
const k = parseVarint(rawFrameData, offset);
|
||||
isKeyframe = k.value !== 0;
|
||||
offset = k.offset;
|
||||
break;
|
||||
default:
|
||||
offset = skipField(rawFrameData, offset, field.wireType);
|
||||
}
|
||||
}
|
||||
|
||||
return { width, height, compressedData, isKeyframe };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Frame Rendering
|
||||
// ============================================================
|
||||
|
||||
function renderFrame(frame) {
|
||||
if (!frame || !frame.compressedData || frame.width === 0 || frame.height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Decompress using fzstd
|
||||
const decompressed = fzstd.decompress(frame.compressedData);
|
||||
|
||||
// Resize canvas if needed
|
||||
if (canvas.width !== frame.width || canvas.height !== frame.height) {
|
||||
canvas.width = frame.width;
|
||||
canvas.height = frame.height;
|
||||
remoteWidth = frame.width;
|
||||
remoteHeight = frame.height;
|
||||
imageData = ctx.createImageData(frame.width, frame.height);
|
||||
document.getElementById('resolution').textContent = `${frame.width}x${frame.height}`;
|
||||
}
|
||||
|
||||
// Convert BGRA to RGBA
|
||||
const pixels = imageData.data;
|
||||
for (let i = 0; i < decompressed.length; i += 4) {
|
||||
pixels[i] = decompressed[i + 2]; // R <- B
|
||||
pixels[i + 1] = decompressed[i + 1]; // G
|
||||
pixels[i + 2] = decompressed[i]; // B <- R
|
||||
pixels[i + 3] = 255; // A (force opaque)
|
||||
}
|
||||
|
||||
// Draw to canvas
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Update stats
|
||||
frameCount++;
|
||||
fpsFrames++;
|
||||
document.getElementById('frame-count').textContent = frameCount;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastFpsTime >= 1000) {
|
||||
document.getElementById('fps').textContent = fpsFrames;
|
||||
fpsFrames = 0;
|
||||
lastFpsTime = now;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Frame render error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Input Event Encoding
|
||||
// ============================================================
|
||||
|
||||
function encodeVarint(value) {
|
||||
const bytes = [];
|
||||
while (value > 0x7f) {
|
||||
bytes.push((value & 0x7f) | 0x80);
|
||||
value >>>= 7;
|
||||
}
|
||||
bytes.push(value & 0x7f);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function encodeSignedVarint(value) {
|
||||
// ZigZag encode
|
||||
const zigzag = (value << 1) ^ (value >> 31);
|
||||
return encodeVarint(zigzag >>> 0);
|
||||
}
|
||||
|
||||
function encodeMouseEvent(x, y, buttons, eventType, wheelDeltaX = 0, wheelDeltaY = 0) {
|
||||
// Build MouseEvent message
|
||||
const mouseEvent = [];
|
||||
|
||||
// Field 1: x (varint)
|
||||
mouseEvent.push(0x08); // field 1, wire type 0
|
||||
mouseEvent.push(...encodeVarint(Math.round(x)));
|
||||
|
||||
// Field 2: y (varint)
|
||||
mouseEvent.push(0x10); // field 2, wire type 0
|
||||
mouseEvent.push(...encodeVarint(Math.round(y)));
|
||||
|
||||
// Field 3: buttons (embedded message)
|
||||
if (buttons) {
|
||||
const buttonsMsg = [];
|
||||
if (buttons.left) { buttonsMsg.push(0x08, 0x01); } // field 1 = true
|
||||
if (buttons.right) { buttonsMsg.push(0x10, 0x01); } // field 2 = true
|
||||
if (buttons.middle) { buttonsMsg.push(0x18, 0x01); } // field 3 = true
|
||||
|
||||
if (buttonsMsg.length > 0) {
|
||||
mouseEvent.push(0x1a); // field 3, wire type 2
|
||||
mouseEvent.push(...encodeVarint(buttonsMsg.length));
|
||||
mouseEvent.push(...buttonsMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Field 4: wheel_delta_x (sint32)
|
||||
if (wheelDeltaX !== 0) {
|
||||
mouseEvent.push(0x20); // field 4, wire type 0
|
||||
mouseEvent.push(...encodeSignedVarint(wheelDeltaX));
|
||||
}
|
||||
|
||||
// Field 5: wheel_delta_y (sint32)
|
||||
if (wheelDeltaY !== 0) {
|
||||
mouseEvent.push(0x28); // field 5, wire type 0
|
||||
mouseEvent.push(...encodeSignedVarint(wheelDeltaY));
|
||||
}
|
||||
|
||||
// Field 6: event_type (enum)
|
||||
mouseEvent.push(0x30); // field 6, wire type 0
|
||||
mouseEvent.push(eventType);
|
||||
|
||||
// Wrap in Message with field 20
|
||||
const message = [];
|
||||
message.push(0xa2, 0x01); // field 20, wire type 2 (20 << 3 | 2 = 162 = 0xa2, then 0x01)
|
||||
message.push(...encodeVarint(mouseEvent.length));
|
||||
message.push(...mouseEvent);
|
||||
|
||||
return new Uint8Array(message);
|
||||
}
|
||||
|
||||
function encodeKeyEvent(vkCode, down) {
|
||||
// Build KeyEvent message
|
||||
const keyEvent = [];
|
||||
|
||||
// Field 1: down (bool)
|
||||
keyEvent.push(0x08); // field 1, wire type 0
|
||||
keyEvent.push(down ? 0x01 : 0x00);
|
||||
|
||||
// Field 3: vk_code (uint32)
|
||||
keyEvent.push(0x18); // field 3, wire type 0
|
||||
keyEvent.push(...encodeVarint(vkCode));
|
||||
|
||||
// Wrap in Message with field 21
|
||||
const message = [];
|
||||
message.push(0xaa, 0x01); // field 21, wire type 2 (21 << 3 | 2 = 170 = 0xaa, then 0x01)
|
||||
message.push(...encodeVarint(keyEvent.length));
|
||||
message.push(...keyEvent);
|
||||
|
||||
return new Uint8Array(message);
|
||||
}
|
||||
|
||||
function encodeSpecialKey(keyType) {
|
||||
// Build SpecialKeyEvent message
|
||||
const specialKey = [];
|
||||
specialKey.push(0x08); // field 1, wire type 0
|
||||
specialKey.push(keyType); // 0 = CTRL_ALT_DEL
|
||||
|
||||
// Wrap in Message with field 22
|
||||
const message = [];
|
||||
message.push(0xb2, 0x01); // field 22, wire type 2
|
||||
message.push(...encodeVarint(specialKey.length));
|
||||
message.push(...specialKey);
|
||||
|
||||
return new Uint8Array(message);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Mouse/Keyboard Event Handlers
|
||||
// ============================================================
|
||||
|
||||
const MOUSE_MOVE = 0;
|
||||
const MOUSE_DOWN = 1;
|
||||
const MOUSE_UP = 2;
|
||||
const MOUSE_WHEEL = 3;
|
||||
|
||||
let lastMouseX = 0;
|
||||
let lastMouseY = 0;
|
||||
let mouseThrottle = 0;
|
||||
|
||||
function getMousePosition(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = remoteWidth / rect.width;
|
||||
const scaleY = remoteHeight / rect.height;
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY
|
||||
};
|
||||
}
|
||||
|
||||
function getButtons(e) {
|
||||
return {
|
||||
left: (e.buttons & 1) !== 0,
|
||||
right: (e.buttons & 2) !== 0,
|
||||
middle: (e.buttons & 4) !== 0
|
||||
};
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (remoteWidth === 0) return;
|
||||
|
||||
// Throttle to ~60 events/sec
|
||||
const now = Date.now();
|
||||
if (now - mouseThrottle < 16) return;
|
||||
mouseThrottle = now;
|
||||
|
||||
const pos = getMousePosition(e);
|
||||
const msg = encodeMouseEvent(pos.x, pos.y, getButtons(e), MOUSE_MOVE);
|
||||
ws.send(msg);
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
e.preventDefault();
|
||||
canvas.focus();
|
||||
|
||||
const pos = getMousePosition(e);
|
||||
const buttons = { left: e.button === 0, right: e.button === 2, middle: e.button === 1 };
|
||||
const msg = encodeMouseEvent(pos.x, pos.y, buttons, MOUSE_DOWN);
|
||||
ws.send(msg);
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
e.preventDefault();
|
||||
|
||||
const pos = getMousePosition(e);
|
||||
const buttons = { left: e.button === 0, right: e.button === 2, middle: e.button === 1 };
|
||||
const msg = encodeMouseEvent(pos.x, pos.y, buttons, MOUSE_UP);
|
||||
ws.send(msg);
|
||||
});
|
||||
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
e.preventDefault();
|
||||
|
||||
const pos = getMousePosition(e);
|
||||
const msg = encodeMouseEvent(pos.x, pos.y, null, MOUSE_WHEEL,
|
||||
Math.round(-e.deltaX), Math.round(-e.deltaY));
|
||||
ws.send(msg);
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
// Keyboard events
|
||||
canvas.setAttribute('tabindex', '0');
|
||||
|
||||
canvas.addEventListener('keydown', (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
e.preventDefault();
|
||||
|
||||
// Use keyCode for virtual key mapping
|
||||
const vkCode = e.keyCode;
|
||||
const msg = encodeKeyEvent(vkCode, true);
|
||||
ws.send(msg);
|
||||
});
|
||||
|
||||
canvas.addEventListener('keyup', (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
e.preventDefault();
|
||||
|
||||
const vkCode = e.keyCode;
|
||||
const msg = encodeKeyEvent(vkCode, false);
|
||||
ws.send(msg);
|
||||
});
|
||||
|
||||
// Focus canvas on click
|
||||
canvas.addEventListener('click', () => canvas.focus());
|
||||
|
||||
// ============================================================
|
||||
// WebSocket Connection
|
||||
// ============================================================
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = localStorage.getItem('guruconnect_token');
|
||||
if (!token) {
|
||||
updateStatus('error', 'Not authenticated');
|
||||
document.getElementById('overlay-text').textContent = 'Not logged in. Please log in first.';
|
||||
return;
|
||||
}
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}&token=${encodeURIComponent(token)}`;
|
||||
|
||||
console.log('Connecting to:', wsUrl);
|
||||
updateStatus('connecting', 'Connecting...');
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
updateStatus('connected', 'Connected');
|
||||
document.getElementById('overlay').classList.add('hidden');
|
||||
canvas.focus();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const frame = parseVideoFrame(event.data);
|
||||
if (frame) {
|
||||
renderFrame(frame);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('WebSocket closed:', event.code, event.reason);
|
||||
updateStatus('error', 'Disconnected');
|
||||
document.getElementById('overlay').classList.remove('hidden');
|
||||
document.getElementById('overlay-text').textContent = 'Connection closed. Reconnecting...';
|
||||
|
||||
// Reconnect after 2 seconds
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
updateStatus('error', 'Connection error');
|
||||
};
|
||||
}
|
||||
|
||||
function updateStatus(state, text) {
|
||||
const status = document.getElementById('status');
|
||||
status.className = 'status ' + state;
|
||||
status.textContent = text;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Toolbar Actions
|
||||
// ============================================================
|
||||
|
||||
function disconnect() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function sendCtrlAltDel() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const msg = encodeSpecialKey(0); // CTRL_ALT_DEL = 0
|
||||
ws.send(msg);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Initialization
|
||||
// ============================================================
|
||||
|
||||
// Set window title
|
||||
document.title = `GuruConnect - Session ${sessionId.substring(0, 8)}`;
|
||||
|
||||
// Connect on load
|
||||
connect();
|
||||
|
||||
// Handle window close
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (ws) ws.close();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
134
session-logs/2025-12-29-session.md
Normal file
134
session-logs/2025-12-29-session.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# GuruConnect Session Log - 2025-12-29
|
||||
|
||||
## Session Summary
|
||||
|
||||
### What Was Accomplished
|
||||
1. **Cleaned up stale persistent sessions** - Deleted 12 offline machines from PostgreSQL database
|
||||
2. **Added machine deletion API with uninstall support** - Implemented full machine management endpoints
|
||||
3. **Added AdminCommand protobuf message** - For server-to-agent commands (uninstall, restart, update)
|
||||
4. **Implemented machine history export** - Sessions and events can be exported before deletion
|
||||
|
||||
### Key Decisions
|
||||
- Machine deletion has two modes:
|
||||
- **Delete Only** (`DELETE /api/machines/:agent_id`) - Removes from DB, allows re-registration
|
||||
- **Delete with Uninstall** (`DELETE /api/machines/:agent_id?uninstall=true`) - Sends uninstall command to agent if online
|
||||
- History export available via `?export=true` query param or separate endpoint
|
||||
- AdminCommand message types: ADMIN_UNINSTALL, ADMIN_RESTART, ADMIN_UPDATE
|
||||
|
||||
### Problems Encountered
|
||||
- Server endpoint returning 404 - new binary may not have been properly deployed
|
||||
- Cross-compilation issues with ring crate for Windows MSVC on Linux
|
||||
|
||||
---
|
||||
|
||||
## Credentials
|
||||
|
||||
### GuruConnect Database (PostgreSQL)
|
||||
- **Host:** 172.16.3.30 (localhost from server)
|
||||
- **Database:** guruconnect
|
||||
- **User:** guruconnect
|
||||
- **Password:** gc_a7f82d1e4b9c3f60
|
||||
- **DATABASE_URL:** `postgres://guruconnect:gc_a7f82d1e4b9c3f60@localhost:5432/guruconnect`
|
||||
|
||||
### Build Server SSH
|
||||
- **Host:** 172.16.3.30
|
||||
- **User:** guru
|
||||
- **Password:** Gptf*77ttb123!@#-rmm
|
||||
- **Sudo Password:** Gptf*77ttb123!@#-rmm
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
### GuruConnect Server
|
||||
- **Host:** 172.16.3.30
|
||||
- **Port:** 3002
|
||||
- **Binary:** `/home/guru/guru-connect/target/release/guruconnect-server`
|
||||
- **Service:** guruconnect.service (systemd)
|
||||
- **Log:** ~/gc-server.log
|
||||
|
||||
### API Endpoints (NEW)
|
||||
```
|
||||
GET /api/machines - List all persistent machines
|
||||
GET /api/machines/:agent_id - Get machine info
|
||||
GET /api/machines/:agent_id/history - Get full session/event history
|
||||
DELETE /api/machines/:agent_id - Delete machine
|
||||
Query params:
|
||||
?uninstall=true - Send uninstall command to agent
|
||||
?export=true - Include history in response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Protobuf Schema
|
||||
- `proto/guruconnect.proto` - Added AdminCommand message and AdminCommandType enum
|
||||
|
||||
### Server Changes
|
||||
- `server/src/main.rs` - Added machine API routes and handlers
|
||||
- `server/src/api/mod.rs` - Added MachineInfo, MachineHistory, DeleteMachineParams types
|
||||
- `server/src/db/machines.rs` - Existing delete_machine function used
|
||||
- `server/src/db/sessions.rs` - Added get_sessions_for_machine()
|
||||
- `server/src/db/events.rs` - Added get_events_for_machine()
|
||||
- `server/src/session/mod.rs` - Added send_admin_command() and remove_agent() methods
|
||||
|
||||
### Agent Changes
|
||||
- `agent/src/session/mod.rs` - Added AdminCommand message handler
|
||||
- `agent/src/main.rs` - Added ADMIN_UNINSTALL and ADMIN_RESTART error handlers
|
||||
|
||||
---
|
||||
|
||||
## Important Commands
|
||||
|
||||
### Query/Delete Machines from PostgreSQL
|
||||
```bash
|
||||
# Query all machines
|
||||
ssh guru@172.16.3.30 'PGPASSWORD=gc_a7f82d1e4b9c3f60 psql -h localhost -U guruconnect -d guruconnect -c "SELECT agent_id, hostname, status FROM connect_machines;"'
|
||||
|
||||
# Delete all offline machines
|
||||
ssh guru@172.16.3.30 'PGPASSWORD=gc_a7f82d1e4b9c3f60 psql -h localhost -U guruconnect -d guruconnect -c "DELETE FROM connect_machines WHERE status = '\''offline'\'';"'
|
||||
```
|
||||
|
||||
### Build Server
|
||||
```bash
|
||||
# Build for Linux
|
||||
ssh guru@172.16.3.30 'cd ~/guru-connect && source ~/.cargo/env && cargo build -p guruconnect-server --release --target x86_64-unknown-linux-gnu'
|
||||
|
||||
# Restart server
|
||||
ssh guru@172.16.3.30 'pkill -f guruconnect-server; cd ~/guru-connect/server && DATABASE_URL="postgres://guruconnect:gc_a7f82d1e4b9c3f60@localhost:5432/guruconnect" nohup ~/guru-connect/target/release/guruconnect-server > ~/gc-server.log 2>&1 &'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pending Tasks
|
||||
|
||||
1. **Debug 404 on /api/machines endpoint** - The new routes aren't being recognized
|
||||
- May need to verify the correct binary is being executed
|
||||
- Check if old process is still running on port 3002
|
||||
|
||||
2. **Test machine deletion flow end-to-end**
|
||||
- Connect an agent
|
||||
- Delete with uninstall flag
|
||||
- Verify agent receives command and uninstalls
|
||||
|
||||
3. **Build Windows agent binary** - Cross-compilation needs MSVC tools or use Windows build
|
||||
|
||||
---
|
||||
|
||||
## Git Status
|
||||
|
||||
Committed and pushed:
|
||||
```
|
||||
commit dc7b742: Add machine deletion API with uninstall command support
|
||||
- 8 files changed, 380 insertions(+), 6 deletions(-)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps for Future Sessions
|
||||
|
||||
1. Investigate why `/api/machines` returns 404 - likely old binary running
|
||||
2. Use systemd properly for server management (need root access)
|
||||
3. Build and test Windows agent with uninstall command handling
|
||||
4. Add dashboard UI for machine management (list, delete with options)
|
||||
Reference in New Issue
Block a user