34 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
c98692e424 fix(server): revoke viewer tokens on logout + stop logging chat content
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
Security follow-ups (audit 2026-05-30, both reviewed APPROVE):
- MEDIUM: viewer tokens were never blacklisted on logout, so a minted
  session-scoped viewer token stayed valid up to its 5-min TTL after the user
  logged out. Add a per-user ViewerTokenRegistry (Arc<Mutex<HashMap<sub,
  Vec<(token, expires_at)>>>>, prune-on-insert) on AppState; mint_viewer_token
  registers each token under the user sub; logout drains take_for_user(sub) and
  blacklists each via the existing token_blacklist. The viewer WS already calls
  is_revoked, so no WS change. Key chain user.user_id == ViewerClaims.sub ==
  registry key verified consistent. 8 new tests.
- LOW: relay chat logs now emit content length, not the chat body (support-chat
  can carry secrets/PII).
cargo fmt/clippy(-D warnings)/test green on GURU-5070 (37 agent + 61 server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:20:15 -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
8119292bcd fix(agent): close auto-update TLS bypass (MITM -> RCE) [HIGH]
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m24s
Build and Test / Build Server (Linux) (push) Successful in 10m41s
Build and Test / Security Audit (push) Successful in 4m19s
Build and Test / Build Summary (push) Successful in 9s
The auto-update path built both reqwest clients with an unconditional
danger_accept_invalid_certs(true), so a network MITM could serve an arbitrary
update .exe (checksum is no defense — same unverified channel) and gain RCE on
every managed endpoint. Replace with dev_insecure_tls() = cfg!(debug_assertions)
&& env GURUCONNECT_DEV_INSECURE_TLS: the cfg gate compiles out of release builds,
so a shipped agent ALWAYS verifies certs; dev keeps a self-signed escape hatch.
Loud warn when the insecure path is taken; verify_checksum kept + documented as
transport-integrity (not tamper) defense; TODO + follow-up for embedded-key
update signing (defense-in-depth). Release-invariant unit test added.
cargo fmt/clippy(-D warnings)/test green on GURU-5070 (90 tests). Closes the
2026-05-30 security-audit HIGH (reports/2026-05-30-gc-audit.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:02:23 -07:00
9f44807230 audit: security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED
Some checks failed
Build and Test / Build Agent (Windows) (push) Successful in 7m1s
Build and Test / Build Server (Linux) (push) Successful in 10m17s
Build and Test / Security Audit (push) Has started running
Build and Test / Build Summary (push) Has been cancelled
Independent /gc-audit --pass=security re-derivation of the v2 secure-session-core
rebuild: all three 2026-05-29 relay CRITICALs confirmed closed with no bypass
(any-JWT-joins-session, viewer-WS blacklist, JWT-as-agent-key). Relay plane clean;
consent/code paths fail closed; abuse surface bounded; rate limiting proxy-aware.
Net-new: 1 HIGH (agent auto-update disables TLS cert verification -> MITM-RCE,
agent/src/update.rs:45,111 — outside the relay plane), 1 LOW (chat content logged),
2 INFO. Report: reports/2026-05-30-gc-audit.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:48:48 -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
442eecefc0 fix(server,agent): apply Tasks 3-5 review fixes (non-blocking)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m6s
Build and Test / Build Server (Linux) (push) Successful in 10m39s
Build and Test / Security Audit (push) Successful in 4m14s
Build and Test / Build Summary (push) Successful in 8s
From the secure-session-core Tasks 3-5 code review (APPROVE-WITH-FIXES):
- MEDIUM-2: delete the dead `validate_agent_key` "accept-any-key" placeholder +
  its AuthenticatedAgent/AuthState scaffolding (zero callers; the real agent
  auth is validate_agent_api_key + per-agent cak_ keys). Removes an auth landmine.
- LOW-3: stop interpolating support-code values into 3 relay log lines (bearer
  credentials).
- LOW-1: document the X-Real-IP trust requirement in ip_extract.rs (NPM must set
  it from $remote_addr); behavior unchanged.
- LOW-2: correct the consent/heartbeat comment in agent session loop (the loop
  awaits the dialog; safe because CONSENT_TIMEOUT 60s < HEARTBEAT_TIMEOUT 90s).
cargo fmt/clippy(-D warnings)/test all green on GURU-5070 (89 tests, 0 warnings).
MEDIUM-1 (viewer-token logout revocation) remains a tracked follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:23:03 -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
c736a710a1 docs: record Tasks 3-5 code review (APPROVE-WITH-FIXES) in plan status
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m43s
Build and Test / Build Agent (Windows) (push) Successful in 7m43s
Build and Test / Security Audit (push) Successful in 4m57s
Build and Test / Build Summary (push) Has been skipped
Formal review on GURU-5070: cargo fmt/clippy/test green (89 tests, 0 warnings);
the 3 audit CRITICALs verified closed with no bypass; all security paths fail
closed. Non-blocking follow-ups tracked (viewer-token logout revocation, delete
dead validate_agent_key placeholder, X-Real-IP/log hygiene). Remaining for
Phase-1 exit: Task 8 e2e verification + /gc-audit security re-audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:14:02 -07:00
786d3e47af docs: correct roadmap — v2 Phase 1 already landed, not a future sprint
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m12s
Build and Test / Security Audit (push) Successful in 4m53s
Build and Test / Build Agent (Windows) (push) Successful in 7m14s
Build and Test / Build Summary (push) Has been skipped
Re-baseline against actual git/deploy state: secure-session-core Tasks 1-7 are
committed and DEPLOYED; the 3 audit CRITICALs are closed and live in prod
(verified: deployed checkout abc55ab descends from the CRITICAL#1 fix + Task 7;
guruconnect.service running on :3002). The prior "Sprint 0: bypasses are live"
banner was wrong (stale 2026-05-29 audit narrative) and is removed. Remaining
to exit Phase 1 = secure-session-core Task 8 (e2e verification + security
re-audit) + Code-Review sign-off on Tasks 3-5. Schema note corrected
(connect_agent_keys + tenancy already exist via migration 004).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:36:18 -07:00
03f62d413f docs: annotate roadmap with v2-first direction + phase mapping
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 4m54s
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
Mark SPEC-003..009 as work-items inside the SPEC-002 v2 phases (not standalone
v1 backlog): banner records the v2-reset decision + the Sprint-0 relay-auth
CRITICAL hotfix, a phase-mapping table (004->P1, 008->P0/1, 003/005/006/007->P2,
009->P3), inline [-> v2 Phase N] tags per spec, and a note to bake SPEC-003
inventory cols + SPEC-004 machine_uid + connect_agent_keys into the Phase-0
fresh schema. Sprint planning 2026-05-30 (Mike: v2 reset first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:26:47 -07:00
7ab87384a7 spec: add SPEC-009 feature-rich documented API
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m42s
Build and Test / Build Agent (Windows) (push) Successful in 7m39s
Build and Test / Security Audit (push) Successful in 4m34s
Build and Test / Build Summary (push) Has been skipped
Everything the console does should be callable by API, documented and
discoverable. Adds: OpenAPI 3.x generated from code (utoipa) + Swagger/Redoc at
/api/docs (drift-proof, route<->spec parity test); long-lived revocable scoped
API tokens (connect_api_tokens, hashed like agent keys) distinct from the 24h
dashboard JWT and agent keys; an API-completeness gap audit (folds in SPEC-004/
006/007 endpoints); consistent pagination/filtering + versioning policy. Today
there is zero API doc tooling and no programmatic token. Depends on SPEC-008 for
the documented error envelope; distinct from the ADR-001 integration contract.
Large. Parallel guru-rmm SPEC-019. Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:35:57 -07:00
65eff5cf50 spec: add SPEC-008 valuable error messages
Cross-cutting error-quality initiative: one structured AppError envelope
(stable error_code + message + correlation_id) replacing the current ad-hoc
mix (bare (StatusCode,&str) tuples, per-file ErrorResponse, two JSON envelopes
the dashboard already unions); correlation-id middleware tied to tracing spans
+ response header so a reported id greps the log; contextual error logging with
identifiers + error chain; sweep the 37 server `let _ =` swallows (the pattern
that silently hid migration-005's missing columns); dashboard renders the real
cause + correlation id (drop the hardcoded generic at MachinesPage.tsx:202);
agent logs why/where auth/connection failed (the auth-loop incident gave no
local signal). Phaseable; Large. Parallel RMM request keeps conventions aligned.
Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:30:07 -07:00
008d2bf30b spec: add SPEC-007 managed-agent installer builder
Dashboard "Build Installer" wizard for pre-labeled managed/persistent agents
(Name/Company/Site/Department/Device Type/Tag/Type) with Download / Copy URL /
Send Link, ScreenConnect-style. The embed-config build path already exists
(downloads.rs appends EmbeddedConfig GURUCONFIG blob; AgentDownloadParams takes
company/site/tags/api_key; agent reads it at config.rs:223) - missing is the UI,
department + device_type fields (EmbeddedConfig/AgentStatus/connect_machines),
name strategy, and Copy-URL/Send-Link actions. Labels persist at install time,
feeding SPEC-003/005/006. Embedded key should be revocable per-machine/site
(pairs with SPEC-004). Biggest open question: appending config after Authenticode
signing invalidates the signature. Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:24:56 -07:00
0eb38520ed spec: add SPEC-006 universal machine search
Single search box matching case-insensitive substring across ALL machine
attributes (OS, logged-on user, external/private IP, company, site, tag,
serial, MAC, client version, ...) server-side, ScreenConnect-style. Replaces
the dashboard's hostname/agent_id-only client filter (inadequate at ~900+
machines). pg_trgm GIN index over a concatenated searchable-text expression
(INET cast to text, tags via array_to_string); multi-term AND; optional
field-scoped syntax (os:/user:/ip:). Parameterized + fixed column allowlist
(no injection), admin-guarded, DoS-capped. Depends on SPEC-003 (attrs must be
persisted to be searchable); reuses SPEC-005 enriched payload. Requested by
Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:21:10 -07:00
cdc182f0fb spec: add SPEC-005 machines list view (dual indicators + rich rows)
ScreenConnect "Access"-list parity for the Operator Console machines list:
per-row dual Host/Guest connection indicators (Guest=agent is_online,
Host=viewer_count>0 with viewer names + durations) and rich inline metadata
(company, site, device type, tags, logged-on user + idle, client version in
red when outdated). Live Host/Guest state already exists on SessionInfo
(is_online, viewer_count, viewers); main work is enriching /api/machines with
that + SPEC-003 inventory and redesigning MachinesPage rows. Depends on
SPEC-003 (data), reads cleanest after SPEC-004 (dedup), dovetails SPEC-002
Phase 2. Company-tree nav split out as a P3 follow-up. Requested by Mike
2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:17:48 -07:00
f8bd4d1dab spec: SPEC-004 add stable machine-derived identity as the primary fix
Address duplicate registration at the source, not just via cleanup. Root
cause now grounded: agent_id is a random UUID (config.rs:90 generate_agent_id)
persisted only in the config file, so a portable/misconfigured execution
(the Pavon desktop launcher) regenerates a fresh id each launch, defeating
both the DB upsert (ON CONFLICT agent_id) and session-reuse dedupe. Add a
deterministic machine_uid (Windows MachineGuid-based, recomputable) keyed by
registration; reaping/supersede become defense-in-depth. Security: machine_uid
is identity not authorization and must be bound to the per-machine agent key
to prevent session/record hijack. Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:11:38 -07:00
ee900c6395 spec: add SPEC-004 session lifecycle reaping + operator removal
Stop orphaned managed sessions accumulating in the Operator Console and let
admins remove stale sessions/units individually and in bulk. Root cause
confirmed in code: the Sessions list is the in-memory SessionManager;
register_agent reconnect-reuse keys on a stable agent_id (session/mod.rs:169)
and persistent sessions are never reaped on disconnect (session/mod.rs:519-542),
so an agent reconnecting with a fresh agent_id leaves a new retained ghost
session each time (observed: 15 sessions/0 live, ~10 orphans for one machine
after a GuruConnect-client reconnect storm). Adds TTL sweep + same-machine
supersede, admin-gated audited purge + bulk endpoints, and dashboard
multi-select removal. Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:05:32 -07:00
abf499cb23 spec: add SPEC-003 full machine inventory in connection DB
Persist a complete per-machine device inventory on connect_machines
(OS+locale+install, CPU/RAM, mfr/model/serial, external WAN IP captured
server-side via trusted-proxy client_ip + private LAN IP + MAC, logged-on
user, idle, time zone, uptime, local-admin-present), refreshed each
AgentStatus and surfaced in the dashboard machine detail — ScreenConnect
"Guest Info" parity. Data layer for SPEC-002 Phase 2; closes the GC side
of the agent-IP gap (coord todo 7459428e). Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:48:09 -07:00
69 changed files with 10083 additions and 361 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 {
@@ -555,8 +563,14 @@ impl SessionManager {
access
);
// The MessageBox blocks the calling thread; run it on the blocking pool
// so the agent's async loop is not stalled and heartbeats keep flowing.
// The MessageBox blocks the calling thread, so it runs on the blocking
// pool to avoid stalling the tokio runtime. Note, however, that the main
// session loop `.await`s this method (see the ConsentRequest arm), so
// the loop is SUSPENDED for the user's entire think-time and does NOT
// process or respond to server heartbeats while the dialog is open.
// This is safe because CONSENT_TIMEOUT_SECS (60s, server-side) is within
// the server's 90s HEARTBEAT_TIMEOUT_SECS: the prompt resolves before the
// server would consider the agent dead, so the session is not torn down.
let granted = tokio::task::spawn_blocking(move || prompt_consent(&technician_name, access))
.await
.unwrap_or_else(|e| {

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

@@ -10,6 +10,25 @@ use tracing::{error, info, warn};
use crate::build_info;
/// Whether to disable TLS certificate verification for update traffic.
///
/// Returns `true` ONLY in a debug build (`cfg!(debug_assertions)`) when the
/// `GURUCONNECT_DEV_INSECURE_TLS` environment variable is set. The `cfg!` gate
/// is compiled out of release builds, so a shipped agent ALWAYS verifies certs
/// regardless of environment — a MITM cannot serve a forged update binary over
/// an unverified channel. The env var lets a developer test against a
/// self-signed server without weakening production.
fn dev_insecure_tls() -> bool {
if cfg!(debug_assertions) && std::env::var("GURUCONNECT_DEV_INSECURE_TLS").is_ok() {
warn!(
"TLS certificate verification DISABLED (dev-insecure mode) — DO NOT use in production"
);
true
} else {
false
}
}
/// Version information from the server
#[derive(Debug, Clone, serde::Deserialize)]
pub struct VersionInfo {
@@ -42,7 +61,7 @@ pub async fn check_for_update(server_base_url: &str) -> Result<Option<VersionInf
info!("Checking for updates at {}", url);
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) // For self-signed certs in dev
.danger_accept_invalid_certs(dev_insecure_tls())
.build()?;
let response = client
@@ -108,7 +127,7 @@ pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
info!("Downloading update from {}", version_info.download_url);
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_certs(dev_insecure_tls())
.build()?;
let response = client
@@ -134,6 +153,13 @@ pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
}
/// Verify downloaded file checksum
///
/// NOTE: This is a transport-integrity check (catches truncated/corrupted
/// downloads), NOT a tamper defense. The expected checksum arrives over the
/// same channel as the binary, so an attacker who can serve a forged binary
/// can also serve a matching checksum. Tamper resistance comes from verifying
/// the TLS certificate of the update server (see `dev_insecure_tls`) and, as a
/// future hardening step, an embedded-public-key signature over the artifact.
pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<bool> {
info!("Verifying checksum...");
@@ -160,6 +186,9 @@ pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<boo
/// Perform the actual update installation
/// This renames the current executable and copies the new one in place
pub fn install_update(temp_path: &PathBuf) -> Result<PathBuf> {
// TODO(security): defense-in-depth — verify an embedded-public-key signature
// over the update binary/manifest before install_update; see
// reports/2026-05-30-gc-audit.md
info!("Installing update...");
// Get current executable path
@@ -321,4 +350,31 @@ mod tests {
assert!(!is_newer_version("0.1.0", "0.2.0"));
assert!(is_newer_version("0.2.0-abc123", "0.1.0-def456"));
}
/// In a release build (`debug_assertions` off), `dev_insecure_tls()` MUST
/// return false regardless of the env var — the shipped agent can never
/// accept invalid certs. In a debug build, it returns true only when
/// `GURUCONNECT_DEV_INSECURE_TLS` is set; we cannot assert the env-var path
/// here without mutating process-global state (which would race other
/// tests), so we only assert the invariant that holds in the current
/// build profile.
#[test]
fn test_dev_insecure_tls_release_is_always_false() {
if !cfg!(debug_assertions) {
// Release/test-release profile: must be false no matter the env.
assert!(
!dev_insecure_tls(),
"release build must never disable TLS verification"
);
} else {
// Debug profile: with the env var unset, must still be false.
// (We avoid setting it to prevent cross-test interference.)
if std::env::var("GURUCONNECT_DEV_INSECURE_TLS").is_err() {
assert!(
!dev_insecure_tls(),
"debug build without the env var must verify TLS"
);
}
}
}
}

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

@@ -8,6 +8,35 @@ GuruConnect is a standalone remote-support product (ScreenConnect/Splashtop-clas
stack. It ships independently of GuruRMM and integrates with it via a versioned contract (see
`specs/native-remote-control/` and ADR-001).
> **Active direction: v2 reset — Phase 1 already landed (2026-05-30).** Per
> [SPEC-002](specs/SPEC-002-v2-modernization-architecture.md), GuruConnect is being rebuilt above the
> salvaged Windows-internals cores. **v2 Phase 1 (secure session core) is implemented in-place and
> deployed** — secure-session-core **Tasks 17 are committed** ([plan](specs/v2-secure-session-core/plan.md)),
> and the **3 audit CRITICALs are closed and live in production** (session-scoped viewer tokens + session-claim
> match, blacklist-on-WS, agent-plane rejects user JWTs via per-agent `cak_` keys). The feature specs below
> (SPEC-003009) are **work-items inside the later v2 phases** — see the mapping.
>
> **Remaining to formally exit Phase 1:** secure-session-core **Task 8** (end-to-end verification +
> `/gc-audit --pass=security` re-audit + the manual CRITICAL checks) and Code-Review sign-off on Tasks 35
> (implemented without a local toolchain at the time; since built + deployed). Live HW-H.264 validation is
> also pending — raw+Zstd remains the shipping default. ~~Sprint 0 (relay-auth CRITICAL hotfix)~~ **not
> needed — those fixes shipped in Tasks 23.**
### v2 phase mapping of current specs
| Spec | v2 Phase | Note |
|------|----------|------|
| **SPEC-004** (identity / per-agent keys / session reaping / removal) | **Phase 1 — secure session core** | per-agent keys + session lifecycle are Phase 1's heart |
| **SPEC-008** (structured errors + correlation IDs) | **Phase 0/1 conventions** | adopted as the cross-cutting error standard |
| **SPEC-003** (machine inventory) | **Phase 2 — dashboard/data** | bake columns into the Phase-0 fresh schema |
| **SPEC-005** (list view) · **SPEC-006** (search) · **SPEC-007** (installer) | **Phase 2 — dashboard** | built on the v2 dashboard + Phase-1 keys |
| **SPEC-009** (documented API + tokens) | **Phase 3 — integration contract** | alongside `/api/integration/v1/` |
> Schema note: the v2 tenancy-ready schema + `connect_agent_keys` already exist (Task 1 / migration
> `004_v2_secure_session_core.sql`). SPEC-004's per-agent-key identity binding is largely covered by
> Tasks 13; what remains of SPEC-004 (deterministic `machine_uid`, TTL session reaping, operator bulk
> removal) and SPEC-003's inventory columns are the additive Phase-2 migrations to fold onto that base.
---
## Operational Tooling & Release Engineering
@@ -32,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)
@@ -47,6 +79,15 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
- [x] JWT auth, Argon2id passwords, rate limiting, security headers
- [x] Sessions / machines / support-codes / events
- [ ] **Full machine inventory in the connection DB** — P2 — persist per-machine device inventory (OS+locale+install, CPU/RAM, mfr/model/serial, external WAN IP captured server-side + private LAN IP + MAC, logged-on user, idle, time zone, uptime, local-admin) on `connect_machines`, refreshed each `AgentStatus`, shown in the dashboard machine detail (ScreenConnect "Guest Info" parity). Data layer for SPEC-002 Phase 2; closes GC side of agent-IP gap (todo 7459428e). **[→ v2 Phase 2]** ([SPEC-003](specs/SPEC-003-machine-inventory.md))
- [ ] **Stable machine identity + session lifecycle reaping + operator removal** — P1 — give the agent a deterministic machine-derived `machine_uid` (Windows `MachineGuid`-based) so the same box can't register duplicates (root cause: `agent_id` is a config-file random UUID that a portable/misconfigured run regenerates each launch); key registration on it; add TTL reaping + same-machine supersede as defense-in-depth; and admin-gated per-row + multi-select bulk removal of stale sessions/units. Identity must be bound to the per-machine agent key (spoof guard). Fixes ghost-session accumulation seen on the live console (15 sessions / 0 live, ~10 orphans for one machine). **[→ v2 Phase 1]** ([SPEC-004](specs/SPEC-004-session-lifecycle-and-removal.md))
- [ ] **Machines list view — dual connection indicators + rich rows** — P2 — ScreenConnect "Access"-list parity: per-row Host/Guest two-segment connection bar (Guest=agent online, Host=viewer connected, with names + durations) and rich inline metadata (company, site, device type, tags, logged-on user + idle, client version in red when outdated). Server-enriches `/api/machines` with live session state + SPEC-003 inventory. **[→ v2 Phase 2]** ([SPEC-005](specs/SPEC-005-machines-list-view-parity.md))
- [ ] Machines "by Company" tree nav with per-company counts — P3 — left-nav grouping sidebar (screenshot parity). Follow-up sub-item of SPEC-005.
- [ ] **Universal machine search ("everything is searchable")** — P2 — server-side `?q=` on `/api/machines` matching case-insensitive substring across ALL attributes (OS, logged-on user, external/private IP, company, site, tag, serial, MAC, version, …), pg_trgm GIN-indexed; multi-term AND + optional field-scoped syntax (`os:`, `user:`, `ip:`). Replaces the hostname-only client filter. Depends on SPEC-003 (attrs must be persisted). **[→ v2 Phase 2]** ([SPEC-006](specs/SPEC-006-universal-machine-search.md))
- [ ] **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
@@ -56,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,241 @@
# SPEC-003: Full Machine Inventory in the Connection Database
**Status:** Proposed
**Priority:** P2
**Requested By:** Mike (2026-05-30)
**Estimated Effort:** Large
## Overview
Persist a complete per-machine device inventory on `connect_machines` — refreshed
from the agent on each status cycle and stamped server-side with the real external
IP — and surface it in the dashboard machine-detail view, bringing GuruConnect to
parity with ScreenConnect's "Guest Info" panel. Today a machine record holds only
`hostname`, `os_version` (literally `std::env::consts::OS``"windows"`),
`is_elevated`, `organization`, `site`, `tags`, and timestamps; a technician cannot
answer "what is this box, who's on it, what's its WAN/LAN IP, what's its MAC" from
GuruConnect at all. Success = every connected agent shows hostname, company, site,
logged-on user, idle time, OS (full string + locale + install date), CPU, memory,
manufacturer/model, serial, **external WAN IP**, **private LAN IP**, **MAC**, client
version, time zone, uptime, and local-admin-present in the machine detail, kept
current as the agent reconnects.
Reference example (ScreenConnect Guest Info — `DESKTOP-VRBQ6LM`, Pavon / Curves):
Network Address `174.78.94.186`, Private Network Address `192.168.1.128`, MAC
`04:42:1A:0C:8C:A6`, Client Version `26.1.24.9579`, TZ `(UTC-07:00) Arizona`, Uptime
`33d 4h`, Local Admin Present `Yes`, Logged On User `DESKTOP-VRBQ6LM\GeoVision`, OS
`Microsoft Windows 11 Pro (10.0.26200) en-US`, OS install `3/15/2025`, CPU `i7-11700
(16 virtual) X64`, Mem `5260 / 16140 MB`, Mfr/Model `ASUS System Product Name`.
> This is the data-layer companion to **SPEC-002 Phase 2**, which builds the
> "machines inventory/history/delete" dashboard surface (SPEC-002 §"Phase 2"). SPEC-003
> defines *what data exists and how it is collected*; SPEC-002 renders it. It also
> closes the GuruConnect side of the GuruRMM agent-IP gap (coord todo `7459428e`):
> GuruRMM has **no** IP fields at all, whereas GC already computes the client IP at
> connect time (`utils::ip_extract::client_ip`) and merely fails to store it.
## Scope
### Included in v1
- New nullable inventory columns on `connect_machines` (migration 008).
- A `DeviceInventory` protobuf message reported by the agent (Windows collection via
WMI/Win32 APIs), carried on `AgentStatus` and written through to the machine row.
- **Server-side** capture of the external IP from the already-extracted
trusted-proxy `client_ip` (never trust an agent-reported WAN IP).
- Extend `MachineInfo` API DTO + `GET /api/machines` / `GET /api/machines/:agent_id`
to return the inventory.
- Dashboard machine-detail rendering of the inventory (the Guest-Info-style panel).
- Derived/display-only fields composed from existing tables: Hosts/Guests Connected
and "Guest Last Connected" from `connect_sessions` history.
### Explicitly out of scope
- macOS / Linux inventory collection — the agent is Windows-only today (cross-platform
agents are roadmap "Future Considerations" P3). The collector is structured so
non-Windows builds report `None`/empty without breaking the wire or schema.
- Editable/operator-authored fields (Department, Device Type, custom Attributes as
free-text the tech sets). v1 stores `department`/`device_type` as **agent-reported
or null**; operator-editable metadata is a follow-up (needs a dashboard write path
+ audit).
- Live polling/push of inventory between status cycles, scheduled re-inventory jobs,
and historical inventory diffing — v1 is "latest snapshot, refreshed on agent status".
- Pending Activity / screenshot thumbnail (separate concerns; screenshot is SPEC-002
viewer work).
## Architecture
### Agent (`agent/src/`)
- New module `agent/src/inventory/mod.rs``collect() -> DeviceInventory`. Windows
implementation gathers:
- **OS**: `Win32_OperatingSystem` (Caption, Version, OSLanguage→locale, InstallDate,
FreePhysicalMemory, TotalVisibleMemorySize).
- **System**: `Win32_ComputerSystem` (Manufacturer, Model, Domain/Workgroup,
UserName→logged-on user, TotalPhysicalMemory).
- **CPU**: `Win32_Processor` (Name, NumberOfLogicalProcessors, Architecture).
- **Serial**: `Win32_BIOS.SerialNumber` (fallback `Win32_SystemEnclosure`).
- **Network**: `GetAdaptersAddresses` (iphlpapi) for primary LAN IPv4 + MAC of the
active adapter (skip loopback/virtual).
- **Idle time**: `GetLastInputInfo` (must run in the interactive session — see Open
Questions; SYSTEM service sees its own idle, so query via the same session path
the agent already uses for capture, or report `None` when unavailable).
- **Local admin present**: enumerate the local `Administrators` group for any
enabled non-builtin member (`NetLocalGroupGetMembers`).
- **Time zone**: `GetDynamicTimeZoneInformation`.
- Uptime already available (`AgentStatus.uptime_secs`).
- Win7 SP1+ safe: all of the above are available on Win7/Server 2008 R2. Prefer the
`wmi` crate (pure-Rust over COM, no runtime redistributable — satisfies the
"statically linked, no .NET/VC++" constraint) or direct `windows`-crate calls.
- Collection cadence: inventory is near-static, so collect once at session start and
re-collect every ~15 min (or on a `display_count`/IP change), **not** every
heartbeat. Carry it on `AgentStatus.inventory` only when present; the lightweight
status tick stays cheap.
- Replace the bogus `os_version: std::env::consts::OS` with the real OS caption (keep
`os_version` for back-compat; populate the richer `os_name` from inventory).
### Protobuf (`proto/guruconnect.proto`)
- Add a `DeviceInventory` message and attach it to `AgentStatus` at the next free
field number **12** (fields 111 are taken; `supports_h264 = 11`):
```proto
message DeviceInventory {
string os_name = 1; // "Microsoft Windows 11 Pro (10.0.26200)"
string os_locale = 2; // "en-US"
int64 os_install_unix = 3; // OS install time, unix secs
string machine_domain = 4; // "WORKGROUP" or AD domain
string logged_on_user = 5; // "DESKTOP-VRBQ6LM\\GeoVision"
int64 idle_secs = 6; // -1 = unknown
string cpu_model = 7;
int32 cpu_logical = 8;
string cpu_arch = 9; // "X64"
int64 mem_total_mb = 10;
int64 mem_available_mb = 11;
string manufacturer = 12;
string model = 13;
string serial_number = 14;
string machine_description = 15;
string private_ip = 16; // LAN IPv4 of the active adapter
string mac_address = 17;
string time_zone = 18; // "(UTC-07:00) Arizona"
bool local_admin_present = 19;
string department = 20; // agent-config-reported (optional)
string device_type = 21; // agent-reported (optional)
}
message AgentStatus {
// ... existing fields 1-11 ...
DeviceInventory inventory = 12; // present periodically, not every tick
}
```
- External IP is **not** an agent-reported field (deliberately) — see Security.
### DB schema (`server/migrations/008_machine_inventory.sql`)
- `ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS ...` — all nullable, no
`NOT NULL` (consistent with the table's existing NULL-tolerant reality and the
manual `FromRow`):
`os_name TEXT, os_locale TEXT, os_install_at TIMESTAMPTZ, machine_domain TEXT,
logged_on_user TEXT, idle_secs BIGINT, cpu_model TEXT, cpu_logical INT,
cpu_arch TEXT, mem_total_mb BIGINT, mem_available_mb BIGINT, manufacturer TEXT,
model TEXT, serial_number TEXT, machine_description TEXT, external_ip INET,
private_ip INET, mac_address TEXT, time_zone TEXT, uptime_secs BIGINT,
local_admin_present BOOLEAN, department TEXT, device_type TEXT,
inventory_updated_at TIMESTAMPTZ`.
- Idempotent `ADD COLUMN IF NOT EXISTS`, applied by `sqlx::migrate!()` on startup —
**never** pre-applied via psql (see `.claude/standards/gururmm/sqlx-migrations.md`,
and the 005→007 lesson: do not rely on `IF NOT EXISTS` to add constraints later).
- Client Version reuses the existing version column written by
`db::releases::update_machine_version` — do not duplicate.
### Server (`server/src/`)
- `db/machines.rs`:
- Extend the `Machine` struct + manual `FromRow` (machines.rs:30107) with the new
columns, each `Option<T>` read NULL-tolerantly (same pattern as `organization`).
- New `update_machine_inventory(pool, agent_id, &DeviceInventory)` (sibling of
`update_machine_metadata`, machines.rs:213) — `COALESCE` each field so a partial
snapshot never nulls a previously-known value; set `inventory_updated_at = NOW()`.
- New `update_machine_external_ip(pool, agent_id, IpAddr)`.
- `relay/mod.rs`:
- At connect/upsert (mod.rs:591, where `client_ip` is in scope) call
`update_machine_external_ip(agent_id, client_ip)` so the WAN IP is stamped from the
trusted-proxy-derived address.
- In the `AgentStatus` handler (mod.rs:779835), when `status.inventory` is present,
call `update_machine_inventory(...)` alongside the existing
`update_machine_metadata(...)` write.
- `api/mod.rs`: extend `MachineInfo` (mod.rs:117) + `From<Machine>` (mod.rs:130) with
the inventory fields (ISO-8601 for timestamps, IPs as strings). `GET /api/machines`,
`GET /api/machines/:agent_id` (main.rs:385386) return them unchanged in shape
beyond the added fields. Compose Hosts/Guests-connected + "Guest Last Connected"
from `db::sessions` for the detail endpoint.
### Dashboard (`dashboard/src/`)
- Extend the `Machine` type (`dashboard/src/api/types.ts`) and machine-detail view
with a Guest-Info-style inventory panel (Session / Device / Network groupings).
Coordinate with SPEC-002 Phase 2 so this is one panel, not two.
## Implementation details
- Files to touch: `proto/guruconnect.proto` (~line 303, `AgentStatus`);
`agent/src/inventory/mod.rs` (new) + wire into `agent/src/session/mod.rs:236`
(`send_status`); `server/migrations/008_machine_inventory.sql` (new);
`server/src/db/machines.rs:30,101,213`; `server/src/relay/mod.rs:591,824`;
`server/src/api/mod.rs:117,130`; `dashboard/src/api/types.ts`,
`dashboard/src/api/machines.ts`, machine-detail component.
- Key structs/messages: `DeviceInventory` (proto), `Machine` (db), `MachineInfo` (api).
- `idle_secs = -1`, empty strings, and absent adapters all map to `NULL`/unknown in
the DB — display as "—", never as `0`/`false` masquerading as real data.
## Security considerations
- **External IP is server-authoritative.** Capture it only from
`utils::ip_extract::client_ip` (trusted-proxy-aware; ignores client-supplied
`X-Forwarded-For`/`X-Real-IP` from untrusted peers — see the module doc). An
agent-reported WAN IP would be spoofable and is intentionally not modeled.
- Auth unchanged: inventory flows over the already-authenticated agent WS (support
code or per-machine key) and is served only on JWT-authenticated machine endpoints.
- `serial_number`, `logged_on_user`, `machine_domain`, and local-admin presence are
mildly sensitive; they are admin-dashboard-only (no new unauthenticated surface).
- Input validation: treat all agent-reported strings as untrusted — length-cap each
field server-side before persisting (defense against a compromised/rogue agent
bloating the row); `bytea`/oversized values rejected.
- Audit: no new audit events required, but an inventory write is `debug!`-logged with
agent_id (no PII beyond what the admin already sees).
## Testing strategy
- **Unit:** `inventory::collect()` on Windows returns populated fields on a real box;
non-Windows returns all-`None`. `update_machine_inventory` COALESCE semantics
(partial snapshot preserves prior values). `MachineInfo` serialization round-trip.
- **Integration:** agent → relay → DB: connect an agent, assert `connect_machines`
gains `external_ip` (matching the test's `client_ip`) at connect and the full
inventory after the first `AgentStatus.inventory`; `GET /api/machines/:id` returns
it. Verify a status tick *without* inventory does not null existing values.
- **Manual:** enroll the Pavon/Raiders or a lab box, confirm the dashboard detail
matches the real machine (cross-check WAN IP against the box's actual egress IP,
LAN IP/MAC against `ipconfig /all`).
## Effort estimate & dependencies
- **Size: Large.** The bulk is the Windows inventory collector (WMI/Win32, Win7-safe)
and the dashboard panel; the proto/DB/relay/API wiring is mechanical and follows
the existing `organization`/`site`/`tags` precedent end-to-end.
- **Depends on:** nothing blocking. Reuses existing `client_ip` extraction and the
`AgentStatus` pipeline.
- **Unblocks / aligns with:** SPEC-002 Phase 2 dashboard "machines inventory" surface;
resolves the GC side of coord todo `7459428e` (agent IP tracking).
## Open questions
1. **Idle time as a SYSTEM service.** `GetLastInputInfo` returns input idle for the
*calling* session; a SYSTEM-context agent may not see the interactive user's idle.
Does the agent already have an interactive-session path (it injects input/captures
the active console) we can reuse, or do we report `idle_secs = -1` when running
headless? Resolve during planning.
2. **Multiple NICs.** Store only the primary active adapter's IP/MAC (v1), or all
adapters (`TEXT[]`)? ScreenConnect shows one; v1 proposes one. Revisit if multi-homed
boxes matter.
3. **Operator-editable Department / Device Type / Attributes.** v1 treats these as
agent-reported/null. Confirm whether the dashboard should also let a tech set them
(adds a write endpoint + audit) now or as a follow-up spec.
4. **Re-inventory cadence.** 15 min fixed vs. change-triggered vs. configurable —
pick a default; 15 min proposed.

View File

@@ -0,0 +1,231 @@
# SPEC-004: Stable Machine Identity, Session Lifecycle Reaping, and Operator Removal
**Status:** Proposed
**Priority:** P1
**Requested By:** Mike (2026-05-30)
**Estimated Effort:** Medium
## Overview
Stop orphaned managed sessions from accumulating in the Operator Console, and give
operators a first-class way to remove stale sessions/units — per-row **and** in bulk
(multi-select mass delete). Today the Sessions view can show many dead rows that look
live, and the only per-row action is "End", which applies to a *live* session and does
nothing for an already-dead one — so junk just piles up with no way to clear it.
The durable fix is at registration: the **same machine must resolve to one stable
identity** so a repeated execution cannot mint duplicates in the first place; reaping
and manual removal then become defense-in-depth and cleanup, not the primary mechanism.
Success = (a) the same machine, run repeatedly (even from a portable/misconfigured
copy), registers to **one** record/session — no duplicates; (b) reconnecting/offline
persistent agents no longer leave behind retained ghost sessions; and (c) an admin can
select one or many session rows (and stale machine rows) and remove them from the
console.
**Observed (live console, 2026-05-30):** the Sessions view listed **15 sessions, 0
live**, of which ~10 were duplicate `MANAGED` rows for a single machine
(`DESKTOP-I66IM5Q` / Pavon-Raiders), each a distinct session UUID, all
`NOT REQUIRED` consent, no viewers, no duration, all "37 minutes ago". That machine had
just been cleaned of a misbehaving GuruConnect *client* that was reconnecting in a loop
— that reconnect storm is what produced the orphans, which is exactly why this needs
**both** a lifecycle fix and a manual-removal control.
## Root cause (confirmed in code)
The Sessions list is served from the **in-memory `SessionManager`**, not the database:
`GET /api/sessions``list_sessions` (`main.rs:636`) → `state.sessions.list_sessions()`
(`session/mod.rs:584`). Three compounding defects let ghosts accumulate there:
0. **Machine identity is a config-file random UUID, not machine-derived.** The agent's
`agent_id` is a random UUID minted by `generate_agent_id()` (`agent/src/config.rs:90`)
on first run and persisted **only in the agent config file** (`config.rs:331`), or
taken from `GURUCONNECT_AGENT_ID` (`config.rs:366`). A portable or misconfigured
execution that cannot locate/write that config — e.g. the Pavon desktop launcher
`guruconnect-pavon-raidersreef.exe` run repeatedly from a user Desktop — regenerates
a **fresh** `agent_id` every launch. Because identity is not derived from the machine,
the same physical box presents as N different agents. The DB upsert
(`upsert_machine`, `ON CONFLICT (agent_id)`) and the session-reuse map both key on
this id, so an unstable id defeats *both* dedupe layers at the source.
1. **Reconnect-reuse is keyed on that `agent_id`.** `register_agent`
(`session/mod.rs:169`) reuses an existing session only when
`self.agents.get(&agent_id)` resolves to an `is_online == false` session. With a new
`agent_id` per launch (defect 0) the lookup misses and a **brand-new persistent
session** is created each time. The `agents` map holds only one session per agent_id,
so prior sessions become unreferenced yet remain in the `sessions` map.
2. **Persistent sessions are never reaped.** On disconnect, only *support* sessions are
removed entirely; persistent/managed sessions are deliberately retained
(`session/mod.rs:519542`) and there is **no TTL sweep**. An offline managed session
therefore lives in memory indefinitely, displayed alongside genuinely-live ones.
Net effect: N reconnects with unstable identity → N retained, never-expiring managed
sessions, none of which the UI can clear.
## Scope
### Included in v1
- **Stable, machine-derived identity (the primary fix):**
- The agent computes a deterministic `machine_uid` from durable machine identifiers —
primary source the Windows `MachineGuid`
(`HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`), optionally folded with a stable
hardware id (board/BIOS serial) — hashed to a stable string. It is **recomputable**:
a lost/absent config self-heals to the *same* id rather than minting a new random
one. Persist a cached copy, but never depend on the config file for correctness.
- Registration keys on `machine_uid`: `upsert_machine`s `ON CONFLICT` and the
in-memory `agents`/session-reuse map both use it, so the same box converges to **one**
machine record and **one** managed session no matter how many times it executes.
- Carry `machine_uid` in the agent connect handshake (`transport/websocket.rs:40`
query params) / `AgentStatus`; keep the legacy random `agent_id` only as a
migration fallback.
- **Lifecycle reaping (defense-in-depth):**
- Periodic background sweep that removes persistent sessions whose agent has been
offline (`is_online == false`) longer than a TTL (default 10 min, configurable),
using the existing `last_heartbeat_instant`.
- On agent reconnect, **supersede** prior retained sessions for the same machine
(dedupe by `hostname`/machine identity, not only exact `agent_id`) so a fresh
agent_id cannot strand the old session.
- On socket drop for a persistent agent, mark the session offline *and* eligible for
the sweep (DB `end_session` already fires at `relay/mod.rs:892`; align in-memory
state with it).
- **Manual removal API (admin-gated, audited):**
- `DELETE /api/sessions/:id?purge=true` — remove the in-memory session record
(`SessionManager::remove_session`, `session/mod.rs:548`) **and** soft-delete the DB
row. Distinguish from the existing `disconnect_session` (which ends a *live*
session) — purge works on dead rows.
- `POST /api/sessions/bulk` (or `DELETE` with a body) taking `{ ids: [...] , action:
"purge" | "end" }` for mass delete / bulk-end.
- Same stale-removal for the Machines view: extend the existing
`DELETE /api/machines/:agent_id` (`main.rs:387`) usage with a bulk variant for
stale units.
- **Dashboard UX:**
- Per-row **Remove** action on `SessionsPage.tsx` for non-live rows (alongside the
existing End in `EndSessionDialog.tsx`).
- Multi-select checkboxes + a bulk-action bar (Select all / mass Remove / bulk End)
on the Sessions view; mirror on `MachinesPage.tsx` for stale units.
### Explicitly out of scope
- Surviving a full machine **reimage/clone** with the same identity. `MachineGuid`
regenerates on sysprep/reimage and is duplicated by naive disk clones, so a reimaged
box legitimately becomes a new `machine_uid` (and a clone collision is caught by the
auth binding below). Cross-reimage identity continuity is out of scope for v1.
- Replacing the shared `AGENT_API_KEY` with per-machine agent keys — tracked separately
(roadmap GuruRMM-Integration). SPEC-004 *assumes* that binding for its threat model
(see Security) and degrades safely without it, but does not implement it.
- "Mass edit" beyond bulk-end / bulk-remove (e.g. bulk tagging/renaming) — the request
mentioned it; deferred unless a concrete edit field is identified.
- Hard-deleting DB session history — v1 soft-deletes (`deleted_at`) to preserve the
audit trail (CLAUDE.md DB conventions).
## Architecture
- **Agent (`agent/src/`):** new `identity` module computes `machine_uid` deterministically
(Windows `MachineGuid` primary; non-Windows fallback to a stable persisted UUID).
Replace/augment `generate_agent_id()` (`config.rs:90`) so the effective id is the
machine-derived value, with the config-file value used only as a cache. Send
`machine_uid` in the connect query string (`transport/websocket.rs:40`) and on
`AgentStatus`.
- **Relay-server (`server/src/session/mod.rs`):** key `register_agent` and the
`agents` map on `machine_uid` so the same machine reuses one session; add
`reap_stale_persistent(ttl)` to `SessionManager` + a periodic task (e.g. every 60 s)
from server startup; supersede any prior same-machine sessions on reconnect; add a
`purge`-style removal the API can call for dead rows.
- **DB (`server/src/db/sessions.rs` + migration `008`/`009`):** add
`deleted_at TIMESTAMPTZ` to `connect_sessions`; add `purge_session` (soft-delete) and
a bulk variant; `get_recent_sessions`/list queries filter `deleted_at IS NULL`.
Idempotent `ADD COLUMN IF NOT EXISTS`, applied by `sqlx::migrate!()` on startup —
never pre-applied via psql (see the 005→007 lesson).
- **API (`server/src/main.rs`, `server/src/api/`):** new purge + bulk routes, all behind
the existing `AuthenticatedUser`/admin guard; emit audit `events` rows.
- **Dashboard (`dashboard/src/features/sessions/`, `.../machines/`):** selection state,
bulk-action bar, Remove confirmation reusing the `EndSessionDialog`/
`DeleteMachineDialog` patterns; `dashboard/src/api/sessions.ts` gains
`purgeSession` / `bulkSessions`.
- **Protobuf:** add `machine_uid` to the agent identity carried on `AgentStatus` (the
connect handshake passes it as a query param; mirroring it on `AgentStatus` lets the
server reconcile mid-session). Otherwise server/dashboard only.
## Implementation details
- Files to touch: `agent/src/identity/` (new — `machine_uid` derivation),
`agent/src/config.rs:90,366` (effective id = machine-derived),
`agent/src/transport/websocket.rs:40` (send `machine_uid`);
`server/src/session/mod.rs:169,519,548,584` (key on `machine_uid`; reuse/reap/remove);
`server/src/relay/mod.rs:584,591` (registration path);
`server/src/main.rs:376388,636,661` (routes + handlers); `server/src/db/sessions.rs`
(purge + bulk + `deleted_at` filtering); `server/migrations/` (new migration for
`deleted_at`); `dashboard/src/features/sessions/SessionsPage.tsx`,
`EndSessionDialog.tsx`, `dashboard/src/api/sessions.ts`;
`dashboard/src/features/machines/MachinesPage.tsx`.
- Keep the in-memory list authoritative for "live"; treat purge as: remove in-memory +
soft-delete DB. A reaped/purged session must vanish from `list_sessions()` output.
## Security considerations
- **Identity is not authorization.** A client-asserted `machine_uid` is self-reported
and therefore spoofable — on its own, agent A could claim agent B's `machine_uid` to
bind to (and hijack) B's session and machine record. The `machine_uid` must be
**bound to the agent's authenticated credential**: the server accepts a given
`machine_uid` only from a connection authenticated by that machine's own agent key
(or, for a brand-new machine, first-seen trust-on-first-use that pins the uid↔key
pair). This is why per-machine agent keys (roadmap) are the natural companion; until
they ship, the shared `AGENT_API_KEY` means `machine_uid` is a *correctness* improvement
(dedupe) but not yet a *trust* boundary — call this out so it isn't mistaken for one.
A clone collision (two boxes, same `MachineGuid`) surfaces here as two agents claiming
one uid and is resolved by the key binding, not by the uid alone.
- All purge/bulk endpoints require an authenticated admin (`AuthenticatedUser`, same
guard as `list_sessions`); never expose removal unauthenticated.
- Audit every removal to the `events` table (who, which session/machine, when, count
for bulk) — soft-delete + audit, not silent hard-delete.
- Validate/limit bulk request size (cap N per call) to avoid a single call sweeping the
whole fleet by accident or abuse.
- Reaping must not end a session that is merely briefly offline (TTL guards against
flapping); never reap an `is_online` or viewer-attached session.
## Testing strategy
- **Unit:** `machine_uid` derivation is deterministic — same machine inputs yield the
same uid across runs, and an absent config recomputes the same value (no fresh random
id). `register_agent` for the same `machine_uid` reuses/supersedes the prior session
(no duplicate retained) even when the legacy `agent_id` differs.
`reap_stale_persistent` removes offline-past-TTL persistent sessions and spares
online/within-TTL ones. `purge_session` soft-deletes and filters out of list queries.
- **Integration:** simulate a reconnect storm (M connects, **varying `agent_id` but the
same `machine_uid`**, as the Pavon launcher did) → assert `list_sessions()` converges
to one live session and `connect_machines` holds one row, not M. A spoof attempt
(uid X presented on a connection not authenticated for X) is rejected/not bound. Purge
a dead session via API → gone from list + `deleted_at` set + audit row written. Bulk
purge of K ids removes exactly K.
- **Manual:** on the live console, reproduce against the Pavon machines, confirm the
ghost rows can be multi-selected and removed and do not reappear after the sweep.
## Effort estimate & dependencies
- **Size: Medium.** Reaping + supersede + purge/bulk + dashboard follow existing
patterns; the migration is trivial. The added agent-side `machine_uid` derivation and
threading it through the handshake/registration is the main new surface (bumps this
toward the upper end of Medium).
- **Depends on:** nothing blocking. **Pairs with** per-machine agent keys (roadmap) for
the full trust boundary on `machine_uid` — see Security; SPEC-004 degrades safely
without them.
- **Unblocks:** a trustworthy Sessions/Machines view (dead rows no longer masquerade as
live; one machine = one record/session), and complements SPEC-002 Phase 2's dashboard
hardening of the same surfaces.
## Open questions
1. **Reap TTL default** — 10 min proposed; confirm. Should it differ for managed vs.
support sessions?
2. **`machine_uid` source mix** — `MachineGuid` alone, or folded with board/BIOS serial?
`MachineGuid` is stable and present everywhere but regenerates on sysprep and is
cloneable; adding a hardware serial reduces clone collisions but churns on hardware
swaps. Pick the recipe (proposed: `MachineGuid` primary, hashed).
3. **uid↔key binding model** — trust-on-first-use pinning of `machine_uid` to the agent
key, vs. requiring per-machine keys before honoring a uid. What's the interim policy
while the shared `AGENT_API_KEY` is still in use?
4. **Migration of existing rows** — legacy random-`agent_id` machine/session rows: let
them age out via the reaper + manual purge, or run a one-time reconcile that maps
known hosts to their new `machine_uid`? (Proposed: age-out + purge; no risky backfill.)
5. **Purge vs. keep history** — soft-delete (proposed) keeps `connect_sessions` history
for audit while hiding it from the console; confirm operators don't expect a hard
purge.
6. **Bulk-action cap** — what's a sane max N per bulk call (e.g. 100)?

View File

@@ -0,0 +1,153 @@
# SPEC-005: Machines List View — Dual Connection Indicators + Rich Rows
**Status:** Proposed
**Priority:** P2
**Requested By:** Mike (2026-05-30)
**Estimated Effort:** Medium
## Overview
Bring the Operator Console Machines list to ScreenConnect "Access"-list parity: each row
gains (1) **dual connection indicators** — a two-segment bar showing Host and Guest
connection state at a glance — and (2) **rich inline metadata** (company, site, device
type, tags, logged-on user + idle, client version) instead of today's bare
status-dot / hostname / OS / last-seen table. An operator should be able to scan the
list and instantly see which machines are online, which have a tech actively connected,
who's logged in, and which agents are running stale clients — without opening the detail
drawer. Success = the list conveys the same at-a-glance operational state as the
reference console.
**Reference (screenshot 2026-05-30, "Arizona Computer Guru" company):** `GURU-BEAST-ROG`
shows a green **Host** segment ("Computer Guru - SysAdmin") *and* a green **Guest**
segment ("Guest - 9h 21m"); most rows show only a green "Guest - Xh Ym" (agent online,
no tech connected); `ACG-TECH-01L` shows neither (offline). Rows carry "Company: …",
"Site: …", "Device Type: …", "Tag …", "User: … (Idle …)", and "Client Version: …" (in
red when out of date).
> Terminology: the requester's "dial" was a typo for **dual** — two indicators
> (Host + Guest), not a dial gauge.
## Scope
### Included in v1
- **Dual connection indicators per row:**
- **Guest segment** (monitor icon): green when the endpoint agent is connected
(`SessionInfo.is_online == true`); label "Guest - <duration online>"; grey when
offline.
- **Host segment** (person icon): green when a viewer/operator is actively connected
(`SessionInfo.viewer_count > 0`); label = the viewer name(s)
(`SessionInfo.viewers[].viewer_name`) + duration since join; grey when none.
- Both states come from **live** session state, refreshed on the existing dashboard
poll/refresh.
- **Rich inline row metadata** (from SPEC-003 inventory + existing columns): Company
(`organization`), Site (`site`), Device Type (`device_type`), Tag(s) (`tags`),
Logged-On User + Idle (`logged_on_user`, `idle_secs`), and **Client Version**
(`agent_version`) rendered in a warning/red style when the agent build is **outdated**
(below the latest stable `releases.version`, or below `min_version` → stronger
"mandatory update" emphasis).
- **Server enrichment:** extend the machines list response so each machine carries its
live Host/Guest state and the inventory fields in one payload (avoid N client-side
joins). See Architecture.
### Explicitly out of scope
- The left-nav **"All Machines by Company" tree** with per-company counts (the
screenshot's grouping sidebar) — captured as a follow-up sub-item in the roadmap;
v1 may ship simple client-side grouping/sort by company but not the full tree nav.
- Editable inline fields (rename, set device type/tag from the row) — display only in
v1; editing is a separate concern (and overlaps SPEC-003's operator-editable note).
- The per-row action menu beyond what exists (Join/Control/keys/remove already covered
by other specs).
- Native viewer changes — dashboard only.
## Architecture
- **Data sources today are split:** `GET /api/machines` (`list_machines`,
`main.rs:636`) returns `MachineInfo` from the **DB** (`connect_machines`), while live
online/viewer state lives in the **in-memory `SessionManager`** and is exposed by
`GET /api/sessions` (`SessionInfo`: `is_online`, `viewer_count`, `viewers`,
`agent_name`, `started_at`). The list view needs both.
- **Relay-server (preferred):** enrich the machines list with live state server-side —
join each `connect_machines` row (by `agent_id`/`machine_uid`) to its current
`SessionManager` session, and add to `MachineInfo` (`api/mod.rs:117`): `is_online`,
`viewer_count`, `viewers` (names), `online_since`, `host_since`, plus the SPEC-003
inventory fields (`device_type`, `logged_on_user`, `idle_secs`, `agent_version`).
Compute `client_outdated` / `client_update_mandatory` by comparing `agent_version` to
the latest stable / `min_version` from `db::releases` (`releases.rs`). One request,
fully-rendered rows.
- Alternative (lighter, more chatter): dashboard fetches `/api/machines` +
`/api/sessions` and joins client-side by agent_id. Acceptable for v1 if server
enrichment is deferred, but the join + outdated-version logic then lives in TS.
- **Dashboard:** replace the plain `DataTable` columns in
`dashboard/src/features/machines/MachinesPage.tsx` with a richer row: a
`ConnectionIndicator` component (Host/Guest two-segment bar) + a metadata block.
Reuse `StatusDot`/`machineTone` semantics. New `dashboard/src/api/types.ts` fields
mirror the enriched `MachineInfo`.
- **Protobuf / DB:** none new — relies on SPEC-003's inventory columns and existing
session state. (`online_since`/`host_since` may need a timestamp the SessionManager
isn't tracking yet — see Open Questions.)
## Implementation details
- Files to touch: `server/src/api/mod.rs:117,130` (enrich `MachineInfo` + `From`);
`server/src/main.rs:636` (`list_machines` — join live `SessionManager` state, compute
outdated flag via `db::releases`); `server/src/session/mod.rs` (expose a
by-agent_id/`machine_uid` lookup + `online_since`/`host_since` if added);
`dashboard/src/features/machines/MachinesPage.tsx` (row layout),
new `dashboard/src/features/machines/ConnectionIndicator.tsx`,
`dashboard/src/api/types.ts` / `machines.ts` (new fields), `machines`/`status` CSS.
- Key logic: Guest green = `is_online`; Host green = `viewer_count > 0` (label from
`viewers`); client-version tone = compare to latest stable release (red if below;
stronger if below `min_version`).
## Security considerations
- No new endpoints beyond enriching an already admin-authenticated list
(`AuthenticatedUser` guard on `list_machines`); no new unauthenticated surface.
- `logged_on_user`, viewer names, and inventory are already admin-only data — same
trust level as the existing detail drawer; the list merely surfaces it earlier.
- Treat agent-reported strings (user, version, device type) as untrusted display data —
length-cap/escape in the dashboard (no HTML injection via a rogue agent's reported
fields).
## Testing strategy
- **Unit:** Host/Guest indicator state derives correctly (online+no viewer = Guest only;
online+viewer = both; offline = neither). `client_outdated` flag true when
`agent_version` < latest stable and when < `min_version`.
- **Integration:** a machine with a live viewer returns `viewer_count>0` + viewer name in
the enriched list payload; an offline machine returns `is_online=false`; the list joins
DB machines to live sessions without dropping rows that have no session.
- **Manual:** against the live console, confirm a row with an active Control session shows
the green Host segment with the tech's name, agent-online rows show the green Guest
segment + duration, and an out-of-date agent shows the client version in red.
Cross-check a few rows against the reference screenshot's layout.
## Effort estimate & dependencies
- **Size: Medium.** Most effort is the dashboard row redesign + the
`ConnectionIndicator` component and the server-side machines/sessions join; the data
largely exists.
- **Depends on:** **SPEC-003** (inventory fields: device_type, logged_on_user,
idle_secs, agent_version) for the rich metadata. Live Host/Guest state needs nothing
new. Reads cleanest **after SPEC-004** (so the list isn't padded with duplicate ghost
machines) and dovetails with **SPEC-002 Phase 2**'s dashboard work — coordinate so this
is the machines-list increment of that surface, not a parallel rebuild.
- **Unblocks:** at-a-glance fleet operability; foundation for the company-tree nav
follow-up.
## Open questions
1. **`online_since` / `host_since` timestamps.** The dual-indicator durations ("Guest -
9h 21m", host join time) need an "online since" and "viewer joined at" the
`SessionManager` may not track today (`started_at` is session creation, not last
went-online). Add these timestamps, or approximate from `started_at`/`last_heartbeat`?
2. **Enrich server-side vs. join in the dashboard.** Preferred is one enriched
`/api/machines` payload; confirm we don't want to keep the machines/sessions split and
join in TS (simpler server, chattier client).
3. **"Outdated" definition.** Below latest *stable* release, below `min_version`
(mandatory), or a configurable lag (e.g. > N versions behind)? Proposed: red if below
latest stable, stronger emphasis if below `min_version`.
4. **Company grouping in v1.** Ship simple sort/group-by-company now, or defer all
grouping to the company-tree follow-up?

View File

@@ -0,0 +1,145 @@
# SPEC-006: Universal Machine Search ("Everything Is Searchable")
**Status:** Proposed
**Priority:** P2
**Requested By:** Mike (2026-05-30)
**Estimated Effort:** Medium
## Overview
Give the Operator Console a single search box that matches a query against **any**
attribute of any guest machine and returns all matches — OS, logged-on user, IP, company,
site, tag, serial, MAC, version, anything — exactly like ScreenConnect. Today the
dashboard filters only `hostname`/`agent_id` client-side (`MachinesPage.tsx:46`), which
is useless across a ~900-machine fleet. Success = typing "windows 11" finds every Win11
box, "fred" finds every machine where Fred is/was logged on, and "98.97" finds the
machine at that IP — server-side, fast, case-insensitive substring, across the whole
fleet.
**Examples (user-stated):** search an OS string → all machines with that OS; a username →
all machines with that logged-on user; an IP (external or private) → the matching
machine(s).
## Scope
### Included in v1
- **Server-side multi-attribute search** on `GET /api/machines?q=<text>` (extend
`list_machines`), default = **case-insensitive substring, match-any-field** across:
`hostname`, `agent_id`/`machine_uid`, `organization` (company), `site`, `department`,
`device_type`, `tags`, `logged_on_user`, `os_name`/`os_version` (+locale),
`agent_version` (client version), `manufacturer`, `model`, `serial_number`,
`machine_description`, `external_ip`, `private_ip`, `mac_address`, `status`.
- **Multi-term AND:** space-separated terms all must match (each term may match a
different field) — "windows fred" = Win machine where Fred is logged on.
- **Performance:** a Postgres index that supports substring search across all columns
(see Architecture) so search stays fast at fleet scale.
- **IP/array handling:** `external_ip`/`private_ip` (INET, from SPEC-003) matched as text
for partial-IP queries ("98.97"); `tags` (TEXT[]) matched per-element.
- Wire the dashboard search box to the server query (debounced), replacing the
hostname-only client filter; render results through the SPEC-005 list view.
### Nice-to-have (v1 if cheap, else fast-follow)
- **Field-scoped syntax** like ScreenConnect: `os:windows`, `user:fred`, `ip:98.97`,
`company:"arizona computer"`, `tag:winter`, `version:23.9` — a known-prefix restricts
the match to that column; unprefixed terms stay match-any-field; quotes group a phrase.
### Explicitly out of scope
- Saved searches / smart groups / search-driven "session groups" (ScreenConnect's
`+ Create Session Group`) — separate feature.
- Boolean OR / NOT / regex / fuzzy ranking — v1 is AND-of-substring; relevance ranking
deferred.
- Searching **live-only** state not persisted to the DB (current viewer/host connection)
— exposed as a secondary structured filter (online/offline), not free-text, since it
lives in the in-memory `SessionManager`, not `connect_machines`.
- Full event/session-history search — this spec is machine search.
## Architecture
- **Relay-server / DB (the core):** add search to `db::machines` and `list_machines`
(`main.rs:636`). Query shape:
- Maintain a searchable-text representation of each `connect_machines` row and index it
for substring search. Two viable approaches (decide in planning):
1. **`pg_trgm` GIN index** over a concatenated expression
(`lower(hostname||' '||coalesce(organization,'')||' '||…||' '||host(external_ip)||…)`).
Supports `ILIKE '%term%'` on arbitrary substrings with index acceleration. Simple,
matches ScreenConnect's substring semantics best.
2. **Generated `search_text` column** (maintained on write / via the same path as
`update_machine_metadata`/`update_machine_inventory`) + `pg_trgm` GIN on it.
Slightly more plumbing, cleaner query, easier to weight later.
- `tags TEXT[]` folded into the text via `array_to_string(tags,' ')`; INET via
`host(external_ip)`/`host(private_ip)`.
- Field-scoped terms compile to a targeted `col ILIKE '%v%'` (or `= ANY(tags)` for
`tag:`); unscoped terms to the concatenated-text match; all terms AND-ed.
- **API:** `list_machines` gains `q` (and reuses the SPEC-005 enriched `MachineInfo`
payload so results render fully). Same `AuthenticatedUser` admin guard.
- **Dashboard:** the existing search input drives `listMachines({ q })` (debounced ~250ms)
in `dashboard/src/api/machines.ts`; remove the hostname-only client filter in
`MachinesPage.tsx`. Show result count + "no matches" empty state.
- **Protobuf:** none — server/dashboard/DB only. Searchability of inventory fields is
entirely dependent on SPEC-003 persisting them.
## Implementation details
- Files to touch: `server/migrations/` (new migration: `CREATE EXTENSION IF NOT EXISTS
pg_trgm;` + the GIN index / optional `search_text` column — idempotent, startup-applied
by `sqlx::migrate!()`, never pre-applied via psql); `server/src/db/machines.rs`
(`search_machines(q)` or `get_all_machines` gaining a filter; parameterized, never
string-interpolated); `server/src/main.rs:636` (`list_machines` reads `q`);
`dashboard/src/features/machines/MachinesPage.tsx:46` (drop local filter, call server),
`dashboard/src/api/machines.ts` (q param).
- Key logic: tokenize on whitespace (respecting quotes), classify each token as
scoped/unscoped, build a parameterized `WHERE term1 AND term2 …`. Cap query length and
term count.
## Security considerations
- **SQL injection:** all terms are bound parameters (`$n`), never concatenated into SQL.
Column list for scoped search is a fixed allowlist — a `field:` prefix can only map to a
known column, never arbitrary SQL.
- Admin-authenticated only (`AuthenticatedUser`), same as the existing machines list — no
new unauthenticated surface; search exposes nothing the list doesn't already.
- **DoS guard:** cap query length, number of terms, and result page size; `pg_trgm` keeps
worst-case substring scans index-backed so a broad query can't table-scan 900+ rows
unindexed.
- Treat the query as untrusted text; the response is the same admin-only machine data.
## Testing strategy
- **Unit:** tokenizer (quotes, multi-term, scoped vs. unscoped); query builder emits
parameterized SQL with the right AND structure; scoped `field:` maps only to allowlisted
columns (unknown prefix → treated as literal text, not an error/injection).
- **Integration (seeded DB):** "windows 11" returns all Win11 rows; a username returns
rows with that `logged_on_user`; a partial IP returns rows whose `external_ip`/
`private_ip` contains it; a tag value returns tagged rows; multi-term ANDs correctly;
empty `q` returns the full list unchanged.
- **Performance:** with ~1k seeded rows, a broad substring query uses the GIN index
(EXPLAIN shows index scan, not seq scan) and returns within target latency.
- **Manual:** on the live console, reproduce the user's three examples (OS, username, IP).
## Effort estimate & dependencies
- **Size: Medium.** The query builder + index is the bulk; the API/dashboard wiring is
small. Field-scoped syntax adds a little parsing.
- **Depends on:** **SPEC-003** — the inventory attributes (OS detail, user, IP, MAC,
serial, version, device type) must be **persisted** on `connect_machines` to be
searchable; without it, search covers only the handful of existing columns. Reuses
**SPEC-005**'s enriched payload to render results; benefits from **SPEC-004** dedup so
results aren't padded with ghost duplicates. Migration orders after SPEC-003/004's.
- **Unblocks:** fast fleet triage ("who's on this IP / running this OS / logged in as X"),
and the saved-search / smart-group follow-up.
## Open questions
1. **Index strategy** — concatenated-expression `pg_trgm` GIN vs. a maintained
`search_text` column. Proposed: start with the expression index (less write plumbing);
move to `search_text` if weighting/ranking is wanted later.
2. **Field-scoped syntax in v1 or fast-follow?** Default match-any-field covers the stated
use cases; scoped syntax is polish.
3. **Result cap / pagination** — return all matches, or page (limit/offset)? At ~1k rows a
cap with "N more" may suffice; confirm.
4. **Include live online/host state as a filter** — free-text can't reach in-memory state;
offer `status:online` as a structured filter that the server resolves against the
SessionManager, or keep search DB-only in v1?

View File

@@ -0,0 +1,162 @@
# SPEC-007: Managed-Agent Installer Builder ("Build Installer")
**Status:** Proposed
**Priority:** P2
**Requested By:** Mike (2026-05-30)
**Estimated Effort:** Medium
## Overview
Add a dashboard **"Build Installer"** wizard that produces a pre-labeled
managed/persistent (unattended) agent installer — the ScreenConnect "Build Installer"
flow. The operator picks a naming strategy and fills Company, Site, Department, Device
Type, and Tag, chooses the platform/type, and gets the installer via **Download**, **Copy
URL**, or **Send Link**. Once installed, the agent enrolls and appears in the machines
list already carrying those labels (so SPEC-003 inventory / SPEC-005 rows / SPEC-006
search are populated at install time, not only agent-detected). Success = a tech can
build and hand off a labeled permanent-agent installer entirely from the console, no CLI
or manual query-string crafting.
**Reference (screenshot 2026-05-30):** modal "Build Installer" with fields **Name** (Use
Machine Name / custom), **Company**, **Site**, **Department**, **Device Type**, **Tag**,
**Type** (Windows .exe), and share actions **Send Link · Copy URL · Download**.
## What already exists (and what's missing)
The embed-config build path is **already implemented**: `server/src/api/downloads.rs`
appends an `EmbeddedConfig` (magic marker `GURUCONFIG` + length + JSON) to the base
`static/downloads/guruconnect.exe`; `AgentDownloadParams` already accepts `company`,
`site`, `tags`, `api_key`; the agent reads it back at `agent/src/config.rs:223`
(`read_embedded_config`). The downloads routes are public links (`main.rs:425`).
Missing for the ScreenConnect-parity builder:
- **No dashboard UI** to drive it (endpoints exist, no wizard).
- **Department + Device Type** are not in `EmbeddedConfig` (downloads.rs:21 / config.rs:20),
not in `AgentStatus`, not persisted on `connect_machines`.
- **Name strategy** ("Use Machine Name" vs. custom) isn't modeled.
- **Share actions** beyond Download: **Copy URL** and **Send Link** don't exist.
- **Key selection** — which agent key the installer embeds (shared `AGENT_API_KEY` vs. a
per-machine/site key) isn't surfaced.
## Scope
### Included in v1
- **Dashboard "Build Installer" modal** (triggered from the Machines page, e.g. a
`Build +` action): fields Name (Use Machine Name | custom), Company, Site, Department,
Device Type, Tag(s), Type (platform); validates and calls the build endpoint.
- **Extend `EmbeddedConfig`** (`downloads.rs` + `agent/src/config.rs`) and
`AgentDownloadParams` with `department` and `device_type`; agent maps them through to
`AgentStatus` (proto) so they persist on `connect_machines` (SPEC-003 columns) the same
way `organization`/`site`/`tags` do today.
- **Name strategy:** `Use Machine Name` (agent uses live hostname — current default) or a
custom override embedded in the config (`hostname_override` already exists,
`config.rs:63`).
- **Share actions:** **Download** (exists), **Copy URL** (returns the parameterized
public download URL for clipboard), **Send Link** (email the download URL — see deps).
- **Type/platform selector:** Windows (.exe) in v1, with the dropdown structured to add
macOS/Linux later (those agents are roadmap P3 — show only available types).
- **Key selection:** embed the correct agent key for enrollment — default the
shared/site key; ready to use a per-machine key when that lands (SPEC-004/roadmap).
### Explicitly out of scope
- Attended **support-code** session creation — that's a different flow (support codes
already exist); this builder is for **managed/persistent** agents only.
- macOS/Linux installer artifacts — UI is forward-compatible but only Windows .exe ships.
- MSI/MSP packaging, GPO/Intune deployment bundles — `.exe` only in v1.
- Code-signing changes — the base binary signing is SPEC-001/ADR-002; the builder just
appends config to the already-signed base (note: appending after signing invalidates
the Authenticode signature — see Open Questions).
## Architecture
- **Dashboard (`dashboard/src/features/machines/`):** new `BuildInstallerModal.tsx` +
`dashboard/src/api/installer.ts`; opens from the Machines page. Builds the request,
shows Download/Copy URL/Send Link. Admin-only.
- **Relay-server (`server/src/api/downloads.rs`):** extend `EmbeddedConfig` +
`AgentDownloadParams` with `department`, `device_type`, and a `name`/`hostname_override`
field; the existing append-config build path is reused. Add a small endpoint to return
the **built URL** (for Copy URL) and a **Send Link** action (POST that emails the URL).
- **Agent (`agent/src/config.rs`):** add `department`, `device_type` to `EmbeddedConfig`
and `Config`; thread into `send_status` (`agent/src/session/mod.rs:236`) on
`AgentStatus`.
- **Protobuf (`proto/guruconnect.proto`):** add `department` and `device_type` to
`AgentStatus` (or to SPEC-003's `DeviceInventory`) so the relay can persist them. Pick
one home and keep it consistent with SPEC-003.
- **DB:** `connect_machines.department` / `device_type` columns come from SPEC-003; no new
migration here if SPEC-003 lands first (else add them).
## Implementation details
- Files to touch: `server/src/api/downloads.rs:21,40` (EmbeddedConfig +
AgentDownloadParams: `department`, `device_type`, `name`/override); `server/src/main.rs`
(Copy-URL / Send-Link routes if added; existing download routes reused);
`agent/src/config.rs:20,59` (Config + EmbeddedConfig fields);
`agent/src/session/mod.rs:236` (`AgentStatus` population);
`proto/guruconnect.proto` (AgentStatus/DeviceInventory fields);
`dashboard/src/features/machines/BuildInstallerModal.tsx` (new),
`dashboard/src/api/installer.ts` (new).
- The build URL is the existing public download endpoint with query params
(`company`, `site`, `tags`, `department`, `device_type`, `name`, key) — Copy URL just
returns that string; Download streams the appended binary; Send Link emails it.
## Security considerations
- **Embedded key exposure.** The installer embeds an agent enrollment key in a public,
user-shareable artifact/URL. Prefer a **per-machine or per-site key** over the shared
`AGENT_API_KEY` so a leaked installer can be revoked without re-keying the fleet; at
minimum make the embedded key revocable. This is the strongest reason to pair with
per-machine agent keys (SPEC-004 Security / roadmap).
- **Build endpoint auth.** Building/Copy-URL/Send-Link must be **admin-authenticated**
(`AuthenticatedUser`) even though the resulting *download* link is public; do not let an
unauthenticated caller mint installers with arbitrary embedded keys.
- **Input validation.** Company/Site/Department/Device Type/Tag/Name are embedded as JSON
and later shown in the console and reported on `AgentStatus` — length-cap and sanitize
(no injection into the embedded JSON, no oversized config blob; the agent already
length-checks the embedded blob, `config.rs:230`).
- **Send Link.** Validate the recipient; rate-limit; the email contains a key-bearing URL,
so treat it as a credential delivery (audit who built/sent what).
- **Signature note.** Appending config after Authenticode signing breaks the signature
(Open Questions) — a security/UX consideration for SmartScreen, not just cosmetic.
## Testing strategy
- **Unit:** `EmbeddedConfig` round-trips `department`/`device_type` (write → append →
`read_embedded_config` → equal); URL builder encodes all fields + key correctly.
- **Integration:** build an installer with all fields set → download → embedded config
parses → (simulated) agent registers and `connect_machines` shows the embedded company/
site/department/device_type/tags. Copy URL returns a URL that downloads the same. Build
endpoints reject unauthenticated callers.
- **Manual:** from the console, build a labeled installer, install on a test box, confirm
it appears in Access pre-labeled; verify Copy URL and (if shipped) Send Link.
## Effort estimate & dependencies
- **Size: Medium.** The build/append path exists; bulk of the work is the dashboard wizard,
the `department`/`device_type` plumbing (agent + proto + config), and Send-Link email.
- **Depends on:** **SPEC-003** for the `department`/`device_type` persistence columns
(and it feeds SPEC-003/005/006 by populating those fields at install time). **Pairs
with** per-machine agent keys (SPEC-004/roadmap) for a revocable embedded key. **Send
Link** needs an outbound email/SMTP capability the server may not have yet — gate that
sub-feature on it (Copy URL + Download have no such dep).
- **Unblocks:** self-service managed-agent onboarding from the console; pre-labeled fleet
data feeding inventory, list view, and search.
## Open questions
1. **Authenticode signature vs. appended config.** Appending the config blob after the
base `.exe` is signed invalidates the signature (SmartScreen warnings). Options: sign
*after* config injection per-build (needs signing in the build path — heavier, ties to
ADR-002/SPEC-001), embed config in a signature-preserving way (e.g. a signed wrapper /
resource section), or accept unsigned per-build installers. Decide in planning — this
is the biggest design question.
2. **Embedded key model** — shared `AGENT_API_KEY`, per-site key, or per-machine key
minted at build time? Strongly prefer revocable per-site/per-machine.
3. **Send Link transport** — does the server have/want SMTP? If not, ship Download + Copy
URL in v1 and defer Send Link (or use a `mailto:` prefilled link as a stopgap).
4. **Name strategy storage** — custom name via existing `hostname_override`
(`config.rs:63`) vs. a separate display-name field that doesn't change the reported
hostname. Which does the console treat as authoritative?
5. **Platform dropdown** — hide unavailable platforms, or show disabled "coming soon"
entries for macOS/Linux?

View File

@@ -0,0 +1,152 @@
# SPEC-008: Valuable Error Messages (Structured Errors + No Silent Swallows)
**Status:** Proposed
**Priority:** P2
**Requested By:** Mike (2026-05-30)
**Estimated Effort:** Large (phaseable)
## Overview
Make every error in GuruConnect carry information a human can act on: a single structured
API error envelope with a stable code and a **correlation id** that also appears in the
server logs, consistent contextual logging on the server and agent, an end to silent
error swallowing, and a dashboard that shows the *real* cause (with the id to quote to
support) instead of a hardcoded generic line. Success = given any error a tech sees, they
(or we) can determine what failed and why — and a user-reported "it broke" comes with a
correlation id that pulls the exact server log line.
This is a cross-cutting quality initiative. **A parallel feature request governs GuruRMM**
(both are Rust/Axum); keep the conventions consistent where practical, but this spec
governs GuruConnect only.
## Current state (researched — this is the problem)
- **Inconsistent API error shapes.** Bare `(StatusCode, &str)` tuples
(`server/src/api/mod.rs:101,106`), a per-file `ErrorResponse` struct
(`server/src/api/auth.rs:36`), and **two different JSON envelopes** in the wild —
`{error}` and `{detail, error_code, status_code}` — which the dashboard already has to
union to cope (`dashboard/src/api/client.ts:48-52`). No single error type.
- **No correlation/request id.** Only `TraceLayer::new_for_http()`
(`server/src/main.rs:471`); nothing ties a response a user saw to a log line.
- **37 `let _ =` swallows** in `server/src/` — many on fallible DB ops:
`create_session` (`relay/mod.rs:594`), `end_session` (`:686`), `log_event`
(`:132,191,303,604`), `mark_machine_offline` (`:687`),
`link_session_to_code` (`:640`). This is the exact pattern that hid the **migration-005
missing-columns bug** — `update_machine_metadata` failed silently for an unknown period.
- **Generic dashboard message.** `MachinesPage.tsx:202` shows "The GuruConnect server did
not respond. Check the relay status, then retry." regardless of cause (auth expired? 500?
network? CORS?), even though `ApiError` (`client.ts:12`) already carries status + code.
- **Thin agent errors.** The GuruConnect-client auth-failure-loop incident (2026-05-30)
produced no useful local signal about *why* auth failed or *which* server it was hitting.
## Scope
### Included in v1
- **One structured API error type.** A server `AppError` enum (`thiserror`) with a single
`IntoResponse` producing one envelope:
`{ error_code: string, message: string, detail?: object, correlation_id: string }` +
consistent HTTP status mapping per variant. Replace the ad-hoc tuples and per-file
`ErrorResponse`; converge the two envelopes to one.
- **Correlation-id middleware.** Generate (or accept inbound `x-request-id`) a request id,
put it on the tracing span, echo it in the response header **and** in every `AppError`
body, so a reported id greps directly to the log.
- **Logging convention.** Errors logged via `tracing` include the operation + relevant
identifiers (`agent_id`/`machine_uid`/`session_id`) + the underlying error chain
(`anyhow` context / `thiserror` source) — never a bare "failed". Use `#[instrument]`
span fields so context is attached automatically.
- **Kill silent swallows.** Sweep all `let _ = <fallible>()`: convert to
`if let Err(e) = … { warn!/error!(error = %e, op = "…", id = …) }`. Where fire-and-forget
is genuinely correct (e.g. best-effort socket close on an already-dead connection),
keep it but add a one-line comment stating why. A clippy lint / CI grep guards against
new bare `let _ =` on `Result`.
- **Dashboard surfaces the real error.** Render `ApiError.code` + `message` +
`correlation_id` ("quote this to support: …"); distinguish network-down (fetch threw)
vs auth-expired (401 → re-login) vs server-500. Remove the hardcoded generic at
`MachinesPage.tsx:202`; reuse one error component across pages.
- **Agent error detail.** Connection/auth/capture failures log *what* failed and *why*
the target server URL, the relay's stated reason, the auth mode attempted — so a
misbehaving agent is self-diagnosing (directly addresses the auth-loop incident).
### Explicitly out of scope
- Centralized log aggregation / external error tracking (Sentry-style) — local structured
logs + correlation id only in v1.
- User-facing localized error catalog / i18n.
- Changing protobuf-level agent↔relay error frames beyond adding context to existing
disconnect reasons (a deeper protocol error model is a later item).
- Rewriting business logic — this is error *plumbing/observability*, not behavior change.
## Architecture
- **Relay-server:** new `server/src/error.rs` (`AppError` + `IntoResponse` + status map);
handlers return `Result<T, AppError>`; correlation-id middleware in
`server/src/main.rs` (layer beside `TraceLayer`, :471). `relay/mod.rs` swallows fixed in
place with context logging.
- **Agent:** `anyhow` context added at error boundaries in `agent/src/transport/` and
`agent/src/session/`; connection/auth failures `error!`-logged with server URL + reason.
- **Dashboard:** `dashboard/src/api/client.ts` keeps/extends `ApiError` to carry
`correlation_id`; a shared `ErrorBanner`/`ErrorState` component; pages stop hardcoding
messages.
- **Protobuf/DB:** none required (existing `Disconnect.reason` string can carry richer
text; no schema change).
## Implementation details
- Files to touch: `server/src/error.rs` (new); `server/src/api/*.rs` (adopt `AppError`,
drop `ErrorResponse`/tuple returns — `mod.rs:101,106`, `auth.rs:36`); `server/src/main.rs`
(correlation-id middleware, :471); `server/src/relay/mod.rs` (swallow sweep — :132,191,
303,594,604,640,686,687,695, …); `agent/src/transport/*`, `agent/src/session/mod.rs`
(error context); `dashboard/src/api/client.ts` (correlation id), shared error component,
`dashboard/src/features/machines/MachinesPage.tsx:202` (use it).
- Stable `error_code` values are an enum (e.g. `AUTH_EXPIRED`, `MACHINE_NOT_FOUND`,
`DB_UNAVAILABLE`, `VALIDATION_FAILED`) — documented and reused, not free-text.
## Security considerations
- **Don't leak internals to clients.** The structured body carries a safe `message` + code;
the full error chain / SQL / stack stays in the **server log** keyed by correlation id.
A client gets "DB_UNAVAILABLE (id abc123)", not the connection string.
- Correlation id is a random opaque token (no embedded PII/sequence that leaks volume).
- Auth errors stay deliberately coarse to the client (e.g. don't reveal whether a username
exists), while the *log* records the specific reason — valuable internally, safe
externally.
- The swallow sweep must not start logging secrets (api keys, tokens) that were previously
silently dropped — scrub identifiers, never log key material.
## Testing strategy
- **Unit:** each `AppError` variant maps to the right status + code + envelope;
correlation id present on every error body and matches the response header.
- **Integration:** a handler error returns the structured envelope with a correlation id
that appears in the captured log; an inbound `x-request-id` is honored end-to-end. A
forced DB failure on a former `let _ =` path now emits a `warn!/error!` (assert via log
capture) instead of vanishing.
- **Dashboard:** 401 renders "session expired / re-login"; 500 renders code + correlation
id; fetch-throw renders "can't reach server" — three distinct states, not one generic.
- **CI guard:** lint/grep fails the build on a new bare `let _ =` over a `Result` in
`server/`.
## Effort estimate & dependencies
- **Size: Large but phaseable.** Phase 1 (highest value): `AppError` + correlation-id
middleware + the `let _ =` swallow sweep. Phase 2: dashboard error rendering. Phase 3:
agent error context. Each ships independently.
- **Depends on:** nothing blocking. Synergizes with **SPEC-004** (the agent-auth-loop
diagnosis would have been trivial with agent error detail) and improves every other spec's
debuggability.
- **Unblocks:** faster incident triage across the product; a real correlation-id workflow
for support.
## Open questions
1. **Envelope migration.** Converge to the single new envelope and update the dashboard
union in lockstep, or keep accepting both for a deprecation window? (Dashboard already
unions both — low risk to converge.)
2. **`error_code` taxonomy.** Agree the initial enum of stable codes and their HTTP status
mapping.
3. **Correlation id source.** Generate server-side always, or trust an inbound
`x-request-id` from the dashboard/NPM when present (and from where is it trusted)?
4. **Swallow policy granularity.** Blanket "log all `let _ =` on Result", or an allowlist
for the genuinely best-effort socket sends? Proposed: log all DB/state ops; comment +
keep best-effort socket closes.

View File

@@ -0,0 +1,136 @@
# SPEC-009: Feature-Rich, Fully-Documented Management API
**Status:** Proposed
**Priority:** P2
**Requested By:** Mike (2026-05-30)
**Estimated Effort:** Large
## Overview
Make GuruConnect fully operable by API: nearly anything a tech can do in the console
should be callable programmatically, against a **documented, discoverable, versioned**
REST API with first-class auth for automation. Today the surface is decent but
**undocumented** (no OpenAPI/Swagger), authable only by a 24h dashboard JWT or agent
keys (no automation tokens), and has UI-only gaps. Success = an OpenAPI spec + browsable
docs cover every public endpoint, a scripted client can authenticate with a long-lived
revocable token and perform every management action the dashboard exposes, and new
endpoints are documented by construction.
This is the GuruConnect half of a cross-product request; the GuruRMM side is
**guru-rmm SPEC-019**. Keep auth model, error envelope, pagination, and doc conventions
aligned across both Rust/Axum products.
## Current state (researched)
- **No API documentation tooling** — no `utoipa`/`openapi`/Swagger/Redoc anywhere
(`server/Cargo.toml`, `server/src/`). The contract lives only in `main.rs` route
registrations and handler code.
- **Reasonable but partial surface:** auth, users (CRUD + client access), support codes,
sessions (list/get/disconnect/viewer-token), machines (list/get/delete/history/keys),
releases (CRUD), downloads, version. Several actions are dashboard-only or being added
piecemeal (session purge/bulk → SPEC-004, machine search → SPEC-006, installer build →
SPEC-007).
- **Auth is dashboard/agent-shaped, not automation-shaped:** JWT bearer (24h,
`server/src/auth/mod.rs:101`) for humans + agent/site keys for agents. **No long-lived,
revocable, scoped API token** for external scripts/integrations.
- **No stable error contract** until SPEC-008 lands (two envelopes today).
## Scope
### Included in v1
- **API completeness audit + gap-fill.** Enumerate every console action; ensure each has a
documented public endpoint. Fill gaps (e.g. anything currently done only via in-memory
state or dashboard-special handlers). The in-flight specs (SPEC-004 purge/bulk, SPEC-006
search, SPEC-007 installer build) register under this umbrella.
- **OpenAPI 3.x, generated from code.** Adopt `utoipa` (Axum-native: derive schemas from
handler/DTO annotations) → serve `GET /api/openapi.json` + interactive docs
(Swagger UI or Redoc) at `/api/docs`. Document auth, every endpoint, request/response
schemas, the SPEC-008 error envelope, and examples. Docs generated by construction so
they can't drift.
- **Programmatic API tokens.** Long-lived, **revocable, scoped** user/integration tokens
(PAT-style) distinct from the 24h dashboard JWT and from agent keys: create/list/revoke
in the dashboard, hashed at rest, last-used tracking, optional expiry, scopes
(read-only vs. read-write, resource-scoped). Accepted as `Authorization: Bearer` on all
`/api/*` like the JWT.
- **Consistency conventions:** uniform pagination (`limit`/`offset` or cursor) + filtering
+ sorting on list endpoints; consistent resource naming; documented HTTP status usage
(pairs with SPEC-008's structured errors).
- **Versioning policy** for the public API (header or path), and a documented relationship
to the ADR-001 `/api/integration/v1/` GuruRMM contract (that narrow, semver'd contract
stays authoritative for its surface; this is the broader operator API).
### Explicitly out of scope
- GraphQL or gRPC public API — REST + OpenAPI only.
- SDK/client-library generation — OpenAPI enables it later; not built here.
- Webhooks/event subscriptions — separate feature.
- Re-speccing the integration contract — owned by `specs/native-remote-control/`.
## Architecture
- **Relay-server:** add `utoipa` derives to handler DTOs (`server/src/api/*`), an
`ApiDoc` aggregator, and routes for `openapi.json` + docs UI (`server/src/main.rs`). New
`api_tokens` module + middleware that accepts either a JWT or a valid API token
(`server/src/auth/`). Gap endpoints added to the relevant `api/*` modules.
- **DB:** `connect_api_tokens` table (id, user_id, name, token_hash, scopes, created_at,
last_used_at, expires_at, revoked_at) + migration. Mirrors the existing
`connect_agent_keys`/`machine_keys` hashing pattern.
- **Dashboard:** a "API Tokens" settings page (create → show-once → list/revoke) and a
link to `/api/docs`.
- **Protobuf:** none.
## Implementation details
- Files to touch: `server/Cargo.toml` (`utoipa`, `utoipa-swagger-ui`/`utoipa-redoc`);
`server/src/api/*.rs` (DTO `#[derive(ToSchema)]` + `#[utoipa::path]` annotations);
`server/src/main.rs` (docs routes; token-aware auth layer);
`server/src/auth/` (API-token verification beside JWT, `mod.rs:101`);
`server/src/db/api_tokens.rs` (new) + migration `connect_api_tokens`;
dashboard API-tokens settings page.
- Reuse the agent-key hashing/verification approach for token storage; never store plaintext.
## Security considerations
- **Tokens are credentials:** hash at rest, show plaintext once, support revoke + expiry +
scopes; log creation/revocation to `events`; rate-limit token auth like login.
- **Scope enforcement:** read-only tokens cannot mutate; resource-scoped tokens limited to
their resources; admin-only actions require an admin-scoped token.
- **Docs exposure:** `/api/docs` describes the API but must not leak secrets or be a
CSRF/SSRF vector; gate write-trying "try it out" behind auth; consider auth-gating the
docs UI in production.
- **No internal leakage** in documented error examples (pairs with SPEC-008).
- **Auth uniformity:** the token path goes through the same guards as JWT so no endpoint is
accidentally left token-bypassable.
## Testing strategy
- **Unit:** OpenAPI spec builds and validates; every registered route appears in it (a test
that diffs the router against the spec catches undocumented endpoints). Token verify
(valid/expired/revoked/wrong-scope).
- **Integration:** a scripted client authenticates with an API token and performs a full
CRUD lifecycle on machines/sessions/users/codes; read-only token is denied writes; the
route↔spec parity test fails if a new endpoint lacks annotation.
- **Manual:** open `/api/docs`, exercise representative calls with a created token; confirm
examples match real responses.
## Effort estimate & dependencies
- **Size: Large.** Annotating the full surface for OpenAPI + the API-token subsystem +
the gap audit is broad, though each handler annotation is mechanical.
- **Depends on:** **SPEC-008** for the documented error envelope (document it once, right).
Composes with SPEC-004/006/007 (their new endpoints land documented). Independent of the
ADR-001 integration contract but should cross-reference it.
- **Unblocks:** automation/scripting against GuruConnect, future SDKs, and third-party
integrations; a self-documenting API that stays current.
## Open questions
1. **Docs UI in production** — public, or auth-gated? Proposed: spec JSON public, "try it
out" + UI auth-gated.
2. **Token scopes granularity** — coarse (read/write/admin) for v1 vs. per-resource scopes?
Proposed: coarse first, structure for finer later.
3. **Versioning mechanism** — path (`/api/v1`) vs. header; and do we retrofit existing
`/api/*` as v1 or leave unversioned with v1 as an alias?
4. **utoipa vs. hand-maintained spec** — confirm utoipa (codegen, drift-proof) over a
hand-written YAML.

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

@@ -0,0 +1,120 @@
# GuruConnect Audit Report — 2026-05-30
**Auditor:** Claude (claude-opus-4-8[1m])
**Passes:** Security & Remote-Session Integrity (`--pass=security` only)
**Previous audit:** 2026-05-29 (the audit that defined the v2 secure-session-core work)
**Scope note:** Single-pass security re-audit to formally verify the v2 secure-session-core
rebuild closed the three relay CRITICALs. Independent re-derivation against the code — not an
echo of the 2026-05-30 Tasks 35 code review.
---
## Executive Summary
| Pass | Total | Critical | High | Medium | Low | Info |
|------|-------|----------|------|--------|-----|------|
| Security & Session | 5 | 0 | 1 | 0 | 1 | 3 |
**The three 2026-05-29 audit CRITICALs are CLOSED with no bypass.** The relay/server plane is
clean. The one serious finding is **net-new and outside the relay plane** — the Windows agent
auto-update path disables TLS verification (MITM → RCE).
**Requires action:** [HIGH] Agent auto-update disables TLS cert verification → MITM-to-RCE.
---
## The three audit CRITICALs — verification
### CRITICAL #1 — "any JWT joins any session" → **CLOSED (no bypass)**
- `viewer_ws_handler` (`server/src/relay/mod.rs:463`) calls `validate_viewer_token`, which enforces
signature + expiry + `purpose=="viewer"` (`server/src/auth/jwt.rs:268,284`). A login JWT lacks the
`ViewerClaims` shape and is rejected (test `test_login_token_rejected_as_viewer_token`).
- Session binding: `claim_session_id != requested_session_id → 403` (`relay/mod.rs:494-501`).
- Mint authz: `mint_viewer_token` (`server/src/api/sessions.rs:114-131`) — `is_admin() ||
has_permission("control")` → Control; else `has_permission("view")` → ViewOnly; else 403.
- View-only enforcement: access mode is in the **signed** claim; relay drops
`MouseEvent`/`KeyEvent`/`SpecialKey` when `!access.can_control()` (`relay/mod.rs:1313-1335`).
### CRITICAL #2 — "viewer-WS blacklist bypass" → **CLOSED (no bypass)**, documented residual
- `token_blacklist.is_revoked(&token)` consulted on the viewer WS path before upgrade
(`relay/mod.rs:476`).
- **Residual [TRACKED — v2-secure-session-core plan.md, MEDIUM]:** logout
(`server/src/api/auth_logout.rs:64`) revokes only the login JWT; minted viewer tokens are not
blacklisted, so a leaked viewer token stays valid until natural expiry (5-min TTL,
`jwt.rs:56`). Known follow-up; the enforcement plumbing is correct for any future force-revoke.
### CRITICAL #3 — "JWT accepted as agent key" → **CLOSED (no bypass), fails closed**
- `validate_agent_api_key` (`relay/mod.rs:384`) has no JWT branch — only a per-agent `cak_` key
(`verify_agent_key`, `server/src/auth/agent_keys.rs:71`; SHA-256 vs `connect_agent_keys`,
`revoked_at IS NULL`) or the deprecated shared `AGENT_API_KEY` (WARNING-logged).
- Identity binding fails closed: unresolved machine → 503 (`relay/mod.rs:276-286`); a client-supplied
`agent_id` is overridden by the key's machine identity (`relay/mod.rs:263-270`).
---
## Pass 5: Security & Remote-Session Integrity
### [HIGH] Agent auto-update disables TLS certificate verification → MITM-to-RCE
**File:** `agent/src/update.rs:45` and `:111` (live path: `agent/src/session/mod.rs:461-468,695-702`)
**Detail:** Both the `check_for_update` and `download_update` reqwest clients are built with
`.danger_accept_invalid_certs(true)` ("for self-signed certs in dev"). This is the **live** auto-update
path — the agent session loop calls `check_for_update → perform_update → download_update →
install_update → restart_with_new_version` at runtime. A network MITM (the relay endpoint is a public
hostname) can serve an arbitrary `guruconnect-update.exe`; the agent writes it over its own binary and
executes it — full code execution on every managed endpoint. `verify_checksum` (`update.rs:294`) does
NOT mitigate: the expected SHA-256 (`version_info.checksum_sha256`) arrives in the **same** `/api/version`
JSON over the unverified TLS connection, so the attacker controls both binary and checksum.
**Recommendation:** Remove `danger_accept_invalid_certs(true)` from both clients (gate behind a dev-only
`cfg`/env flag never set in release). Defense-in-depth: sign the update binary/manifest with an embedded
public key and verify the signature before `install_update`, so update trust does not rest solely on TLS.
### [LOW] Chat message content logged at INFO
**File:** `server/src/relay/mod.rs:776` and `:1373`
**Detail:** Full chat text is logged (`Chat from client/technician: {content}`). Support-session chat can
carry sensitive data (spoken passwords, account numbers) — PII/secret-bearing content in server logs.
**Recommendation:** Drop content from the log line (log length or hash, or move to `trace!`).
### [INFO] Bootstrap admin password logged on credentials-file write failure
**File:** `server/src/main.rs:200-201`
**Detail:** Self-documented one-time fallback at first boot if `.admin-credentials` write fails; password
is meant to be changed immediately. Awareness only, not a defect.
### [INFO] Viewer-token mint not scoped to a specific machine/session ACL — **[TRACKED — SPEC-002 Phase 4]**
**File:** `server/src/api/sessions.rs:27,114`
**Detail:** Any `control`-permission user can mint a control token for any live session (no per-machine /
per-client ACL). Intentional for Phase 1; per-tenant narrowing deferred to Phase 4.
### [INFO] Verified-sound areas
Single-use support codes (atomic `consume_for_bind`, `support_codes.rs:240` / `db/support_codes.rs:110`);
consent gate (viewer refused until `allows_viewer()`, `session/mod.rs:393`; deny/timeout tears down before
the relay loop, no pre-consent window); frame caps (agent 4 MiB / viewer 64 KiB, `relay/mod.rs:330,524`);
input throttle (200 ev/s token bucket + non-blocking `try_send`); rate limiting wired + proxy-aware
(`main.rs:317-370`, `utils/ip_extract.rs:147`); `JWT_SECRET` length-checked ≥32 at startup; Argon2id
passwords; no secret/token/code logging; parameterized runtime sqlx (no `format!`-built SQL); `wss://` default.
---
## Verdict
| CRITICAL | Verdict | Enforced at |
|----------|---------|-------------|
| #1 any-JWT-joins-any-session | **CLOSED** | `api/sessions.rs:114` (authz) + `relay/mod.rs:463,494` (token type + session bind) |
| #2 viewer-WS blacklist bypass | **CLOSED** (TTL-bounded residual tracked) | `relay/mod.rs:476` |
| #3 JWT-accepted-as-agent-key | **CLOSED** | `relay/mod.rs:384` (no JWT branch; fail-closed binding `:276`) |
**Net-new findings:** CRITICAL 0 · HIGH 1 · MEDIUM 0 · LOW 1 · INFO 2.
**Overall posture:** The relay/server plane is clean — all three CRITICALs genuinely closed, fail-closed
session/consent/code paths, bounded abuse surface, correct proxy-aware rate limiting, no SQL injection or
credential logging. The one serious net-new issue is on the Windows **agent** (auto-update TLS bypass →
MITM-RCE), independent of the v2 secure-session-core work and in no existing spec — fix before the next
agent release.
---
## Recommended Action Order
1. **[HIGH]** Remove `danger_accept_invalid_certs(true)` from `agent/src/update.rs` (+ sign update binary).
2. **[LOW]** Stop logging chat content (`relay/mod.rs:776,1373`).
3. **[TRACKED/MEDIUM]** Viewer-token logout revocation (existing plan.md follow-up).
*Note: only `--pass=security` was run. The API-surface, Rust-quality, TypeScript, protocol-integrity,
docs-reconciliation, and CI/CD passes were not executed this run.*

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

@@ -60,10 +60,27 @@ pub async fn logout(
// Extract token from headers
let token = extract_token_from_headers(request.headers())?;
// Add token to blacklist
// Add the login JWT to the blacklist.
state.token_blacklist.revoke(&token).await;
info!("User {} logged out (token revoked)", user.username);
// Also revoke any outstanding session-scoped VIEWER tokens this user minted
// (CRITICAL #2). The login-JWT blacklist alone leaves a viewer token minted
// before logout valid for the rest of its 5-minute TTL, keeping a live
// viewer/remote-control plane open after logout. `user.user_id` is the `sub`
// the viewer tokens were registered under (the same claim stamped into them).
// The viewer WS already blacklist-checks the exact token string, so adding
// them here is sufficient — no WS change needed. take_for_user drains and
// clears the registry entry; expired tokens are pruned (not returned).
let viewer_tokens = state.viewer_tokens.take_for_user(&user.user_id);
let revoked_viewer_count = viewer_tokens.len();
for viewer_token in viewer_tokens {
state.token_blacklist.revoke(&viewer_token).await;
}
info!(
"User {} logged out (login token revoked, {} viewer token(s) revoked)",
user.username, revoked_viewer_count
);
Ok(Json(LogoutResponse {
message: "Logged out successfully".to_string(),
@@ -196,3 +213,74 @@ pub async fn cleanup_blacklist(
remaining_count: remaining,
}))
}
#[cfg(test)]
mod tests {
use crate::auth::{JwtConfig, TokenBlacklist, ViewerAccess, ViewerTokenRegistry};
use std::time::Duration;
use uuid::Uuid;
/// End-to-end (component-level) proof of CRITICAL #2: a viewer token minted
/// under a user, then revoked at logout via the registry drain, is rejected
/// by the SAME blacklist check the viewer WS runs (`is_revoked`).
///
/// This exercises the real mint → register → logout-drain → blacklist path
/// without the HTTP/DB plumbing of the full handler. Uses local component
/// instances only (no process-global env), so it is parallel-safe.
#[tokio::test]
async fn logout_revokes_minted_viewer_token() {
let jwt = JwtConfig::new("test-secret-at-least-32-chars-long!!".to_string(), 24);
let registry = ViewerTokenRegistry::new();
let blacklist = TokenBlacklist::new();
let user_sub = Uuid::new_v4().to_string();
let session_id = Uuid::new_v4();
let tenant_id = Uuid::new_v4();
// Mint a viewer token and register it under the user (as mint_viewer_token does).
let viewer_token = jwt
.create_viewer_token(&user_sub, session_id, tenant_id, ViewerAccess::Control)
.unwrap();
registry.register(&user_sub, &viewer_token, Duration::from_secs(300));
// The viewer WS check (is_revoked) passes BEFORE logout — token is live.
assert!(!blacklist.is_revoked(&viewer_token).await);
// Logout drains the user's viewer tokens into the blacklist (handler logic).
for tok in registry.take_for_user(&user_sub) {
blacklist.revoke(&tok).await;
}
// After logout the same WS check now REJECTS the viewer token.
assert!(blacklist.is_revoked(&viewer_token).await);
// The token also remains a structurally valid viewer JWT (not expired),
// proving revocation — not natural expiry — is what blocks it.
assert!(jwt.validate_viewer_token(&viewer_token).is_ok());
}
/// A different user's logout must NOT revoke this user's viewer token.
#[tokio::test]
async fn logout_does_not_revoke_other_users_viewer_token() {
let jwt = JwtConfig::new("test-secret-at-least-32-chars-long!!".to_string(), 24);
let registry = ViewerTokenRegistry::new();
let blacklist = TokenBlacklist::new();
let user_a = Uuid::new_v4().to_string();
let user_b = Uuid::new_v4().to_string();
let session_id = Uuid::new_v4();
let tenant_id = Uuid::new_v4();
let token_b = jwt
.create_viewer_token(&user_b, session_id, tenant_id, ViewerAccess::ViewOnly)
.unwrap();
registry.register(&user_b, &token_b, Duration::from_secs(300));
// user_a logs out — drains only user_a's (empty) token set.
for tok in registry.take_for_user(&user_a) {
blacklist.revoke(&tok).await;
}
// user_b's token is untouched.
assert!(!blacklist.is_revoked(&token_b).await);
}
}

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

@@ -160,6 +160,18 @@ pub async fn mint_viewer_token(
)
})?;
// Register the minted token under the authenticated user's `sub` so logout
// can revoke it (CRITICAL #2): a viewer token minted before logout would
// otherwise stay valid for the rest of its 5-minute TTL. The registry prunes
// expired entries on insert, so it cannot grow unbounded. The token string
// is held transiently in memory (already handled server-side) and never
// logged. `user.user_id` is the `sub` claim stamped into the viewer token.
state.viewer_tokens.register(
&user.user_id,
&token,
std::time::Duration::from_secs(crate::auth::jwt::VIEWER_TOKEN_TTL_SECS as u64),
);
tracing::info!(
"User {} minted a {} viewer token for session {} (agent {})",
user.username,

View File

@@ -7,10 +7,12 @@ pub mod agent_keys;
pub mod jwt;
pub mod password;
pub mod token_blacklist;
pub mod viewer_token_registry;
pub use jwt::{Claims, JwtConfig, ViewerAccess};
pub use password::{generate_random_password, hash_password, verify_password};
pub use token_blacklist::TokenBlacklist;
pub use viewer_token_registry::ViewerTokenRegistry;
use axum::{
extract::FromRequestParts,
@@ -58,30 +60,6 @@ impl From<Claims> for AuthenticatedUser {
}
}
/// Authenticated agent from API key
#[derive(Debug, Clone)]
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
pub struct AuthenticatedAgent {
pub agent_id: String,
pub org_id: String,
}
/// JWT configuration stored in app state
#[derive(Clone)]
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
pub struct AuthState {
pub jwt_config: Arc<JwtConfig>,
}
impl AuthState {
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
pub fn new(jwt_secret: String, expiry_hours: i64) -> Self {
Self {
jwt_config: Arc::new(JwtConfig::new(jwt_secret, expiry_hours)),
}
}
}
/// Extract authenticated user from request
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
@@ -169,14 +147,3 @@ where
}
}
}
/// Validate an agent API key (placeholder for MVP)
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
pub fn validate_agent_key(_api_key: &str) -> Option<AuthenticatedAgent> {
// TODO: Implement actual API key validation against database
// For now, accept any key for agent connections
Some(AuthenticatedAgent {
agent_id: "mvp-agent".to_string(),
org_id: "mvp-org".to_string(),
})
}

View File

@@ -0,0 +1,186 @@
//! Per-user registry of outstanding viewer tokens.
//!
//! Minted viewer tokens ([`super::jwt::ViewerClaims`]) are short-lived
//! (`VIEWER_TOKEN_TTL_SECS`, 5 min) and session-scoped. The login-JWT blacklist
//! revokes the LOGIN token on logout, but a viewer token minted before logout
//! stays valid for the rest of its TTL — letting a just-logged-out user keep a
//! live remote-control plane until natural expiry.
//!
//! This registry closes that gap with the least-invasive mechanism: when a
//! viewer token is minted it is registered here under the minting user's `sub`;
//! on logout, the user's outstanding viewer tokens are taken from the registry
//! and added to the SAME exact-string blacklist the viewer WebSocket already
//! checks (`relay::viewer_ws_handler` -> `TokenBlacklist::is_revoked`). No
//! change to the WS path is required.
//!
//! Design notes:
//! - The registry holds bearer-token strings transiently in memory. The server
//! already handles these tokens; they are never logged or otherwise exposed
//! by this module.
//! - Entries are pruned-on-access (insert and take) by expiry, so the map can
//! never grow unbounded even if a user never logs out — expired viewer tokens
//! self-evict on the next operation that touches the map.
//! - Locking is a poison-tolerant `std::sync::Mutex` (matching the rate-limiter
//! pattern: `lock().unwrap_or_else(|e| e.into_inner())`); a panic while the
//! lock is held must not wedge logout/mint. No `.unwrap()` on lock paths.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// A single registered viewer token: the token string and the instant at which
/// it expires (mint time + TTL).
#[derive(Clone)]
struct Entry {
token: String,
expires_at: Instant,
}
/// Per-user registry of outstanding (un-expired, un-revoked) viewer tokens.
///
/// Cheap to clone (shares the inner map via `Arc`), so it can live in
/// `AppState` and be cloned with it like the other shared state.
#[derive(Clone, Default)]
pub struct ViewerTokenRegistry {
/// Map of user `sub` -> that user's outstanding viewer tokens.
inner: Arc<Mutex<HashMap<String, Vec<Entry>>>>,
}
impl ViewerTokenRegistry {
/// Create an empty registry.
pub fn new() -> Self {
Self::default()
}
/// Register a freshly minted viewer token under its owning user (`sub`),
/// expiring `ttl` from now.
///
/// Prunes expired entries for this user on insert so the per-user vector and
/// the overall map stay bounded. The token string is stored verbatim (it is
/// the exact string the viewer WS blacklist-checks); it is never logged.
pub fn register(&self, sub: &str, token: &str, ttl: Duration) {
let now = Instant::now();
let expires_at = now + ttl;
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let entries = map.entry(sub.to_string()).or_default();
// Prune-on-insert: drop this user's already-expired tokens.
entries.retain(|e| e.expires_at > now);
entries.push(Entry {
token: token.to_string(),
expires_at,
});
}
/// Remove and return all currently-valid viewer tokens for `sub`.
///
/// Expired tokens are pruned (not returned) — they are already invalid at
/// the JWT layer, so there is no value in blacklisting them. The user's
/// entry is cleared from the map regardless (taking everything out). Returns
/// an empty vec if the user has no registered tokens.
pub fn take_for_user(&self, sub: &str) -> Vec<String> {
let now = Instant::now();
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
match map.remove(sub) {
Some(entries) => entries
.into_iter()
.filter(|e| e.expires_at > now)
.map(|e| e.token)
.collect(),
None => Vec::new(),
}
}
/// Number of users currently tracked (test/observability helper). Does not
/// prune; reflects the raw map size.
#[cfg(test)]
fn user_count(&self) -> usize {
let map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
map.len()
}
/// Number of registered (un-pruned) tokens for a user (test helper).
#[cfg(test)]
fn token_count(&self, sub: &str) -> usize {
let map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
map.get(sub).map(|v| v.len()).unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_then_take_returns_and_clears() {
let reg = ViewerTokenRegistry::new();
reg.register("user-a", "tok-1", Duration::from_secs(300));
reg.register("user-a", "tok-2", Duration::from_secs(300));
assert_eq!(reg.token_count("user-a"), 2);
let mut taken = reg.take_for_user("user-a");
taken.sort();
assert_eq!(taken, vec!["tok-1".to_string(), "tok-2".to_string()]);
// Taking clears the user's entry.
assert_eq!(reg.token_count("user-a"), 0);
assert_eq!(reg.user_count(), 0);
assert!(reg.take_for_user("user-a").is_empty());
}
#[test]
fn take_for_unknown_user_is_empty() {
let reg = ViewerTokenRegistry::new();
assert!(reg.take_for_user("nobody").is_empty());
}
#[test]
fn tokens_are_scoped_per_user() {
let reg = ViewerTokenRegistry::new();
reg.register("user-a", "a-tok", Duration::from_secs(300));
reg.register("user-b", "b-tok", Duration::from_secs(300));
assert_eq!(reg.take_for_user("user-a"), vec!["a-tok".to_string()]);
// user-b is untouched.
assert_eq!(reg.take_for_user("user-b"), vec!["b-tok".to_string()]);
}
#[test]
fn expired_tokens_are_not_returned_by_take() {
let reg = ViewerTokenRegistry::new();
// Zero TTL -> already expired by the time take runs.
reg.register("user-a", "expired", Duration::from_secs(0));
reg.register("user-a", "live", Duration::from_secs(300));
let taken = reg.take_for_user("user-a");
assert_eq!(taken, vec!["live".to_string()]);
}
#[test]
fn expired_tokens_are_pruned_on_insert() {
let reg = ViewerTokenRegistry::new();
// Register an already-expired token, then a live one for the same user.
reg.register("user-a", "expired", Duration::from_secs(0));
// First insert leaves 1 entry; the second insert prunes the expired one
// before pushing, so only the live token remains.
reg.register("user-a", "live", Duration::from_secs(300));
assert_eq!(reg.token_count("user-a"), 1);
assert_eq!(reg.take_for_user("user-a"), vec!["live".to_string()]);
}
#[test]
fn multiple_logins_accumulate_then_all_taken() {
// A user with several concurrent logins mints several viewer tokens;
// logout must revoke ALL of them at once.
let reg = ViewerTokenRegistry::new();
for i in 0..5 {
reg.register("user-a", &format!("tok-{i}"), Duration::from_secs(300));
}
assert_eq!(reg.token_count("user-a"), 5);
assert_eq!(reg.take_for_user("user-a").len(), 5);
assert_eq!(reg.user_count(), 0);
}
}

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,
@@ -37,7 +37,10 @@ use tower_http::trace::TraceLayer;
use tracing::{info, Level};
use tracing_subscriber::FmtSubscriber;
use auth::{generate_random_password, hash_password, AuthenticatedUser, JwtConfig, TokenBlacklist};
use auth::{
generate_random_password, hash_password, AuthenticatedUser, JwtConfig, TokenBlacklist,
ViewerTokenRegistry,
};
/// Root of the static asset tree, relative to the server's working directory.
/// Holds the agent `downloads/` tree AND the v2 SPA build under `app/`.
@@ -54,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};
@@ -66,6 +79,13 @@ pub struct AppState {
db: Option<db::Database>,
pub jwt_config: Arc<JwtConfig>,
pub token_blacklist: TokenBlacklist,
/// Per-user registry of outstanding session-scoped viewer tokens. Minting a
/// viewer token registers it here under the minting user's `sub`; logout
/// drains the user's registered viewer tokens into `token_blacklist` so a
/// just-logged-out user cannot keep a live viewer/remote-control plane until
/// the token's natural 5-minute expiry. The viewer WS already blacklist-
/// checks the exact token string, so no WS change is needed.
pub viewer_tokens: ViewerTokenRegistry,
/// Optional API key for persistent agents (env: AGENT_API_KEY)
pub agent_api_key: Option<String>,
/// Prometheus metrics
@@ -235,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;
}
}
@@ -247,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 {
@@ -291,6 +368,7 @@ async fn main() -> Result<()> {
// Create application state
let token_blacklist = TokenBlacklist::new();
let viewer_tokens = ViewerTokenRegistry::new();
let state = AppState {
sessions,
@@ -298,6 +376,7 @@ async fn main() -> Result<()> {
db: database,
jwt_config,
token_blacklist,
viewer_tokens,
agent_api_key,
metrics,
registry,
@@ -375,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",
@@ -383,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",
@@ -443,7 +536,10 @@ async fn main() -> Result<()> {
// fallback below — CLAUDE.md documents this as the public download URL.
// `nest_service` is matched BEFORE `fallback_service`, so these binaries
// are served from disk and never fall through to the SPA index.html.
.nest_service("/downloads", ServeDir::new(format!("{STATIC_DIR}/downloads")))
.nest_service(
"/downloads",
ServeDir::new(format!("{STATIC_DIR}/downloads")),
)
// NOTE: there are intentionally no /login, /dashboard, /users routes.
// The v2 SPA (BrowserRouter) owns those paths and resolves them via the
// fallback_service below; registering server-side handlers for them would
@@ -658,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(
@@ -754,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 {
@@ -909,4 +900,3 @@ async fn trigger_machine_update(
))
}
}

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!(
@@ -561,7 +598,7 @@ async fn handle_agent_connection(
if let Some(ref code) = support_code {
// Check if the code is cancelled or invalid
if support_codes.is_cancelled(code).await {
warn!("Agent tried to connect with cancelled code: {}", code);
warn!("Agent tried to connect with a cancelled support code");
// Send disconnect message to agent
let disconnect_msg = proto::Message {
payload: Some(proto::message::Payload::Disconnect(proto::Disconnect {
@@ -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(
@@ -740,7 +790,7 @@ async fn handle_agent_connection(
interval.tick().await;
if let Some(ref code) = support_code_check {
if support_codes_check.is_cancelled(code).await {
info!("Support code {} was cancelled, disconnecting agent", code);
info!("Support code was cancelled, disconnecting agent");
// Send disconnect message
let disconnect_msg = proto::Message {
payload: Some(proto::message::Payload::Disconnect(proto::Disconnect {
@@ -772,8 +822,11 @@ async fn handle_agent_connection(
let _ = frame_tx.send(data.to_vec());
}
Some(proto::message::Payload::ChatMessage(chat)) => {
// Broadcast chat message to all viewers
info!("Chat from client: {}", chat.content);
// Broadcast chat message to all viewers. Do NOT log
// the chat body: support-session chat can carry
// secrets/PII. Log only the (non-sensitive) length
// so relay activity stays observable.
info!("Chat from client ({} chars)", chat.content.len());
let _ = frame_tx.send(data.to_vec());
}
Some(proto::message::Payload::AgentStatus(status)) => {
@@ -917,7 +970,7 @@ async fn handle_agent_connection(
let _ = db::support_codes::mark_code_completed(db.pool(), code).await;
}
info!("Support code {} marked as completed", code);
info!("Support code marked as completed");
}
}
@@ -1369,8 +1422,10 @@ async fn handle_viewer_connection(
}
Some(proto::message::Payload::ChatMessage(chat)) => {
// Forward chat message to agent (not throttled —
// not an injected-input vector). Bounded send.
info!("Chat from technician: {}", chat.content);
// not an injected-input vector). Bounded send. Do
// NOT log the chat body (secrets/PII); log only the
// (non-sensitive) length so the relay is observable.
info!("Chat from technician ({} chars)", chat.content.len());
let _ = input_tx.try_send(data.to_vec());
}
_ => {}

File diff suppressed because it is too large Load Diff

View File

@@ -154,6 +154,14 @@ pub fn client_ip(peer: &SocketAddr, headers: &HeaderMap, trusted: &TrustedProxie
}
// Trusted peer: prefer the single-value X-Real-IP if the proxy set it.
//
// SECURITY: we take X-Real-IP verbatim here, trusting it as set by the
// reverse proxy. The proxy (NPM) MUST overwrite it from the real TCP peer:
// proxy_set_header X-Real-IP $remote_addr;
// It must NOT pass through a client-supplied X-Real-IP. A trusted peer that
// forwards an attacker-controlled value would let the client spoof the IP
// used for rate-limiting and audit logging. The trusted-proxy gate above
// only authenticates the immediate hop, not the contents of this header.
if let Some(ip) = header_single_ip(headers, X_REAL_IP) {
return ip;
}

View File

@@ -1,5 +1,13 @@
# v2 Secure Session Core — Implementation Plan
> **STATUS 2026-05-30: Tasks 17 IMPLEMENTED + DEPLOYED. Tasks 35 now CODE-REVIEWED — verdict
> APPROVE-WITH-FIXES (no CRITICAL/HIGH).** Compile-verified on GURU-5070: `cargo fmt --check` clean,
> `clippy -D warnings` 0 warnings, `cargo test --workspace` 89 pass. The 3 audit CRITICALs verified
> closed with no bypass; all security paths fail closed. Non-blocking follow-ups tracked: viewer-token
> logout revocation (MEDIUM, TTL-bounded), delete the dead `validate_agent_key` "accept-any" placeholder
> (MEDIUM), `X-Real-IP`/consent-comment/support-code-log hygiene (LOW). **Remaining for Phase-1 exit:
> Task 8 (e2e verification + `/gc-audit --pass=security` re-audit).**
>
> Spec created: 2026-05-29
> Status: in progress — Tasks 1-4 IMPLEMENTED 2026-05-29 (Task 4 self-reviewed, pending Code Review;
> Tasks 1-3 code-reviewed APPROVED). Task 4 completes the KEYSTONE (secure auth/session core). Viewer-token authz

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.