19 Commits

Author SHA1 Message Date
guruconnect-ci
e967cce1a1 chore: release v0.3.0 [skip ci] 2026-06-01 00:10:58 +00:00
16586c4a1b chore: reconcile manifest versions to v0.2.2 baseline
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m14s
Build and Test / Build Server (Linux) (push) Successful in 11m25s
Build and Test / Security Audit (push) Successful in 7m13s
Build and Test / Build Summary (push) Successful in 1m2s
agent + server Cargo.toml hardcoded 0.2.0 (below the workspace.package
0.2.2 and the last release tag v0.2.2); dashboard was on a divergent
2.0.0 scheme. Align all component manifests + the dashboard lockfile to
the v0.2.2 baseline so the next release bumps them coherently to 0.3.0
rather than decreasing the dashboard. No code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:50:59 -07:00
96f9c0ab45 feat(dashboard): operator removal UI for stale machines/sessions (SPEC-004 Task 5)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m13s
Build and Test / Build Server (Linux) (push) Successful in 11m21s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 11s
Admin-only per-row Remove + multi-select bulk removal on the machines view, plus
per-row purge Remove on the sessions view, wired to the Task-5 admin API
(DELETE /api/machines|sessions/:id?purge=true, POST /api/machines/bulk-remove).
Confirm modals (danger-styled, focus-trapped), TanStack refetch so purged rows
leave the console, structured ApiError surfacing, honest partial-bulk summary,
and admin-gating via useAuth().isAdmin as defense-in-depth over the server 403.
Replaces the legacy all-user delete trigger. typecheck/lint/build clean.

Implements specs/v2-stable-identity/plan.md Task 5 (dashboard portion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:14:49 -07:00
5ee6675337 feat(server): operator removal of stale sessions/machines (SPEC-004 Task 5, server)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m29s
Build and Test / Build Server (Linux) (push) Successful in 10m58s
Build and Test / Security Audit (push) Successful in 4m4s
Build and Test / Build Summary (push) Successful in 8s
Admin-gated soft-delete + purge so operators can clear ghost machines/sessions
(the ~15-rows-for-one-host accumulation) from the console.

- migration 009: deleted_at on connect_sessions + connect_machines, with partial
  indexes WHERE deleted_at IS NULL.
- DELETE /api/machines/:agent_id?purge=true and DELETE /api/sessions/:id?purge=true
  soft-delete the row and purge the in-memory session (remove_session); the
  non-purge path keeps the legacy hard-delete / live-only disconnect. POST
  /api/machines/bulk-remove handles multi-select (batch cap 500). All admin-gated
  (AdminUser -> 403; tightens the prior any-user delete) and audited to
  connect_session_events (actor + target + trusted client IP).
- list/get queries filter deleted_at IS NULL so removed units leave the console;
  upsert revives (deleted_at = NULL) a genuinely-reconnecting machine. The
  keyed-reattach identity resolver (get_machine_by_id) is intentionally unfiltered.

Dashboard removal UI is the A3b follow-up. 86 server tests pass; fmt/clippy/test
clean. Implements specs/v2-stable-identity/plan.md Task 5 (server portion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:52:36 -07:00
cef1928379 style(server): cargo fmt for SPEC-004 Task 2 + Task 4
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m40s
Build and Test / Build Server (Linux) (push) Successful in 10m18s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 12s
Pure rustfmt reflow of the Task 2 (machine_uid dedup) and Task 4 (session
reaping) code; no logic change. The CI Build-Server-Linux job gates on
cargo fmt --check, which the two feature commits failed because local
validation ran check/clippy/test but not fmt --check. fmt --check, check,
and clippy -D warnings all clean now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:27:01 -07:00
4e80573cbd feat(server): reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4)
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m32s
Build and Test / Build Agent (Windows) (push) Has started running
Build and Test / Security Audit (push) Has started running
Build and Test / Build Summary (push) Has been cancelled
A periodic reaper removes persistent, offline, viewerless sessions whose last
heartbeat is older than a 10-minute TTL (60s sweep spawned at startup), and a
same-machine supersede on the new-session path drops a stranded prior session
when a legacy no-uid agent upgrades to a fresh agent_id + machine_uid. Both
removals re-assert the predicate under the write lock (remove_session_if) to
close a snapshot->remove TOCTOU.

Security: keyed (cak_) agents pass machine_uid=None, so they never trigger
supersede and are never reaped as a uid victim; online, viewer-attached, and
support sessions are never reaped. 82 server tests pass; clippy clean.

Implements specs/v2-stable-identity/plan.md Task 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:21:15 -07:00
ffca7f0cee feat(server): dedup machines on machine_uid (SPEC-004 Task 2)
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m48s
Build and Test / Build Agent (Windows) (push) Successful in 7m34s
Build and Test / Security Audit (push) Successful in 4m44s
Build and Test / Build Summary (push) Has been skipped
Persist the agent-reported machine_uid and dedup connect_machines on it so a
single physical machine can't register duplicate rows when its config-file
agent_id regenerates (the ghost-session root cause).

- migration 008: nullable connect_machines.machine_uid + partial unique index
  (WHERE machine_uid IS NOT NULL); idempotent, startup-applied.
- upsert_machine: two-path dedup (ON CONFLICT machine_uid when present, else
  the legacy ON CONFLICT agent_id path, unchanged).
- session reattach: a machine_uid index consulted before agent_id, with all
  removal paths purging it.
- security: keyed (cak_) agents stay authoritative — their claimed machine_uid
  is dropped (effective_machine_uid=None); uid is dedup-only for un-keyed /
  support-code agents. Startup restore skips uid-indexing keyed machines and
  fails closed if the keyed-set query errors.

74 server tests pass; clippy clean. Implements specs/v2-stable-identity/plan.md Task 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:06:50 -07:00
97780304e7 fix(agent): make native H.264 viewer render live frames
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m2s
Build and Test / Build Server (Linux) (push) Successful in 10m24s
Build and Test / Security Audit (push) Successful in 4m15s
Build and Test / Build Summary (push) Successful in 9s
The native viewer's H.264 path (Task 7 first-cut, compile-verified only)
never rendered a frame. Three stacked bugs, all confirmed via live loopback:

1. decoder: MF_E_NOTACCEPTING (0xC00D36B5) was treated as fatal and only
   one output was drained per call, so once the MFT filled it rejected
   every subsequent frame. decode() now returns Vec<DecodedFrame>, drains
   on back-pressure and retries the unconsumed sample, then drains all
   ready outputs.
2. decoder: the NV12 output type was hand-built and rejected by the MS
   H.264 decoder MFT (MF_E_TRANSFORM_TYPE_NOT_SET, 0xC00D6D60). It is now
   negotiated by enumerating GetOutputAvailableType on STREAM_CHANGE /
   TYPE_NOT_SET.
3. render: a manual pump_messages() in about_to_wait stole winit's own
   thread messages and froze the event loop after one iteration, so frames
   were never drained from the channel. Removed; winit's run_app pump
   already services the WH_KEYBOARD_LL hook.

Validated on a 5070 loopback: 0 decode errors, frames decode/paint/present
(present count 0 -> 1740). Reviewed (APPROVE-WITH-NITS); diagnostics stripped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:25:05 -07:00
afbf0d81b8 spec: add SPEC-015 Configurable Notification Overlay
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 8m0s
Build and Test / Build Server (Linux) (push) Successful in 11m26s
Build and Test / Security Audit (push) Successful in 4m37s
Build and Test / Build Summary (push) Successful in 12s
Comprehensive specification for on-screen notification when technician connects.

- Semi-transparent topmost window with configurable message, position, duration
- Dashboard admin settings page (enable/disable, message template, position, duration)
- Template variables: {{technician_name}}, {{company}}, {{time}}
- Agent displays overlay on StartStream, auto-hides after duration or manual dismiss
- Database: notification_config singleton table
- Protobuf: NotificationConfig message in StartStream
- Priority: P2, Effort: Medium (3-4 weeks)
- Added to roadmap under Core Remote Control

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-31 08:40:53 -07:00
b45c683a51 spec: add SPEC-014 Branding and White-Label Configuration
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 8m16s
Build and Test / Build Server (Linux) (push) Successful in 11m48s
Build and Test / Security Audit (push) Successful in 4m35s
Build and Test / Build Summary (push) Successful in 13s
Comprehensive specification for branding/whitelabel configuration.

- Dashboard admin settings page (logo, brand hue, product name, company name, favicon)
- OKLCH color system with CSS variables for dynamic theming
- Agent tray tooltip customization via registry key
- Singleton database table with public GET endpoint
- Priority: P2, Effort: Medium (4-6 weeks)
- Added to roadmap under Server/API (v2 Phase 2)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-31 08:12:37 -07:00
5637e4c1f9 spec: add SPEC-013 Windows Session Selection and Backstage Mode
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 8m5s
Build and Test / Build Server (Linux) (push) Successful in 11m24s
Build and Test / Security Audit (push) Successful in 4m30s
Build and Test / Build Summary (push) Successful in 12s
2026-05-31 07:54:25 -07:00
b3e8f32734 feat(agent): derive + report deterministic machine_uid (SPEC-004 Task 1)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m4s
Build and Test / Build Server (Linux) (push) Successful in 9m41s
Build and Test / Security Audit (push) Successful in 4m11s
Build and Test / Build Summary (push) Successful in 10s
Agent now derives a recomputable, opaque machine_uid (Windows: SHA-256 of the OS
MachineGuid at HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid -> muid_<hex>;
non-Windows / registry-failure: persisted random UUID, warn-logged). Raw GUID
never exposed; OnceLock-cached. Reported ALONGSIDE agent_id (unchanged) on
AgentStatus (new additive proto field 12) and in the connect handshake query.
This is the stable identity that fixes config-loss duplicate registrations
(DESKTOP-I66IM5Q x9); server-side dedup keying that consumes it is SPEC-004
Task 2. Non-breaking, isolated. 5 unit tests; cargo fmt/clippy(-D warnings)/test
green on GURU-5070.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:23:11 -07:00
92bc522c3a spec: add v2-stable-identity implementation plan (SPEC-004 breakdown)
Some checks failed
Build and Test / Build Server (Linux) (push) Has started running
Build and Test / Build Agent (Windows) (push) Has started running
Build and Test / Security Audit (push) Has been cancelled
Build and Test / Build Summary (push) Has been cancelled
Ordered, execution-ready plan for SPEC-004 (stable machine identity + session
reaping + operator removal). Works out the core integration: machine_uid =
deterministic MachineGuid-based hardware identity (recomputable, so config loss
can't duplicate); per-agent cak_ key stays the credential/trust boundary; they
compose so one cak_ key per machine_uid = one key per real machine (the
prerequisite the fleet key-migration #7 needs). Root cause grounded in code:
agent_id is a random UUID (config.rs:90), connect_machines dedups on ON CONFLICT
(agent_id), so config loss -> duplicate rows (DESKTOP-I66IM5Q x9 live). 5 ordered
tasks (agent uid -> server dedup -> reconcile/age-out -> reaping -> operator
removal). Unblocks #7 -> #5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:17:49 -07:00
df51d40094 feat(server): per-agent H.264 test override (h264-test tag) [Task 8 prep]
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m32s
Build and Test / Build Server (Linux) (push) Successful in 10m55s
Build and Test / Security Audit (push) Successful in 4m14s
Build and Test / Build Summary (push) Successful in 11s
Lets the HW-H.264 path be live-validated on tagged test agents without affecting
the live client fleet. Adds H264_TEST_TAG="h264-test" + a pure prefer_h264_for(tags)
helper (DEFAULT_PREFER_H264 || tags contains the tag, case-insensitive); StartStream
codec negotiation now computes prefer_h264 from the agent's reported tags instead of
the bare const, and logs the computed value. SAFETY: untagged sessions are byte-for-
byte unchanged (prefer_h264 == DEFAULT_PREFER_H264 == false -> raw); the supports_h264
guard still forces raw for a no-HW agent even when tagged. DEFAULT_PREFER_H264 stays
false (flipping the global default is a separate future step). 3 unit tests added.
cargo fmt/clippy(-D warnings)/test green on GURU-5070 (37 agent + 64 server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:17:38 -07:00
7be8f454e0 Merge remote security fixes with local specs
All checks were successful
Build and Test / Build Server (Linux) (push) Successful in 9m56s
Build and Test / Build Agent (Windows) (push) Successful in 6m21s
Build and Test / Security Audit (push) Successful in 4m21s
Build and Test / Build Summary (push) Successful in 9s
2026-05-30 19:21:42 -07:00
761bae5d01 spec: update SPEC-012 to include both Serial Console + PTY Shell modes
Major update to SPEC-012 adding dual-mode terminal access:

Mode 1: Serial Console Mode (True Remote Console)
- Direct access to system serial console (/dev/ttyS0 or /dev/console)
- Sees GRUB bootloader, kernel boot messages, login prompts, kernel panics
- Boot-time interaction: select GRUB entries, edit kernel parameters, single-user mode
- Requires root privileges or CAP_SYS_TTY_CONFIG capability
- Setup: GRUB + kernel parameters configured for serial console output
- Like KVM-over-IP or IPMI Serial-over-LAN (text-mode equivalent)

Mode 2: PTY Shell Mode (Interactive Shell)
- Spawn pseudo-TTY with bash/zsh shell session
- Normal server management (package updates, log review, etc.)
- Runs as unprivileged agent service user
- Standard interactive shell with full ANSI/VT100 support

Architecture:
- Agent mode selection based on viewer request (console vs. shell)
- Dashboard shows two buttons: "Console" and "Shell" for headless agents
- Same xterm.js viewer handles both modes transparently
- Protobuf extensions: TerminalModeRequest enum, console_mode flag

Security:
- Console mode requires root (boot-level control risk)
- Recommend RBAC: separate console_access and shell_access permissions
- Console sessions should require MFA (Phase 2)
- Audit logging for both modes

Setup Requirements:
- One-time GRUB configuration for serial console
- systemd service with CAP_SYS_TTY_CONFIG for console mode
- serial-getty@ttyS0.service enabled for login prompt

Updated effort: Medium (5-7 weeks, up from 4-6)
Priority remains P2

Addresses user request for "remote console" (as if at the machine)
not just shell access.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-30 19:02:27 -07:00
a062a825ea spec: add SPEC-012 Headless Linux Mode (Direct TTY Access)
Comprehensive specification for terminal-based remote access to headless
Linux servers (no X11/Wayland GUI):

Core Capabilities:
- PTY spawn via openpty() + fork/exec shell (/bin/bash or $SHELL)
- Terminal I/O: PTY output → TerminalData protobuf → WebSocket relay
- Input: keyboard → TerminalInput protobuf → PTY master write
- Resize: SIGWINCH on terminal window resize, TIOCSWINSZ ioctl
- Auto-detection: agent detects headless environment (no DISPLAY) at runtime

Viewer:
- xterm.js-based web terminal (80x24 default, resizable)
- Full ANSI/VT100 support (colors, cursor control, vim/nano/htop)
- Same protobuf-over-WSS protocol, support-code/agent-key auth
- Dashboard shows "Terminal" badge, routes to terminal viewer

Use Cases:
- Server management (headless Ubuntu Server, VMs, containers)
- Emergency recovery (systemd rescue mode, single-user mode)
- Container debugging (exec into running containers)
- SSH replacement with centralized audit logging

Protobuf Extensions:
- TerminalData, TerminalInput, TerminalResize messages
- AgentStatus.terminal_mode flag

Security:
- Run agent as unprivileged user + sudo for privileged commands
- Session recording to terminal_recordings table (asciicast format)
- Same auth model as GUI agents (support-code / per-agent key)

Estimated effort: Medium (4-6 weeks)
Priority: P2 (server management is market-critical)

Extends SPEC-010 Linux agent with PTY alternative to screen capture.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-30 18:28:34 -07:00
b1862800a1 spec: add SPEC-011 Mobile Agent Support (iOS and Android)
Comprehensive specification for iOS/Android devices as remote control targets:

iOS Agent (View-Only):
- ReplayKit 2 screen capture (user consent required)
- VideoToolbox H.264 encoding
- NO input injection (iOS sandboxing limitation)
- APNs push notifications for session requests
- Foreground-only operation (OS requirement)

Android Agent (View + Control):
- MediaProjection API screen capture (user consent)
- MediaCodec H.264 encoding
- Accessibility Service for input injection (tap/swipe/type)
- FCM push notifications
- Foreground service with persistent notification

Architecture:
- Native Swift/SwiftUI (iOS) and Kotlin/Jetpack Compose (Android) apps
- Same protobuf-over-WSS protocol as desktop agents
- Support-code authentication (persistent mode deferred to Phase 2)
- Minor protobuf additions: MobileCapabilities, TouchEvent
- Server push module: APNs (a2 crate) + FCM HTTP v1

Key constraints:
- Attended-only sessions (user must grant permission)
- Foreground-only (cannot capture in background on either platform)
- iOS view-only (platform sandbox prevents input injection)
- Consent-first model (MediaProjection/ReplayKit user prompts)

Estimated effort: X-Large (16-20 weeks, requires mobile expertise)
Priority: P3

Distinct from GuruRMM SPEC-017 (MDM/inventory) — this is remote
control, not device management.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-30 18:24:16 -07:00
5e2325507f spec: add SPEC-010 Cross-Platform Agent Support (macOS and Linux)
Comprehensive specification for expanding agent support beyond Windows:

macOS Agent (Priority 1):
- ScreenCaptureKit API (macOS 13+) with AVFoundation fallback
- CGEvent input injection
- VideoToolbox H.264 encoding
- NSStatusItem menu bar icon
- Universal binary (x86_64 + arm64)
- Code signing and notarization

Linux Agent (Priority 2):
- X11 XShm screen capture with Wayland detection
- XTest input injection
- VA-API hardware H.264 encoding with software fallback
- StatusNotifier system tray
- .deb and .rpm packaging

Architecture:
- Platform abstraction layer (traits for capture/input/encoder/tray)
- Refactor existing Windows code behind PlatformCapture/Input/Encoder
- No protobuf protocol changes
- Same authentication (support codes and agent keys)

Estimated effort: X-Large (12-16 weeks)
Priority: P2 (market-critical for multi-platform MSP adoption)

Updated roadmap: promoted from P3 to P2 with full spec link.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-30 18:15:16 -07:00
54 changed files with 8305 additions and 310 deletions

View File

@@ -6,20 +6,66 @@ All notable changes to GuruConnect are documented here. Format follows
Per-version entries below are generated from conventional commits (`feat:`, `fix:`, `perf:`)
by the release workflow; per-component changelogs are also written to
`changelogs/<component>/v<version>.md` and served at `/api/changelog/...`.
## [0.3.0] - 2026-06-01
### Added
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
- GuruConnect v2 Users admin view (96b4fd77)
- GuruConnect v2 Support Codes view (664f33d5)
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
- GuruConnect v2 operator console (pass 1) (43a9432b)
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
- V2 secure-session-core Task 5 - attended consent (9082e114)
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
### Fixed
- Make native H.264 viewer render live frames (97780304)
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
- Clippy fixes for Task 4 (CI green) (21189423)
### Security
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
### Spec
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
- Add SPEC-009 feature-rich documented API (7ab87384)
- Add SPEC-008 valuable error messages (65eff5cf)
- Add SPEC-007 managed-agent installer builder (008d2bf3)
- Add SPEC-006 universal machine search (0eb38520)
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
- Add v2-secure-session-core shape spec (81e4b99a)
## [0.2.2] - 2026-05-29
### Fixed
- Drop broken jsign --info verify step in release (5727ccf3)
## [0.2.1] - 2026-05-29
### Fixed
- Use jsign 7.1 for Azure Trusted Signing (e7f38ce2)
## [0.2.0] - 2026-05-29
### Added
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
@@ -28,6 +74,11 @@ by the release workflow; per-component changelogs are also written to
- Use Self:: for static method calls (cc35d111)
### Fixed
- Drop broken jsign --info verify step in release (5727ccf3)
- Use jsign 7.1 for Azure Trusted Signing (e7f38ce2)
### Security
- Require authentication for all WebSocket and API endpoints (4614df04)

1
Cargo.lock generated
View File

@@ -1414,6 +1414,7 @@ dependencies = [
"chrono",
"clap",
"futures-util",
"hex",
"hostname",
"image",
"muda",

View File

@@ -6,7 +6,7 @@ members = [
]
[workspace.package]
version = "0.2.2"
version = "0.3.0"
edition = "2021"
authors = ["AZ Computer Guru"]
license = "Proprietary"

View File

@@ -1,6 +1,6 @@
[package]
name = "guruconnect"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
authors = ["AZ Computer Guru"]
description = "GuruConnect Remote Desktop - Agent and Viewer"
@@ -47,6 +47,7 @@ toml = "0.8"
# Crypto
ring = "0.17"
sha2 = "0.10"
hex = "0.4"
# HTTP client for updates
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }

300
agent/src/identity.rs Normal file
View File

@@ -0,0 +1,300 @@
//! Deterministic, recomputable machine identity (`machine_uid`).
//!
//! SPEC-004 / v2-stable-identity Task 1.
//!
//! `machine_uid()` returns a stable, opaque identifier for *this physical
//! machine*. Unlike `agent_id` (a random UUID persisted in the config file,
//! which mints a fresh value — and thus a duplicate server row — whenever the
//! config is lost), `machine_uid` is **derived from the hardware/OS** and is
//! **recomputable**: the same machine yields the same id on every call with no
//! persistence required.
//!
//! - **Windows:** SHA-256 hash of the OS machine GUID read from
//! `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (a `REG_SZ`). The raw
//! GUID is never returned — only the opaque `muid_<hex>` derived from it.
//! - **Non-Windows (and Windows registry failure):** a random UUID persisted in
//! the agent's data directory, read back on subsequent runs so it is stable
//! across calls and process restarts.
//!
//! This module deliberately does NOT change `agent_id`/`generate_agent_id`.
//! `machine_uid` is reported *alongside* `agent_id`; the server-side dedup that
//! consumes it is a separate task.
use std::sync::OnceLock;
/// Prefix marking the value as an opaque machine-uid (vs. a raw GUID/UUID).
const MUID_PREFIX: &str = "muid_";
/// Cached value — `machine_uid()` reads the registry / a file, so compute once
/// and reuse for the lifetime of the process.
static MACHINE_UID: OnceLock<String> = OnceLock::new();
/// Return a deterministic, recomputable opaque machine identifier.
///
/// The result is non-empty and prefixed with [`MUID_PREFIX`]. It is cached after
/// the first call. On Windows it is derived purely from the OS machine GUID (no
/// persistence). If the Windows registry read fails — or on any non-Windows
/// platform — it degrades to a persisted random UUID (today's-behavior-equivalent
/// stability) rather than panicking.
pub fn machine_uid() -> String {
MACHINE_UID.get_or_init(compute_machine_uid).clone()
}
/// Derive the opaque id from a raw machine-identity string via SHA-256.
///
/// Returns `muid_<first-16-bytes-of-sha256, hex>`. Hashing makes the value
/// opaque (the raw `MachineGuid` is never exposed) while staying fully
/// deterministic for a given input.
fn derive_uid(raw: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(raw.as_bytes());
let hash = hasher.finalize();
format!("{}{}", MUID_PREFIX, hex::encode(&hash[..16]))
}
#[cfg(windows)]
fn compute_machine_uid() -> String {
match read_machine_guid() {
Ok(guid) if !guid.trim().is_empty() => derive_uid(guid.trim()),
Ok(_) => {
tracing::warn!(
"MachineGuid registry value was empty; falling back to persisted machine_uid"
);
persisted_uid()
}
Err(e) => {
tracing::warn!(
"Failed to read MachineGuid from registry ({e}); falling back to persisted machine_uid"
);
persisted_uid()
}
}
}
#[cfg(not(windows))]
fn compute_machine_uid() -> String {
// No OS machine GUID available — use the persisted random UUID, hashed for a
// uniform opaque shape with the Windows path.
persisted_uid()
}
/// Read `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (REG_SZ).
///
/// Uses `RegGetValueW`, which opens, queries, null-terminates, and (with
/// `RRF_RT_REG_SZ`) type-checks the value in one call.
#[cfg(windows)]
fn read_machine_guid() -> anyhow::Result<String> {
use anyhow::{anyhow, Context};
use windows::core::PCWSTR;
use windows::Win32::Foundation::ERROR_SUCCESS;
use windows::Win32::System::Registry::{RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ};
fn to_wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
let subkey = to_wide(r"SOFTWARE\Microsoft\Cryptography");
let value = to_wide("MachineGuid");
unsafe {
// First query the required buffer size (in bytes).
let mut size: u32 = 0;
let status = RegGetValueW(
HKEY_LOCAL_MACHINE,
PCWSTR(subkey.as_ptr()),
PCWSTR(value.as_ptr()),
RRF_RT_REG_SZ,
None,
None,
Some(&mut size),
);
if status != ERROR_SUCCESS {
return Err(anyhow!("RegGetValueW(size) failed: {:?}", status));
}
if size == 0 {
return Err(anyhow!("MachineGuid reported zero length"));
}
// `size` is bytes; allocate a u16 buffer large enough to hold it.
let len_u16 = size.div_ceil(2) as usize;
let mut buffer = vec![0u16; len_u16];
let mut size_out = size;
let status = RegGetValueW(
HKEY_LOCAL_MACHINE,
PCWSTR(subkey.as_ptr()),
PCWSTR(value.as_ptr()),
RRF_RT_REG_SZ,
None,
Some(buffer.as_mut_ptr() as *mut _),
Some(&mut size_out),
);
if status != ERROR_SUCCESS {
return Err(anyhow!("RegGetValueW(read) failed: {:?}", status));
}
// Trim the trailing NUL(s) that RegGetValueW guarantees.
let chars = size_out as usize / 2;
let slice = &buffer[..chars.min(buffer.len())];
let end = slice.iter().position(|&c| c == 0).unwrap_or(slice.len());
String::from_utf16(&slice[..end]).context("MachineGuid was not valid UTF-16")
}
}
/// Read (or, on first use, generate and persist) a random UUID, then derive the
/// opaque id from it. This is the fallback identity: stable across calls and
/// process restarts because it is persisted to disk.
fn persisted_uid() -> String {
let path = fallback_uid_path();
// Try to read an existing value.
if let Some(ref p) = path {
if let Ok(contents) = std::fs::read_to_string(p) {
let trimmed = contents.trim();
if !trimmed.is_empty() {
return derive_uid(trimmed);
}
}
}
// Generate a new random seed and persist it (best-effort).
let seed = uuid::Uuid::new_v4().to_string();
if let Some(ref p) = path {
if let Some(parent) = p.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(p, &seed) {
tracing::warn!(
"Could not persist fallback machine_uid seed to {:?} ({e}); \
id will be stable for this process only",
p
);
}
} else {
tracing::warn!(
"No writable data directory for fallback machine_uid seed; \
id will be stable for this process only"
);
}
derive_uid(&seed)
}
/// Location of the persisted fallback seed file.
///
/// - **Windows:** `%ProgramData%\GuruConnect\machine_uid` (mirrors the agent
/// config location), used only when the registry read fails.
/// - **Non-Windows:** `$XDG_DATA_HOME/guruconnect/machine_uid`, falling back to
/// `$HOME/.local/share/guruconnect/machine_uid`, then a temp-dir path.
fn fallback_uid_path() -> Option<std::path::PathBuf> {
#[cfg(windows)]
{
if let Ok(program_data) = std::env::var("ProgramData") {
return Some(
std::path::PathBuf::from(program_data)
.join("GuruConnect")
.join("machine_uid"),
);
}
}
#[cfg(not(windows))]
{
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return Some(
std::path::PathBuf::from(xdg)
.join("guruconnect")
.join("machine_uid"),
);
}
}
if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() {
return Some(
std::path::PathBuf::from(home)
.join(".local")
.join("share")
.join("guruconnect")
.join("machine_uid"),
);
}
}
}
// Last resort: a stable name in the system temp dir.
Some(std::env::temp_dir().join("guruconnect_machine_uid"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn machine_uid_is_non_empty_and_prefixed() {
let uid = machine_uid();
assert!(!uid.is_empty(), "machine_uid must not be empty");
assert!(
uid.starts_with(MUID_PREFIX),
"machine_uid must start with {MUID_PREFIX}: got {uid}"
);
// muid_ + 16 bytes hex (32 chars).
assert_eq!(
uid.len(),
MUID_PREFIX.len() + 32,
"unexpected machine_uid length: {uid}"
);
assert!(
uid[MUID_PREFIX.len()..]
.chars()
.all(|c| c.is_ascii_hexdigit()),
"machine_uid suffix must be lowercase hex: {uid}"
);
}
#[test]
fn machine_uid_is_deterministic_across_calls() {
// The cached public API must be stable.
assert_eq!(machine_uid(), machine_uid());
}
#[test]
fn derive_uid_is_deterministic() {
// Same input -> same output; different input -> different output.
let a = derive_uid("the-same-input");
let b = derive_uid("the-same-input");
let c = derive_uid("a-different-input");
assert_eq!(a, b);
assert_ne!(a, c);
assert!(a.starts_with(MUID_PREFIX));
}
/// The non-Windows fallback must be stable across calls because it persists
/// its seed. We exercise `persisted_uid()` directly (the public `machine_uid`
/// is cached, so it cannot demonstrate persistence on its own).
#[test]
fn persisted_uid_is_stable_across_calls() {
let first = persisted_uid();
let second = persisted_uid();
assert_eq!(
first, second,
"persisted fallback uid must be stable across calls"
);
assert!(first.starts_with(MUID_PREFIX));
}
/// On Windows specifically, the registry-derived path must be deterministic:
/// reading the same `MachineGuid` twice yields the same uid.
#[cfg(windows)]
#[test]
fn windows_machine_guid_path_is_deterministic() {
// If the registry read succeeds, two reads must agree and the derived
// uid must match. If it fails (unusual), the test still validates the
// fallback determinism via compute_machine_uid().
let a = compute_machine_uid();
let b = compute_machine_uid();
assert_eq!(a, b, "compute_machine_uid must be deterministic on Windows");
assert!(a.starts_with(MUID_PREFIX));
}
}

View File

@@ -17,6 +17,7 @@ mod chat;
mod config;
mod consent;
mod encoder;
mod identity;
mod input;
mod install;
mod sas_client;

View File

@@ -103,12 +103,16 @@ impl SessionManager {
pub async fn connect(&mut self) -> Result<()> {
self.state = SessionState::Connecting;
// Deterministic, recomputable identity reported alongside agent_id
// (v2 stable-identity Task 1). Cached after the first call.
let machine_uid = crate::identity::machine_uid();
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(),
Some(&machine_uid),
)
.await?;
@@ -247,6 +251,10 @@ impl SessionManager {
// Advertise hardware H.264 capability so the server can negotiate the
// codec (Task 7). Detected once and cached by the encoder module.
supports_h264: encoder::supports_hardware_h264(),
// Deterministic, recomputable hardware identity (v2 stable-identity
// Task 1). Reported alongside the unchanged random agent_id; cached
// after the first (registry) read.
machine_uid: crate::identity::machine_uid(),
};
let msg = Message {

View File

@@ -35,14 +35,25 @@ impl WebSocketTransport {
api_key: &str,
hostname: Option<&str>,
support_code: Option<&str>,
machine_uid: Option<&str>,
) -> Result<Self> {
// Build query parameters
// Build query parameters. agent_id + api_key are kept exactly as-is;
// machine_uid is appended ALONGSIDE them (v2 stable-identity Task 1) so
// the server sees the deterministic identity at connect time. It does not
// change registration keying (a separate server-side task).
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(machine_uid) = machine_uid {
params.push_str(&format!(
"&machine_uid={}",
urlencoding::encode(machine_uid)
));
}
if let Some(code) = support_code {
params.push_str(&format!("&support_code={}", code));
}

View File

@@ -21,8 +21,9 @@ use windows::Win32::Media::MediaFoundation::{
MFSTARTUP_LITE, MFT_CATEGORY_VIDEO_DECODER, MFT_ENUM_FLAG_SORTANDFILTER, MFT_ENUM_FLAG_SYNCMFT,
MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, MFT_MESSAGE_NOTIFY_END_OF_STREAM,
MFT_MESSAGE_NOTIFY_END_STREAMING, MFT_MESSAGE_NOTIFY_START_OF_STREAM, MFT_OUTPUT_DATA_BUFFER,
MFT_OUTPUT_STREAM_INFO, MFT_REGISTER_TYPE_INFO, MF_E_TRANSFORM_NEED_MORE_INPUT,
MF_E_TRANSFORM_STREAM_CHANGE, MF_MT_FRAME_SIZE, MF_MT_MAJOR_TYPE, MF_MT_SUBTYPE,
MFT_OUTPUT_STREAM_INFO, MFT_REGISTER_TYPE_INFO, MF_E_NOTACCEPTING,
MF_E_TRANSFORM_NEED_MORE_INPUT, MF_E_TRANSFORM_STREAM_CHANGE, MF_E_TRANSFORM_TYPE_NOT_SET,
MF_MT_FRAME_SIZE, MF_MT_MAJOR_TYPE, MF_MT_SUBTYPE,
};
/// A decoded NV12 frame and its dimensions, ready for NV12 -> BGRA conversion.
@@ -91,15 +92,32 @@ impl H264Decoder {
Ok(())
}
/// Set the decoder output type to NV12 once the stream size is known.
unsafe fn configure_output_nv12(&mut self) -> Result<()> {
let out_type: IMFMediaType = MFCreateMediaType().context("MFCreateMediaType(dec out)")?;
out_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
out_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12)?;
self.transform
.SetOutputType(self.output_stream_id, &out_type, 0)
.context("SetOutputType(NV12 decode)")?;
Ok(())
/// Negotiate the decoder's NV12 output type by ENUMERATING the available
/// output types it offers (these carry the decoder-negotiated frame size),
/// then setting the NV12 one. The Microsoft H.264 decoder MFT rejects a
/// hand-built, underspecified output type, so we must select from what it
/// exposes after it has parsed enough of the bitstream. Driven by a
/// STREAM_CHANGE / TYPE_NOT_SET round-trip — never set eagerly.
unsafe fn negotiate_output_type(&mut self) -> Result<()> {
let mut index: u32 = 0;
// GetOutputAvailableType returns Err (MF_E_NO_MORE_TYPES) past the last
// entry, which ends the enumeration.
while let Ok(mt) = self
.transform
.GetOutputAvailableType(self.output_stream_id, index)
{
let subtype = mt
.GetGUID(&MF_MT_SUBTYPE)
.context("read available output subtype")?;
if subtype == MFVideoFormat_NV12 {
self.transform
.SetOutputType(self.output_stream_id, &mt, 0)
.context("SetOutputType(NV12 decode)")?;
return Ok(());
}
index += 1;
}
Err(anyhow!("decoder offered no NV12 output type"))
}
/// Read the negotiated output frame size from the decoder's current output type.
@@ -129,42 +147,79 @@ impl H264Decoder {
Ok(())
}
/// Feed one H.264 access unit and return a decoded BGRA frame if one is
/// produced this tick. `Ok(None)` means the decoder needs more input (normal
/// while it buffers the first GOP).
pub fn decode(&mut self, h264: &[u8], pts_100ns: i64) -> Result<Option<DecodedFrame>> {
/// Feed one H.264 access unit and return all BGRA frames the decoder emits
/// in response. A single input access unit can legitimately yield zero, one,
/// or more decoded frames, so the result is a `Vec`.
///
/// This implements the Media Foundation MFT streaming contract: `ProcessInput`
/// may return `MF_E_NOTACCEPTING`, which is NOT an error — it means the decoder
/// has pending output that must be fully drained via `ProcessOutput` before it
/// will accept the next input. The previous implementation treated NOTACCEPTING
/// as fatal and only drained one frame per call, so once the MFT filled up it
/// rejected every subsequent frame (0xC00D36B5) and nothing rendered. We now
/// drain on back-pressure, retry the same (unconsumed) sample, then drain ALL
/// ready outputs before returning.
pub fn decode(&mut self, h264: &[u8], pts_100ns: i64) -> Result<Vec<DecodedFrame>> {
let mut out = Vec::new();
if h264.is_empty() {
return Ok(None);
return Ok(out);
}
unsafe {
self.ensure_streaming()?;
let sample = make_input_sample(h264, pts_100ns)?;
match self
.transform
.ProcessInput(self.input_stream_id, &sample, 0)
{
Ok(()) => {}
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => {}
Err(e) => return Err(anyhow!("decoder ProcessInput failed: {e:#}")),
// Submit the sample, tolerating back-pressure. On NOTACCEPTING the
// sample is NOT consumed, so we drain pending output and re-submit the
// same `&sample`.
loop {
match self
.transform
.ProcessInput(self.input_stream_id, &sample, 0)
{
// Input accepted (or accepted while still wanting more).
Ok(()) => break,
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => break,
// Back-pressure: drain a pending output, then retry the SAME
// sample (it was not consumed).
Err(e) if e.code() == MF_E_NOTACCEPTING => {
match self.drain_one()? {
Some(frame) => {
out.push(frame);
continue;
}
// Pathological: decoder won't accept input yet has
// nothing to drain. Don't spin — warn once and drop
// this access unit.
None => {
tracing::warn!(
"H.264 decoder reported NOTACCEPTING with no drainable output; dropping access unit"
);
return Ok(out);
}
}
}
Err(e) => return Err(anyhow!("decoder ProcessInput failed: {e:#}")),
}
}
self.drain_one()
// Drain every output the decoder has ready for this input.
while let Some(frame) = self.drain_one()? {
out.push(frame);
}
Ok(out)
}
}
/// Drain one decoded output sample, handling the initial NV12 output-type
/// negotiation (`MF_E_TRANSFORM_STREAM_CHANGE`).
unsafe fn drain_one(&mut self) -> Result<Option<DecodedFrame>> {
// Tracks whether we have already (re)negotiated the output type during
// THIS drain call. Guards against spinning forever if the decoder keeps
// surfacing TYPE_NOT_SET / STREAM_CHANGE without making progress.
let mut negotiated = false;
loop {
// If we have not yet set an output type, do so now (NV12). The first
// ProcessOutput typically returns STREAM_CHANGE until this is set.
if self.width == 0 {
// Try to set NV12 output; ignore failures here (the decoder may
// require a STREAM_CHANGE round-trip first).
let _ = self.configure_output_nv12();
}
let stream_info: MFT_OUTPUT_STREAM_INFO = self
.transform
.GetOutputStreamInfo(self.output_stream_id)
@@ -214,11 +269,26 @@ impl H264Decoder {
}));
}
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => return Ok(None),
Err(e) if e.code() == MF_E_TRANSFORM_STREAM_CHANGE => {
// The decoder learned the frame size: (re)negotiate NV12 out,
// record the size, and retry the drain.
self.configure_output_nv12()
// Both of these mean "you must (re)negotiate the output type now."
// STREAM_CHANGE fires once the decoder has parsed the sequence
// header and learned the real frame size; depending on input
// timing the MS decoder may surface TYPE_NOT_SET instead. Handle
// them identically: enumerate the decoder's available output
// types, set the NV12 one, record the negotiated size, and retry.
Err(e)
if e.code() == MF_E_TRANSFORM_STREAM_CHANGE
|| e.code() == MF_E_TRANSFORM_TYPE_NOT_SET =>
{
// We already negotiated once this drain yet the decoder still
// demands a type: bail rather than spin forever.
if negotiated {
return Err(anyhow!(
"decoder still reports output type not set after renegotiation: {e:#}"
));
}
self.negotiate_output_type()
.context("decoder output renegotiation after stream change")?;
negotiated = true;
if let Ok((w, h)) = self.read_output_size() {
self.width = w;
self.height = h;

View File

@@ -27,9 +27,8 @@ use tracing::trace;
use windows::{
Win32::Foundation::{LPARAM, LRESULT, WPARAM},
Win32::UI::WindowsAndMessaging::{
CallNextHookEx, DispatchMessageW, PeekMessageW, SetWindowsHookExW, TranslateMessage,
UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, LLKHF_EXTENDED, MSG, PM_REMOVE,
WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT,
LLKHF_EXTENDED, WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
},
};
@@ -237,18 +236,6 @@ fn current_modifiers() -> proto::Modifiers {
}
}
/// 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)]
@@ -262,10 +249,6 @@ impl KeyboardHook {
}
}
#[cfg(not(windows))]
#[allow(dead_code)]
pub fn pump_messages() {}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -74,20 +74,27 @@ fn spawn_h264_decode_worker(
let dec = decoder.as_mut().expect("decoder present after init");
match dec.decode(&data, pts) {
Ok(Some(decoded)) => {
let frame = render::FrameData {
width: decoded.width,
height: decoded.height,
data: decoded.bgra,
compressed: false, // already BGRA
is_keyframe: false,
};
if viewer_tx.blocking_send(ViewerEvent::Frame(frame)).is_err() {
// Viewer closed; stop the worker.
// One input access unit may yield zero, one, or more frames.
Ok(frames) => {
let mut viewer_closed = false;
for decoded in frames {
let frame = render::FrameData {
width: decoded.width,
height: decoded.height,
data: decoded.bgra,
compressed: false, // already BGRA
is_keyframe: false,
};
if viewer_tx.blocking_send(ViewerEvent::Frame(frame)).is_err() {
// Viewer closed; stop the worker.
viewer_closed = true;
break;
}
}
if viewer_closed {
break;
}
}
Ok(None) => { /* decoder buffering; no output this tick */ }
Err(e) => {
warn!("H.264 decode error: {e:#}");
}

View File

@@ -465,9 +465,11 @@ impl ApplicationHandler for ViewerApp {
// Keep checking for events
event_loop.set_control_flow(ControlFlow::Poll);
// Process Windows messages for keyboard hook
#[cfg(windows)]
input::pump_messages();
// NOTE: do NOT manually pump the Win32 message queue here. winit's own
// run_app loop already pumps this thread's messages (which also services
// the low-level keyboard hook). A manual PeekMessage/DispatchMessage pump
// inside about_to_wait steals winit's messages and re-enters its window
// proc, freezing the event loop after one iteration (blank viewer).
// Request redraw periodically to check for new frames
if let Some(window) = &self.window {

View File

@@ -1,14 +1,58 @@
## [0.2.0] - 2026-05-29
## [0.3.0] - 2026-06-01
### Added
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
- GuruConnect v2 Users admin view (96b4fd77)
- GuruConnect v2 Support Codes view (664f33d5)
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
- GuruConnect v2 operator console (pass 1) (43a9432b)
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
- V2 secure-session-core Task 5 - attended consent (9082e114)
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
### Fix
### Fixed
- Use Self:: for static method calls (cc35d111)
- Make native H.264 viewer render live frames (97780304)
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
- Clippy fixes for Task 4 (CI green) (21189423)
### Security
- Require authentication for all WebSocket and API endpoints (4614df04)
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
### Spec
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
- Add SPEC-009 feature-rich documented API (7ab87384)
- Add SPEC-008 valuable error messages (65eff5cf)
- Add SPEC-007 managed-agent installer builder (008d2bf3)
- Add SPEC-006 universal machine search (0eb38520)
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
- Add v2-secure-session-core shape spec (81e4b99a)

View File

@@ -1,14 +1,58 @@
## [0.2.0] - 2026-05-29
## [0.3.0] - 2026-06-01
### Added
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
- GuruConnect v2 Users admin view (96b4fd77)
- GuruConnect v2 Support Codes view (664f33d5)
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
- GuruConnect v2 operator console (pass 1) (43a9432b)
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
- V2 secure-session-core Task 5 - attended consent (9082e114)
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
### Fix
### Fixed
- Use Self:: for static method calls (cc35d111)
- Make native H.264 viewer render live frames (97780304)
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
- Clippy fixes for Task 4 (CI green) (21189423)
### Security
- Require authentication for all WebSocket and API endpoints (4614df04)
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
### Spec
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
- Add SPEC-009 feature-rich documented API (7ab87384)
- Add SPEC-008 valuable error messages (65eff5cf)
- Add SPEC-007 managed-agent installer builder (008d2bf3)
- Add SPEC-006 universal machine search (0eb38520)
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
- Add v2-secure-session-core shape spec (81e4b99a)

View File

@@ -1,14 +1,58 @@
## [0.2.0] - 2026-05-29
## [0.3.0] - 2026-06-01
### Added
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
- GuruConnect v2 Users admin view (96b4fd77)
- GuruConnect v2 Support Codes view (664f33d5)
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
- GuruConnect v2 operator console (pass 1) (43a9432b)
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
- V2 secure-session-core Task 5 - attended consent (9082e114)
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
### Fix
### Fixed
- Use Self:: for static method calls (cc35d111)
- Make native H.264 viewer render live frames (97780304)
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
- Clippy fixes for Task 4 (CI green) (21189423)
### Security
- Require authentication for all WebSocket and API endpoints (4614df04)
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
### Spec
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
- Add SPEC-009 feature-rich documented API (7ab87384)
- Add SPEC-008 valuable error messages (65eff5cf)
- Add SPEC-007 managed-agent installer builder (008d2bf3)
- Add SPEC-006 universal machine search (0eb38520)
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
- Add v2-secure-session-core shape spec (81e4b99a)

View File

@@ -0,0 +1,58 @@
## [0.3.0] - 2026-06-01
### Added
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
- GuruConnect v2 Users admin view (96b4fd77)
- GuruConnect v2 Support Codes view (664f33d5)
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
- GuruConnect v2 operator console (pass 1) (43a9432b)
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
- V2 secure-session-core Task 5 - attended consent (9082e114)
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
### Fixed
- Make native H.264 viewer render live frames (97780304)
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
- Clippy fixes for Task 4 (CI green) (21189423)
### Security
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
### Spec
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
- Add SPEC-009 feature-rich documented API (7ab87384)
- Add SPEC-008 valuable error messages (65eff5cf)
- Add SPEC-007 managed-agent installer builder (008d2bf3)
- Add SPEC-006 universal machine search (0eb38520)
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
- Add v2-secure-session-core shape spec (81e4b99a)

View File

@@ -0,0 +1,58 @@
## [0.3.0] - 2026-06-01
### Added
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
- GuruConnect v2 Users admin view (96b4fd77)
- GuruConnect v2 Support Codes view (664f33d5)
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
- GuruConnect v2 operator console (pass 1) (43a9432b)
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
- V2 secure-session-core Task 5 - attended consent (9082e114)
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
### Fixed
- Make native H.264 viewer render live frames (97780304)
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
- Clippy fixes for Task 4 (CI green) (21189423)
### Security
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
### Spec
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
- Add SPEC-009 feature-rich documented API (7ab87384)
- Add SPEC-008 valuable error messages (65eff5cf)
- Add SPEC-007 managed-agent installer builder (008d2bf3)
- Add SPEC-006 universal machine search (0eb38520)
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
- Add v2-secure-session-core shape spec (81e4b99a)

View File

@@ -0,0 +1,58 @@
## [0.3.0] - 2026-06-01
### Added
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
- GuruConnect v2 Users admin view (96b4fd77)
- GuruConnect v2 Support Codes view (664f33d5)
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
- GuruConnect v2 operator console (pass 1) (43a9432b)
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
- V2 secure-session-core Task 5 - attended consent (9082e114)
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
### Fixed
- Make native H.264 viewer render live frames (97780304)
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
- Clippy fixes for Task 4 (CI green) (21189423)
### Security
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
### Spec
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
- Add SPEC-009 feature-rich documented API (7ab87384)
- Add SPEC-008 valuable error messages (65eff5cf)
- Add SPEC-007 managed-agent installer builder (008d2bf3)
- Add SPEC-006 universal machine search (0eb38520)
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
- Add v2-secure-session-core shape spec (81e4b99a)

View File

@@ -1,12 +1,12 @@
{
"name": "@guruconnect/dashboard",
"version": "2.0.0",
"version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@guruconnect/dashboard",
"version": "2.0.0",
"version": "0.2.2",
"license": "Proprietary",
"dependencies": {
"@fontsource/hanken-grotesk": "^5.0.8",

View File

@@ -1,6 +1,6 @@
{
"name": "@guruconnect/dashboard",
"version": "2.0.0",
"version": "0.3.0",
"description": "GuruConnect v2 operator dashboard",
"author": "AZ Computer Guru",
"license": "Proprietary",

View File

@@ -1,5 +1,6 @@
import { http } from "./client";
import type {
BulkRemoveResponse,
CreatedKey,
DeleteMachineParams,
DeleteMachineResponse,
@@ -29,12 +30,21 @@ export function getMachineHistory(
);
}
/** DELETE /api/machines/:agent_id — remove a machine, optionally uninstall/export. */
/**
* DELETE /api/machines/:agent_id — remove a machine (admin only).
*
* Two server-side modes, selected by the query flags:
* - `purge: true` → soft-delete + purge the in-memory session (Task 5
* operator removal of ghost rows). Mutually exclusive with uninstall/export.
* - otherwise → the legacy hard delete, optionally commanding the agent
* to uninstall and/or returning full history in the response.
*/
export function deleteMachine(
agentId: string,
params: DeleteMachineParams = {},
): Promise<DeleteMachineResponse> {
const qs = new URLSearchParams();
if (params.purge) qs.set("purge", "true");
if (params.uninstall) qs.set("uninstall", "true");
if (params.export) qs.set("export", "true");
const suffix = qs.toString() ? `?${qs.toString()}` : "";
@@ -43,6 +53,22 @@ export function deleteMachine(
);
}
/**
* POST /api/machines/bulk-remove — remove many machines at once (admin only).
* Each id is soft-deleted + its session purged when `purge` is true. Invalid or
* unknown ids are reported per-id in the response rather than failing the batch;
* the server caps the batch at 500.
*/
export function bulkRemoveMachines(
ids: string[],
purge = true,
): Promise<BulkRemoveResponse> {
return http.post<BulkRemoveResponse>("/api/machines/bulk-remove", {
ids,
purge,
});
}
// --- Admin: per-agent keys --------------------------------------------------
/** GET /api/machines/:agent_id/keys — list key metadata (admin only). */

View File

@@ -1,5 +1,9 @@
import { http } from "./client";
import type { Session, ViewerTokenResponse } from "./types";
import type {
RemoveSessionResponse,
Session,
ViewerTokenResponse,
} from "./types";
/**
* GET /api/sessions — all live sessions known to the relay's in-memory session
@@ -27,10 +31,25 @@ export function mintViewerToken(
}
/**
* DELETE /api/sessions/:id — disconnect/end a live session. The relay sends a
* Disconnect to the agent. Returns 200 on success, 404 if the session is not
* found. Requires an authenticated dashboard JWT (not admin-gated server-side).
* DELETE /api/sessions/:id — disconnect/end a live session (admin only). The
* relay sends a Disconnect to the agent. Returns 200 on success, 404 if the
* session is not live in memory. This is the live-only path (no `purge`); it
* does not soft-delete any persisted row.
*/
export function endSession(sessionId: string): Promise<void> {
return http.del<void>(`/api/sessions/${encodeURIComponent(sessionId)}`);
}
/**
* DELETE /api/sessions/:id?purge=true — operator removal of a session (admin
* only). Soft-deletes the persisted `connect_sessions` row and drops any live
* in-memory session, clearing a ghost/stale session from the console. 404 only
* when neither a live nor a persisted session exists.
*/
export function purgeSession(
sessionId: string,
): Promise<RemoveSessionResponse> {
return http.del<RemoveSessionResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}?purge=true`,
);
}

View File

@@ -164,6 +164,13 @@ export interface DeleteMachineParams {
uninstall?: boolean;
/** Include full history in the delete response before removal. */
export?: boolean;
/**
* Operator-removal (Task 5): soft-delete the machine and purge its in-memory
* session so a ghost row disappears from the console. Selects the server's
* `?purge=true` path (admin-only). Mutually exclusive with the legacy
* `uninstall`/`export` hard-delete options.
*/
purge?: boolean;
}
export interface DeleteMachineResponse {
@@ -173,6 +180,38 @@ export interface DeleteMachineResponse {
history: MachineHistory | null;
}
/**
* Per-id outcome in a bulk machine removal. Mirrors
* `api::removal::BulkRemoveItem`. `status` is one of `removed` | `not_found` |
* `invalid` | `error` (widened to string for forward compatibility).
*/
export interface BulkRemoveItem {
agent_id: string;
status: "removed" | "not_found" | "invalid" | "error" | string;
}
/**
* Body for `POST /api/machines/bulk-remove`. Mirrors
* `api::removal::BulkRemoveRequest`. The server caps the batch at 500 ids and
* defaults `purge` to true; we always send it explicitly for the operator
* removal workflow.
*/
export interface BulkRemoveRequest {
ids: string[];
purge: boolean;
}
/**
* Summary body for a bulk removal. Mirrors `api::removal::BulkRemoveResponse`.
* `requested` is the batch size, `removed` the count that actually soft-deleted,
* and `results` the per-id outcomes.
*/
export interface BulkRemoveResponse {
requested: number;
removed: number;
results: BulkRemoveItem[];
}
// ---------------------------------------------------------------------------
// Sessions (live relay state)
// ---------------------------------------------------------------------------
@@ -221,6 +260,18 @@ export interface Session {
consent_state: ConsentState | string;
}
/**
* Response from `DELETE /api/sessions/:id?purge=true`. Mirrors
* `api::removal::RemoveSessionResponse`. `soft_deleted` is whether a persisted
* `connect_sessions` row was marked deleted (false when the session was only
* live in memory, e.g. an attended session that never persisted).
*/
export interface RemoveSessionResponse {
success: boolean;
message: string;
soft_deleted: boolean;
}
/** Access mode the relay grants a minted viewer token. */
export type ViewerAccess = "control" | "view_only";

View File

@@ -53,6 +53,33 @@
padding-right: 0 !important;
}
/* Selection column — fixed narrow rail to the left of the status dot. */
.dt__select {
width: 34px;
padding-left: 16px !important;
padding-right: 0 !important;
}
/* Generous hit target around the checkbox; the label also stops row-click. */
.dt__checkwrap {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
margin: -6px;
cursor: pointer;
}
.dt__check {
width: 15px;
height: 15px;
accent-color: var(--accent);
cursor: pointer;
}
.dt__check:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring);
border-radius: 3px;
}
/* Cell affordances. */
.dt__mono {
font-family: var(--font-mono);
@@ -151,3 +178,19 @@
.toolbar__count .mono {
color: var(--text);
}
/* Bulk-action bar: replaces the count readout when rows are selected. */
.bulkbar {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 10px;
}
.bulkbar__count {
font-size: 12px;
color: var(--text-muted);
}
.bulkbar__count .mono {
color: var(--text);
font-weight: 600;
}

View File

@@ -0,0 +1,112 @@
import { ApiError } from "../../api/client";
import type { BulkRemoveItem } from "../../api/types";
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
import { useToast } from "../../components/ui/toast-context";
import { useBulkRemoveMachines } from "./hooks";
interface BulkRemoveMachinesDialogProps {
/** Selected agent_ids to remove, or empty when the dialog is closed. */
agentIds: string[];
/** Whether the dialog is open. Kept explicit so an empty list can stay open. */
open: boolean;
onClose: () => void;
/** Called after a successful batch so the page can clear its selection. */
onRemoved: () => void;
}
/** Count outcomes by status for a compact "12 removed, 1 not found" summary. */
function summarize(results: BulkRemoveItem[]): string {
const counts = new Map<string, number>();
for (const r of results) counts.set(r.status, (counts.get(r.status) ?? 0) + 1);
const order = ["removed", "not_found", "invalid", "error"];
const labels: Record<string, string> = {
removed: "removed",
not_found: "not found",
invalid: "invalid",
error: "errored",
};
const parts: string[] = [];
for (const status of order) {
const n = counts.get(status);
if (n) parts.push(`${n} ${labels[status] ?? status}`);
}
// Surface any unexpected status the server may add in the future.
for (const [status, n] of counts) {
if (!order.includes(status)) parts.push(`${n} ${status}`);
}
return parts.join(", ");
}
/**
* Confirm + bulk-remove the selected machines (Task 5). On confirm the selected
* agent_ids are purged in one request; the per-id summary the server returns is
* surfaced as a toast (e.g. "12 removed, 1 not found") so a partial outcome is
* visible rather than silently swallowed.
*/
export function BulkRemoveMachinesDialog({
agentIds,
open,
onClose,
onRemoved,
}: BulkRemoveMachinesDialogProps) {
const toast = useToast();
const bulkRemove = useBulkRemoveMachines();
const count = agentIds.length;
function onConfirm() {
if (count === 0) {
onClose();
return;
}
bulkRemove.mutate(agentIds, {
onSuccess: (res) => {
const summary = summarize(res.results);
if (res.removed === res.requested) {
toast.success(
`Removed ${res.removed} ${res.removed === 1 ? "machine" : "machines"}`,
summary || undefined,
);
} else {
// Partial: some ids were not found / invalid. Report as info, not an
// error — the requested removals that could happen, did.
toast.info(
`Removed ${res.removed} of ${res.requested}`,
summary || undefined,
);
}
onRemoved();
onClose();
},
onError: (err) => {
toast.error(
"Could not remove machines",
err instanceof ApiError
? `${err.message}${err.code ? ` (${err.code})` : ""}`
: "The server did not respond. No machines were removed.",
);
},
});
}
return (
<ConfirmDialog
open={open}
title={`Remove ${count} ${count === 1 ? "machine" : "machines"}?`}
danger
busy={bulkRemove.isPending}
confirmLabel={`Remove ${count}`}
cancelLabel="Keep machines"
onConfirm={onConfirm}
onCancel={onClose}
body={
<p style={{ marginTop: 0 }}>
Remove the {count} selected{" "}
{count === 1 ? "machine" : "machines"} from the GuruConnect console.
Their live sessions are dropped and the rows disappear from the list.
Any that are genuinely still in service re-appear when their agents
next check in.
</p>
}
/>
);
}

View File

@@ -12,6 +12,13 @@ interface DeleteMachineDialogProps {
}
/**
* INTENTIONALLY UNWIRED. This legacy per-row delete dialog was superseded by
* the admin-only purge Remove (RemoveMachineDialog / BulkRemoveMachinesDialog)
* and currently has no caller. It is kept — not deleted — because it is the
* only remaining caller pattern for the `uninstall`/`export` machine-delete
* params, pending a future "full uninstall/export" admin action that will
* re-wire it. Do not treat its lack of references as a wiring bug.
*
* Destructive machine removal with two options:
* - uninstall: also command the agent to uninstall (only meaningful online)
* - export: return full history in the delete response before removal

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ApiError } from "../../api/client";
import type { Machine } from "../../api/types";
import { useAuth } from "../../auth/AuthContext";
@@ -21,9 +21,10 @@ import { Table, type Column } from "../../components/ui/Table";
import { TableSkeleton } from "../../components/ui/TableSkeleton";
import { absoluteTime, relativeTime } from "../../lib/time";
import "./machines.css";
import { DeleteMachineDialog } from "./DeleteMachineDialog";
import { BulkRemoveMachinesDialog } from "./BulkRemoveMachinesDialog";
import { MachineDetailDrawer } from "./MachineDetailDrawer";
import { MachineKeysModal } from "./MachineKeysModal";
import { RemoveMachineDialog } from "./RemoveMachineDialog";
import { useMachines } from "./hooks";
export function MachinesPage() {
@@ -33,7 +34,14 @@ export function MachinesPage() {
const [detailFor, setDetailFor] = useState<Machine | null>(null);
const [keysFor, setKeysFor] = useState<Machine | null>(null);
const [deleteFor, setDeleteFor] = useState<Machine | null>(null);
const [removeFor, setRemoveFor] = useState<Machine | null>(null);
// Bulk-select state (admin only). Keyed by agent_id so selection survives the
// 20s poll re-ordering. A stale id (machine removed elsewhere) is harmless —
// it is reconciled against the current list before any action.
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkOpen, setBulkOpen] = useState(false);
const selectAllRef = useRef<HTMLInputElement>(null);
const { data } = machinesQuery;
const machines = useMemo(() => data ?? [], [data]);
@@ -53,7 +61,103 @@ export function MachinesPage() {
[machines],
);
// Selection is scoped to the currently-visible (filtered) rows.
const selectedVisible = useMemo(
() => filtered.filter((m) => selected.has(m.agent_id)),
[filtered, selected],
);
const allVisibleSelected =
filtered.length > 0 && selectedVisible.length === filtered.length;
const someVisibleSelected =
selectedVisible.length > 0 && !allVisibleSelected;
// Drop selections that no longer exist (removed, or filtered away by a poll
// that dropped the machine) so the "Remove selected (N)" count stays truthful.
useEffect(() => {
setSelected((prev) => {
if (prev.size === 0) return prev;
const live = new Set(machines.map((m) => m.agent_id));
let changed = false;
const next = new Set<string>();
for (const id of prev) {
if (live.has(id)) next.add(id);
else changed = true;
}
return changed ? next : prev;
});
}, [machines]);
// Reflect the partial state on the header checkbox (indeterminate is DOM-only).
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = someVisibleSelected;
}
}, [someVisibleSelected]);
function toggleOne(agentId: string, checked: boolean) {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(agentId);
else next.delete(agentId);
return next;
});
}
function toggleAllVisible(checked: boolean) {
setSelected((prev) => {
const next = new Set(prev);
for (const m of filtered) {
if (checked) next.add(m.agent_id);
else next.delete(m.agent_id);
}
return next;
});
}
function clearSelection() {
setSelected(new Set());
}
const columns: Column<Machine>[] = [
// Admin-only selection rail. Built conditionally so non-admins never see it.
...(isAdmin
? [
{
key: "select",
cellClass: "dt__select",
header: (
<label className="dt__checkwrap">
<input
ref={selectAllRef}
type="checkbox"
className="dt__check"
checked={allVisibleSelected}
onChange={(e) => toggleAllVisible(e.target.checked)}
aria-label={
allVisibleSelected
? "Deselect all machines"
: "Select all machines"
}
/>
</label>
),
render: (m: Machine) => (
<label
className="dt__checkwrap"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
className="dt__check"
checked={selected.has(m.agent_id)}
onChange={(e) => toggleOne(m.agent_id, e.target.checked)}
aria-label={`Select ${m.hostname}`}
/>
</label>
),
} satisfies Column<Machine>,
]
: []),
{
key: "status",
header: "",
@@ -132,15 +236,18 @@ export function MachinesPage() {
Keys
</Button>
)}
<Button
variant="danger"
size="sm"
onClick={() => setDeleteFor(m)}
aria-label={`Remove ${m.hostname}`}
>
<TrashIcon width={14} height={14} />
Delete
</Button>
{/* Removal is admin-only, mirroring the server's 403 for non-admins. */}
{isAdmin && (
<Button
variant="danger"
size="sm"
onClick={() => setRemoveFor(m)}
aria-label={`Remove ${m.hostname}`}
>
<TrashIcon width={14} height={14} />
Remove
</Button>
)}
</span>
),
},
@@ -177,10 +284,40 @@ export function MachinesPage() {
aria-label="Filter machines"
/>
</div>
<div className="toolbar__count">
<span className="mono">{onlineCount}</span> online ·{" "}
<span className="mono">{machines.length}</span> total
</div>
{/* Bar count tracks the VISIBLE selected rows — the exact set the
bulk dialog will state and remove. Under an active filter that
hides some selected rows, this keeps bar == dialog == removed.
Keyed off selectedVisible so a "Remove selected (0)" bar never
shows when the only selections are filtered out of view. */}
{isAdmin && selectedVisible.length > 0 ? (
<div className="bulkbar" role="status">
<span className="bulkbar__count">
<span className="mono">{selectedVisible.length}</span> selected
</span>
<Button
variant="ghost"
size="sm"
onClick={clearSelection}
aria-label="Clear selection"
>
Clear
</Button>
<Button
variant="danger"
size="sm"
onClick={() => setBulkOpen(true)}
aria-label={`Remove ${selectedVisible.length} selected machines`}
>
<TrashIcon width={14} height={14} />
Remove selected ({selectedVisible.length})
</Button>
</div>
) : (
<div className="toolbar__count">
<span className="mono">{onlineCount}</span> online ·{" "}
<span className="mono">{machines.length}</span> total
</div>
)}
</div>
</div>
@@ -190,7 +327,11 @@ export function MachinesPage() {
Loading machines
</span>
<TableSkeleton
headers={["", "Hostname", "OS", "Mode", "Last seen", "Agent ID", ""]}
headers={
isAdmin
? ["", "", "Hostname", "OS", "Mode", "Last seen", "Agent ID", ""]
: ["", "Hostname", "OS", "Mode", "Last seen", "Agent ID", ""]
}
/>
</>
) : machinesQuery.isError ? (
@@ -239,7 +380,20 @@ export function MachinesPage() {
{isAdmin && (
<MachineKeysModal machine={keysFor} onClose={() => setKeysFor(null)} />
)}
<DeleteMachineDialog machine={deleteFor} onClose={() => setDeleteFor(null)} />
{isAdmin && (
<RemoveMachineDialog
machine={removeFor}
onClose={() => setRemoveFor(null)}
/>
)}
{isAdmin && (
<BulkRemoveMachinesDialog
open={bulkOpen}
agentIds={selectedVisible.map((m) => m.agent_id)}
onClose={() => setBulkOpen(false)}
onRemoved={clearSelection}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { ApiError } from "../../api/client";
import type { Machine } from "../../api/types";
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
import { useToast } from "../../components/ui/toast-context";
import { useDeleteMachine } from "./hooks";
interface RemoveMachineDialogProps {
/** The machine to remove, or null when the dialog is closed. */
machine: Machine | null;
onClose: () => void;
}
/**
* Operator removal (Task 5): purge a single machine from the console. This is
* the soft-delete path (`?purge=true`) used to clear ghost/stale rows — it does
* NOT uninstall the agent or export history (that is the separate full-delete
* dialog). On confirm the row is soft-deleted and its live session dropped; a
* genuinely-reconnecting machine re-appears on its next check-in.
*/
export function RemoveMachineDialog({
machine,
onClose,
}: RemoveMachineDialogProps) {
const toast = useToast();
const remove = useDeleteMachine();
function onConfirm() {
if (!machine) return;
remove.mutate(
{ agentId: machine.agent_id, params: { purge: true } },
{
onSuccess: () => {
toast.success(
"Machine removed",
`${machine.hostname} was removed from the console.`,
);
onClose();
},
onError: (err) => {
toast.error(
"Could not remove machine",
err instanceof ApiError
? `${err.message}${err.code ? ` (${err.code})` : ""}`
: "The server did not respond. The machine was not removed.",
);
},
},
);
}
return (
<ConfirmDialog
open={machine != null}
title="Remove machine?"
danger
busy={remove.isPending}
confirmLabel="Remove machine"
cancelLabel="Keep machine"
onConfirm={onConfirm}
onCancel={onClose}
body={
machine ? (
<p style={{ marginTop: 0 }}>
Remove <strong>{machine.hostname}</strong> from the GuruConnect
console. Its live session is dropped and the row disappears from the
list. If this machine is genuinely still in service it re-appears the
next time its agent checks in.
</p>
) : null
}
/>
);
}

View File

@@ -45,6 +45,21 @@ export function useDeleteMachine() {
});
}
/**
* Bulk-remove (purge) many machines, then invalidate the list so the removed
* rows drop. Resolves with the per-id summary so the caller can report how many
* actually removed vs. were not found / invalid.
*/
export function useBulkRemoveMachines() {
const qc = useQueryClient();
return useMutation({
mutationFn: (ids: string[]) => machinesApi.bulkRemoveMachines(ids, true),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: MACHINES_KEY });
},
});
}
// --- Admin: per-agent keys --------------------------------------------------
export function useMachineKeys(agentId: string | null, enabled: boolean) {

View File

@@ -0,0 +1,68 @@
import { ApiError } from "../../api/client";
import type { Session } from "../../api/types";
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
import { useToast } from "../../components/ui/toast-context";
import { usePurgeSession } from "./hooks";
interface RemoveSessionDialogProps {
/** The session to remove, or null when the dialog is closed. */
session: Session | null;
onClose: () => void;
}
/**
* Operator removal (Task 5): purge a session from the console. Unlike "End"
* (which only disconnects a live agent), this soft-deletes the persisted row and
* drops any in-memory session, clearing a ghost/stale session — including one
* for an agent that is already offline.
*/
export function RemoveSessionDialog({
session,
onClose,
}: RemoveSessionDialogProps) {
const toast = useToast();
const purge = usePurgeSession();
function onConfirm() {
if (!session) return;
purge.mutate(session.id, {
onSuccess: () => {
toast.success(
"Session removed",
`Cleared the session for ${session.agent_name}.`,
);
onClose();
},
onError: (err) => {
toast.error(
"Could not remove session",
err instanceof ApiError
? `${err.message}${err.code ? ` (${err.code})` : ""}`
: "The relay did not respond. The session was not removed.",
);
},
});
}
return (
<ConfirmDialog
open={session != null}
title="Remove session?"
danger
busy={purge.isPending}
confirmLabel="Remove session"
cancelLabel="Keep session"
onConfirm={onConfirm}
onCancel={onClose}
body={
session ? (
<p style={{ marginTop: 0 }}>
Remove the session for <strong>{session.agent_name}</strong> from the
console. Any live connection is dropped and the row disappears from
the list. This clears a stale or ghost session and cannot be undone.
</p>
) : null
}
/>
);
}

View File

@@ -8,6 +8,7 @@ import {
RefreshIcon,
SearchIcon,
StopIcon,
TrashIcon,
} from "../../components/layout/icons";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
@@ -21,6 +22,7 @@ import { TableSkeleton } from "../../components/ui/TableSkeleton";
import { absoluteTime, relativeTime } from "../../lib/time";
import { EndSessionDialog } from "./EndSessionDialog";
import { JoinSessionModal } from "./JoinSessionModal";
import { RemoveSessionDialog } from "./RemoveSessionDialog";
import { useSessions } from "./hooks";
import "./sessions.css";
@@ -44,6 +46,7 @@ export function SessionsPage() {
const [joinFor, setJoinFor] = useState<Session | null>(null);
const [endFor, setEndFor] = useState<Session | null>(null);
const [removeFor, setRemoveFor] = useState<Session | null>(null);
// The same authz split the server enforces at mint time: admin OR the
// `control` permission yields a control token; otherwise the floor is `view`.
@@ -193,6 +196,19 @@ export function SessionsPage() {
<StopIcon width={14} height={14} />
End
</Button>
{/* Operator removal (admin only): purge a stale/ghost session,
including offline ones the live-only "End" cannot clear. */}
{isAdmin && (
<Button
variant="danger"
size="sm"
onClick={() => setRemoveFor(s)}
aria-label={`Remove session on ${s.agent_name}`}
>
<TrashIcon width={14} height={14} />
Remove
</Button>
)}
</span>
);
},
@@ -301,6 +317,12 @@ export function SessionsPage() {
onClose={() => setJoinFor(null)}
/>
<EndSessionDialog session={endFor} onClose={() => setEndFor(null)} />
{isAdmin && (
<RemoveSessionDialog
session={removeFor}
onClose={() => setRemoveFor(null)}
/>
)}
</div>
);
}

View File

@@ -44,3 +44,17 @@ export function useEndSession() {
},
});
}
/**
* Operator-remove (purge) a session: soft-delete the persisted row + drop any
* live in-memory session, then invalidate the list so the ghost row disappears.
*/
export function usePurgeSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (sessionId: string) => sessionsApi.purgeSession(sessionId),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: SESSIONS_KEY });
},
});
}

View File

@@ -61,6 +61,9 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
- [x] Support-code (attended) and persistent (unattended) agent modes
- [x] Protobuf-over-WSS transport, Zstd frame compression
- [~] React/TS web viewer (`dashboard/src/components/RemoteViewer.tsx`) — embeddable session viewer
- [ ] **Headless Linux mode (direct TTY access)** — P2 — Terminal-based remote access for Linux servers without GUI. PTY spawn (`openpty`), xterm.js web viewer, full ANSI/VT100 support. Enables server management, container debugging, emergency recovery via GuruConnect dashboard with audit logging. SSH replacement with centralized auth. ([SPEC-012](specs/SPEC-012-headless-linux-tty.md))
- [ ] **Windows session selection and backstage mode** — P2 — Enumerate and switch between Windows user sessions (Terminal Services/RDP/Fast User Switching) and access Session 0 (backstage) for system-level admin tasks. ScreenConnect parity: session selector shows all logged-on users, instant switching without reconnect. Backstage mode provides terminal/command interface for services management without disrupting any user desktop. Critical for multi-user server environments. ([SPEC-013](specs/SPEC-013-session-selection-and-backstage.md))
- [ ] **Configurable notification overlay on viewer connection** — P2 — Display a semi-transparent on-screen notification when a technician connects, showing technician name and company. Dashboard-configurable message template (supports `{{technician_name}}`, `{{company}}`, `{{time}}`), duration (5-60s), position (top-left/right, bottom-left/right, center), and dismissible behavior. Increases transparency and user awareness during remote support sessions. Compliance-friendly for privacy policies requiring user notification. ([SPEC-015](specs/SPEC-015-notification-overlay.md))
- [ ] Multi-monitor switching — P2
- [ ] File transfer — P3 (out of scope for native-remote-control v1)
- [ ] Session recording — P3 (out of scope for native-remote-control v1)
@@ -84,6 +87,7 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
- [ ] **Managed-agent installer builder ("Build Installer")** — P2 — dashboard wizard to build a pre-labeled persistent-agent installer (Name/Company/Site/Department/Device Type/Tag/Type) with Download / Copy URL / Send Link, reusing the existing embed-config download path; adds department + device_type to EmbeddedConfig/AgentStatus so labels persist at install time. Pairs with revocable per-machine keys; signature-vs-appended-config is the key open question. **[→ v2 Phase 2]** ([SPEC-007](specs/SPEC-007-managed-agent-installer-builder.md))
- [ ] **Valuable error messages (structured errors + no silent swallows)** — P2 — one structured API error envelope with stable codes + a correlation id that also lands in the logs; contextual tracing on server/agent; sweep the 37 `let _ =` swallows (the pattern that hid the migration-005 bug); dashboard surfaces the real cause + id instead of a generic line. **[→ v2 Phase 0/1 conventions]** ([SPEC-008](specs/SPEC-008-valuable-error-messages.md))
- [ ] **Feature-rich, fully-documented management API** — P2 — everything the console can do, callable by API: OpenAPI 3.x generated from code (utoipa) + browsable docs at `/api/docs`, long-lived revocable scoped API tokens (PAT-style, distinct from the 24h JWT + agent keys), an API-completeness gap audit, and consistent pagination/error conventions. Distinct from the ADR-001 RMM integration contract. **[→ v2 Phase 3]** ([SPEC-009](specs/SPEC-009-feature-rich-documented-api.md))
- [ ] **Branding and white-label configuration** — P2 — Allow MSPs to customize logo, colors, and product name for white-labeled remote support. Dashboard admin settings page with logo upload (PNG/SVG, max 2MB), brand hue slider (OKLCH 0-360°, default 184=cyan), product name override, company name, and favicon. Agent tray tooltip uses custom product name from registry. Singleton database table with public GET endpoint for unauthenticated rendering. CSS variables (`--brand-hue`, `--accent`, `--panel`) for dynamic theming. **[→ v2 Phase 2]** ([SPEC-014](specs/SPEC-014-branding-whitelabel.md))
- [ ] Programmatic session pre-create + viewer-token (integration contract) — P2
## Security & Infrastructure
@@ -93,5 +97,6 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
## Future Considerations
- [ ] macOS / Linux remote-control agents — P3
- [ ] **Cross-platform agent support (macOS and Linux)** — P2 — Enable remote control beyond Windows with native agents for macOS 12+ and Ubuntu 22.04+ LTS. Platform abstraction layer for capture/input/encoding, VideoToolbox (macOS) and VA-API (Linux) H.264 encoding, .app/.deb/.rpm packaging. Unblocks multi-platform MSP adoption. ([SPEC-010](specs/SPEC-010-cross-platform-agents.md))
- [ ] **Mobile agent support (iOS and Android as remote control targets)** — P3 — Native mobile apps for supervised support sessions. iOS: ReplayKit screen sharing (view-only, no input injection due to sandboxing). Android: MediaProjection screen capture + Accessibility Service input injection. Support-code authentication, push notifications (APNs/FCM), consent-first model. Foreground-only operation (OS limitation). Distinct from GuruRMM SPEC-017 (MDM/management). ([SPEC-011](specs/SPEC-011-mobile-agents.md))
- [ ] Auto-update for the agent — P3

View File

@@ -0,0 +1,358 @@
# SPEC-010: Cross-Platform Agent Support (macOS and Linux)
**Status:** Proposed
**Priority:** P2
**Requested By:** Mike Swanson (2026-05-30)
**Estimated Effort:** X-Large
## Overview
Expand GuruConnect's agent support beyond Windows to include macOS and Linux platforms, enabling remote control and support across the full spectrum of enterprise and home computing environments. This cross-platform expansion addresses a critical market gap—ScreenConnect, Splashtop, and AnyDesk all support macOS/Linux, and GuruConnect's Windows-only limitation blocks adoption by multi-platform organizations. The primary technical challenge is abstracting platform-specific screen capture, input injection, and video encoding while maintaining the core protobuf-over-WSS transport and session model. Success criteria: feature parity with the Windows agent (capture, input, tray presence, persistent/support-code modes, H.264 encoding) on macOS 12+ and Ubuntu 22.04+ LTS.
## Scope
### Included in v1
**macOS Agent (Priority 1):**
- Screen capture via ScreenCaptureKit API (macOS 13+) with AVFoundation fallback (macOS 12)
- Input injection via CGEvent
- H.264 encoding via VideoToolbox
- Menu bar icon (NSStatusItem) with status and exit option
- `guruconnect://` protocol handler registration
- .app bundle packaging and code signing
- Screen Recording permission prompt and validation
- Universal binary (x86_64 + arm64)
**Linux Agent (Priority 2):**
- Screen capture via X11 (XShm) with Wayland pipewire fallback detection
- Input injection via XTest (X11) or uinput (Wayland)
- H.264 encoding via VA-API (hardware) or software fallback (libx264)
- System tray icon via StatusNotifier/AppIndicator
- `guruconnect://` protocol handler (.desktop file)
- .deb packaging (Ubuntu/Debian) and .rpm (Fedora/RHEL)
- x86_64 binary
**Shared Cross-Platform:**
- Unified agent codebase with platform-specific modules behind traits
- Same protobuf protocol (no wire format changes)
- Same persistent/support-code authentication modes
- Same AgentStatus metadata reporting (OS, architecture, uptime, logged-on user)
- Viewer compatibility: existing native Windows viewer and web viewer work unchanged
### Explicitly out of scope
- **FreeBSD/BSD support** — defer; assess demand post-launch
- **Wayland-native screen capture** — v1 uses X11 compatibility layer; native Wayland portal support is Phase 2
- **Multi-monitor switching on macOS/Linux** — already deferred on Windows (P2 roadmap item); cross-platform parity follows
- **macOS < 12** — 12.x is the minimum (released Oct 2021, 95%+ adoption)
- **Linux distros outside Ubuntu 22.04+ / Debian 11+ / Fedora 38+ / RHEL 9+** — official support limited; community builds acceptable
## Architecture
### Agent Refactor: Platform Abstraction Layer
Current Windows agent (`agent/src/`) tightly couples platform APIs. Refactor into:
```
agent/src/
├── main.rs # CLI entry + platform dispatch
├── session/ # Session logic (platform-agnostic)
├── transport/ # WebSocket (unchanged)
├── platform/ # NEW: trait definitions
│ ├── mod.rs # PlatformCapture, PlatformInput, PlatformTray traits
│ ├── windows/ # Windows impl (existing code refactored)
│ │ ├── capture.rs # DXGI + GDI behind PlatformCapture
│ │ ├── input.rs # Win32 SendInput behind PlatformInput
│ │ ├── encoder.rs # Media Foundation H.264
│ │ └── tray.rs # Win32 shell tray
│ ├── macos/ # NEW: macOS impl
│ │ ├── capture.rs # ScreenCaptureKit/AVFoundation
│ │ ├── input.rs # CGEvent
│ │ ├── encoder.rs # VideoToolbox H.264
│ │ └── tray.rs # NSStatusItem (Objective-C via objc2 crate)
│ └── linux/ # NEW: Linux impl
│ ├── capture.rs # X11 XShm / pipewire detection
│ ├── input.rs # XTest / uinput
│ ├── encoder.rs # VA-API / libx264
│ └── tray.rs # StatusNotifier
├── encoder/ # Color conversion, raw fallback (platform-agnostic)
├── viewer/ # Native viewer (winit cross-platform, unchanged)
├── config.rs # Config (unchanged)
└── install.rs # Installation (platform-specific #[cfg] blocks)
```
**Key traits:**
```rust
// agent/src/platform/mod.rs
pub trait PlatformCapture: Send {
fn capture_frame(&mut self) -> Result<CapturedFrame>;
fn get_dimensions(&self) -> (u32, u32);
}
pub trait PlatformInput: Send {
fn inject_mouse(&mut self, event: MouseEvent) -> Result<()>;
fn inject_keyboard(&mut self, event: KeyboardEvent) -> Result<()>;
}
pub trait PlatformEncoder: Send {
fn encode(&mut self, frame: &[u8], width: u32, height: u32) -> Result<Vec<u8>>;
fn set_bitrate(&mut self, kbps: u32);
}
pub trait PlatformTray: Send {
fn show(&mut self, status: &str) -> Result<()>;
fn poll_events(&mut self) -> Vec<TrayEvent>;
}
```
### Protobuf Changes
**None.** The existing `AgentStatus` message already carries OS/arch metadata; viewer consumes frames opaquely. Protocol remains unchanged.
### Database Schema
**No migration required.** The `connect_machines` table's `os` and `architecture` fields (part of SPEC-003 inventory) already accommodate macOS/Linux values.
### Agent Build & Packaging
**Cargo.toml adjustments:**
```toml
[target.'cfg(target_os = "macos")'.dependencies]
core-graphics = "0.23"
core-foundation = "0.9"
objc2 = "0.5"
objc2-foundation = "0.2"
objc2-app-kit = "0.2"
security-framework = "2.9"
[target.'cfg(target_os = "linux")'.dependencies]
x11 = { version = "2.21", features = ["xlib", "xtest", "xrandr"] }
libva = "0.4"
```
**macOS packaging:**
- `.app` bundle structure: `GuruConnect.app/Contents/MacOS/guruconnect`, `Info.plist` with protocol handler
- Code signing: `codesign --sign "Developer ID Application: Arizona Computer Guru LLC"`
- Notarization via `notarytool` (Apple requirement for macOS 10.15+)
- Universal binary: `cargo build --release --target x86_64-apple-darwin && cargo build --release --target aarch64-apple-darwin && lipo -create ...`
**Linux packaging:**
- `.deb` via `cargo-deb`:
```toml
[package.metadata.deb]
maintainer = "AZ Computer Guru <support@azcomputerguru.com>"
depends = "libx11-6, libxext6, libxtst6"
section = "net"
assets = [
["target/release/guruconnect", "usr/bin/", "755"],
["deploy/linux/guruconnect.desktop", "usr/share/applications/", "644"],
]
```
- `.rpm` via `cargo-generate-rpm`
### Distribution
- **Server downloads:** `/downloads/guruconnect-macos.dmg`, `/downloads/guruconnect-linux.deb`, `/downloads/guruconnect-linux.rpm`
- **Dashboard detection:** browser User-Agent → suggest correct download
- **Auto-update (out of scope for v1):** defer cross-platform updater to Phase 2
## Implementation Details
### Files to Create
**macOS:**
- `agent/src/platform/macos/capture.rs` — ScreenCaptureKit stream + AVFoundation fallback
- `agent/src/platform/macos/input.rs` — CGEventCreateMouseEvent, CGEventCreateKeyboardEvent, CGEventPost
- `agent/src/platform/macos/encoder.rs` — VTCompressionSession (VideoToolbox)
- `agent/src/platform/macos/tray.rs` — NSStatusBar + NSMenu via objc2
- `agent/Info.plist.template` — bundle metadata, protocol handler (`CFBundleURLSchemes`)
**Linux:**
- `agent/src/platform/linux/capture.rs` — X11Display::open, XShmGetImage; pipewire detection fallback (log warning)
- `agent/src/platform/linux/input.rs` — XTestFakeMotionEvent, XTestFakeButtonEvent, XTestFakeKeyEvent
- `agent/src/platform/linux/encoder.rs` — libva VAConfigAttrib + VABufferType; software fallback via openh264
- `agent/src/platform/linux/tray.rs` — dbus org.kde.StatusNotifierItem protocol
- `deploy/linux/guruconnect.desktop` — .desktop file with `MimeType=x-scheme-handler/guruconnect`
**Shared:**
- `agent/src/platform/mod.rs` — trait definitions + platform factory `fn create_platform() -> Box<dyn Platform>`
- Refactor `agent/src/capture/{dxgi,gdi}.rs` → `agent/src/platform/windows/capture.rs`
- Refactor `agent/src/input/{mouse,keyboard}.rs` → `agent/src/platform/windows/input.rs`
- Refactor `agent/src/encoder/h264.rs` → `agent/src/platform/windows/encoder.rs` (Media Foundation)
### Key Logic
**macOS screen capture (ScreenCaptureKit, macOS 13+):**
```rust
// agent/src/platform/macos/capture.rs
use core_graphics::display::CGDisplay;
use objc2::rc::Id;
use objc2_foundation::NSArray;
use objc2_screen_capture_kit::{SCStreamConfiguration, SCStream, SCContentFilter, SCStreamOutputType};
pub struct MacOSCapture {
stream: Id<SCStream>,
latest_frame: Arc<Mutex<Option<Vec<u8>>>>,
}
impl PlatformCapture for MacOSCapture {
fn capture_frame(&mut self) -> Result<CapturedFrame> {
// ScreenCaptureKit delivers frames to delegate callback
let frame = self.latest_frame.lock().unwrap().clone()
.ok_or(anyhow!("No frame available"))?;
Ok(CapturedFrame { data: frame, width: self.width, height: self.height })
}
}
```
**macOS input injection:**
```rust
// agent/src/platform/macos/input.rs
use core_graphics::event::{CGEvent, CGEventType, CGMouseButton, EventField};
impl PlatformInput for MacOSInput {
fn inject_mouse(&mut self, event: MouseEvent) -> Result<()> {
let cg_event = match event.button {
MouseButton::Left => CGEvent::new_mouse_event(
None, CGEventType::LeftMouseDown,
CGPoint::new(event.x as f64, event.y as f64),
CGMouseButton::Left
)?,
// ...
};
cg_event.post(CGEventTapLocation::HID);
Ok(())
}
}
```
**Linux X11 capture:**
```rust
// agent/src/platform/linux/capture.rs
use x11::xlib::{XOpenDisplay, XDefaultRootWindow, XGetImage, ZPixmap};
use x11::xshm::{XShmAttach, XShmGetImage};
pub struct LinuxCapture {
display: *mut Display,
root: Window,
shm_info: XShmSegmentInfo,
}
impl PlatformCapture for LinuxCapture {
fn capture_frame(&mut self) -> Result<CapturedFrame> {
let img = unsafe { XShmGetImage(self.display, self.root, ...) };
// Convert XImage BGRA → frame buffer
Ok(CapturedFrame { data: converted, width, height })
}
}
```
**Permission handling (macOS Screen Recording):**
```rust
// agent/src/platform/macos/permissions.rs
use objc2_av_foundation::AVCaptureDevice;
pub fn check_screen_recording_permission() -> bool {
// macOS 10.15+ requires explicit Screen Recording permission
// Trigger prompt on first run; subsequent checks use TCC database status
AVCaptureDevice::authorizationStatusForMediaType(AVMediaTypeVideo) == AVAuthorizationStatusAuthorized
}
```
## Security Considerations
### macOS Security
- **Screen Recording permission:** User must grant permission in System Settings → Privacy & Security → Screen Recording. Agent detects denial and logs instructions.
- **Gatekeeper:** Code signing + notarization required to avoid "unidentified developer" block.
- **Hardened runtime:** Enable hardened runtime entitlements (`com.apple.security.cs.allow-unsigned-executable-memory` for JIT, if needed).
- **Input injection:** No additional permission for CGEvent (runs as the logged-on user).
### Linux Security
- **X11 security:** XTest extension enabled by default; uinput requires `/dev/uinput` write access (typically granted to `input` group).
- **Wayland sandboxing:** v1 runs under XWayland (X11 compatibility); native Wayland requires PipeWire portal (user consent per-session).
- **AppArmor/SELinux:** Agent may need profile exemptions for screen capture on hardened distros.
### Authentication
**No protocol changes.** macOS/Linux agents authenticate with the same support-code or persistent agent key (`AGENT_API_KEY` or per-agent `cak_*` key from SPEC-004). Relay server validates identically.
### Audit Events
Existing `events` table logs agent connections with `os` and `architecture` fields. No schema change needed.
## Testing Strategy
### Unit Tests
- Mock platform traits for capture/input/encoder modules
- Test frame conversion (BGRA → RGB, stride alignment)
- Test protobuf message serialization (unchanged, but validate on new platforms)
### Integration Tests
- **macOS test rig:** macOS 13 VM (UTM or Parallels), run agent, verify viewer connects and displays screen
- **Linux test rig:** Ubuntu 22.04 LXC container (X11 forwarded), verify capture + input
- **Cross-platform viewer:** existing Windows native viewer connects to macOS/Linux agents
### Manual Testing Scenarios
1. **macOS install:** Download `.app`, drag to Applications, launch, grant Screen Recording permission, enter support code, verify viewer connects.
2. **Linux install:** `sudo dpkg -i guruconnect.deb`, run `guruconnect --support-code ABC123`, verify tray icon, test remote control.
3. **Encoding quality:** Compare H.264 output from VideoToolbox (macOS), VA-API (Linux), Media Foundation (Windows) — validate frame rate and bitrate parity.
4. **Input fidelity:** Test full keyboard (including modifiers, function keys) and mouse (click, drag, scroll) on all three platforms.
5. **Protocol handler:** Click `guruconnect://connect?session=...` link, verify viewer launches with session ID pre-filled.
### CI/CD Additions
- **macOS build:** Gitea Actions runner on macOS (M1 or x86_64), build universal binary, sign, notarize
- **Linux build:** Existing `ubuntu-latest` runner, build `.deb` and `.rpm` via cargo-deb/cargo-generate-rpm
- **Artifact upload:** Upload binaries to `server/static/downloads/` on release tag
## Effort Estimate & Dependencies
**Size:** X-Large (12-16 weeks, 1 developer)
**Breakdown:**
- Platform abstraction refactor (Windows code): 2 weeks
- macOS implementation (capture, input, encoder, tray): 4 weeks
- Linux implementation (X11 capture, input, VA-API encoder, tray): 3 weeks
- Packaging and code signing (macOS notarization, .deb/.rpm): 2 weeks
- Testing, documentation, CI/CD integration: 2 weeks
- Buffer for platform-specific edge cases: 1-3 weeks
**Dependencies:**
- **SPEC-002 v2 Phase 1 completion** — per-agent keys and secure session core must be stable (already shipped)
- **SPEC-004 deterministic machine identity** — macOS/Linux need stable `machine_uid` derivation (macOS: `IOPlatformUUID`, Linux: `/etc/machine-id`)
- **Code signing infrastructure:** Apple Developer Program account + Azure Trusted Signing already in place (for Windows); reuse for macOS
- **Test infrastructure:** macOS VM or bare-metal test host, Linux LXC container with X11
**Unblocks:**
- Multi-platform MSP adoption (organizations with mixed Windows/macOS/Linux fleets)
- Education/developer market (high macOS/Linux penetration)
- SPEC-003 machine inventory (macOS/Linux populate same fields)
## Open Questions
1. **Wayland native support timeline?** — v1 uses X11 compatibility (XWayland); when do we commit to PipeWire portal? Defer to Phase 2 pending Wayland adoption metrics.
2. **ARM Linux (Raspberry Pi, ARM servers)?** — Ubuntu 22.04 arm64 is viable; add as a build target? Defer to community request.
3. **macOS Ventura (13.0) vs. Monterey (12.0) adoption split?** — ScreenCaptureKit (13.0+) is preferred; fallback to AVFoundation for 12.x. Monitor 12.x usage after 6 months; deprecate if <5%.
4. **Linux H.264 licensing?** — VA-API uses hardware codecs (no license issue); software fallback via openh264 (Cisco BSD license, royalty-free). Confirm distro packaging allows bundling.
5. **System tray on headless Linux servers?** — Agent can run headless (tray is optional); `--no-tray` flag for server deployments.
---
**Cross-references:**
- ADR-001: GuruConnect is a standalone product (no RMM coupling)
- SPEC-002: v2 modernization architecture (agent key model)
- SPEC-003: Machine inventory (OS/arch fields)
- SPEC-004: Stable machine identity (macOS IOPlatformUUID, Linux /etc/machine-id)

View File

@@ -0,0 +1,482 @@
# SPEC-011: Mobile Agent Support (iOS and Android as Remote Control Targets)
**Status:** Proposed
**Priority:** P3
**Requested By:** Mike Swanson (2026-05-30)
**Estimated Effort:** X-Large (16-20 weeks, requires mobile development expertise)
## Overview
Enable remote viewing and control of iOS/Android mobile devices by building native GuruConnect agent apps for the App Store and Google Play. Unlike desktop agents that run persistently in the background, mobile agents operate within OS sandbox constraints: they require user consent to share the screen, must remain in the foreground during sessions, and (on iOS) cannot inject input at all. The primary use case is **supervised support sessions**—a user opens the app, shares their screen with a technician, and the technician can see the screen (both platforms) and remotely control it (Android only). This is fundamentally different from desktop remote control; it's an attended, consent-driven support tool constrained by mobile OS security models.
**Use Cases:**
- Support technician walks a customer through app setup while viewing their mobile screen in real-time
- Android device troubleshooting with remote control (tap, swipe, type) via Accessibility Service
- iOS device screen sharing for demonstration or guided support (view-only, user retains control)
**Success Criteria:**
- iOS app (iOS 14+) shares screen via ReplayKit with user consent; viewer sees live screen; no input injection
- Android app (Android 10+) shares screen via MediaProjection and accepts remote input via Accessibility Service
- Both apps connect using same protobuf-over-WSS protocol and support-code authentication as desktop agents
- Push notifications wake the app when a support session is requested
## Scope
### Included in v1
**iOS Agent (View-Only):**
- Native Swift/SwiftUI app targeting iOS 14+ and iPadOS 14+
- Screen capture via ReplayKit 2 (`RPScreenRecorder`, `RPBroadcastSampleHandler`)
- H.264 encoding via VideoToolbox
- User consent required: "Start Broadcast" button triggers system permission prompt
- Foreground-only operation (app must remain visible during session)
- Support-code authentication (6-digit code entry)
- Push notification (APNs) to alert user of incoming support request
- **NO input injection** (iOS sandboxing prevents third-party input; user controls their own device)
- Displays "Session Active" banner with duration and "Stop Sharing" button
**Android Agent (View + Control):**
- Native Kotlin/Jetpack Compose app targeting Android 10+ (API 29+)
- Screen capture via MediaProjection API (requires user consent per session)
- H.264 encoding via MediaCodec
- Input injection via Accessibility Service (user must enable in Settings → Accessibility)
- Foreground service with persistent notification during session
- Support-code authentication (6-digit code entry)
- Push notification (FCM) to alert user of incoming support request
- Displays ongoing notification: "GuruConnect session active - Tap to stop"
**Shared Cross-Platform:**
- Same protobuf protocol (`AgentStatus`, `FrameData`, `InputEvent`) as desktop agents
- Support-code-only authentication (persistent agent mode deferred to Phase 2)
- Relay server unchanged (mobile agents are just another platform)
- Dashboard shows mobile devices with OS icon (iOS/Android) and "Mobile" badge
- Existing native/web viewers display mobile screens without modification
### Explicitly out of scope
- **Persistent/unattended agent mode** — v1 is attended-only (user must open the app and consent)
- **iOS input injection** — technically impossible without jailbreak or Apple Private APIs (violates App Store guidelines)
- **Background screen capture** — both iOS and Android require the app to be foreground during capture
- **File transfer** — defer to Phase 2 (not in desktop agents yet per roadmap)
- **Chat** — defer to Phase 2 (desktop agents have it, but deprioritized for mobile v1)
- **Multi-device support in single app** — one mobile device = one agent instance
- **Tablet-optimized UI** — v1 UI is phone-first; iPad/Android tablet use same layout
## Architecture
### iOS App Structure
```
GuruConnectMobile-iOS/
├── App/
│ ├── GuruConnectApp.swift # SwiftUI app entry
│ ├── ContentView.swift # Main UI (support code entry, status)
│ ├── SessionView.swift # Active session UI (duration, stop button)
│ └── Info.plist # Capabilities, permissions
├── Broadcast/ # ReplayKit broadcast extension
│ ├── SampleHandler.swift # RPBroadcastSampleHandler subclass
│ ├── VideoEncoder.swift # VideoToolbox H.264 encoding
│ └── Info.plist # Extension config
├── Shared/
│ ├── Protocol/ # Protobuf messages (Swift codegen)
│ ├── Transport/ # WebSocket client (Starscream)
│ ├── Authentication.swift # Support code validation
│ └── PushNotifications.swift # APNs registration + handling
└── GuruConnectMobile.xcodeproj
```
**ReplayKit architecture:**
- Main app registers broadcast extension via `RPSystemBroadcastPickerView`
- User taps "Start Broadcast" → system shows app picker → user selects GuruConnect
- Extension (`SampleHandler`) receives CMSampleBuffers in `processSampleBuffer(_:with:)`
- Extension encodes H.264 via VideoToolbox, sends frames to shared App Group container
- Main app reads from shared container, sends frames via WebSocket to relay server
- App Group required: shared data between app and extension (`group.com.azcomputerguru.guruconnect`)
### Android App Structure
```
GuruConnectMobile-Android/
├── app/src/main/
│ ├── java/com/azcomputerguru/guruconnect/
│ │ ├── MainActivity.kt # Jetpack Compose UI
│ │ ├── SessionActivity.kt # Active session screen
│ │ ├── ScreenCaptureService.kt # Foreground service, MediaProjection
│ │ ├── InputAccessibilityService.kt # AccessibilityService for input injection
│ │ ├── VideoEncoder.kt # MediaCodec H.264 encoding
│ │ ├── WebSocketClient.kt # OkHttp WebSocket
│ │ ├── ProtobufHandler.kt # Protobuf serialization
│ │ └── PushMessagingService.kt # FCM receiver
│ ├── res/
│ │ ├── layout/ # XML layouts (if not full Compose)
│ │ ├── values/strings.xml
│ │ └── xml/accessibility_service_config.xml
│ └── AndroidManifest.xml # Permissions, services
├── proto/ # Protobuf definitions (shared with server)
└── build.gradle
```
**MediaProjection architecture:**
- User grants MediaProjection permission via `MediaProjectionManager.createScreenCaptureIntent()`
- `ScreenCaptureService` (foreground service) creates `VirtualDisplay` → frames to `ImageReader`
- `VideoEncoder` encodes frames with `MediaCodec` (H.264)
- `WebSocketClient` sends encoded frames to relay server
- `InputAccessibilityService` receives `InputEvent` protobuf messages, dispatches `AccessibilityService.dispatchGesture()`
### Protobuf Changes
**Minor additions to support mobile-specific metadata:**
```protobuf
// proto/guruconnect.proto
message AgentStatus {
// Existing fields...
optional MobileCapabilities mobile_capabilities = 20;
}
message MobileCapabilities {
bool supports_input_injection = 1; // false for iOS, true for Android (if Accessibility enabled)
bool requires_foreground = 2; // true for both (can't capture in background)
bool requires_user_consent = 3; // true for both (MediaProjection/ReplayKit consent)
}
message InputEvent {
// Existing MouseEvent/KeyboardEvent...
optional TouchEvent touch_event = 3; // NEW: mobile touch events
}
message TouchEvent {
enum Action {
DOWN = 0;
MOVE = 1;
UP = 2;
}
Action action = 1;
float x = 2; // normalized 0.0-1.0
float y = 3;
int32 pointer_id = 4; // for multi-touch
}
```
### Database Schema
**No migration required.** Mobile devices populate existing `connect_machines` table with:
- `os`: "iOS" or "Android"
- `os_version`: "17.2.1", "14.0", etc.
- `architecture`: "arm64", "aarch64"
- `device_type`: "iPhone 15 Pro", "Samsung Galaxy S24", etc. (from device model identifier)
### Push Notifications
**iOS (APNs):**
- App registers for push on launch: `UNUserNotificationCenter.requestAuthorization()`
- Server stores APNs device token in `connect_machines.push_token`
- When viewer requests session, server sends APNs push: `{"aps": {"alert": "Support session requested", "sound": "default"}, "session_id": "..."}`
- User taps notification → app opens, auto-fills support code, prompts to start broadcast
**Android (FCM):**
- App registers with Firebase on launch, uploads FCM token to server
- Server sends FCM push when session requested
- User taps notification → `MainActivity` opens with support code pre-filled
### Input Injection (Android Only)
**AccessibilityService setup:**
1. User enables service in Settings → Accessibility → GuruConnect → toggle ON
2. App declares service in `AndroidManifest.xml`:
```xml
<service android:name=".InputAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter><action android:name="android.accessibilityservice.AccessibilityService" /></intent-filter>
<meta-data android:name="android.accessibilityservice.config"
android:resource="@xml/accessibility_service_config" />
</service>
```
3. During session, relay sends `InputEvent` (touch/swipe) → service dispatches:
```kotlin
val path = Path().apply { moveTo(x, y) }
val gesture = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, 0, duration))
.build()
dispatchGesture(gesture, null, null)
```
**iOS: No input injection.** Relay server detects `mobile_capabilities.supports_input_injection = false` and disables input controls in viewer UI (show "View-Only Mode" banner).
## Implementation Details
### Files to Create
**iOS (Swift/SwiftUI):**
- `GuruConnectMobile-iOS/App/GuruConnectApp.swift` — App entry, scene setup
- `GuruConnectMobile-iOS/App/ContentView.swift` — Support code entry, connection status
- `GuruConnectMobile-iOS/App/SessionView.swift` — Active session UI (timer, stop button)
- `GuruConnectMobile-iOS/Broadcast/SampleHandler.swift` — ReplayKit extension, frame capture
- `GuruConnectMobile-iOS/Broadcast/VideoEncoder.swift` — VideoToolbox H.264 encoding
- `GuruConnectMobile-iOS/Shared/Transport/WebSocketClient.swift` — Starscream WebSocket wrapper
- `GuruConnectMobile-iOS/Shared/Protocol/Protobuf.swift` — Swift protobuf codegen
- `GuruConnectMobile-iOS/Shared/PushNotifications.swift` — APNs registration + handling
**Android (Kotlin/Jetpack Compose):**
- `app/src/main/java/.../MainActivity.kt` — Compose UI, support code entry
- `app/src/main/java/.../SessionActivity.kt` — Active session screen
- `app/src/main/java/.../ScreenCaptureService.kt` — MediaProjection foreground service
- `app/src/main/java/.../InputAccessibilityService.kt` — Accessibility service for input
- `app/src/main/java/.../VideoEncoder.kt` — MediaCodec H.264 encoding
- `app/src/main/java/.../WebSocketClient.kt` — OkHttp WebSocket
- `app/src/main/java/.../ProtobufHandler.kt` — Protobuf serialization (protobuf-javalite)
- `app/src/main/java/.../PushMessagingService.kt` — FCM message receiver
**Server (minor additions):**
- `server/src/push/` — NEW module for APNs/FCM push sending
- `server/src/push/apns.rs` — APNs HTTP/2 client (via `a2` crate)
- `server/src/push/fcm.rs` — FCM HTTP v1 client
- `server/src/push/mod.rs` — Unified `send_push_notification(device_token, session_id)` API
- `server/src/api/devices.rs` — NEW: `POST /api/devices/:id/push-token` to store APNs/FCM tokens
- `proto/guruconnect.proto` — Add `MobileCapabilities` and `TouchEvent` messages
**Shared:**
- `proto/guruconnect.proto` — Update with mobile messages (protobuf source of truth)
### Key Logic
**iOS ReplayKit screen capture:**
```swift
// GuruConnectMobile-iOS/Broadcast/SampleHandler.swift
import ReplayKit
import VideoToolbox
class SampleHandler: RPBroadcastSampleHandler {
var encoder: VideoEncoder?
var wsClient: WebSocketClient?
override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) {
encoder = VideoEncoder()
wsClient = WebSocketClient(url: "wss://connect.azcomputerguru.com/ws/agent")
wsClient?.connect(supportCode: setupInfo?["supportCode"] as? String)
}
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
guard sampleBufferType == .video else { return }
if let encoded = encoder?.encode(sampleBuffer) {
let frameData = FrameData(data: encoded, width: 1920, height: 1080)
wsClient?.send(frameData)
}
}
}
```
**Android MediaProjection screen capture:**
```kotlin
// ScreenCaptureService.kt
class ScreenCaptureService : Service() {
private lateinit var mediaProjection: MediaProjection
private lateinit var virtualDisplay: VirtualDisplay
private lateinit var imageReader: ImageReader
private val encoder = VideoEncoder()
private val wsClient = WebSocketClient()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val resultCode = intent?.getIntExtra("resultCode", 0) ?: return START_NOT_STICKY
val data = intent.getParcelableExtra<Intent>("data") ?: return START_NOT_STICKY
val projection = MediaProjectionManager.getMediaProjection(resultCode, data)
imageReader = ImageReader.newInstance(1920, 1080, PixelFormat.RGBA_8888, 2)
virtualDisplay = projection.createVirtualDisplay(
"GuruConnect",
1920, 1080, densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader.surface, null, null
)
imageReader.setOnImageAvailableListener({ reader ->
val image = reader.acquireLatestImage()
val encoded = encoder.encode(image)
wsClient.sendFrame(encoded)
image.close()
}, backgroundHandler)
return START_STICKY
}
}
```
**Android Accessibility Service input injection:**
```kotlin
// InputAccessibilityService.kt
class InputAccessibilityService : AccessibilityService() {
fun injectTouch(x: Float, y: Float, action: TouchEvent.Action) {
val displayMetrics = resources.displayMetrics
val absX = x * displayMetrics.widthPixels
val absY = y * displayMetrics.heightPixels
val path = Path().apply { moveTo(absX, absY) }
val duration = if (action == TouchEvent.Action.DOWN || action == TouchEvent.Action.UP) 10L else 50L
val gesture = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, 0, duration))
.build()
dispatchGesture(gesture, object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription) {
Log.d("GC", "Touch injected: ($absX, $absY)")
}
}, null)
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {}
override fun onInterrupt() {}
}
```
**Push notification handling (iOS):**
```swift
// PushNotifications.swift
import UserNotifications
class PushNotificationHandler: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
if let sessionId = userInfo["session_id"] as? String {
// Navigate to SessionView with pre-filled support code
NotificationCenter.default.post(name: .sessionRequested, object: sessionId)
}
completionHandler()
}
}
```
## Security Considerations
### iOS Security
- **Screen Recording permission:** User must explicitly start ReplayKit broadcast; cannot be triggered remotely
- **App Sandbox:** Extension runs in separate sandbox from main app; shared data via App Group only
- **APNs authentication:** Server uses APNs auth key (`.p8` file) with Team ID + Key ID
- **No input injection:** Not a security choice—iOS platform limitation (actually a security feature)
### Android Security
- **MediaProjection consent:** User must grant permission via system dialog; consent required per session (cannot be saved)
- **Accessibility Service risk:** Granting Accessibility permission is high-privilege; app must clearly explain why (remote support) and warn user
- **Foreground service:** Session runs as foreground service with persistent notification (user always aware)
- **FCM authentication:** Server uses FCM service account key (JSON) for authenticated sends
### Authentication
**Support code only in v1:**
- User enters 6-digit support code from dashboard
- Agent authenticates via `POST /api/auth/support-code` (same as desktop agents)
- Viewer token issued, session begins
**Persistent agent mode deferred to Phase 2:**
- Requires secure storage of agent key (iOS Keychain, Android EncryptedSharedPreferences)
- Requires background keep-alive (iOS: silent push, Android: foreground service)
### Privacy
- **Consent-first model:** User must actively grant screen sharing permission each session
- **No background capture:** OS prevents capturing screen when app is backgrounded (security feature)
- **User can stop anytime:** "Stop Sharing" button (iOS) or notification action (Android)
## Testing Strategy
### Unit Tests
- iOS: XCTest for protobuf serialization, support code validation
- Android: JUnit + MockK for input event handling, encoder logic
### Integration Tests
- **iOS test rig:** iPhone 14 Pro (iOS 17) physical device or simulator (ReplayKit requires real device for broadcast extension)
- **Android test rig:** Pixel 6 (Android 14) physical device (MediaProjection requires real device)
- Test flow: enter support code → grant permissions → viewer connects → verify frames received
### Manual Testing Scenarios
1. **iOS attended session:**
- User opens app, enters support code, taps "Start Broadcast", selects GuruConnect
- Viewer connects, sees iPhone home screen, user navigates Settings
- Verify: frames display correctly, input controls disabled (view-only banner shown)
- User taps "Stop Sharing" → session ends gracefully
2. **Android attended session with input:**
- User opens app, enables Accessibility Service in Settings
- User enters support code, grants MediaProjection permission
- Viewer connects, remotely taps app icon, swipes, types text
- Verify: input events execute on device, foreground notification shows
- User swipes down notification, taps "Stop" → session ends
3. **Push notification wake:**
- Viewer requests session from dashboard
- Push notification appears on locked phone
- User taps notification → app opens with support code pre-filled
- User grants screen sharing → session starts
4. **Low bandwidth:** Throttle connection to 1 Mbps, verify H.264 adapts, frames remain usable
### App Store / Play Store Review
- **iOS App Store:** Requires detailed privacy policy explaining screen recording usage, ReplayKit justification in app review notes
- **Google Play:** Requires Accessibility Service usage justification ("remote support for user's own device with their explicit consent")
## Effort Estimate & Dependencies
**Size:** X-Large (16-20 weeks, 1 developer with mobile experience)
**Breakdown:**
- iOS app + ReplayKit extension: 5 weeks
- Android app + MediaProjection service: 4 weeks
- Android Accessibility Service input injection: 2 weeks
- Push notification backend (APNs + FCM): 2 weeks
- Server protobuf additions + mobile capabilities handling: 1 week
- Viewer UI adjustments (touch event handling, view-only mode): 1 week
- App Store + Play Store submission, review cycles: 2 weeks
- Testing, edge cases, OS compatibility: 2 weeks
- Buffer: 1-3 weeks
**Dependencies:**
- **Apple Developer Program enrollment** ($99/year) — required for APNs + App Store distribution
- **Google Play Developer account** ($25 one-time) — required for Play Store distribution
- **Firebase project setup** (free tier) — for FCM push notifications
- **SPEC-002 v2 Phase 1 completion** — per-agent keys model must be stable (already shipped)
- **Mobile development expertise** — Swift/SwiftUI + Kotlin/Jetpack Compose; consider contract hire if not in-house
**Unblocks:**
- Mobile support parity with competitors (ScreenConnect, TeamViewer, Splashtop all have mobile agents)
- "Show me the problem" use case for phone/tablet support
- BYOD enterprise support (employees request help on personal iOS/Android devices)
## Open Questions
1. **iOS view-only limitation — acceptable to market?** — Competitors (TeamViewer, Splashtop) also view-only on iOS due to platform constraints. Document prominently in UI/marketing.
2. **Android Accessibility Service friction — how to onboard?** — Most users don't know how to enable Accessibility. Need step-by-step wizard with screenshots. Alternatively: offer view-only Android mode (no Accessibility required) as Phase 1, add input in Phase 2.
3. **Foreground-only sessions — user can't multitask?** — Correct. iOS/Android stop screen capture when app backgrounds. This is an OS limitation, not a bug. Frame UI as "supervised support session" not "unattended monitoring."
4. **Push notification reliability?** — APNs/FCM are best-effort, not guaranteed. If push fails, user can manually open app and enter support code. Fallback: dashboard shows "waiting for device" with code to give user over phone.
5. **Cross-platform viewer compatibility?** — Existing native Windows viewer and web viewer already handle arbitrary frame sizes (mobile screens are just smaller). Touch events map to mouse clicks for non-touch-aware viewers.
6. **App Store/Play Store approval risk?** — Accessibility Service apps face extra scrutiny on Android. Emphasize "user-initiated remote support" positioning, not "remote monitoring." Provide detailed privacy policy. Low risk if framed correctly.
7. **Multi-touch support?** — v1 supports single-touch only (maps to mouse). Multi-touch (pinch-zoom, two-finger gestures) deferred to Phase 2 (requires `TouchEvent.pointer_id` array).
---
**Cross-references:**
- GuruRMM SPEC-017: Mobile Device Support (MDM, inventory, lock/wipe) — complementary, not overlapping
- SPEC-002: v2 modernization architecture (per-agent keys)
- SPEC-010: Cross-platform agents (macOS/Linux) — similar platform abstraction approach
- ADR-001: GuruConnect is standalone (no RMM coupling for this feature)

View File

@@ -0,0 +1,931 @@
# SPEC-012: Headless Linux Mode (Serial Console + PTY Shell Access)
**Status:** Proposed
**Priority:** P2
**Requested By:** Mike Swanson (2026-05-30)
**Estimated Effort:** Medium (5-7 weeks)
## Overview
Enable GuruConnect agent support for headless Linux servers (no X11/Wayland GUI) by providing two modes of terminal access: **Serial Console Mode** for boot-level access (GRUB, kernel messages, panics) and **PTY Shell Mode** for normal server management. This addresses critical server management use cases—from emergency recovery to routine administration—without requiring SSH. The viewer displays a terminal emulator (xterm.js web viewer) connected to either the system serial console (`/dev/ttyS0`) or a pseudo-TTY shell session. Serial Console Mode provides true "remote console" access like KVM-over-IP or IPMI Serial-over-LAN, seeing everything the physical monitor would show. PTY Shell Mode provides an interactive shell for normal management tasks. Success criteria: technician can access GRUB bootloader, view kernel boot messages, handle kernel panics, AND perform routine server management—all via GuruConnect dashboard with centralized authentication and audit logging.
**Use Cases:**
- **Boot-level access:** GRUB menu selection, kernel parameter editing, single-user mode
- **Emergency recovery:** Kernel panic diagnosis, filesystem repair, systemd rescue shell
- **Server management:** Package updates, configuration changes, log review (normal shell access)
- **Container debugging:** Exec into running containers via GuruConnect
- **MSP consolidation:** One tool for desktop support (GUI), server boot recovery (console), and server management (shell)
**Success Criteria:**
- **Serial Console Mode:** View GRUB bootloader, kernel boot messages, kernel panics, login prompts—as if sitting at physical console
- **PTY Shell Mode:** Interactive shell (bash/zsh) with full ANSI color, cursor control, vim/nano/htop support
- GuruConnect agent runs on Ubuntu Server 22.04 minimal install (no desktop packages)
- Dashboard mode selector: "Console" vs. "Shell" per agent (user chooses at connection time)
- Same protobuf-over-WSS transport, support-code and persistent-agent authentication
- Audit logging: session recording for both console and shell modes
## Scope
### Included in v1
**Mode 1: Serial Console Mode (True Remote Console)**
- Open system serial console device (`/dev/ttyS0` or `/dev/console`) for raw I/O
- Relay all bytes bidirectionally: console output → `TerminalData` → viewer; viewer input → `TerminalInput` → console
- **Sees everything:** GRUB bootloader menu, kernel boot messages, systemd startup, login prompts, kernel panics
- **Boot-time interaction:** Select GRUB entries, edit kernel parameters, boot into single-user mode
- Requires root privileges (serial console access restricted to root)
- Requires serial console enabled on target server (GRUB + kernel parameters configured)
- No PTY spawning—direct device I/O, like `screen /dev/ttyS0 115200`
- Agent config flag: `console_mode: true` + `console_device: "/dev/ttyS0"`
**Mode 2: PTY Shell Mode (Interactive Shell)**
- Detect headless environment (no DISPLAY, no X11/Wayland libraries) at runtime
- Spawn pseudo-TTY (PTY) via `openpty()` + fork/exec shell (`/bin/bash -l` or user's `$SHELL`)
- Terminal I/O: read PTY output → encode as protobuf `TerminalData` → send via WebSocket
- Input: receive protobuf `TerminalInput` → write to PTY master
- Terminal resize: handle `TerminalResize` message → send `SIGWINCH` to PTY
- Fallback shell selection: `$SHELL` env var → `/bin/bash``/bin/sh`
- Graceful PTY cleanup on session end (send exit command, wait for shell exit, close PTY)
- Standard user privileges (runs as agent service user)
**Mode Selection:**
- Dashboard shows mode selector when connecting to headless agent: "Console" vs. "Shell"
- "Console" button: viewer sends `mode: console` in connection request → agent opens `/dev/ttyS0`
- "Shell" button: viewer sends `mode: shell` in connection request → agent spawns PTY
- Agent config specifies default mode if serial console unavailable
- If serial console device doesn't exist or permission denied, fall back to PTY shell mode with warning
**Both Modes Share:**
- Same agent binary: `guruconnect` detects headless and offers both modes
- Same xterm.js viewer (handles both serial console and PTY identically)
**Viewer (Web Viewer):**
- xterm.js-based terminal emulator embedded in `viewer.html`
- Connects to same `/ws/viewer` endpoint with session JWT
- Relay server detects `TerminalData` frames (not `FrameData`) and routes accordingly
- Terminal controls: resize on window resize, copy/paste support, configurable font size
- Session toolbar: connection status, terminal size (e.g., "80x24"), reconnect button
**Viewer (Native Desktop Viewer - optional Phase 2):**
- Defer native viewer terminal support to Phase 2
- v1: web viewer only for terminal sessions (show "Open in browser" prompt if launched via `guruconnect://`)
**Protobuf Protocol:**
- New message types: `TerminalData` (PTY output), `TerminalInput` (keyboard input), `TerminalResize` (window size)
- `AgentStatus` includes `terminal_mode: bool` flag (true for headless agents)
- Dashboard shows terminal icon for headless agents, camera icon for GUI agents
**Dashboard:**
- Detect `terminal_mode: true` in agent status
- "Connect" button opens web viewer in terminal mode (not screen capture mode)
- Agent list shows "Terminal" badge for headless agents
**Session Recording (Audit):**
- Log all terminal I/O to `events` table or separate `terminal_sessions` table
- Playback: recorded session can be replayed as "terminal recording" (asciicast format or raw PTY dump)
### Explicitly out of scope
- **GUI mode on headless agents** — v1 is terminal-only; no attempt to start Xvfb or launch GUI apps
- **SSH key management** — agent uses GuruConnect auth (support code / agent key), not SSH keys
- **File transfer via terminal** — defer to SPEC (file transfer is a separate roadmap item for all agent types)
- **Multi-user terminal sessions** — v1 is single-session console/PTY; no tmux/screen built-in sharing
- **Windows terminal mode** — defer; Windows Server typically has GUI (RDP) or SSH (OpenSSH)
- **macOS terminal mode** — defer; macOS servers are rare and typically have GUI access
- **Framebuffer capture (`/dev/fb0`)** — defer; serial console is more reliable and doesn't require framebuffer device
### Serial Console Setup Requirements (Mode 1)
To use Serial Console Mode, the target Linux server must be configured to output to serial console. This is a **one-time setup per server** (typically done during provisioning):
**Step 1: Configure GRUB**
Edit `/etc/default/grub`:
```bash
# Enable serial console output at 115200 baud
GRUB_TERMINAL="serial console"
GRUB_SERIAL_COMMAND="serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1"
# Kernel console output to both VGA (tty0) and serial (ttyS0)
GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0,115200n8"
```
Update GRUB and reboot:
```bash
sudo update-grub # Debian/Ubuntu
# OR
sudo grub2-mkconfig -o /boot/grub2/grub.cfg # RHEL/CentOS
sudo reboot
```
**Step 2: Enable getty on Serial Console**
Ensure a login prompt appears on serial console after boot:
```bash
sudo systemctl enable serial-getty@ttyS0.service
sudo systemctl start serial-getty@ttyS0.service
```
**Step 3: Verify**
Test serial console locally before configuring GuruConnect:
```bash
sudo screen /dev/ttyS0 115200
# Should see kernel messages, login prompt
```
**What This Provides:**
- ✓ GRUB bootloader menu visible via serial console
- ✓ Kernel boot messages stream to serial console
- ✓ Login prompt on `/dev/ttyS0` after boot
- ✓ Kernel panics output to serial console
- ✓ systemd rescue shell accessible via serial console
**Compatibility:**
- Physical servers: Uses hardware serial port (COM1 = ttyS0)
- Virtual machines: VMware/Proxmox/KVM expose virtual serial port; configure VM to attach serial port
- Cloud VMs: AWS, GCP, Azure offer "Serial Console" feature (already configured); GuruConnect agent can relay it
## Architecture
### Agent Mode Selection
**Connection request handling:**
```rust
// agent/src/session/terminal.rs
pub async fn handle_terminal_session(
ws: WebSocketClient,
mode: TerminalMode, // Console or Shell
support_code: String
) -> Result<()> {
match mode {
TerminalMode::Console => run_console_session(ws, support_code).await,
TerminalMode::Shell => run_shell_session(ws, support_code).await,
}
}
pub enum TerminalMode {
Console, // Serial console (/dev/ttyS0)
Shell, // PTY shell session
}
```
### Agent Serial Console Handling (Mode 1)
**Serial device open:**
```rust
// agent/src/platform/linux/console.rs
use std::fs::OpenOptions;
use std::os::unix::io::AsRawFd;
pub struct ConsoleSession {
device_fd: RawFd,
device_path: String, // "/dev/ttyS0" or "/dev/console"
}
impl ConsoleSession {
pub fn open(device_path: &str) -> Result<Self> {
// Open serial console device for read/write
// Requires root privileges
let file = OpenOptions::new()
.read(true)
.write(true)
.open(device_path)
.context("Failed to open serial console - requires root")?;
let device_fd = file.as_raw_fd();
// Configure terminal settings (115200 baud, 8N1)
unsafe {
let mut termios: libc::termios = std::mem::zeroed();
if libc::tcgetattr(device_fd, &mut termios) != 0 {
return Err(anyhow!("tcgetattr failed"));
}
// Set baud rate to 115200
libc::cfsetispeed(&mut termios, libc::B115200);
libc::cfsetospeed(&mut termios, libc::B115200);
// 8N1 (8 data bits, no parity, 1 stop bit)
termios.c_cflag &= !libc::CSIZE;
termios.c_cflag |= libc::CS8;
termios.c_cflag &= !(libc::PARENB | libc::PARODD);
termios.c_cflag &= !libc::CSTOPB;
// Raw mode (no line buffering, no echo)
libc::cfmakeraw(&mut termios);
if libc::tcsetattr(device_fd, libc::TCSANOW, &termios) != 0 {
return Err(anyhow!("tcsetattr failed"));
}
}
Ok(ConsoleSession {
device_fd,
device_path: device_path.to_string(),
})
}
pub fn read(&self, buf: &mut [u8]) -> Result<usize> {
unsafe {
let n = libc::read(self.device_fd, buf.as_mut_ptr() as *mut _, buf.len());
if n < 0 {
Err(anyhow!("Console read failed"))
} else {
Ok(n as usize)
}
}
}
pub fn write(&self, data: &[u8]) -> Result<()> {
unsafe {
let n = libc::write(self.device_fd, data.as_ptr() as *const _, data.len());
if n < 0 {
Err(anyhow!("Console write failed"))
} else {
Ok(())
}
}
}
}
impl Drop for ConsoleSession {
fn drop(&mut self) {
unsafe { libc::close(self.device_fd); }
}
}
```
**Console session loop:**
```rust
// agent/src/session/console.rs
pub async fn run_console_session(ws: WebSocketClient, support_code: String) -> Result<()> {
// Try /dev/ttyS0 first, fall back to /dev/console
let console = ConsoleSession::open("/dev/ttyS0")
.or_else(|_| ConsoleSession::open("/dev/console"))?;
// Status update: terminal mode, console
ws.send(AgentStatus {
terminal_mode: true,
console_mode: true, // NEW flag
os: "Linux".to_string(),
// ...
}).await?;
let mut buf = vec![0u8; 4096];
loop {
tokio::select! {
// Read console output, send to relay
Ok(n) = tokio::task::spawn_blocking({
let fd = console.device_fd;
move || unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) }
}) => {
if n > 0 {
ws.send(TerminalData {
data: buf[..n as usize].to_vec(),
}).await?;
}
}
// Receive input from relay, write to console
Some(msg) = ws.recv() => {
match msg {
Message::TerminalInput(input) => {
console.write(&input.data)?;
}
Message::Disconnect => break,
// Note: Resize ignored for serial console (not applicable)
_ => {}
}
}
}
}
Ok(())
}
```
### Agent PTY Handling (Mode 2)
**Headless detection:**
```rust
// agent/src/platform/linux/headless.rs
pub fn is_headless() -> bool {
// Check if DISPLAY is unset and no X11/Wayland session detected
std::env::var("DISPLAY").is_err() &&
std::env::var("WAYLAND_DISPLAY").is_err() &&
!std::path::Path::new("/tmp/.X11-unix").exists()
}
```
**PTY spawn:**
```rust
// agent/src/platform/linux/pty.rs
use libc::{openpty, fork, execvp, dup2, STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO, winsize, TIOCSWINSZ};
use std::os::unix::io::RawFd;
pub struct PtySession {
master_fd: RawFd,
child_pid: libc::pid_t,
cols: u16,
rows: u16,
}
impl PtySession {
pub fn spawn(shell: &str, cols: u16, rows: u16) -> Result<Self> {
let mut master_fd: RawFd = 0;
let mut slave_fd: RawFd = 0;
let mut winsize = winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
unsafe {
if openpty(&mut master_fd, &mut slave_fd, std::ptr::null_mut(),
std::ptr::null(), &mut winsize as *mut _) != 0 {
return Err(anyhow!("openpty failed"));
}
let pid = fork();
if pid == 0 {
// Child process: exec shell
dup2(slave_fd, STDIN_FILENO);
dup2(slave_fd, STDOUT_FILENO);
dup2(slave_fd, STDERR_FILENO);
libc::close(master_fd);
libc::close(slave_fd);
let shell_cstr = CString::new(shell)?;
let args = [shell_cstr.as_ptr(), std::ptr::null()];
execvp(shell_cstr.as_ptr(), args.as_ptr());
std::process::exit(1); // exec failed
} else {
// Parent process: close slave, return master FD
libc::close(slave_fd);
Ok(PtySession {
master_fd,
child_pid: pid,
cols,
rows,
})
}
}
}
pub fn read(&self, buf: &mut [u8]) -> Result<usize> {
unsafe {
let n = libc::read(self.master_fd, buf.as_mut_ptr() as *mut _, buf.len());
if n < 0 {
Err(anyhow!("PTY read failed"))
} else {
Ok(n as usize)
}
}
}
pub fn write(&self, data: &[u8]) -> Result<()> {
unsafe {
let n = libc::write(self.master_fd, data.as_ptr() as *const _, data.len());
if n < 0 {
Err(anyhow!("PTY write failed"))
} else {
Ok(())
}
}
}
pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
self.cols = cols;
self.rows = rows;
let winsize = winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
unsafe {
if libc::ioctl(self.master_fd, TIOCSWINSZ, &winsize as *const _) != 0 {
return Err(anyhow!("TIOCSWINSZ failed"));
}
}
// Send SIGWINCH to child process group
unsafe { libc::kill(-self.child_pid, libc::SIGWINCH); }
Ok(())
}
}
impl Drop for PtySession {
fn drop(&mut self) {
unsafe {
libc::close(self.master_fd);
// Send SIGTERM to child, wait briefly, then SIGKILL if still alive
libc::kill(self.child_pid, libc::SIGTERM);
std::thread::sleep(std::time::Duration::from_millis(500));
libc::waitpid(self.child_pid, std::ptr::null_mut(), libc::WNOHANG);
}
}
}
```
**Agent session loop:**
```rust
// agent/src/session/terminal.rs
pub async fn run_terminal_session(ws: WebSocketClient, support_code: String) -> Result<()> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
let mut pty = PtySession::spawn(&shell, 80, 24)?;
// Status update: terminal mode
ws.send(AgentStatus {
terminal_mode: true,
os: "Linux".to_string(),
// ...
}).await?;
let mut buf = vec![0u8; 4096];
loop {
tokio::select! {
// Read PTY output, send to relay
Ok(n) = tokio::task::spawn_blocking({
let master = pty.master_fd;
move || unsafe { libc::read(master, buf.as_mut_ptr() as *mut _, buf.len()) }
}) => {
if n > 0 {
ws.send(TerminalData {
data: buf[..n as usize].to_vec(),
}).await?;
}
}
// Receive input from relay, write to PTY
Some(msg) = ws.recv() => {
match msg {
Message::TerminalInput(input) => {
pty.write(&input.data)?;
}
Message::TerminalResize(resize) => {
pty.resize(resize.cols, resize.rows)?;
}
Message::Disconnect => break,
_ => {}
}
}
}
}
Ok(())
}
```
### Protobuf Protocol Extensions
```protobuf
// proto/guruconnect.proto
message AgentStatus {
// Existing fields...
optional bool terminal_mode = 21; // true for headless agents
optional bool console_mode = 22; // true for serial console mode, false for PTY shell mode
}
message TerminalData {
bytes data = 1; // Raw terminal output (PTY or serial console, includes ANSI escape sequences)
}
message TerminalInput {
bytes data = 1; // Keyboard input from viewer (UTF-8 encoded)
}
message TerminalResize {
uint32 cols = 1; // Terminal width (characters)
uint32 rows = 2; // Terminal height (lines)
// Note: Resize only applies to PTY shell mode; serial console ignores this
}
enum TerminalModeRequest {
SHELL = 0; // Request PTY shell session
CONSOLE = 1; // Request serial console session
}
message SessionRequest {
// Existing fields...
optional TerminalModeRequest terminal_mode_request = 10; // NEW: viewer specifies console vs. shell
}
// Update AgentMessage and ViewerMessage unions
message AgentMessage {
oneof message {
AgentStatus status = 1;
FrameData frame = 2;
TerminalData terminal_data = 10; // NEW
}
}
message ViewerMessage {
oneof message {
InputEvent input = 1;
TerminalInput terminal_input = 10; // NEW
TerminalResize terminal_resize = 11; // NEW
}
}
```
### Relay Server Changes
**Route terminal vs. screen capture sessions:**
```rust
// server/src/relay/mod.rs
async fn handle_agent_message(msg: AgentMessage, session: &Session) {
match msg.message {
Some(agent_message::Message::Status(status)) => {
session.terminal_mode = status.terminal_mode.unwrap_or(false);
// Store in DB: UPDATE sessions SET terminal_mode = ? WHERE id = ?
}
Some(agent_message::Message::TerminalData(data)) => {
// Forward to viewer WebSocket
if let Some(viewer_ws) = session.viewer_ws.lock().await.as_mut() {
viewer_ws.send(ViewerMessage {
message: Some(viewer_message::Message::TerminalData(data))
}).await?;
}
// Optional: append to terminal_recording buffer for audit
}
Some(agent_message::Message::Frame(frame)) => {
// Existing screen capture logic...
}
_ => {}
}
}
async fn handle_viewer_message(msg: ViewerMessage, session: &Session) {
match msg.message {
Some(viewer_message::Message::TerminalInput(input)) => {
// Forward to agent WebSocket
if let Some(agent_ws) = session.agent_ws.lock().await.as_mut() {
agent_ws.send(AgentMessage {
message: Some(agent_message::Message::TerminalInput(input))
}).await?;
}
}
Some(viewer_message::Message::TerminalResize(resize)) => {
// Forward resize to agent
if let Some(agent_ws) = session.agent_ws.lock().await.as_mut() {
agent_ws.send(AgentMessage {
message: Some(agent_message::Message::TerminalResize(resize))
}).await?;
}
}
Some(viewer_message::Message::Input(input)) => {
// Existing GUI input logic...
}
_ => {}
}
}
```
### Web Viewer (xterm.js)
**HTML template:**
```html
<!-- server/static/viewer-terminal.html -->
<!DOCTYPE html>
<html>
<head>
<title>GuruConnect Terminal</title>
<link rel="stylesheet" href="/vendor/xterm/xterm.css" />
<script src="/vendor/xterm/xterm.js"></script>
<script src="/vendor/xterm/xterm-addon-fit.js"></script>
<style>
#terminal { height: 100vh; }
.toolbar { background: #333; color: #fff; padding: 8px; }
</style>
</head>
<body>
<div class="toolbar">
<span id="status">Connecting...</span>
<span id="size" style="float: right;">80x24</span>
</div>
<div id="terminal"></div>
<script>
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Consolas, "Courier New", monospace',
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
}
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
fitAddon.fit();
const ws = new WebSocket(`wss://${location.host}/ws/viewer?token=${TOKEN}&session=${SESSION_ID}`);
ws.onopen = () => {
document.getElementById('status').textContent = 'Connected';
// Send initial terminal size
ws.send(encodeTerminalResize(term.cols, term.rows));
};
ws.onmessage = (event) => {
const msg = decodeProtobuf(event.data);
if (msg.terminal_data) {
term.write(new Uint8Array(msg.terminal_data.data));
}
};
term.onData((data) => {
ws.send(encodeTerminalInput(data));
});
term.onResize(({ cols, rows }) => {
document.getElementById('size').textContent = `${cols}x${rows}`;
ws.send(encodeTerminalResize(cols, rows));
});
window.addEventListener('resize', () => fitAddon.fit());
</script>
</body>
</html>
```
### Dashboard Mode Selector
```javascript
// server/static/dashboard.js
function renderAgentRow(agent) {
const icon = agent.terminal_mode
? '<i class="icon-terminal"></i> Terminal'
: '<i class="icon-screen"></i> Screen';
// For headless agents, show mode selector (Console vs. Shell)
let connectButtons;
if (agent.terminal_mode && agent.online) {
connectButtons = `
<div class="terminal-mode-selector">
<button class="btn-console" onclick="connectToTerminal('${agent.id}', 'console')"
title="Serial console access (GRUB, boot, panics)">
Console
</button>
<button class="btn-shell" onclick="connectToTerminal('${agent.id}', 'shell')"
title="Interactive shell (bash/zsh)">
Shell
</button>
</div>
`;
} else if (!agent.terminal_mode && agent.online) {
// GUI agent
connectButtons = `<button onclick="connectToAgent('${agent.id}')">Connect</button>`;
} else {
connectButtons = '<span>Offline</span>';
}
return `<tr>
<td>${agent.name}</td>
<td>${icon}</td>
<td>${agent.os} ${agent.os_version}</td>
<td>${connectButtons}</td>
</tr>`;
}
function connectToAgent(agentId) {
// GUI agent connection
window.open(`/viewer.html?session=${agentId}&token=${JWT}`, '_blank');
}
function connectToTerminal(agentId, mode) {
// Terminal agent connection with mode parameter
window.open(`/viewer-terminal.html?session=${agentId}&token=${JWT}&mode=${mode}`, '_blank');
}
```
**Dashboard UI for headless agents:**
- Shows two buttons: "Console" and "Shell"
- "Console" button: opens serial console session (GRUB, boot messages, panics)
- "Shell" button: opens PTY shell session (normal server management)
- Tooltip on hover explains each mode
- Mode parameter passed to viewer via URL query string
### Database Schema
**Minor addition to `sessions` table:**
```sql
-- migrations/012_terminal_mode.sql
ALTER TABLE connect_sessions ADD COLUMN terminal_mode BOOLEAN DEFAULT FALSE;
-- Optional: separate table for terminal recordings
CREATE TABLE IF NOT EXISTS terminal_recordings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID REFERENCES connect_sessions(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ DEFAULT NOW(),
ended_at TIMESTAMPTZ,
recording_data BYTEA, -- asciicast JSON or raw PTY dump (compressed)
size_bytes BIGINT,
INDEX idx_terminal_recordings_session (session_id)
);
```
## Implementation Details
### Files to Create
**Agent (Linux-specific):**
- `agent/src/platform/linux/console.rs` — NEW: Serial console device I/O (`/dev/ttyS0`, termios config)
- `agent/src/session/console.rs` — NEW: Console session loop (serial device ↔ WebSocket)
- `agent/src/platform/linux/pty.rs` — PTY spawn, I/O, resize (openpty, fork, exec)
- `agent/src/platform/linux/headless.rs` — Headless detection logic
- `agent/src/session/terminal.rs` — Mode dispatcher (console vs. shell), shell session loop
**Server:**
- `server/src/relay/terminal.rs` — Terminal message routing (TerminalData/Input/Resize)
- `server/static/viewer-terminal.html` — xterm.js-based web terminal viewer
- `server/static/vendor/xterm/` — xterm.js library files (CDN or bundled)
- `server/migrations/012_terminal_mode.sql` — Schema update
**Protobuf:**
- `proto/guruconnect.proto` — Add TerminalData, TerminalInput, TerminalResize messages
**Dashboard:**
- `server/static/dashboard.js` — Detect `terminal_mode`, render terminal icon, route to terminal viewer
### Key Dependencies
```toml
# agent/Cargo.toml (Linux-specific)
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2" # openpty, fork, exec, ioctl
nix = "0.27" # Safe wrappers for POSIX APIs
```
**xterm.js (web viewer):**
- Version: 5.3.0+ (latest stable)
- Addons: `xterm-addon-fit` (auto-resize)
- Delivery: CDN link or bundled in `server/static/vendor/xterm/`
## Security Considerations
### Serial Console Access (Mode 1)
- **Requires root privileges:** Opening `/dev/ttyS0` or `/dev/console` requires root access
- **Implication:** Agent must run as root for console mode, OR use capabilities (`CAP_SYS_TTY_CONFIG`)
- **Boot-level control:** Serial console grants full boot-time control (GRUB menu, kernel parameters, single-user mode)
- **Risk:** Attacker with console access can modify bootloader, disable security features, boot into recovery
- **Mitigation 1:** Restrict console mode to authorized users only (dashboard RBAC: "console_access" permission)
- **Mitigation 2:** Require MFA for console mode sessions (stronger auth than shell mode)
- **Mitigation 3:** Audit logging: record ALL console I/O with immutable timestamps
- **Mitigation 4:** Alert on console mode connections (notify admin when console session starts)
**Recommended deployment:**
- Run agent as unprivileged user for shell mode (default)
- For console mode: either run agent as root OR grant `CAP_SYS_TTY_CONFIG` capability via systemd unit
### Shell Access Risk (Mode 2)
- **Privilege escalation:** PTY spawns shell as the agent's user (typically unprivileged `guruconnect` service user)
- **Mitigation 1:** Run agent as unprivileged user, use `sudo` for privileged commands
- **Mitigation 2:** Add `allowed_commands` whitelist (optional Phase 2 feature) — restrict to specific binaries
- **Mitigation 3:** Audit logging: record all terminal I/O for compliance review
### Authentication
**Same as GUI agents:**
- Support-code for ad-hoc sessions (6-digit, time-limited)
- Persistent agent key for managed servers (per-agent `cak_*` key from SPEC-004)
- Viewer JWT token required for WebSocket connection
### Session Recording (Compliance)
- **Optional toggle:** dashboard setting "Record terminal sessions" (default: ON for compliance)
- **Storage:** `terminal_recordings` table (BYTEA column, compressed)
- **Playback:** Admin dashboard can replay terminal sessions as asciicast (xterm.js built-in playback)
- **Retention:** configurable (default: 90 days, auto-purge older recordings)
### Input Sanitization
- **No sanitization needed:** PTY handles raw bytes; ANSI escape sequences are terminal-native
- **DoS risk:** Malicious viewer could spam resize events; rate-limit `TerminalResize` (max 10/sec)
## Testing Strategy
### Unit Tests
- PTY spawn/cleanup: verify `openpty()` success, shell exec, FD management
- Terminal I/O: mock PTY master FD, test read/write buffers
- Protobuf serialization: TerminalData/Input/Resize round-trip
### Integration Tests
- **Headless VM:** Ubuntu Server 22.04 minimal (no desktop packages)
- **Agent install:** `guruconnect` binary, systemd service, no X11 deps
- **Connect flow:** Dashboard → "Connect" → xterm.js viewer → type `ls`, verify output
- **Resize:** Browser window resize → PTY receives SIGWINCH → `htop` redraws correctly
- **Session cleanup:** Close viewer → PTY process exits gracefully
### Manual Testing Scenarios
1. **Basic shell interaction:**
- Connect to headless agent via dashboard
- Type `ls -la`, verify colorized output
- Run `vim test.txt`, verify cursor movement, editing, save/quit
- Run `htop`, verify full-screen TUI app renders correctly
2. **Terminal resize:**
- Start session at default 80x24
- Resize browser window to 120x40
- Run `tput cols; tput lines` → verify output matches
- Run `htop` → verify UI scales to new dimensions
3. **Multi-line output:**
- Run `dmesg | head -100` → verify scrollback works
- Run `journalctl -f` → verify live log streaming
4. **Session recording playback:**
- Perform session actions (ls, vim, htop)
- End session
- Admin dashboard → "View Recording" → verify asciicast playback
5. **Privilege escalation (sudo):**
- Agent runs as `guruconnect` user (non-root)
- Connect via terminal
- Run `sudo apt update` → enter sudo password → verify command executes
- Run `whoami` → verify shows `root` after sudo
### Performance
- **Latency target:** <100ms round-trip for input (same as GUI mode)
- **Bandwidth:** ~1-5 KB/sec for typical terminal I/O (much lower than screen capture)
- **Stress test:** Run `yes` command (infinite output) → verify relay doesn't OOM, rate-limit applied
## Effort Estimate & Dependencies
**Size:** Medium (5-7 weeks, 1 developer)
**Breakdown:**
- Serial console implementation (Linux agent): 1.5 weeks
- PTY implementation (Linux agent): 1.5 weeks
- Mode selection + dispatcher: 0.5 weeks
- Protobuf protocol updates (mode enum, console_mode flag): 0.5 weeks
- Relay server terminal routing: 1 week
- xterm.js web viewer integration: 1 week
- Dashboard mode selector UI + routing: 0.5 weeks
- Session recording + playback (both modes): 1 week
- Testing (console + shell modes), edge cases, systemd integration: 1.5 weeks
- Documentation (setup guide for serial console): 0.5 weeks
**Dependencies:**
- **SPEC-010 Linux agent base** — PTY mode extends the Linux agent; can be implemented in parallel with SPEC-010's GUI capture
- **xterm.js library** — mature, well-tested (used by VS Code, Jupyter, many commercial products)
- **libc/nix crates** — standard Rust POSIX bindings
- **SPEC-004 per-agent keys** — already shipped for persistent agent auth
**Unblocks:**
- **Boot-level access** (GRUB menu, kernel parameters, single-user mode) via serial console mode
- **Emergency recovery** (kernel panics, filesystem repair, systemd rescue shell) via serial console
- **Server management** (Linux VMs, containers, bare metal) via shell mode
- **SSH replacement** with centralized audit logging and GuruConnect auth
- **Container debugging** (exec into running containers via GuruConnect)
- **KVM-over-IP alternative** (serial console provides text-mode equivalent to IPMI Serial-over-LAN)
## Open Questions
1. **Serial console permissions - root vs. capabilities?** — Opening `/dev/ttyS0` requires root. Options: (a) run agent as root for console mode, (b) use Linux capabilities (`CAP_SYS_TTY_CONFIG`), (c) add agent user to `dialout` group (may not work for `/dev/console`). Recommend (b) via systemd unit: `AmbientCapabilities=CAP_SYS_TTY_CONFIG`.
2. **Default mode if serial console unavailable?** — If `/dev/ttyS0` doesn't exist or permission denied, fall back to shell mode automatically or show error? Recommend auto-fallback with warning message in viewer.
3. **Serial console baud rate?** — v1 hardcodes 115200 (industry standard). Phase 2: make configurable if slower links needed (9600, 38400).
4. **Shell selection (PTY mode)?** — v1: `$SHELL` env var → `/bin/bash``/bin/sh`. Phase 2: dashboard setting to override shell per agent (`/bin/zsh`, `/bin/fish`).
5. **Concurrent sessions?** — v1: one console/shell session per agent connection (like SSH). Phase 2: tmux/screen integration for multi-viewer session sharing.
6. **Terminal recording format?** — Asciicast (JSON, industry standard, xterm.js playback support) vs. raw dump (more compact, custom playback). Recommend asciicast for v1.
7. **Command whitelisting (shell mode)?** — Optional Phase 2 feature. v1 is unrestricted shell access (same as SSH). Add `allowed_commands` array to agent config if compliance requires it.
8. **RBAC for console vs. shell access?** — Should some users only have shell access (not console, which grants boot-level control)? Recommend yes: add `console_access` permission, separate from `shell_access`.
9. **MFA for console mode?** — Given boot-level control risk, require MFA for console mode sessions? Defer to Phase 2 (MFA is a broader GuruConnect feature).
10. **Windows/macOS terminal mode?** — Defer. Windows Server typically uses RDP or SSH (OpenSSH built-in since Server 2019). macOS servers are rare. Linux headless servers are the primary use case.
11. **File upload/download via terminal?** — v1: use standard tools (`scp`, `rsync`, `wget`). Phase 2: integrate with SPEC (file transfer) for dashboard-native upload/download.
---
**Cross-references:**
- SPEC-010: Cross-platform agents (macOS/Linux GUI) — headless mode extends Linux agent with PTY alternative
- SPEC-004: Stable machine identity — headless agents use same deterministic `machine_uid` (`/etc/machine-id`)
- ADR-001: GuruConnect is standalone — headless mode doesn't require GuruRMM integration
- Future: File transfer spec (roadmap item) — will integrate with terminal mode for `scp`-like functionality

View File

@@ -0,0 +1,717 @@
# SPEC-013: Windows Session Selection and Backstage Mode
**Status:** Proposed
**Priority:** P2
**Requested By:** Mike Swanson (2026-05-30)
**Estimated Effort:** Large
## Overview
Enable GuruConnect to enumerate and switch between multiple Windows user sessions (Terminal Services/RDP/Fast User Switching) and access Session 0 (backstage mode) for system-level administrative tasks. This addresses a critical gap for MSPs managing multi-user Windows servers and workstations — ScreenConnect's session selector allows technicians to see all logged-on users and switch between them instantly, and the backstage mode provides a command/task-manager interface for Session 0 (services) without disrupting any user's desktop. Success criteria: technicians can view and select from all active sessions in the dashboard/viewer, switch sessions mid-stream without reconnecting, and enter backstage mode to run commands and view services when no user desktop is needed.
## Scope
### Included in v1
**Session Enumeration and Switching:**
- Agent enumerates all Windows sessions via `WTSEnumerateSessions` API at startup and on-demand
- Reports session list to server: session ID, user name, session state (active/disconnected), session type (console/RDP)
- Viewer displays session selector UI (dropdown or list) showing all available sessions
- User selects a session → server sends `SwitchSession` message → agent switches capture/input to that session
- Session switching without WebSocket disconnect (same session token, just changes the desktop being captured)
- Dashboard shows session count and logged-on users per machine
**Backstage Mode (Session 0 Access):**
- Agent can enter "backstage" mode targeting Session 0 (services session)
- Backstage mode provides a terminal/command interface (not DXGI screen capture, since Session 0 has no interactive desktop in modern Windows)
- Dashboard/viewer shows backstage UI with:
- Command prompt / PowerShell terminal (PTY-like, text-based)
- Running services list (via `EnumServicesStatusEx`)
- Running processes list (via `EnumProcesses` / `CreateToolhelp32Snapshot`)
- System info panel (OS version, uptime, logged-on sessions)
- Backstage mode uses `SessionType::BACKSTAGE` (already in protobuf line 42)
- Agent runs commands in Session 0 context via `CreateProcessAsUser` with Session 0 token
**Protobuf Extensions:**
- New messages: `SessionInfo`, `SessionList`, `SwitchSession`, `BackstageCommand`, `BackstageOutput`
- Extend `AgentStatus` to include session list
- Extend `StartStream` to specify target session ID
**Security:**
- Session switching requires elevated (admin) agent process
- Backstage mode is admin-only (gated server-side by user role)
- Audit log records all session switches and backstage commands
### Explicitly out of scope
- **Session shadowing** (viewing a session while another user is active in it) — defer to Phase 2; complex Terminal Services permission model
- **Session connect/disconnect control** (logging users off, disconnecting RDP sessions) — defer; high-impact action
- **Cross-session clipboard** — clipboard currently session-scoped; multi-session clipboard is a future enhancement
- **Linux/macOS session switching** — this spec is Windows-only; cross-platform session concepts differ significantly
- **Backstage GUI tools** (registry editor, event log viewer) — v1 is terminal/command-only; GUI tools are future enhancements
- **Session 0 screen capture** — Session 0 has no interactive desktop on modern Windows (Session 0 isolation, Vista+); backstage is terminal/data-only
## Architecture
### Agent Changes
**Session Management Module** (`agent/src/session/mod.rs` or new `agent/src/sessions/`)
- **Enumerate sessions:**
```rust
use windows::Win32::System::RemoteDesktop::{
WTSEnumerateSessionsW, WTSFreeMemory, WTSQuerySessionInformationW,
WTS_SESSION_INFOW, WTSUserName, WTSSessionInfo, WTS_CURRENT_SERVER_HANDLE
};
pub struct SessionInfo {
pub session_id: u32,
pub user_name: String,
pub state: SessionState, // Active, Disconnected, Idle
pub session_type: SessionType, // Console, RDP
}
pub fn enumerate_sessions() -> Result<Vec<SessionInfo>> {
// Call WTSEnumerateSessionsW
// For each session, call WTSQuerySessionInformationW(WTSUserName, WTSSessionInfo)
// Filter out Session 0 (services) from user session list
}
```
- **Switch session desktop:**
```rust
use windows::Win32::System::RemoteDesktop::WTSGetActiveConsoleSessionId;
use windows::Win32::UI::WindowsAndMessaging::{OpenDesktop, CloseDesktop, SwitchDesktop};
pub struct SessionContext {
pub session_id: u32,
pub desktop_handle: HDESK, // Handle to the session's desktop
}
pub fn switch_to_session(session_id: u32) -> Result<SessionContext> {
// OpenDesktop for the target session (desktop name: "WinSta0\\Default" or session-specific)
// SwitchDesktop to make it the active desktop for capture
// Reinitialize DXGI capturer on the new desktop
}
```
- **Backstage mode (Session 0 command execution):**
```rust
use windows::Win32::System::Threading::{CreateProcessAsUserW, PROCESS_INFORMATION};
use windows::Win32::Security::{LogonUserW, DuplicateTokenEx};
pub struct BackstageSession {
pub session_id: u32, // Always 0 for backstage
pub process_handle: HANDLE,
pub stdout_pipe: HANDLE,
}
pub fn execute_backstage_command(command: &str) -> Result<BackstageOutput> {
// CreateProcessAsUserW with Session 0 token (obtained via WTSQueryUserToken for Session 0)
// Capture stdout/stderr via anonymous pipes
// Return output as text (no screen capture)
}
pub fn list_services() -> Result<Vec<ServiceInfo>> {
// EnumServicesStatusEx(SC_MANAGER_ENUMERATE_SERVICE)
}
pub fn list_processes() -> Result<Vec<ProcessInfo>> {
// CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS)
}
```
**Capture Module Changes** (`agent/src/capture/mod.rs`, `agent/src/capture/dxgi.rs`)
- Refactor `DxgiCapturer::new()` to accept a `session_id` parameter
- `OpenDesktop` for the target session before initializing DXGI duplication
- Store current session ID in capturer state; reinitialize on session switch
- Backstage mode does NOT use DXGI — it's terminal/data-only
**Input Injection Changes** (`agent/src/input/mod.rs`)
- Ensure `SendInput` targets the correct session's desktop (already handles this via desktop handle)
- Backstage mode does NOT inject input (command execution only)
**New Files:**
- `agent/src/sessions/mod.rs` — session enumeration, switching, backstage execution
- `agent/src/sessions/wts.rs` — WTS API wrappers (enumerate, query, get token)
- `agent/src/sessions/backstage.rs` — Session 0 command execution, service/process enumeration
### Relay Server Changes
**Session State Management** (`server/src/relay/mod.rs` or `server/src/session/`)
- Extend `SessionState` struct to track current session ID:
```rust
pub struct SessionState {
pub session_id: String,
pub session_type: SessionType, // SCREEN_CONTROL, VIEW_ONLY, BACKSTAGE
pub target_session_id: Option<u32>, // Windows session ID (1, 2, 3, etc.) or 0 for backstage
// ... existing fields
}
```
- Handle `SessionList` message from agent (periodic update of available sessions)
- Store session list in `SessionState` for dashboard queries
- Handle `SwitchSession` message from viewer → forward to agent
- Handle `BackstageCommand` message from viewer → forward to agent
- Handle `BackstageOutput` message from agent → forward to viewer
**API Endpoints** (`server/src/api/sessions.rs`)
- `GET /api/sessions/:session_id/windows-sessions` — return list of Windows sessions for this agent
```json
{
"sessions": [
{"id": 1, "user": "jdoe", "state": "Active", "type": "Console"},
{"id": 2, "user": "admin", "state": "Disconnected", "type": "RDP"}
]
}
```
- Backstage commands are sent via WebSocket, not HTTP (same as mouse/keyboard input)
**Database Schema** (`server/migrations/`)
- Extend `connect_sessions` table:
```sql
ALTER TABLE connect_sessions ADD COLUMN windows_session_id INT NULL;
ALTER TABLE connect_sessions ADD COLUMN session_type VARCHAR(20) DEFAULT 'screen_control';
```
- Extend `events` table to log session switches:
```sql
INSERT INTO events (session_id, event_type, details) VALUES
('...', 'session_switched', '{"from_session": 1, "to_session": 2, "user": "admin"}');
```
### Protobuf Changes (`proto/guruconnect.proto`)
```protobuf
// Session information (Windows Terminal Services session)
message SessionInfo {
uint32 session_id = 1; // Windows session ID (1, 2, 3, etc.)
string user_name = 2; // Logged-on user name
SessionState state = 3; // Active, Disconnected, Idle
WindowsSessionType type = 4; // Console, RDP
}
enum SessionState {
SESSION_ACTIVE = 0;
SESSION_DISCONNECTED = 1;
SESSION_IDLE = 2;
}
enum WindowsSessionType {
SESSION_CONSOLE = 0; // Local console session
SESSION_RDP = 1; // Remote Desktop session
}
// List of available Windows sessions (agent -> server)
message SessionList {
repeated SessionInfo sessions = 1;
uint32 current_session_id = 2; // Which session the agent is currently capturing
}
// Switch to a different Windows session (viewer -> agent)
message SwitchSession {
uint32 target_session_id = 1; // Session ID to switch to (0 for backstage)
}
// Backstage command execution (viewer -> agent)
message BackstageCommand {
BackstageCommandType command_type = 1;
string command_text = 2; // For EXEC_COMMAND: PowerShell or CMD command
}
enum BackstageCommandType {
BACKSTAGE_EXEC_COMMAND = 0; // Execute a command in Session 0
BACKSTAGE_LIST_SERVICES = 1; // Enumerate running services
BACKSTAGE_LIST_PROCESSES = 2; // Enumerate running processes
BACKSTAGE_SYSTEM_INFO = 3; // Get system information
}
// Backstage output (agent -> viewer)
message BackstageOutput {
BackstageCommandType command_type = 1;
oneof output {
CommandOutput command_output = 10;
ServiceList service_list = 11;
ProcessList process_list = 12;
SystemInfo system_info = 13;
}
}
message CommandOutput {
string stdout = 1;
string stderr = 2;
int32 exit_code = 3;
}
message ServiceList {
repeated ServiceInfo services = 1;
}
message ServiceInfo {
string name = 1;
string display_name = 2;
ServiceStatus status = 3; // Running, Stopped, Paused
string startup_type = 4; // Automatic, Manual, Disabled
}
enum ServiceStatus {
SERVICE_RUNNING = 0;
SERVICE_STOPPED = 1;
SERVICE_PAUSED = 2;
}
message ProcessList {
repeated ProcessInfo processes = 1;
}
message ProcessInfo {
uint32 pid = 1;
string name = 2;
string user = 3;
uint64 memory_kb = 4;
}
message SystemInfo {
string os_version = 1;
string hostname = 2;
uint64 uptime_secs = 3;
repeated SessionInfo logged_on_sessions = 4;
}
// Extend Message wrapper to include new message types
message Message {
oneof payload {
// ... existing messages ...
// Session switching (field numbers 90-99)
SessionList session_list = 90;
SwitchSession switch_session = 91;
BackstageCommand backstage_command = 92;
BackstageOutput backstage_output = 93;
}
}
```
### Dashboard/Viewer Changes
**Dashboard** (`server/static/dashboard.html` or React component)
- Machine detail view shows "Sessions (3)" next to online status
- Click sessions count → modal showing session list with Switch button
- "Enter Backstage" button for admin users (gated by `user.role === 'admin'`)
**Native Viewer** (`agent/src/viewer/mod.rs`)
- Session selector dropdown in viewer toolbar
- Populate dropdown from `SessionList` message
- On selection change → send `SwitchSession` message
- "Backstage Mode" button (toggle) — switches to terminal UI instead of video frames
**Web Viewer** (`dashboard/src/components/RemoteViewer.tsx`)
- Same session selector dropdown as native viewer
- Backstage mode shows a terminal-like UI (xterm.js or custom) displaying command output
- Text input for commands, buttons for List Services / List Processes / System Info
## Implementation Details
### Files to Create
**Agent:**
- `agent/src/sessions/mod.rs` (300 lines) — session enumeration, switching, state
- `agent/src/sessions/wts.rs` (200 lines) — WTS API wrappers
- `agent/src/sessions/backstage.rs` (400 lines) — Session 0 command execution, service/process enum
**Server:**
- `server/src/relay/sessions.rs` (150 lines) — session list state, switch logic
- `server/migrations/00N_session_selection.sql` (50 lines) — schema changes
**Dashboard:**
- `server/static/js/session-selector.js` (200 lines) — session dropdown UI
- `server/static/css/backstage.css` (100 lines) — backstage terminal styling
### Files to Modify
**Agent:**
- `agent/src/capture/dxgi.rs:42` — Add session ID parameter to `DxgiCapturer::new()`
- `agent/src/capture/mod.rs:76` — Call `OpenDesktop` for target session before DXGI init
- `agent/src/session/mod.rs:120` — Extend session loop to handle `SwitchSession` and `BackstageCommand`
- `agent/src/main.rs:15` — Add `mod sessions;`
**Server:**
- `server/src/relay/mod.rs:85` — Handle `SessionList`, `SwitchSession`, `BackstageCommand`, `BackstageOutput` messages
- `server/src/api/sessions.rs:40` — Add endpoint for session list query
- `server/src/db/events.rs:25` — Log session switch events
**Proto:**
- `proto/guruconnect.proto:454` — Add new message types (as shown above)
### Key Logic
**Agent Session Enumeration:**
```rust
// agent/src/sessions/wts.rs
use windows::Win32::System::RemoteDesktop::{
WTSEnumerateSessionsW, WTSQuerySessionInformationW, WTSFreeMemory,
WTS_SESSION_INFOW, WTSUserName, WTSConnectState, WTS_CURRENT_SERVER_HANDLE
};
pub fn enumerate_sessions() -> Result<Vec<SessionInfo>> {
let mut sessions = Vec::new();
let mut session_info_ptr: *mut WTS_SESSION_INFOW = std::ptr::null_mut();
let mut count: u32 = 0;
unsafe {
if WTSEnumerateSessionsW(
WTS_CURRENT_SERVER_HANDLE,
0, // Reserved
1, // Version
&mut session_info_ptr,
&mut count
).as_bool() {
let session_array = std::slice::from_raw_parts(session_info_ptr, count as usize);
for wts_session in session_array {
// Skip Session 0 (services) for user session list
if wts_session.SessionId == 0 {
continue;
}
// Query user name
let mut user_name_ptr: *mut u16 = std::ptr::null_mut();
let mut bytes_returned: u32 = 0;
if WTSQuerySessionInformationW(
WTS_CURRENT_SERVER_HANDLE,
wts_session.SessionId,
WTSUserName,
&mut user_name_ptr as *mut _ as *mut _,
&mut bytes_returned
).as_bool() {
let user_name = String::from_utf16_lossy(
std::slice::from_raw_parts(user_name_ptr, (bytes_returned / 2) as usize)
);
sessions.push(SessionInfo {
session_id: wts_session.SessionId,
user_name,
state: map_wts_state(wts_session.State),
session_type: if wts_session.pWinStationName.to_string()?.contains("RDP") {
WindowsSessionType::RDP
} else {
WindowsSessionType::Console
}
});
WTSFreeMemory(user_name_ptr as *mut _);
}
}
WTSFreeMemory(session_info_ptr as *mut _);
}
}
Ok(sessions)
}
```
**Agent Session Switching:**
```rust
// agent/src/sessions/mod.rs
use windows::Win32::UI::WindowsAndMessaging::{
OpenDesktopW, CloseDesktop, SwitchDesktop, HDESK
};
use windows::core::PCWSTR;
pub struct SessionManager {
current_session_id: u32,
desktop_handle: Option<HDESK>,
}
impl SessionManager {
pub fn switch_session(&mut self, target_session_id: u32) -> Result<()> {
tracing::info!("Switching from session {} to {}", self.current_session_id, target_session_id);
// Close current desktop handle
if let Some(handle) = self.desktop_handle.take() {
unsafe { CloseDesktop(handle) };
}
// Open the target session's desktop
// Desktop name format: "WinSta0\\Default" for console session
let desktop_name = format!("WinSta0\\Default");
let desktop_name_wide: Vec<u16> = desktop_name.encode_utf16().chain(Some(0)).collect();
let desktop_handle = unsafe {
OpenDesktopW(
PCWSTR::from_raw(desktop_name_wide.as_ptr()),
0, // Flags
false.into(), // Inherit handles
0x0001 | 0x0002 | 0x0004 | 0x0008, // DESKTOP_READOBJECTS | DESKTOP_CREATEWINDOW | etc.
)?
};
// Switch to the new desktop
unsafe {
SwitchDesktop(desktop_handle)?;
}
self.current_session_id = target_session_id;
self.desktop_handle = Some(desktop_handle);
// Reinitialize screen capturer for the new desktop
// (caller must recreate DxgiCapturer)
Ok(())
}
}
```
**Backstage Command Execution:**
```rust
// agent/src/sessions/backstage.rs
use windows::Win32::System::Threading::{CreateProcessW, PROCESS_INFORMATION, STARTUPINFOW};
use windows::Win32::System::Pipes::{CreatePipe, ReadFile};
pub fn execute_command(command: &str) -> Result<CommandOutput> {
// Create anonymous pipes for stdout/stderr
let mut stdout_read: HANDLE = HANDLE::default();
let mut stdout_write: HANDLE = HANDLE::default();
unsafe {
CreatePipe(&mut stdout_read, &mut stdout_write, None, 0)?;
}
// Launch cmd.exe /c <command> with redirected output
let command_line = format!("cmd.exe /c {}", command);
let mut command_line_wide: Vec<u16> = command_line.encode_utf16().chain(Some(0)).collect();
let mut startup_info = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
hStdOutput: stdout_write,
hStdError: stdout_write,
dwFlags: STARTF_USESTDHANDLES,
..Default::default()
};
let mut process_info = PROCESS_INFORMATION::default();
unsafe {
CreateProcessW(
None,
PWSTR::from_raw(command_line_wide.as_mut_ptr()),
None,
None,
true.into(), // Inherit handles
0,
None,
None,
&startup_info,
&mut process_info
)?;
CloseHandle(stdout_write);
// Read output
let mut output = Vec::new();
let mut buffer = [0u8; 4096];
let mut bytes_read: u32 = 0;
loop {
if ReadFile(stdout_read, Some(&mut buffer), Some(&mut bytes_read), None).is_ok() {
if bytes_read == 0 {
break;
}
output.extend_from_slice(&buffer[..bytes_read as usize]);
} else {
break;
}
}
// Wait for process to exit
WaitForSingleObject(process_info.hProcess, INFINITE);
let mut exit_code: u32 = 0;
GetExitCodeProcess(process_info.hProcess, &mut exit_code)?;
CloseHandle(process_info.hProcess);
CloseHandle(process_info.hThread);
CloseHandle(stdout_read);
Ok(CommandOutput {
stdout: String::from_utf8_lossy(&output).to_string(),
stderr: String::new(),
exit_code: exit_code as i32,
})
}
}
```
## Security Considerations
### Authentication & Authorization
- **Session switching requires elevated agent:** Agent must run as SYSTEM or Administrator to call WTS APIs and `OpenDesktop` across sessions
- **Backstage mode is admin-only:** Server checks `user.role === 'admin'` before allowing `BackstageCommand` messages
- **Audit logging:** All session switches and backstage commands are logged to `events` table with:
- Timestamp
- Technician user ID
- Session ID (from/to)
- Command text (for backstage)
- Output (sanitized, no secrets)
### Input Validation
- **Session ID bounds checking:** Agent validates `target_session_id` exists in `WTSEnumerateSessions` results before switching
- **Command injection prevention:** Backstage commands are executed via `cmd.exe /c` with proper escaping; no shell metacharacter expansion
- **Output sanitization:** Command output is truncated to 100KB max to prevent memory exhaustion
### Threat Model
- **Malicious session switch:** Attacker with viewer access tries to switch to a privileged session (e.g., admin RDP session) → mitigated by server-side session ownership validation (can only switch sessions on machines you have access to)
- **Backstage command abuse:** Attacker executes destructive commands (e.g., `rd /s /q C:\`) → mitigated by admin-only role gate + audit logging
- **Session 0 privilege escalation:** Attacker uses backstage to run code as SYSTEM → inherent risk, same as any admin tool; audit log is the control
### Audit Events
**New event types:**
- `session_switched` — Log when technician switches Windows sessions
- `backstage_command` — Log all backstage command executions
- `backstage_service_query` — Log service list queries
- `backstage_process_query` — Log process list queries
**Event schema:**
```sql
CREATE TABLE events (
id UUID PRIMARY KEY,
session_id UUID REFERENCES connect_sessions(id),
user_id UUID REFERENCES users(id),
event_type VARCHAR(50) NOT NULL,
details JSONB NOT NULL, -- {"from_session": 1, "to_session": 2, "command": "ipconfig", "exit_code": 0}
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
## Testing Strategy
### Unit Tests
**Agent (Rust):**
- `sessions::wts::test_enumerate_sessions()` — mock WTS API, verify session parsing
- `sessions::backstage::test_execute_command()` — mock CreateProcess, verify output capture
- `sessions::test_switch_session()` — mock OpenDesktop/SwitchDesktop, verify state change
**Server (Rust):**
- `relay::sessions::test_session_list_update()` — verify session list parsing and storage
- `relay::sessions::test_switch_session_message()` — verify message forwarding to agent
- `api::sessions::test_get_sessions_endpoint()` — verify API response format
### Integration Tests
**End-to-end session switching:**
1. Start agent on Windows machine with 2 logged-on users (console + RDP)
2. Connect viewer to session
3. Agent sends `SessionList` with 2 sessions
4. Viewer sends `SwitchSession(2)`
5. Agent switches to RDP session, restarts DXGI capture
6. Viewer receives frames from RDP session desktop
7. Verify session switch logged in `events` table
**Backstage command execution:**
1. Admin user connects to agent
2. Enter backstage mode
3. Execute command: `ipconfig /all`
4. Verify output displayed in viewer
5. Execute command: `sc query wuauserv` (query Windows Update service)
6. Verify service status returned
7. Verify commands logged with full details
### Manual Testing Scenarios
1. **Multi-user server:**
- Windows Server 2019 with 3 RDP users logged in
- Enumerate sessions → verify all 3 shown in dropdown
- Switch between sessions → verify screen updates correctly
- Verify no session 0 in dropdown (backstage mode is separate)
2. **Fast User Switching workstation:**
- Windows 10 workstation with 2 users (one active, one locked)
- Enumerate sessions → verify both shown with correct state
- Switch to locked session → verify lock screen is captured
3. **Backstage mode:**
- Connect to agent
- Enter backstage → verify no video frames, just terminal UI
- Run `hostname` → verify output
- List services → verify service table displayed
- List processes → verify process list
- Exit backstage → return to normal screen control
4. **Permission enforcement:**
- Non-admin user tries to enter backstage → verify 403 Forbidden
- Non-admin user tries to send `BackstageCommand` → server rejects
5. **Audit logging:**
- Perform session switch + backstage commands
- Query `events` table → verify all actions logged with timestamps, user IDs, details
### CI/CD Additions
- **Windows test VM:** Gitea Actions Windows runner with 2 test users configured (via Local Users and Groups)
- **Automated test:** PowerShell script to create RDP session, run integration test, verify session switch
- **Audit log verification:** Integration test queries events table after actions, asserts correct event_type and details
## Effort Estimate & Dependencies
**Size:** Large (8-10 weeks, 1 developer)
**Breakdown:**
- Agent session enumeration + WTS API wrappers: 1.5 weeks
- Agent session switching (OpenDesktop, SwitchDesktop, DXGI reinit): 2 weeks
- Agent backstage mode (command execution, service/process enum): 2 weeks
- Server session state management + protobuf: 1 week
- Dashboard session selector UI: 1 week
- Backstage terminal UI (web viewer): 1.5 weeks
- Testing (integration tests, manual scenarios, audit verification): 1.5 weeks
- Buffer for Windows session edge cases (disconnected sessions, fast user switching quirks): 1 week
**Dependencies:**
- **SPEC-002 v2 Phase 1 completion** — per-agent keys and secure session core must be stable (already shipped)
- **Admin role enforcement** — server must have role-based access control for backstage gating (already exists: `users.role`)
- **Event logging infrastructure** — audit events table and logging (already exists: `events` table)
**Unblocks:**
- Multi-user server management (critical for MSPs with Windows Server environments)
- Session 0 administrative tasks without GUI disruption (backstage mode)
- SPEC-005 machines list view (can show session count and logged-on users per machine)
- Future: session recording (can record specific session, not just active console)
## Open Questions
1. **Session 0 GUI in older Windows?** — Windows XP/Server 2003 allowed interactive Session 0. GuruConnect targets Windows 7+ where Session 0 is non-interactive. Confirm backstage terminal-only approach is acceptable, or add a "force capture Session 0 desktop" option for legacy systems?
2. **Session switching during active control?** — If technician is moving the mouse and switches sessions mid-action, should the agent queue the switch until input is idle, or switch immediately? (Recommend: immediate switch, input events are session-scoped.)
3. **Disconnected session handling?** — If technician switches to a disconnected RDP session, should the agent attempt to reconnect it (via `WTSConnectSession`), or just capture the "disconnected" screen? (Recommend: just capture; reconnection is a high-impact action.)
4. **Backstage command timeout?** — Long-running commands (e.g., `chkdsk`) could hang. Implement a timeout (e.g., 60 seconds) and kill the process? (Recommend: yes, 60s default, configurable.)
5. **Clipboard across sessions?** — Current clipboard implementation is session-scoped. Should session switching clear the clipboard state, or try to preserve it? (Recommend: clear on switch; cross-session clipboard is a future enhancement.)
---
**Cross-references:**
- SPEC-002: v2 modernization (protobuf already has `BACKSTAGE = 2`)
- SPEC-005: Machines list view (can show session count + logged-on users)
- SPEC-008: Structured errors (session switch failures need clear error codes)
- REQUIREMENTS.md:498 — "Backstage Tools (No Screen Required)"
- REQUIREMENTS.md:727 — "Backstage / Silent Support Mode"

View File

@@ -0,0 +1,722 @@
# SPEC-014: Branding and White-Label Configuration
**Status:** Proposed
**Priority:** P2
**Requested By:** Mike Swanson (2026-05-30)
**Estimated Effort:** Medium
---
## Overview
Enable MSPs to customize GuruConnect branding (logo, colors, company name) to match their corporate identity or provide fully white-labeled remote support services. This addresses a competitive gap — ScreenConnect, Splashtop, and AnyDesk all offer white-labeling for MSPs who resell remote support under their own brand. MSPs using GuruConnect want their technicians and end-users to see "Acme Remote Support" instead of "GuruConnect", with Acme's logo and colors throughout the interface.
**Use Cases:**
- **MSP brand consistency:** MSP wants dashboard, viewer, and agent tray to display their company branding
- **White-label reselling:** MSP sells remote support services under a completely different product name
- **Multi-brand MSP:** Large MSP operates multiple service brands with different branding per brand
**Success Criteria:**
- MSP can upload custom logo, set primary/accent colors (brand hue), configure product name
- Dashboard displays custom branding on login page and throughout UI
- Native viewer shows custom product name in window title
- Windows agent tray icon tooltip shows custom product name
- Support code page shows custom branding
- Changes apply instance-wide (single-tenant v1)
---
## Scope
### Included in v1
**Dashboard Branding:**
- Custom logo upload (SVG/PNG, displayed in sidebar and login page)
- Brand hue customization (OKLCH hue override for `--brand-hue` CSS variable)
- Accent color customization (overrides `--accent` variable)
- Product name (replaces "GuruConnect" in page titles, headers, login page)
- Company name (footer copyright)
- Custom favicon
**Viewer Branding:**
- Native viewer window title uses custom product name
- Web viewer page title and header use custom product name
**Agent Branding (Windows):**
- Tray icon tooltip shows custom product name
- System tray menu header shows custom product name
**Support Code Page:**
- Displays custom logo
- Shows custom company name
- Uses custom brand colors
**Configuration Management:**
- Admin-only branding settings page in dashboard
- Logo upload with drag-and-drop and preview
- OKLCH hue slider with live preview (0-360 degrees)
- Accent color picker with OKLCH preview
- Text inputs for product name and company name
- Default values fallback to GuruConnect branding
### Explicitly Out of Scope
- **Per-tenant branding:** v1 is instance-wide (one branding config per GuruConnect deployment). Multi-tenant per-organization branding deferred to v2.
- **Full theme customization:** Cannot change surface colors, typography, spacing, layout. v1 only overrides brand hue and accent color.
- **Agent installer branding:** Agent `.exe` branding (file properties, icon) requires compile-time changes. Defer to v2.
- **Email branding:** GuruConnect doesn't send emails yet. If email notifications are added later, inherit branding config.
- **Custom domain support:** v1 uses the same deployment URL. Custom domain requires nginx/DNS configuration outside this spec.
- **Mobile app branding:** No mobile GuruConnect app exists (see SPEC-011).
---
## Architecture
### Components
**Relay Server (`server/src/`):**
- New `branding_config` database table storing logo path, colors, names
- New API endpoints: `GET /api/branding`, `PUT /api/branding`, `POST /api/branding/logo`
- Serve uploaded logos from `server/static/branding/` directory
- Support code page reads branding config for rendering
**Dashboard (`dashboard/src/`):**
- New Settings page: Branding tab (`BrandingSettings.tsx`)
- Logo upload component with drag-and-drop and preview
- OKLCH hue slider (0-360 degrees) with live preview
- Accent color picker (OKLCH)
- Text inputs for product name and company name
- Apply branding via CSS variables injection
- Login page uses branding config
**Agent (`agent/src/`):**
- Read product name from registry: `HKLM\SOFTWARE\GuruConnect\ProductName`
- Tray icon tooltip and menu header use custom product name
- No icon changes in v1 (compile-time resource)
**Native Viewer (`agent/src/viewer/`):**
- Window title uses product name from config file or registry
- No other visual changes in v1
### Data Flow
1. **Admin configures branding:**
- Admin navigates to Dashboard > Settings > Branding
- Uploads logo → `POST /api/branding/logo` → server saves to `server/static/branding/logo.png`
- Sets brand hue (e.g., 280 for purple) → `PUT /api/branding` → server updates database
2. **Dashboard applies branding:**
- Root component fetches `GET /api/branding` on mount
- Injects CSS variables: `--brand-hue: 280deg`, `--accent: oklch(78% 0.13 280)`
- Replaces "GuruConnect" text with `productName`
- Renders logo from `/branding/logo.png`
- Updates favicon reference
3. **Support code page applies branding:**
- Server-side renders support code page with branding config
- Injects logo, product name, brand hue into HTML template
4. **Agent uses branding:**
- Agent reads `HKLM\SOFTWARE\GuruConnect\ProductName` on startup
- Tray tooltip: "{ProductName} Agent — Online"
- Tray menu header: "{ProductName}"
### Database Schema
```sql
-- New table: branding_config (singleton, one row)
CREATE TABLE branding_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_name VARCHAR(100) NOT NULL DEFAULT 'GuruConnect',
company_name VARCHAR(200) NOT NULL DEFAULT 'Arizona Computer Guru',
logo_filename VARCHAR(200), -- e.g., "logo.png" (stored in server/static/branding/)
favicon_filename VARCHAR(200), -- e.g., "favicon.ico"
brand_hue INTEGER NOT NULL DEFAULT 184, -- OKLCH hue, 0-360 degrees (184 = cyan)
accent_color VARCHAR(50) NOT NULL DEFAULT 'oklch(78% 0.13 184)', -- Full OKLCH color value
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Insert default row
INSERT INTO branding_config (product_name, company_name, brand_hue, accent_color)
VALUES ('GuruConnect', 'Arizona Computer Guru', 184, 'oklch(78% 0.13 184)');
-- Enforce singleton: only one row allowed
CREATE UNIQUE INDEX branding_config_singleton ON branding_config ((true));
```
### API Endpoints
**`GET /api/branding`** (Public, no auth required for dashboard/support code rendering)
- Returns branding config JSON
- Response:
```json
{
"product_name": "Acme Remote Support",
"company_name": "Acme IT Solutions",
"logo_url": "/branding/logo.png",
"favicon_url": "/branding/favicon.ico",
"brand_hue": 280,
"accent_color": "oklch(78% 0.13 280)"
}
```
**`PUT /api/branding`** (Admin only, requires JWT with admin role)
- Updates branding config (text fields and colors only)
- Request body:
```json
{
"product_name": "Acme Remote Support",
"company_name": "Acme IT Solutions",
"brand_hue": 280,
"accent_color": "oklch(78% 0.13 280)"
}
```
- Validates brand_hue range (0-360)
- Validates accent_color is valid OKLCH syntax
- Returns updated config
**`POST /api/branding/logo`** (Admin only, multipart/form-data)
- Uploads custom logo (PNG or SVG, max 2MB)
- Validates image dimensions (recommended 200x40px, max 400x100px)
- Saves to `server/static/branding/logo.{png|svg}`
- Updates `branding_config.logo_filename`
- Returns updated config
**`POST /api/branding/favicon`** (Admin only, multipart/form-data)
- Uploads custom favicon (ICO, PNG, SVG, max 100KB)
- Saves to `server/static/branding/favicon.{ico|png|svg}`
- Updates `branding_config.favicon_filename`
**`DELETE /api/branding/logo`** (Admin only)
- Removes custom logo, reverts to default GuruConnect logo
- Deletes file from disk
- Sets `logo_filename` to NULL
**`DELETE /api/branding/favicon`** (Admin only)
- Removes custom favicon, reverts to default
---
## Implementation Details
### Relay Server (`server/src/`)
**Files to create:**
- `server/src/api/branding.rs` (250 lines) — API routes, logo upload handler, validation
- `server/src/db/branding.rs` (100 lines) — Database queries for branding config
- `server/migrations/NNN_branding_config.sql` (40 lines) — Schema as shown above
**Files to modify:**
- `server/src/api/mod.rs` — Add branding routes
- `server/src/main.rs` — Create `server/static/branding/` directory on startup
- `server/static/support_code.html` (if it exists) — Inject branding config into template
**Branding API implementation:**
```rust
// server/src/api/branding.rs
use axum::{
extract::{Multipart, State},
http::StatusCode,
routing::{delete, get, post, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::auth::AdminOnly;
use crate::db::branding as db;
use crate::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/branding", get(get_branding))
.route("/api/branding", put(update_branding))
.route("/api/branding/logo", post(upload_logo))
.route("/api/branding/logo", delete(delete_logo))
.route("/api/branding/favicon", post(upload_favicon))
.route("/api/branding/favicon", delete(delete_favicon))
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BrandingConfig {
pub product_name: String,
pub company_name: String,
pub logo_url: Option<String>,
pub favicon_url: Option<String>,
pub brand_hue: i32, // 0-360 OKLCH hue
pub accent_color: String, // Full OKLCH value, e.g., "oklch(78% 0.13 280)"
}
// GET /api/branding (public, no auth)
async fn get_branding(State(state): State<AppState>) -> Result<Json<BrandingConfig>, StatusCode> {
let config = db::get_branding_config(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(config))
}
// PUT /api/branding (admin only)
async fn update_branding(
_admin: AdminOnly,
State(state): State<AppState>,
Json(req): Json<UpdateBrandingRequest>,
) -> Result<Json<BrandingConfig>, StatusCode> {
// Validate brand hue (0-360)
if req.brand_hue < 0 || req.brand_hue > 360 {
return Err(StatusCode::BAD_REQUEST);
}
// Validate accent color (basic OKLCH syntax check)
if !req.accent_color.starts_with("oklch(") {
return Err(StatusCode::BAD_REQUEST);
}
let config = db::update_branding_config(&state.db, req)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(config))
}
// POST /api/branding/logo (admin only, multipart upload)
async fn upload_logo(
_admin: AdminOnly,
State(state): State<AppState>,
mut multipart: Multipart,
) -> Result<Json<BrandingConfig>, StatusCode> {
// Extract file from multipart
let mut file_data: Option<Vec<u8>> = None;
let mut file_ext: Option<String> = None;
while let Some(field) = multipart.next_field().await.ok().flatten() {
if field.name().unwrap_or("") == "logo" {
let content_type = field.content_type().unwrap_or("").to_string();
let bytes = field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?;
// Validate size (max 2MB)
if bytes.len() > 2 * 1024 * 1024 {
return Err(StatusCode::PAYLOAD_TOO_LARGE);
}
// Determine extension from content type
file_ext = match content_type.as_str() {
"image/png" => Some("png".to_string()),
"image/svg+xml" => Some("svg".to_string()),
_ => return Err(StatusCode::BAD_REQUEST),
};
file_data = Some(bytes.to_vec());
}
}
let (data, ext) = match (file_data, file_ext) {
(Some(d), Some(e)) => (d, e),
_ => return Err(StatusCode::BAD_REQUEST),
};
// Save to server/static/branding/logo.{ext}
let branding_dir = PathBuf::from("server/static/branding");
tokio::fs::create_dir_all(&branding_dir)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let filename = format!("logo.{}", ext);
let file_path = branding_dir.join(&filename);
tokio::fs::write(&file_path, &data)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Update database
db::set_logo_filename(&state.db, &filename)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Return updated config
let config = db::get_branding_config(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(config))
}
```
### Dashboard (`dashboard/src/`)
**Files to create:**
- `dashboard/src/features/settings/BrandingSettings.tsx` (300 lines) — Branding configuration page
- `dashboard/src/components/BrandingProvider.tsx` (150 lines) — React context for branding config
- `dashboard/src/components/ui/ColorPicker.tsx` (100 lines) — OKLCH hue slider component
- `dashboard/src/components/ui/LogoUpload.tsx` (150 lines) — Drag-and-drop logo upload
**Files to modify:**
- `dashboard/src/main.tsx` — Wrap app in `BrandingProvider`
- `dashboard/src/features/auth/LoginPage.tsx` — Use branding config for logo and colors
- `dashboard/index.html:7` — Update page title with product name
- `dashboard/src/styles/tokens.css:12` — Allow `--brand-hue` override via inline style
**Branding Provider:**
```tsx
// dashboard/src/components/BrandingProvider.tsx
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { brandingApi } from "../api/client";
interface BrandingConfig {
product_name: string;
company_name: string;
logo_url?: string;
favicon_url?: string;
brand_hue: number;
accent_color: string;
}
const BrandingContext = createContext<BrandingConfig | null>(null);
export function BrandingProvider({ children }: { children: ReactNode }) {
const { data: branding } = useQuery({
queryKey: ["branding"],
queryFn: brandingApi.get,
staleTime: Infinity, // Branding rarely changes
});
useEffect(() => {
if (!branding) return;
// Apply CSS variables
document.documentElement.style.setProperty("--brand-hue", String(branding.brand_hue));
if (branding.accent_color) {
document.documentElement.style.setProperty("--accent", branding.accent_color);
}
// Update page title
document.title = `${branding.product_name} — Operator Console`;
// Update favicon if custom
if (branding.favicon_url) {
const link = document.querySelector("link[rel='icon']") as HTMLLinkElement;
if (link) {
link.href = branding.favicon_url;
}
}
}, [branding]);
return (
<BrandingContext.Provider value={branding || null}>
{children}
</BrandingContext.Provider>
);
}
export function useBranding() {
const ctx = useContext(BrandingContext);
return ctx || {
product_name: "GuruConnect",
company_name: "Arizona Computer Guru",
brand_hue: 184,
accent_color: "oklch(78% 0.13 184)",
};
}
```
**Branding Settings Page:**
```tsx
// dashboard/src/features/settings/BrandingSettings.tsx
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Upload } from "lucide-react";
import { Button } from "../../components/ui/Button";
import { LogoUpload } from "../../components/ui/LogoUpload";
import { useBranding } from "../../components/BrandingProvider";
import { brandingApi } from "../../api/client";
export function BrandingSettings() {
const queryClient = useQueryClient();
const branding = useBranding();
const [formData, setFormData] = useState({
product_name: branding.product_name,
company_name: branding.company_name,
brand_hue: branding.brand_hue,
});
const updateMutation = useMutation({
mutationFn: brandingApi.update,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["branding"] });
},
});
const logoMutation = useMutation({
mutationFn: brandingApi.uploadLogo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["branding"] });
},
});
const handleSave = () => {
updateMutation.mutate({
...formData,
accent_color: `oklch(78% 0.13 ${formData.brand_hue})`,
});
};
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold">Branding Configuration</h2>
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium mb-2">Logo</label>
{branding.logo_url && (
<img
src={branding.logo_url}
alt="Current logo"
className="h-12 mb-4 max-w-[300px]"
/>
)}
<LogoUpload onUpload={(file) => logoMutation.mutate(file)} />
</div>
{/* Product Name */}
<div>
<label className="block text-sm font-medium mb-2">Product Name</label>
<input
type="text"
value={formData.product_name}
onChange={(e) => setFormData({ ...formData, product_name: e.target.value })}
className="w-full px-3 py-2 bg-panel border border-border rounded"
/>
</div>
{/* Company Name */}
<div>
<label className="block text-sm font-medium mb-2">Company Name</label>
<input
type="text"
value={formData.company_name}
onChange={(e) => setFormData({ ...formData, company_name: e.target.value })}
className="w-full px-3 py-2 bg-panel border border-border rounded"
/>
</div>
{/* Brand Hue Slider */}
<div>
<label className="block text-sm font-medium mb-2">Brand Hue (OKLCH)</label>
<input
type="range"
min="0"
max="360"
value={formData.brand_hue}
onChange={(e) => setFormData({ ...formData, brand_hue: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex items-center gap-4 mt-2">
<div
className="w-12 h-12 rounded border border-border"
style={{ background: `oklch(78% 0.13 ${formData.brand_hue})` }}
/>
<span className="text-sm text-muted">{formData.brand_hue}° — oklch(78% 0.13 {formData.brand_hue})</span>
</div>
</div>
{/* Save Button */}
<Button onClick={handleSave} disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</div>
);
}
```
### Agent (`agent/src/`)
**Files to modify:**
- `agent/src/tray/mod.rs` (Windows) — Read product name from registry, use in tooltip and menu
- `agent/src/main.rs` — Optionally write default product name to registry on first run
**Registry key:**
```
HKLM\SOFTWARE\GuruConnect\ProductName (REG_SZ): "Acme Remote Support"
```
**Agent reads branding:**
```rust
// agent/src/tray/mod.rs (Windows)
use winreg::RegKey;
use winreg::enums::*;
fn get_product_name() -> String {
#[cfg(windows)]
{
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
if let Ok(gc_key) = hklm.open_subkey("SOFTWARE\\GuruConnect") {
if let Ok(name) = gc_key.get_value::<String, _>("ProductName") {
return name;
}
}
}
"GuruConnect".to_string() // Default fallback
}
// Tray tooltip
let tooltip = format!("{} Agent — {}", get_product_name(), if online { "Online" } else { "Offline" });
// Tray menu header
let menu_header = get_product_name();
```
---
## Security Considerations
### Authentication & Authorization
- **Branding GET endpoint is public** — allows unauthenticated dashboard/login/support code pages to render with custom branding
- **All mutation endpoints (PUT, POST, DELETE) require admin JWT** — only admins can change branding
- **Logo upload validates file type and size** — only PNG/SVG allowed, max 2MB
- **Stored files served from static directory** — no code execution risk
### Input Validation
- **Brand hue validation:** Must be 0-360 integer
- **Accent color validation:** Must start with `oklch(` (basic syntax check)
- **Product/company name length limits:** Max 100/200 chars
- **Logo filename sanitization:** Prevent path traversal (e.g., `../../../etc/passwd.png`)
### Audit Logging
**New event types:**
- `branding_updated` — Log all branding changes
- `logo_uploaded` — Log logo uploads with filename and size
- `logo_deleted` — Log logo deletions
**Event schema:**
```sql
INSERT INTO events (event_type, user_id, details) VALUES
('branding_updated', '...', '{"changes": {"brand_hue": {"old": 184, "new": 280}}}');
```
### Threat Model
- **Logo upload abuse:** Attacker uploads malicious SVG with embedded script → mitigated by CSP and serving files with `Content-Type: image/svg+xml`
- **CSS injection via OKLCH values:** Attacker injects CSS via accent_color field → mitigated by basic `oklch(` prefix validation
- **Phishing via fake branding:** Attacker configures branding to impersonate another MSP → mitigated by audit logging and admin-only access
---
## Testing Strategy
### Unit Tests
**Server (Rust):**
- `api/branding_test.rs` — Test CRUD operations, validation (hue range, OKLCH syntax)
- `db/branding_test.rs` — Test singleton enforcement, default values
**Dashboard (TypeScript):**
- `BrandingSettings.test.tsx` — Test form inputs, logo upload, hue slider
- `BrandingProvider.test.tsx` — Test context, CSS variable injection
### Integration Tests
**End-to-end branding flow:**
1. Admin navigates to Settings > Branding
2. Uploads custom logo (PNG, 50KB)
3. Sets product name to "Acme Remote Support", brand hue to 280 (purple)
4. Clicks Save
5. Refreshes page → verify branding persists (logo, product name, purple hue)
6. Logs out → verify login page shows custom logo and purple accent
7. Generates support code → verify support code page shows custom branding
### Manual Testing Scenarios
1. **Logo upload:**
- Upload PNG (valid) → verify preview, save, reload
- Upload SVG (valid) → verify preview
- Upload JPEG (invalid) → verify error
- Upload 3MB file → verify 413 error
2. **Brand hue slider:**
- Drag slider to 0 (red) → verify live preview updates
- Drag to 120 (green) → verify preview
- Drag to 240 (blue) → verify preview
- Drag to 280 (purple) → verify preview
- Save → verify dashboard sidebar color changes
3. **Product name:**
- Change to "Acme Remote Support" → verify page title, sidebar header
- Verify login page shows new name
- Verify support code page shows new name
4. **Agent branding:**
- Set registry key manually: `HKLM\SOFTWARE\GuruConnect\ProductName = "Acme Remote Support"`
- Restart agent → verify tray tooltip shows "Acme Remote Support Agent"
- Right-click tray icon → verify menu header shows "Acme Remote Support"
### CI/CD Additions
- **Branding table seed:** Add default branding row to test database
- **Logo upload mock:** Mock multipart upload in integration tests
- **Hue validation tests:** Parametrized tests for valid/invalid hue values
---
## Effort Estimate & Dependencies
**Size:** Medium (4-6 weeks, 1 developer)
**Breakdown:**
- Server branding API + database: 1 week
- Dashboard branding settings page: 1.5 weeks
- Dashboard branding provider + CSS variable injection: 1 week
- Agent registry key reading: 0.5 weeks
- Testing (integration + manual): 1 week
- Documentation: 0.5 weeks
**Dependencies:**
- None — branding is standalone with no hard dependencies
**Unblocks:**
- Multi-tenant per-organization branding (v2)
- GuruRMM integration with white-labeled GuruConnect (RMM can inherit branding)
- Custom domain support (future)
---
## Open Questions
1. **Should viewer (native + web) support full branding or just product name?** — v1 spec only changes product name in window/page title. Full visual branding (logo in viewer, custom colors) deferred to v2.
2. **Should agent tray icon be dynamically loaded or compiled-in?** — v1 uses compiled-in icon (simplicity). Dynamic icon loading from server would enable branding changes without reinstalling agents, but adds complexity. Recommendation: static for v1, defer to v2.
3. **Should support code page be server-rendered or client-side?** — If server-rendered, branding is injected at render time. If client-side React, it fetches branding config. Recommendation: server-rendered for simplicity (support code page is standalone, not part of dashboard SPA).
4. **Should branding be per-tenant or global?** — v1 spec is global (one branding config per GuruConnect instance). If GuruConnect becomes multi-tenant in the future, add `tenant_id` FK to `branding_config`. Recommendation: start global, add per-tenant FK in v2 if multi-tenancy is implemented.
5. **Should dashboard support custom fonts?** — Not in v1 (uses Hanken Grotesk and JetBrains Mono). Custom font upload is complex (licensing, performance). Recommendation: defer to v2.
---
**Cross-references:**
- SPEC-002: v2 modernization (branding can be part of Phase 2/3)
- SPEC-005: Machines list view (could show custom branding in UI)
- ADR-001: GuruConnect is standalone (branding is GuruConnect-owned, not coupled to GuruRMM)
---
**Next Steps:**
1. Review specification with Mike
2. Create database migration (`NNN_branding_config.sql`)
3. Implement server API (`server/src/api/branding.rs`)
4. Implement dashboard UI (`dashboard/src/features/settings/BrandingSettings.tsx`)
5. Update agent tray to read product name from registry
6. Test end-to-end branding flow
7. Document in user guide

File diff suppressed because it is too large Load Diff

View File

@@ -317,6 +317,14 @@ message AgentStatus {
// negotiation (see StartStream.video_codec). Detected once and cached;
// false on non-Windows / no HW encoder / MF unavailable.
bool supports_h264 = 11;
// Deterministic, recomputable hardware identity (v2 stable-identity Task 1).
// Opaque "muid_<hex>" derived by SHA-256 hashing the OS machine GUID
// (Windows: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid); non-Windows /
// registry-failure falls back to a persisted random UUID. Reported ALONGSIDE
// agent_id (which is unchanged). The server-side dedup that consumes this is a
// separate task; until then it is informational. Empty only if the agent
// predates this field.
string machine_uid = 12;
}
// Server commands agent to uninstall itself

View File

@@ -1,6 +1,6 @@
[package]
name = "guruconnect-server"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
authors = ["AZ Computer Guru"]
description = "GuruConnect Remote Desktop Relay Server"

View File

@@ -0,0 +1,41 @@
-- Migration: 008_machine_uid.sql
-- Purpose: Give connect_machines a deterministic, recomputable hardware identity
-- (machine_uid) and dedup registrations on it (SPEC-004 / v2-stable-identity
-- Task 2).
--
-- Today connect_machines is keyed only on `agent_id` — a random UUID the agent
-- persists in its config (generate_agent_id()). A lost/missing config produces a
-- fresh UUID, and because upsert_machine dedups `ON CONFLICT (agent_id)`, that
-- fresh id inserts a NEW row: the duplicate-registration bug (15 rows for 5 real
-- hosts in production). The agent (Task 1) now derives a stable `machine_uid` from
-- durable hardware/OS identifiers (Windows MachineGuid, hashed; recomputable) and
-- reports it on the connect handshake and on AgentStatus. This migration persists
-- it and adds the uniqueness needed to dedup the un-keyed / shared-key / config-loss
-- fleet on that stable identity.
--
-- SECURITY (SPEC-004): a client-asserted machine_uid is spoofable and is therefore
-- NOT a trust boundary. For per-agent (`cak_`) keyed agents the key's machine
-- binding stays authoritative (the server dedups those on their authenticated
-- agent_id, never on a claimed uid). machine_uid is a *correctness* aid for the
-- un-keyed path only. The UNIQUE index below enforces "one row per machine_uid"
-- at the schema level so a concurrent/racing un-keyed insert cannot create a
-- duplicate behind the application-level ON CONFLICT.
--
-- Idempotent: ADD COLUMN IF NOT EXISTS + CREATE UNIQUE INDEX IF NOT EXISTS. The
-- column is NULLABLE: legacy rows and agents that do not report a uid (older agents,
-- some support-code clients) carry NULL, and the partial index excludes NULLs so any
-- number of un-keyed rows may coexist without a uid. Applied on server startup by
-- sqlx::migrate!(); never pre-applied via psql. Ordered after 007.
-- See .claude/standards/gururmm/sqlx-migrations.md.
-- 1. machine_uid: deterministic hardware identity reported by the agent. NULLABLE
-- so legacy rows and non-reporting agents are unaffected.
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS machine_uid TEXT;
-- 2. Enforce one row per machine_uid, but ONLY for rows that actually have one.
-- A partial UNIQUE index (WHERE machine_uid IS NOT NULL) lets unlimited legacy
-- NULL rows coexist while making a non-null machine_uid a true dedup key — this
-- is what upsert_machine's `ON CONFLICT (machine_uid)` arbiter binds to.
CREATE UNIQUE INDEX IF NOT EXISTS idx_connect_machines_machine_uid
ON connect_machines (machine_uid)
WHERE machine_uid IS NOT NULL;

View File

@@ -0,0 +1,43 @@
-- Migration: 009_session_machine_soft_delete.sql
-- Purpose: Give connect_machines and connect_sessions a soft-delete marker
-- (deleted_at) so an operator can PURGE a stale machine/session —
-- removing it from the live console — without destroying its audit
-- history (SPEC-004 / v2-stable-identity Task 5).
--
-- Task 5 is the operator-removal mechanism that finally purges the ~14 live
-- ghost connect_machines rows left by the duplicate-registration bug. The
-- live-only admin "disconnect" (DELETE /api/sessions/:id) and the legacy hard
-- DELETE /api/machines/:agent_id stay as they were; the NEW purge path
-- (`?purge=true`) sets deleted_at, drops the in-memory session via
-- SessionManager::remove_session, and writes an audit row. A soft delete keeps
-- the row (and its connect_session_events history) for the audit trail per the
-- project convention "prefer deleted_at over hard deletes" (CLAUDE.md), while
-- every list/get query filters `deleted_at IS NULL` so the purged unit
-- disappears from the dashboard and the startup reconcile never restores it.
--
-- Idempotent: ADD COLUMN IF NOT EXISTS. The columns are NULLABLE with no default,
-- so adding them is a metadata-only change on Postgres (no table rewrite, no row
-- locks held for a scan) — safe to apply online. A NULL deleted_at means "live";
-- a non-null timestamp means "removed at that instant". Applied on server startup
-- by sqlx::migrate!(); never pre-applied via psql. Ordered after 008.
-- See .claude/standards/gururmm/sqlx-migrations.md.
-- 1. connect_sessions.deleted_at: when set, the session was operator-purged and is
-- excluded from every list/get query. NULL = live.
ALTER TABLE connect_sessions ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- 2. connect_machines.deleted_at: when set, the machine was operator-purged. This
-- is the marker that hides the ghost duplicate rows from /api/machines and the
-- startup reconcile. NULL = live.
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- 3. Partial indexes so the hot "live rows only" filter (deleted_at IS NULL) used
-- by every list query stays an index scan as the soft-deleted set grows. The
-- predicate matches the WHERE clause the queries use.
CREATE INDEX IF NOT EXISTS idx_connect_machines_live
ON connect_machines (hostname)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_connect_sessions_live
ON connect_sessions (started_at DESC)
WHERE deleted_at IS NULL;

View File

@@ -6,6 +6,7 @@ pub mod changelog;
pub mod downloads;
pub mod machine_keys;
pub mod releases;
pub mod removal;
pub mod sessions;
pub mod users;
@@ -172,7 +173,7 @@ impl From<db::sessions::DbSession> for SessionRecord {
#[derive(Debug, Serialize)]
pub struct EventRecord {
pub id: i64,
pub session_id: String,
pub session_id: Option<String>,
pub event_type: String,
pub timestamp: String,
pub viewer_id: Option<String>,
@@ -185,7 +186,7 @@ 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(),
session_id: e.session_id.map(|id| id.to_string()),
event_type: e.event_type,
timestamp: e.timestamp.to_rfc3339(),
viewer_id: e.viewer_id,
@@ -208,12 +209,17 @@ pub struct MachineHistory {
/// Query parameters for machine deletion
#[derive(Debug, Deserialize)]
pub struct DeleteMachineParams {
/// If true, send uninstall command to agent (if online)
/// If true, send uninstall command to agent (if online). Legacy (non-purge) path.
#[serde(default)]
pub uninstall: bool,
/// If true, include history in response before deletion
/// If true, include history in response before deletion. Legacy (non-purge) path.
#[serde(default)]
pub export: bool,
/// If true, take the Task-5 SOFT-DELETE path: set `connect_machines.deleted_at`,
/// drop the live in-memory session, and audit the removal — instead of the legacy
/// hard delete. This is the operator-removal mechanism that purges ghost rows.
#[serde(default)]
pub purge: bool,
}
/// Response for machine deletion

612
server/src/api/removal.rs Normal file
View File

@@ -0,0 +1,612 @@
//! Operator-removal endpoints (admin plane) — SPEC-004 / v2-stable-identity Task 5.
//!
//! Lets an administrator PURGE stale machines and sessions: soft-delete the DB row
//! (`deleted_at = NOW()`, retaining the audit history), drop the live in-memory
//! session via [`SessionManager::remove_session`], and write an audit row to
//! `connect_session_events`. This is the mechanism that removes the ghost duplicate
//! `connect_machines` rows left by the historical duplicate-registration bug.
//!
//! Removal semantics vs. the pre-existing live-only controls:
//! - `DELETE /api/sessions/:id` (live-only "disconnect") sends a `Disconnect`
//! message to the agent. It does NOT touch the DB. Left unchanged.
//! - `DELETE /api/machines/:agent_id` WITHOUT `?purge=true` keeps the legacy
//! behavior (optional `?uninstall=true` admin command, optional `?export=true`
//! history dump, then a HARD delete). Left unchanged.
//! - `?purge=true` on either route is the NEW soft-delete path defined here.
//!
//! Auth: dashboard JWT + admin role (the [`AdminUser`] extractor). A non-admin gets
//! 403. Every purge is audited with the acting admin and the target id.
//!
//! Errors use the shared [`ApiError`] envelope; raw `sqlx` strings are never sent to
//! the client (they are logged server-side via `tracing`).
use std::net::IpAddr;
use axum::{
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, StatusCode},
Json,
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use uuid::Uuid;
use crate::auth::AdminUser;
use crate::db;
use crate::db::events::EventTypes;
use crate::proto;
use crate::AppState;
use super::machine_keys::ApiError;
use super::{DeleteMachineParams, DeleteMachineResponse, MachineHistory};
type ApiResult<T> = Result<T, (StatusCode, Json<ApiError>)>;
/// Build the shared error envelope (mirrors `machine_keys`/`sessions`).
fn err(status: StatusCode, code: &str, detail: &str) -> (StatusCode, Json<ApiError>) {
(
status,
Json(ApiError {
detail: detail.to_string(),
error_code: code.to_string(),
status_code: status.as_u16(),
}),
)
}
/// Resolve the live `Database` handle or 503 (DB is optional in this server).
fn require_db(state: &AppState) -> ApiResult<&db::Database> {
state.db.as_ref().ok_or_else(|| {
err(
StatusCode::SERVICE_UNAVAILABLE,
"DATABASE_UNAVAILABLE",
"Database not available",
)
})
}
/// Real client IP for the audit row (trusted-proxy aware, matching the rest of the
/// server). Best-effort: a missing/garbled forwarded header just yields the peer.
fn audit_ip(state: &AppState, addr: SocketAddr, headers: &HeaderMap) -> IpAddr {
crate::utils::ip_extract::client_ip(&addr, headers, &state.trusted_proxies)
}
// ============================================================================
// Single-machine removal — DELETE /api/machines/:agent_id[?purge=true]
// ============================================================================
/// DELETE /api/machines/:agent_id
///
/// Admin-only. Two modes, selected by the `purge` query flag:
/// - `?purge=true` → SOFT-DELETE (Task 5): set `connect_machines.deleted_at`,
/// drop the machine's live in-memory session via `remove_session`, and write a
/// `machine_removed` audit event. The row + its history are retained; the
/// machine disappears from `/api/machines` and is not restored on startup.
/// - otherwise → the legacy HARD delete (optional uninstall command + history
/// export, then a cascading `DELETE`), preserved verbatim for compatibility.
pub async fn remove_machine(
AdminUser(admin): AdminUser,
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(agent_id): Path<String>,
Query(params): Query<DeleteMachineParams>,
) -> ApiResult<Json<DeleteMachineResponse>> {
// `agent_id` is a UUID minted by the agent (`generate_agent_id()`); validate the
// shape before touching the DB so a malformed path is a clean 400, not a lookup.
if Uuid::parse_str(&agent_id).is_err() {
return Err(err(
StatusCode::BAD_REQUEST,
"INVALID_AGENT_ID",
"agent_id must be a valid UUID",
));
}
let db = require_db(&state)?;
// The machine must currently exist and be live (the get filters soft-deleted),
// so a re-purge or an unknown id is a clean 404 rather than a silent success.
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id)
.await
.map_err(|e| {
tracing::error!("DB error loading machine for removal: {}", e);
err(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"Internal server error",
)
})?
.ok_or_else(|| {
err(
StatusCode::NOT_FOUND,
"MACHINE_NOT_FOUND",
"Machine not found",
)
})?;
if params.purge {
return purge_machine(
&state,
db,
&admin,
machine,
audit_ip(&state, addr, &headers),
)
.await;
}
// -------- Legacy hard-delete path (unchanged behavior) --------
let history = if params.export {
let sessions = db::sessions::get_sessions_for_machine(db.pool(), machine.id)
.await
.map_err(|e| {
tracing::error!("DB error exporting sessions: {}", e);
err(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"Internal server error",
)
})?;
let events = db::events::get_events_for_machine(db.pool(), machine.id)
.await
.map_err(|e| {
tracing::error!("DB error exporting events: {}", e);
err(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"Internal server error",
)
})?;
Some(MachineHistory {
machine: super::MachineInfo::from(machine.clone()),
sessions: sessions
.into_iter()
.map(super::SessionRecord::from)
.collect(),
events: events.into_iter().map(super::EventRecord::from).collect(),
exported_at: chrono::Utc::now().to_rfc3339(),
})
} else {
None
};
let mut uninstall_sent = false;
if params.uninstall {
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 {
tracing::info!("Sent uninstall command to agent {}", agent_id);
}
}
}
}
state.sessions.remove_agent(&agent_id).await;
db::machines::delete_machine(db.pool(), &agent_id)
.await
.map_err(|e| {
tracing::error!("DB error hard-deleting machine: {}", e);
err(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"Failed to delete machine",
)
})?;
tracing::info!(
"Admin {} hard-deleted machine {} (uninstall_sent: {})",
admin.username,
agent_id,
uninstall_sent
);
Ok(Json(DeleteMachineResponse {
success: true,
message: format!("Machine {} deleted", machine.hostname),
uninstall_sent,
history,
}))
}
/// Soft-delete + in-memory removal + audit for one machine (the `?purge=true` path).
async fn purge_machine(
state: &AppState,
db: &db::Database,
admin: &crate::auth::AuthenticatedUser,
machine: db::machines::Machine,
ip: IpAddr,
) -> ApiResult<Json<DeleteMachineResponse>> {
// 1. Soft-delete the DB row. 0 rows means it was purged between the load and
// here (a race) — treat as already-removed success, idempotent.
let affected = db::machines::soft_delete_machine(db.pool(), &machine.agent_id)
.await
.map_err(|e| {
tracing::error!("DB error soft-deleting machine: {}", e);
err(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"Failed to remove machine",
)
})?;
// 2. Drop the live in-memory session (agents + machine_uids indexes) so the
// purged machine cannot keep streaming or be reattached. `remove_agent`
// resolves agent_id -> session_id and routes through `remove_session`.
let removed_session = state.sessions.remove_agent(&machine.agent_id).await;
// 3. Audit. Anchor to the machine's last session when known so the event links
// to that machine's history; the detail JSON independently carries the ids.
let details = serde_json::json!({
"target_kind": "machine",
"agent_id": machine.agent_id,
"machine_id": machine.id,
"hostname": machine.hostname,
"machine_uid": machine.machine_uid,
"purge": true,
"soft_deleted": affected > 0,
"in_memory_session_removed": removed_session.map(|s| s.to_string()),
});
if let Err(e) = db::events::log_admin_removal(
db.pool(),
machine.last_session_id,
EventTypes::MACHINE_REMOVED,
&admin.user_id,
&admin.username,
details,
Some(ip),
)
.await
{
// Audit is best-effort: the purge already happened. Log loudly; do not fail
// the request (mirrors how the relay treats audit-write failures).
tracing::error!("Failed to write machine_removed audit event: {}", e);
}
tracing::info!(
"Admin {} purged machine {} (agent_id {}, soft_deleted={}, session_removed={})",
admin.username,
machine.hostname,
machine.agent_id,
affected > 0,
removed_session.is_some()
);
Ok(Json(DeleteMachineResponse {
success: true,
message: format!("Machine {} removed", machine.hostname),
uninstall_sent: false,
history: None,
}))
}
// ============================================================================
// Single-session removal — DELETE /api/sessions/:id[?purge=true]
// ============================================================================
/// Query flag for the session removal route.
#[derive(Debug, Deserialize)]
pub struct PurgeParams {
/// When true, soft-delete the session row + remove it in-memory + audit. When
/// false/absent, fall back to the live-only disconnect (unchanged behavior).
#[serde(default)]
pub purge: bool,
}
/// Result body for a session removal.
#[derive(Debug, Serialize)]
pub struct RemoveSessionResponse {
pub success: bool,
pub message: String,
/// Whether the row was soft-deleted in the DB (false if there was no DB row,
/// e.g. a live support session that never persisted, or it was already purged).
pub soft_deleted: bool,
}
/// DELETE /api/sessions/:id?purge=true
///
/// Admin-only soft-delete of a session: set `connect_sessions.deleted_at`, drop the
/// live in-memory session via `remove_session`, and write a `session_removed` audit
/// event. WITHOUT `?purge=true` this delegates to the live-only disconnect so the
/// pre-existing semantics are preserved.
pub async fn remove_session(
AdminUser(admin): AdminUser,
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(id): Path<String>,
Query(params): Query<PurgeParams>,
) -> ApiResult<Json<RemoveSessionResponse>> {
let session_id = Uuid::parse_str(&id).map_err(|_| {
err(
StatusCode::BAD_REQUEST,
"INVALID_SESSION_ID",
"session id must be a valid UUID",
)
})?;
if !params.purge {
// Live-only disconnect (unchanged): send a Disconnect to the agent. 404 if
// the session is not live in memory.
let disconnected = state
.sessions
.disconnect_session(session_id, "Disconnected by administrator")
.await;
if disconnected {
tracing::info!(
"Admin {} disconnected session {} (live-only)",
admin.username,
session_id
);
return Ok(Json(RemoveSessionResponse {
success: true,
message: "Session disconnected".to_string(),
soft_deleted: false,
}));
}
return Err(err(
StatusCode::NOT_FOUND,
"SESSION_NOT_FOUND",
"Session not found",
));
}
let db = require_db(&state)?;
// A session may be live in memory, persisted in the DB, or both. Soft-delete the
// DB row (no-op if there is none) and drop any in-memory session. The purge is a
// 404 only when NEITHER exists.
let in_memory = state.sessions.get_session(session_id).await.is_some();
let soft_deleted = db::sessions::soft_delete_session(db.pool(), session_id)
.await
.map_err(|e| {
tracing::error!("DB error soft-deleting session: {}", e);
err(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"Failed to remove session",
)
})?
> 0;
if !in_memory && !soft_deleted {
return Err(err(
StatusCode::NOT_FOUND,
"SESSION_NOT_FOUND",
"Session not found",
));
}
state.sessions.remove_session(session_id).await;
let details = serde_json::json!({
"target_kind": "session",
"session_id": session_id,
"purge": true,
"soft_deleted": soft_deleted,
"was_live": in_memory,
});
if let Err(e) = db::events::log_admin_removal(
db.pool(),
Some(session_id),
EventTypes::SESSION_REMOVED,
&admin.user_id,
&admin.username,
details,
Some(audit_ip(&state, addr, &headers)),
)
.await
{
tracing::error!("Failed to write session_removed audit event: {}", e);
}
tracing::info!(
"Admin {} purged session {} (soft_deleted={}, was_live={})",
admin.username,
session_id,
soft_deleted,
in_memory
);
Ok(Json(RemoveSessionResponse {
success: true,
message: "Session removed".to_string(),
soft_deleted,
}))
}
// ============================================================================
// Bulk machine removal — POST /api/machines/bulk-remove
// ============================================================================
/// Request body for the bulk machine removal.
#[derive(Debug, Deserialize)]
pub struct BulkRemoveRequest {
/// `agent_id`s to remove. Each is validated as a UUID; unknown/invalid ids are
/// reported per-id rather than failing the whole batch.
pub ids: Vec<String>,
/// When true, soft-delete + in-memory removal + audit (Task 5). When false, the
/// legacy hard delete is applied to each id. Defaults to true: the bulk endpoint
/// exists for the purge workflow.
#[serde(default = "default_true")]
pub purge: bool,
}
fn default_true() -> bool {
true
}
/// Per-id outcome in a bulk response.
#[derive(Debug, Serialize)]
pub struct BulkRemoveItem {
pub agent_id: String,
/// `removed` | `not_found` | `invalid` | `error`.
pub status: String,
}
/// Summary body for a bulk removal.
#[derive(Debug, Serialize)]
pub struct BulkRemoveResponse {
pub requested: usize,
pub removed: usize,
pub results: Vec<BulkRemoveItem>,
}
/// POST /api/machines/bulk-remove
///
/// Admin-only. Removes many machines in one call, auditing ONE `machine_removed`
/// event per actually-removed id (matching the single-machine granularity). Invalid
/// or unknown ids are reported in the per-id results and never abort the batch. With
/// `purge=true` (default) each removal is the Task-5 soft-delete; otherwise the
/// legacy hard delete.
pub async fn bulk_remove_machines(
AdminUser(admin): AdminUser,
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(body): Json<BulkRemoveRequest>,
) -> ApiResult<Json<BulkRemoveResponse>> {
if body.ids.is_empty() {
return Err(err(
StatusCode::BAD_REQUEST,
"EMPTY_ID_LIST",
"ids must contain at least one agent_id",
));
}
// Guard against an unbounded batch.
const MAX_BATCH: usize = 500;
if body.ids.len() > MAX_BATCH {
return Err(err(
StatusCode::BAD_REQUEST,
"BATCH_TOO_LARGE",
"ids exceeds the maximum batch size of 500",
));
}
let db = require_db(&state)?;
let ip = audit_ip(&state, addr, &headers);
let requested = body.ids.len();
let mut results = Vec::with_capacity(requested);
let mut removed = 0usize;
for agent_id in body.ids {
// Validate shape first.
if Uuid::parse_str(&agent_id).is_err() {
results.push(BulkRemoveItem {
agent_id,
status: "invalid".to_string(),
});
continue;
}
// Must exist + be live.
let machine = match db::machines::get_machine_by_agent_id(db.pool(), &agent_id).await {
Ok(Some(m)) => m,
Ok(None) => {
results.push(BulkRemoveItem {
agent_id,
status: "not_found".to_string(),
});
continue;
}
Err(e) => {
tracing::error!("DB error loading machine {} in bulk: {}", agent_id, e);
results.push(BulkRemoveItem {
agent_id,
status: "error".to_string(),
});
continue;
}
};
if body.purge {
match db::machines::soft_delete_machine(db.pool(), &agent_id).await {
Ok(affected) => {
let removed_session = state.sessions.remove_agent(&agent_id).await;
let details = serde_json::json!({
"target_kind": "machine",
"agent_id": machine.agent_id,
"machine_id": machine.id,
"hostname": machine.hostname,
"machine_uid": machine.machine_uid,
"purge": true,
"bulk": true,
"soft_deleted": affected > 0,
"in_memory_session_removed": removed_session.map(|s| s.to_string()),
});
if let Err(e) = db::events::log_admin_removal(
db.pool(),
machine.last_session_id,
EventTypes::MACHINE_REMOVED,
&admin.user_id,
&admin.username,
details,
Some(ip),
)
.await
{
tracing::error!(
"Failed to write machine_removed audit event (bulk) for {}: {}",
agent_id,
e
);
}
removed += 1;
results.push(BulkRemoveItem {
agent_id,
status: "removed".to_string(),
});
}
Err(e) => {
tracing::error!("DB error soft-deleting machine {} in bulk: {}", agent_id, e);
results.push(BulkRemoveItem {
agent_id,
status: "error".to_string(),
});
}
}
} else {
// Legacy hard-delete per id.
state.sessions.remove_agent(&agent_id).await;
match db::machines::delete_machine(db.pool(), &agent_id).await {
Ok(()) => {
removed += 1;
results.push(BulkRemoveItem {
agent_id,
status: "removed".to_string(),
});
}
Err(e) => {
tracing::error!("DB error hard-deleting machine {} in bulk: {}", agent_id, e);
results.push(BulkRemoveItem {
agent_id,
status: "error".to_string(),
});
}
}
}
}
tracing::info!(
"Admin {} bulk-removed {}/{} machine(s) (purge={})",
admin.username,
removed,
requested,
body.purge
);
Ok(Json(BulkRemoveResponse {
requested,
removed,
results,
}))
}

View File

@@ -12,6 +12,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::collections::HashSet;
use uuid::Uuid;
/// Per-agent key record from the database.
@@ -142,3 +143,27 @@ pub async fn touch_last_used(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error>
.await?;
Ok(())
}
/// Return the set of `connect_machines.id` values that are bound to at least one
/// active (non-revoked) per-agent `cak_` key.
///
/// Used by the startup restore loop (`main.rs`) to enforce the same keyed/un-keyed
/// distinction the relay applies at connect time (SPEC-004 Task 2). For a KEYED
/// machine the key→machine binding is the authoritative identity, so its restored
/// session must NOT be indexed by a client-asserted `machine_uid` — otherwise an
/// un-keyed agent spoofing that uid could reattach the keyed machine's offline
/// session after a restart. Membership here lets the caller pass `machine_uid =
/// None` for keyed machines while still indexing un-keyed ones for legitimate
/// uid-based reattach.
pub async fn keyed_machine_ids(pool: &PgPool) -> Result<HashSet<Uuid>, sqlx::Error> {
let ids = sqlx::query_scalar::<_, Uuid>(
r#"
SELECT DISTINCT machine_id
FROM connect_agent_keys
WHERE revoked_at IS NULL
"#,
)
.fetch_all(pool)
.await?;
Ok(ids.into_iter().collect())
}

View File

@@ -11,7 +11,9 @@ use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SessionEvent {
pub id: i64,
pub session_id: Uuid,
/// Nullable: admin-removal audit rows (Task 5) carry no session, so this is
/// `None` for those. Session/viewer events always populate it.
pub session_id: Option<Uuid>,
pub event_type: String,
pub timestamp: DateTime<Utc>,
pub viewer_id: Option<String>,
@@ -58,6 +60,15 @@ impl EventTypes {
/// A `ConsentRequest` was sent to the agent for an attended session (the
/// prompt is now awaiting the end user's decision).
pub const CONSENT_REQUESTED: &'static str = "consent_requested";
// Operator-removal events (Task 5). Written by the admin purge endpoints. The
// acting admin is recorded in `viewer_id`/`viewer_name` (the only actor fields
// the audit table carries) and the structured purge detail (target id, purge
// flag, in-memory removal result) goes in `details`.
/// An administrator soft-deleted (purged) a machine and dropped its live session.
pub const MACHINE_REMOVED: &'static str = "machine_removed";
/// An administrator soft-deleted (purged) a session and dropped it in-memory.
pub const SESSION_REMOVED: &'static str = "session_removed";
}
/// Log a session event
@@ -92,6 +103,57 @@ pub async fn log_event(
Ok(result)
}
/// Log an operator-removal audit event (Task 5).
///
/// Writes to the same `connect_session_events` audit table as [`log_event`], but
/// shaped for an admin action rather than a viewer/session event: the acting admin
/// is recorded in `viewer_id` (the admin's user id) and `viewer_name` (username) —
/// the only actor-bearing columns the table has — and structured detail (target id,
/// `purge` flag, in-memory removal result) goes in `details`.
///
/// `session_id` is the audit anchor. For a session purge it is the purged session.
/// For a machine purge it is the machine's `last_session_id` when known (linking the
/// event to that machine's history) or `None` (the `session_id` FK column is
/// nullable). The FK is `ON DELETE CASCADE`, so anchoring to a session that later
/// gets hard-deleted would cascade the audit row away — acceptable here because the
/// Task-5 flow soft-deletes (never hard-deletes) and the detail JSON independently
/// carries the target id.
///
/// Best-effort: a failure to write the audit row is logged by the caller and does
/// not roll back the purge (the soft-delete + in-memory removal already happened),
/// matching how the relay treats audit writes.
#[allow(clippy::too_many_arguments)]
pub async fn log_admin_removal(
pool: &PgPool,
session_id: Option<Uuid>,
event_type: &str,
actor_user_id: &str,
actor_username: &str,
details: 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(actor_user_id)
.bind(actor_username)
.bind(details)
.bind(ip_str)
.fetch_one(pool)
.await?;
Ok(result)
}
/// Get events for a session
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
pub async fn get_session_events(

View File

@@ -50,6 +50,20 @@ pub struct Machine {
/// impl) so it can never error at decode time.
#[serde(default)]
pub tags: Vec<String>,
/// Deterministic, recomputable hardware identity reported by the agent
/// (`AgentStatus.machine_uid` / connect query param). Column added in migration
/// 008. NULLABLE: legacy rows and agents that do not report a uid carry `None`.
/// For un-keyed agents this is the dedup key (`upsert_machine` keys
/// `ON CONFLICT (machine_uid)` when present); for `cak_`-keyed agents the key's
/// machine binding stays authoritative and the claimed uid is NOT used to dedup
/// (see `upsert_machine`).
pub machine_uid: Option<String>,
/// Soft-delete marker (migration 009). When non-null the machine was
/// operator-purged (Task 5): it is excluded from every list/get query and is
/// never restored by the startup reconcile, but the row (and its audit
/// history) is retained. NULL = live. Nullable, so it is read NULL-tolerantly
/// in the manual `FromRow` below.
pub deleted_at: Option<DateTime<Utc>>,
}
impl<'r> FromRow<'r, PgRow> for Machine {
@@ -65,6 +79,10 @@ impl<'r> FromRow<'r, PgRow> for Machine {
tenant_id: row.try_get("tenant_id")?,
organization: row.try_get("organization")?,
site: row.try_get("site")?,
// Schema-nullable (migration 008); decode directly as Option.
machine_uid: row.try_get("machine_uid")?,
// Schema-nullable (migration 009); decode directly as Option.
deleted_at: row.try_get("deleted_at")?,
// Nullable-with-default columns mapped to non-`Option` Rust types: read as
// `Option<T>` and fall back to the type default so a NULL cell never errors.
is_elevated: row
@@ -97,29 +115,96 @@ impl<'r> FromRow<'r, PgRow> for Machine {
}
}
/// Get or create a machine by agent_id (upsert)
/// Get or create a machine record (upsert), deduplicating on the most stable
/// identity available (SPEC-004 / v2-stable-identity Task 2).
///
/// Two dedup paths, selected by whether the caller passes a `machine_uid`:
///
/// - **`machine_uid = Some(uid)` (the un-keyed dedup path):** key on the stable
/// hardware identity — `ON CONFLICT (machine_uid)`. The SAME machine reconnecting
/// with a DIFFERENT `agent_id` (e.g. after a config loss minted a fresh random id)
/// updates its EXISTING row instead of inserting a duplicate. `agent_id` and
/// `hostname` are refreshed to the latest reported values. This is what collapses
/// the duplicate-registration fleet down to one row per real machine.
///
/// - **`machine_uid = None` (legacy / authoritative path):** preserve the original
/// behavior exactly — `ON CONFLICT (agent_id)`. Used for agents that do not report
/// a uid AND, critically, for `cak_`-keyed agents: the caller (`relay`) passes
/// `None` for keyed agents so their AUTHORITATIVE key-bound `agent_id` is the dedup
/// key and a client-claimed `machine_uid` can never repoint a keyed machine's row.
///
/// SECURITY: a client-asserted `machine_uid` is spoofable, so it is a *correctness*
/// aid, not a trust boundary. Only the un-keyed path supplies it; the keyed path's
/// authority lives in the key→machine binding upstream (see
/// `relay::agent_ws_handler`), never here.
///
/// SOFT-DELETE REVIVE (Task 5): both `ON CONFLICT DO UPDATE` arms clear
/// `deleted_at` (set it back to NULL). A machine that was operator-purged but then
/// genuinely reconnects is a live machine again, so it must reappear in the console
/// rather than stay hidden behind a stale soft-delete marker. A purge of a truly
/// gone host is permanent precisely because such a host never upserts again.
pub async fn upsert_machine(
pool: &PgPool,
agent_id: &str,
hostname: &str,
is_persistent: bool,
machine_uid: Option<&str>,
) -> 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
match machine_uid {
// Un-keyed dedup path: stable hardware identity is the conflict arbiter.
// A new agent_id for the same physical machine updates the existing row.
//
// Edge case: if this INSERT's new random `agent_id` happens to collide with a
// DIFFERENT legacy row's value under the `agent_id UNIQUE` constraint, Postgres
// raises the unique violation on `agent_id` BEFORE the `ON CONFLICT
// (machine_uid)` arbiter is consulted, so the upsert errors instead of merging
// on uid. This is non-fatal: the caller logs the error and the live in-memory
// session is unaffected, and the agent simply retries with a freshly minted
// UUID. The window closes on its own as legacy rows age out (SPEC-004 Task 3).
Some(uid) => {
sqlx::query_as::<_, Machine>(
r#"
INSERT INTO connect_machines (agent_id, hostname, is_persistent, status, last_seen, machine_uid)
VALUES ($1, $2, $3, 'online', NOW(), $4)
ON CONFLICT (machine_uid) DO UPDATE SET
agent_id = EXCLUDED.agent_id,
hostname = EXCLUDED.hostname,
status = 'online',
last_seen = NOW(),
deleted_at = NULL
RETURNING *
"#,
)
.bind(agent_id)
.bind(hostname)
.bind(is_persistent)
.bind(uid)
.fetch_one(pool)
.await
}
// Legacy / authoritative path: dedup on agent_id exactly as before. Leaves
// machine_uid NULL (the partial unique index excludes NULLs, so any number
// of these may coexist).
None => {
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(),
deleted_at = NULL
RETURNING *
"#,
)
.bind(agent_id)
.bind(hostname)
.bind(is_persistent)
.fetch_one(pool)
.await
}
}
}
/// Update machine status and info
@@ -153,24 +238,31 @@ pub async fn update_machine_status(
Ok(())
}
/// Get all persistent machines (for restore on startup)
/// Get all persistent machines (for the dashboard list AND the startup restore).
///
/// Excludes operator-purged rows (`deleted_at IS NOT NULL`, migration 009 / Task 5):
/// a soft-deleted machine must not reappear in `/api/machines` and must not be
/// re-restored into the in-memory session manager on startup. This is the filter
/// that makes the ghost-row purge stick.
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",
"SELECT * FROM connect_machines WHERE is_persistent = true AND deleted_at IS NULL ORDER BY hostname",
)
.fetch_all(pool)
.await
}
/// Get machine by agent_id
/// Get machine by agent_id (live rows only — excludes soft-deleted, Task 5).
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
sqlx::query_as::<_, Machine>(
"SELECT * FROM connect_machines WHERE agent_id = $1 AND deleted_at IS NULL",
)
.bind(agent_id)
.fetch_optional(pool)
.await
}
/// Get machine by its primary-key UUID (`connect_machines.id`).
@@ -179,6 +271,13 @@ pub async fn get_machine_by_agent_id(
/// `verify_agent_key` back to its canonical `agent_id`, so persistent reattach
/// binds to the authenticated identity rather than a client-supplied query
/// param (Task 3 identity binding).
///
/// NOTE (Task 5): this deliberately does NOT filter `deleted_at IS NULL`. It is
/// the authenticated-identity resolver for a `cak_`-keyed agent's reattach, not a
/// dashboard read. If a previously operator-purged machine genuinely reconnects
/// with a valid key, it must resolve so `upsert_machine` can revive it (the
/// upsert clears `deleted_at`). The dashboard get-by-id path is
/// `get_machine_by_agent_id`, which IS filtered.
pub async fn get_machine_by_id(
pool: &PgPool,
machine_id: Uuid,
@@ -200,7 +299,11 @@ pub async fn mark_machine_offline(pool: &PgPool, agent_id: &str) -> Result<(), s
Ok(())
}
/// Delete a machine record
/// Hard-delete a machine record (legacy path, retained for backward compatibility).
///
/// Cascades to `connect_sessions` / `connect_session_events` via the FKs, so it
/// also destroys audit history. The Task-5 operator-removal flow prefers
/// [`soft_delete_machine`] instead, which keeps the row for the audit trail.
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)
@@ -209,6 +312,24 @@ pub async fn delete_machine(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::E
Ok(())
}
/// Soft-delete (operator purge) a single machine by `agent_id` (Task 5).
///
/// Sets `deleted_at = NOW()` so the row is excluded from every list/get query and
/// the startup reconcile, while retaining the row and its `connect_session_events`
/// history for the audit trail. Only flips rows that are still live
/// (`deleted_at IS NULL`), so a re-purge is a no-op rather than overwriting the
/// original removal instant. Returns the number of rows affected (0 = unknown or
/// already-purged `agent_id`), letting the caller distinguish a 404 from a success.
pub async fn soft_delete_machine(pool: &PgPool, agent_id: &str) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
"UPDATE connect_machines SET deleted_at = NOW() WHERE agent_id = $1 AND deleted_at IS NULL",
)
.bind(agent_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
/// Update machine organization, site, and tags
pub async fn update_machine_metadata(
pool: &PgPool,
@@ -239,3 +360,290 @@ pub async fn update_machine_metadata(
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::postgres::PgPoolOptions;
/// Connect to a throwaway test Postgres and apply migrations, or return `None`
/// when `TEST_DATABASE_URL` is unset so the suite is a no-op on workstations
/// without a database. CI sets `TEST_DATABASE_URL` against an ephemeral Postgres,
/// where these run for real. (The server crate is Linux-targeted and validated
/// in Gitea CI; these DB tests run there.)
async fn test_pool() -> Option<PgPool> {
let url = std::env::var("TEST_DATABASE_URL").ok()?;
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("connect to TEST_DATABASE_URL");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("apply migrations to the test database");
Some(pool)
}
/// Remove any rows this test created so reruns are clean and tests don't collide.
async fn cleanup(pool: &PgPool, agent_ids: &[&str], machine_uids: &[&str]) {
for id in agent_ids {
let _ = sqlx::query("DELETE FROM connect_machines WHERE agent_id = $1")
.bind(id)
.execute(pool)
.await;
}
for uid in machine_uids {
let _ = sqlx::query("DELETE FROM connect_machines WHERE machine_uid = $1")
.bind(uid)
.execute(pool)
.await;
}
}
/// (a) Same `machine_uid` with two DIFFERENT `agent_id`s collapses to ONE row.
/// The second upsert updates the existing row (and repoints its agent_id) rather
/// than inserting a duplicate — the core dedup guarantee for the un-keyed fleet.
#[tokio::test]
async fn same_machine_uid_two_agent_ids_one_row() {
let Some(pool) = test_pool().await else {
return; // no TEST_DATABASE_URL: skip (runs in CI)
};
let uid = "test-muid-dedup-001";
cleanup(&pool, &["agent-A", "agent-B"], &[uid]).await;
let m1 = upsert_machine(&pool, "agent-A", "HOST-A", true, Some(uid))
.await
.expect("first upsert");
let m2 = upsert_machine(&pool, "agent-B", "HOST-A2", true, Some(uid))
.await
.expect("second upsert with same uid, different agent_id");
// Same physical row, agent_id and hostname refreshed to the latest.
assert_eq!(m1.id, m2.id, "same machine_uid must update the same row");
assert_eq!(m2.agent_id, "agent-B");
assert_eq!(m2.hostname, "HOST-A2");
assert_eq!(m2.machine_uid.as_deref(), Some(uid));
// Exactly one row carries this uid.
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM connect_machines WHERE machine_uid = $1")
.bind(uid)
.fetch_one(&pool)
.await
.expect("count rows for uid");
assert_eq!(count, 1, "must be exactly one row for the machine_uid");
cleanup(&pool, &["agent-A", "agent-B"], &[uid]).await;
}
/// (b) Legacy NULL-`machine_uid` path is unchanged: dedup keys on `agent_id`,
/// the row's `machine_uid` stays NULL, and re-upserting the same agent_id with
/// no uid updates the same row (no crash, no duplicate).
#[tokio::test]
async fn legacy_null_machine_uid_dedups_on_agent_id() {
let Some(pool) = test_pool().await else {
return; // no TEST_DATABASE_URL: skip (runs in CI)
};
let agent = "test-legacy-agent-001";
cleanup(&pool, &[agent], &[]).await;
let m1 = upsert_machine(&pool, agent, "LEGACY-HOST", true, None)
.await
.expect("legacy upsert (no uid)");
assert_eq!(
m1.machine_uid, None,
"legacy row must have NULL machine_uid"
);
let m2 = upsert_machine(&pool, agent, "LEGACY-HOST-RENAMED", true, None)
.await
.expect("legacy re-upsert (no uid)");
assert_eq!(m1.id, m2.id, "legacy agent_id must dedup to the same row");
assert_eq!(m2.hostname, "LEGACY-HOST-RENAMED");
assert_eq!(m2.machine_uid, None);
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM connect_machines WHERE agent_id = $1")
.bind(agent)
.fetch_one(&pool)
.await
.expect("count legacy rows");
assert_eq!(count, 1, "legacy path must not duplicate the row");
cleanup(&pool, &[agent], &[]).await;
}
/// Multiple legacy rows with NULL machine_uid coexist — the partial unique index
/// excludes NULLs, so distinct un-keyed agents are independent rows.
#[tokio::test]
async fn multiple_null_machine_uid_rows_coexist() {
let Some(pool) = test_pool().await else {
return; // no TEST_DATABASE_URL: skip (runs in CI)
};
cleanup(&pool, &["null-1", "null-2"], &[]).await;
let a = upsert_machine(&pool, "null-1", "H1", true, None)
.await
.expect("first null-uid row");
let b = upsert_machine(&pool, "null-2", "H2", true, None)
.await
.expect("second null-uid row");
assert_ne!(a.id, b.id, "distinct legacy agents must be distinct rows");
cleanup(&pool, &["null-1", "null-2"], &[]).await;
}
/// Helper: does `get_all_machines` (the dashboard list / startup restore query)
/// currently return a row with this agent_id?
async fn list_contains(pool: &PgPool, agent_id: &str) -> bool {
get_all_machines(pool)
.await
.expect("list machines")
.iter()
.any(|m| m.agent_id == agent_id)
}
/// Task 5: soft-deleting a machine sets `deleted_at` and excludes it from BOTH
/// the list query and the by-agent_id get — the core operator-removal guarantee
/// (a purged ghost row must not reappear in /api/machines).
#[tokio::test]
async fn soft_delete_machine_hides_from_list_and_get() {
let Some(pool) = test_pool().await else {
return; // no TEST_DATABASE_URL: skip (runs in CI)
};
let agent = "test-softdel-agent-001";
cleanup(&pool, &[agent], &[]).await;
let m = upsert_machine(&pool, agent, "SOFTDEL-HOST", true, None)
.await
.expect("create machine");
assert!(m.deleted_at.is_none(), "fresh row must be live");
assert!(
list_contains(&pool, agent).await,
"live machine must be listed"
);
assert!(
get_machine_by_agent_id(&pool, agent)
.await
.expect("get")
.is_some(),
"live machine must be gettable"
);
// Soft-delete.
let affected = soft_delete_machine(&pool, agent)
.await
.expect("soft delete");
assert_eq!(affected, 1, "exactly one live row flips to deleted");
// Excluded from list and get.
assert!(
!list_contains(&pool, agent).await,
"soft-deleted machine must NOT be listed"
);
assert!(
get_machine_by_agent_id(&pool, agent)
.await
.expect("get after delete")
.is_none(),
"soft-deleted machine must NOT be gettable by agent_id"
);
// The row still exists with a non-null deleted_at (history retained).
let deleted_at: Option<DateTime<Utc>> =
sqlx::query_scalar("SELECT deleted_at FROM connect_machines WHERE agent_id = $1")
.bind(agent)
.fetch_one(&pool)
.await
.expect("row still present");
assert!(
deleted_at.is_some(),
"row must be retained with deleted_at set"
);
// Re-purge is a no-op (does not overwrite the original instant).
let again = soft_delete_machine(&pool, agent)
.await
.expect("re-soft-delete");
assert_eq!(
again, 0,
"re-purge of an already-deleted row affects 0 rows"
);
cleanup(&pool, &[agent], &[]).await;
}
/// Task 5: a genuine reconnect (upsert) of a previously soft-deleted machine
/// REVIVES it — `deleted_at` is cleared so it reappears in the console. A purge
/// only sticks for a host that never upserts again.
#[tokio::test]
async fn upsert_revives_soft_deleted_machine() {
let Some(pool) = test_pool().await else {
return; // no TEST_DATABASE_URL: skip (runs in CI)
};
let agent = "test-revive-agent-001";
cleanup(&pool, &[agent], &[]).await;
upsert_machine(&pool, agent, "REVIVE-HOST", true, None)
.await
.expect("create");
soft_delete_machine(&pool, agent)
.await
.expect("soft delete");
assert!(!list_contains(&pool, agent).await, "purged: hidden");
// Reconnect.
let revived = upsert_machine(&pool, agent, "REVIVE-HOST", true, None)
.await
.expect("reconnect upsert");
assert!(
revived.deleted_at.is_none(),
"reconnect must clear deleted_at"
);
assert!(
list_contains(&pool, agent).await,
"revived machine must be listed again"
);
cleanup(&pool, &[agent], &[]).await;
}
/// Task 5: bulk soft-delete (as the bulk endpoint does, one id at a time)
/// removes every listed id from the live list.
#[tokio::test]
async fn bulk_soft_delete_hides_all_listed() {
let Some(pool) = test_pool().await else {
return; // no TEST_DATABASE_URL: skip (runs in CI)
};
let agents = ["test-bulk-a", "test-bulk-b", "test-bulk-c"];
cleanup(&pool, &agents, &[]).await;
for (i, a) in agents.iter().enumerate() {
upsert_machine(&pool, a, &format!("BULK-HOST-{i}"), true, None)
.await
.expect("create bulk machine");
}
for a in &agents {
assert!(list_contains(&pool, a).await, "{a} listed before bulk");
}
// Purge all three (the bulk endpoint loops soft_delete_machine per id).
let mut removed = 0u64;
for a in &agents {
removed += soft_delete_machine(&pool, a)
.await
.expect("bulk soft delete");
}
assert_eq!(removed, 3, "all three live rows flipped to deleted");
for a in &agents {
assert!(
!list_contains(&pool, a).await,
"{a} must be hidden after bulk purge"
);
}
cleanup(&pool, &agents, &[]).await;
}
}

View File

@@ -170,6 +170,7 @@ pub async fn get_machines_needing_update(
SELECT agent_id FROM connect_machines
WHERE status = 'online'
AND is_persistent = true
AND deleted_at IS NULL
AND (agent_version IS NULL OR agent_version < $1)
"#,
)

View File

@@ -25,6 +25,10 @@ pub struct DbSession {
/// Attended-consent state: 'not_required' | 'pending' | 'granted' | 'denied'.
/// Enforcement is Task 5; this column carries the state only.
pub consent_state: String,
/// Soft-delete marker (migration 009). When non-null the session was
/// operator-purged (Task 5) and is excluded from every list/get query, while
/// the row (and its audit history) is retained. NULL = live.
pub deleted_at: Option<DateTime<Utc>>,
}
/// Create a new session record
@@ -120,10 +124,12 @@ 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
sqlx::query_as::<_, DbSession>(
"SELECT * FROM connect_sessions WHERE id = $1 AND deleted_at IS NULL",
)
.bind(session_id)
.fetch_optional(pool)
.await
}
/// Get active sessions for a machine
@@ -133,7 +139,7 @@ pub async fn get_active_sessions_for_machine(
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"
"SELECT * FROM connect_sessions WHERE machine_id = $1 AND status = 'active' AND deleted_at IS NULL ORDER BY started_at DESC"
)
.bind(machine_id)
.fetch_all(pool)
@@ -144,7 +150,7 @@ pub async fn get_active_sessions_for_machine(
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
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",
"SELECT * FROM connect_sessions WHERE deleted_at IS NULL ORDER BY started_at DESC LIMIT $1",
)
.bind(limit)
.fetch_all(pool)
@@ -157,9 +163,140 @@ pub async fn get_sessions_for_machine(
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",
"SELECT * FROM connect_sessions WHERE machine_id = $1 AND deleted_at IS NULL ORDER BY started_at DESC",
)
.bind(machine_id)
.fetch_all(pool)
.await
}
/// Soft-delete (operator purge) a single session by id (Task 5).
///
/// Sets `deleted_at = NOW()` so the row is excluded from every list/get query
/// while the row and its `connect_session_events` history are retained for the
/// audit trail. Only flips rows that are still live (`deleted_at IS NULL`) so a
/// re-purge does not overwrite the original removal instant. Returns the number
/// of rows affected (0 = unknown or already-purged id) so the caller can return a
/// clean 404 vs. success.
pub async fn soft_delete_session(pool: &PgPool, session_id: Uuid) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
"UPDATE connect_sessions SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
)
.bind(session_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::machines;
use sqlx::postgres::PgPoolOptions;
/// Connect to a throwaway test Postgres and apply migrations, or return `None`
/// when `TEST_DATABASE_URL` is unset (self-skip on workstations; runs in CI).
async fn test_pool() -> Option<PgPool> {
let url = std::env::var("TEST_DATABASE_URL").ok()?;
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("connect to TEST_DATABASE_URL");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("apply migrations to the test database");
Some(pool)
}
/// Create a machine to satisfy the `connect_sessions.machine_id` FK; returns its id.
async fn seed_machine(pool: &PgPool, agent_id: &str) -> Uuid {
let _ = sqlx::query("DELETE FROM connect_machines WHERE agent_id = $1")
.bind(agent_id)
.execute(pool)
.await;
machines::upsert_machine(pool, agent_id, "SESS-TEST-HOST", true, None)
.await
.expect("seed machine")
.id
}
/// Task 5: soft-deleting a session sets `deleted_at` and excludes it from get and
/// the machine/recent list queries, while the row is retained.
#[tokio::test]
async fn soft_delete_session_hides_from_get_and_lists() {
let Some(pool) = test_pool().await else {
return; // no TEST_DATABASE_URL: skip (runs in CI)
};
let agent = "test-sess-softdel-agent";
let machine_id = seed_machine(&pool, agent).await;
let session_id = Uuid::new_v4();
let s = create_session(&pool, session_id, machine_id, false, None)
.await
.expect("create session");
assert!(s.deleted_at.is_none(), "fresh session must be live");
// Visible before deletion.
assert!(
get_session(&pool, session_id).await.expect("get").is_some(),
"live session must be gettable"
);
assert!(
get_sessions_for_machine(&pool, machine_id)
.await
.expect("list for machine")
.iter()
.any(|x| x.id == session_id),
"live session must be in the machine list"
);
// Soft-delete.
let affected = soft_delete_session(&pool, session_id)
.await
.expect("soft delete session");
assert_eq!(affected, 1, "exactly one live session flips to deleted");
// Excluded from get and lists.
assert!(
get_session(&pool, session_id)
.await
.expect("get after delete")
.is_none(),
"soft-deleted session must NOT be gettable"
);
assert!(
!get_sessions_for_machine(&pool, machine_id)
.await
.expect("list after delete")
.iter()
.any(|x| x.id == session_id),
"soft-deleted session must NOT be in the machine list"
);
// Row retained with deleted_at set.
let deleted_at: Option<DateTime<Utc>> =
sqlx::query_scalar("SELECT deleted_at FROM connect_sessions WHERE id = $1")
.bind(session_id)
.fetch_one(&pool)
.await
.expect("session row still present");
assert!(deleted_at.is_some(), "session row retained with deleted_at");
// Re-purge is a no-op.
let again = soft_delete_session(&pool, session_id)
.await
.expect("re-soft-delete");
assert_eq!(
again, 0,
"re-purge of an already-deleted session affects 0 rows"
);
// Cleanup (cascade from the machine removes the session row too).
let _ = sqlx::query("DELETE FROM connect_machines WHERE id = $1")
.bind(machine_id)
.execute(&pool)
.await;
}
}

View File

@@ -21,7 +21,7 @@ pub mod proto {
use anyhow::Result;
use axum::http::{HeaderValue, Method};
use axum::{
extract::{ConnectInfo, Json, Path, Query, Request, State},
extract::{ConnectInfo, Json, Path, Request, State},
http::StatusCode,
middleware::{self as axum_middleware, Next},
response::IntoResponse,
@@ -57,6 +57,16 @@ const SPA_DIR: &str = "static/app";
/// non-WS, non-asset GET so `BrowserRouter` deep links (`/machines`,
/// `/sessions`, `/login`) survive a hard reload.
const SPA_INDEX: &str = "static/app/index.html";
/// How long an OFFLINE persistent session may sit idle before the reaper removes
/// it (v2-stable-identity Task 4). Measured on the session's monotonic
/// `last_heartbeat_instant`. Ten minutes is well past the agent's 30s heartbeat /
/// 90s timeout, so only genuinely-gone machines age out — a brief reconnect blip
/// never reaps a real session.
const PERSISTENT_SESSION_TTL: std::time::Duration = std::time::Duration::from_secs(10 * 60);
/// Cadence of the stale-session reaper sweep (v2-stable-identity Task 4).
const PERSISTENT_SESSION_REAP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
use metrics::SharedMetrics;
use prometheus_client::registry::Registry;
use support_codes::{CodeValidation, CreateCodeRequest, SupportCode, SupportCodeManager};
@@ -245,9 +255,35 @@ async fn main() -> Result<()> {
"Reconciling {} managed session(s) from database",
machines.len()
);
// Machines bound to an active `cak_` key. For these the key→machine
// binding is authoritative (SPEC-004 Task 2), so we must NOT index a
// restored keyed session by its stored `machine_uid`: doing so would
// let an un-keyed agent spoofing that uid reattach the keyed machine's
// offline session after a restart. The connect path never writes a uid
// for keyed agents, so a non-NULL uid on a keyed row can only come from
// a legacy pre-keying row — but close the gap regardless. On query
// failure, fail closed (treat all machines as keyed: index none by uid)
// rather than risk indexing a keyed machine.
let keyed_ids = match db::agent_keys::keyed_machine_ids(db.pool()).await {
Ok(ids) => ids,
Err(e) => {
tracing::warn!(
"Could not load keyed-machine set; suppressing uid reattach index for all restored machines: {}",
e
);
machines.iter().map(|m| m.id).collect()
}
};
for machine in machines {
// Keyed machines get None (uid reattach disabled); un-keyed
// machines keep their stored uid for legitimate reattach.
let restore_uid = if keyed_ids.contains(&machine.id) {
None
} else {
machine.machine_uid.as_deref()
};
sessions
.restore_offline_machine(&machine.agent_id, &machine.hostname)
.restore_offline_machine(&machine.agent_id, &machine.hostname, restore_uid)
.await;
}
}
@@ -257,6 +293,37 @@ async fn main() -> Result<()> {
}
}
// Spawn the stale-session reaper (v2-stable-identity Task 4). Periodically
// removes OFFLINE persistent sessions that have aged out past
// PERSISTENT_SESSION_TTL with no viewer attached, purging both the agent_id and
// machine_uid indexes via SessionManager::remove_session. Spawned AFTER the
// startup restore so restored-offline rows are present (they age out once they
// pass the TTL, per plan). The task holds only a clone of the SessionManager
// (Arc-backed internally), so it shares the live session map.
{
let reaper_sessions = sessions.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(PERSISTENT_SESSION_REAP_INTERVAL);
// Skip the immediate first tick so we do not sweep before the server is
// even serving; the first real sweep happens one interval in.
interval.tick().await;
loop {
interval.tick().await;
let reaped = reaper_sessions
.reap_stale_persistent(PERSISTENT_SESSION_TTL)
.await;
if reaped > 0 {
info!("Stale-session reaper removed {} offline session(s)", reaped);
}
}
});
}
info!(
"Stale-session reaper started (ttl {}s, sweep every {}s)",
PERSISTENT_SESSION_TTL.as_secs(),
PERSISTENT_SESSION_REAP_INTERVAL.as_secs()
);
// Agent API key for persistent agents (optional)
let agent_api_key = std::env::var("AGENT_API_KEY").ok();
if let Some(ref key) = agent_api_key {
@@ -387,7 +454,9 @@ async fn main() -> Result<()> {
// REST API - Sessions
.route("/api/sessions", get(list_sessions))
.route("/api/sessions/:id", get(get_session))
.route("/api/sessions/:id", delete(disconnect_session))
// DELETE: live-only disconnect by default; `?purge=true` soft-deletes +
// removes in-memory + audits (admin-only). Task 5 (api::removal).
.route("/api/sessions/:id", delete(api::removal::remove_session))
// Session-scoped viewer-token minting (dashboard JWT; bound to one session)
.route(
"/api/sessions/:id/viewer-token",
@@ -395,8 +464,20 @@ async fn main() -> Result<()> {
)
// REST API - Machines
.route("/api/machines", get(list_machines))
// Bulk operator removal (admin-only). Registered before the `:agent_id`
// routes; matchit (axum 0.7) prefers the static `bulk-remove` segment over
// the `:agent_id` capture, so it never shadows a real agent_id. Task 5.
.route(
"/api/machines/bulk-remove",
post(api::removal::bulk_remove_machines),
)
.route("/api/machines/:agent_id", get(get_machine))
.route("/api/machines/:agent_id", delete(delete_machine))
// DELETE: legacy hard-delete by default; `?purge=true` soft-deletes +
// removes in-memory + audits (admin-only). Task 5 (api::removal).
.route(
"/api/machines/:agent_id",
delete(api::removal::remove_machine),
)
.route("/api/machines/:agent_id/history", get(get_machine_history))
.route(
"/api/machines/:agent_id/update",
@@ -673,28 +754,6 @@ 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(
@@ -769,89 +828,6 @@ async fn get_machine_history(
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 {

View File

@@ -74,6 +74,13 @@ pub struct AgentParams {
/// API key for persistent (managed) agents
#[serde(default)]
api_key: Option<String>,
/// Deterministic, recomputable hardware identity reported by the agent
/// (v2-stable-identity Task 1; `transport/websocket.rs`). Used to dedup
/// registrations for UN-KEYED agents only — see the security note where it is
/// consumed below. CLIENT-SUPPLIED and therefore spoofable: it is never used to
/// override a `cak_` key's authoritative machine binding.
#[serde(default)]
machine_uid: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -110,6 +117,12 @@ pub async fn agent_ws_handler(
.unwrap_or_else(|| agent_id.clone());
let support_code = params.support_code.clone();
let api_key = params.api_key.clone();
// CLIENT-SUPPLIED machine_uid (v2-stable-identity Task 1). Spoofable; see the
// security gate below. It is used for dedup ONLY on the un-keyed path; for
// `cak_`-keyed agents it is suppressed so the key's machine binding stays
// authoritative. `is_keyed_agent` records which path authenticated us.
let claimed_machine_uid = params.machine_uid.clone();
let mut is_keyed_agent = false;
// Real client IP via the trusted-proxy-aware extractor (shared with the rate
// limiter and audit log). Behind NPM on loopback, `addr.ip()` is 127.0.0.1;
// this resolves the actual remote agent IP from forwarding headers when the
@@ -268,6 +281,11 @@ pub async fn agent_ws_handler(
);
}
agent_id = trusted_agent_id;
// KEYED agent: the key→machine binding is AUTHORITATIVE. Suppress
// the client-claimed machine_uid for dedup (below) so a valid key
// for machine X can never repoint machine Y's row by claiming Y's
// uid. Dedup for this agent stays on its authenticated agent_id.
is_keyed_agent = true;
info!(
"Agent {} from {} authenticated via per-agent key",
agent_id, client_ip
@@ -324,6 +342,20 @@ pub async fn agent_ws_handler(
let support_codes = state.support_codes.clone();
let db = state.db.clone();
// SECURITY GATE for machine_uid dedup (v2-stable-identity Task 2):
// - KEYED (`cak_`) agents: dedup stays on the authenticated agent_id; the
// claimed uid is DROPPED so it cannot override the key's machine binding.
// - UN-KEYED agents (support-code, deprecated shared key, no auth-identity):
// the claimed uid is a dedup-only correctness aid and is passed through.
// A client-asserted machine_uid is spoofable, so it must never be the dedup key
// for a keyed agent. This is the only place the distinction is enforced.
let effective_machine_uid = if is_keyed_agent {
None
} else {
// Treat an empty/whitespace-only claim as absent.
claimed_machine_uid.filter(|u| !u.trim().is_empty())
};
// Bounded relay: cap inbound frame/message size before the socket is upgraded
// so oversized agent frames are rejected by the WS layer, never `to_vec()`'d
// and broadcast. (WS-OOM HIGH.)
@@ -340,6 +372,7 @@ pub async fn agent_ws_handler(
agent_id,
agent_name,
support_code,
effective_machine_uid,
Some(client_ip),
)
}))
@@ -548,6 +581,10 @@ async fn handle_agent_connection(
agent_id: String,
agent_name: String,
support_code: Option<String>,
// Dedup identity for the UN-KEYED path only (already security-gated to `None`
// for `cak_`-keyed agents by `agent_ws_handler`). Drives both the in-memory
// session reattach key and the DB `ON CONFLICT (machine_uid)` upsert.
machine_uid: Option<String>,
client_ip: Option<std::net::IpAddr>,
) {
info!(
@@ -581,14 +618,27 @@ async fn handle_agent_connection(
// 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)
.register_agent(
agent_id.clone(),
agent_name.clone(),
is_persistent,
machine_uid.as_deref(),
)
.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 {
match db::machines::upsert_machine(
db.pool(),
&agent_id,
&agent_name,
is_persistent,
machine_uid.as_deref(),
)
.await
{
Ok(machine) => {
// Create session record
let _ = db::sessions::create_session(

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
# v2 Stable Machine Identity + Session Reaping + Operator Removal — Implementation Plan
> Status: planned 2026-05-30. Parent: [SPEC-004](../../docs/specs/SPEC-004-session-lifecycle-and-removal.md)
> (v2 Phase 1/2). Builds on the v2-secure-session-core per-agent `cak_` keys.
> Unblocks the fleet per-agent-key migration (task #7) → retire shared key (task #5).
## Why now
Live evidence of the problem: `connect_machines` holds **15 persistent rows for 5 real hosts**
(`DESKTOP-I66IM5Q` ×9) — the duplicate-registration bug. Until the fleet is deduped, you
cannot mint one `cak_` key per real machine, so the shared `AGENT_API_KEY` can't be retired.
## The core integration: `machine_uid` vs. per-agent `cak_` key
Worked out against the current code so this composes with the just-built auth, not against it:
- **Today:** `agent_id` = random UUID from `generate_agent_id()` (`agent/src/config.rs:90`), persisted
in the config file. Lost/missing config → a fresh UUID → a new row, because
`connect_machines.agent_id` is `UNIQUE` and `upsert_machine` dedups on `ON CONFLICT (agent_id)`
(`server/src/db/machines.rs:101,111`). Unstable id → duplicates.
- **Per-agent keys already prevent duplicates for KEYED agents:** a `cak_` key binds to a
`connect_machines` row via `connect_agent_keys.machine_id`, and reattach uses the **key's** machine
identity, ignoring a client-supplied `agent_id`. The duplicate problem is the **shared-key /
support-code / config-loss** fleet, which has no stable identity.
- **`machine_uid` = deterministic hardware identity** (Windows `MachineGuid` from
`HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`, hashed; optionally folded with a board serial),
**recomputable** so a lost config self-heals to the *same* id. It is the IDENTITY (who this box is);
the `cak_` key is the CREDENTIAL (proof it's authorized as that box). They compose: **one `cak_` key
per `machine_uid` = one key per real machine** — exactly what task #7 needs.
- **Security stance (unchanged from SPEC-004):** a client-asserted `machine_uid` is spoofable, so it is
**not** a trust boundary on its own. For keyed agents the `cak_` key stays authoritative (server uses
the key's machine, not the claimed uid). For un-keyed agents `machine_uid` is **dedup-only**
(correctness, not trust). Reimage/clone caveats per SPEC-004 (MachineGuid regenerates on sysprep,
clones collide — caught by the key binding).
## Tasks (ordered; each Coding Agent + Code Review, gates green)
### Task 1 — Agent derives + reports `machine_uid`
- New `agent/src/identity.rs`: `machine_uid()` = stable hash of `MachineGuid` (Windows; registry read),
recomputable; non-Windows fallback = a persisted random UUID. Cache in config but **recompute if
absent** (don't depend on the config file for correctness).
- Send `machine_uid` in the connect handshake (`agent/src/transport/websocket.rs:40` query) and on
`AgentStatus` (proto). Keep the legacy random `agent_id` as a migration fallback only.
- Tests: deterministic (same machine inputs → same uid); a wiped config recomputes the same uid.
### Task 2 — Server schema + dedup on `machine_uid`
- Migration `008_machine_uid.sql`: add `connect_machines.machine_uid TEXT` (nullable for legacy) +
a unique index `WHERE machine_uid IS NOT NULL`. Idempotent; startup-applied.
- `upsert_machine` keys on `machine_uid` when present (`ON CONFLICT (machine_uid)`), falling back to
`agent_id` for legacy agents. Session reuse / reattach key on `machine_uid`. The `cak_` key's machine
binding stays authoritative for keyed agents.
- Tests: same `machine_uid` with varying `agent_id` → ONE row; legacy (no uid) path unchanged.
### Task 3 — Reconcile existing duplicate rows
- Prefer **age-out + operator removal over a risky backfill** (per the #7 audit note): once Tasks 4/5
land, the 14 duplicate ghost rows reap/purge naturally. If a deterministic collapse is wanted later,
map duplicates by hostname→`machine_uid` and repoint sessions/keys — but only as a separate, reviewed
migration. v1 of this plan does NOT auto-collapse.
### Task 4 — Session lifecycle reaping (`server/src/session/mod.rs`)
- `reap_stale_persistent(ttl)` on `SessionManager`: periodic sweep (spawned at startup, ~60s) removing
persistent sessions offline (`is_online == false`) past a TTL (default 10 min, via `last_heartbeat_instant`).
- On reconnect, **supersede** prior same-machine (`machine_uid`) sessions so a fresh `agent_id` can't
strand the old one.
- Tests: offline-past-TTL reaped; online / within-TTL spared; same-machine reconnect supersedes; never
reap an online or viewer-attached session.
### Task 5 — Operator removal API + dashboard
- `deleted_at` on `connect_sessions` (+ machines as needed); `DELETE …?purge=true` (in-memory remove +
DB soft-delete) distinct from the live-only disconnect; a bulk endpoint; per-row + multi-select removal
on the dashboard machines/sessions views. Admin-gated, audited to `events`.
- This is also the **immediate fix for the live ghost rows** — once it lands, purge the 14 duplicates.
## Exit criteria
One machine = one record/session; a config-loss or portable run can't duplicate; admins can purge stale
rows individually and in bulk; the fleet is deduped enough to mint one `cak_` key per real machine —
unblocking task #7 (fleet key migration) → task #5 (retire shared `AGENT_API_KEY`).
## Open questions
1. `machine_uid` recipe — `MachineGuid` alone vs. folded with a board/BIOS serial? (Proposed: MachineGuid
primary, hashed.)
2. Should `machine_uid` REPLACE `agent_id` as the primary key, or sit alongside it (legacy fallback)?
(Proposed: alongside, dedup prefers `machine_uid`; agent_id retained for legacy + transition.)
3. Reap TTL default (10 min proposed) and whether managed vs. support sessions differ.