14 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
51 changed files with 6531 additions and 309 deletions

View File

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

1
Cargo.lock generated
View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,9 +27,8 @@ use tracing::trace;
use windows::{
Win32::Foundation::{LPARAM, LRESULT, WPARAM},
Win32::UI::WindowsAndMessaging::{
CallNextHookEx, DispatchMessageW, PeekMessageW, SetWindowsHookExW, TranslateMessage,
UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, LLKHF_EXTENDED, MSG, PM_REMOVE,
WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT,
LLKHF_EXTENDED, WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
},
};
@@ -237,18 +236,6 @@ fn current_modifiers() -> proto::Modifiers {
}
}
/// Pump Windows message queue (required for hooks to work).
#[cfg(windows)]
pub fn pump_messages() {
unsafe {
let mut msg = MSG::default();
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
// Non-Windows stubs
#[cfg(not(windows))]
#[allow(dead_code)]
@@ -262,10 +249,6 @@ impl KeyboardHook {
}
}
#[cfg(not(windows))]
#[allow(dead_code)]
pub fn pump_messages() {}
#[cfg(test)]
mod tests {
use super::*;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,6 +62,8 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
- [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)
@@ -85,6 +87,7 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
- [ ] **Managed-agent installer builder ("Build Installer")** — P2 — dashboard wizard to build a pre-labeled persistent-agent installer (Name/Company/Site/Department/Device Type/Tag/Type) with Download / Copy URL / Send Link, reusing the existing embed-config download path; adds department + device_type to EmbeddedConfig/AgentStatus so labels persist at install time. Pairs with revocable per-machine keys; signature-vs-appended-config is the key open question. **[→ v2 Phase 2]** ([SPEC-007](specs/SPEC-007-managed-agent-installer-builder.md))
- [ ] **Valuable error messages (structured errors + no silent swallows)** — P2 — one structured API error envelope with stable codes + a correlation id that also lands in the logs; contextual tracing on server/agent; sweep the 37 `let _ =` swallows (the pattern that hid the migration-005 bug); dashboard surfaces the real cause + id instead of a generic line. **[→ v2 Phase 0/1 conventions]** ([SPEC-008](specs/SPEC-008-valuable-error-messages.md))
- [ ] **Feature-rich, fully-documented management API** — P2 — everything the console can do, callable by API: OpenAPI 3.x generated from code (utoipa) + browsable docs at `/api/docs`, long-lived revocable scoped API tokens (PAT-style, distinct from the 24h JWT + agent keys), an API-completeness gap audit, and consistent pagination/error conventions. Distinct from the ADR-001 RMM integration contract. **[→ v2 Phase 3]** ([SPEC-009](specs/SPEC-009-feature-rich-documented-api.md))
- [ ] **Branding and white-label configuration** — P2 — Allow MSPs to customize logo, colors, and product name for white-labeled remote support. Dashboard admin settings page with logo upload (PNG/SVG, max 2MB), brand hue slider (OKLCH 0-360°, default 184=cyan), product name override, company name, and favicon. Agent tray tooltip uses custom product name from registry. Singleton database table with public GET endpoint for unauthenticated rendering. CSS variables (`--brand-hue`, `--accent`, `--panel`) for dynamic theming. **[→ v2 Phase 2]** ([SPEC-014](specs/SPEC-014-branding-whitelabel.md))
- [ ] Programmatic session pre-create + viewer-token (integration contract) — P2
## Security & Infrastructure

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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