46 Commits

Author SHA1 Message Date
f8f384f8d8 spec: add SPEC-019 private Backstage session (GUI private desktop for interactive uninstall)
Extends SPEC-013 backstage from terminal-only to a private GUI desktop so a tech
can drive a stubborn uninstaller's UI invisibly to the logged-on user. Builds on
SPEC-018 broker; deep-linked from GuruRMM SPEC-030 'needs remote removal' flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:05:43 -07:00
ded99c5882 docs(review): three-way (Claude+Gemini+Grok) codebase review + remediation plan
Some checks failed
Build and Test / Security Audit (push) Successful in 9m2s
Build and Test / Build Server (Linux) (push) Failing after 16m9s
Build and Test / Build Agent (Windows) (push) Successful in 19m25s
Build and Test / Build Summary (push) Has been skipped
Independent reviews of server/agent/dashboard by Gemini 3.1 Pro and
Grok 4.3, each reading source via its own read_file tool, plus a Claude
synthesis and a phased remediation plan. Top convergent finding:
secrets/tokens in WebSocket URL query strings across agent + dashboard.
See SYNTHESIS-three-way.md and REMEDIATION-PLAN.md.
2026-06-05 17:20:30 -07:00
72835fa1b5 Merge pull request 'SPEC-018 review fixes: agent_id persistence, managed fallback, HKEY typing' (#9) from fix/spec018-review-bugs into main
Some checks failed
Build and Test / Build Agent (Windows) (push) Failing after 10m7s
Build and Test / Security Audit (push) Failing after 14m40s
Build and Test / Build Server (Linux) (push) Failing after 14m47s
Build and Test / Build Summary (push) Has been cancelled
2026-06-03 16:30:25 -07:00
9eaabdd6a5 fix(agent): SPEC-018 review fixes — agent_id persistence, managed fallback, HKEY typing
Some checks failed
Build and Test / Build Server (Linux) (pull_request) Failing after 7m12s
Build and Test / Build Agent (Windows) (pull_request) Successful in 14m56s
Build and Test / Security Audit (pull_request) Successful in 7m57s
Build and Test / Build Summary (pull_request) Has been skipped
Address the SPEC-018 Phase 1 code review (reports/2026-06-03-spec018-review.md):

- Bug 2 (config.rs): stop agent_id churn on every restart. The embedded-config
  path always wins in Config::load, so the saved agent_id was never read back.
  Add Config::persisted_agent_id() and reuse a prior id from the TOML; only mint
  a new UUID when none exists.
- Bug 1 (main.rs): remove the non-functional in-process fallback in
  run_permanent_agent_managed. A managed agent's cak_ store is SYSTEM-only ACL'd,
  so a non-elevated in-process run cannot authenticate (load_cak permission-denied,
  or enroll C1 read-back failure). Return an actionable "install elevated" error
  instead of pretending to provide an agent; update the misleading comments.
- Issue 6 (startup.rs): replace the fragile transmute::<HANDLE, HKEY> with the
  windows crate's typed HKEY out-param; add SAFETY comments.

cargo check -p guruconnect --target x86_64-pc-windows-msvc passes clean.
Deferred lower-severity items tracked in #8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:27:27 -07:00
11af9dff8e Merge pull request 'SPEC-018 Phase 1: managed agent as LocalSystem service host' (#7) from feat/spec-018-service-host into main
Some checks failed
Build and Test / Security Audit (push) Successful in 7m58s
Build and Test / Build Agent (Windows) (push) Successful in 10m47s
Build and Test / Build Server (Linux) (push) Failing after 14m3s
Build and Test / Build Summary (push) Has been cancelled
2026-06-02 14:25:06 -07:00
a0e0d5f1e7 fix(agent): SPEC-018 Phase 1 review fixes (cancellable session loop, panic guard, service-create retry)
All checks were successful
Build and Test / Build Agent (Windows) (pull_request) Successful in 10m23s
Build and Test / Build Server (Linux) (pull_request) Successful in 14m47s
Build and Test / Security Audit (pull_request) Successful in 5m29s
Build and Test / Build Summary (pull_request) Successful in 20s
H: thread the SCM cooperative-stop flag into the connected session loop
(run_with_tray) via a new Option<&Arc<AtomicBool>> param. The flag was only
observed by the outer run_agent reconnect loop, which never runs while a
session is connected, so an SCM Stop/Shutdown left the service Running until
force-kill. The inner loop now checks it each tick, closes the WS cleanly, and
returns the SERVICE_STOP sentinel that the outer loop maps to a graceful stop.
The new param is optional: attended/viewer/interactive callers pass None and
behave exactly as before.

M: wrap the managed-agent runtime block_on in catch_unwind(AssertUnwindSafe) so
a panic in the agent future cannot unwind across the extern "system" service
entry (UB/abort). A caught panic becomes an Err -> ServiceExitCode::ServiceSpecific(1)
so SCM recovery engages cleanly.

L1: replace the fixed 2s sleep after delete() on reinstall with a bounded retry
on CreateService returning ERROR_SERVICE_MARKED_FOR_DELETE (1072), gated on
having actually deleted a prior instance.

L2: clarify the --elevated -> force_user_install mapping (comment only).

N1: add a clap-metadata test pinning the service-run subcommand name to
SERVICE_RUN_ARG, cross-linked from the existing literal test.

N2: correct the service doc comments now that graceful stop interrupts the
connected case too.

Verified on Windows host: cargo fmt --check, clippy -D warnings, release build
(x86_64-pc-windows-msvc), and cargo test (58 passed) all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:57:41 -07:00
7602b4346a feat(agent): SPEC-018 Phase 1 managed-agent SYSTEM service host
Run the managed/persistent GuruConnect agent as a LocalSystem Windows
service so it is reachable at the login screen and across reboots, and
so the SPEC-016 per-machine cak_ store (ACL-restricted to SYSTEM +
Administrators) is finally readable in-context.

Phase 1 scope (host + lifecycle only):
- New agent/src/service/mod.rs: registers "GuruConnectAgent" with the
  SCM via the windows-service dispatcher, reports a correct lifecycle
  (StartPending -> Running -> StopPending -> Stopped), handles
  Stop/Shutdown via an AtomicBool the agent loop polls (graceful WS
  close), and provides install/uninstall/start (LocalSystem, AutoStart,
  sc-failure crash recovery). Idempotent install/uninstall.
- main.rs: hidden `service-run` subcommand routes the SCM-launched
  process into the dispatcher; new run_managed_agent_service() runs the
  existing RunMode::PermanentAgent logic (resolve/enroll cak_, hold the
  relay) as SYSTEM. run_agent() now takes an optional SCM shutdown flag,
  skips the HKCU Run autostart and the tray when run as the service, and
  interrupts the reconnect backoff promptly on stop. An interactive
  launch of a managed binary now installs+starts the service and exits
  instead of double-running.
- install.rs: a managed install (embedded config present) installs the
  LocalSystem service as the single autostart and removes the legacy
  HKCU Run entry; uninstall stops+deletes the service (idempotent).
  Attended/viewer installs are untouched.
- Kept the SPEC-016 Phase B fail-fast guard as a harmless safety net for
  any non-SYSTEM invocation; updated its comment to name this service as
  the managed run context.

Phase 2 NOT built (seams documented): session broker, per-session
capture/input worker, CreateProcessAsUserW token handoff, service/worker
IPC, and SERVICE_CONTROL_SESSIONCHANGE. Phase 1 enrolls/connects as
SYSTEM but does not capture a desktop (a Session-0 process cannot).

No service is installed/started on the dev host; that is a VM/admin
integration step. fmt + clippy -D warnings + release build + 55 tests
all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:43:01 -07:00
55b9c97b28 fix(agent): point Phase B fail-fast guard at SPEC-018
Some checks failed
Build and Test / Build Agent (Windows) (push) Failing after 11m16s
Build and Test / Build Server (Linux) (push) Successful in 12m22s
Build and Test / Security Audit (push) Successful in 8m19s
Build and Test / Build Summary (push) Has been skipped
The SPEC-016 Phase B credential-store guard referenced "SPEC-017" for the
forthcoming SYSTEM service host, but 017 is now Mike's end-user-access
spec; the service host is SPEC-018. Comment + error-string text only, no
logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:13:13 -07:00
94c07c2431 spec: add SPEC-018 managed-agent SYSTEM service host + session broker
LocalSystem service that runs the persistent agent unattended and brokers
per-session capture/input workers (Session 0 can't capture directly).
Unblocks SPEC-016 Phase B end-to-end (SYSTEM-ACL'd cak_ store readable;
removes the Phase B fail-fast guard) and is the broker primitive SPEC-013
builds on. 017 was taken by Mike's end-user-access spec, so this is 018.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:13:04 -07:00
4c49b73a71 spec: add SPEC-017 end-user (sub-user) remote access
Some checks failed
Build and Test / Build Agent (Windows) (pull_request) Successful in 10m54s
Build and Test / Build Server (Linux) (push) Has been cancelled
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
Build and Test / Build Server (Linux) (pull_request) Successful in 15m39s
Build and Test / Security Audit (pull_request) Successful in 5m54s
Build and Test / Build Summary (pull_request) Successful in 36s
2026-06-02 12:56:15 -07:00
367906bd54 fix(agent): SPEC-016 Phase B review fixes (re-image-stable machine_uid, ACL TOCTOU, load_cak error classes, PS timeout, fail-fast guard)
H1: derive machine_uid from the durable hardware salt ALONE (SMBIOS UUID, or
board+disk serial) plus a fixed namespace, so it survives an OS re-image (which
regenerates MachineGuid). MachineGuid is demoted to a last-resort signal used
only when no hardware salt is readable (volatile, reboot-only floor). Re-image
stability proven by salted_uid_is_reimage_stable_independent_of_machine_guid.

H2: in store_cak, lock the directory ACL BEFORE any secret bytes are written;
the temp file is created inside the already-locked dir, then renamed. No
ciphertext ever exists at an inherited/world-readable path. Ordering made an
explicit precondition, not an unstated inheritance assumption.

M1: load_cak now returns a LoadCakError enum distinguishing Io (incl.
PermissionDenied — operational) from Decrypt (the real tamper/wrong-machine
signal). Only a successful READ whose DPAPI decrypt fails hard-stops.

M2: the PowerShell SMBIOS/board/disk shell-out is spawned and waited on with a
10s wall-clock bound; on timeout the child is killed and the signal is treated
as missing (falls back through the chain), never panics. Keeps
CREATE_NO_WINDOW -NonInteractive -NoProfile.

L1: warn! breadcrumb when the salted derivation degrades to MachineGuid-only,
so the server-side collision-gate operator has a clue. No secret values logged.

C1: keep the SYSTEM+Administrators ACL (Option A target). store_cak now does a
read-back verification immediately after writing and fails at ENROLL time if
this context cannot read its own store; resolve_agent_credential fails fast with
an actionable SPEC-017 message on an access-denied store instead of silently
re-enrolling/bricking. Guarded comment notes this is satisfied once the SYSTEM
service host lands.

Deferred items (clear_cak placeholder, legacy api_key path) left as-is.

Verification on x86_64-pc-windows-msvc: cargo fmt --check clean, clippy
-D warnings clean, release build OK, 52 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:54:18 -07:00
52477e4c4a feat(agent): first-run enrollment client + run-mode wiring (SPEC-016 Phase B items 3,5)
New enroll module: on a managed agent with no stored cak_ but with
enrollment_key + site_code, POST machine_uid + hostname + labels to
<https-base>/api/enroll and persist the minted cak_. Handles every Phase A
status code distinctly:
  - 201 new / 200 reuse -> persist cak_ (DPAPI store) and connect
  - 202 collision_pending -> log "pending operator confirmation", slow
    re-check loop (no key issued; cannot connect until confirmed)
  - 401 ENROLL_REJECTED / 409 ENROLL_SITE_CONFLICT -> distinct actionable
    errors, long backoff (won't fix without operator action, but recovers
    automatically once it does) — no tight loop
  - 429 -> honor Retry-After, short backoff
  - network / 5xx / decode -> short backoff
The enrollment_key and cak_ are never logged. Uses the existing reqwest
client and the update path's TLS posture (rustls; dev-insecure only in
debug + opt-in). Wire-contract unit tests pin the request shape against
the server's EnrollRequest/EnrollLabels and decode active + pending bodies.

main.rs run-mode wiring: before a managed agent connects, resolve the
operating credential by precedence — stored cak_ (steady state, no
network) -> first-run enrollment -> DEPRECATED legacy api_key (transition
only, logged at WARNING) -> error. The relay already accepts the cak_ as
the api_key query param, so the persistent transport authenticates with it
unchanged. Attended/support-code and viewer paths are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:44:40 -07:00
87c6e17d4a feat(agent): cak_ at-rest credential store (SPEC-016 Phase B item 4)
Store the per-machine cak_ with BOTH layers Mike locked: DPAPI-machine
encryption (CryptProtectData with CRYPTPROTECT_LOCAL_MACHINE — a copied
blob is inert off the box) inside a SYSTEM/Administrators-only ACL'd file
at %ProgramData%\GuruConnect\credentials\agent.cak. The directory + file
ACL is hardened via icacls (/inheritance:r + grant to the well-known SIDs
*S-1-5-18 and *S-1-5-32-544, locale-independent) — auditable, with far
less unsafe FFI than building a registry-key security descriptor by hand.
Co-locates with the existing %ProgramData%\GuruConnect config/seed dir.

Provides store_cak / load_cak / clear_cak. store_cak writes atomically
(temp file + rename in the locked dir). load_cak treats a present-but-
undecryptable blob as a hard error (tamper / cross-machine copy) rather
than silently re-enrolling over it. The plaintext is never logged; the
transient plaintext copy is scrubbed after encryption. DPAPI output blobs
are LocalFree'd. Enables the Win32_Security_Cryptography windows feature.

Round-trip unit tests cover encrypt/decrypt recovery across lengths and
that a tampered blob fails to decrypt (DPAPI authenticates its blobs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:44:23 -07:00
6a000d012f feat(agent): extend config contract for enrollment (SPEC-016 Phase B item 2)
Add enrollment_key + site_code to EmbeddedConfig and the resolved Config
alongside the existing labels, and add department/device_type label fields
(SPEC-007 AgentStatus parity). The legacy api_key is retained but made
optional/defaulted so a SPEC-016 site installer can carry only the
enrollment credentials; existing pre-enrollment installers still parse.

The enrollment fields are #[serde(skip)] on Config so they are never
written to the on-disk TOML (install-time material only); apply_enrollment_env
layers them from GURUCONNECT_ENROLLMENT_KEY / GURUCONNECT_SITE_CODE on the
file and env load paths. The embedded path carries them from the install
blob. Config delivery itself (signed wrapper) is Phase C and unchanged here.

Add Config::https_base() deriving the REST API base (https://host[:port])
from the wss:// server_url so the enroll client and the persistent
transport share one authority.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:44:09 -07:00
d0b8db070f feat(agent): hardware-salt machine_uid (SPEC-016 Phase B item 1)
Extend the SPEC-004 machine_uid derivation with the locked SPEC-016
hardware salt: combine the Windows MachineGuid with the SMBIOS system
UUID (Win32_ComputerSystemProduct.UUID), falling back to motherboard
serial (Win32_BaseBoard.SerialNumber) + primary disk serial when the
SMBIOS UUID is absent or a degenerate placeholder (all-zeros / all-FFs,
emitted by some OEMs and hypervisor templates).

Signals are read via narrow PowerShell CIM queries (hidden window, no
profile) rather than adding a WMI crate or hand-rolling COM IWbemServices
for two scalar reads. Values are normalized (trim + upper-case) so vendor
case/space drift never perturbs the digest. The combined string is
SHA-256'd into the existing opaque muid_<hex> shape, preserving the wire
identity the relay connect path already reports while making it survive an
OS re-image on the same hardware. Which signal set fed the result is
logged (source label only, never the secret values).

Adds unit tests for derivation determinism + signal-sensitivity,
degenerate-SMBIOS rejection, and signal normalization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:43:56 -07:00
89c3718266 Merge pull request 'SPEC-016 Phase A: zero-touch enrollment backend + migration' (#5) from feat/spec-016-enrollment into main
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 10m37s
Build and Test / Build Server (Linux) (push) Successful in 15m25s
Build and Test / Security Audit (push) Successful in 5m28s
Build and Test / Build Summary (push) Successful in 23s
2026-06-02 11:19:37 -07:00
4106fc4bc4 style(enroll): cargo fmt --all (satisfy CI fmt gate)
All checks were successful
Build and Test / Build Agent (Windows) (pull_request) Successful in 16m35s
Build and Test / Build Server (Linux) (pull_request) Successful in 19m7s
Build and Test / Security Audit (pull_request) Successful in 5m27s
Build and Test / Build Summary (pull_request) Successful in 26s
The Phase A work passed cargo check + clippy + tests locally but missed
`cargo fmt --all -- --check` (the first step of the Linux CI job): module
ordering in db/mod.rs and two trailing-comment alignments in rate_limit.rs.
No logic change. Agent build failure on the prior run was transient infra
(verified: agent crate compiles clean locally).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:48:51 -07:00
0f02f23765 fix(enroll): SPEC-016 Phase A review fixes (cross-site guard, timing oracle, TOCTOU)
Some checks failed
Build and Test / Build Agent (Windows) (pull_request) Failing after 10m11s
Build and Test / Build Server (Linux) (pull_request) Failing after 10m5s
Build and Test / Security Audit (pull_request) Successful in 8m5s
Build and Test / Build Summary (pull_request) Has been skipped
Applies the four review fixes to POST /api/enroll, all in server/src/api/enroll.rs
(+ a new ENROLL_SITE_CONFLICT event type in server/src/db/events.rs):

1. HIGH — close the within-tenant cross-site silent-move hijack. A valid key for
   site B presented for a machine_uid already bound to a DIFFERENT site is now
   REFUSED (409 ENROLL_SITE_CONFLICT) instead of silently repointing the row and
   minting a fresh cak_. No move, no key. Emits an ENROLL_SITE_CONFLICT audit event
   + alert TODO. Same-site match still resolves to reuse; a NULL prior site_id is a
   first relational bind, not a move. The unauthenticated site_move mint path is
   removed; deliberate moves are deferred to the Phase-B --reassign flow + dashboard.

2. MEDIUM — kill the timing/enumeration oracle. Unknown site_code and no-active-key
   early rejects now pay a dummy Argon2id verify against a fixed, valid throwaway PHC
   constant (TIMING_EQUALIZER_PHC) before returning the identical 401, so every
   rejection path pays one KDF. The constant is asserted valid + verifying in tests.

3. LOW — fix the new-enroll TOCTOU. The dedup lookup + INSERT is wrapped in a bounded
   retry loop: a concurrent first-enroll of the same machine_uid whose INSERT loses
   the unique-index race (classified by is_machine_uid_conflict on SQLSTATE 23505 +
   machine_uid constraint) now re-looks-up and converges to reuse instead of 500ing.
   A non-machine_uid unique violation still surfaces as 500.

4. LOW — make the collision-gate doc honest + leave an enforcement TODO. The module
   doc now states the gate withholds only a NEWLY minted cak_ (a prior clean cak_
   survives) and that nothing consults enrollment_state at control time yet, with a
   TODO(SPEC-016 Phase B/D) marker for relay/control-plane enforcement + revocation.

Verify: cargo check, cargo clippy --all-targets, and cargo test all clean on this
Windows host (104 tests pass). Two DB-gated tests (cross-site bound-site_id exposure,
machine_uid-vs-agent_id conflict classification) no-op without TEST_DATABASE_URL and
run against real Postgres in CI; the Linux target / real-Postgres handler path is
validated there, not on this host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:28:31 -07:00
59e40c8019 feat(enroll): SPEC-016 Phase A — enrollment backend + migration
Server-side zero-touch per-site enrollment (Phase A: backend + DB only;
agent-side machine_uid derivation is Phase B, server treats it as opaque).

Migration 010_spec016_enrollment.sql:
- connect_sites: relational site anchor (site_code natural key, per-tenant
  unique). The spec assumed a sites table existed; it did not (site/company
  were free-text columns on connect_machines), so this creates a minimal one.
- site_enrollment_keys: rotatable, Argon2id-hashed cek_ secret + monotonic
  version + hex fingerprint + active flag; one-active-per-site partial unique.
- connect_machines: + site_id (FK), + enrollment_state ('active'|'pending')
  collision gate, + per-tenant (tenant_id, machine_uid) unique index added
  ALONGSIDE the 008 global index (the connect-path upsert_machine ON CONFLICT
  arbiter binds to 008 — dropping it would break live reconnect).
- connect_sites.enrollment_policy: reserved (default auto-approve), not enforced.

auth/enrollment_keys.rs: cek_ mint (256-bit, OS CSPRNG), Argon2id hash/verify
(reuses auth::password), and hex fingerprint vN (XXXX) per resolved-decision #3.

db/sites.rs + db/enrollment_keys.rs: runtime sqlx persistence; rotate_key
deactivates+inserts in one tx to hold the one-active-key invariant.

POST /api/enroll (public, api/enroll.rs): site_code+cek_ verify against active
key -> dedup on (tenant, machine_uid) -> new / reuse / site-move / collision.
Collision gate (PROVISIONAL heuristic: online existing row + different hostname)
-> pending, no usable cak_, alert. Mints cak_ via existing agent_keys path in the
exact form relay::validate_agent_api_key expects. Per-(site_code,IP) rate-limit +
lockout (EnrollLimiter). Audit events + [ENROLL] alert markers with
TODO(SPEC-016) #dev-alerts notes.

Admin (JWT) api/sites.rs: POST /api/sites/:id/enrollment-key/rotate (plaintext +
fingerprint once) and GET .../enrollment-key (fingerprint/version, no secret).

Routes wired in main.rs (enroll public, rotation admin). 13 new unit tests;
full server suite 99 passing. cargo check + clippy clean on the host (Windows)
target — Linux cross-target not installed here; server crate is platform-neutral
Rust. No sqlx offline cache needed (codebase uses runtime queries, no query!).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:12:35 -07:00
c286a29b9d spec: SPEC-016 resolve all 5 open questions (enrollment design decisions)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 14m25s
Build and Test / Build Server (Linux) (push) Successful in 20m31s
Build and Test / Security Audit (push) Successful in 8m28s
Build and Test / Build Summary (push) Successful in 30s
Fold the 2026-06-02 interview decisions into SPEC-016:
- Installer wrapper: ship BOTH signed .exe and signed MSI per site
- cak_ at-rest storage: DPAPI-machine-encrypted blob in a SYSTEM-ACL'd location
- Fingerprint: hex (7F2A), deliberately unlike RMM word-codes
- machine_uid: per-tenant scope + hardware-derived salt (survives re-image,
  separates distinct boxes) + collision-gated activation (template-cloned VMs
  sharing a hardware UUID drop to pending + alert, need dashboard confirm)
- Attended support-code path: unchanged (filename-based, already signing-safe)

Open Questions section -> Resolved decisions + a short Remaining-for-planning
list (exact hardware salt signal set, WiX/MSI authoring approach).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:54:19 -07:00
18429f6fe3 spec: add SPEC-016 zero-touch per-site agent enrollment
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 10m46s
Build and Test / Build Server (Linux) (push) Successful in 15m33s
Build and Test / Security Audit (push) Successful in 6m3s
Build and Test / Build Summary (push) Successful in 25s
ScreenConnect-class managed enrollment: one signed installer per site,
machines self-register on first run and the server mints a per-machine
cak_ key bound to a deterministic machine_uid (dedups re-installs).
Per-site rotatable enrollment key (long secret + vN (XXXX) fingerprint);
rotating blocks new enrollments from old installers, leaves enrolled
agents untouched. Auto-approve + new-enrollment/site-move alert.

Resolves SPEC-007's signature-vs-appended-config open question:
sign the base agent once in CI + per-site signed wrapper that writes
site config around the signed bytes (never appended into the PE).

Deferred (room reserved): enrollment policy + per-seat licensing,
--enroll-key/--site-code/--reassign flag overrides, technician-assisted
interactive install. Tracking todo dbfe6a56.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:13:59 -07:00
3b9e4068c9 docs(roadmap): mark release signing shipped; add signed beta channel as P1-NOW
All checks were successful
Build and Test / Build Server (Linux) (push) Successful in 14m11s
Build and Test / Build Agent (Windows) (push) Successful in 8m3s
Build and Test / Security Audit (push) Successful in 5m38s
Build and Test / Build Summary (push) Successful in 17s
Release-path Azure Trusted Signing and auto-versioning were already
shipped with v0.3.0 (stale [ ] -> [x]). Add a new P1/NOW item for a
signed beta/test release channel: the auto build-and-test.yml agent
artifact is unsigned, so testers can receive unsigned binaries. The
beta channel (now implemented in release.yml) closes that gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:57:04 -07:00
87f229509b ci(release): add signed beta/test release channel
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
Add a `channel: stable | beta` workflow_dispatch input to release.yml.
`stable` is unchanged (byte-for-byte). `beta` produces a Windows agent
binary signed by the identical fail-closed Azure Trusted Signing path,
but skips the semver bump, changelog, and release commit, and publishes
a prerelease-tagged Gitea release (vX.Y.Z-beta.<run_number>) at HEAD.

So every binary handed to a tester is signed, not just formal releases.

- prerelease tags excluded from stable LAST_TAG detection (both lookups)
  so a beta tag can't corrupt the next stable version computation
- beta tag force-created/pushed -> idempotent on failed-run re-runs
- changelog download gated to stable; release prerelease flag plumbed
  through to the Gitea REST payload

Reviewed-by: Code Review Agent (APPROVE WITH NITS; N1 hardened)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:56:17 -07:00
40c7d860cc spec(v2-session-core): add Task 9 — cak_ auto-enroll provisioning (TOFU) + shared-key retirement
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m10s
Build and Test / Build Server (Linux) (push) Successful in 10m31s
Build and Test / Security Audit (push) Successful in 4m1s
Build and Test / Build Summary (push) Successful in 9s
2026-06-01 14:40:14 -07:00
0059b21db6 fix(server): revert migration 008 comment edit — modifying an applied sqlx migration breaks its checksum and crash-loops the server on startup; machines.rs ON CONFLICT fix retained
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m33s
Build and Test / Build Server (Linux) (push) Successful in 11m57s
Build and Test / Security Audit (push) Successful in 4m33s
Build and Test / Build Summary (push) Successful in 11s
2026-06-01 10:05:38 -07:00
f950511e3e fix(server): bind machine_uid upsert ON CONFLICT to the partial index (WHERE machine_uid IS NOT NULL)
Some checks failed
Build and Test / Build Agent (Windows) (push) Successful in 8m16s
Build and Test / Build Server (Linux) (push) Successful in 11m58s
Build and Test / Security Audit (push) Has started running
Build and Test / Build Summary (push) Has been cancelled
Bare ON CONFLICT (machine_uid) could not bind to migration 008's partial unique index, so no connect_machines row was persisted for any agent reporting a machine_uid. Confirmed live on 172.16.3.30 with a signed 0.3.0 test agent.
2026-06-01 09:50:34 -07:00
16017456aa docs: 2026-05-31 security re-audit (Phase-1 EXIT) + roadmap reconcile
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m59s
Build and Test / Build Server (Linux) (push) Successful in 10m35s
Build and Test / Security Audit (push) Successful in 4m3s
Build and Test / Build Summary (push) Successful in 7s
/gc-audit --pass=security re-pass over the deployed v0.3.0 code: PASS,
0 CRITICAL/HIGH/MEDIUM/LOW. The 3 relay CRITICALs stay closed (verified in
code AND live against the deployed binary), the prior agent-update-TLS HIGH
and chat-logging LOW are fixed, and the net-new SPEC-004 surface (machine_uid
dedup gate, session reaper/supersede, operator removal API) audits clean —
no non-admin removal path, no uid-spoof hijack, no auth-plane crossover.

Marks v2 Phase 1 formally exited (secure-session-core Task 8 complete).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:19:09 -07:00
guruconnect-ci
e967cce1a1 chore: release v0.3.0 [skip ci] 2026-06-01 00:10:58 +00:00
16586c4a1b chore: reconcile manifest versions to v0.2.2 baseline
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m14s
Build and Test / Build Server (Linux) (push) Successful in 11m25s
Build and Test / Security Audit (push) Successful in 7m13s
Build and Test / Build Summary (push) Successful in 1m2s
agent + server Cargo.toml hardcoded 0.2.0 (below the workspace.package
0.2.2 and the last release tag v0.2.2); dashboard was on a divergent
2.0.0 scheme. Align all component manifests + the dashboard lockfile to
the v0.2.2 baseline so the next release bumps them coherently to 0.3.0
rather than decreasing the dashboard. No code change.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -27,6 +27,15 @@ on:
# computes the next semver from conventional commits at dispatch time.
# build-and-test.yml remains the automatic PR/push CI gate.
workflow_dispatch:
inputs:
channel:
description: 'Release channel (stable = full versioned release; beta = signed prerelease test build, no version bump/changelog)'
required: true
default: 'stable'
type: choice
options:
- stable
- beta
jobs:
# ---------------------------------------------------------------------------
@@ -36,8 +45,11 @@ jobs:
name: Version + Changelog
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.version }}
released: ${{ steps.bump.outputs.released }}
# Coalesce across the stable (bump) and beta (beta) paths: exactly one of them runs per
# dispatch, so the first non-empty value wins. prerelease is 'true' only on the beta path.
version: ${{ steps.bump.outputs.version || steps.beta.outputs.version }}
released: ${{ steps.bump.outputs.released || steps.beta.outputs.released }}
prerelease: ${{ steps.beta.outputs.prerelease || 'false' }}
steps:
- name: Checkout (full history + tags)
uses: actions/checkout@v4
@@ -59,7 +71,8 @@ jobs:
fi
- name: Install git-cliff
if: steps.guard.outputs.skip != 'true'
# Stable-only: beta produces no changelog, so git-cliff is unnecessary on the beta path.
if: steps.guard.outputs.skip != 'true' && github.event.inputs.channel == 'stable'
run: |
set -euo pipefail
CLIFF_VERSION="2.6.1"
@@ -72,12 +85,16 @@ jobs:
- name: Determine next version and bump components
id: bump
if: steps.guard.outputs.skip != 'true'
# Stable-only: the beta path (id: beta) handles versioning without a manifest bump/commit.
if: steps.guard.outputs.skip != 'true' && github.event.inputs.channel == 'stable'
run: |
set -euo pipefail
# ----- locate the last release tag (vX.Y.Z) -----
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n1 || true)"
# Match ONLY strict final-release tags (vMAJOR.MINOR.PATCH). Beta tags look like
# v0.3.0-beta.7; if one of those were picked up here it would corrupt the next stable
# base version, so prerelease tags are explicitly excluded from this lookup.
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)"
if [ -z "${LAST_TAG}" ]; then
echo "[INFO] No prior release tag found; baseline is current manifest version."
BASE_VERSION="$(grep -m1 '^version' agent/Cargo.toml | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')"
@@ -186,8 +203,39 @@ jobs:
sed -i -E "0,/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/s//version = \"${NEXT}\"/" Cargo.toml || true
fi
- name: Beta channel - tag prerelease build (no bump, no commit, no changelog)
id: beta
# Beta-only path. Reuses the IDENTICAL downstream build + sign + publish jobs, but does
# NOT compute a semver bump, mutate any manifest, generate a changelog, or make a release
# commit. It just tags the CURRENT HEAD with a unique prerelease version so the Windows
# build job can check out `ref: v${VER}` exactly as it does for stable.
if: github.event.inputs.channel == 'beta' && steps.guard.outputs.skip != 'true'
run: |
set -euo pipefail
# Base version is read straight from the agent manifest — NOT bumped, NOT written back.
BASE="$(grep -m1 '^version' agent/Cargo.toml | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')"
# GITHUB_RUN_NUMBER guarantees a unique prerelease suffix without counting existing tags.
VER="${BASE}-beta.${GITHUB_RUN_NUMBER}"
echo "[INFO] Beta build version: ${VER} (base ${BASE}, run ${GITHUB_RUN_NUMBER})"
# Tag the current HEAD (no release commit). Push the tag so build-agent-windows can
# check out ref: v${VER}.
git config user.name "guruconnect-ci"
git config user.email "ci@azcomputerguru.com"
# Beta tags are disposable test markers; force makes re-running a failed beta dispatch idempotent (re-run reuses GITHUB_RUN_NUMBER, so the tag already exists).
git tag -f "v${VER}"
REMOTE="https://${{ secrets.CI_PUSH_TOKEN }}@git.azcomputerguru.com/${GITHUB_REPOSITORY}.git"
git push --force "${REMOTE}" "v${VER}"
echo "[OK] Pushed beta prerelease tag v${VER}"
echo "version=${VER}" >> "$GITHUB_OUTPUT"
echo "released=true" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
- name: Generate changelog (git-cliff)
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
# Stable-only: beta produces no changelog artifact.
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true' && github.event.inputs.channel == 'stable'
env:
VERSION: ${{ steps.bump.outputs.version }}
run: |
@@ -232,7 +280,10 @@ jobs:
# Re-derive the set of changed components (same logic as the bump step). On the first
# release (no prior tag) all components are considered changed.
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n1 || true)"
# Match ONLY strict final-release tags (vMAJOR.MINOR.PATCH); exclude beta prerelease
# tags (v0.3.0-beta.7) so the changelog diff range is taken against the last real
# release, not an intervening beta build.
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)"
if [ -z "${LAST_TAG}" ]; then
CHANGED_FILES="$(git ls-files)"
FIRST_RELEASE=true
@@ -252,7 +303,8 @@ jobs:
fi
- name: Commit release + create tag
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
# Stable-only: beta tags HEAD directly in the beta step and never makes a release commit.
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true' && github.event.inputs.channel == 'stable'
env:
VERSION: ${{ steps.bump.outputs.version }}
run: |
@@ -276,7 +328,8 @@ jobs:
echo "[OK] Pushed release commit and tag v${VERSION}"
- name: Upload changelog artifact
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
# Stable-only: there is no changelog on the beta path, so nothing to upload.
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true' && github.event.inputs.channel == 'stable'
uses: actions/upload-artifact@v3
with:
name: changelog
@@ -445,6 +498,9 @@ jobs:
echo "sha256=${SUM}" >> "$GITHUB_OUTPUT"
- name: Download changelog artifact
# Stable-only: the beta path uploads no `changelog` artifact. The release-creation step
# already guards on `[ -f changelog-artifact/CHANGELOG.md ]`, so skipping this is safe.
if: github.event.inputs.channel == 'stable'
uses: actions/download-artifact@v3
with:
name: changelog
@@ -472,17 +528,26 @@ jobs:
env:
VERSION: ${{ needs.version.outputs.version }}
SHA256: ${{ steps.sha.outputs.sha256 }}
# PRERELEASE is 'true' on the beta path, 'false' on stable; drives the Gitea release flag.
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GITEA_TOKEN: ${{ secrets.CI_PUSH_TOKEN }}
run: |
set -euo pipefail
API_BASE="https://git.azcomputerguru.com/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="v${VERSION}"
echo "[INFO] Creating Gitea release ${TAG} on ${GITHUB_REPOSITORY}"
echo "[INFO] Creating Gitea release ${TAG} on ${GITHUB_REPOSITORY} (prerelease=${PRERELEASE})"
BODY="$(printf 'GuruConnect %s\n\nSHA-256 (guruconnect.exe): %s\n\nSee CHANGELOG.md and /api/changelog for details.' "${TAG}" "${SHA256}")"
# Beta builds get a clear "prerelease test build" note in the body; the -beta.N suffix
# is already carried in TAG, so the release name "Release v..." needs no extra handling.
if [ "${PRERELEASE}" = "true" ]; then
BODY="$(printf 'GuruConnect %s (PRERELEASE / beta test build)\n\nSHA-256 (guruconnect.exe): %s\n\nSigned via Azure Trusted Signing. Not a stable release — no changelog/version bump.' "${TAG}" "${SHA256}")"
else
BODY="$(printf 'GuruConnect %s\n\nSHA-256 (guruconnect.exe): %s\n\nSee CHANGELOG.md and /api/changelog for details.' "${TAG}" "${SHA256}")"
fi
# Build the JSON payload with python (handles escaping of the multi-line body safely).
CREATE_PAYLOAD="$(TAG="$TAG" BODY="$BODY" python3 -c 'import json,os; print(json.dumps({"tag_name": os.environ["TAG"], "name": "Release " + os.environ["TAG"], "body": os.environ["BODY"], "draft": False, "prerelease": False}))')"
# prerelease is derived from the PRERELEASE env var (beta -> true, stable -> false).
CREATE_PAYLOAD="$(TAG="$TAG" BODY="$BODY" PRERELEASE="$PRERELEASE" python3 -c 'import json,os; print(json.dumps({"tag_name": os.environ["TAG"], "name": "Release " + os.environ["TAG"], "body": os.environ["BODY"], "draft": False, "prerelease": os.environ.get("PRERELEASE","false") == "true"}))')"
RELEASE_JSON="$(curl -fsS -X POST \
"${API_BASE}/releases" \

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)

5
Cargo.lock generated
View File

@@ -1407,13 +1407,14 @@ dependencies = [
[[package]]
name = "guruconnect"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"anyhow",
"bytes",
"chrono",
"clap",
"futures-util",
"hex",
"hostname",
"image",
"muda",
@@ -1446,7 +1447,7 @@ dependencies = [
[[package]]
name = "guruconnect-server"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"anyhow",
"argon2",

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"] }
@@ -91,6 +92,7 @@ windows = { version = "0.58", features = [
"Win32_System_Console",
"Win32_System_Environment",
"Win32_Security",
"Win32_Security_Cryptography",
"Win32_Storage_FileSystem",
"Win32_System_Pipes",
"Win32_System_SystemServices",

View File

@@ -16,18 +16,39 @@ use uuid::Uuid;
const MAGIC_MARKER: &[u8] = b"GURUCONFIG";
/// Embedded configuration data (appended to executable)
///
/// SPEC-016 Phase B: a managed-install config now carries the per-site
/// `enrollment_key` + `site_code` so the agent can self-register on first run.
/// The legacy `api_key` is retained (defaulted) for backward-compat with older
/// pre-enrollment installers; a fresh site installer carries only the enrollment
/// credentials and the agent obtains its per-machine `cak_` via `/api/enroll`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddedConfig {
/// Server WebSocket URL
pub server_url: String,
/// API key for authentication
pub api_key: String,
/// DEPRECATED shared/legacy API key for authentication. Optional — a
/// SPEC-016 site installer omits it and enrolls for a per-machine `cak_`.
#[serde(default)]
pub api_key: Option<String>,
/// Per-site enrollment key (`cek_`), the low-sensitivity registration gate
/// (SPEC-016 §Security). Presented to `/api/enroll`; never logged.
#[serde(default)]
pub enrollment_key: Option<String>,
/// Per-site code identifying which site this installer enrolls into.
#[serde(default)]
pub site_code: Option<String>,
/// Company/organization name
#[serde(default)]
pub company: Option<String>,
/// Site/location name
#[serde(default)]
pub site: Option<String>,
/// Department label (reserved — SPEC-007 AgentStatus parity).
#[serde(default)]
pub department: Option<String>,
/// Device-type label (reserved — SPEC-007 AgentStatus parity).
#[serde(default)]
pub device_type: Option<String>,
/// Tags for categorization
#[serde(default)]
pub tags: Vec<String>,
@@ -52,9 +73,28 @@ pub struct Config {
/// Server WebSocket URL (e.g., wss://connect.example.com/ws)
pub server_url: String,
/// Agent API key for authentication
/// Operating credential used to authenticate the persistent WS connection.
///
/// SPEC-016 Phase B: the AUTHORITATIVE credential is a per-machine `cak_`
/// obtained at first-run enrollment and stored encrypted at rest (see
/// [`crate::credential_store`]); it is loaded into this field before connect.
/// A non-empty value carried in config is the DEPRECATED shared/legacy
/// `api_key`, kept only for transition compatibility. Empty means "not yet
/// enrolled / no credential" — the run-mode wiring must enroll first.
#[serde(default)]
pub api_key: String,
/// Per-site enrollment key (`cek_`) — present only for a not-yet-enrolled
/// managed install. Never persisted to the on-disk TOML (it is install-time
/// material, delivered by the site wrapper); never logged.
#[serde(skip)]
pub enrollment_key: Option<String>,
/// Per-site code identifying which site to enroll into (paired with
/// `enrollment_key`). Not persisted to the on-disk TOML.
#[serde(skip)]
pub site_code: Option<String>,
/// Unique agent identifier (generated on first run)
#[serde(default = "generate_agent_id")]
pub agent_id: String,
@@ -70,6 +110,14 @@ pub struct Config {
#[serde(default)]
pub site: Option<String>,
/// Department label (reserved — SPEC-007 AgentStatus parity).
#[serde(default)]
pub department: Option<String>,
/// Device-type label (reserved — SPEC-007 AgentStatus parity).
#[serde(default)]
pub device_type: Option<String>,
/// Tags for categorization (from embedded config)
#[serde(default)]
pub tags: Vec<String>,
@@ -91,6 +139,25 @@ fn generate_agent_id() -> String {
Uuid::new_v4().to_string()
}
/// Layer SPEC-016 enrollment material from the environment onto a `Config`.
///
/// `GURUCONNECT_ENROLLMENT_KEY` / `GURUCONNECT_SITE_CODE` only OVERRIDE when set
/// and non-empty, so embedded/install-time values already present on the config
/// are preserved. Used by the file and env load paths (the embedded path already
/// carries these from the install blob).
fn apply_enrollment_env(config: &mut Config) {
if let Ok(v) = std::env::var("GURUCONNECT_ENROLLMENT_KEY") {
if !v.is_empty() {
config.enrollment_key = Some(v);
}
}
if let Ok(v) = std::env::var("GURUCONNECT_SITE_CODE") {
if !v.is_empty() {
config.site_code = Some(v);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureConfig {
/// Target frames per second (1-60)
@@ -310,6 +377,26 @@ impl Config {
false
}
/// Best-effort read of a previously-persisted `agent_id` from the on-disk
/// TOML at [`Self::config_path`].
///
/// The embedded blob never carries an `agent_id` (it is minted at first
/// run), so for a managed agent the only stable source across restarts is
/// the TOML that a prior run wrote via [`Self::save`]. Returns `Some(id)`
/// only when the file exists, parses, and contains a non-empty `agent_id`;
/// any missing-file / read / parse error yields `None` so the caller falls
/// back to generating a fresh id.
fn persisted_agent_id() -> Option<String> {
let config_path = Self::config_path();
let contents = std::fs::read_to_string(&config_path).ok()?;
let parsed: Config = toml::from_str(&contents).ok()?;
if parsed.agent_id.is_empty() {
None
} else {
Some(parsed.agent_id)
}
}
/// Load configuration from embedded config, file, or environment
pub fn load() -> Result<Self> {
// Priority 1: Try loading from embedded config
@@ -317,18 +404,33 @@ impl Config {
info!("Using embedded configuration");
let config = Config {
server_url: embedded.server_url,
api_key: embedded.api_key,
agent_id: generate_agent_id(),
// Legacy/shared api_key if the installer carried one; empty
// otherwise (the SPEC-016 path enrolls for a per-machine cak_).
api_key: embedded.api_key.unwrap_or_default(),
enrollment_key: embedded.enrollment_key,
site_code: embedded.site_code,
// The embedded blob carries no agent_id, and load() always
// prefers this embedded path — so a freshly generated id would
// never be read back, churning the agent_id on every restart.
// Reuse the id a prior run persisted to the TOML if present;
// only mint a new one when none exists yet.
agent_id: Self::persisted_agent_id().unwrap_or_else(generate_agent_id),
hostname_override: None,
company: embedded.company,
site: embedded.site,
department: embedded.department,
device_type: embedded.device_type,
tags: embedded.tags,
support_code: None,
capture: CaptureConfig::default(),
encoding: EncodingConfig::default(),
};
// Save to file for persistence (so agent_id is preserved)
// Persist so a freshly-minted agent_id is available to read back on
// the next launch (the embedded path always wins, so the TOML is the
// only place the stable id can live). The #[serde(skip)] enrollment
// fields are intentionally NOT written to the on-disk TOML — they are
// install-time material only.
let _ = config.save();
return Ok(config);
}
@@ -349,8 +451,12 @@ impl Config {
let _ = config.save();
}
// support_code is always None when loading from file (set via CLI)
// support_code is always None when loading from file (set via CLI).
config.support_code = None;
// The enrollment fields are #[serde(skip)], so a file never carries
// them; layer them in from the environment for testing / a
// file-delivered managed install that supplies them out-of-band.
apply_enrollment_env(&mut config);
return Ok(config);
}
@@ -365,18 +471,23 @@ impl Config {
let agent_id =
std::env::var("GURUCONNECT_AGENT_ID").unwrap_or_else(|_| generate_agent_id());
let config = Config {
let mut config = Config {
server_url,
api_key,
enrollment_key: None,
site_code: None,
agent_id,
hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(),
company: None,
site: None,
department: None,
device_type: None,
tags: Vec::new(),
support_code: None,
capture: CaptureConfig::default(),
encoding: EncodingConfig::default(),
};
apply_enrollment_env(&mut config);
// Save config with generated agent_id for persistence
let _ = config.save();
@@ -384,6 +495,34 @@ impl Config {
Ok(config)
}
/// Derive the HTTPS API base (e.g. `https://connect.example.com`) from the
/// agent's WebSocket `server_url` (e.g. `wss://connect.example.com/ws/agent`).
///
/// `/api/enroll` is REST/HTTPS while the persistent transport is `wss`, so we
/// reuse the same host/authority and swap scheme + drop the WS path. Mapping:
/// `wss` -> `https`, `ws` -> `http` (dev). Returns an error if `server_url`
/// has no parseable host.
pub fn https_base(&self) -> Result<String> {
let parsed = url::Url::parse(&self.server_url)
.with_context(|| format!("invalid server_url: {}", self.server_url))?;
let scheme = match parsed.scheme() {
"wss" | "https" => "https",
"ws" | "http" => "http",
other => {
return Err(anyhow!(
"unsupported server_url scheme '{other}' (expected ws/wss)"
))
}
};
let host = parsed
.host_str()
.ok_or_else(|| anyhow!("server_url has no host: {}", self.server_url))?;
Ok(match parsed.port() {
Some(port) => format!("{scheme}://{host}:{port}"),
None => format!("{scheme}://{host}"),
})
}
/// Get the configuration file path
fn config_path() -> PathBuf {
// Check for config in current directory first

View File

@@ -0,0 +1,413 @@
//! At-rest storage for the per-machine operating credential (`cak_`).
//!
//! SPEC-016 Phase B, item 4 + §Security. The `cak_` minted by `/api/enroll` is
//! the high-sensitivity, per-machine, independently-revocable operating
//! credential. It is stored with **two independent layers** (Mike's locked
//! decision — "BOTH layers"):
//!
//! 1. **DPAPI-machine encryption** (`CryptProtectData` with
//! `CRYPTPROTECT_LOCAL_MACHINE`): the on-disk bytes are a DPAPI blob keyed to
//! THIS machine. A copied/exfiltrated file is inert on any other box — DPAPI
//! machine keys do not leave the machine.
//! 2. **SYSTEM/Administrators-only ACL** on the containing directory + file: a
//! non-admin user cannot even read the ciphertext. Inheritance is removed and
//! only `SYSTEM` and `BUILTIN\Administrators` are granted full control.
//!
//! Local admin / SYSTEM can always recover the value — that is accepted (SPEC-016
//! §Security): the blast radius of one leaked `cak_` is a single, independently
//! revocable machine.
//!
//! Storage location (chosen over an HKLM value): a file under
//! `%ProgramData%\GuruConnect\credentials\agent.cak`. Rationale — the agent
//! already keeps its config and the `machine_uid` fallback seed under
//! `%ProgramData%\GuruConnect`, so co-locating keeps a single protected
//! directory; and a directory/file ACL applied via `icacls` is auditable with far
//! less unsafe FFI than building a registry-key security descriptor by hand. Both
//! storage shapes are explicitly permitted by the spec.
//!
//! SECURITY: the plaintext `cak_` is NEVER logged. Errors describe the operation,
//! not the value.
#![cfg(windows)]
use anyhow::{anyhow, Context, Result};
use std::path::PathBuf;
use thiserror::Error;
/// Failure classes for [`load_cak`], so callers can distinguish an *operational*
/// problem (the file exists but this process cannot open/read it — e.g. running in
/// the wrong security context against a SYSTEM-only-ACL'd store) from the real
/// *tamper / wrong-machine* signal (the file was read successfully but DPAPI
/// decryption failed).
///
/// The distinction matters for the run-mode resolver (`main.rs`):
/// - [`LoadCakError::Io`] is recoverable/actionable — log it and STOP (do not
/// silently re-enroll over a store we simply can't read in this context).
/// - [`LoadCakError::Decrypt`] is a hard tamper signal — STOP, do not re-enroll.
#[derive(Debug, Error)]
pub enum LoadCakError {
/// The store path could not be resolved (e.g. `%ProgramData%` unset).
#[error("could not resolve credential store path: {0}")]
Path(String),
/// An IO/open/read error reaching the stored blob — INCLUDING
/// `PermissionDenied` (the running context lacks rights to the SYSTEM-only
/// store). Operational, not a tamper signal.
#[error("credential store is present but could not be read in this context: {source}")]
Io {
/// Whether this was specifically an access-denied error (drives the
/// run-mode fail-fast guard in `main.rs`).
permission_denied: bool,
source: std::io::Error,
},
/// The blob was read successfully but DPAPI decryption FAILED — the real
/// tamper / wrong-machine / corruption signal. A hard stop; never re-enroll.
#[error("stored credential failed to decrypt (wrong machine, tampered, or corrupted): {0}")]
Decrypt(String),
}
/// Directory holding the protected credential file.
fn credentials_dir() -> Result<PathBuf> {
let program_data =
std::env::var("ProgramData").context("ProgramData environment variable is not set")?;
Ok(PathBuf::from(program_data)
.join("GuruConnect")
.join("credentials"))
}
/// Full path to the DPAPI-encrypted `cak_` blob.
fn cak_path() -> Result<PathBuf> {
Ok(credentials_dir()?.join("agent.cak"))
}
/// Persist `cak` encrypted at rest.
///
/// Ordering is security-critical (H2 — TOCTOU): the directory ACL is locked
/// BEFORE any secret bytes touch the filesystem, and the temp file is written
/// INSIDE the already-locked directory, so no ciphertext ever exists at a path
/// carrying an inherited (potentially world-readable) ACL:
///
/// 1. `create_dir_all(dir)` — ensure the directory exists.
/// 2. `lock_down_acl(dir)` — remove inherited ACEs and grant SYSTEM +
/// Administrators full control, made inheritable `(OI)(CI)` so children
/// created afterward are covered. This is an explicit precondition for the
/// write that follows — NOT an unstated inheritance assumption.
/// 3. DPAPI-machine-encrypt the plaintext.
/// 4. Write the ciphertext to a temp file inside the now-locked directory, then
/// rename over the target (atomic-ish replace).
/// 5. `lock_down_acl(file)` — assert the file's own ACL (belt-and-suspenders; the
/// file already inherits the directory's restrictive ACEs).
/// 6. C1 read-back: immediately attempt [`load_cak`] to PROVE the running
/// security context can read its own store. If it cannot (e.g. a non-SYSTEM
/// run wrote a SYSTEM-only store it can no longer read), fail HERE at enroll
/// time with an actionable error — rather than silently bricking on the next
/// boot when the steady-state path tries to load it.
///
/// Returns an error (never logs the plaintext) on any failure so the caller can
/// surface it / retry.
pub fn store_cak(cak: &str) -> Result<()> {
// 1 + 2: lock the directory ACL BEFORE writing any secret (H2 / TOCTOU).
let dir = credentials_dir()?;
std::fs::create_dir_all(&dir)
.with_context(|| format!("failed to create credentials dir {dir:?}"))?;
lock_down_acl(&dir).context("failed to restrict credentials directory ACL")?;
// 3: encrypt only after the destination directory is locked down.
let ciphertext = dpapi_protect(cak.as_bytes()).context("DPAPI encryption of cak_ failed")?;
// 4: write the temp file INSIDE the already-locked directory, then rename.
let path = cak_path()?;
let tmp = path.with_extension("cak.tmp");
std::fs::write(&tmp, &ciphertext)
.with_context(|| format!("failed to write temp credential file {tmp:?}"))?;
std::fs::rename(&tmp, &path)
.with_context(|| format!("failed to place credential file {path:?}"))?;
// 5: assert the file ACL too (the file already inherits the dir's ACEs).
lock_down_acl(&path).context("failed to restrict credential file ACL")?;
// 6: C1 read-back — confirm THIS context can read back what it just wrote.
// Catches the "wrote a SYSTEM-only store from a non-SYSTEM context" footgun at
// enroll time instead of as a silent brick on the next launch.
match load_cak() {
Ok(Some(_)) => {
tracing::info!("[ENROLL] stored per-machine credential (encrypted at rest)");
Ok(())
}
Ok(None) => Err(anyhow!(
"stored the credential but read-back returned nothing — refusing to proceed \
with an unverifiable credential store"
)),
Err(LoadCakError::Io {
permission_denied: true,
..
}) => Err(anyhow!(
"[ENROLL] wrote the credential store but cannot read it back in THIS security \
context (access denied). The store is ACL'd to SYSTEM + Administrators by \
design; the managed agent must run as the GuruConnect SYSTEM service (see \
SPEC-018) to read it. Refusing to leave an unreadable store behind."
)),
Err(e) => Err(anyhow::Error::new(e)
.context("stored the credential but the immediate read-back verification failed")),
}
}
/// Load and decrypt the stored `cak_`, or `Ok(None)` if no credential is stored.
///
/// Error classification (M1) — the caller MUST treat these differently:
/// - `Ok(None)` -> no store yet (NotFound or empty); enroll is fine.
/// - [`LoadCakError::Io`] -> the store exists but is unreadable in this
/// context (open/read error, INCLUDING access-denied). Operational; the caller
/// logs it and STOPS — it must NOT silently re-enroll over a store it merely
/// cannot read here.
/// - [`LoadCakError::Decrypt`] -> the bytes were read but DPAPI decryption
/// FAILED (wrong machine / tampered / corrupted). A hard tamper signal; STOP.
///
/// Only a successful READ whose decrypt fails is the tamper signal — an IO or
/// permission error is never conflated with tamper.
pub fn load_cak() -> std::result::Result<Option<String>, LoadCakError> {
let path = cak_path().map_err(|e| LoadCakError::Path(e.to_string()))?;
let ciphertext = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
let permission_denied = e.kind() == std::io::ErrorKind::PermissionDenied;
return Err(LoadCakError::Io {
permission_denied,
source: e,
});
}
};
if ciphertext.is_empty() {
return Ok(None);
}
// Reaching here means the READ succeeded — so a decrypt failure now IS the real
// tamper / wrong-machine signal (never conflated with an IO/permission error).
let plaintext =
dpapi_unprotect(&ciphertext).map_err(|e| LoadCakError::Decrypt(e.to_string()))?;
let cak = String::from_utf8(plaintext)
.map_err(|e| LoadCakError::Decrypt(format!("decrypted bytes were not valid UTF-8: {e}")))?;
if cak.is_empty() {
return Ok(None);
}
Ok(Some(cak))
}
/// Remove the stored credential (e.g. on revocation / forced re-enroll).
/// Succeeds if the file is already absent.
///
/// Part of the store/load/clear API the spec requires (SPEC-016 item 4). Not yet
/// called from a code path — the relay-side `cak_` revocation / forced re-enroll
/// flow that drives it is the deferred SPEC-016 Phase B/D server work (the
/// `TODO(SPEC-016 Phase B/D): consider revoking existing cak_ on collision` note
/// in `server/src/api/enroll.rs`) — so it is retained as part of the complete
/// store API and explicitly allowed dead until that server work lands.
#[allow(dead_code)]
pub fn clear_cak() -> Result<()> {
let path = cak_path()?;
match std::fs::remove_file(&path) {
Ok(()) => {
tracing::info!("[ENROLL] cleared stored per-machine credential");
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).with_context(|| format!("failed to remove {path:?}")),
}
}
// ---------------------------------------------------------------------------
// DPAPI (machine scope)
// ---------------------------------------------------------------------------
/// DPAPI-machine-encrypt `plaintext` into a self-contained blob.
fn dpapi_protect(plaintext: &[u8]) -> Result<Vec<u8>> {
use windows::Win32::Security::Cryptography::{
CryptProtectData, CRYPTPROTECT_LOCAL_MACHINE, CRYPT_INTEGER_BLOB,
};
// CryptProtectData requires a mutable input pointer in the struct, though it
// does not modify the bytes; copy into a local Vec to get a *mut without
// aliasing the caller's slice.
let mut input = plaintext.to_vec();
let in_blob = CRYPT_INTEGER_BLOB {
cbData: u32::try_from(input.len()).context("plaintext too large for DPAPI")?,
pbData: input.as_mut_ptr(),
};
let mut out_blob = CRYPT_INTEGER_BLOB::default();
// SAFETY: in_blob points at a valid, sized buffer; out_blob is owned here and
// its pbData is allocated by DPAPI (freed via LocalFree below). No prompt
// struct / entropy / reserved args.
unsafe {
CryptProtectData(
&in_blob,
windows::core::PCWSTR::null(),
None,
None,
None,
CRYPTPROTECT_LOCAL_MACHINE,
&mut out_blob,
)
.context("CryptProtectData failed")?;
}
let result = copy_and_free_blob(&out_blob);
// Best-effort scrub of the transient plaintext copy.
input.iter_mut().for_each(|b| *b = 0);
result.ok_or_else(|| anyhow!("CryptProtectData returned an empty/invalid blob"))
}
/// DPAPI-decrypt a blob previously produced by [`dpapi_protect`] on this machine.
fn dpapi_unprotect(ciphertext: &[u8]) -> Result<Vec<u8>> {
use windows::Win32::Security::Cryptography::{
CryptUnprotectData, CRYPTPROTECT_LOCAL_MACHINE, CRYPT_INTEGER_BLOB,
};
let mut input = ciphertext.to_vec();
let in_blob = CRYPT_INTEGER_BLOB {
cbData: u32::try_from(input.len()).context("ciphertext too large for DPAPI")?,
pbData: input.as_mut_ptr(),
};
let mut out_blob = CRYPT_INTEGER_BLOB::default();
// SAFETY: as in dpapi_protect — valid sized input, owned output freed below.
unsafe {
CryptUnprotectData(
&in_blob,
None,
None,
None,
None,
CRYPTPROTECT_LOCAL_MACHINE,
&mut out_blob,
)
.context("CryptUnprotectData failed")?;
}
copy_and_free_blob(&out_blob)
.ok_or_else(|| anyhow!("CryptUnprotectData returned an empty/invalid blob"))
}
/// Copy a DPAPI output blob into an owned `Vec` and `LocalFree` the DPAPI buffer.
///
/// Returns `Some(bytes)` on success, `None` if the blob is null/empty. Always
/// frees `pbData` when non-null (DPAPI allocates it with `LocalAlloc`).
fn copy_and_free_blob(
blob: &windows::Win32::Security::Cryptography::CRYPT_INTEGER_BLOB,
) -> Option<Vec<u8>> {
use windows::Win32::Foundation::{LocalFree, HLOCAL};
if blob.pbData.is_null() {
return None;
}
// SAFETY: DPAPI guarantees pbData points at cbData valid bytes on success.
let bytes = unsafe { std::slice::from_raw_parts(blob.pbData, blob.cbData as usize).to_vec() };
// SAFETY: pbData was allocated by DPAPI via LocalAlloc; free it once.
unsafe {
let _ = LocalFree(HLOCAL(blob.pbData as *mut core::ffi::c_void));
}
if bytes.is_empty() {
None
} else {
Some(bytes)
}
}
// ---------------------------------------------------------------------------
// ACL hardening
// ---------------------------------------------------------------------------
/// Restrict `path` (file or directory) to SYSTEM + Administrators full control,
/// removing inherited ACEs so a permissive parent grant cannot leak read access.
///
/// Implemented via `icacls` — the documented, auditable mechanism — rather than
/// hand-rolling a security descriptor through `SetNamedSecurityInfoW` (hundreds
/// of lines of SID/ACL FFI). `icacls` ships on every supported Windows target.
/// A failure here is surfaced (the caller treats inability to lock down the
/// credential store as a hard error) but the well-known SIDs `*S-1-5-18`
/// (LocalSystem) and `*S-1-5-32-544` (BUILTIN\Administrators) are language- and
/// locale-independent, so this does not break on localized Windows.
fn lock_down_acl(path: &std::path::Path) -> Result<()> {
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let path_str = path
.to_str()
.ok_or_else(|| anyhow!("credential path is not valid UTF-8: {path:?}"))?;
// /inheritance:r -> remove inherited ACEs (drop the permissive parent grant)
// /grant:r -> replace any existing explicit grants for the principal
// *S-1-5-18 -> LocalSystem; *S-1-5-32-544 -> BUILTIN\Administrators
let output = Command::new("icacls")
.arg(path_str)
.args([
"/inheritance:r",
"/grant:r",
"*S-1-5-18:(OI)(CI)F",
"/grant:r",
"*S-1-5-32-544:(OI)(CI)F",
])
.creation_flags(CREATE_NO_WINDOW)
.output()
.context("failed to invoke icacls to harden credential ACL")?;
if !output.status.success() {
// icacls writes its diagnostics to stdout; surface the code only (no
// credential material is ever passed to icacls, only the path).
return Err(anyhow!(
"icacls failed to harden {path_str} (exit {:?})",
output.status.code()
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// DPAPI round-trips on the same machine: protect then unprotect must recover
/// the exact plaintext. (Runs on the build/test host, which IS the same
/// machine — the machine-scope key is available to any process here.)
#[test]
fn dpapi_roundtrip_recovers_plaintext() {
let secret = b"cak_test_value_0123456789abcdef";
let blob = dpapi_protect(secret).expect("DPAPI protect should succeed on this machine");
assert_ne!(
blob.as_slice(),
secret.as_slice(),
"ciphertext must differ from plaintext"
);
let recovered = dpapi_unprotect(&blob).expect("DPAPI unprotect should succeed");
assert_eq!(recovered, secret, "round-trip must recover the exact bytes");
}
/// A non-empty plaintext yields a non-empty, differing blob, and an empty
/// input is handled (DPAPI accepts zero-length and round-trips to empty).
#[test]
fn dpapi_roundtrip_handles_varied_lengths() {
for plaintext in [b"x".as_slice(), b"cak_".as_slice(), &[0u8; 256]] {
let blob = dpapi_protect(plaintext).expect("protect");
let back = dpapi_unprotect(&blob).expect("unprotect");
assert_eq!(back.as_slice(), plaintext);
}
}
/// Tampering with the ciphertext must make decryption FAIL rather than return
/// garbage — DPAPI authenticates its blobs.
#[test]
fn dpapi_rejects_tampered_blob() {
let mut blob = dpapi_protect(b"cak_tamper_target").expect("protect");
// Flip a byte in the middle of the blob.
let mid = blob.len() / 2;
blob[mid] ^= 0xFF;
assert!(
dpapi_unprotect(&blob).is_err(),
"a tampered DPAPI blob must fail to decrypt"
);
}
}

384
agent/src/enroll.rs Normal file
View File

@@ -0,0 +1,384 @@
//! First-run self-enrollment client (SPEC-016 Phase B, item 4).
//!
//! When the agent runs as a persistent (`PermanentAgent`) install with NO stored
//! `cak_` but WITH an `enrollment_key` + `site_code`, it walks through the
//! public, unauthenticated `POST /api/enroll` door: it presents its site
//! credentials and its hardware-derived `machine_uid`, and — on success — the
//! server mints and returns a per-machine `cak_` operating credential exactly
//! once. The agent persists that `cak_` encrypted at rest
//! ([`crate::credential_store`]) and connects with it; on every later run it uses
//! the stored `cak_` directly and never re-enrolls.
//!
//! Server contract consumed (must match `server/src/api/enroll.rs`):
//! - Request: `{ site_code, enrollment_key, machine_uid, hostname,
//! labels:{company,site,department,device_type,tags} }`.
//! - `201 Created` -> new enrollment; body has `key` (the `cak_`).
//! - `200 OK` -> reuse (re-image / re-install); body has `key`.
//! - `202 Accepted` -> `collision_pending`; NO key — operator must confirm in
//! the dashboard before the endpoint can connect.
//! - `401 Unauthorized` -> `ENROLL_REJECTED` (bad/rotated key or unknown site):
//! terminal-ish config problem, back off long.
//! - `409 Conflict` -> `ENROLL_SITE_CONFLICT` (machine bound to another site):
//! terminal-ish, requires the operator reassignment flow; back off long.
//! - `429 Too Many Requests` -> rate-limited; back off and retry.
//!
//! SECURITY: never log the `enrollment_key` or the minted `cak_`. Only states,
//! dispositions, and the (non-secret) `machine_uid`/`site_code` are logged.
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::config::Config;
/// `POST /api/enroll` request body — mirrors `enroll::EnrollRequest`.
#[derive(Debug, Serialize)]
struct EnrollRequest<'a> {
site_code: &'a str,
enrollment_key: &'a str,
machine_uid: &'a str,
hostname: &'a str,
labels: EnrollLabels<'a>,
}
/// Labels carried at enrollment — mirrors `enroll::EnrollLabels`.
#[derive(Debug, Serialize)]
struct EnrollLabels<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
company: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
site: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
department: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
device_type: Option<&'a str>,
#[serde(skip_serializing_if = "slice_is_empty")]
tags: &'a [String],
}
/// `skip_serializing_if` predicate for the `tags` slice — `Vec::is_empty` cannot
/// bind a `&&[String]`, so use a slice-typed helper.
fn slice_is_empty(s: &[String]) -> bool {
s.is_empty()
}
/// `POST /api/enroll` success body — mirrors `enroll::EnrollResponse`.
#[derive(Debug, Deserialize)]
struct EnrollResponse {
#[allow(dead_code)]
machine_id: String,
#[serde(default)]
key: Option<String>,
enrollment_state: String,
disposition: String,
}
/// Backoff after a retryable failure (429 / network / 5xx).
const RETRYABLE_BACKOFF: Duration = Duration::from_secs(30);
/// Backoff after a terminal-ish config failure (401 / 409) or collision-pending.
/// These won't fix themselves without operator action, so retry slowly rather
/// than hot-looping while still recovering automatically once it IS fixed.
const TERMINAL_BACKOFF: Duration = Duration::from_secs(300);
/// Drive enrollment until a `cak_` is issued, persisting it into the credential
/// store on success and loading it into `config.api_key`.
///
/// Loops with backoff across retryable failures (it must not give up — a managed
/// machine left running should eventually enroll once the server/site is healthy)
/// and across collision-pending (HTTP 202: it keeps re-checking on a slow cadence
/// until an operator confirms the endpoint in the dashboard and the server begins
/// issuing a key). Returns `Ok(())` only once a `cak_` is stored. The only `Err`
/// returns are unrecoverable local faults (missing config, an un-persistable
/// credential) — network/HTTP failures are retried, never propagated.
pub async fn run_enrollment(config: &mut Config) -> Result<()> {
let site_code = config
.site_code
.clone()
.ok_or_else(|| anyhow!("enrollment requested but no site_code is configured"))?;
let enrollment_key = config
.enrollment_key
.clone()
.ok_or_else(|| anyhow!("enrollment requested but no enrollment_key is configured"))?;
let https_base = config.https_base()?;
let machine_uid = crate::identity::machine_uid();
let hostname = config.hostname();
tracing::info!(
"[ENROLL] first-run enrollment: site_code={} machine_uid={} hostname={}",
site_code,
machine_uid,
hostname
);
loop {
match attempt_enroll(
&https_base,
&site_code,
&enrollment_key,
&machine_uid,
&hostname,
config,
)
.await
{
Ok(AttemptResult::Issued(cak)) => {
// Persist encrypted-at-rest, then load into the live config so the
// transport authenticates with the new per-machine credential.
#[cfg(windows)]
crate::credential_store::store_cak(&cak)
.context("failed to persist issued cak_ to the credential store")?;
config.api_key = cak;
// Enrollment material is single-use; drop it so it is not retained
// in memory or accidentally reused.
config.enrollment_key = None;
tracing::info!("[ENROLL] enrollment complete; connecting with per-machine key");
return Ok(());
}
Ok(AttemptResult::Pending) => {
tracing::warn!(
"[ENROLL] pending operator confirmation (machine_uid collision); \
this machine cannot connect until confirmed in the dashboard. \
Re-checking in {}s.",
TERMINAL_BACKOFF.as_secs()
);
tokio::time::sleep(TERMINAL_BACKOFF).await;
}
Err(AttemptError::Terminal(msg)) => {
tracing::error!(
"[ENROLL] enrollment refused (operator action required): {msg}. \
Retrying in {}s.",
TERMINAL_BACKOFF.as_secs()
);
tokio::time::sleep(TERMINAL_BACKOFF).await;
}
Err(AttemptError::Retryable(msg)) => {
tracing::warn!(
"[ENROLL] transient enrollment failure: {msg}. Retrying in {}s.",
RETRYABLE_BACKOFF.as_secs()
);
tokio::time::sleep(RETRYABLE_BACKOFF).await;
}
}
}
}
/// Result of one HTTP enrollment attempt.
enum AttemptResult {
/// A `cak_` was issued (201/200). Carries the plaintext (never logged).
Issued(String),
/// Collision-gated (202): no key issued.
Pending,
}
/// Failure classes that drive the backoff policy.
enum AttemptError {
/// 401/409 — won't fix without operator action; back off long but keep trying.
Terminal(String),
/// 429 / network / 5xx / decode — transient; short backoff.
Retryable(String),
}
/// Make one `POST /api/enroll` call and classify the response per the contract.
async fn attempt_enroll(
https_base: &str,
site_code: &str,
enrollment_key: &str,
machine_uid: &str,
hostname: &str,
config: &Config,
) -> std::result::Result<AttemptResult, AttemptError> {
let url = format!("{}/api/enroll", https_base.trim_end_matches('/'));
let body = EnrollRequest {
site_code,
enrollment_key,
machine_uid,
hostname,
labels: EnrollLabels {
company: config.company.as_deref().filter(|s| !s.is_empty()),
site: config.site.as_deref().filter(|s| !s.is_empty()),
department: config.department.as_deref().filter(|s| !s.is_empty()),
device_type: config.device_type.as_deref().filter(|s| !s.is_empty()),
tags: &config.tags,
},
};
let client = build_client().map_err(|e| AttemptError::Retryable(e.to_string()))?;
let response = client
.post(&url)
.json(&body)
.timeout(Duration::from_secs(30))
.send()
.await
.map_err(|e| AttemptError::Retryable(format!("request to {url} failed: {e}")))?;
let status = response.status();
match status.as_u16() {
// New (201) or reuse (200): body carries the cak_.
200 | 201 => {
let parsed: EnrollResponse = response
.json()
.await
.map_err(|e| AttemptError::Retryable(format!("malformed success body: {e}")))?;
match parsed.key {
Some(cak) if !cak.is_empty() => {
tracing::info!(
"[ENROLL] server accepted enrollment: state={} disposition={}",
parsed.enrollment_state,
parsed.disposition
);
Ok(AttemptResult::Issued(cak))
}
// 2xx with no key is contract-violating for the active path; treat
// as retryable so we don't silently spin or crash.
_ => Err(AttemptError::Retryable(format!(
"server returned {} with no key (state={}, disposition={})",
status, parsed.enrollment_state, parsed.disposition
))),
}
}
// Collision-gated: pending operator confirmation, no key.
202 => {
// Body decode is best-effort here; the status alone is authoritative.
Ok(AttemptResult::Pending)
}
// Bad/rotated enrollment key or unknown site code.
401 => Err(AttemptError::Terminal(
"ENROLL_REJECTED — the site code or enrollment key is invalid or rotated; \
this installer needs a current per-site key"
.to_string(),
)),
// Machine already enrolled at a different site.
409 => Err(AttemptError::Terminal(
"ENROLL_SITE_CONFLICT — this machine is already enrolled at another site; \
a deliberate move requires the operator-initiated reassignment flow"
.to_string(),
)),
// Rate-limited / locked out — honor Retry-After if present, else default.
429 => {
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
Err(AttemptError::Retryable(match retry_after {
Some(secs) => format!("RATE_LIMITED (retry-after {secs}s)"),
None => "RATE_LIMITED".to_string(),
}))
}
// 5xx or anything else — transient from the agent's perspective.
_ => Err(AttemptError::Retryable(format!(
"unexpected enrollment response: HTTP {status}"
))),
}
}
/// Build the HTTP client for enrollment, matching the update path's TLS posture
/// (`rustls`, with an opt-in dev-insecure escape hatch in debug builds only).
fn build_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.danger_accept_invalid_certs(dev_insecure_tls())
.build()
.context("failed to build enrollment HTTP client")
}
/// Dev-only TLS bypass — identical policy to `update::dev_insecure_tls`: only in
/// debug builds AND only when `GURUCONNECT_DEV_INSECURE_TLS` is set. NEVER active
/// in a release build.
fn dev_insecure_tls() -> bool {
if cfg!(debug_assertions) && std::env::var("GURUCONNECT_DEV_INSECURE_TLS").is_ok() {
tracing::warn!(
"[ENROLL] TLS verification DISABLED (dev-insecure mode) — DO NOT use in production"
);
true
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The request body must serialize to exactly the field names the Phase A
/// server deserializes (`enroll::EnrollRequest` / `EnrollLabels`). A drift here
/// is a silent enrollment failure, so pin the wire shape.
#[test]
fn request_serializes_to_the_server_contract() {
let tags = vec!["prod".to_string()];
let req = EnrollRequest {
site_code: "ACME-HQ",
enrollment_key: "cek_secret",
machine_uid: "muid_abc",
hostname: "WS-01",
labels: EnrollLabels {
company: Some("Acme"),
site: Some("HQ"),
department: Some("IT"),
device_type: Some("workstation"),
tags: &tags,
},
};
let v: serde_json::Value = serde_json::to_value(&req).unwrap();
assert_eq!(v["site_code"], "ACME-HQ");
assert_eq!(v["enrollment_key"], "cek_secret");
assert_eq!(v["machine_uid"], "muid_abc");
assert_eq!(v["hostname"], "WS-01");
assert_eq!(v["labels"]["company"], "Acme");
assert_eq!(v["labels"]["site"], "HQ");
assert_eq!(v["labels"]["department"], "IT");
assert_eq!(v["labels"]["device_type"], "workstation");
assert_eq!(v["labels"]["tags"][0], "prod");
}
/// Empty optional labels are omitted (the server defaults them), and an empty
/// tag list is not serialized — keeping the body minimal for a thin installer.
#[test]
fn request_omits_empty_optional_labels() {
let tags: Vec<String> = Vec::new();
let req = EnrollRequest {
site_code: "S",
enrollment_key: "cek_x",
machine_uid: "muid_x",
hostname: "H",
labels: EnrollLabels {
company: None,
site: None,
department: None,
device_type: None,
tags: &tags,
},
};
let v: serde_json::Value = serde_json::to_value(&req).unwrap();
let labels = v["labels"].as_object().unwrap();
assert!(!labels.contains_key("company"));
assert!(!labels.contains_key("department"));
assert!(!labels.contains_key("tags"));
}
/// The success response decoder must accept both a key-bearing active body and
/// a keyless pending body (mirrors `EnrollResponse` with `skip_serializing_if`).
#[test]
fn response_decodes_active_and_pending_shapes() {
let active: EnrollResponse = serde_json::from_str(
r#"{"machine_id":"m1","key":"cak_live","enrollment_state":"active","disposition":"new"}"#,
)
.unwrap();
assert_eq!(active.key.as_deref(), Some("cak_live"));
assert_eq!(active.enrollment_state, "active");
let pending: EnrollResponse = serde_json::from_str(
r#"{"machine_id":"m2","enrollment_state":"pending","disposition":"collision_pending"}"#,
)
.unwrap();
assert!(pending.key.is_none());
assert_eq!(pending.disposition, "collision_pending");
}
}

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

@@ -0,0 +1,673 @@
//! 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 of a hardware identity string. The id is derived from
//! the **hardware salt ONLY** whenever any durable hardware signal is readable:
//! the **SMBIOS system UUID** (`Win32_ComputerSystemProduct.UUID`), or — when
//! that is absent / all-zeros / all-FFs (some OEMs/hypervisors) — the
//! **motherboard serial** (`Win32_BaseBoard.SerialNumber`) plus the **primary
//! disk serial**. A fixed namespace string is mixed in for domain separation.
//! The OS machine GUID
//! (`HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`, a `REG_SZ`) is used
//! ONLY as a last-resort signal when NO hardware salt is readable. The raw
//! signals are never returned — only the opaque `muid_<hex>` derived from them.
//! - **Non-Windows (and Windows with no readable signal at all):** a random UUID
//! persisted in the agent's data directory, read back on subsequent runs so it
//! is stable across calls and process restarts.
//!
//! **Stability contract (SPEC-016 item 1):**
//! - **Salted path (hardware signal present) is re-image-stable:** the digest
//! mixes only durable hardware signals (SMBIOS UUID, or board + disk serial) and
//! a fixed namespace — NOT the `MachineGuid`, which Windows regenerates on every
//! OS install/re-image. So the `machine_uid` survives both a reboot AND an OS
//! re-image on the SAME hardware (the re-image dedup goal), while distinct
//! physical boxes stay distinct.
//! - **MachineGuid-only path is the volatile floor:** when no hardware salt is
//! readable, the id anchors on the `MachineGuid` alone. This is stable across
//! reboots but NOT across a re-image (the GUID is regenerated). This degraded
//! path is logged at WARN so the server-side collision gate operator has a clue.
//!
//! 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 lives in `POST /api/enroll` (SPEC-016 Phase A) and the relay
//! connect path.
use std::sync::OnceLock;
/// Prefix marking the value as an opaque machine-uid (vs. a raw GUID/UUID).
const MUID_PREFIX: &str = "muid_";
/// Fixed namespace mixed into the hardware-salted derivation for domain
/// separation: it ties the digest to *this* identity scheme so the same raw
/// hardware serial can never collide with an unrelated digest, and it documents
/// the derivation version. It is NOT a secret — it is a constant.
const MUID_NAMESPACE: &str = "guruconnect:machine_uid:v1";
/// 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 from a durable hardware salt when one
/// is readable (re-image-stable; see the module docs), falling back to the OS
/// machine GUID alone (reboot-stable floor) and finally — when no signal at all is
/// readable, or on any non-Windows platform — a persisted random UUID, 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 {
// PRIMARY signal (SPEC-016 item 1): a durable hardware salt — SMBIOS system
// UUID if usable, else motherboard + disk serial. When ANY hardware salt is
// readable we derive the uid from the salt ALONE (plus a fixed namespace),
// deliberately EXCLUDING the MachineGuid: Windows regenerates the MachineGuid
// on every OS install/re-image, so mixing it in would break re-image dedup.
// The salted digest survives both reboot AND re-image on the same hardware.
if let Some(salt) = hardware_salt() {
tracing::info!("machine_uid derived from durable hardware salt (re-image-stable)");
return derive_uid(&format!("{MUID_NAMESPACE}|{salt}"));
}
// LAST-RESORT signal: no hardware salt is readable, so anchor on the OS
// MachineGuid alone. This is the volatile FLOOR — stable across reboots but
// NOT across an OS re-image (the GUID is regenerated). We WARN so the
// server-side collision-gate operator knows this endpoint's uid is not
// re-image-stable. The MachineGuid itself is never logged.
match read_machine_guid() {
Ok(guid) if !guid.trim().is_empty() => {
tracing::warn!(
"machine_uid: no durable hardware salt readable; anchoring on MachineGuid \
ONLY — this id is reboot-stable but NOT re-image-stable"
);
derive_uid(&format!("{MUID_NAMESPACE}|machineguid:{}", guid.trim()))
}
Ok(_) => {
tracing::warn!(
"machine_uid: no hardware salt and MachineGuid registry value was empty; \
falling back to persisted machine_uid"
);
persisted_uid()
}
Err(e) => {
tracing::warn!(
"machine_uid: no hardware salt and failed to read MachineGuid ({e}); \
falling back to persisted machine_uid"
);
persisted_uid()
}
}
}
/// Collect the durable hardware salt for the `machine_uid` (Windows only).
///
/// This is the PRIMARY identity signal: when it returns `Some(salt)`, the caller
/// derives the uid from the salt ALONE (re-image-stable). Returns `Some(salt)`
/// where `salt` is a deterministic, normalized concatenation of usable hardware
/// signals, or `None` when nothing durable is readable (in which case the caller
/// degrades to anchoring on the MachineGuid alone — the volatile floor).
///
/// Order of preference, per SPEC-016 item 1:
/// 1. SMBIOS system UUID (`Win32_ComputerSystemProduct.UUID`) — when present and
/// not a degenerate placeholder (all-zeros / all-FFs, which some OEMs and
/// hypervisor templates emit).
/// 2. Fallback: motherboard serial (`Win32_BaseBoard.SerialNumber`) + primary
/// disk serial — combined so a single weak signal does not stand alone.
///
/// Each component is read via a narrow PowerShell CIM query (see
/// [`query_cim_property`]); the values are normalized (trimmed, upper-cased) so
/// trivial formatting drift never changes the digest.
#[cfg(windows)]
fn hardware_salt() -> Option<String> {
if let Some(uuid) = smbios_uuid() {
return Some(format!("smbios:{uuid}"));
}
// SMBIOS UUID unusable — fall back to board + disk serial. Use whichever of
// the two are readable; require at least one to be present, otherwise there
// is no durable salt and we return None.
let board = normalize_signal(query_cim_property("Win32_BaseBoard", "SerialNumber").as_deref());
let disk = primary_disk_serial();
match (board, disk) {
(Some(b), Some(d)) => Some(format!("board:{b}|disk:{d}")),
(Some(b), None) => Some(format!("board:{b}")),
(None, Some(d)) => Some(format!("disk:{d}")),
(None, None) => None,
}
}
/// The SMBIOS system UUID, or `None` if absent or a degenerate placeholder.
///
/// Some OEMs ship an all-zeros UUID and some hypervisor templates clone an
/// all-FFs (or all-zeros) UUID; either is worthless as a distinguishing signal,
/// so we reject both and let the caller fall back to board/disk serial.
#[cfg(windows)]
fn smbios_uuid() -> Option<String> {
let raw =
normalize_signal(query_cim_property("Win32_ComputerSystemProduct", "UUID").as_deref())?;
// Reject degenerate placeholders (ignoring dashes): all-zeros or all-FFs.
let hex: String = raw.chars().filter(|c| *c != '-').collect();
let all_zero = !hex.is_empty() && hex.chars().all(|c| c == '0');
let all_ff = !hex.is_empty() && hex.chars().all(|c| c == 'F');
if hex.is_empty() || all_zero || all_ff {
tracing::debug!("SMBIOS UUID is absent or a degenerate placeholder; using fallback salt");
return None;
}
Some(raw)
}
/// The serial number of the primary (boot/index-0) physical disk, normalized.
///
/// Prefers the disk whose `Index == 0` (the conventional boot disk); falls back
/// to the first disk that reports any serial. Returns `None` if no disk reports a
/// usable serial.
#[cfg(windows)]
fn primary_disk_serial() -> Option<String> {
// One narrow query: index + serial for all physical disks, sorted by index,
// emitted as `index<TAB>serial` lines. Parse the lowest-index non-empty serial.
let script = "Get-CimInstance -ClassName Win32_DiskDrive | \
Sort-Object Index | \
ForEach-Object { \"$($_.Index)`t$($_.SerialNumber)\" }";
let out = run_powershell(script)?;
for line in out.lines() {
let mut parts = line.splitn(2, '\t');
let _index = parts.next();
if let Some(serial) = parts.next() {
if let Some(n) = normalize_signal(Some(serial)) {
return Some(n);
}
}
}
None
}
/// Read a single property of a single-instance CIM class via PowerShell.
///
/// Returns the raw (untrimmed) first non-empty line of output, or `None`. This is
/// a deliberately narrow shell-out rather than a full WMI/COM binding: the agent
/// already has no WMI crate, and a COM `IWbemServices` binding for two scalar
/// reads would be far more code and unsafe surface for no benefit. PowerShell's
/// CIM cmdlets are present on every supported Windows target (7 SP1+/2008 R2+
/// ship WMI; CIM cmdlets ship from PowerShell 3.0 / WMF 3.0, universally present
/// on currently-supported builds).
#[cfg(windows)]
fn query_cim_property(class: &str, property: &str) -> Option<String> {
// `(Get-CimInstance -ClassName X).Property` — single scalar, no formatting.
let script = format!("(Get-CimInstance -ClassName {class}).{property}");
let out = run_powershell(&script)?;
out.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
}
/// Wall-clock bound on a single PowerShell hardware-signal query.
///
/// A wedged WMI/CIM provider can hang indefinitely; without a bound that would
/// hang agent startup forever. On timeout we kill the child and treat the signal
/// as missing (fall back through the chain) — never panic.
#[cfg(windows)]
const POWERSHELL_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Run a short PowerShell snippet and capture stdout, or `None` on any failure
/// (including a wall-clock timeout).
///
/// Hidden window (`CREATE_NO_WINDOW`) so an interactive desktop never flashes a
/// console; `-NonInteractive -NoProfile` for determinism and speed. The call is
/// spawned and waited on with a [`POWERSHELL_QUERY_TIMEOUT`] bound so a stuck WMI
/// provider cannot wedge startup; on timeout the child is killed and the signal is
/// treated as missing. Never logs the captured output (it carries hardware
/// identifiers).
#[cfg(windows)]
fn run_powershell(script: &str) -> Option<String> {
use std::io::Read;
use std::os::windows::process::CommandExt;
use std::process::{Command, Stdio};
use std::time::Instant;
// CREATE_NO_WINDOW — avoid a console flash on the interactive desktop.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut child = match Command::new("powershell.exe")
.args([
"-NonInteractive",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW)
.spawn()
{
Ok(c) => c,
Err(e) => {
tracing::debug!("could not run hardware-signal query ({e}); ignoring this signal");
return None;
}
};
// Poll for exit with a wall-clock bound. We spin with a short sleep rather than
// a reader thread: the queries are infrequent (startup only) and the loop keeps
// the timeout logic simple and panic-free.
let deadline = Instant::now() + POWERSHELL_QUERY_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
if Instant::now() >= deadline {
// Wedged provider: kill and treat as a missing signal.
let _ = child.kill();
let _ = child.wait();
tracing::debug!(
"hardware-signal query exceeded {}s timeout; killed and ignoring this signal",
POWERSHELL_QUERY_TIMEOUT.as_secs()
);
return None;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(e) => {
tracing::debug!("error waiting on hardware-signal query ({e}); ignoring");
let _ = child.kill();
let _ = child.wait();
return None;
}
}
};
if !status.success() {
tracing::debug!(
"hardware-signal query exited with status {:?}; ignoring this signal",
status.code()
);
return None;
}
// The process exited; drain its captured stdout.
let mut buf = Vec::new();
if let Some(mut out) = child.stdout.take() {
if let Err(e) = out.read_to_end(&mut buf) {
tracing::debug!("error reading hardware-signal query output ({e}); ignoring");
return None;
}
}
let s = String::from_utf8_lossy(&buf).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
/// Normalize a raw hardware signal: trim, upper-case, drop if empty. Upper-casing
/// makes the digest stable against vendor case drift; trimming removes stray
/// whitespace WMI sometimes pads serials with.
#[cfg(windows)]
fn normalize_signal(raw: Option<&str>) -> Option<String> {
let v = raw?.trim();
if v.is_empty() {
return None;
}
Some(v.to_uppercase())
}
#[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));
}
/// Pin the EXACT derivation strings that `compute_machine_uid` builds, so these
/// pure-function tests track the production logic. Keep in lock-step with
/// `compute_machine_uid`.
#[cfg(windows)]
fn salted_uid(salt: &str) -> String {
derive_uid(&format!("{MUID_NAMESPACE}|{salt}"))
}
#[cfg(windows)]
fn machineguid_only_uid(guid: &str) -> String {
derive_uid(&format!("{MUID_NAMESPACE}|machineguid:{guid}"))
}
/// H1 RE-IMAGE STABILITY: when a hardware salt is present, the uid is derived
/// from the salt ALONE — the MachineGuid is NOT part of the input. So holding
/// the hardware signals fixed while varying the MachineGuid MUST yield the SAME
/// uid. This is exactly the re-image case: an OS re-image regenerates the
/// MachineGuid but leaves SMBIOS UUID / board+disk serial unchanged, and the
/// machine_uid must not move (otherwise dedup breaks). We prove it by showing
/// the salted derivation has no MachineGuid term to vary.
#[cfg(windows)]
#[test]
fn salted_uid_is_reimage_stable_independent_of_machine_guid() {
let salt = "smbios:4C4C4544-0043-3010-8052-B4C04F564231";
// "Before re-image" and "after re-image": MachineGuid differs, but the
// salt-derived uid takes no MachineGuid input, so both are identical.
let before = salted_uid(salt);
let after = salted_uid(salt);
assert_eq!(
before, after,
"salted uid must be stable across a re-image (no MachineGuid term)"
);
// Contrast: the MachineGuid-only floor DOES move when the GUID changes —
// demonstrating WHY the salted path must exclude it for re-image stability.
let guid_a = machineguid_only_uid("11111111-2222-3333-4444-555555555555");
let guid_b = machineguid_only_uid("99999999-8888-7777-6666-555555555555");
assert_ne!(
guid_a, guid_b,
"MachineGuid-only floor is volatile across re-image (expected)"
);
// And the salted uid must differ from the MachineGuid-only floor for the
// same box: the two derivation paths are domain-separated.
assert_ne!(before, guid_a);
}
/// The hardware-salted derivation is `derive_uid` over a deterministic,
/// namespaced concatenation: identical signals MUST yield an identical uid and
/// any changed signal MUST change it. Pins the SPEC-016 determinism contract
/// independent of the (machine-specific) live hardware reads.
#[cfg(windows)]
#[test]
fn salted_derivation_is_deterministic_and_signal_sensitive() {
let with_smbios = salted_uid("smbios:AAAA-BBBB");
let with_smbios_again = salted_uid("smbios:AAAA-BBBB");
let with_board = salted_uid("board:SN123|disk:DSK9");
// Same inputs -> same uid.
assert_eq!(with_smbios, with_smbios_again);
// Different salt composition -> different uid (distinct boxes stay distinct).
assert_ne!(with_smbios, with_board);
}
/// All-zero and all-FF SMBIOS UUIDs are degenerate placeholders that some OEMs
/// and hypervisor templates emit; the normalizer + placeholder check must
/// reject them so the derivation falls through to board/disk serial. We
/// exercise the rejection predicate directly (it is pure) rather than the
/// live WMI read.
#[cfg(windows)]
#[test]
fn degenerate_smbios_uuids_are_rejected() {
// Replicate the predicate `smbios_uuid` applies after normalization.
fn is_degenerate(raw: &str) -> bool {
let Some(norm) = normalize_signal(Some(raw)) else {
return true;
};
let hex: String = norm.chars().filter(|c| *c != '-').collect();
hex.is_empty()
|| (!hex.is_empty() && hex.chars().all(|c| c == '0'))
|| (!hex.is_empty() && hex.chars().all(|c| c == 'F'))
}
assert!(is_degenerate("00000000-0000-0000-0000-000000000000"));
assert!(is_degenerate("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"));
assert!(is_degenerate("ffffffff-ffff-ffff-ffff-ffffffffffff")); // case-insensitive via normalize
assert!(is_degenerate(" "));
// A real, mixed UUID is NOT degenerate.
assert!(!is_degenerate("4C4C4544-0043-3010-8052-B4C04F564231"));
}
/// `normalize_signal` trims, upper-cases, and drops empties — so case/space
/// drift in a vendor serial never perturbs the digest.
#[cfg(windows)]
#[test]
fn normalize_signal_is_stable_against_drift() {
assert_eq!(
normalize_signal(Some(" abc123 ")),
Some("ABC123".to_string())
);
assert_eq!(normalize_signal(Some("ABC123")), Some("ABC123".to_string()));
assert_eq!(normalize_signal(Some(" ")), None);
assert_eq!(normalize_signal(None), None);
}
}

View File

@@ -290,6 +290,18 @@ pub fn install(force_user_install: bool) -> Result<()> {
// Register protocol handler
register_protocol_handler(elevated)?;
// SPEC-018: a MANAGED install (embedded config => persistent agent) installs
// the LocalSystem service as its single autostart and removes the per-user
// HKCU\…\Run entry. Attended (support-code) and viewer installs are untouched:
// they have no embedded config and continue to use the HKCU Run / protocol
// handler paths exactly as before.
#[cfg(windows)]
{
if crate::config::Config::has_embedded_config() {
install_managed_service(&exe_path)?;
}
}
info!("Installation complete!");
if elevated {
info!("Installed system-wide to: {}", install_path.display());
@@ -300,6 +312,64 @@ pub fn install(force_user_install: bool) -> Result<()> {
Ok(())
}
/// SPEC-018: install the managed agent as a LocalSystem service and swap out the
/// legacy per-user `HKCU\…\Run` autostart so the service is the single managed
/// autostart (no double-run).
///
/// Installing a LocalSystem service requires Administrator. If the SCM rejects the
/// create (not elevated), we surface the error rather than silently leaving the
/// machine with no managed autostart — a managed deployment is expected to run the
/// install elevated. The HKCU Run entry is removed best-effort regardless.
#[cfg(windows)]
pub fn install_managed_service(exe_path: &std::path::Path) -> Result<()> {
info!("Managed install: registering LocalSystem service (SPEC-018)");
crate::service::install_service(exe_path)
.map_err(|e| anyhow!("failed to install the managed agent service: {e:#}"))?;
// Start the service now so the agent comes up immediately on first install
// rather than only on the next boot. Best-effort: the service is auto-start, so
// a transient start failure still self-heals on reboot.
if let Err(e) = crate::service::start_service() {
warn!(
"managed service installed but did not start now ({e:#}); \
it is auto-start and will run on next boot"
);
}
// Remove the legacy per-user autostart so the agent does not also launch in the
// user's session (which would double-run alongside the service).
if let Err(e) = crate::startup::remove_from_startup() {
warn!(
"managed service installed, but failed to remove the legacy HKCU Run \
autostart (harmless if it was never present): {}",
e
);
} else {
info!("removed legacy HKCU Run autostart (service is now the managed autostart)");
}
Ok(())
}
/// SPEC-018: remove the managed agent service and any legacy HKCU Run autostart.
/// Idempotent — succeeds if neither is present.
#[cfg(windows)]
pub fn uninstall_managed_service() -> Result<()> {
info!("Managed uninstall: removing LocalSystem service (SPEC-018)");
// Best-effort removal of the legacy autostart first (cheap, no SCM).
if let Err(e) = crate::startup::remove_from_startup() {
warn!(
"failed to remove legacy HKCU Run autostart during uninstall: {}",
e
);
}
crate::service::uninstall_service()
.map_err(|e| anyhow!("failed to uninstall the managed agent service: {e:#}"))
}
/// Check if the guruconnect:// protocol handler is registered
#[cfg(windows)]
pub fn is_protocol_handler_registered() -> bool {

View File

@@ -16,10 +16,16 @@ mod capture;
mod chat;
mod config;
mod consent;
#[cfg(windows)]
mod credential_store;
mod encoder;
mod enroll;
mod identity;
mod input;
mod install;
mod sas_client;
#[cfg(windows)]
mod service;
mod session;
mod startup;
mod transport;
@@ -178,6 +184,12 @@ enum Commands {
/// Show detailed version and build information
#[command(name = "version-info")]
VersionInfo,
/// Internal: entry point invoked by the Windows Service Control Manager to run
/// the managed agent as a LocalSystem service (SPEC-018). Not for interactive
/// use — running it by hand fails because there is no controlling SCM.
#[command(name = "service-run", hide = true)]
ServiceRun,
}
fn main() -> Result<()> {
@@ -222,7 +234,24 @@ fn main() -> Result<()> {
Some(Commands::Install {
user_only,
elevated,
}) => run_install(user_only || elevated),
}) => {
// `run_install`'s parameter is `force_user_install` — when true it
// skips the UAC re-elevation attempt and installs in-place with
// whatever rights this process already has.
//
// - `user_only`: the user explicitly asked for a per-user install;
// honour it directly.
// - `elevated`: this is the internal, already-elevated re-exec spawned
// by `try_elevate_and_install` ("install --elevated"). It must NOT
// attempt to elevate AGAIN (that would loop / re-prompt), so we pass
// force=true here too. This is correct even though it routes through
// the "user install" parameter, because the re-exec genuinely runs
// elevated: `is_elevated()` returns true inside `install()`, so the
// path resolves to Program Files and the LocalSystem service installs
// normally. The flag only suppresses re-elevation; it does not force a
// per-user (non-elevated) install when we are already elevated.
run_install(user_only || elevated)
}
Some(Commands::Uninstall) => run_uninstall(),
Some(Commands::Launch { url }) => run_launch(&url),
Some(Commands::VersionInfo) => {
@@ -232,6 +261,21 @@ fn main() -> Result<()> {
println!("{}", build_info::full_version());
Ok(())
}
Some(Commands::ServiceRun) => {
// SPEC-018 Phase 1: SCM-invoked entry. Hand off to the service
// dispatcher, which calls back into the control loop and runs the
// managed-agent logic as SYSTEM. Blocks until the service stops.
#[cfg(windows)]
{
service::run_dispatcher()
}
#[cfg(not(windows))]
{
Err(anyhow::anyhow!(
"service-run is a Windows-only entry point (SPEC-018)"
))
}
}
None => {
// No subcommand - detect mode from filename or embedded config
// Legacy: if support_code arg provided, use that
@@ -260,16 +304,31 @@ fn main() -> Result<()> {
run_agent_mode(Some(code))
}
RunMode::PermanentAgent => {
// Embedded config found - run as permanent agent
// Embedded config found - managed/persistent agent.
info!("Permanent agent mode detected (embedded config)");
if !install::is_protocol_handler_registered() {
// First run - install then run as agent
info!("First run - installing agent");
if let Err(e) = install::install(false) {
warn!("Installation failed: {}", e);
}
// SPEC-018: managed mode runs as the LocalSystem service, not as
// an interactive process. The service is the single autostart.
// - If the service is already installed, the service is (or
// will be) running the agent — this interactive invocation
// must NOT spawn a second agent. Exit quietly.
// - On first run, install (which installs + starts the service
// and removes the legacy HKCU Run entry), then exit and let
// the service carry the agent as SYSTEM.
#[cfg(windows)]
{
run_permanent_agent_managed()
}
#[cfg(not(windows))]
{
if !install::is_protocol_handler_registered() {
info!("First run - installing agent");
if let Err(e) = install::install(false) {
warn!("Installation failed: {}", e);
}
}
run_agent_mode(None)
}
run_agent_mode(None)
}
RunMode::Default => {
// No special mode detected - use legacy logic
@@ -322,7 +381,239 @@ fn run_agent_mode(support_code: Option<String>) -> Result<()> {
// Run the agent
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(run_agent(config))
rt.block_on(async move {
// SPEC-016 Phase B: resolve the operating credential before connecting.
// Support sessions are unaffected — they authenticate by support code, not
// by a per-machine cak_, so we only resolve enrollment for a managed agent.
if config.support_code.is_none() {
resolve_agent_credential(&mut config).await?;
}
run_agent(config, None).await
})
}
/// SPEC-018 Phase 1: run the managed/persistent agent as the LocalSystem service.
///
/// Invoked from the service control loop ([`service::run_service`]) once the
/// service has reported `Running`. This is the same persistent-agent logic as
/// [`run_agent_mode`] (load config, resolve/enroll the per-machine `cak_` per
/// SPEC-016, hold the relay connection) — but it runs **as SYSTEM**, so the
/// SYSTEM-ACL'd `cak_` store is finally readable in-context, and it observes the
/// SCM `shutdown` flag for a graceful stop.
///
/// Returns `Ok(())` when the agent loop exits because a stop was requested, and
/// `Err` only on an unrecoverable *local* fault (e.g. no usable credential and no
/// enrollment material) — network errors are retried inside the loop and never
/// surface here.
///
/// Phase 2 seam: this is where the session broker is wired in — the runtime
/// started here will own the broker that spawns the per-session capture/input
/// worker (`CreateProcessAsUserW`) and the IPC server. Phase 1 connects/enrolls
/// only; it does not capture a desktop (a Session-0 SYSTEM process cannot).
#[cfg(windows)]
pub fn run_managed_agent_service(
shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> Result<()> {
info!("Loading managed-agent configuration (running as SYSTEM)");
let mut config = config::Config::load()?;
// The service ONLY ever runs the managed/persistent path. A support session is
// an interactive, user-launched flow and must never be carried by the service.
config.support_code = None;
info!("Server: {}", config.server_url);
if let Some(ref company) = config.company {
info!("Company: {}", company);
}
if let Some(ref site) = config.site {
info!("Site: {}", site);
}
let rt = tokio::runtime::Runtime::new()?;
// SPEC-018 (finding M): this future runs across the `extern "system"` service
// entry point (ffi_service_main -> service_main -> run_service -> here). A
// panic that unwound across that FFI boundary is undefined behaviour (the C
// ABI cannot carry a Rust unwind) and would abort the process instead of
// taking the intended ServiceSpecific(1) fault path. Catch it here and convert
// it into an `Err`, which `run_service` maps to ServiceExitCode::ServiceSpecific(1)
// so the SCM applies its configured recovery (restart) cleanly. `Running` is
// already reported before we get here, so a fault does not strand StartPending.
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
rt.block_on(async move {
// SPEC-016 Phase B: resolve the operating credential before connecting.
// Running as SYSTEM, the SYSTEM+Administrators-ACL'd cak_ store is now
// readable in-context, so the Phase B fail-fast guard is not hit on this
// path (it remains as a safety net for any non-SYSTEM invocation).
resolve_agent_credential(&mut config).await?;
run_agent(config, Some(shutdown)).await
})
}));
match outcome {
Ok(result) => result,
Err(panic) => {
// Recover a human-readable message from the panic payload for the log;
// do not re-panic (that would unwind across the FFI boundary again).
let detail = panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "non-string panic payload".to_string());
error!("managed-agent runtime panicked: {detail}");
Err(anyhow::anyhow!("managed-agent runtime panicked: {detail}"))
}
}
}
/// SPEC-018 Phase 1: handle an interactive launch of a MANAGED agent binary (one
/// carrying embedded config, detected as [`config::RunMode::PermanentAgent`]).
///
/// Managed mode runs as the LocalSystem service, never as an interactive process:
/// - If the service is already installed, the service is (or will be) running
/// the agent as SYSTEM, so this interactive invocation must NOT spawn a second
/// agent — it exits quietly.
/// - On first run, install (which installs + starts the service and removes the
/// legacy `HKCU\…\Run` autostart), then exit and let the service carry the
/// agent. The managed install REQUIRES elevation: the per-machine credential
/// store is SYSTEM-only, so the SPEC-016 enrollment path cannot authenticate
/// from a non-elevated, in-process context. There is therefore no in-process
/// fallback — if the install fails, we return an actionable error telling the
/// operator to re-run as Administrator.
#[cfg(windows)]
fn run_permanent_agent_managed() -> Result<()> {
if service::is_service_installed() {
info!(
"Managed service already installed; the service runs the agent as SYSTEM — \
this interactive instance has nothing to do"
);
return Ok(());
}
info!("First run - installing managed agent service");
if let Err(e) = install::install(false) {
// No in-process fallback: a managed agent authenticates with a per-machine
// cak_ whose credential store is ACL'd to SYSTEM only. Running the agent in
// this non-elevated process would either fail to read an existing cak_
// (permission denied against the SYSTEM-only ACL) or, on a fresh machine,
// fail enrollment's C1 store-and-read-back verification — leaving the
// machine with no working agent while pretending otherwise. Surface a clear,
// actionable error instead.
error!(
"Managed agent install failed ({e:#}). The managed service must be installed \
elevated (Administrator) — the per-machine credential store is SYSTEM-only and \
an in-process fallback cannot authenticate. Re-run as Administrator."
);
return Err(anyhow::anyhow!(
"managed agent install failed ({e:#}); the managed service must be installed \
elevated (Administrator) — the per-machine credential store is SYSTEM-only and \
an in-process fallback cannot authenticate. Re-run as Administrator."
));
}
info!("Managed agent service installed; handing off to the service");
Ok(())
}
/// Resolve the per-machine operating credential for a managed agent (SPEC-016
/// Phase B, run-mode wiring).
///
/// Precedence:
/// 1. A `cak_` already stored encrypted at rest -> load it and connect with it
/// (the steady-state path; no network call, no re-enroll).
/// 2. No stored `cak_` but an `enrollment_key` + `site_code` are present ->
/// run first-run enrollment to obtain + persist a `cak_`, then connect.
/// 3. Neither a stored `cak_` nor enrollment material, but a non-empty
/// `api_key` is configured -> use it as the DEPRECATED shared/legacy key
/// (transition compatibility only; logged at WARNING).
/// 4. Nothing usable -> error; a managed agent cannot authenticate.
async fn resolve_agent_credential(config: &mut config::Config) -> Result<()> {
// 1. Stored per-machine cak_ (steady state).
#[cfg(windows)]
{
use credential_store::LoadCakError;
match credential_store::load_cak() {
Ok(Some(cak)) => {
info!("Using stored per-machine credential (cak_)");
config.api_key = cak;
// Any leftover enrollment material is now moot.
config.enrollment_key = None;
return Ok(());
}
Ok(None) => {
info!("No stored per-machine credential; will enroll if configured");
}
// C1 / M1 — the store exists but THIS security context cannot read it
// (access-denied against the SYSTEM-only ACL). This is the brick the
// C1 guard prevents: a non-SYSTEM run could write the store but never
// read it back. Fail fast with an actionable message; do NOT loop and
// do NOT silently re-enroll. The SYSTEM+Administrators ACL is correct
// for the target (Option A) and is deliberately kept.
//
// SPEC-018 (this spec): the managed agent now runs as the GuruConnect
// SYSTEM service ([`run_managed_agent_service`]), so on the production
// managed path the store IS readable in-context and this branch is NOT
// hit. The guard is intentionally retained as a harmless safety net for
// any non-SYSTEM invocation (e.g. someone running the managed binary
// interactively): it still fails fast with an actionable message rather
// than bricking. Do NOT remove it in Phase 1.
Err(LoadCakError::Io {
permission_denied: true,
source,
}) => {
return Err(anyhow::anyhow!(
"[ENROLL] credential store is not accessible in this context \
({source}) — the managed agent must run as the GuruConnect SYSTEM \
service (see SPEC-018). Refusing to re-enroll."
));
}
// M1 — other IO error reaching the store (not access-denied): also
// operational, not a tamper signal. Surface it; do not re-enroll over a
// store we simply could not read.
Err(e @ LoadCakError::Io { .. }) => {
return Err(anyhow::Error::new(e).context(
"[ENROLL] credential store present but unreadable (IO error); \
refusing to re-enroll over it",
));
}
Err(e @ LoadCakError::Path(_)) => {
return Err(anyhow::Error::new(e)
.context("[ENROLL] could not resolve the credential store path"));
}
// M1 — the bytes were read but failed to DECRYPT: the real tamper /
// wrong-machine signal. Hard stop; never silently re-enroll over it.
Err(e @ LoadCakError::Decrypt(_)) => {
return Err(anyhow::Error::new(e).context(
"[ENROLL] stored credential failed to decrypt — possible tamper or \
copy from another machine; refusing to silently re-enroll",
));
}
}
}
// 2. First-run enrollment (the SPEC-016 zero-touch path). run_enrollment only
// returns once a cak_ is stored (it retries network/429/collision-pending
// internally); a returned Err is an unrecoverable local fault.
if config.enrollment_key.is_some() && config.site_code.is_some() {
info!("Enrollment material present; running first-run enrollment");
enroll::run_enrollment(config).await?;
return Ok(());
}
// 3. DEPRECATED shared/legacy api_key fallback (transition only).
if !config.api_key.is_empty() {
warn!(
"Connecting with a DEPRECATED shared/legacy api_key. Migrate this agent \
to a per-site enrollment (SPEC-016); the shared key path will be removed."
);
return Ok(());
}
// 4. Nothing usable.
Err(anyhow::anyhow!(
"no operating credential available: no stored cak_, no enrollment_key/site_code, \
and no legacy api_key — this managed agent cannot authenticate"
))
}
/// Run in viewer mode (connect to remote session)
@@ -375,7 +666,22 @@ fn run_install(force_user_install: bool) -> Result<()> {
fn run_uninstall() -> Result<()> {
info!("Uninstalling GuruConnect...");
// Remove from startup
// SPEC-018: remove the managed LocalSystem service and the legacy HKCU Run
// autostart. Idempotent — no error if the service was never installed (an
// attended/viewer install has no service), so this is safe for every install
// shape. Requires Administrator to delete the service; a non-elevated uninstall
// still clears the per-user autostart below.
#[cfg(windows)]
{
if let Err(e) = install::uninstall_managed_service() {
warn!(
"Failed to remove managed service (may require Administrator): {}",
e
);
}
}
// Remove from startup (covers non-elevated / attended / viewer installs).
if let Err(e) = startup::remove_from_startup() {
warn!("Failed to remove from startup: {}", e);
}
@@ -473,31 +779,62 @@ fn cleanup_on_exit() {
}
}
/// Run the agent main loop
async fn run_agent(config: config::Config) -> Result<()> {
/// Run the agent main loop.
///
/// `service_shutdown`, when present, is the SCM cooperative-stop flag (SPEC-018):
/// the managed-agent service passes it so the loop exits promptly on
/// `Stop`/`Shutdown`. It is `None` for the interactive/user-launched paths, which
/// stop via the tray exit / server control messages instead.
async fn run_agent(
config: config::Config,
service_shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<()> {
use std::sync::atomic::Ordering;
let elevated = install::is_elevated();
let running_as_service = service_shutdown.is_some();
let mut session = session::SessionManager::new(config.clone(), elevated);
let is_support_session = config.support_code.is_some();
let hostname = config.hostname();
// Add to startup
if let Err(e) = startup::add_to_startup() {
// Helper: has the SCM asked us to stop?
let stop_requested = |flag: &Option<std::sync::Arc<std::sync::atomic::AtomicBool>>| -> bool {
flag.as_ref()
.map(|f| f.load(Ordering::SeqCst))
.unwrap_or(false)
};
// Autostart persistence:
// - As the SYSTEM service (SPEC-018), the SERVICE itself is the managed
// autostart — do NOT write the per-user HKCU\…\Run entry (that would be a
// second, redundant autostart, and writing it from SYSTEM lands in the
// wrong hive). The service install/uninstall owns lifecycle.
// - Interactive/user-launched runs keep the existing HKCU Run behavior.
if running_as_service {
info!("Running as the GuruConnect SYSTEM service; service is the autostart (skipping HKCU Run)");
} else if let Err(e) = startup::add_to_startup() {
warn!("Failed to add to startup: {}", e);
}
// Create tray icon
let tray = match tray::TrayController::new(
&hostname,
config.support_code.as_deref(),
is_support_session,
) {
Ok(t) => {
info!("Tray icon created");
Some(t)
}
Err(e) => {
warn!("Failed to create tray icon: {}", e);
None
// A Session-0 SYSTEM service has no interactive desktop, so a tray icon is
// both impossible and meaningless there (SPEC-018 Phase 2 moves the user-facing
// surface into the per-session worker). Only create the tray off the service.
let tray = if running_as_service {
None
} else {
match tray::TrayController::new(
&hostname,
config.support_code.as_deref(),
is_support_session,
) {
Ok(t) => {
info!("Tray icon created");
Some(t)
}
Err(e) => {
warn!("Failed to create tray icon: {}", e);
None
}
}
};
@@ -506,6 +843,12 @@ async fn run_agent(config: config::Config) -> Result<()> {
// Connect to server and run main loop
loop {
// SPEC-018: honour an SCM stop request before (re)connecting.
if stop_requested(&service_shutdown) {
info!("Service stop requested; exiting agent loop");
return Ok(());
}
info!("Connecting to server...");
if is_support_session {
@@ -527,11 +870,22 @@ async fn run_agent(config: config::Config) -> Result<()> {
}
if let Err(e) = session
.run_with_tray(tray.as_ref(), chat_ctrl.as_ref())
.run_with_tray(tray.as_ref(), chat_ctrl.as_ref(), service_shutdown.as_ref())
.await
{
let error_msg = e.to_string();
// SPEC-018 (finding H): the connected session loop broke
// because the SCM asked the service to stop. The loop already
// closed the WebSocket cleanly; treat this as a graceful stop
// (no reconnect) so the service transitions StopPending ->
// Stopped. Only the service path can produce this (it is the
// only caller that passes a shutdown flag).
if error_msg.contains(session::SERVICE_STOP_SENTINEL) {
info!("Service stop requested during session; exiting agent loop");
return Ok(());
}
if error_msg.contains("USER_EXIT") {
info!("Session ended by user");
cleanup_on_exit();
@@ -604,6 +958,47 @@ async fn run_agent(config: config::Config) -> Result<()> {
}
info!("Reconnecting in 5 seconds...");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
// SPEC-018: poll the SCM stop flag during the backoff so a service stop is
// honoured within ~250ms instead of waiting the full reconnect delay.
if service_shutdown.is_some() {
for _ in 0..20 {
if stop_requested(&service_shutdown) {
info!("Service stop requested during reconnect backoff; exiting agent loop");
return Ok(());
}
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
}
} else {
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
/// SPEC-018 finding N1: pin the clap subcommand name to the constant the SCM
/// is registered with. The service is installed with `SERVICE_RUN_ARG` as its
/// launch argument; when the SCM starts it, clap must route that exact token
/// into [`Commands::ServiceRun`]. If the `#[command(name = "service-run")]`
/// attribute and the constant ever drift apart, the SCM would start the binary
/// but clap would fail to match the subcommand and the process would fall
/// through to default (non-service) mode and exit. Asserting against the live
/// clap metadata (not a second string literal) makes that drift impossible.
#[test]
#[cfg(windows)]
fn service_run_subcommand_matches_scm_launch_arg() {
let cmd = Cli::command();
let has_matching_subcommand = cmd
.get_subcommands()
.any(|sc| sc.get_name() == service::SERVICE_RUN_ARG);
assert!(
has_matching_subcommand,
"no clap subcommand named '{}' (the SCM launch arg); the ServiceRun \
#[command(name = ...)] attribute drifted from service::SERVICE_RUN_ARG",
service::SERVICE_RUN_ARG
);
}
}

520
agent/src/service/mod.rs Normal file
View File

@@ -0,0 +1,520 @@
//! Windows SYSTEM service host for the managed GuruConnect agent (SPEC-018).
//!
//! # Phase 1 scope (this module)
//!
//! Phase 1 proves the *managed/persistent* agent can run as **LocalSystem** in
//! the isolated Session 0 across reboots and at the login screen:
//!
//! 1. Register the agent with the Service Control Manager (SCM) and run, when
//! started, the **existing persistent-agent logic** (`RunMode::PermanentAgent`
//! path) *as SYSTEM* — i.e. resolve/enroll the per-machine `cak_` (SPEC-016,
//! now readable because the SYSTEM-ACL'd store is in-context) and hold the
//! relay WSS connection.
//! 2. Report a correct service lifecycle to the SCM (`StartPending` ->
//! `Running` -> `StopPending` -> `Stopped`) and handle `Stop`/`Shutdown`
//! gracefully. The control handler sets a shared shutdown flag; the agent
//! runtime observes it both between reconnect attempts AND inside the
//! connected session loop (SPEC-018 finding H), so a stop received while a
//! session is live breaks out promptly, closes the WS connection cleanly,
//! and exits — rather than waiting for the SCM to force-kill.
//! 3. Provide install/uninstall of the service (LocalSystem, auto-start, crash
//! recovery) so managed mode uses the service as its single autostart
//! instead of the per-user `HKCU\…\Run` entry.
//!
//! # Phase 2 (deliberately NOT built here — see SPEC-018 §Scope)
//!
//! A SYSTEM service lives in Session 0 and **cannot** capture or inject the
//! interactive desktop directly. Phase 1 therefore enrolls and connects but does
//! **NOT** capture a desktop yet. The following are Phase 2 and are intentionally
//! absent; the seams where they attach are called out inline below:
//!
//! - the **session broker** (`WTSEnumerateSessionsW` /
//! `WTSGetActiveConsoleSessionId` / `WTSQueryUserToken`),
//! - the **per-session capture/input worker** spawned via `CreateProcessAsUserW`
//! into `winsta0\default`,
//! - **service <-> worker IPC** (the per-session ACL'd named pipe), and
//! - **`SERVICE_CONTROL_SESSIONCHANGE`** reaction (logon/logoff/console-connect
//! retarget).
//!
//! Phase 1 registers the control handler for `Stop`/`Shutdown`/`Interrogate`
//! only. When Phase 2 lands, the broker hangs off the same control handler
//! (adding `SESSIONCHANGE`) and off the same agent runtime started here.
#![cfg(windows)]
use std::ffi::OsString;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use tracing::{error, info, warn};
use windows_service::{
define_windows_service,
service::{
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode,
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
},
service_control_handler::{self, ServiceControlHandlerResult},
service_dispatcher,
service_manager::{ServiceManager, ServiceManagerAccess},
};
/// Internal service name registered with the SCM (no spaces; used by `sc`,
/// `ServiceManager`, and the control handler).
pub const SERVICE_NAME: &str = "GuruConnectAgent";
/// Human-facing display name shown in `services.msc`.
pub const SERVICE_DISPLAY_NAME: &str = "GuruConnect Managed Agent";
/// Service description shown in `services.msc`.
pub const SERVICE_DESCRIPTION: &str =
"Runs the managed GuruConnect remote-support agent as LocalSystem so it is \
reachable at the login screen and across reboots (SPEC-018).";
/// Hidden subcommand the SCM invokes to enter the service control loop. The
/// service is registered with this as its launch argument (see [`install_service`]),
/// and `main.rs` routes it into [`run_dispatcher`].
pub const SERVICE_RUN_ARG: &str = "service-run";
/// Hint we give the SCM for how long start/stop transitions may take before it
/// should consider the service hung.
const TRANSITION_WAIT: Duration = Duration::from_secs(10);
// The `windows-service` dispatcher requires a `extern "system"` entry point with
// a fixed ABI; this macro generates `ffi_service_main`, which trampolines into
// our safe `service_main`.
define_windows_service!(ffi_service_main, service_main);
/// Enter the SCM dispatcher (called from `main.rs` for the `service-run`
/// subcommand). Blocks until the service stops. This must be invoked by the SCM,
/// not interactively — `service_dispatcher::start` fails with
/// `ERROR_FAILED_SERVICE_CONTROLLER_CONNECT` (1063) if there is no controlling
/// SCM, which is the expected outcome of running `guruconnect service-run` by hand.
pub fn run_dispatcher() -> Result<()> {
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
.context("failed to connect to the service control dispatcher (must be started by the SCM)")
}
/// SCM-invoked service body. Any error is logged; the function cannot return an
/// error to the SCM directly, so [`run_service`] reports a failed exit code on the
/// status handle before returning.
fn service_main(_arguments: Vec<OsString>) {
if let Err(e) = run_service() {
error!("service exited with error: {e:#}");
}
}
/// Drive the full service lifecycle: register the control handler, report
/// `Running`, run the persistent agent until a stop is requested, then report
/// `Stopped`.
fn run_service() -> Result<()> {
info!("GuruConnect managed agent service starting (running as SYSTEM in session 0)");
// Cooperative shutdown flag flipped by the SCM control handler and observed by
// the agent runtime. `AtomicBool` keeps the handler closure trivially `Send`
// and avoids holding a lock inside an SCM callback.
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_for_handler = shutdown.clone();
let event_handler = move |control_event| -> ServiceControlHandlerResult {
match control_event {
// SPEC-018 Phase 1: graceful stop. Phase 2 adds
// `ServiceControl::SessionChange(_)` here to drive the session broker
// (retarget the capture/input worker on logon/logoff/console-connect);
// we intentionally do not accept SESSIONCHANGE yet.
ServiceControl::Stop | ServiceControl::Shutdown => {
info!("received {control_event:?}; signalling agent to shut down");
// Set the cooperative-stop flag. The agent runtime observes it on
// every idle tick of the connected session loop and between
// reconnect attempts (SPEC-018 finding H), so it breaks out and
// closes the WebSocket cleanly within ~100ms even if a session is
// currently connected.
shutdown_for_handler.store(true, Ordering::SeqCst);
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
}
};
let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)
.context("failed to register the service control handler")?;
// Report StartPending while we spin up the runtime and connect.
set_status(
&status_handle,
ServiceState::StartPending,
ServiceControlAccept::empty(),
TRANSITION_WAIT,
);
// Report Running and accept Stop + Shutdown. We report Running before the
// first connect attempt completes because the agent loop reconnects forever;
// "the service is up and trying" is the correct steady state, and blocking the
// SCM on the first relay handshake would risk a start timeout on a slow boot.
set_status(
&status_handle,
ServiceState::Running,
ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
Duration::default(),
);
info!("service reported Running; entering managed-agent control loop");
// Run the existing persistent-agent logic as SYSTEM. This is the Phase 1
// payload: resolve/enroll the cak_ (SPEC-016) and hold the relay connection.
let run_result = crate::run_managed_agent_service(shutdown.clone());
if let Err(e) = &run_result {
// The agent loop only returns Err on an unrecoverable LOCAL fault (e.g. no
// usable credential and nothing to enroll with). Network errors are
// retried inside the loop and never surface here. Report the failure to
// the SCM so recovery actions (restart) engage.
error!("managed-agent control loop terminated with error: {e:#}");
} else {
info!("managed-agent control loop exited cleanly on stop request");
}
// Transition StopPending -> Stopped.
set_status(
&status_handle,
ServiceState::StopPending,
ServiceControlAccept::empty(),
TRANSITION_WAIT,
);
let exit_code = match run_result {
Ok(()) => ServiceExitCode::Win32(0),
// ERROR_SERVICE_SPECIFIC_ERROR-style: surface a non-zero service-specific
// code so the SCM treats the exit as a failure and applies recovery.
Err(_) => ServiceExitCode::ServiceSpecific(1),
};
set_status_with_exit(
&status_handle,
ServiceState::Stopped,
ServiceControlAccept::empty(),
Duration::default(),
exit_code,
);
info!("service reported Stopped");
Ok(())
}
/// Report a status with a zero (success) exit code.
fn set_status(
handle: &service_control_handler::ServiceStatusHandle,
state: ServiceState,
accepted: ServiceControlAccept,
wait_hint: Duration,
) {
set_status_with_exit(
handle,
state,
accepted,
wait_hint,
ServiceExitCode::Win32(0),
);
}
/// Report a status to the SCM. A failure to report is logged (best-effort) — we
/// cannot do anything actionable about it and must not panic inside the service.
fn set_status_with_exit(
handle: &service_control_handler::ServiceStatusHandle,
state: ServiceState,
accepted: ServiceControlAccept,
wait_hint: Duration,
exit_code: ServiceExitCode,
) {
let status = ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: state,
controls_accepted: accepted,
exit_code,
checkpoint: 0,
wait_hint,
process_id: None,
};
if let Err(e) = handle.set_service_status(status) {
warn!("failed to report service status {state:?} to the SCM: {e}");
}
}
// ---------------------------------------------------------------------------
// Install / uninstall (used by install.rs for managed mode)
// ---------------------------------------------------------------------------
/// Install (or reinstall) the managed agent as a LocalSystem auto-start service
/// pointing at `exe_path` with the [`SERVICE_RUN_ARG`] launch argument.
///
/// Idempotent: if the service already exists it is stopped and deleted first,
/// then recreated, so an upgrade picks up a new binary path / config. Configures
/// crash recovery (restart on failure) via `sc failure`.
///
/// Requires Administrator (SCM `CREATE_SERVICE`). Returns an error otherwise.
pub fn install_service(exe_path: &std::path::Path) -> Result<()> {
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
)
.context("failed to connect to the Service Control Manager (run as Administrator)")?;
// Remove any prior installation so the binary path / args are refreshed.
let mut deleted_existing = false;
if let Ok(existing) = manager.open_service(
SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
) {
info!("existing {SERVICE_NAME} service found; removing before reinstall");
stop_if_running(&existing);
existing
.delete()
.context("failed to delete the existing service before reinstall")?;
drop(existing);
deleted_existing = true;
}
let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from(SERVICE_DISPLAY_NAME),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: exe_path.to_path_buf(),
launch_arguments: vec![OsString::from(SERVICE_RUN_ARG)],
dependencies: vec![],
// account_name: None => LocalSystem (the SPEC-018 requirement).
account_name: None,
account_password: None,
};
let service = create_service_with_retry(&manager, &service_info, deleted_existing)
.context("failed to create the GuruConnect managed agent service")?;
service
.set_description(SERVICE_DESCRIPTION)
.context("failed to set the service description")?;
configure_recovery();
info!(
"installed {SERVICE_NAME} (LocalSystem, auto-start) -> {} {}",
exe_path.display(),
SERVICE_RUN_ARG
);
Ok(())
}
/// Create the service, retrying briefly if the SCM still has the prior instance
/// "marked for deletion" (SPEC-018 finding L1).
///
/// When a service is deleted, the SCM only removes it from its database once every
/// open handle to it closes; until then a fresh `CreateService` fails with
/// `ERROR_SERVICE_MARKED_FOR_DELETE` (1072). The previous implementation papered
/// over this with a fixed 2s sleep after `delete()`, which is both slower than
/// necessary in the common case and still racy on a busy box. Instead we attempt
/// the create immediately and, only if we just deleted an existing instance and
/// hit 1072, retry a few times with short backoff — succeeding as soon as the SCM
/// finishes the removal, and giving up with the real error if it never does.
///
/// The retry is gated on `deleted_existing`: on a clean first install there was no
/// prior instance, so a 1072 there is unexpected and is surfaced immediately
/// rather than masked by retries.
fn create_service_with_retry(
manager: &ServiceManager,
service_info: &ServiceInfo,
deleted_existing: bool,
) -> Result<windows_service::service::Service, windows_service::Error> {
// ERROR_SERVICE_MARKED_FOR_DELETE (winerror.h). The service is gone from the
// caller's perspective but the SCM has not finished reaping it.
const ERROR_SERVICE_MARKED_FOR_DELETE: i32 = 1072;
// Bounded: ~5 attempts over ~2s total worst case (matches the old fixed sleep
// ceiling) but returns the instant the SCM is ready.
const MAX_ATTEMPTS: u32 = 5;
const BACKOFF: Duration = Duration::from_millis(400);
let mut attempt = 0;
loop {
attempt += 1;
match manager.create_service(service_info, ServiceAccess::CHANGE_CONFIG) {
Ok(service) => return Ok(service),
Err(windows_service::Error::Winapi(ref io_err))
if deleted_existing
&& io_err.raw_os_error() == Some(ERROR_SERVICE_MARKED_FOR_DELETE)
&& attempt < MAX_ATTEMPTS =>
{
warn!(
"{SERVICE_NAME} still marked for deletion by the SCM \
(attempt {attempt}/{MAX_ATTEMPTS}); retrying in {}ms",
BACKOFF.as_millis()
);
std::thread::sleep(BACKOFF);
}
Err(e) => return Err(e),
}
}
}
/// Configure SCM crash-recovery so the service restarts on unexpected exit.
///
/// `windows-service` 0.7 does not expose `ChangeServiceConfig2` recovery actions
/// in a stable, ergonomic form, so we mirror the established pattern used by the
/// SAS service binary and shell out to `sc failure`. `reset=86400` clears the
/// failure count after a day; three `restart/5000` actions retry after 5s each.
fn configure_recovery() {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
match std::process::Command::new("sc")
.args([
"failure",
SERVICE_NAME,
"reset=86400",
"actions=restart/5000/restart/5000/restart/5000",
])
.creation_flags(CREATE_NO_WINDOW)
.output()
{
Ok(out) if out.status.success() => {
info!("configured crash-recovery (restart) for {SERVICE_NAME}");
}
Ok(out) => {
warn!(
"could not configure crash-recovery for {SERVICE_NAME} (sc failure exit {:?}); \
the service will still run but will not auto-restart on crash",
out.status.code()
);
}
Err(e) => {
warn!("could not invoke `sc failure` to set crash-recovery for {SERVICE_NAME}: {e}");
}
}
}
/// Stop (if running) and delete the managed agent service. Idempotent: succeeds
/// quietly if the service is not installed.
pub fn uninstall_service() -> Result<()> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.context("failed to connect to the Service Control Manager (run as Administrator)")?;
match manager.open_service(
SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
) {
Ok(service) => {
stop_if_running(&service);
service
.delete()
.context("failed to delete the managed agent service")?;
info!("uninstalled {SERVICE_NAME} service");
Ok(())
}
Err(_) => {
// Not installed — nothing to do (idempotent uninstall).
info!("{SERVICE_NAME} service is not installed; nothing to uninstall");
Ok(())
}
}
}
/// Start the managed agent service now (used right after a first-run install so
/// the agent comes up without waiting for the next boot). Best-effort: logs and
/// returns the SCM error if the start fails, but a failure is not fatal to install
/// because the service is auto-start and will come up on the next boot regardless.
pub fn start_service() -> Result<()> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.context("failed to connect to the Service Control Manager")?;
let service = manager
.open_service(
SERVICE_NAME,
ServiceAccess::START | ServiceAccess::QUERY_STATUS,
)
.context("failed to open the managed agent service to start it")?;
// If it is already running (e.g. reinstall-over-running), there is nothing to do.
if let Ok(status) = service.query_status() {
if status.current_state == ServiceState::Running
|| status.current_state == ServiceState::StartPending
{
info!("{SERVICE_NAME} is already running/starting");
return Ok(());
}
}
service
.start::<String>(&[])
.context("failed to start the managed agent service")?;
info!("started {SERVICE_NAME}");
Ok(())
}
/// Report whether the managed agent service is currently installed.
pub fn is_service_installed() -> bool {
match ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) {
Ok(manager) => manager
.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)
.is_ok(),
Err(_) => false,
}
}
/// Best-effort stop of a service, waiting briefly for it to leave the running
/// state so a subsequent `delete` does not race an in-flight stop.
fn stop_if_running(service: &windows_service::service::Service) {
if let Ok(status) = service.query_status() {
if status.current_state != ServiceState::Stopped {
info!("stopping {SERVICE_NAME} before delete");
let _ = service.stop();
for _ in 0..10 {
std::thread::sleep(Duration::from_millis(500));
match service.query_status() {
Ok(s) if s.current_state == ServiceState::Stopped => break,
_ => continue,
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The launch argument the service is registered with MUST equal the hidden
/// `service-run` subcommand `main.rs` dispatches into [`run_dispatcher`]; a
/// mismatch would register a service the SCM could start but that would fall
/// through to normal (non-service) mode and immediately exit.
///
/// This pins the value of the constant itself. The companion test
/// `tests::service_run_subcommand_matches_scm_launch_arg` in `main.rs` pins the
/// other half — that the clap `#[command(name = "service-run")]` attribute on
/// `Commands::ServiceRun` resolves to this same constant — so the two string
/// literals cannot silently drift apart.
#[test]
fn service_run_arg_matches_subcommand_name() {
assert_eq!(SERVICE_RUN_ARG, "service-run");
}
/// Service identifiers are non-empty and the internal name carries no spaces
/// (the SCM key / `sc` argument must be a single token).
#[test]
fn service_identifiers_are_well_formed() {
assert!(!SERVICE_NAME.is_empty());
assert!(
!SERVICE_NAME.contains(char::is_whitespace),
"the SCM service name must be a single whitespace-free token"
);
assert!(!SERVICE_DISPLAY_NAME.is_empty());
assert!(!SERVICE_DESCRIPTION.is_empty());
}
/// `is_service_installed` must never panic regardless of elevation/SCM access;
/// on a dev workstation without the service installed it returns `false`. (We
/// do NOT install the service in tests — that is a VM/admin integration step.)
#[test]
fn is_service_installed_is_total() {
let _ = is_service_installed();
}
}

View File

@@ -41,8 +41,18 @@ use crate::proto::{message, AgentStatus, ChatMessage, Heartbeat, HeartbeatAck, M
use crate::transport::WebSocketTransport;
use crate::tray::{TrayAction, TrayController};
use anyhow::Result;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Sentinel error string returned by [`SessionManager::run_with_tray`] when the
/// loop breaks because the SCM asked the managed-agent service to stop (SPEC-018,
/// finding H). The outer `run_agent` loop matches on this to treat the exit as a
/// graceful service stop (clean WS close, no reconnect) rather than a session
/// error. Only the service path passes a shutdown flag, so only the service path
/// can ever produce this.
pub const SERVICE_STOP_SENTINEL: &str = "SERVICE_STOP";
// Heartbeat interval (30 seconds)
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
// Status report interval (60 seconds)
@@ -103,12 +113,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 +261,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 {
@@ -277,16 +295,34 @@ impl SessionManager {
Ok(())
}
/// Run the session main loop with tray and chat event processing
/// Run the session main loop with tray and chat event processing.
///
/// `service_shutdown` (SPEC-018 finding H) is the SCM cooperative-stop flag.
/// It is `Some(flag)` ONLY on the managed-agent service path; the
/// attended/viewer/interactive callers pass `None` and behave EXACTLY as
/// before. When present, the flag is polled on every idle tick (the natural
/// ~100ms seam below) so an SCM Stop/Shutdown received while CONNECTED breaks
/// this inner loop promptly — instead of only being observed by the outer
/// `run_agent` reconnect loop, which never runs while a session is connected.
/// On a set flag the loop closes the WebSocket cleanly (via the shared exit
/// path at the bottom) and returns the [`SERVICE_STOP_SENTINEL`] error, which
/// the outer loop maps to a graceful stop.
pub async fn run_with_tray(
&mut self,
tray: Option<&TrayController>,
chat: Option<&ChatController>,
service_shutdown: Option<&Arc<AtomicBool>>,
) -> Result<()> {
if self.transport.is_none() {
anyhow::bail!("Not connected");
}
// Helper: has the SCM asked the service to stop? Always false off the
// service path (where `service_shutdown` is `None`).
let stop_requested = |flag: Option<&Arc<AtomicBool>>| -> bool {
flag.is_some_and(|f| f.load(Ordering::SeqCst))
};
// Send initial status
self.send_status().await?;
@@ -299,6 +335,29 @@ impl SessionManager {
// Main loop
loop {
// SPEC-018 (finding H): honour an SCM stop request received while the
// session is CONNECTED. The outer `run_agent` loop only observes the
// flag between connection attempts, but a managed agent spends its
// entire connected life inside THIS loop — so without this check an
// SCM Stop while connected would not break out until the connection
// dropped on its own. Breaking here falls through to the shared exit
// path below, which closes the transport cleanly (clean WS close);
// the sentinel tells the outer loop this was a graceful stop.
if stop_requested(service_shutdown) {
tracing::info!("Service stop requested; ending connected session loop");
self.release_streaming();
self.state = SessionState::Disconnected;
if let Some(transport) = self.transport.as_mut() {
// Best-effort clean WebSocket close (sends a Close frame). A
// failure here just means the peer/socket is already gone; the
// service still stops cleanly.
if let Err(e) = transport.close().await {
tracing::warn!("error during clean WebSocket close on service stop: {}", e);
}
}
return Err(anyhow::anyhow!(SERVICE_STOP_SENTINEL));
}
// Process tray events
if let Some(t) = tray {
if let Some(action) = t.process_events() {
@@ -737,3 +796,47 @@ impl SessionManager {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// SPEC-018 finding H: the connected-stop contract. When the SCM sets the
/// shutdown flag, `run_with_tray` returns an error whose message contains
/// [`SERVICE_STOP_SENTINEL`]; the outer `run_agent` loop recognises a graceful
/// stop with `error_msg.contains(SERVICE_STOP_SENTINEL)`. This pins that the
/// error the loop constructs on stop actually satisfies that match — so the
/// two halves (producer here, consumer in `main.rs`) cannot drift.
///
/// A full end-to-end test of the in-loop interrupt would need a live connected
/// transport (a real or mocked server), which is an integration concern; this
/// unit test instead pins the wire contract the interrupt relies on.
#[test]
fn service_stop_sentinel_is_matched_by_outer_loop_check() {
let produced = anyhow::anyhow!(SERVICE_STOP_SENTINEL);
assert!(
produced.to_string().contains(SERVICE_STOP_SENTINEL),
"the stop error must contain the sentinel the outer loop matches on"
);
assert!(
!SERVICE_STOP_SENTINEL.is_empty(),
"the sentinel must be a non-empty, distinctive token"
);
}
/// The shutdown-flag check is a no-op (always `false`) when no flag is passed,
/// i.e. on the attended/viewer/interactive paths — guaranteeing the new
/// parameter is a pure addition that cannot alter non-service behaviour
/// (SPEC-018 finding H: "no regression").
#[test]
fn no_shutdown_flag_never_requests_stop() {
let none: Option<&Arc<AtomicBool>> = None;
let check = |flag: Option<&Arc<AtomicBool>>| flag.is_some_and(|f| f.load(Ordering::SeqCst));
assert!(!check(none));
let set = Arc::new(AtomicBool::new(true));
assert!(check(Some(&set)));
let unset = Arc::new(AtomicBool::new(false));
assert!(!check(Some(&unset)));
}
}

View File

@@ -9,7 +9,7 @@ use tracing::{info, warn};
use windows::core::PCWSTR;
#[cfg(windows)]
use windows::Win32::System::Registry::{
RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegSetValueExW, HKEY_CURRENT_USER, KEY_WRITE,
RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_WRITE,
REG_SZ,
};
@@ -42,40 +42,39 @@ pub fn add_to_startup() -> Result<()> {
.chain(std::iter::once(0))
.collect();
// SAFETY: FFI into the Win32 registry API. `key_path`/`value_name`/`value_data`
// are NUL-terminated wide strings that outlive the calls. `RegOpenKeyExW`
// writes the opened key into `hkey`; we only use it after confirming success,
// and always pair it with `RegCloseKey`.
unsafe {
let mut hkey = windows::Win32::Foundation::HANDLE::default();
let mut hkey = HKEY::default();
// Open the Run key
// Open the Run key. RegOpenKeyExW takes a `*mut HKEY` out-param.
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR(key_path.as_ptr()),
0,
KEY_WRITE,
&mut hkey as *mut _ as *mut _,
&mut hkey,
);
if result.is_err() {
anyhow::bail!("Failed to open registry key: {:?}", result);
}
let hkey_raw = std::mem::transmute::<
windows::Win32::Foundation::HANDLE,
windows::Win32::System::Registry::HKEY,
>(hkey);
// Set the value
let data_bytes =
std::slice::from_raw_parts(value_data.as_ptr() as *const u8, value_data.len() * 2);
let set_result = RegSetValueExW(
hkey_raw,
hkey,
PCWSTR(value_name.as_ptr()),
0,
REG_SZ,
Some(data_bytes),
);
let _ = RegCloseKey(hkey_raw);
let _ = RegCloseKey(hkey);
if set_result.is_err() {
anyhow::bail!("Failed to set registry value: {:?}", set_result);
@@ -103,15 +102,19 @@ pub fn remove_from_startup() -> Result<()> {
.chain(std::iter::once(0))
.collect();
// SAFETY: FFI into the Win32 registry API. `key_path`/`value_name` are
// NUL-terminated wide strings that outlive the calls. `RegOpenKeyExW` writes
// the opened key into `hkey`; we only use it after confirming success, and
// always pair it with `RegCloseKey`.
unsafe {
let mut hkey = windows::Win32::Foundation::HANDLE::default();
let mut hkey = HKEY::default();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR(key_path.as_ptr()),
0,
KEY_WRITE,
&mut hkey as *mut _ as *mut _,
&mut hkey,
);
if result.is_err() {
@@ -119,14 +122,9 @@ pub fn remove_from_startup() -> Result<()> {
return Ok(()); // Not an error if key doesn't exist
}
let hkey_raw = std::mem::transmute::<
windows::Win32::Foundation::HANDLE,
windows::Win32::System::Registry::HKEY,
>(hkey);
let delete_result = RegDeleteValueW(hkey, PCWSTR(value_name.as_ptr()));
let delete_result = RegDeleteValueW(hkey_raw, PCWSTR(value_name.as_ptr()));
let _ = RegCloseKey(hkey_raw);
let _ = RegCloseKey(hkey);
if delete_result.is_err() {
warn!("Registry value may not exist: {:?}", delete_result);

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

@@ -16,11 +16,16 @@ stack. It ships independently of GuruRMM and integrates with it via a versioned
> match, blacklist-on-WS, agent-plane rejects user JWTs via per-agent `cak_` keys). The feature specs below
> (SPEC-003009) are **work-items inside the later v2 phases** — see the mapping.
>
> **Remaining to formally exit Phase 1:** secure-session-core **Task 8** (end-to-end verification +
> `/gc-audit --pass=security` re-audit + the manual CRITICAL checks) and Code-Review sign-off on Tasks 35
> (implemented without a local toolchain at the time; since built + deployed). Live HW-H.264 validation is
> also pending — raw+Zstd remains the shipping default. ~~Sprint 0 (relay-auth CRITICAL hotfix)~~ **not
> needed — those fixes shipped in Tasks 23.**
> **Phase 1 formally EXITED (2026-05-31).** secure-session-core **Task 8** is complete — end-to-end
> functional verification (live CRITICAL boundary checks against the deployed binary: login-JWT→401,
> wrong-session viewer token→403, JWT-as-agent-key→401) **plus the `/gc-audit --pass=security` re-audit:
> PASS, 0 CRITICAL/HIGH/MEDIUM/LOW** ([report](../reports/2026-05-31-gc-audit.md)). Code-Review sign-off on
> Tasks 35 landed earlier. On top of Phase 1, **SPEC-004 (Tasks 2/4/5 — machine_uid dedup, session
> reaping, operator removal API+UI) is implemented, reviewed, deployed, and the 11 live ghost rows were
> purged**; the agent is now **auto-versioned + Azure-Trusted-Signing-signed via `release.yml`** with
> **v0.3.0 published** as the stable release. ~~Sprint 0 (relay-auth CRITICAL hotfix)~~ **not needed.**
> Still pending (NOT a Phase-1 blocker): live HW-H.264 cross-GPU validation — **raw+Zstd remains the
> shipping default** (`DEFAULT_PREFER_H264=false`) until H.264 is validated across GPUs.
### v2 phase mapping of current specs
@@ -43,8 +48,9 @@ stack. It ships independently of GuruRMM and integrates with it via a versioned
Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](specs/SPEC-001-operational-tooling-parity.md).
- [ ] **Code signing — Azure Trusted Signing in CI** — P1 — sign the Windows agent `.exe` via `jsign` (TRUSTEDSIGNING) in Gitea Actions, reusing the shared ACG cert profile. (SPEC-001 §2)
- [ ] **Automatic versioning** — P1 — conventional-commit-driven version bump across agent/server/dashboard, embedded via `build.rs`. (SPEC-001 §3)
- [x] **Code signing — Azure Trusted Signing in CI** — P1 — Windows agent `.exe` signed via `jsign` (TRUSTEDSIGNING) in `release.yml`, fail-closed (never publishes unsigned). Shipped with v0.3.0. (SPEC-001 §2)
- [ ] **Signed beta/test release channel****P1 — NOW** — every binary we hand to a tester must be signed, but signing today only runs on a deliberate full `release.yml` dispatch; the automatic `build-and-test.yml` agent artifact is explicitly **unsigned**. Add a `channel: stable | beta` `workflow_dispatch` input to `release.yml`: `beta` signs the agent and publishes a prerelease-tagged Gitea release (e.g. `v0.4.0-beta.1`) **skipping the semver bump + changelog**; `stable` keeps the existing full path. Keeps signing secrets out of PR-triggered runs. (SPEC-001 §2)
- [x] **Automatic versioning** — P1 — conventional-commit-driven version bump computed at dispatch in `release.yml`, embedded via `build.rs`. Shipped with v0.3.0. (SPEC-001 §3)
- [ ] **Changelog generation & API** — P2 — `CHANGELOG.md` + per-version changelogs from conventional commits, served at `/api/changelog/...`. (SPEC-001 §4)
- [ ] **Feature-request workflow** — P2 — `/gc-feature-request` skill producing `docs/specs/SPEC-NNN-*.md` and updating this roadmap. (SPEC-001 §1)
- [ ] **Roadmap / ADR / spec tracking** — P1 — this file + `ARCHITECTURE_DECISIONS.md` + `docs/specs/`. (SPEC-001 §5) — *bootstrapped*
@@ -61,6 +67,11 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
- [x] Support-code (attended) and persistent (unattended) agent modes
- [x] Protobuf-over-WSS transport, Zstd frame compression
- [~] React/TS web viewer (`dashboard/src/components/RemoteViewer.tsx`) — embeddable session viewer
- [ ] **Headless Linux mode (direct TTY access)** — P2 — Terminal-based remote access for Linux servers without GUI. PTY spawn (`openpty`), xterm.js web viewer, full ANSI/VT100 support. Enables server management, container debugging, emergency recovery via GuruConnect dashboard with audit logging. SSH replacement with centralized auth. ([SPEC-012](specs/SPEC-012-headless-linux-tty.md))
- [ ] **Managed-agent SYSTEM service host + session broker** — P1 — convert the persistent agent from `HKCU Run` (user context) to a LocalSystem **service** that runs unattended (login screen, no user, across reboots) and spawns a per-session capture/input worker into the active desktop (Session 0 can't capture directly). Unblocks SPEC-016 Phase B end-to-end (the SYSTEM-ACL'd `cak_` store becomes readable; removes the Phase B fail-fast guard), enables true unattended access, and is the **broker primitive SPEC-013 builds on**. ([SPEC-018](specs/SPEC-018-managed-agent-service-host.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))
- [ ] **Private Backstage session — GUI private desktop (interactive uninstall)** — P2 — extend backstage (SPEC-013, terminal-only) to a private GUI **desktop**: run an arbitrary program (e.g. a stubborn uninstaller) on a hidden `CreateDesktop` desktop, capture it (GDI; DXGI can't target an off-screen desktop) and inject input bound to it, streamed only to the tech — the logged-on user sees nothing. Builds on SPEC-018 (service host/broker). Deep-linked from the GuruRMM "Needs remote removal" flag (RMM SPEC-030 Tier-2). ([SPEC-019](specs/SPEC-019-private-backstage-session.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)
@@ -78,12 +89,15 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
- [x] Sessions / machines / support-codes / events
- [ ] **Full machine inventory in the connection DB** — P2 — persist per-machine device inventory (OS+locale+install, CPU/RAM, mfr/model/serial, external WAN IP captured server-side + private LAN IP + MAC, logged-on user, idle, time zone, uptime, local-admin) on `connect_machines`, refreshed each `AgentStatus`, shown in the dashboard machine detail (ScreenConnect "Guest Info" parity). Data layer for SPEC-002 Phase 2; closes GC side of agent-IP gap (todo 7459428e). **[→ v2 Phase 2]** ([SPEC-003](specs/SPEC-003-machine-inventory.md))
- [ ] **Stable machine identity + session lifecycle reaping + operator removal** — P1 — give the agent a deterministic machine-derived `machine_uid` (Windows `MachineGuid`-based) so the same box can't register duplicates (root cause: `agent_id` is a config-file random UUID that a portable/misconfigured run regenerates each launch); key registration on it; add TTL reaping + same-machine supersede as defense-in-depth; and admin-gated per-row + multi-select bulk removal of stale sessions/units. Identity must be bound to the per-machine agent key (spoof guard). Fixes ghost-session accumulation seen on the live console (15 sessions / 0 live, ~10 orphans for one machine). **[→ v2 Phase 1]** ([SPEC-004](specs/SPEC-004-session-lifecycle-and-removal.md))
- [ ] **Zero-touch per-site agent enrollment** — P1 — ScreenConnect-class managed enrollment: one signed installer per site, machines self-register on first run and the server mints a per-machine `cak_` bound to a deterministic `machine_uid` (dedups re-installs). Per-site **rotatable** enrollment key (long secret + `vN (XXXX)` fingerprint) — rotating blocks new enrollments from old installers, leaves enrolled agents untouched. Auto-approve + new-enrollment/site-move alert. **Sign base agent once (CI, shipped) + per-site signed wrapper that writes site config around the signed bytes — resolves SPEC-007's signature-vs-appended-config question.** Anticipated/deferred: enrollment policy + licensing, `--enroll-key`/`--reassign` flag overrides, technician-assisted interactive install. **[→ v2 Phase 1]** ([SPEC-016](specs/SPEC-016-zero-touch-enrollment.md))
- [ ] **Machines list view — dual connection indicators + rich rows** — P2 — ScreenConnect "Access"-list parity: per-row Host/Guest two-segment connection bar (Guest=agent online, Host=viewer connected, with names + durations) and rich inline metadata (company, site, device type, tags, logged-on user + idle, client version in red when outdated). Server-enriches `/api/machines` with live session state + SPEC-003 inventory. **[→ v2 Phase 2]** ([SPEC-005](specs/SPEC-005-machines-list-view-parity.md))
- [ ] Machines "by Company" tree nav with per-company counts — P3 — left-nav grouping sidebar (screenshot parity). Follow-up sub-item of SPEC-005.
- [ ] **Universal machine search ("everything is searchable")** — P2 — server-side `?q=` on `/api/machines` matching case-insensitive substring across ALL attributes (OS, logged-on user, external/private IP, company, site, tag, serial, MAC, version, …), pg_trgm GIN-indexed; multi-term AND + optional field-scoped syntax (`os:`, `user:`, `ip:`). Replaces the hostname-only client filter. Depends on SPEC-003 (attrs must be persisted). **[→ v2 Phase 2]** ([SPEC-006](specs/SPEC-006-universal-machine-search.md))
- [ ] **Managed-agent installer builder ("Build Installer")** — P2 — dashboard wizard to build a pre-labeled persistent-agent installer (Name/Company/Site/Department/Device Type/Tag/Type) with Download / Copy URL / Send Link, reusing the existing embed-config download path; adds department + device_type to EmbeddedConfig/AgentStatus so labels persist at install time. Pairs with revocable per-machine keys; signature-vs-appended-config is the key open question. **[→ v2 Phase 2]** ([SPEC-007](specs/SPEC-007-managed-agent-installer-builder.md))
- [ ] **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; the signature-vs-appended-config question is resolved by SPEC-016 (sign-once base + per-site signed wrapper, no PE append). **[→ 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))
- [ ] **End-user (sub-user) remote access** — P2 (may be P3) — let a client pay for their employees to reach their *own* machines from home: a deny-by-default `end_user` login role, a locked-down end-user portal listing only granted machines, and Connect reusing the existing session-scoped viewer-token + relay path. Grant primitive already exists (`user_client_access`, migration 002); directory sync (AD/Entra/Google) is a separate future spec. **[→ new capability, post v2-console]** ([SPEC-017](specs/SPEC-017-end-user-remote-access.md))
- [ ] Programmatic session pre-create + viewer-token (integration contract) — P2
## Security & Infrastructure
@@ -93,5 +107,6 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
## Future Considerations
- [ ] macOS / Linux remote-control agents — P3
- [ ] **Cross-platform agent support (macOS and Linux)** — P2 — Enable remote control beyond Windows with native agents for macOS 12+ and Ubuntu 22.04+ LTS. Platform abstraction layer for capture/input/encoding, VideoToolbox (macOS) and VA-API (Linux) H.264 encoding, .app/.deb/.rpm packaging. Unblocks multi-platform MSP adoption. ([SPEC-010](specs/SPEC-010-cross-platform-agents.md))
- [ ] **Mobile agent support (iOS and Android as remote control targets)** — P3 — Native mobile apps for supervised support sessions. iOS: ReplayKit screen sharing (view-only, no input injection due to sandboxing). Android: MediaProjection screen capture + Accessibility Service input injection. Support-code authentication, push notifications (APNs/FCM), consent-first model. Foreground-only operation (OS limitation). Distinct from GuruRMM SPEC-017 (MDM/management). ([SPEC-011](specs/SPEC-011-mobile-agents.md))
- [ ] Auto-update for the agent — P3

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,244 @@
# SPEC-016: Zero-Touch Per-Site Agent Enrollment
**Status:** Proposed
**Priority:** P1
**Requested By:** Mike (2026-06-02)
**Estimated Effort:** X-Large
## Overview
Give GuruConnect a ScreenConnect-class managed-agent enrollment flow: a technician runs
**one signed installer per site** on every machine at that site — no per-machine key
minting, no flags, no typing — and each machine **self-registers** on first run, the
server minting it a per-machine `cak_` key bound to a stable, machine-derived
`machine_uid`. Each site installer carries a **rotatable per-site enrollment key** (a long
server-generated secret) plus a short human-readable **fingerprint** (`vN (XXXX)`) so an
operator can tell at a glance whether an installer is current. Rotating a site's key blocks
*new* enrollments from old installers while leaving already-enrolled machines untouched
(they hold their own `cak_`).
This is the missing piece that turns the v2 secure-session-core (SPEC-004 per-agent keys +
`machine_uid`) into a real product workflow, and it **resolves SPEC-007's open
signature-vs-appended-config question**: the agent binary is signed **once** in CI
(already shipped via `release.yml`), and per-site customization rides in a thin **signed
wrapper** that writes site config to the endpoint at install time — never appended into the
signed PE.
**Success criteria:**
1. A tech installs one site installer on N machines; all N appear in the console under the
correct company/site, each as a distinct, deduplicated machine — zero per-machine setup.
2. Re-installing / re-imaging the same hardware **reuses** the existing machine row (no
ghost duplicates — the failure mode SPEC-004 documents).
3. Rotating a site's enrollment key makes old installers unable to enroll new machines,
while every already-enrolled agent keeps working.
4. Every distributed installer is **validly Authenticode-signed** (SmartScreen/WDAC clean).
## Background — what exists today (confirmed in code)
- **Embedded config is append-based and breaks signing.** `server/src/api/downloads.rs`
(`download_agent`, ~`:152`) reads `static/downloads/guruconnect.exe` and **appends**
`MAGIC_MARKER` + `len:u32` + JSON (`:196`) to the end of the PE. The agent reads it back
in `agent/src/config.rs` (`read_embedded_config`, `:223`). Appending bytes after a signed
PE invalidates the Authenticode signature — so the current customization path and the
newly-shipped CI signing are mutually exclusive.
- **No self-registration exists.** Per-agent `cak_` keys are minted **admin-only** in
`server/src/api/machine_keys.rs` (`create_key`, `:119`; "Admin issued a per-agent key",
`:146`). There is no endpoint where an agent first-run exchanges an enrollment credential
for its own key.
- **Relay already accepts per-agent keys.** `server/src/relay/mod.rs`
(`validate_agent_api_key`, `:417`) calls `crate::auth::agent_keys::verify_agent_key`
(`:422`) — the `cak_` path — then falls back to the **deprecated** shared `AGENT_API_KEY`
(`:444`, logs a "migrate to per-agent `cak_`" warning).
- **Key primitives exist.** `server/src/auth/agent_keys.rs`: `generate_agent_key` mints a
`cak_`-prefixed high-entropy key (`:36`/`:46`); `verify_agent_key` (`:71`).
`server/src/db/agent_keys.rs` already inserts into `connect_agent_keys (machine_id,
key_hash, tenant_id)` (`:47`) — the v2 tenancy column is present (migration
`004_v2_secure_session_core.sql`).
- **Identity is a random config UUID, not machine-derived** — the root cause of duplicates
per SPEC-004 (`agent/src/config.rs` `generate_agent_id`, `:90`).
- **Agent mode dispatch:** `agent/src/main.rs` `Commands::Install` (`:160`) → `run_install`;
`agent/src/config.rs` `detect_run_mode` (`:162`) returns `RunMode::PermanentAgent` when
embedded config is present.
## Scope
### Included in v1 (CORE)
1. **`machine_uid` — deterministic machine identity (hardware-salted, per-tenant).** Derive
a stable id from the Windows `MachineGuid`
(`HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`) **salted with stable hardware
signals** (SMBIOS UUID / motherboard + disk serial), independent of the config-file
`agent_id`. Hardware-derived salt is deliberate: it **survives an OS reinstall/re-image
on the same hardware** (so the row is reused — the re-image dedup goal) while keeping
distinct physical boxes distinct (a per-install *random* salt would break re-image dedup
and is rejected). Uniqueness is scoped **per-tenant** — dedup key `(tenant_id,
machine_uid)` — so the same hardware legitimately present in two tenants stays two
independent rows. (Shared root with SPEC-004; whichever lands first owns the impl, the
other consumes it.) Used as the dedup key for register/move.
**Collision-gated activation.** The residual collision case is VMs/templates that share a
hardware UUID (some hypervisors clone the SMBIOS UUID). When the server detects a
`machine_uid` collision (a seemingly-different endpoint resolving to an existing uid), the
endpoint does **not** auto-activate: it drops to a **pending** state, fires an alert, and
an operator must confirm in the dashboard that the collided endpoint may activate. This is
the one deliberate exception to auto-approve (see item 6).
2. **Per-site enrollment key + fingerprint.**
- Long (≥256-bit) server-generated secret per site, stored **hashed** (Argon2id, same
as `cak_`/passwords), never recoverable in plaintext after issue.
- A non-secret **fingerprint** = monotonic version + short derived code in **hex**,
rendered `vN (XXXX)` (e.g. `v3 (7F2A)`), shown in the dashboard, baked into the
installer filename, and reported by the agent at enrollment. Hex is deliberate —
**not** the RMM word-style code (`GREEN-FALCON`) — so GuruConnect and GuruRMM
artifacts are never visually conflated.
- **Rotate** regenerates the secret and bumps the version; old installers are rejected
for *new* enrollments; existing agents (holding `cak_`) are unaffected.
3. **Self-registration endpoint.** New `POST /api/enroll` (public, unauthenticated by JWT —
gated by the enrollment key) accepting `{ site_code, enrollment_key, machine_uid,
hostname, labels{company,site,department,device_type,tags} }`:
- Verify `(site_code, enrollment_key)` against the current per-site key.
- **Dedup by `machine_uid`** within the site: if the machine exists, reuse the row and
rotate its `cak_`; else create the machine row.
- Mint a `cak_` (reuse `generate_agent_key`), store hashed via `db::agent_keys` bound to
`machine_id` (+ `tenant_id` from the site), return the plaintext `cak_` **once**.
- Emit an audit event + **new-enrollment alert** (and a **site-move** alert when an
existing `machine_uid` enrolls under a different site).
- **Rate-limit + lockout** per `(site_code, source-IP)` as defense-in-depth (the key is
long, so this is belt-and-suspenders, not load-bearing).
4. **Agent first-run enrollment.** On `RunMode::PermanentAgent` with no stored `cak_`:
read site config → call `/api/enroll` with `machine_uid` → persist the returned `cak_`
to a SYSTEM-only protected store (HKLM under a SYSTEM-only ACL, or DPAPI-machine) →
connect to `wss://connect.azcomputerguru.com/ws/agent` using the `cak_`. On subsequent
runs, use the stored `cak_` directly (no re-enroll).
5. **Sign-once base + per-site signed wrapper (resolves SPEC-007 open question).**
- The base agent is signed once in CI (`release.yml`, already shipped) and stays
byte-identical for everyone.
- Per-site customization (labels + enrollment key + fingerprint) is delivered to the
endpoint **at install time** via a signing-safe channel — NOT appended to the signed
PE. **v1 produces BOTH a signed bootstrapper `.exe` and a signed MSI per site**
(ScreenConnect parity — manual installs grab the `.exe`, GPO/Intune fleet pushes take
the MSI), both wrapping the same sign-once agent and writing the site config to the
protected config location. The two differ only in packaging (bootstrapper stub vs. WiX
bundle); both are signed.
- **Deprecate the append path** in `downloads.rs` for managed installs (keep only for
attended/support-code if still needed), eliminating the signature-invalidation defect.
6. **Auto-approve posture (with collision-gate exception).** A self-registered machine is
live and controllable immediately (ScreenConnect parity); the new-enrollment alert is the
tripwire. The **one** exception is a detected `machine_uid` collision (item 1), which
gates the endpoint to **pending** until an operator confirms it in the dashboard.
### Explicitly out of scope (ANTICIPATED — reserve room, do NOT build in v1)
The v1 data model and agent mode-dispatch must leave room for these without building them:
- **Per-site enrollment POLICY** — a `sites.enrollment_policy` field (default
`auto-approve`; future `pending-approval`) plus per-seat/per-endpoint licensing controls.
Commercial, multi-tenant (the `tenant_id` column already exists). Its own future SPEC.
- **Flag overrides** — `--enroll-key` / `--site-code` (generic installer, key supplied on
the command line) and `--reassign` (move an existing machine to a new site, gated by
possession of the destination site's key, with an **explicit accidental-move guard**:
a different-site re-run refuses unless `--reassign` is passed) + cross-client move policy.
Backend (`machine_uid` + authorized site + `cak_`) is designed to support it; CLI surface
is deferred.
- **Technician-assisted interactive install** — `--technician` on a generic installer:
prompts for the tech's own server credentials, and on auth presents a **validated**
Company/Site/tags picker from the live authorized list (authz-by-identity, full audit
trail). Heaviest path (interactive UI + auth/list callback); deferred.
All three converge on the **same backend operation** delivered in v1: `machine_uid` +
authorized site + issued `cak_`. v1 only ships the per-site-embedded-key door.
## Architecture
- **Agent** (`agent/`): compute `machine_uid`; first-run enroll → store `cak_`; use stored
`cak_` thereafter; read site config from the wrapper-written location instead of an
appended PE blob. Touches `config.rs` (`EmbeddedConfig`/`detect_run_mode`/storage),
`main.rs` (`Install`/run-mode), a new `enroll` client module, transport auth.
- **Relay-server** (`server/`): new `POST /api/enroll`; per-site key issue/rotate/verify;
`machine_uid` dedup + site-move on register; audit + alert emission; rate-limit/lockout.
Touches `api/` (new `enroll.rs`, `sites` key endpoints), `auth/agent_keys.rs`,
`db/agent_keys.rs`, `relay/mod.rs` (enrollment vs. connect), `main.rs` routes.
- **Dashboard**: per-site enrollment-key display (fingerprint `vN (XXXX)`), **Rotate**
action, "current installer" download wired to the signed wrapper build. (Builder UI is
SPEC-007; this spec supplies the key/fingerprint/rotation it consumes.)
- **DB migration:** `site_enrollment_keys` (or columns on the site): `site_id`,
`key_hash`, `version`, `fingerprint`, `created_at`, `rotated_at`, `active`. Reserve
`sites.enrollment_policy` (nullable, default `auto-approve`) for the anticipated policy
work. `connect_machines` gains `machine_uid` (unique per tenant/site).
- **Protobuf** (`proto/guruconnect.proto`): no wire change required for enrollment if
`/api/enroll` is REST; `AgentStatus` label fields per SPEC-007 (`department`,
`device_type`) ride along if landed together.
## Security considerations
- **Two-tier credential model:** low-sensitivity **enrollment key** (gates "may register",
shared per site, rotatable) vs. high-sensitivity **per-machine `cak_`** (operating
credential, per-machine revocation). Compromise of an enrollment key is recovered by
rotating one site — no fleet-wide re-key.
- **Enrollment keys stored hashed** (Argon2id); plaintext shown once at issue/rotate.
- **`cak_` at rest on the endpoint** is stored as a **DPAPI-machine-encrypted blob inside a
SYSTEM-ACL'd location** (HKLM value or `ProgramData` file) — both layers: the SYSTEM ACL
stops non-admin users reading it, and DPAPI-machine encryption makes a copied file/export
inert off the box. (Local admin/SYSTEM can always recover it; that is accepted — blast
radius of one leaked `cak_` is a single, independently-revocable machine.)
- **`machine_uid` binding** is the spoof-guard SPEC-004 wants: a `cak_` is bound to a
`machine_uid`; a different box presenting another box's `cak_` is detectable.
- **Authorization model** for moves/enrolls is possession-of-destination-key in v1
(identity-based authz deferred to the technician-assisted path).
- **Open registration risk** is mitigated by requiring `(site_code + long key)` and
rate-limit/lockout; auto-approve is acceptable because the enrollment key is the gate and
every enrollment/site-move fires an alert.
- **Audit events:** enroll, re-enroll/reuse, site-move, key-rotate — all logged with
`machine_uid`, site, and source IP.
## Testing strategy
- **Unit:** `machine_uid` derivation stability; enrollment-key verify/rotate; fingerprint
derivation; `cak_` mint/hash/verify; dedup decision (new vs. reuse vs. move).
- **Integration:** enroll new → row + `cak_` issued; re-enroll same `machine_uid` → reuse,
no duplicate; enroll with rotated (old) key → rejected; old `cak_` still connects after
rotation; rate-limit/lockout trips; site-move emits alert.
- **Manual:** build a site wrapper installer → run on a clean VM → appears in console under
correct site, immediately controllable; re-image VM → same row reused; `signtool verify
/pa` passes on the distributed wrapper and the laid-down agent.
## Effort estimate & dependencies
- **Size:** X-Large (agent + relay + DB migration + CI build/sign wrapper + dashboard
key/rotation surface).
- **Depends on:** SPEC-004 `machine_uid` (shared root); the CI signing already shipped
(SPEC-001 §2 / `release.yml`).
- **Unblocks:** SPEC-007 (installer builder gets a real per-site key + the signing
resolution), and the parked managed-agent test deployment on the internal beta machines.
- **Relationship to v2 phases:** sits with the Phase-1 secure-session-core (per-agent keys
+ identity) and feeds Phase-2 dashboard work.
## Resolved decisions (2026-06-02, Mike)
1. **Wrapper shape — BOTH.** v1 ships a signed bootstrapper `.exe` *and* a signed MSI per
site (ScreenConnect offers both; manual installs use the `.exe`, GPO/Intune fleet pushes
use the MSI). Same sign-once agent inside each.
2. **`cak_` storage — BOTH layers.** DPAPI-machine-encrypted blob stored in a SYSTEM-ACL'd
location. Non-admins can't read it; a stolen copy is inert off the box.
3. **Fingerprint — hex (`7F2A`).** Deliberately *not* the RMM word-code style, so the two
products' artifacts are never visually conflated.
4. **`machine_uid` — per-tenant scope, hardware-derived salt, collision-gated.** Dedup key
`(tenant_id, machine_uid)`; salt from stable hardware signals (survives same-hardware
re-image, separates distinct boxes); detected collisions (e.g. template-cloned VMs
sharing a hardware UUID) drop to pending + alert and require dashboard confirmation to
activate.
5. **Attended (support-code) path — unchanged.** `download_support` is filename-based
(`GuruConnect-<code>.exe`), not append-based, so renaming never breaks the signature —
it is already signing-safe. Only the managed `download_agent` append path is retired.
## Remaining for planning
- Exact stable-hardware signal set for the salt (SMBIOS UUID alone vs. + motherboard/disk
serial) and hypervisor behavior matrix (which hypervisors duplicate the SMBIOS UUID on
clone → exercise the collision-gate).
- MSI authoring approach (WiX) and whether per-site config rides as a per-site MSI vs. a
base MSI + property/transform.

View File

@@ -0,0 +1,180 @@
# SPEC-017: End-User (Sub-User) Remote Access
**Status:** Proposed
**Priority:** P2 (may settle to P3 depending on client demand)
**Requested By:** Mike (2026-06-02)
**Estimated Effort:** Large
## Overview
Let a client pay for their own employees to remotely reach **their own work machines** from home
through GuruConnect — the Splashtop-Business / unattended-end-user-access model, layered on top of the
MSP-technician console GuruConnect ships today. An MSP admin (or, later, a delegated client-company
admin) provisions a list of **end-users** and grants each one access to specific managed machines. The
end-user signs into a locked-down **end-user portal**, sees only the machines granted to them, and
connects — reusing the existing persistent-agent + session-scoped-viewer-token + relay path.
Success criteria: an `end_user`-role account can log in at a separate portal, see exactly the machines
in its grant set (and no others, across no other tenant), launch a control session to an online granted
machine, and is hard-denied from every technician/admin API, the agent plane, and any machine it was
not granted — with each login and machine access written to the audit log.
This is a net-new **sellable capability**, not a console-MVP blocker. It is sequenced after the v2
console foundations it depends on (tenancy, machine identity, persistent enrollment), which is why it is
P2 rather than P1.
## Scope
### Included in v1
- A new **`end_user`** value for `users.role`, provisioned by an MSP admin, with **deny-by-default**
authority: no console permissions, no agent-plane access, machine reach limited strictly to its
`user_client_access` grant set within its own tenant.
- A **separate end-user login + portal** route (locked-down): lists only granted machines with
online/offline state and a Connect action. No admin nav, no other users/machines/companies.
- **Admin UI + API** to create/disable end-users and assign/revoke per-machine grants, reusing the
existing `user_client_access` table.
- **Connect flow** that reuses the landed session-scoped viewer-token mechanism (`ViewerClaims`,
`jwt.rs:114`) and the relay enforcement path — no new transport.
- A new `connect_sessions.source` value **`end_user`** (migration widening the existing CHECK).
- **Audit**: end-user login success/failure and each machine-access grant-check written to
`connect_session_events`.
- Rate limiting + lockout on the public end-user login.
### Explicitly out of scope (v1)
- **Directory sync (AD / Entra-365 / Google) → end-user list** — its own future spec; v1 is manual
list management only.
- **Self-service seat purchasing / billing automation.** v1 records/counts seats per tenant; real
metering and Syncro/billing wiring is deferred.
- **Delegated client-company-admin role** (a client managing its own end-users/grants) — noted as a
fast-follow; v1 grants are MSP-admin-managed.
- Per-session view-only-vs-control *policy* per end-user (v1 = Control of one's own machine; the
`ViewerAccess` split still exists at the token layer).
- File transfer, session recording (already out of scope for the broader product v1).
## Architecture
### Principal model — `end_user` is a constrained variant of the login plane
GuruConnect already has three credential planes that must stay separate (audit-hardened in v2 Phase 1):
1. **Login `Claims`** (`jwt.rs:11`) — dashboard users; `role ∈ {admin, operator, viewer}` today.
2. **Session-scoped `ViewerClaims`** (`jwt.rs:114`) — 5-min, one session, `purpose=viewer`.
3. **Agent `cak_` keys** (`connect_agent_keys`, migration 004) — agents only.
`end_user` is added as a **fourth role on the login plane** — it issues a normal login JWT
(`create_token`, `jwt.rs:161`) carrying `role: "end_user"` and an **empty permission list**. The
separation guarantees the v2 audit established are preserved: an `end_user` JWT still cannot be used as
a viewer token (lacks `purpose`) nor as an agent key (agent plane rejects user JWTs).
**Critical authz inversion:** `user_client_access` today documents "no entries = access to all (for
admins)" (migration 002, line 25-26). The grant check **must branch on role** — for `end_user`, an
empty grant set means **zero** machines, never all. Authz is deny-by-default and grant-scoped; the
admin-bypass in `Claims::has_permission` (`jwt.rs:28-33`) must never fire for `end_user`.
### Agent / Relay-server / Viewer / Dashboard responsibilities
- **Agent:** no changes. End-users connect to existing **persistent/unattended** managed agents
(consent `not_required` — it is the user's own machine). Optionally honors the SPEC-015 notification
overlay if a per-machine policy requires it.
- **Relay-server:** no transport change. New end-user auth + portal + connect endpoints; the
grant-check + viewer-token mint is the only new server logic on the hot path.
- **Viewer:** reuse the React/TS web viewer (`dashboard/src/components/RemoteViewer.tsx`) — the
end-user portal embeds the same component with a Control-mode viewer token.
- **Dashboard:** new **role-gated end-user portal** route (recommended separate from the technician
console — see Open Questions), plus admin screens for end-user + grant management.
### Database (migrations)
- **`user_client_access`** — reused as the grant table; no schema change (already
`user_id UUID × client_id UUID → connect_machines(id)`, unique pair, migration 002).
- New migration `011_end_user_access.sql`:
- Widen `connect_sessions.source` CHECK to `('standalone','gururmm','end_user')` (currently
`('standalone','gururmm')`, migration 004 line 99-102).
- Optional `users` columns for the external principal: `mfa_secret TEXT NULL`,
`must_change_password BOOLEAN NOT NULL DEFAULT false`, and a partial index for fast
`role='end_user'` listing per `tenant_id`.
- (Seat tracking, if landed in v1: a lightweight per-tenant `end_user` count view or a
`tenant_seats` row — kept minimal.)
- Grants are tenant-contained: insert path validates `machine.tenant_id == end_user.tenant_id`.
### API endpoints / WS messages
- `POST /api/enduser/auth/login` — public, rate-limited; returns an `end_user` login JWT.
- `GET /api/enduser/machines` — lists only the caller's granted, in-tenant machines + presence.
- `POST /api/enduser/machines/:id/connect` — grant-checked; creates a `source=end_user` session and
mints a Control `ViewerClaims` token (`create_viewer_token`, `jwt.rs:233`) for that session.
- Admin: `POST /api/users` (role=end_user), `POST /api/users/:id/grants`,
`DELETE /api/users/:id/grants/:machine_id`, `GET /api/users?role=end_user`.
- No new protobuf messages — the WS viewer path and `guruconnect.proto` are unchanged.
## Implementation details
- `server/src/auth/jwt.rs` — extend the role vocabulary doc (`Claims.role`, line 16-17); add an
`is_end_user()` helper and ensure `has_permission` cannot grant `end_user` anything beyond explicit
permissions (the admin short-circuit at line 30 must be guarded).
- `server/src/auth/mod.rs``AuthenticatedUser` (line 29+) gains role-aware helpers; add an extractor
/ middleware that rejects non-`end_user` on the `/api/enduser/*` namespace and rejects `end_user` on
every console/admin route (deny-by-default allowlist).
- `server/src/api/` — new `enduser` handler module (login, machines, connect); admin user+grant
handlers extended for `role=end_user` and `user_client_access` writes.
- Grant check (shared fn): `machine_id ∈ user_client_access[user] AND machine.tenant_id == user.tenant_id`;
used by both `GET /machines` and `connect`.
- Session create stamps `source='end_user'`, `is_managed=true`/unattended, `consent_state='not_required'`,
then mints the viewer token via the existing path so relay enforcement is unchanged.
- `dashboard/src/` — end-user portal route (role-gated), reusing `RemoteViewer.tsx`; admin grant-matrix
UI. White-label (SPEC-014) applies to the portal as the most client-facing surface.
- Migration `server/migrations/011_end_user_access.sql` as above (idempotent; applied by
`sqlx::migrate!` per the migration standard).
## Security considerations
- **Preserve the plane separation** audited in v2 Phase 1 — `end_user` is login-plane only; it can
never satisfy `validate_viewer_token` or the agent `cak_` path.
- **Deny-by-default, grant-scoped:** empty `user_client_access` for an `end_user` = no access; the
admin-bypass must not apply. Every `/api/enduser/*` call re-checks the grant + tenant server-side
(never trust a machine id from the client).
- **Tenant containment:** an `end_user` and its grants live in one tenant; cross-tenant grants are
rejected at write and re-validated at connect. (Full tenant isolation lands with Phase 4; v1 enforces
via explicit `tenant_id` equality checks.)
- **External-user trust:** these accounts are public-internet-facing from home. Require
rate-limiting + lockout on `/api/enduser/auth/login`; support (recommend require) **TOTP MFA** for
`end_user` — schema column included so MFA can be v1 or an immediate fast-follow without a second
migration. Argon2id passwords (existing standard).
- **Audit:** log each end-user login (success/failure, source IP) and each machine access to
`connect_session_events`; the unattended access is to the user's *own* machine but must be fully
traceable. Optionally enforce the SPEC-015 overlay per machine policy.
- **Threat model:** stolen end-user creds reach only that user's granted machines (blast radius =
grant set), never the console, never the agent plane, never another tenant. Disabling the account
(`users.enabled=false`) immediately revokes portal + future tokens; the 5-min viewer-token TTL bounds
any in-flight session.
## Testing strategy
- **Unit:** grant-check fn (granted / not-granted / cross-tenant / empty-set-for-end_user = deny);
`has_permission` never elevates `end_user`; role-namespace middleware (end_user→console = 403,
technician→/api/enduser = 403).
- **Integration:** end-user login → list shows only granted machines → connect mints a Control viewer
token for a `source=end_user` session → relay admits; connect to a non-granted / other-tenant machine
→ 403; disabled account → login + token use rejected.
- **Manual:** full portal walkthrough from an off-network browser; MFA enrol + challenge; audit rows
present for login and access; white-label branding renders on the portal.
## Effort estimate & dependencies
- **Size:** Large (new principal + portal + admin grant UI + auth namespace; transport/agent untouched
and the grant table already exists, which holds it below X-Large).
- **Depends on (must precede / strongly preferred):**
- **Tenancy** (`tenants` + `tenant_id`, migration 004) — needed for containment; full isolation is
Phase 4 but v1 uses explicit tenant checks.
- **Stable machine identity + persistent enrollment** (SPEC-004 / 008 `machine_uid`, SPEC-016
zero-touch `cak_`) — end-users reach persistent managed agents.
- **Session-scoped viewer tokens** (v2 Phase 1, landed) — reused directly.
- **Pairs with:** SPEC-014 (white-label — the portal is the client-facing surface), SPEC-003/005
(machine inventory/list — portal machine rows), SPEC-015 (optional connect-notification overlay).
- **Unblocks:** the directory-sync spec (AD/Entra/Google → end-user list), delegated client-admin role,
and per-seat billing — all of which build on the `end_user` principal defined here.
## Open questions
1. **Same console vs separate end-user portal?** Recommendation: **separate, role-gated route**
smaller attack surface, no risk of leaking technician controls, cleaner white-label. Confirm before
build.
2. **End-users in the existing `users` table (role=end_user) vs a dedicated `end_users` table?**
Recommendation: reuse `users` (the grant FK `user_client_access.user_id` already points there) with
hard role guardrails. Revisit if mixing external + internal principals in one table proves risky.
3. **MFA in v1 or immediate fast-follow?** Schema is included either way; decide enforcement timing.
4. **Who administers grants in v1** — MSP admin only (assumed), or ship the delegated client-company
admin role together? (Affects scope/effort materially.)
5. **Seat/licensing enforcement depth for v1** — count-and-display vs hard-cap vs billing-integrated.
6. **Default access mode** — Control assumed (own machine); should an admin be able to pin a machine to
view-only for a given end-user? (Token layer already supports it.)

View File

@@ -0,0 +1,146 @@
# SPEC-018: Managed-Agent SYSTEM Service Host + Session Broker
**Status:** Proposed
**Priority:** P1 (blocks SPEC-016 Phase B end-to-end runtime and SPEC-013)
**Requested By:** Mike (2026-06-02)
**Estimated Effort:** X-Large
## Overview
Convert the managed/persistent GuruConnect agent from a user-context `HKCU\…\Run` autostart into a
**Windows SYSTEM service** that runs unattended — at the login screen, with no user logged in, across
reboots — and **brokers per-session capture/input worker processes** into the active interactive
desktop. A SYSTEM service lives in the isolated **Session 0** and cannot capture or inject the
interactive desktop directly, so the service spawns a worker into the target user session (the
ScreenConnect architecture).
This is foundational, not cosmetic. It unblocks three things at once:
1. **SPEC-016 Phase B end-to-end runtime** — the per-machine `cak_` store is ACL'd to SYSTEM +
Administrators; today the agent runs as the interactive *user* and can't read its own store (the
Phase B C1 *fail-fast guard* exists precisely because of this). Running as SYSTEM makes the store
readable and removes the guard.
2. **True unattended access** — a user-context agent only runs while that user is logged in. Reaching
a rebooted server or a machine sitting at the login screen (table-stakes for remote support)
requires SYSTEM.
3. **SPEC-013 session selection / backstage** — the session-broker primitive built here is the
substrate SPEC-013's session-switching UX drives.
**Success criteria:** the managed agent installs as an auto-start SYSTEM service; it holds the relay
connection and performs SPEC-016 enrollment as SYSTEM (reading/writing the SYSTEM-ACL'd `cak_`); it
spawns a capture/input worker into the active interactive session and relays frames; the worker is
respawned/retargeted on logon/logoff/console-connect; and the Phase B fail-fast guard is removed
because the store is now readable in-context.
## Background — why this is needed (confirmed in code)
- The persistent agent autostarts via `HKCU\…\Run` (`agent/src/startup.rs:21`, `STARTUP_KEY` = HKCU)
→ interactive-user token, not SYSTEM. The only SYSTEM service today is the separate `sas_service`
(Secure Attention Sequence helper).
- SPEC-016 Phase B (`agent/src/credential_store.rs`) ACLs the `cak_` store to `*S-1-5-18` (SYSTEM) +
`*S-1-5-32-544` (Administrators). In the current user context the agent writes but cannot read it
back → the Phase B fail-fast guard (`agent/src/main.rs` `resolve_agent_credential`) emits
"must run as the GuruConnect SYSTEM service (see SPEC-018)" instead of bricking.
- Capture/input live in the agent process (`agent/src/capture/`, `agent/src/input/`); a Session-0
SYSTEM service cannot drive these against the interactive desktop without a per-session worker.
## Scope
### Included in v1
1. **Windows service install/lifecycle** (`agent/src/install.rs` + a new service module): register the
managed agent as a **LocalSystem auto-start service** (`CreateServiceW` / a service crate),
configure failure/recovery (restart on crash), and **replace the HKCU `Run` autostart for managed
mode** (remove the Run entry on service install). Clean uninstall (stop + delete service).
2. **Service control loop** (Session 0, SYSTEM): owns the persistent WSS connection to the relay,
performs SPEC-016 enrollment as SYSTEM (now able to read/write the `cak_` store), and dispatches
session/connect requests to workers. Handles `SERVICE_CONTROL_STOP`/`SHUTDOWN` and
`SERVICE_CONTROL_SESSIONCHANGE`.
3. **Session broker:** enumerate sessions (`WTSEnumerateSessionsW`), resolve the active interactive
session (`WTSGetActiveConsoleSessionId`), obtain its user token (`WTSQueryUserToken`
`DuplicateTokenEx`), and spawn a **per-session capture/input worker** into that session's desktop
(`CreateProcessAsUserW`, `winsta0\default`). The worker does DXGI capture + input injection in the
user's session; the service relays frames over the existing transport.
4. **Service ↔ worker IPC:** a local, ACL'd channel (named pipe `\\.\pipe\guruconnect-<sessionId>`)
carrying frames/input/control; pipe ACL restricted to SYSTEM + the target session user.
5. **Session-change handling:** on logon/logoff/console-connect/disconnect/lock/unlock, (re)spawn or
retarget the worker so the active desktop is always the one being served.
6. **Remove the SPEC-016 Phase B fail-fast guard** once the service runs as SYSTEM (the store is
readable in-context); keep the SYSTEM+Administrators ACL.
### Explicitly out of scope (anticipated, separate specs)
- **Session-selection / backstage UX** — the operator-facing picker and Session-0/secure-desktop
command surface are **SPEC-013**; this spec only provides the broker primitive it drives.
- **Login-screen / secure-desktop (winlogon) capture** beyond the broker hook — the hard
Secure-Desktop case is coordinated with SPEC-013; v1 here targets the active interactive session.
- **macOS/Linux service equivalents** — future SPEC-010 (cross-platform agents).
## Architecture
- **Agent splits into two roles:**
- **service-host** (LocalSystem, Session 0): service lifecycle, relay transport, SPEC-016
enrollment + `cak_` store, session broker, IPC server.
- **session-worker** (per interactive session, user token): DXGI/GDI capture, input injection,
IPC client. Spawned by the service via `CreateProcessAsUserW`.
- **Service install** (`install.rs`): `CreateServiceW` with `SERVICE_AUTO_START`, `SERVICE_WIN32_OWN_PROCESS`,
recovery actions; uninstall stops + deletes. Replaces managed-mode `HKCU Run`.
- **Token handoff:** `WTSGetActiveConsoleSessionId``WTSQueryUserToken``DuplicateTokenEx`
(primary token) → `CreateProcessAsUserW` with `lpDesktop = "winsta0\\default"`.
- **IPC:** named pipe per session, length-prefixed protobuf (reuse `proto/` message types where
sensible), pipe security descriptor granting only SYSTEM + the session user.
- **Session events:** the service registers for `SERVICE_CONTROL_SESSIONCHANGE` and reacts to
`WTS_CONSOLE_CONNECT`, `WTS_SESSION_LOGON/LOGOFF`, `WTS_SESSION_LOCK/UNLOCK`.
## Security considerations
- **LocalSystem is maximal privilege** — minimize the service's attack surface; validate every
relay-delivered command; never spawn a worker except into a legitimately-enumerated active session.
- **IPC pipe must be ACL'd** (SYSTEM + the specific session user only) so a non-admin user can't
inject capture/input commands by connecting to the pipe.
- **Token hygiene:** close duplicated tokens promptly; don't leak SYSTEM or user primary tokens.
- The SPEC-016 `cak_` store (SYSTEM-ACL'd) is now correctly readable; the fail-fast guard is removed
but the ACL stays.
- **Audit:** service start/stop, enrollment-as-SYSTEM, worker spawn, session attach/retarget — written
to the existing event pipeline.
## Implementation details
- New service module (e.g. `agent/src/service/{mod.rs, broker.rs, ipc.rs}`); worker entry split out of
the current capture path. New `Commands` variants or an internal `--service`/`--session-worker`
dispatch in `agent/src/main.rs`.
- `install.rs`: service create/recovery/delete; drop the managed-mode HKCU `Run` write.
- `windows` crate features: `Win32_System_Services`, `Win32_System_RemoteDesktop`
(`WTS*`), `Win32_Security`, `Win32_System_Threading` (`CreateProcessAsUserW`),
`Win32_System_Pipes`.
- Remove the `resolve_agent_credential` fail-fast guard branch added in SPEC-016 Phase B.
## Testing strategy
- **Service:** install → auto-start on boot → stop → uninstall on a clean VM.
- **`cak_` end-to-end:** SYSTEM service enrolls (SPEC-016), stores + reads the `cak_`, connects — the
integration test SPEC-016 Phase B currently cannot run.
- **Session broker:** worker spawns into the active session; capture/input work; survives logoff→logon
(respawn) and console-connect (retarget); fast-user-switch retarget.
- **Security:** non-admin cannot connect to the IPC pipe; worker runs with the user's token (not
SYSTEM) in the user's desktop.
## Effort estimate & dependencies
- **Size:** X-Large (service host + worker split + token-handoff + IPC + session-change handling +
install/uninstall).
- **Depends on:** SPEC-016 (enrollment + `cak_` store); the existing capture/input cores.
- **Unblocks:** SPEC-016 Phase B end-to-end runtime (and the parked managed-agent enrollment test on
the internal beta machines); **SPEC-013** (session selection builds on this broker).
## Open questions
1. **Service vs. SYSTEM scheduled task** — a true Windows service (recovery, SCM integration) is the
standard, robust choice; recommend service. Lock in planning.
2. **One multi-session worker vs. one worker per session** — per-session worker is simpler to reason
about and isolates a crash to one session; confirm.
3. **IPC transport** — named pipe (recommended) vs. local TCP/loopback; pipe ACLing is the cleaner
security story.
4. **Login-screen / Secure-Desktop capture** — how much (if any) in this spec vs. deferred to SPEC-013
(it needs a worker in the winlogon/secure desktop, a distinct hard problem).
5. **Migration** — on upgrade, cleanly transition existing HKCU-`Run` managed installs to the service
(remove the Run entry, install the service) without a gap.

View File

@@ -0,0 +1,119 @@
# SPEC-019: Private Backstage Session (interactive uninstall / hidden desktop)
**Status:** Proposed
**Priority:** P2
**Requested By:** Howard (2026-06-22)
**Estimated Effort:** Large
## Overview
A **private session** mode for GuruConnect: the tech gets an interactive remote view of a
process's UI on the target machine that the **logged-on user does not see**. The motivating use
case is GuruRMM's Tier-2 software removal — when a program has no silent uninstall (flagged
`needs_remote` by the RMM removal engine), the tech opens a Backstage session, the stubborn
uninstaller's GUI runs on a **private desktop**, and the tech clicks through it remotely while the
end user's screen is undisturbed. This is GuruConnect's equivalent of ScreenConnect Backstage.
**Success criteria:**
- A tech can launch a program (e.g. an uninstaller) on a target and drive its UI remotely.
- The logged-on user does not see the window or the interaction (private-desktop mode).
- Works with no interactive user logged in (system-spawned private desktop).
- Deep-linkable from the GuruRMM "Needs remote removal" flag.
## The Windows constraint (why this is non-trivial)
A GUI process needs an interactive **window station + desktop**. The agent runs as SYSTEM in
**Session 0**, which is isolated and cannot present UI. So GuruConnect must either (a) run the
target process on the **active user's desktop** and mirror it (visible to the user), or (b) create
a **separate private desktop** (`CreateDesktop`) and stream only that desktop to the tech
(invisible to the user). (b) is the Backstage model.
## Scope
### Included in v1
- **Private-desktop capture/input:** capture a *specific* desktop's framebuffer and inject
input into it (not just the active console desktop), via a dedicated window station/desktop the
agent creates (`CreateWindowStation`/`CreateDesktop` + `SetThreadDesktop` on the capture/input
threads).
- **Launch-process-in-session:** start an arbitrary command (the uninstaller) bound to that
private desktop, as SYSTEM or as a chosen user token.
- **Backstage viewer mode** in the web/native viewer: a session flagged "private" with a clear
"user cannot see this" indicator.
- **GuruRMM deep link:** accept a launch target (program path/uninstall string) so the RMM
`needs_remote` action opens straight into a Backstage session pointed at that program.
### Explicitly out of scope
- Mirroring the *active user* desktop — that is ordinary remote control (already covered); this
spec is specifically the **private/hidden** desktop.
- Logging in a brand-new interactive user session (secondary-logon) — heavier and fragile;
considered in Future Considerations only.
- The RMM-side removal engine + tracking (lives in GuruRMM SPEC-030; this is the GC counterpart).
## Architecture
- **agent (Windows):** new private-desktop subsystem — create a window station + desktop, run the
capture loop with `SetThreadDesktop` bound to it (DXGI won't capture an off-screen desktop, so
fall back to GDI `BitBlt`/`PrintWindow` for the private desktop), inject input with
`SendInput`/`PostMessage` targeted at that desktop's windows. Process launch via
`CreateProcessAsUser`/`CreateProcess` with `STARTUPINFO.lpDesktop` set to the private desktop.
- **relay-server (Axum):** a new session kind `private`/`backstage`; same protobuf transport,
flagged so the dashboard/viewer render the "hidden from user" state and audit it distinctly.
- **viewer:** render the private-desktop stream; banner indicating the user can't see it.
- **dashboard:** "Start Backstage session" entry; accept the deep-link launch target.
- **proto (`proto/guruconnect.proto`):** add a session-mode enum (`NORMAL`/`PRIVATE`) and a
`LaunchProcess { command, desktop: PRIVATE, run_as }` control message.
## Implementation details
- Capture: DXGI Desktop Duplication is tied to the active output/desktop; a private off-screen
desktop is **not** duplicatable that way — use GDI capture of the private desktop's windows
(`PrintWindow` per top-level window or `BitBlt` of the desktop DC after `SetThreadDesktop`).
Lower FPS is acceptable for clicking through an installer.
- Input: `SendInput` operates on the calling thread's desktop — bind the input thread with
`SetThreadDesktop(hPrivateDesktop)` before injecting; for stubborn controls use
`PostMessage`/`SendMessage` to the target HWND.
- Lifecycle: tear down the private desktop + window station when the session ends; kill orphaned
child processes bound to it.
## Security considerations
- A private/hidden session that the end user cannot see is **powerful and must be audited
loudly** — record start/stop, operator, target, and the launched command. Consider an
org/policy toggle to require consent or to disallow private mode per tenant.
- Reuse GuruConnect's existing auth (JWT/support code/agent key). Launching arbitrary processes
as SYSTEM on a private desktop is high privilege — gate behind tech-role auth.
- Threat model: a hidden remote session is an attractive abuse target; treat parity with the
existing remote-control trust boundary plus extra audit.
## Testing strategy
- Unit: desktop/window-station create+destroy; thread-desktop binding.
- Manual: launch Notepad on a private desktop, confirm the logged-on user does NOT see it and the
tech can type into it; then a real GUI uninstaller (e.g. an NVIDIA/Office leftover) driven to
completion; then with no user logged in.
- Integration: RMM `needs_remote` deep-link opens a Backstage session at the right program.
## Relationship to existing specs (read first)
- **SPEC-018 (managed-agent SYSTEM service host + session broker)** is the prerequisite — it
provides the SYSTEM service + per-session worker spawning this builds on.
- **SPEC-013 (session selection + backstage)** already defines backstage as a **terminal/command**
interface (services management). SPEC-019 **extends** backstage from terminal-only to a **private
GUI desktop** — the ability to see and click an arbitrary program's *window* (an uninstaller)
that the logged-on user cannot see. Consider folding this into SPEC-013 as its "GUI backstage"
phase if the team prefers one spec.
## Effort estimate & dependencies
- **Large.** Depends on SPEC-018 (broker) and the existing capture/input pipeline; complements
SPEC-013 (backstage). The private-desktop GDI capture + input-desktop binding is the hard, novel
part. Unblocks GuruRMM Tier-2 interactive removal and any "do something the user shouldn't see"
support workflow.
## Open questions
- DXGI vs GDI for the private desktop — confirm DXGI truly can't target it; measure GDI FPS.
- Run the uninstaller as SYSTEM or as the logged-on user's token on the private desktop? (Some
per-user uninstallers need the user's profile/HKCU.)
- Default posture: should private mode require explicit per-session consent or a tenant policy?
## References
- GuruRMM SPEC-030 (remote software inventory + bulk uninstall) — the `needs_remote` flag + the
removal knowledge base that feeds this. Tier-2 is the last resort; the RMM vendor table shrinks
the set over time.
- ScreenConnect Backstage (prior art for a hidden support session).

View File

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

View File

@@ -0,0 +1,129 @@
# GuruConnect Audit Report — 2026-05-31
**Auditor:** Claude (claude-opus-4-8[1m])
**Passes:** Security & Remote-Session Integrity (`--pass=security` only)
**Previous audit:** 2026-05-30 (`reports/2026-05-30-gc-audit.md`)
**Scope note:** v2 **Phase-1 EXIT gate** re-audit. Confirms the three relay CRITICALs stay closed and
the prior net-new HIGH is fixed, and assesses the net-new SPEC-004 surface (Tasks 2/4/5 — machine_uid
dedup, session reaping, operator removal) now committed + deployed. Includes **live** boundary tests
against the running production binary, not just a code re-derivation.
**Code under audit:** working tree at tag **v0.3.0 / e967cce** = the binary deployed to prod
172.16.3.30:3002 (deployed this session from 96f9c0a; e967cce adds only the version bump + changelog).
---
## Executive Summary
| Pass | Total | Critical | High | Medium | Low | Info |
|------|-------|----------|------|--------|-----|------|
| Security & Session | 4 | 0 | 0 | 0 | 0 | 4 |
**Phase-1 security EXIT gate: PASS.** The relay/server plane is clean. All three 2026-05-29 CRITICALs
remain CLOSED (verified in code AND live against the deployed server). The prior net-new HIGH (agent
auto-update TLS bypass) and the prior LOW (chat content logged at INFO) are both remediated. The
net-new SPEC-004 surface (operator removal, machine_uid dedup gate, session reaper/supersede) audits
clean with the keyed-identity security invariant intact end-to-end. No net-new findings.
**Requires action:** none.
---
## Live functional verification (deployed binary, 172.16.3.30:3002)
Forged tokens (HS256, real `JWT_SECRET`) exercised the WS auth boundaries directly. Each illegitimate
access was REJECTED (4xx, never a 101 upgrade):
| Check | Result | Proves |
|-------|--------|--------|
| Login-shape JWT on `/ws/viewer` | **401** | Login token not accepted as a viewer token (`purpose=="viewer"` enforced) — CRITICAL #1 |
| Validly-signed viewer token for session AAAA used on session BBBB | **403** | Session binding enforced — a correctly-signed token is refused for the wrong session — CRITICAL #1 |
| Login JWT used as agent `api_key` on `/ws/agent` | **401** | Agent plane rejects JWTs (no JWT branch) — CRITICAL #3 |
| Wrong-signature token on `/ws/viewer` | **401** | Signature validation holds (control) |
The session-bind case is the decisive one: a token that WOULD be accepted for its own session is
rejected 403 for a different session, proving the binding rather than mere signature validation.
---
## The three relay CRITICALs — verdict
| CRITICAL | Verdict | Enforced at |
|----------|---------|-------------|
| #1 any-JWT-joins-any-session | **CLOSED** | mint authz `api/sessions.rs` (is_admin \|\| permission); viewer WS `relay/mod.rs:496` `validate_viewer_token` (sig+expiry+`purpose=="viewer"`); session-bind `relay/mod.rs:527-534` (`claim != requested → 403`) |
| #2 viewer-WS blacklist | **CLOSED** (TTL-bounded residual unchanged) | `relay/mod.rs:509` `token_blacklist.is_revoked` before upgrade. Residual: logout revokes login JWT not minted viewer tokens (5-min TTL) — same tracked MEDIUM, no regression |
| #3 JWT-accepted-as-agent-key | **CLOSED**, fails closed | `relay/mod.rs:417` `validate_agent_api_key` — no JWT branch; only `cak_` (`auth/agent_keys.rs`, SHA-256 vs `connect_agent_keys`, `revoked_at IS NULL`) or deprecated shared key (WARN). Unresolved machine → 503 (`:303`); client `agent_id` overridden by key identity (`:283`) |
Live results match these code paths exactly.
---
## Prior HIGH — FIXED
**Agent auto-update TLS bypass → MITM-RCE: CLOSED.** `agent/src/update.rs:21` `dev_insecure_tls()` is
`cfg!(debug_assertions)` AND env-var gated, so a release build's `cfg!` compiles out and the agent
ALWAYS verifies certs. Both `check_for_update` (`:64`) and `download_update` (`:130`) consume it; unit
test `test_dev_insecure_tls_release_is_always_false` (`:362`) asserts the release invariant. No
`danger_accept_invalid_certs(true)` reachable in production. A signed-manifest defense-in-depth TODO is
filed at `install_update` (`:189`) (= tracked task #10, not an exit blocker).
---
## Pass 5: Security & Remote-Session Integrity — net-new SPEC-004 surface
### [INFO] Operator removal API (`server/src/api/removal.rs`) — clean, admin-gated
Every removal handler takes the `AdminUser` extractor as its first argument (runs before any DB
mutation): `remove_machine` (`:88`), `remove_session` (`:321`), `bulk_remove_machines` (`:471`).
`AdminUser` (`auth/mod.rs:141`) validates JWT (signature + expiry + blacklist `:97`) then requires
`is_admin()` else 403 (`:146`). Soft-deletes are parameterized + idempotent (`WHERE … AND deleted_at IS
NULL`); bulk bounded (MAX_BATCH 500) with per-id UUID validation + isolated failures; audit
(`db/events.rs:126`) records actor + target + trusted-proxy IP, best-effort (cannot be suppressed by
attacker-controlled input). Removal is admin-role-gated globally (not per-tenant ACL) — same Phase-1
posture as viewer-mint, per-tenant narrowing deferred to SPEC-002 Phase 4. Acceptable by context.
### [INFO] machine_uid dedup security gate — invariant holds
Gate at `relay/mod.rs:352`: `effective_machine_uid = if is_keyed_agent { None } else { claimed }`. The
suppressed value (not the raw claim) flows to `register_agent` and `upsert_machine`. Keyed (`cak_`)
agents take the agent_id-keyed upsert branch and never write/touch a `ON CONFLICT (machine_uid)` row, so
a valid key for machine X cannot repoint machine Y via a claimed uid. An un-keyed uid-spoof can only
match a uid-bearing row — which the keyed connect path never creates; the only residual is a legacy
pre-keying row, and the startup L1 fix (`main.rs:267-288` via `keyed_machine_ids`, fail-closed on query
error) ensures keyed machines are never uid-indexed on restore.
### [INFO] Session reaper + same-machine supersede — clean, TOCTOU closed
`reap_stale_persistent` (`:875`) and supersede (`:322`) select under a read lock then re-assert the full
predicate under the write lock via `remove_session_if` (`:755`). Predicate requires
`!is_online && is_persistent && viewers.is_empty()` (+ TTL / same-uid) — an online, viewer-attached, or
support session is never reaped/superseded. Un-keyed uid-spoof blast radius = denial-of-persistence on
an offline same-uid session at worst, never a hijack. Lock order matches `register_agent`; predicate is
synchronous (no await under lock).
### [INFO] General posture — confirmed, no regressions
Runtime sqlx parameterized everywhere (no `format!`-built SQL); migrations 008/009 idempotent. Frame
caps: agent 4 MiB / viewer 64 KiB applied before upgrade. Input throttle retained. `/api/auth/login`
rate-limited (`main.rs:397`). `JWT_SECRET` panics if <32 (`main.rs:143`); agent keys SHA-256; Argon2id
passwords; no secret/token/code/PII logged. **Chat content no longer logged** (prior LOW fixed —
`relay/mod.rs:829,1428` now log length only).
---
## Definitive answers
- **(a) Any non-admin removal path?** NO — all three removal handlers gate on `AdminUser` (JWT+blacklist+`is_admin`→403) before any DB mutation.
- **(b) Any uid-spoof that repoints/hijacks another machine's row or session (not just denial)?** NO — keyed identity is authoritative and uid-suppressed across connect → upsert → reattach → startup restore. Worst case for an un-keyed spoof is denial-of-persistence on an offline same-uid session.
- **(c) Any auth-plane bypass (agent↔viewer credential crossover)?** NO — viewer plane requires a `purpose=="viewer"` session-bound minted token; agent plane requires a `cak_`/shared key with no JWT branch. Confirmed in code and live.
---
## Verdict
**Phase-1 security EXIT gate: PASS.** Relay/server plane clean; prior HIGH + LOW remediated; SPEC-004
surface sound with the keyed-identity invariant intact across the connect path, DB upsert, in-memory
reattach, and startup restore. No new CRITICAL/HIGH/MEDIUM/LOW.
**Tracked, deferred-by-design (not exit blockers):**
- Viewer-token logout revocation residual (MEDIUM, TTL-bounded) — `v2-secure-session-core/plan.md`.
- Update-binary signature verification (defense-in-depth, task #10) — TODO at `update.rs:189`.
*Note: only `--pass=security` was run. API-surface, Rust-quality, TypeScript, protocol-integrity,
docs-reconciliation, and CI/CD passes were not executed this run.*

View File

@@ -0,0 +1,79 @@
# Code Review Notes: GuruConnect SPEC-018 Phase 1 (Managed Agent as LocalSystem Service Host)
**Review date:** 2026-06-03
**Reviewer:** Grok (meticulous code reviewer persona)
**Scope:** SPEC-018 Phase 1 changes (merge 11af9df, including review-fix commit a0e0d5f). Diff: `git diff 55b9c97..11af9df` (agent/src/{install.rs,main.rs,service/mod.rs,session/mod.rs}). Full reads of: `agent/src/service/mod.rs`, relevant sections + full run_* functions in `agent/src/main.rs`, `agent/src/session/mod.rs`, `agent/src/credential_store.rs`, `agent/src/install.rs`, `agent/src/startup.rs`, `agent/src/config.rs`, `agent/src/identity.rs`, `agent/src/enroll.rs`, `agent/src/transport/websocket.rs`, `agent/Cargo.toml`, and light greps/server-side enrollment in `server/src/api/enroll.rs` + rate_limit. Also cross-checked CLAUDE.md constraints, Windows service practices, auth paths, shutdown propagation, panic guard, and recent SPEC-016 enrollment/identity code.
**Commands used for exploration:** `git log --oneline 55b9c97..11af9df`, `git diff ... --name-only`, file reads (chunked for large), rg/grep for `\.unwrap\(\)`, `\.expect\(`, `unsafe`, `AtomicBool|shutdown|SERVICE_STOP_SENTINEL`, `println!`, `enroll|machine_uid`, etc. No code changes performed.
## Summary
The SPEC-018 Phase 1 implementation is high-quality, security-conscious, and largely correct for its narrow scope (enrollment + relay only; no capture/input/desktop in Session 0). It correctly introduces the LocalSystem service as the single autostart for managed agents (embedded config), wires cooperative shutdown via `Arc<AtomicBool>` (observed both in outer `run_agent` reconnects and inner `SessionManager::run_with_tray` connected loop via `SERVICE_STOP_SENTINEL`), uses `panic::catch_unwind(AssertUnwindSafe)` + downcast around the Tokio runtime to protect the SCM FFI boundary (mapping to `ServiceSpecific(1)` for recovery), implements idempotent SCM install/uninstall with bounded retry for `ERROR_SERVICE_MARKED_FOR_DELETE`, configures crash recovery via `sc failure`, skips tray/autostart HKCU Run for the service path (preventing double-agents and Session-0 nonsense), and documents Phase 2 seams (broker, `CreateProcessAsUserW`, `SESSIONCHANGE`, IPC) extensively in comments. Adherence to CLAUDE.md is excellent: `tracing` (no `println` in core paths), `anyhow`/`thiserror`, async, no hardcoded secrets, strict auth (managed path always requires cak_/enroll material or legacy key; support codes only for interactive `run_agent_mode`; no unauth agent paths), UUIDs/soft-delete conventions (on server), etc. High-privilege SYSTEM surface is handled with care (ACL'd credential store readable only in-context, fail-fast guards retained as safety net, best-effort status reporting).
However, there are a handful of correctness bugs (especially the non-elevated managed "fallback" that doesn't actually run an agent, and unconditional `agent_id` churn for all embedded-config managed agents on every restart), several `unwrap()`s in hot paths (session loop), a stale `#[allow(dead_code)]`, and some pre-existing but now-in-scope fragile unsafe/transmute patterns in registry code touched by the autostart changes. Service lifecycle, status reporting, and install/retry logic are solid with only minor nits. Phase 2 gaps are clearly and repeatedly called out (no omissions). Light broader review of recent enrollment/identity (SPEC-016) work (which this builds directly on) found related issues in config load priority + agent_id stability and a couple of test-only unwraps. No evidence of race conditions in the AtomicBool propagation or handler blocking. Overall: mergeable with fixes for the flagged bugs; strong engineering but not flawless on edge cases for a SYSTEM RCE surface.
**Issue counts (by severity):** 2 bug, 1 bug/suggestion, 4 suggestion/nit, 1 nit. Total 8. (No issues found in: auth enforcement for agents, use of tracing/anyhow, panic guard intent + sentinel contract (tests pin it), no-double-agent logic on happy path, credential ACL + C1 verification when running as SYSTEM, Windows service handler non-blocking + recovery config, or Phase 2 documentation.)
## Issues
### Issue 1 -- Severity: bug
- File: agent/src/main.rs:496 (inside `run_permanent_agent_managed`, called from PermanentAgent detection at 320 and 186)
- Description: When `install::install(false)` fails (e.g., the managed binary is launched interactively without Administrator rights), the code does `warn!(... falling back to in-process agent for this run); return run_agent_mode(None);`. The surrounding comment (and similar in 294-296) claims this ensures "the machine is not left with no agent at all." However, for a managed/PermanentAgent binary (which has embedded `enrollment_key` + `site_code` or a prior `cak_`), `run_agent_mode``resolve_agent_credential` will *always* fail: (a) if a `cak_` exists, `load_cak` hits `LoadCakError::Io { permission_denied: true }` and the explicit guard returns a hard error ("must run as the GuruConnect SYSTEM service"); (b) if no `cak_` yet, it calls `enroll::run_enrollment``credential_store::store_cak`, whose C1 read-back verification (`load_cak` immediately after write) will see the SYSTEM+Admins ACL and return the perm-denied error, causing `store_cak` and thus enrollment to fail. The "fallback" path therefore exits with error (no relay connection) in the exact case it was meant to save. This is a direct correctness gap for the high-privilege managed path.
- Suggestion: Remove the fallback for the `has_embedded_config()` / managed case (surface a clear error requiring elevation for first-run managed install, as already stated in `install_managed_service` docs). Or introduce a true degraded fallback (e.g., force a per-user cak_ path or legacy api_key mode) with loud warnings. Update all comments and the "falling back" log. Consider a distinct code path or flag so non-elevated managed binaries don't pretend to provide agent functionality.
- Status: open
### Issue 2 -- Severity: bug
- File: agent/src/config.rs:392 (in `Config::load` embedded branch, lines 382-408; also interacts with `detect_run_mode:255`, `has_embedded_config:285`, `read_embedded_config:290`, and PermanentAgent paths in main.rs:166+)
- Description: Embedded config (the trigger for `RunMode::PermanentAgent` / managed installs per SPEC-016) never includes an `agent_id`. On every `load()` via the embedded priority-1 path, it does `agent_id: generate_agent_id()` (fresh v4 UUID) unconditionally, then `let _ = config.save()`. Because `load()` always prefers `read_embedded_config().is_ok()` (the exe still carries the magic blob post-install), the toml written by `save()` is *never read back* for agent_id on subsequent launches. Consequence: every service start/restart (and every interactive launch of a managed binary) produces a brand-new `agent_id`. This value is passed to `WebSocketTransport::connect(..., &self.config.agent_id, ...)` (session/mod.rs:121) and used for server-side connection identity/tracking. While `machine_uid` + the `cak_`-bound machine row are the stable dedup keys (per identity.rs and server enroll), churning `agent_id` on every restart is still observable churn in logs, sessions, and any agent_id-keyed state. The comment "Save to file for persistence (so agent_id is preserved)" is actively false for the embedded case that managed agents always take.
- Suggestion: In the embedded construction block, first try loading an existing `agent_id` from the on-disk toml (via a helper or the file-load logic) if present and non-empty; only generate if absent. Alternatively, persist the generated agent_id into the embedded blob at installer creation time (or treat the server's minted agent_id as authoritative and stop sending client-generated one for managed agents). This is in the "recent enrollment/identity code" under light broader review.
- Status: open
### Issue 3 -- Severity: bug
- File: agent/src/session/mod.rs:386 (recv path), 486 (chat poll), 559 (frame send) — all inside `run_with_tray` after the `if self.transport.is_none() { bail }` guard at 316 and before the connectivity check at 579-587.
- Description: Direct `.unwrap()` on `self.transport.as_mut().unwrap()` (and `.as_ref()` in the is_connected check is safe, but the mut ones are not). These execute on *every* message, every outgoing chat, and every video frame while streaming/connected. Although current control flow (transport only set in `connect()`, never set to `None` inside the loop, `release_streaming` doesn't touch it, and the bottom-of-loop check breaks before next iteration) makes them "safe" today, this violates the review rule to flag `unwrap()` in hot paths. A future change (e.g., error path that clears transport, or making run_with_tray re-entrant, or transport becoming Option in more states) turns this into a panic of the entire agent (especially bad under the SYSTEM service). Also present in the stop-sentinel path (which does use `if let Some` for close).
- Suggestion: Replace with `.expect("transport must be Some inside run_with_tray after the NotConnected guard and while the outer loop considers us connected")` (or better, hold the transport behind a non-Option after the initial check, or use `if let Some(t) = ... { ... } else { break; }` guards). Audit similar patterns elsewhere in the connected loop.
- Status: open
### Issue 4 -- Severity: suggestion
- File: agent/src/main.rs:442-466 (the `catch_unwind` + match in `run_managed_agent_service`); related: service/mod.rs:103-107 (service_main), 112 (run_service body), 167 (the call site), 250-256 (comment explaining finding M)
- Description: The required panic guard across the SCM FFI boundary (`extern "system" ffi_service_main -> service_main -> run_service -> run_managed...`) exists and correctly converts unwind to `Err(...)``ServiceExitCode::ServiceSpecific(1)` (so SCM recovery restarts instead of UB/abort). The downcast logic is careful (handles &str/String, falls back without re-panic). However, the `catch_unwind(AssertUnwindSafe(|| { rt.block_on(...) }))` only covers the *inner agent runtime*. Panics originating in `run_service()` itself (e.g., in initial `service_control_handler::register`, the two `set_status` calls before/after the agent loop, the `if let Err` logging, or `set_status_with_exit` for Stopped) would still unwind out of `run_service`/`service_main` across the FFI with no guard. The top-level `if let Err(e) = run_service()` in service_main cannot catch a panic.
- Suggestion: Wrap more of `run_service()` (or the whole body after the shutdown flag setup) in an outer `catch_unwind`, or add a guard inside `service_main`. This makes the "panic guard across SCM FFI" complete rather than scoped only to the "finding M" agent block_on. (Current scope is probably sufficient in practice, as panics are unlikely in the thin status/reporting code.)
- Status: open
### Issue 5 -- Severity: suggestion
- File: agent/src/service/mod.rs:353 (`std::thread::sleep(BACKOFF)` inside `create_service_with_retry` loop), 470-476 (`stop_if_running` 10x 500ms polling loop); also called from install.rs:327/369 and startup paths changed by SPEC-018.
- Description: These blocking sleeps are *only* in the synchronous install/uninstall code paths (CLI `guruconnect install` / uninstall, which run under the current user's context before handing off to the service). They are not inside the SCM control `event_handler` closure (which correctly does only `store(true)` + `NoError` / `NotImplemented` with no I/O or waits — satisfying "no blocking in handler"). However, they are still blocking the main thread during (re)install, and the fixed polling is a minor source of non-determinism/race on slow SCM. The retry logic itself (gated on `deleted_existing`, bounded attempts) is a clear improvement over the prior fixed 2s sleep.
- Suggestion: Acceptable for install paths. Consider `std::thread::sleep` → a small async sleep if these ever move under tokio, or document "install-time only." The 500ms stop poll is fine for best-effort delete prep.
- Status: open
### Issue 6 -- Severity: bug (pre-existing, but now in scope due to SPEC-018 touching autostart/install/uninstall paths)
- File: agent/src/startup.rs:61 (`std::mem::transmute::<HANDLE, HKEY>(hkey)` after `RegOpenKeyExW`), 122-125 (identical in `remove_from_startup`), plus similar raw pointer casts and unsafe registry blocks in install.rs:152+ (protocol handler), 380+ (is_protocol_handler_registered). Also related unsafe in credential_store (DPAPI) and identity (RegGetValueW) which are part of the enrollment/identity surface reviewed.
- Description: The transmute from a `HANDLE` (obtained via the windows crate's `RegOpenKeyExW` call site that takes a `*mut _`) to `HKEY` for subsequent `RegSetValueExW`/`RegDeleteValueW` is a fragile, non-idiomatic, and potentially unsound hack. The `windows` 0.58 crate provides properly typed `HKEY` and safe-ish wrappers; mixing `HANDLE` + transmute + `as *mut _` casts risks wrong handle values, leaks, or UB. This code predates SPEC-018 but is executed on the managed install path (which does `remove_from_startup` inside `install_managed_service` and `uninstall_managed_service`).
- Suggestion: Refactor to use the crate's typed registry APIs directly (pass `&mut HKEY` to the open call, or use `RegOpenKeyExW` overloads that return the handle type). Add `// SAFETY:` comments with justification for any remaining FFI. This is a correctness + maintainability issue for registry paths now used by the SYSTEM service host.
- Status: open
### Issue 7 -- Severity: nit
- File: agent/src/transport/websocket.rs:208 (`pub async fn close(&mut self) -> Result<()> { ... }`)
- Description: The method has `#[allow(dead_code)]` (and the call site in the pre-SPEC-018 code may have been conditional). SPEC-018 now calls it unconditionally in the service-stop path: `session/mod.rs:1134` (`if let Some(transport) = self.transport.as_mut() { if let Err(e) = transport.close().await { ... } }`). The allow is now misleading (though harmless, as the fn is live).
- Suggestion: Remove `#[allow(dead_code)]`.
- Status: open
### Issue 8 -- Severity: nit (light broader review of recent enrollment/identity code)
- File: agent/src/enroll.rs:329,359,360,373,380 (all in `#[cfg(test)]`); also scattered test unwraps in credential_store.rs:379+ (DPAPI tests), encoder tests, etc.
- Description: Tests use `.unwrap()` liberally (e.g., `serde_json::to_value(&req).unwrap()`, response parses, `dpapi_protect(...).expect(...)`). This is conventional and acceptable in unit tests (they are expected to succeed on the test host), but still "remaining .unwrap()". No runtime impact. (The production paths in enroll use proper error classification into `AttemptError` and never unwrap on HTTP/JSON.)
- Suggestion: For hygiene, change test unwraps to `?` inside `#[test] fn` (which can return Result) or `.expect("test precondition")`. Not a priority.
- Status: open
**No issues found in these areas (explicitly checked):**
- Authentication / never accepting unauthenticated agents: Managed service path always forces `config.support_code = None` and goes through `resolve_agent_credential` (which requires cak_, enrollment material, or deprecated api_key; errors hard on nothing usable). Support-code sessions are exclusively the interactive `run_agent_mode` path. Viewer WS requires JWT (out of scope here). `run_dispatcher` / `service-run` cannot be usefully invoked interactively.
- Cooperative shutdown + graceful WS close: AtomicBool (SeqCst) set only in handler; polled at top of connected loop (every ~1-100ms) *and* in reconnect backoff (250ms); sentinel error drives clean `transport.close()` (sends WS Close frame) + `return Ok(())` with no reconnect. Tests pin the sentinel contract and "no regression for non-service paths."
- Service lifecycle / status / recovery / no blocking in handler: Correct `StartPending``Running` (accept STOP|SHUTDOWN) → (on stop) `StopPending``Stopped` (with Win32(0) or ServiceSpecific(1)). Handler closure is trivial move + store + match. `configure_recovery` + `sc failure` for restart-on-crash. Idempotent create/delete with retry for SCM delete races. `is_service_installed` is total (never panics).
- No double-agents / autostart hygiene (happy path): `run_permanent_agent_managed` early-exits if service installed; managed install removes HKCU Run (best-effort); service path skips `add_to_startup` and tray creation entirely. Non-managed paths untouched.
- Credential handling as SYSTEM: `run_managed_agent_service` runs as SYSTEM → `load_cak` (and `store_cak` during enroll) succeed because ACL grants SYSTEM full control. The perm-denied guard + C1 read-back in store_cak are retained exactly as safety net for non-SYSTEM invocations of managed binaries. No secrets logged.
- Use of tracing/anyhow/thiserror/async/clippy-adjacent style: New code uses `tracing::{error,info,warn}`, `anyhow::Result` + `?` + `.context`, `thiserror` for `LoadCakError`. No `println!` in agent runtime (only in version-info / fallback message boxes / separate sas_service bin).
- Phase 2 gaps called out: Explicitly and repeatedly in `service/mod.rs` module-level docs (lines 24-41), inline comments (e.g., 124, 422 in main, 1085+ in session for the "no capture yet" rationale and seams for broker/SESSIONCHANGE/CreateProcessAsUser/IPC). No silent omissions.
- Other CLAUDE.md: Single static binary target, Win7+ (PS/CIM used for identity are present), no redist deps (icacls/sc are inbox), DB conventions on server side (UUID PKs, snake_case, events for audit), etc.
**Executive verdict:** SPEC-018 Phase 1 is a solid, well-defended implementation of the SYSTEM service host with correct lifecycle, shutdown, and guardrails. The two "bug" issues (non-functional fallback on install failure for managed agents; agent_id churn on every restart) are real correctness problems that should be fixed before wider managed deployment, as they affect reliability of the high-privilege path and identity stability. The unwraps and registry transmute are lower-severity but worth cleaning for a SYSTEM binary. The rest of the work (including the excellent documentation of what's deliberately left for Phase 2) meets or exceeds the project's standards and the requirements in CLAUDE.md. Recommend addressing Issues 1-3 (and 6) prior to release; the rest can be follow-ups. The changes do not introduce new auth holes or unauthenticated agent paths.
**File written:** D:\GrokTools\guru-connect-review-SPEC018.md
(End of review notes.)

View File

@@ -0,0 +1,110 @@
# GuruConnect — Remediation Plan (from the 2026-06-05 three-way review)
Derived from `SYNTHESIS-three-way.md` (Claude + Gemini + Grok). Findings are grouped into
shippable phases. Each phase is sized to a GuruConnect spec and should go through the
standard pipeline (spec → three-way design review → build → three-way code review → deploy →
validate). Phase numbers are priority order, not strict dependency order — P0 and parts of
P4/P5 can run in parallel with the larger P1/P2/P3 efforts.
Effort key: **S** = <0.5 day, **M** = 0.52 days, **L** = >2 days / multi-component.
Finding IDs (C#/H#) reference the synthesis report.
---
## P0 — Emergency hardening pass (proposed SPEC-019)
Small, self-contained, high-value. No protocol or cross-component coordination needed. Ship first.
| Finding | File(s) | Action | Effort |
|---|---|---|---|
| **C2** | `server/src/api/downloads.rs` | Remove the `"managed-agent"` default API-key fallback and the hardcoded `wss://connect.azcomputerguru.com/...` URL; move both into `Config`/`AppState` and have the download handlers take `State`. Either implement real support-config embedding (MAGIC_MARKER + len + JSON, matching the permanent path) or correct the module docstring to stop claiming it. | M |
| **C3** | `server/src/api/downloads.rs` | Replace every `Response::builder()…body(…).unwrap()` with a real error path (500 JSON). Reject/percent-encode (RFC 5987 `filename*=`) `Content-Disposition` values; cap input length before sanitizing. Closes an **unauthenticated panic/DoS**. | S |
| **H1** | `server/src/api/auth.rs` (login, change_password) | Add rate-limit + failure-lockout on the login path, reusing the existing `state.rate_limits` machinery from the enroll path. Key on `(username.lower(), client_ip)`. Return 429 after threshold; record failures after constant-time work. | M |
| **H2** | `server/src/main.rs` (bootstrap ~198225) | Stop writing the generated admin **plaintext** to `.admin-credentials`; remove the `info!` log fallback. Prefer operator-supplied initial password via env/one-time flag (error on zero-user start if absent), or print once to stdout with a "copy now, never persisted" warning. _(Server deploys on Linux so 0o600 works — the residual risk is on-disk plaintext + the log path, not the Windows-ACL angle.)_ | S |
| **H3** | `server/src/api/auth_logout.rs:116` | The `revoke_user_tokens` 501 stub claims partial behavior it doesn't have. Either delete the route until the session-tracking table exists, or implement the minimal real version and make the comment/error match reality. | S |
| **H5** | `server/src/api/users.rs` + `auth` extractor | Enforce the **self-role-demotion / self-disable guard server-side** (currently only self-_delete_ is blocked; the lockout guard is client-only in `EditUserModal.tsx`). Mirror the last-admin/self guards already shipped in GuruRMM SPEC-027. | S |
**Validation:** unauthenticated download fuzz (quotes/control chars in `company`/`site`), login brute-force returns 429, fresh-server bootstrap leaves no secret on disk/in logs, self-demote returns 4xx server-side.
---
## P1 — WebSocket auth handshake redesign (proposed SPEC-020) — the big one
**C1 — the top convergent finding.** Secrets/tokens currently travel in WS URL query strings on
both planes (agent: `api_key`/`support_code`/`machine_uid`; viewer: the session-scoped control token).
This is partly **by current design**`guru-connect/CLAUDE.md` Security Rules mandate
"Viewer WebSocket: JWT token required in `token` query parameter." Both external models reject
that. **First action: a design decision to change that spec rule.** Server-side the viewer token
is already hardened (minted, session-bound, signature/purpose/`session_id` checked, blacklist on
the WS plane) — only the transport channel is the problem.
Workstream (L, cross-component — run a three-way *design* review before building):
1. `proto/guruconnect.proto`: add an initial `AuthHandshake` message (agent creds / viewer token + binding).
2. `server/src/relay/mod.rs`: accept the WS upgrade unauthenticated, then require the auth frame as the first message; fail-closed on timeout/missing. Keep all existing binding/blacklist/consent checks.
3. `agent/src/transport/websocket.rs` (+ call sites: `session/mod.rs`, `viewer/transport.rs`, `install.rs`): connect, then send creds in the first protobuf frame. Remove secrets from the URL.
4. `dashboard/src/features/sessions/JoinSessionModal.tsx` (`buildViewerUrl`) + `api/sessions.ts`: send the viewer token via the handshake; stop surfacing the full token-bearing URL as a copyable field.
5. Update `guru-connect/CLAUDE.md` security rules to match.
**Interim mitigation (until P1 ships):** make minted viewer tokens single-use-on-first-attach and shorten TTL; ensure the relay/NPM proxy never logs full request URLs.
---
## P2 — Update & embedded-config integrity (proposed SPEC-021)
| Finding | File(s) | Action | Effort |
|---|---|---|---|
| **C5** | `agent/src/update.rs` | SHA-256 over the same channel is not tamper defense (the code's own TODO says so). Embed an Ed25519 public key; sign the update manifest + binary at build time; **verify signature before `install_update`**. Lock `dev_insecure_tls` to debug builds and consider removing the env escape hatch. | M |
| **MED** | `agent/src/config.rs` (`read_embedded_config`) | Same crypto primitive: sign the appended `GURUCONFIG` blob (Ed25519) and verify before trusting `server_url`/`enrollment_key`. Add a strict max length, validate the marker sits after the PE end, and allowlist `server_url` scheme/host. Prevents an attacker blob from repointing the agent to a hostile relay. | M |
Bundle these because they share the signing key infrastructure and the build-pipeline signing step. The Pluto build server / Gitea webhook pipeline will need a signing key (store in SOPS vault).
---
## P3 — Agent runtime correctness (proposed SPEC-022)
| Finding | File(s) | Action | Effort |
|---|---|---|---|
| **C4** | `agent/src/transport/websocket.rs` | Remove `block_in_place(|| Handle::block_on(...))` from the main session loop and viewer recv. Use a dedicated recv task forwarding over `mpsc`, or async `timeout` directly on the locked stream. Fix the racy `connected` flag. | M |
| **H7** | `agent/src/session/mod.rs` | Drive attended-consent off the main loop (spawn a task that owns only the response send) so heartbeats/status/stop-signals keep flowing during the ~60s think-time. | M |
| **H8** | `agent/src/credential_store.rs:~309/345` | Call `icacls` by absolute path (`C:\Windows\System32\icacls.exe`) or use Win32 `SetNamedSecurityInfoW` directly; verify the resulting ACL actually denies non-admins (or log raw `icacls` output on failure) instead of silently leaving a weaker store. | S |
| **MED** | `agent/src/encoder/h264.rs`, `decoder.rs`, `capture/{dxgi,gdi}.rs`, `session/mod.rs` | Wrap the per-frame `capture()`/`encode()` in the same `catch_unwind` + "mark capturer bad, fall back/idle" recovery used at init; make COM `ReleaseFrame`/`DeleteObject` strict (scopeguard, exactly-once per Acquire); after N consecutive H.264 errors force renegotiation/hard-switch to raw instead of per-frame silent drop; remove dead `last_frame`. | L |
---
## P4 — Auth/session lifetime & dashboard hardening (proposed SPEC-023)
| Finding | File(s) | Action | Effort |
|---|---|---|---|
| **H4** | `server/src/auth/token_blacklist.rs:85` | Store `(token, exp)` and make `cleanup_expired` a pure time comparison instead of re-verifying every JWT signature. Capture `exp` at revoke time (cheap unsigned parse). | S |
| **H6** | `dashboard/src/auth/AuthProvider.tsx`, `api/client.ts` | Parse/respect token `exp`; add proactive refresh-or-logout and an idle-activity timeout. Longer-term: move the JWT to an `httpOnly; Secure; SameSite=Strict` cookie managed by the server (needs a server-side cookie/refresh endpoint — coordinate with P1). | M |
| **MED** | `dashboard/src/auth/AuthProvider.tsx` (`restore`) | Only `clearSession()` on an explicit `401`; on network (status 0) / 5xx keep the token and show an offline/retry state. Stops flaky-connection logout loops. | S |
| **MED** | `server/src/api/users.rs` + `dashboard/src/features/users/hooks.ts` | Add a transactional `PATCH /api/users/:id` that updates core fields + permissions atomically; switch the dashboard off the two-call `updateUser``setUserPermissions` sequence. | M |
---
## P5 — Hygiene & maintainability (rolling, no dedicated spec)
- **MED** Password policy: complexity + upper bound + breach-list (`zxcvbn`); raise min to 12. (`auth.rs`, `users.rs`, `enroll.rs`)
- **MED** Input validation: cap + control-char-strip `machine_uid`/`hostname`/`site_code`/password on the unauthenticated enroll path before DB / rate-limit keys. (`enroll.rs`)
- **MED** Audit correctness: `CONNECTION_REJECTED_*` events should insert `session_id = NULL` (like enrollment/removal), not a random UUID. Add `log_connection_rejected` or make `log_event` take `Option<Uuid>`. (`relay/mod.rs`, `db/events.rs`) — _also resolves the dangling-row issue noted in the earlier in-product audit assessment._
- **MED** Generate dashboard TS types from the Rust API (`ts-rs`/`specta`) to kill type drift; interim: a snapshot-diff test. (`dashboard/src/api/types.ts`)
- **MED** Wire the declared `VIEWER_INPUT_EVENTS_PER_SEC = 200` viewer-input rate limit (token bucket / coalesce latest mouse-move) or update the comment to match the backpressure-only reality. (`relay/mod.rs`)
- **LOW** Remove panic sites on response/header construction (`prometheus_metrics` locks/encode, `security_headers` `from_static().unwrap()`). (`main.rs`, `middleware/security_headers.rs`)
- **LOW** Document (or persist) that in-memory rate-limit/lockout state resets on restart; consider a bounded TTL table in Postgres for enroll/code-validate. (`rate_limit.rs`)
- **LOW** Redundant post-decode `exp` checks; consider a small configurable leeway. (`auth/jwt.rs`)
- **LOW** `set_client_access` should verify client UUIDs exist before storing. (`users.rs`)
- **LOW** Dashboard polling: pause `refetchInterval` when `document.hidden`, add jitter/backoff. (`features/*/hooks.ts`)
- **LOW** Remove `stubs.ts` from the barrel export; audit + delete or feature-gate the large `#[allow(dead_code)]` "native-remote-control"/Phase-4 surface as it's activated. (dashboard + server/agent)
- **LOW** Standardize on the `ApiError` envelope server-wide; centralize role/permission allow-lists. (`api/*`)
- **LOW** Dashboard `rowKey` on `m.id` while everything else keys on `agent_id` — standardize on `agent_id`. (`MachinesPage.tsx`)
- **LOW** Treat unknown/plain-text server errors as opaque in the dashboard toasts (don't dump raw bodies). (`api/client.ts`)
- **LOW** Drop the deprecated `document.execCommand("copy")` clipboard fallback. (`useClipboard.ts`)
---
## Suggested sequencing
1. **P0** immediately (one hardening spec, ~23 days total, all small).
2. Kick off the **P1** design review in parallel (it's the highest-impact and longest-lead).
3. **P2** and **P3** next (agent-side; can overlap once P1 design is settled, since P1 also touches the agent transport).
4. **P4** (some of it — httpOnly cookie — depends on P1's server auth changes).
5. **P5** folded into whichever spec touches the relevant file, or a periodic cleanup pass.
## Tracking
Create coord todos (project_key `guruconnect`) for P0P4 as parent items, with the individual
findings as sub-tasks, plus Gitea issues on `azcomputerguru/guru-connect` for the CRITICAL/HIGH
items so they're referenced from commits (`Fixes #N`).

View File

@@ -0,0 +1,96 @@
# GuruConnect — Three-Way Independent Review (Claude + Gemini + Grok)
**Date:** 2026-06-05
**Reviewers:** Gemini `gemini-3.1-pro-preview`, Grok 4.3, Claude (synthesis + cross-check)
**Scope:** server (42 Rust files), agent (34 Rust files), dashboard (66 TS/TSX files). Each model read the source via its own `read_file` tool (no embedding). Grok's server pass was split A/B because 42 files exceeded its read-file capacity.
**Raw reports:** `gemini-{server,agent,dashboard}.md`, `grok-server-a.md`, `grok-server-b.md`, `grok-agent.md`, `grok-dashboard.md` (same folder).
Findings where **both** external models independently agree are marked **[CONVERGENT]** — higher confidence. Claude's cross-checks/adjustments are in _italics_.
---
## The one cross-cutting theme: secrets in WebSocket URL query strings
This is the **single highest-signal finding** — flagged independently as CRITICAL on **all three surfaces**:
- **Agent → relay** (`agent/src/transport/websocket.rs`): `api_key`/`cak_`, `support_code`, `machine_uid` in the WS URL query. **[CONVERGENT — both models' #1 for the agent]**
- **Dashboard → relay** (`dashboard/src/features/sessions/JoinSessionModal.tsx` `buildViewerUrl`): the session-scoped **viewer token** (the actual remote-control capability) in `?token=`. **[CONVERGENT — both models' #1 for the dashboard]**
Query strings leak into reverse-proxy/relay access logs, browser history, and copy-paste. Over `wss://` the body is encrypted but the URL is routinely logged.
_Claude cross-check: this is partly **by current design**`CLAUDE.md` security rules explicitly mandate "Viewer WebSocket: JWT token required in `token` query parameter." So this isn't an accidental bug; it's a spec decision both external models reject. **Recommendation: revisit that spec rule** — move auth to the first post-connect protobuf frame or `Sec-WebSocket-Protocol`. Server-side the viewer token is already hardened (minted, session-scoped, signature+purpose+`session_id`-binding checked, blacklist-checked on the WS plane — confirmed in `grok-server-b`), so the residual risk is purely the transport channel. Fix this once, on both planes._
---
## CRITICAL
| # | Area | Finding | Source |
|---|------|---------|--------|
| C1 | agent + dashboard | Secrets/tokens in WS query string (see theme above) | **[CONVERGENT]** Gemini+Grok, both surfaces |
| C2 | `server/src/api/downloads.rs` | Unauthenticated download endpoints: hardcoded prod relay URL + `"managed-agent"` default API-key fallback; support-download docstring says "embeds code" but only renames the file (temp sessions get no embedded config). | **[CONVERGENT]** Gemini-server (key), Grok-server-a (key + URL + docstring gap) |
| C3 | `server/src/api/downloads.rs` | `Response::builder()…body(…).unwrap()` on attacker-controlled `Content-Disposition` filename (`sanitize_filename` doesn't escape `"`/control chars) → **unauthenticated panic / worker DoS**. | Grok-server-a |
| C4 | `agent/src/transport/websocket.rs` | `block_in_place(|| Handle::block_on(...))` inside the main async session loop (and viewer recv) — async/sync mixing → thread starvation / deadlock risk under contention; racy `connected` flag. | Grok-agent |
| C5 | `agent/src/update.rs` | Auto-update verified by **SHA-256 only over the same channel** as the binary — no signature. A malicious/MITM relay can push SYSTEM-level code to the whole fleet. | **[CONVERGENT]** Gemini-agent (#1) + Grok-agent (HIGH) |
_Claude note on C5: code carries an explicit `TODO` acknowledging the missing signature, and there's a debug-only `dev_insecure_tls` escape hatch. Both models rate this top-tier; it's the highest-impact agent issue after the query-string transport._
---
## HIGH
| # | Area | Finding | Source |
|---|------|---------|--------|
| H1 | `server/src/api/auth.rs` (login/change_password) | **No rate limiting / lockout on the human login path**, despite sophisticated per-`(site_code,ip)` + timing-equalizer protection on the machine-enroll path. Unlimited online brute-force / credential stuffing. | **[CONVERGENT]** Gemini-server + Grok-server-a |
| H2 | bootstrap admin password | First-run writes the generated admin **plaintext password to `.admin-credentials`** in CWD, with an `info!` **log fallback** on write failure. | **[CONVERGENT]** Gemini-server + Grok-server-b (Grok's "most important") |
| H3 | `server/src/api/auth_logout.rs:116` | `revoke_user_tokens` is a **501 stub** but the comment claims partial behavior — an admin "revoke any user's tokens" endpoint that does nothing. | Grok-server-a |
| H4 | `server/src/auth/token_blacklist.rs:85` | `cleanup_expired` re-runs **full JWT signature verification** on every blacklisted token (stores whole tokens in RAM) instead of comparing a stored `exp`. | Grok-server-a |
| H5 | self-service role guard | Server **does not block self-role-demotion** (only self-_delete_ is blocked); the lock-out guard is **client-side only** in `EditUserModal.tsx`. | **[CONVERGENT]** Gemini-dashboard + Grok-dashboard (both cite the in-code server comment) |
| H6 | dashboard auth lifetime | JWT in `sessionStorage`, blindly attached as Bearer; **no `exp` handling, refresh, or idle timeout** — XSS/workstation compromise → long-lived privileged access + ability to mint per-session control tokens. | **[CONVERGENT]** Gemini-dashboard + Grok-dashboard |
| H7 | `agent/src/session/mod.rs` consent | Attended-session consent (`MessageBoxW`) is `.await`ed **inside the main loop** → for up to ~60s no heartbeats / status / stop-signal processing. | **[CONVERGENT]** Gemini-agent + Grok-agent |
| H8 | `agent/src/credential_store.rs` | `cak_` store ACL set by shelling out to bare `icacls` (PATH-search) from a SYSTEM context → LPE if `icacls.exe` is hijacked; silent weaker store on failure. | Gemini-agent (+ Grok-agent notes the silent-failure variant) |
_Claude cross-check on H2: the server **deploys on Linux** (Ubuntu 22.04 per `CLAUDE.md`), so the `0o600` chmod **is** effective and Grok's "Windows ACL is a no-op" framing doesn't apply to the deployed server. The real residual risk is the plaintext-on-disk + the `info!` log-fallback path. I'd keep this **HIGH**, not CRITICAL._
---
## MEDIUM (consolidated)
- **Weak password policy** — min length 8 only, no complexity/upper-bound/breach check. **[CONVERGENT]** (Gemini-server, Grok-server-a)
- **Input validation caps** — unbounded/uncontrolled `machine_uid`, `hostname`, `site_code`, password length on unauthenticated enroll; no control-char stripping before DB / rate-limit keys. (Grok-server-a)
- **Agent embedded-config trust** — `GURUCONFIG` blob (server_url + enrollment_key) appended to the EXE is read with an unchecked length field and `from_slice`, no signature/allowlist → attacker blob can repoint the agent to a hostile relay. (Grok-agent)
- **`support_code` derived from EXE filename** with no checksum/strength, then sent in the query string. (Grok-agent)
- **Audit-trail correctness** — `CONNECTION_REJECTED_*` events insert a random `session_id` (dangling vs `connect_sessions`) instead of `NULL` like the enrollment/removal paths. (Grok-server-b) _Ties into the earlier in-product audit assessment: the write path works but has rough edges._
- **Panic sites on response/header construction** — `prometheus_metrics` `registry.lock().unwrap()`/`encode().unwrap()`; `security_headers` `from_static(...).unwrap()`. (Grok-server-b)
- **Declared-but-missing viewer input rate limit** — `VIEWER_INPUT_EVENTS_PER_SEC = 200` constant + comment promise coalescing/drop, but the path is a plain `mpsc(64)` with only backpressure; **[CONVERGENT-ish]** Gemini-server raised the same aggregate-flood concern. (Grok-server-b, Gemini-server)
- **Non-atomic user update** — dashboard does `updateUser` then `setUserPermissions` as two calls; partial-failure leaves drift. **[CONVERGENT]** (Gemini-dashboard, Grok-dashboard) → wants a single transactional `PATCH /api/users/:id`.
- **Hand-maintained TS type mirrors** of Rust API structs → silent type drift. **[CONVERGENT]** (both dashboard reviews) → generate via `ts-rs`/`specta`.
- **Premature `clearSession()` on any `getMe` failure** (incl. network status 0) → flaky-connection logout loops. (Gemini-dashboard)
- **In-memory rate-limit/lockout state** — reset on every restart/deploy → attacker gets a fresh brute-force window. (Grok-server-b; Gemini-server LOW)
- **H.264 MFT robustness / capture resource release** — complex unsafe COM streaming-state machine, per-frame silent drops, best-effort `.ok()` releases (leak/double-release risk); per-frame `capture()` not under the same `catch_unwind` as init. (Grok-agent)
- **Error-body passthrough** — dashboard surfaces raw server error text in toasts → potential internal-detail leak. (Grok-dashboard)
## LOW / maintainability
- Redundant post-decode `exp` checks (`leeway=0`) in `jwt.rs`. (Grok-server-a)
- `set_client_access` stores client UUIDs without existence check (dangling refs). (Grok-server-a)
- Large `#[allow(dead_code)]` surface ("TODO(native-remote-control)", Phase 4) — risk that future activation enables un-reviewed paths. (Grok both server + agent)
- Audit-log table grows unbounded, no pruning/retention. (Gemini-server)
- `react-query` polling is aggressive with no `document.hidden` pause/jitter. (Grok-dashboard)
- `stubs.ts` dead scaffolding still in the barrel export. (Grok-dashboard)
- Duplicated error envelopes (`ErrorResponse` vs `ApiError`) + repeated role allow-lists across handlers. (Grok-server-a)
- Deprecated `document.execCommand("copy")` clipboard fallback. (Gemini-dashboard)
---
## What all three reviewers independently praised (don't regress these)
Argon2id + timing-equalizer on the unauthenticated enroll path; fully parameterized sqlx (no SQLi surface); per-agent `cak_` identity binding that stops a client from seizing another machine's session; atomic single-use support-code consumption at bind time; trusted-proxy-aware `client_ip` extractor as the single root for rate-limit + audit; consent gate before any viewer join/stream; reattach/supersede/reap TOCTOU hardening via recheck-under-write-lock; bounded WS message sizes on both planes; minted session-scoped viewer tokens with blacklist + session-binding checks; DPAPI + ACL credential store with readback verification; thoughtful one-time-secret UX (codes/keys/passwords) and dialog focus management on the dashboard.
---
## Recommended fix order (Claude)
1. **C1 / query-string secrets** — one architectural change, both planes; revisit the `CLAUDE.md` viewer-token-in-query rule. Highest leverage.
2. **C2 + C3** — harden the unauthenticated `downloads.rs` surface (remove default key + hardcoded URL, kill the `.unwrap()` panic, fix support-embed gap). Cheap, high exposure.
3. **H1** — add login rate-limit/lockout (reuse the enroll limiter). Cheap, high value.
4. **C5 / update signing** — embed a public key, sign the update manifest+binary. Fleet-wide RCE prevention.
5. **C4 + H7** — fix agent async/sync mixing and move consent off the main loop.
6. **H2, H3, H4, H5, H6** — bootstrap-secret handling, finish/remove the logout stub, cheap blacklist cleanup, server-side self-demotion guard, dashboard token lifetime.
7. MEDIUM/LOW as capacity allows; prioritize embedded-config signing and the audit `session_id`-NULL fix.

View File

@@ -0,0 +1,34 @@
/d/claudetools/projects/msp-tools/guru-connect/agent/src/bin/sas_service.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/capture/display.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/capture/dxgi.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/capture/gdi.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/capture/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/chat/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/config.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/consent/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/credential_store.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/encoder/capability.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/encoder/color.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/encoder/h264.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/encoder/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/encoder/raw.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/enroll.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/identity.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/input/keyboard.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/input/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/input/mouse.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/install.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/main.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/sas_client.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/service/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/session/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/startup.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/transport/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/transport/websocket.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/tray/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/update.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/viewer/decoder.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/viewer/input.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/viewer/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/viewer/render.rs
/d/claudetools/projects/msp-tools/guru-connect/agent/src/viewer/transport.rs

View File

@@ -0,0 +1,66 @@
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/App.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/api/auth.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/api/client.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/api/codes.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/api/index.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/api/machines.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/api/sessions.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/api/stubs.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/api/types.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/api/users.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/auth/AdminRoute.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/auth/AuthContext.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/auth/AuthProvider.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/auth/ProtectedRoute.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/layout/AppShell.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/layout/PageHeader.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/layout/Sidebar.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/layout/Topbar.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/layout/icons.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/Badge.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/Button.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/ConfirmDialog.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/Drawer.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/Input.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/Modal.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/Panel.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/Spinner.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/States.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/StatusDot.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/Table.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/TableSkeleton.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/dialogStack.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/status.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/toast-context.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/components/ui/toast.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/auth/LoginPage.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/codes/CancelCodeDialog.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/codes/GenerateCodeModal.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/codes/SupportCodesPage.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/codes/hooks.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/machines/BulkRemoveMachinesDialog.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/machines/DeleteMachineDialog.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/machines/KeyRevealModal.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/machines/MachineDetailDrawer.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/machines/MachineKeysModal.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/machines/MachinesPage.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/machines/RemoveMachineDialog.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/machines/hooks.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/sessions/EndSessionDialog.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/sessions/JoinSessionModal.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/sessions/RemoveSessionDialog.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/sessions/SessionsPage.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/sessions/hooks.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/users/CreateUserModal.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/users/DeleteUserDialog.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/users/EditUserModal.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/users/PasswordField.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/users/PermissionsField.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/users/UsersPage.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/users/hooks.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/features/users/password.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/lib/time.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/lib/useClipboard.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/lib/useRelayStatus.ts
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/main.tsx
/d/claudetools/projects/msp-tools/guru-connect/dashboard/src/vite-env.d.ts

View File

@@ -0,0 +1,21 @@
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/auth.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/auth_logout.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/changelog.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/downloads.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/enroll.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/machine_keys.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/releases.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/removal.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/sessions.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/sites.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/users.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/agent_keys.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/enrollment_keys.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/jwt.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/password.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/token_blacklist.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/viewer_token_registry.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/config.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/agent_keys.rs

View File

@@ -0,0 +1,21 @@
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/enrollment_keys.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/events.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/machines.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/releases.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/sessions.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/sites.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/support_codes.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/tenancy.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/users.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/main.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/metrics/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/middleware/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/middleware/rate_limit.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/middleware/security_headers.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/relay/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/session/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/support_codes.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/utils/ip_extract.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/utils/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/utils/validation.rs

View File

@@ -0,0 +1,42 @@
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/auth.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/auth_logout.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/changelog.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/downloads.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/enroll.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/machine_keys.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/releases.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/removal.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/sessions.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/sites.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/api/users.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/agent_keys.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/enrollment_keys.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/jwt.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/password.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/token_blacklist.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/auth/viewer_token_registry.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/config.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/agent_keys.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/enrollment_keys.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/events.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/machines.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/releases.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/sessions.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/sites.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/support_codes.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/tenancy.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/db/users.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/main.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/metrics/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/middleware/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/middleware/rate_limit.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/middleware/security_headers.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/relay/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/session/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/support_codes.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/utils/ip_extract.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/utils/mod.rs
/d/claudetools/projects/msp-tools/guru-connect/server/src/utils/validation.rs

View File

@@ -0,0 +1,25 @@
# GuruConnect agent review — gemini — 2026-06-05
This code review of the GuruConnect agent (v2/SPEC-016/SPEC-018) identifies several critical and high-severity findings related to session security, privilege escalation, and resource management.
### Prioritized Findings List
| SEVERITY | FILE:LINE | FINDING | CONCRETE FIX |
| :--- | :--- | :--- | :--- |
| **CRITICAL** | `websocket.rs:43` | **Insecure Auth via Query String.** API keys and JWT tokens are passed in the URL query string. These are often logged by reverse proxies (nginx/IIS) and visible in process monitors, leading to session takeover or credential theft. | Move credentials to the `Authorization` header or the Protobuf handshake payload. |
| **CRITICAL** | `sas_service.rs:136` | **Command Injection in SYSTEM SAS Service.** The named pipe server reads a string, trims it, and matches it against `"sas"`. However, if the command protocol evolves or is misused, a lack of strict length/format validation on a SYSTEM-owned pipe is a high-risk surface for local privilege escalation (LPE). | Use a fixed-size byte enum or a proper Protobuf-serialized command structure for IPC. |
| **HIGH** | `update.rs:197` | **Insecure Auto-Update (No Signature).** Updates are verified via SHA-256 checksum sent over the same channel as the binary. A MITM attacker can serve a malicious binary + matching checksum. | Implement RSA/Ed25519 signature verification using an embedded public key before `install_update`. |
| **HIGH** | `credential_store.rs:309` | **LPE via `icacls` Subprocess.** The agent calls `icacls.exe` to secure the `cak_` store. If an attacker places a malicious `icacls.exe` in the search path (before the agent's working directory or in a user-writable path), the SYSTEM service will execute it. | Use the absolute path `C:\Windows\System32\icacls.exe` or use Win32 `SetNamedSecurityInfoW` API directly. |
| **HIGH** | `session.rs:498` | **Session DoS via Blocking Consent.** `prompt_consent` runs on `spawn_blocking`, but the main loop `await`s it. While heartbeats might survive a 60s timeout, a user ignoring the box stalls all other control messages (like Disconnect or AdminRestart). | Run the consent prompt as a background task that sends the response to the server without stalling the main message-processing loop. |
| **MEDIUM** | `main.rs:260` | **Service Run Panic Boundary.** `run_managed_agent_service` uses `catch_unwind` but runs inside a `tokio` runtime. Panics in spawned tasks or certain async contexts may still escape or cause the runtime to hang without triggering the SCM recovery. | Ensure the top-level `block_on` is wrapped, but also add a custom `PanicHook` to ensure the process exits cleanly so the SCM can restart it. |
| **MEDIUM** | `sas_service.rs:46` | **Pipe Squatting/Race.** The SAS service pipe uses a hardcoded name and `PIPE_UNLIMITED_INSTANCES`. An attacker starting before the service could squat the name. | Use `FILE_FLAG_FIRST_PIPE_INSTANCE` when creating the first pipe instance to ensure the service owns the name. |
| **LOW** | `config.rs:335` | **Config Exposure.** `EmbeddedConfig` contains `api_key` and `enrollment_key`. While it's in the binary, it's easily extractable via strings/binwalk. | The spec mentions `cek_` is low-sensitivity, but `api_key` (legacy) should be scrubbed from the binary after the first successful enrollment. |
---
### Overall Assessment
The codebase is well-structured and follows the SPEC-016/018 requirements closely, particularly the DPAPI integration and the transition to a SYSTEM service. However, it relies heavily on "security by obscurity" in its transport (credentials in URLs) and lacks a robust trust anchor for auto-updates. The use of shell-outs (`icacls`, `powershell`) from a SYSTEM context is a significant LPE risk vector.
### Single Most Important Thing to Fix
**Implement Cryptographic Signature Verification for Auto-Updates.**
The current checksum-only approach allows any attacker capable of intercepting the WebSocket or API traffic (e.g., via a compromised relay or DNS) to gain SYSTEM-level code execution on the entire agent fleet by pushing a "mandatory" update.

View File

@@ -0,0 +1,3 @@
[ask-gemini] file not found: agent/src/bin/sas_service.rs
EXIT=2 files=34
EXIT=0 files=34

View File

@@ -0,0 +1,46 @@
# GuruConnect dashboard review — gemini — 2026-06-05
This code review focuses on the provided TypeScript/React dashboard files for GuruConnect. The codebase is generally well-structured with a strong emphasis on accessibility and clean state management using TanStack Query. However, several high-priority security and correctness issues were identified.
### Prioritized Findings
1. **[SEVERITY: HIGH] Non-Atomic User Updates**
* **File:** `3_hooks.ts:50` (in `useUpdateUser`)
* **Problem:** Updating a user's core profile and their permissions involves two sequential API calls (`updateUser` then `setUserPermissions`). If the first succeeds but the second fails (e.g., network drop, server error), the user is left in an inconsistent state. The UI attempts to warn the user, but this does not prevent the underlying data drift.
* **Fix:** Implement a single atomic `PATCH /api/users/:id` endpoint on the server that accepts both core fields and permissions in one transaction. Update the frontend to use this single call.
2. **[SEVERITY: HIGH] JWT Exposure in SessionStorage**
* **File:** `AuthProvider.tsx:16`
* **Problem:** The authentication JWT is stored in `sessionStorage`. While this prevents persistence across browser restarts, it remains fully accessible to any script on the same origin. An XSS vulnerability anywhere in the dashboard would allow an attacker to steal the operator's session token.
* **Fix:** Move JWT handling to an `httpOnly`, `Secure`, `SameSite=Strict` cookie managed by the server. Remove token management from the frontend entirely, letting the browser attach the cookie automatically.
3. **[SEVERITY: HIGH] Client-Side Enforcement of Security Guards**
* **File:** `EditUserModal.tsx:48`
* **Problem:** The logic preventing an admin from demoting themselves (which would lock them out of the admin plane) is implemented strictly in the frontend. The code explicitly notes that "the server ... does NOT currently block a self-role-demotion." Relying on the client for authorization logic is a security risk.
* **Fix:** Implement the self-demotion and self-disable guards on the server within the `AdminUser` extractor or the update handler. The server must be the final authority on these state transitions.
4. **[SEVERITY: HIGH] Sensitive Tokens in URL Query Strings**
* **File:** `JoinSessionModal.tsx:34` (in `buildViewerUrl`)
* **Problem:** The session-scoped viewer token is passed to the relay via a WebSocket query parameter (`token=...`). URLs are frequently logged by reverse proxies, load balancers, and browser history, potentially leaking the short-lived but powerful viewer tokens.
* **Fix:** Pass the token via the `Sec-WebSocket-Protocol` header or a custom `Authorization` header if the WebSocket client/relay supports it. If query params must be used, ensure the relay is configured to never log the full URL.
5. **[SEVERITY: MEDIUM] Hand-Maintained API Type Mirrors**
* **File:** `types.ts:1`
* **Problem:** All API response shapes are hand-maintained mirrors of the Rust source. This is highly prone to "type drift," where a backend change (e.g., a field rename or type change) is not reflected in the frontend, leading to silent runtime failures or `undefined` values in the UI.
* **Fix:** Use a tool like `ts-rs` or `specta` in the Rust backend to automatically generate TypeScript definitions from the actual API structs. This ensures the frontend and backend are always in sync.
6. **[SEVERITY: MEDIUM] Premature Session Clearing on Network Failure**
* **File:** `AuthProvider.tsx:55` (in `restore`)
* **Problem:** During the initial app load, if the `getMe` call fails for *any* reason (including a transient network error with status 0), the `clearSession()` function is called, wiping the user's token. A technician on a flaky connection would be logged out repeatedly.
* **Fix:** Only call `clearSession()` if the error status is explicitly `401 Unauthorized`. For network errors (status 0) or server errors (5xx), the app should keep the token and show a "Retry" or "Offline" state.
7. **[SEVERITY: LOW] Deprecated Clipboard API Usage**
* **File:** `useClipboard.ts:25`
* **Problem:** The hook uses `document.execCommand("copy")` as a fallback for non-secure contexts. This API is deprecated and has inconsistent support in modern browsers.
* **Fix:** Rely exclusively on the `navigator.clipboard` API. Since the dashboard should only be served over HTTPS, the "non-secure context" fallback is largely unnecessary.
### Overall Assessment
The GuruConnect dashboard is architecturally sound and leverages modern React best practices effectively. The UI components are robust and accessibility-conscious. However, the security model relies too heavily on client-side logic and `sessionStorage`, which are vulnerable points in a remote-access platform. The most critical area for improvement is moving security-sensitive state transitions (like user role management) and session handling (JWT storage and token transmission) to more secure, server-enforced patterns.
### The Single Most Important Fix
**Move the JWT to an `httpOnly` cookie.** This single change significantly hardens the platform against session hijacking via XSS, which is the most likely attack vector for a browser-based support console.

View File

@@ -0,0 +1,3 @@
[ask-gemini] file not found: dashboard/src/App.tsx
EXIT=2 files=66
EXIT=0 files=66

View File

@@ -0,0 +1,29 @@
# GuruConnect server review — gemini — 2026-06-05
I have completed an independent code review of the GuruConnect server based on the provided Rust source files. Below is the prioritized list of findings, categorized by severity, followed by an overall assessment and the most critical recommendation.
### **Priority Findings List**
| SEVERITY | FILE:LINE | FINDING | CONCRETE FIX |
| :--- | :--- | :--- | :--- |
| **CRITICAL** | `downloads.rs:142` | **Insecure Default API Key:** The permanent agent download endpoint uses a hardcoded default `"managed-agent"` API key if none is provided. This is a weak, well-known credential that grants any agent immediate persistent access if enabled on the server. | Remove the default fallback. Require a valid, high-entropy API key or a session-scoped enrollment key for all permanent agent downloads. |
| **CRITICAL** | `main.rs:163` | **Sensitive Credentials in Local File:** On first run, the server writes the initial admin password to `.admin-credentials`. While it attempts to set `0o600` permissions, this file remains a high-value target for local privilege escalation or accidental exposure in backups/logs. | Provide the initial admin password via a one-time environment variable or secure secret manager. If a file must be used, ensure it is deleted automatically after the first successful login. |
| **HIGH** | `main.rs:430+` | **Missing Rate Limiting on Admin CRUD:** Sensitive admin endpoints (`/api/users`, `/api/releases`, etc.) lack per-IP rate limiting. A compromised low-privilege account or a brute-force attack on the admin plane could cause DoS or data corruption. | Apply the `RateLimiter` middleware to all `/api/users`, `/api/releases`, and `/api/sites` management routes. |
| **HIGH** | `removal.rs:104` | **TOCTOU in Machine Removal:** The legacy hard-delete path for machines performs a `get_machine_by_agent_id` lookup, then an optional `uninstall` command, then a `DELETE`. A machine could reconnect or be modified between the lookup and the delete, leading to inconsistent state or missed audits. | Wrap the removal logic in a database transaction and use `SELECT ... FOR UPDATE` or conditional `DELETE` statements (e.g., `WHERE agent_id = $1 AND updated_at = $2`) to ensure atomicity. |
| **HIGH** | `relay.rs:664` | **Unbounded Input Queue in Viewer:** The viewer's input channel has a capacity of 64 messages. While `try_send` is used (dropping excess), a large number of viewers could still pressure the agent's inbound bandwidth if not throttled correctly at the broadcast level. | Implement a global per-session input rate limit (not just per-viewer) to protect the agent from aggregate input flooding by multiple technicians. |
| **MEDIUM** | `auth.rs:320` | **Weak Password Policy:** The server only enforces a minimum length of 8 characters for new passwords. It does not check for complexity, common patterns, or use a "pwned password" list. | Integrate a password strength library (e.g., `zxcvbn`) and reject common passwords. Increase the minimum length to 12+ characters. |
| **MEDIUM** | `jwt.rs:207` | **Lack of Token Refresh Mechanism:** The server relies on 24-hour JWTs with no explicit refresh token flow. This encourages long-lived sessions and increases the impact of a stolen token. | Implement short-lived access tokens (e.g., 15 mins) and long-lived refresh tokens stored securely (e.g., `HttpOnly` cookies). |
| **MEDIUM** | `downloads.rs:188` | **Path Sanitization Limit:** `sanitize_filename` truncates at 32 characters but doesn't check for reserved Windows filenames (e.g., `CON`, `PRN`, `NUL`). | Use a more robust sanitization library or explicitly reject reserved Windows filenames to prevent issues on the client-side download. |
| **LOW** | `rate_limit.rs:94` | **In-Memory Rate Limit Persistence:** Rate limit state is purely in-memory. A server restart clears all lockouts/counters, allowing an attacker to resume brute-forcing immediately. | (Optional) Periodically persist the `RateLimitState` to the database or use a fast-access cache like Redis for distributed/persistent rate limiting. |
| **LOW** | `events.rs:252` | **Audit Log Growth:** The `connect_session_events` table grows indefinitely with every connection and admin action. There is no automated pruning or archival logic visible. | Implement a background task to prune or archive audit events older than a configured retention period (e.g., 90 days). |
---
### **Overall Assessment**
The GuruConnect server demonstrates a strong focus on security fundamentals, particularly in its use of **Argon2id** for password/enrollment hashing, **SHA-256** for per-agent keys, and the implementation of a **session-scoped viewer token** model that successfully closes common relay-hijacking vulnerabilities. The architecture is modular and tenancy-ready, and the "fail-closed" approach to authentication (especially in the `agent_ws_handler`) is commendable.
However, the presence of an insecure default API key in the download path and the reliance on local files for sensitive bootstrap credentials represent significant "low-hanging fruit" for attackers. The concurrency controls in the removal paths also show potential for TOCTOU races that could complicate auditing and state management in high-load environments.
### **Single Most Important Thing to Fix**
**Remove the default `"managed-agent"` API key fallback in `downloads.rs:142`.**
This is a "backdoor" by default; any permanent agent download without an explicit key results in a credential that an attacker can predict and use to attempt unauthorized machine registration if the server's `AGENT_API_KEY` environment variable happens to match this weak default. All credentials should be high-entropy and unique.

View File

@@ -0,0 +1,3 @@
[ask-gemini] file not found: server/src/api/auth.rs
EXIT=2 files=42
EXIT=0 files=42

View File

@@ -0,0 +1,83 @@
# GuruConnect agent review — grok — 2026-06-05
**All required files read via `read_file` (absolute paths, full content via large `limit` where needed; identity.json read first per project rules). No other files explored, no modifications made, no further tools used after the mandated reads. Review strictly limited to the 34 listed source files + identity.**
**User context (from mandatory identity read):** Mike Swanson (mike, admin) on GURU-5070.
### Prioritized Findings
**[SEVERITY: CRITICAL] transport/websocket.rs:44-66 (and cross-references: main.rs:389 (support_code path), config.rs:441 (default), session/mod.rs:119 (connect call), viewer/transport.rs:65 (viewer token), install.rs:462 (protocol URL token), enroll.rs:195 (body but related))**
Problem: Authentication material (`api_key` (post-enroll `cak_`), `support_code`, `machine_uid`, hostname, `agent_id`) is placed directly into the WebSocket connection URL as query parameters (`agent_id=...&api_key=...&support_code=...&machine_uid=...`). This is done before `connect_async`. Even over `wss://`, query strings are commonly logged (server access logs, reverse proxies, CDNs, browser history/debug tools, Referer leakage). `support_code` is the binding secret for attended/temp sessions; `cak_` (as `api_key`) is the long-lived per-machine operating credential. No post-TLS initial auth frame or subprotocol is used.
Concrete fix: Perform the unauthenticated WS connect (or use a distinct unauth bootstrap), then immediately send the first protobuf `Message` carrying credentials (or a signed token) as binary payload. Alternatively, use a WebSocket subprotocol for auth handshake or HTTP upgrade header (where supported). Remove all secrets from the URL. Update all call sites and the server contract.
**[SEVERITY: CRITICAL] transport/websocket.rs:104-143 (try_recv) + 147-169 (recv); session/mod.rs:385-392 (call site in main loop); viewer/transport.rs:27-60 (similar pattern)**
Problem: `try_recv` does `tokio::task::block_in_place(|| Handle::current().block_on(async { ... timeout(..., stream.next()) }))` (and `recv` does direct block_on under lock). This is called from the main session loop (`run_with_tray`) on every iteration and from viewer receive task. Anti-pattern in async (especially current-thread or work-stealing runtimes); risks thread starvation, deadlocks under contention, or breaking cooperative scheduling. The 1ms timeout poll + VecDeque buffering compounds it. `connected` flag is racy with the stream lock.
Concrete fix: Use purely async non-blocking patterns (e.g., `tokio::time::timeout` directly on the locked stream without `block_in_place`, or a dedicated recv task that forwards over an mpsc channel to the session). Make `WebSocketTransport` fully async-friendly; expose a proper `Stream` or async `recv` only.
**[SEVERITY: HIGH] session/mod.rs:625 (handle_consent_request) + 602-656 (the await + comment) + 337-359 (main loop) + 441-454 (heartbeats only in Idle)**
Problem: `handle_consent_request` does `spawn_blocking` for the `MessageBoxW` (correct for blocking UI) but the caller `.await`s it directly inside the main `run_with_tray` loop. During the entire user think-time (up to server CONSENT_TIMEOUT_SECS ~60s), the loop is suspended: no heartbeats, no status, no `try_recv` processing, no tray polling, no service_shutdown checks. Comments acknowledge this but claim it's "safe" because of server timeouts; the loop also does not send heartbeats while in the consent path (only Idle/Streaming branches do). A slow/denied consent can cause server-side timeout of the agent or missed stop signals.
Concrete fix: Drive consent off the main loop (e.g., spawn a task that owns the transport send for the response only; keep the loop pumping heartbeats/status/service flag via a separate small state machine or channel). Or restructure so consent does not block the pump.
**[SEVERITY: HIGH] config.rs:264-282 (extract_support_code) + 248-252 (RunMode::TempSupport) + 369-372 (main) + session/mod.rs:782 + 839 (is_support_session) + transport/websocket.rs:57-59 (passes in query) + main.rs:301-304 (filename detection)**
Problem: 6-digit numeric support codes are extracted from the executable filename (split on `-`/`_`/`.` or last 6 chars) with no checksum, expiry, or strength check in the agent. The code is then passed verbatim in the WS URL query for session binding. Filename-based secrets are easily guessable/brute-forceable (especially if server has no strong rate limiting or short TTL on codes) and leak via process listings, installers, or shared binaries. No binding proof (e.g., HMAC) is added client-side.
Concrete fix: Treat support codes as high-entropy one-time secrets only (server-enforced); do not derive from or embed in filenames for production attended sessions. Add client-side length/charset validation and consider a short MAC over the code + nonce if the wire protocol allows. Document that binding/enforcement is server-side only.
**[SEVERITY: HIGH] main.rs:548-554 + 570-575 (LoadCakError paths in resolve_agent_credential) + credential_store.rs:169-195 (load_cak) + 109-154 (store_cak with C1 readback) — good intent but ...; cross with service/mod.rs:414 (run_managed_agent_service) and main.rs:482 (run_permanent_agent_managed)**
Problem: The detailed `LoadCakError` classification (Io permission_denied vs. Decrypt tamper vs. plain Io) and fail-fast "refuse to re-enroll" logic is excellent and correctly wired for the SYSTEM service path. However, on the interactive `PermanentAgent` first-run path (`run_permanent_agent_managed`), if the service install fails (not elevated), it falls back to `run_agent_mode(None)` which will hit the same "store present but unreadable" or "no credential" error and can still attempt enrollment in some flows. The ACL is applied via `icacls` Command (credential_store.rs:345) after `create_dir_all` but before secret write (good TOCTOU ordering), yet any failure in `icacls` (localized SIDs, path quoting, policy) silently leaves a weaker store. `store_cak` does immediate C1 readback, which is strong.
Concrete fix: Make the interactive managed fallback path also refuse enrollment if a store exists but is unreadable in-context (surface the exact `LoadCakError` message). Add verification that the post-`icacls` ACL actually denies non-admins (test read as low user) or at least log the raw `icacls` stdout on failure. Consider a pure-Win32 ACL path for the directory as belt-and-suspenders.
**[SEVERITY: MEDIUM] encoder/h264.rs:368-375 (ProcessInput) + 310 (ProcessOutput) + 236-252 (reinit) + many unsafe blocks; decoder.rs:176-204 (similar NOTACCEPTING/NEED_MORE_INPUT/ STREAM_CHANGE dance) + 278-297 (negotiate loop with `negotiated` guard); encoder/mod.rs:105-132 (factory fallback)**
Problem: The H.264 MF paths are heavily unsafe COM with manual streaming state (`streaming`, stream IDs, force_keyframe). The first-cut synchronous drain/retry logic for `MF_E_NOTACCEPTING` / `MF_E_TRANSFORM_NEED_MORE_INPUT` / `STREAM_CHANGE` / `TYPE_NOT_SET` is complex and has a "negotiated" guard to avoid infinite loops, but a single missed state transition, dimension change mid-stream, or MFT that behaves differently on real hardware can drop frames or panic/unwrap in the unsafe paths. Encoder factory falls back to raw on init error (good), but per-frame errors in `encode` surface and can stall the session loop (session/mod.rs:552: `if let Ok(encoded) = ...` silently drops). No sequence number or integrity on the wire frames beyond protobuf.
Concrete fix: Add defensive checks (e.g., never trust MFT-reported sizes without validation against capture; bound the drain loops). Make H.264 failures increment a counter and force a codec renegotiation or hard switch to raw after N consecutive errors instead of per-frame silent drop. Add end-to-end frame sequence + checksum (or rely on transport) for wire integrity. Validate all `unwrap_or` / `expect` in the MF paths.
**[SEVERITY: MEDIUM] session/mod.rs:153-187 (panic::catch_unwind around capture::primary_display / create_capturer / encoder / InputController) + 551 (capture in loop); capture/dxgi.rs:332 (Drop ReleaseFrame) + gdi.rs (manual ReleaseDC etc.); many `.ok()` on Release**
Problem: Capture/encoder/input init is wrapped in `catch_unwind` to prevent a bad driver/DXGI from taking down the whole agent (good pragmatism), with GDI fallback. However, the runtime capture path (`capturer.capture()`) is not protected the same way; a panic there drops the frame but the `?` / `if let` in the loop can leave state inconsistent. DXGI/GDI Drops do best-effort `.ok()` ReleaseFrame / DeleteObject etc.; a prior Acquire without matching Release (or double-release on error paths) can leak or crash the device. `last_frame` in DxgiCapturer is dead.
Concrete fix: Wrap the per-frame `capture()` + `encode()` in the streaming branch with a similar catch_unwind + "mark capturer bad, fall back or idle" recovery. Make resource release strict (e.g., use scopeguard or ensure ReleaseFrame is called exactly once per successful Acquire, even on early returns). Remove dead `last_frame`.
**[SEVERITY: MEDIUM] config.rs:290-348 (read_embedded_config) + 314 (rposition for MAGIC_MARKER) + 325 (length from bytes) + 338 (serde_json::from_slice); 383 (load priority); main.rs:300 (has_embedded_config check)**
Problem: Executable is mutated by appending `GURUCONFIG` + u32 LE length + JSON. The reader seeks from end (last 64KB), does `rposition`, then trusts the length field and does an unchecked slice + `from_slice`. A malicious or corrupted appended blob (truncated length, huge length, or JSON that deserializes to attacker-controlled `server_url` + `enrollment_key`) can cause the agent to connect to an attacker relay or exfiltrate the machine. No signature, no upper bound on length beyond the search buffer, no validation that the marker is in the expected "appended" region (post-PE).
Concrete fix: Add a strict max size for embedded config (e.g., 16KB). After locating the marker, validate that the config_start is after the PE end (or at least sanity-check the JSON fields: server_url must be wss/https to a known allowlist, enrollment_key length/format). Prefer an embedded signature (ed25519 over the config blob) verified at load time before trusting any enrollment material or server_url.
**[SEVERITY: MEDIUM] identity.rs:249-331 (run_powershell + CIM queries for machine_uid) + 192 (primary_disk_serial script) + 221 (query_cim_property); 289 (kill on timeout)**
Problem: `machine_uid` (used for enrollment dedup + connect identity) is derived by shelling out to `powershell.exe -Command "..."` (hardcoded scripts, no user data interpolated — good) with a 10s wall-clock timeout + kill. Output is captured but never logged (good). However, the scripts rely on CIM/WMI providers that can be slow, disabled, or return localized data; `primary_disk_serial` parses `Index<TAB>serial` with `splitn`. A wedged provider or unexpected output can cause fallback to `persisted_uid` (random + file). On re-image the MachineGuid path is intentionally volatile (documented), but the whole path is used for server-side collision/revocation decisions.
Concrete fix: Add stronger output validation (e.g., serials must look like plausible hardware IDs, not empty after normalize). Consider a pure-Rust or narrow WMI binding for the two scalar reads to remove the shell dependency entirely. Cache failures explicitly and surface "machine_uid used fallback (see logs)" in AgentStatus so operators know the dedup signal is weak.
**[SEVERITY: MEDIUM] update.rs:163-184 (verify_checksum) + 318 (perform_update) + 189 (TODO comment) + 63 (dev_insecure_tls in download); same pattern in enroll.rs:285-304 + 296 (dev_insecure_tls)**
Problem: Update integrity is a SHA-256 checksum delivered over the *same* channel as the binary (explicitly noted in comments as "transport integrity, not tamper defense"). `dev_insecure_tls` (debug + env only) disables cert verification for the version check + download. On success the old binary is renamed to `.old` and new copied in place while running; restart is detached. No signature verification (the TODO acknowledges this). A compromised/evil server (or MITM when insecure) can serve a matching checksum + malicious binary.
Concrete fix: Add (or implement the TODO for) an embedded public key + signature over the manifest + binary (or at minimum over the checksum + version). Keep the TLS bypass strictly debug-only and consider removing the env escape hatch. Verify signature *before* `install_update`.
**[SEVERITY: LOW] (multiple files) pervasive `#[allow(dead_code)]`, `unwrap_or_default`, best-effort `.ok()`, and incomplete paths (chat, multi-display dirty rects, VP9/HEVC, X buttons, etc.); config.rs:442 (`"dev-key"` default); hard-coded prod URLs in several places.**
Problem: Indicates incomplete implementation surface. Dead paths + silent ignores can hide bugs. The dev-key default in env fallback is a footgun if a managed config load fails.
Concrete fix: Audit and either implement or remove the dead_code items. Remove or guard the `"dev-key"` default behind a stronger "debug build only" + explicit warning. Centralize server URLs.
### Overall Assessment
The reviewed agent code demonstrates strong awareness of the Windows security model (SYSTEM service for SAS + managed agent, DPAPI+ACL credential store with readback verification + TOCTOU ordering, explicit consent for attended sessions via topmost MessageBox, tiered SAS delivery, panic-catch around risky capture/encode init, modifier release on focus loss/blur, focus-gated global keyboard hook for system keys, re-image-stable `machine_uid` derivation with documented fallbacks, and detailed error classification for enrollment/credential paths). The protobuf wire protocol, idle-vs-streaming resource release, codec negotiation with hardware guard + raw fallback, and heartbeat/status discipline are all reasonable. Many sharp edges (COM lifetime, DXGI access lost, MF streaming back-pressure) have explicit handling or guards.
However, the implementation is still "first-cut / Task N" in several critical areas (H.264 is compile-verified only; many `#[allow(dead_code)]`; async/sync mixing is fragile). The review surface is entirely client-side; trust is placed heavily in the (unreviewed) relay for session binding, consent gating, revocation, and auth enforcement.
**The single most important thing to fix:** Move all authentication and session-binding material (`api_key`/`cak_`, `support_code`, `machine_uid`) out of WebSocket URL query parameters (transport/websocket.rs and all call sites) into a post-connect authenticated protobuf message (or proper WS subprotocol). This is the highest-leverage security win: it directly mitigates secret leakage that enables session takeover, unauthorized access, and impersonation across both persistent and attended flows, independent of TLS. Everything else (async hygiene, consent loop suspension, H.264 robustness, update signing) is important but secondary to not putting the crown jewels in the most commonly logged part of the connection.
All findings are derived strictly from the provided file contents.

View File

@@ -0,0 +1,3 @@
[ask-grok] file not found: agent/src/bin/sas_service.rs
EXIT=2 files=34
EXIT=0 files=34

View File

@@ -0,0 +1,111 @@
# GuruConnect dashboard review — grok — 2026-06-05
**Independent code review: GuruConnect dashboard (TypeScript/React operator console)**
All 66 files were read via `read_file` (absolute paths exactly as specified). Review is strictly limited to the provided files. No modifications were made. Backend (Rust relay, protobuf wire protocol, DB, JWT minting/claims, session manager, support-code redemption, SQL, agent auth) is out of scope; findings are restricted to what is observable or implied by the dashboard code + its API client.
Findings are prioritized (CRITICAL → HIGH → MEDIUM → LOW). Format per request: `[SEVERITY: X], file:line, problem, concrete fix`. Focus areas were applied literally. Only concrete, evidenced issues are listed.
### CRITICAL
**[SEVERITY: CRITICAL], dashboard/src/features/sessions/JoinSessionModal.tsx:29-36**
The short-lived viewer token (which the relay uses to grant `control` or `view_only` access to a live remote session, binding to a specific `session_id`) is placed directly into the query string when building the WS viewer URL:
```ts
const qs = new URLSearchParams({
session_id: token.session_id,
viewer_name: viewerName,
token: token.token, // !!!
});
return `${wsProto}//${window.location.host}/ws/viewer?${qs.toString()}`;
```
(See: buildViewerUrl, the effect that calls mintViewerToken, the "Relay viewer URL" FieldReveal copy target, JoinSessionModal:108-112 comments, sessionsApi.mintViewerToken, and the fact that the relay reads ViewerParams from query per the comment.)
**Problem**: Query-string tokens appear in browser history, server access logs (relay + any reverse proxy), proxy logs, copy-paste artifacts, and can be bookmarked/shared. This is the capability that actually attaches a viewer to an agent's screen/stream. Even with short `expires_in_secs`, a leaked token for a live attended or managed session enables immediate session viewing or takeover. The client has no additional binding (e.g., origin, viewer IP) visible here.
**Fix**: Move the token out of the query string. Preferred: pass via `Authorization: Bearer` header on the WS upgrade or a subprotocol (`Sec-WebSocket-Protocol`). If the current relay wire format requires query params for the viewer plane, (a) make the minted token one-time or single-use after first successful attach, (b) bind it to the caller's IP or a very short TTL (<< current), and (c) document the exposure + require operators to treat the entire URL as a single-use secret. At minimum, stop surfacing the full URL as a casually copyable field.
**[SEVERITY: CRITICAL], dashboard/src/auth/AuthProvider.tsx:7 (STORAGE_KEY), 16 (sessionStorage.getItem + tokenRef), 66-73 (login), 42-64 (restore), 75-84 (logout), combined with client.ts:86-87 and all privileged API calls**
The primary dashboard JWT (used for `GET /me`, listing machines/sessions/codes, `POST /codes`, `POST /sessions/:id/viewer-token` (the control/view mint), admin user/machine operations, etc.) has no client-visible expiration handling, refresh, max lifetime, or idle timeout. It lives in `sessionStorage` (survives reloads within the tab lifetime) + an in-memory ref and is blindly attached as `Bearer` on every request.
**Problem**: Compromise of the operator workstation (XSS in the console, malware, devtools exfil, physical access while tab is open, or shoulder-surf of a token during debugging) yields long-lived privileged access. Because `mintViewerToken` re-uses the same bearer, the attacker can also obtain per-session control/view tokens for any machine/session the account can see. Logout is best-effort; 401 is the only forced teardown. No `exp` parsing or proactive re-auth is present.
**Fix**: Server should issue short-lived access tokens (or rotate the main token) + refresh tokens (or a refresh endpoint that requires the old token + rotation). Client must: (a) parse and respect `exp` (or an explicit `expires_in`) from LoginResponse, (b) proactively refresh or force logout before expiry, (c) add an idle/activity timeout that clears the session after N minutes of inactivity, and (d) consider binding the token to the browser tab lifetime more strictly. Treat the current long-lived bearer as equivalent to persistent remote access creds.
### HIGH
**[SEVERITY: HIGH], dashboard/src/features/codes/api/codes.ts:37-38 (cancelCode) + SupportCodesPage.tsx:21-24 (canCancel) + 114-136 (actions) + GenerateCodeModal + hooks**
Support code value (the `XXX-XXX-XXX` string read aloud to the end user, the single-use bearer for attended session start) is used as a path segment: `` `/api/codes/${encodeURIComponent(code)}/cancel` ``. The client also renders `code.session_id`, `client_machine`, `client_name`, `connected_at`, and consent state directly from the polled list response. No additional client-side binding verification or proof-of-possession step exists before offering cancel or trusting "connected" state.
**Problem**: An attacker with a dashboard JWT (see CRITICAL auth finding) can enumerate active codes via `listCodes` and cancel pending/connected ones (DoS on support workflows) or observe which codes have bound to which sessions/machines. The code-to-session binding and single-use guard are entirely server-side; the dashboard is a pure reflection of whatever the `/codes` and `/sessions` responses claim. A server-side race or mis-binding between code redemption and session creation would be invisible to (and unmitigated by) the client.
**Fix**: (Defense-in-depth on client) Treat the human code as a high-value secret in the UI: minimize how long it is rendered in clear, add explicit "this code is now bound to session X for machine Y — confirm before cancel" extra confirmation when status==="connected", and surface the exact `session_id` + consent_state with a warning on the cancel dialog. Server must remain authoritative for binding and single-use enforcement; client should not be the only thing preventing reuse.
**[SEVERITY: HIGH], dashboard/src/features/machines/MachinesPage.tsx:56 (isAdmin), 228-250 (conditional Keys/Remove buttons), 380-396 (modals), UsersPage.tsx:42+161 (self checks), EditUserModal.tsx:92-120 (lockSelfDemotion + isSelf guards), AdminRoute.tsx:15-56, AuthContext.tsx:10+93 (isAdmin = role === "admin"), and equivalent hasPermission checks throughout**
All privileged UI affordances (bulk remove, per-agent key mint/revoke, user create/edit/delete, admin routes) are gated exclusively by client-side `isAdmin` (exact string compare on `user.role`) or `hasPermission(p)` (`user?.permissions.includes(p) ?? false`). These derive the `canControl`/`canView` passed to JoinSessionModal and the selection rails.
**Problem**: These are only UI hints. A desync (role string casing, permission array containing non-canonical values, future role like "super-admin", or a bug in the AuthProvider memo) can show/hide actions incorrectly. Self-lockout guards (disable own account, demote self from admin) are client-only in the modal; the server comment in the code acknowledges it does not currently block self-demotion. While the server re-enforces (AdminUser extractor, 403s, self-delete 400), the client state is what the operator sees and what drives which buttons are even rendered.
**Fix**: Keep the client gates but make them derive from a single, narrow, well-commented `can*` helper that exactly mirrors the documented server rules (or fetch effective capabilities from `/me` or a dedicated endpoint). Add explicit client-side rejection + toast for the self-demotion/disable cases even if the server would later 4xx. Widen the `Role | string` / `Permission | string` handling only after logging the unexpected value.
### MEDIUM
**[SEVERITY: MEDIUM], dashboard/src/api/client.ts:65-82 (extractError), 108-113 (401 path), 118-127 (success body handling) + callers that surface raw messages**
Error normalization handles two envelopes but falls back to raw `res.text()` (trimmed if <300 chars) for non-JSON and for certain machines routes (plain `&'static str`). Mutation error toasts (e.g. DeleteMachineDialog, BulkRemove, users hooks) directly render `err.message`.
**Problem**: Server error bodies can leak internal details, hostnames, IPs (from events), or implementation strings into the operator UI and any logs/toasts that capture them. No redaction or classification of error text is performed before display. 401 after the handler still throws, which is mostly fine but means some error paths can double-surface.
**Fix**: In `extractError` (or a post-processing step in the toast surfaces), treat unknown/plain-text errors as opaque. Prefix or categorize them ("Server error: ...") rather than dumping the body verbatim. Review the Rust error responses for machines/users/sessions/codes to ensure they never emit sensitive fields on failure paths.
**[SEVERITY: MEDIUM], dashboard/src/features/machines/MachinesPage.tsx:372 (rowKey={(m) => m.id}), 66-88 (selected Set keyed by agent_id + reconciliation), 78-88 (useEffect that reconciles against live agent_ids), hooks.ts:12-18, api/machines.ts:18-19 (paths use agent_id), and Machine type (both `id` and `agent_id` present)**
React table keys use `m.id` while selection state, bulk operations, detail drawers, key modals, remove flows, filters, and all API paths consistently use `agent_id`. Reconciliation logic keys off `agent_id`.
**Problem**: If `id` (DB/synthetic) and `agent_id` (agent-reported stable identity) can ever diverge in lifetime or uniqueness (soft-delete + re-enroll, purge paths, historical rows), React list reconciliation, selection survival across polls, and "remove selected" correctness become fragile. The bulk bar and dialogs already have careful visible-set logic; a key mismatch adds another surface for state skew.
**Fix**: Standardize on the stable business key (`agent_id`) for `rowKey`, React keys, and selection. Keep `id` only for display or history joins if the server distinguishes them. Add a comment in types.ts and the page explaining the two identifiers.
**[SEVERITY: MEDIUM], dashboard/src/features/users/hooks.ts:58-85 (useUpdateUser — two sequential awaits), 82 (onSettled always invalidates) + EditUserModal:122-134 (sendPermissions logic) + types.ts:99-104 (UpdateUserRequest)**
Update is split (core fields then optional permissions PUT). On permissions failure it throws a synthetic Error; `onSettled` (not `onSuccess`) invalidates.
**Problem**: Partial success window exists (role/enabled changed, permissions not yet or failed). The error message rewrite is good, but an operator who closes the modal or navigates away during the window sees inconsistent state until the next refetch. No client-side transaction or "permissions pending" indicator.
**Fix**: Either make the server accept a single combined update, or surface an explicit "core saved; retry permissions" state in the modal (keep it open, highlight the PermissionsField) instead of fully rejecting the mutation. The current `onSettled` + rewritten error is already better than most, but the UX for the partial case is still a toast + stale-looking row until poll.
### LOW / Architecture & Maintainability
**[SEVERITY: LOW], dashboard/src/api/stubs.ts:1-18 + api/index.ts:6 (export * as stubsApi)**
Stubs for sessions/users that return `unknown[]` are still exported under `stubsApi` (with comments "Pass 1", "do NOT build UI against these yet"). Real implementations live in sessions.ts / users.ts and are imported directly by features.
**Problem**: Dead/exported-but-unused surface that can mislead readers or cause accidental import of the stub versions. Minor namespace pollution.
**Fix**: Delete or clearly mark as `@deprecated / internal scaffolding` and remove from the barrel export. (Trivial.)
**[SEVERITY: LOW], dashboard/src/features/*/hooks.ts (multiple): refetchInterval + staleTime hardcoded (machines 20s/10s, sessions 8s/4s, codes 7s/3.5s, health 15s), no `enabled: !document.hidden` or jitter, no error backoff**
Polling is aggressive and constant.
**Problem**: Unnecessary load on the relay when the tab is backgrounded or the operator is idle on another page. No adaptive behavior.
**Fix**: Add a small `useVisibility` or `document.visibilityState` guard to pause refetchInterval when hidden. Consider light jitter or a single shared "activity" signal. Keep the intervals tight for consent/watch use cases but make them configurable or at least documented.
**[SEVERITY: LOW], dashboard/src/api/types.ts (entire file, especially ROLES/PERMISSIONS/ROLE_DEFAULT_PERMISSIONS, all the `| string` widenings, hand-maintained comment at top) + consumers that do exact string compares or `.includes`**
Types are manually kept in sync with server/src/api/*.rs. Many fields are widened to tolerate drift.
**Problem**: Classic source-of-truth split. A new role/permission, a renamed field, or a new CodeStatus will either cause runtime surprises or require coordinated changes across Rust + this file. The defensive `| string` + filter-to-canonical in EditUserModal etc. mitigate but do not eliminate the maintenance tax.
**Fix**: (Long-term) Generate the TS types from the Rust API (OpenAPI, prost, or a small schema export). In the interim, add a test or build step that diffs the TypeScript literals against a checked-in snapshot from the server.
Additional minor notes (not elevated to findings):
- `useClipboard` execCommand fallback is deprecated but acceptable for an internal tool in non-secure contexts.
- No `dangerouslySetInnerHTML`; React text is used safely.
- Dialog stack + focus trap + inert + restore in Modal/Drawer is correctly implemented and shared.
- Password generation (Web Crypto + rejection sampling) + one-time copy + wipe-on-close is done well.
- `encodeURIComponent` is consistently used on all path segments (agent_id, code, session id, user id, key id).
- StrictMode double-mint guards in GenerateCodeModal and JoinSessionModal are present and correct.
### Overall Assessment
The dashboard is a clean, well-factored React + react-query SPA with strong separation between API client, typed feature hooks, and presentational components. UI/UX details around one-time secrets (codes, keys, passwords, viewer tokens), consent state, self-lockout, stacked dialogs, and partial-failure reconciliation are thoughtful and above average for an internal operator tool. The posture "client renders what the server says; server is the authority" is consistently followed (AdminRoute, permission checks, etc. are treated as hints + defense-in-depth).
The code is skeptical of its own state in the right places (reconciliation effects, mintedFor refs, onSettled invalidation, `| string` widenings) but trusts the authenticated API responses for the security-critical bindings (code → session, viewer token access mode, consent_state, machine ownership).
**Single most important thing to fix**: The CRITICAL placement of viewer tokens (the actual remote-control capability tokens) into query strings in `JoinSessionModal.buildViewerUrl`. This is the direct bridge from the operator console to live agent sessions. Everything else (auth lifetime, client-side gates, support-code exposure) is important but secondary to preventing easy leakage of tokens that let an observer or attacker attach to a victim's desktop/session. Fix the wire format or the mint+display path for these tokens first.

View File

@@ -0,0 +1 @@
EXIT=0 files=66

View File

@@ -0,0 +1,62 @@
# GuruConnect server review — grok — 2026-06-05
**All specified files have been read via the `read_file` tool (absolute paths, with follow-up offset reads for the two files whose initial reads were truncated by the default ~1000-line limit: `api/enroll.rs` and a no-op offset on the short `api/mod.rs`). No other files were read. No modifications were made (no writes, edits, terminal mutations, etc.). Review is strictly limited to the content of the 21 listed files.**
### Prioritized Findings
**[SEVERITY: CRITICAL], projects/msp-tools/guru-connect/server/src/api/downloads.rs:94 (and 101, 139, 146, 163, 190, 232, ~same pattern in viewer/support/agent paths)**
`Response::builder()... .header(CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", filename)) ... .body(...).unwrap()` (and equivalent for error bodies). `sanitize_filename` (236) only maps chars and truncates; it does not escape `"` or other header-special chars. A `company`/`site` query param containing `"` (or other values that make the header invalid) causes the builder to fail; the `.unwrap()` panics the handler task. These endpoints are unauthenticated/public.
**Concrete fix:** Never `.unwrap()` a `Response::builder()`. Use `axum::response::Response::builder()` with proper error path returning a 500 JSON/body. For `Content-Disposition`, either reject inputs containing `"`, `\`, or control chars before formatting, or use RFC 5987/8187 `filename*=` encoding (or `percent_encoding`). Add server-side length caps before embedding/sanitizing.
**[SEVERITY: CRITICAL], projects/msp-tools/guru-connect/server/src/api/downloads.rs:169 (hardcoded), +169-179 (embedding), +107-149 (support path), +78-104 (viewer)**
`download_agent` hardcodes `server_url: "wss://connect.azcomputerguru.com/ws/agent"` and falls back to `api_key: "managed-agent"`. The top-of-file comment claims support downloads are "with embedded code"; the implementation only mutates the filename and serves the raw base binary (no `MAGIC_MARKER` + length + JSON append like the permanent path at 196-198). `download_*` handlers take only `Query`, no `State`/`AppState`/`Config`. Dev/staging builds will embed prod relay URLs; temp support sessions get no embedded config at all.
**Concrete fix:** Add the required server URL + default API key (and any other embed params) to `Config`/`AppState`. Make all three download handlers take `State`, read the values, and (for support) perform equivalent embedding of a support-oriented config (or a minimal `{code, server_url}`) using the same MAGIC format. Remove the hardcoded prod string. Update the module docstring.
**[SEVERITY: HIGH], projects/msp-tools/guru-connect/server/src/api/auth.rs:41 (login), 205 (change_password), +86 (verify), no rate-limiter use anywhere in the file**
No rate limiting, progressive backoff, or account lockout on the primary password auth path (contrast with the sophisticated per-`(site_code, ip)` + timing-equalizer + failure recording in `enroll.rs:265`). Unlimited online brute-force / password stuffing is possible against any username. `get_user_by_username` + `verify_password` cost is paid on every attempt.
**Concrete fix:** Inject a rate-limiter (modeled on `state.rate_limits.enroll`) into `AppState`. Key on `(username.lower(), client_ip)` (or global + per-user). Record failures on bad password (and perhaps unknown user after constant-time work). Enforce in `login` (and consider `change_password`). Return 429 + opaque message after threshold. Add the same to the auth extractor or middleware for defense-in-depth.
**[SEVERITY: HIGH], projects/msp-tools/guru-connect/server/src/auth/token_blacklist.rs:85 (`cleanup_expired`)**
`retain(|token| jwt_config.validate_token(token).is_ok())` re-runs full signature verification + claim parsing + the redundant post-decode `exp` check on every token in the `HashSet` (full JWT strings stored verbatim). Cleanup is admin-triggered only; a large backlog (many users, long 24h tokens) does expensive crypto work and keeps the whole token (claims + sig) in RAM until then.
**Concrete fix:** Change the internal storage to `(token: String, exp: i64)` (capture `exp` at `revoke` time from a cheap unsigned parse or from the minting site, since we only ever insert tokens we just validated). Cleanup then becomes a pure time comparison; no `validate_token` calls. (The existing `is_revoked` path still needs the string for the WS check.)
**[SEVERITY: HIGH], projects/msp-tools/guru-connect/server/src/api/auth_logout.rs:116 (`revoke_user_tokens`)**
Handler is a complete stub: checks `is_admin()`, logs a warning that it is "NOT IMPLEMENTED", and unconditionally returns 501. The preceding comment claims "This currently only revokes the admin's own token as a demonstration" — the code does neither. The TODO describes exactly the missing session-tracking table. An admin endpoint documented for revoking other users' tokens does nothing.
**Concrete fix:** Either (a) delete the route + handler until the prerequisite table exists, or (b) implement the minimal version that at least revokes the caller's own token (or all currently-blacklistable artifacts for the target via the viewer registry + current blacklist) and make the comment + error message match reality. Do not leave a 501 admin "revoke anyone" endpoint that claims partial behavior.
**[SEVERITY: MEDIUM], projects/msp-tools/guru-connect/server/src/api/downloads.rs:169 + enroll.rs:169 (and similar), config.rs:36**
`jwt_secret` and `database_url` are `Option` in `Config::load()` with silent fallbacks. `download_agent` never consults config at all. Several paths (login, mint viewer token, agent key issuance) will fail at runtime (or embed wrong values) if secrets are missing. No startup enforcement or `debug`-only relaxation.
**Concrete fix:** In `Config::load()`, require `JWT_SECRET` (and `DATABASE_URL` if not in a documented dev mode). Surface a clear error. Wire the resolved values into `AppState` and the download paths (see CRITICAL above). Consider a `require_jwt_secret` helper used by `JwtConfig::new` call sites.
**[SEVERITY: MEDIUM], projects/msp-tools/guru-connect/server/src/api/* (multiple) + auth/* consistency**
Duplicated error shapes: `api/auth.rs` + `auth_logout` use `ErrorResponse { error: String }`; `machine_keys`, `enroll`, `sessions`, `sites`, `removal` define/use a richer `ApiError { detail, error_code, status_code }` (sometimes re-exported). Role/permission allow-lists are repeated verbatim in `users.rs` (122, 210, 339, 511). Boilerplate `db unavailable` / `map_err(500)` blocks appear in almost every handler.
**Concrete fix:** Standardize on the `ApiError` envelope everywhere (documented in `.claude/standards`). Extract small helpers (`require_db`, `map_db_err`, `require_admin` already partially exist). Centralize the `["admin","operator","viewer"]` + permission lists (or load from a const table). Use `thiserror` + `IntoResponse` for the common 4xx/5xx cases to kill the duplication.
**[SEVERITY: MEDIUM], projects/msp-tools/guru-connect/server/src/api/enroll.rs:255 (and similar length checks), users.rs:133/378, auth.rs:271**
Only a minimum length (8) for passwords; no upper bound, no complexity, no control-char stripping on usernames/hostnames/machine_uid/site_code before DB or rate-limiter keys. `machine_uid` and `hostname` from unauthenticated enroll can be arbitrarily long (subject only to HTTP limits).
**Concrete fix:** Add reasonable caps (e.g. 128-256 chars) + sanitization (trim + reject controls / NUL) on all user-controlled identifiers early, before rate-limit keys or DB. Reject or truncate passwords >128. Make the checks consistent and return the standard `ApiError`.
**[SEVERITY: MEDIUM], projects/msp-tools/guru-connect/server/src/api/sessions.rs:169 (`mint_viewer_token` registration) + auth_logout.rs:74 (take_for_user + revoke)**
The registry + blacklist cross-revoke for viewer tokens (addressing the "CRITICAL #2" scenario documented in comments and the test in `auth_logout.rs:231`) is only as good as the `sub` matching and the fact that the WS also calls `is_revoked` on the exact string. The mint path trusts `user.user_id` from the login JWT; no additional binding to the specific dashboard session that minted it. A compromised long-lived login JWT can still mint viewer tokens until it is revoked.
**Concrete fix:** (Defense-in-depth) Consider also stamping a `jti` (or short session id) from the login token into the viewer token claims (or the registry entry) and checking it on the WS side, or shortening the login JWT TTL + forcing refresh. At minimum, ensure the test coverage stays and that the registry prune + blacklist path is exercised on every logout.
**[SEVERITY: LOW], projects/msp-tools/guru-connect/server/src/db/agent_keys.rs + api/machine_keys.rs:215 (key_belongs_to_machine)**
Good scoping (the revoke checks ownership via the FK before `revoke_agent_key`), but `keyed_machine_ids` (158) and the startup use are only in comments here. No visible index or query hint for the `revoked_at IS NULL` filter in hot paths.
**Concrete fix:** Ensure a partial index on `(machine_id) WHERE revoked_at IS NULL` (or equivalent) exists in migrations; the code already relies on it for the "active only" semantics.
**[SEVERITY: LOW], projects/msp-tools/guru-connect/server/src/api/users.rs:574 (client_ids parsing) + 589 (set without existence check)**
`set_client_access` parses the supplied strings as UUIDs and stores them. No verification that the UUIDs actually exist in any clients table (or that the caller is allowed to see them). Silent creation of dangling references.
**Concrete fix:** After parsing, do a cheap `SELECT ... WHERE id = ANY(...)` (or equivalent) and reject the whole set if any are unknown (or at least log). This is the same pattern used for machine/site existence checks elsewhere.
**[SEVERITY: LOW], projects/msp-tools/guru-connect/server/src/auth/jwt.rs:212 + 289 (post-decode exp checks) + 200 (validation config)**
Redundant `if claims.exp < now` after `validate_exp = true` + decode. `leeway = 0` is strict (good), but clock skew between dashboard and server is possible. Viewer tokens have a separate validator.
**Concrete fix:** Remove the redundant checks (or keep one with a tiny documented leeway for prod clock drift). Consider exposing a small configurable leeway.
Other lower-severity notes (duplication of `get_user_permissions(...).unwrap_or_default()`, best-effort audit writes that can fail silently after the main action, `OptionalUser` and dead-code annotations for future native remote control, provisional collision heuristic in enroll with extensive comments, etc.) exist but are not prioritized above.
### Overall Assessment
The reviewed surface (auth layer, JWT + viewer tokens + logout revocation, per-agent + per-site enrollment keys, the public enroll path, admin CRUD for machines/users/releases/sites/keys, downloads, and supporting DB helpers) demonstrates careful, defense-in-depth thinking in several high-risk areas: Argon2id + timing equalization for the unauthenticated enroll path, explicit cross-site + collision gating with audits, parameterized queries everywhere (no SQLi surface), plaintext secrets returned exactly once and never logged, IDOR scoping on key revocation, tiered `control` vs `view_only` stamped inside signed viewer tokens, and the registry+blacklist mechanism to close the post-logout viewer token window. The code is heavily commented with references to prior audit findings ("CRITICAL #1", "CRITICAL #2", SPEC-016 items) and includes component tests that exercise the exact revocation paths.
Weaknesses are primarily in the **unauthenticated download surface** (panic vectors + hardcoded prod config + docstring/implementation mismatch), absence of brute-force protection on the human login path (despite sophisticated protection on the machine enroll path), boilerplate and inconsistent error handling, and reliance on in-memory structures (full JWTs, viewer registry) whose cleanup/lifetime is only partially exercised in the provided code. Core relay/session-manager state, the actual WS handlers that consume viewer tokens, and the support-code issuance/matching logic live outside these files, so races, deadlocks, or binding integrity issues in the live control plane cannot be assessed from this slice alone.
**Single most important thing to fix:** The downloads handlers (`download_viewer`/`support`/`agent`). They are unauthenticated, accept attacker-controlled query parameters directly into header construction and (for the agent path) into embedded config, perform no `State`-driven configuration, contain multiple `.unwrap()` panic sites on the response builder, and have a documented-vs-actual behavior gap for support sessions. A single malformed or malicious download request can panic a worker, serve binaries pointing at the wrong relay, or (if embedding is required for temp sessions) produce non-functional agents. This is a high-exposure, low-trust boundary that should be hardened first.

View File

@@ -0,0 +1 @@
EXIT=0 files=21

View File

@@ -0,0 +1,49 @@
# GuruConnect server review — grok — 2026-06-05
**Code review complete.** I used `read_file` on every listed absolute path exactly as specified and performed the review across all of them. No files were created, edited, or otherwise modified.
I reviewed only the provided files (GuruConnect server Rust sources for the relay, session manager, DB layer, auth-adjacent middleware, support codes, IP extraction, validation, metrics, and main wiring). Focus was strictly on the requested categories: (1) security (JWT/viewer tokens, session binding/takeover, support-code single-use + binding, authz/IDOR, input validation, SQLi, secrets, rate-limit bypass via IP extraction or otherwise); (2) correctness/concurrency (races in relay/session state, reattach/supersede/reap, lock ordering, panics/unwraps, error handling, resource leaks, consent gate); (3) protocol/wire integrity (bounds, decode paths, protobuf usage); (4) architecture/maintainability (defensive patterns, tenancy stubs, dead code, best-effort DB, config sensitivity).
All DB access uses parameterized `sqlx::query*` + `.bind` (no SQLi surface visible). No compile-time `query!` macros. Protobuf via prost with explicit size caps on WS upgrades. Viewer auth path (post-Task work) requires a session-scoped minted viewer token (not a raw login JWT), with signature/expiry/purpose validation, explicit blacklist check on the WS plane, and a claim `session_id` == requested session binding check that returns 403 on mismatch. Agent auth for `cak_` (per-agent) keys performs identity binding (overrides client-supplied `agent_id` with the key's canonical machine `agent_id`; suppresses client `machine_uid` for keyed agents so it cannot override the key→machine binding). Support-code consumption is atomic under the in-memory write lock (`consume_for_bind` only from Pending) + best-effort durable DB conditional UPDATE (`consumed_at IS NULL AND status='pending' AND (no expiry or future)`). Reattach/supersede/reap use snapshot + `remove_session_if` recheck under the write lock to close TOCTOU. Consent gate (attended/support-code sessions) is enforced in `join_session` (`!allows_viewer()` returns None) and driven to completion in the agent handler before the main relay loop or StartStream. Rate limiting + lockouts (login, change-password, code-validate, enroll) are per-route, use the shared trusted-proxy-aware `client_ip` extractor, and include pre-handler lockout checks. Bounded WS message/frame sizes on both planes (4 MiB agent, 64 KiB viewer). Input flood constant declared (200/s) with "drop excess" intent.
**Prioritized findings** (only issues; no padding with "looks good" items). Lines are from the exact file contents returned by the tool calls. Paths use the `server/src/...` form for the crate layout (matching the listed absolute paths under `projects/msp-tools/guru-connect/server/src`).
- **[SEVERITY: HIGH] server/src/main.rs:198-225 (admin bootstrap block) + 216-223 (fallback)**: On first run (no users), a 16-char random password is generated, hashed, and the *plaintext* is written to a file named `.admin-credentials` in the server's current working directory. The `set_permissions(0o600)` is inside `#[cfg(unix)]` only; on Windows (the reported environment) it is a no-op and the file has default ACLs. On `fs::write` failure the plaintext is emitted via `info!` (visible in logs). The file is left on disk for the operator to read once and delete. This is a high-privilege bootstrap secret persisted in an uncontrolled location with weak cross-platform protection and a log fallback path.
**Concrete fix**: Do not write secrets to CWD files. Either (a) require the operator to supply the initial admin password via env/one-time flag/secret manager and error if none present on zero-user startup, or (b) print the password exactly once to stdout (with a strong "copy now" warning) and never persist it; remove the file-write path entirely. Apply Windows ACLs (e.g., via `icacls` or the `windows` crate) when the Unix path is taken. Never `info!` (or any log level) the generated password—only the "written to file" or "change immediately" guidance.
- **[SEVERITY: MEDIUM] server/src/relay/mod.rs:144-157 and parallel sites (e.g. 203-216, 319-334)**: `CONNECTION_REJECTED_*` audit events (no-auth, invalid code, invalid API key) are emitted via `db::events::log_event(..., Uuid::new_v4(), ...)` (non-null `session_id`). This inserts rows into `connect_session_events` with a random `session_id` that has no row in `connect_sessions` (the FK is nullable, but these are semantically dangling). Contrast with `log_enrollment_event` / `log_admin_removal` which correctly pass `NULL` for session-less events. Pollutes the audit trail and complicates joins/queries that assume a valid session anchor.
**Concrete fix**: Add (or extend) a `log_connection_rejected` helper (or make `log_event` accept `Option<Uuid>` for `session_id` and update its INSERT) and pass `None` for these pre-session auth failures. Keep the rich `details` JSON + `ip_address`. Update the callers in `agent_ws_handler` (and any viewer equivalents).
- **[SEVERITY: MEDIUM] server/src/support_codes.rs:182-183 (validate_code) + 181 (the server_url field in CodeValidation)**: The public (rate-limited but unauthenticated) `GET /api/codes/:code/validate` preview always returns a hardcoded `server_url: "wss://connect.azcomputerguru.com/ws/support"` when the code is Pending or Connected. This path does not exist in the mounted routes (`/ws/agent`, `/ws/viewer`); the modern attended flow uses a minted session-scoped viewer token on `/ws/viewer`. Any consumer of the validation response (or docs/tests) gets a wrong target. Additionally, `validate_code` (preview) intentionally accepts both Pending and Connected; this is fine for preview but means a correctly guessed/high-entropy code that has already been bound will still return `valid: true` + the (wrong) URL + session_id until the code is completed/cancelled.
**Concrete fix**: Remove `server_url` from `CodeValidation` (or make it a server-configured value derived from the incoming request or a single source of truth that matches the actual WS mounts and the viewer-token flow). Keep the preview behavior as-is (it must not consume), but consider returning a generic "already in use" or omitting the session_id for Connected codes if the preview is only meant to tell a technician "this code is claimable now."
- **[SEVERITY: MEDIUM] server/src/main.rs:652-658 (prometheus_metrics) + server/src/middleware/security_headers.rs:49-51 (and similar .parse().unwrap() sites)**: `registry.lock().unwrap()` + `encode(&mut buffer, &registry).unwrap()`. A poisoned lock or encode failure panics the handler. In `security_headers`, multiple `HeaderValue::from_static(...).unwrap()` (or equivalent `parse().unwrap()`) on literals for CSP, X-Frame-Options, etc. A future edit that introduces an invalid literal turns every response into a panic.
**Concrete fix**: Use `if let Ok(lock) = ...` or `.lock().expect("registry poisoned")` + graceful error response for metrics. For headers, use `const` `HeaderValue` or `.expect("statically valid security header")` / `unwrap_or_else(|_| /* static fallback */)` so construction cannot panic a live response.
- **[SEVERITY: MEDIUM] server/src/rate_limit.rs:140,212,238,346 etc. (all the `Mutex` maps) + main.rs wiring + 97 (MAX_TRACKED_IPS)**: Rate limiters (`RateLimiter`, `FailureLockout`, `EnrollLimiter`) and lockout state are purely in-memory (`Arc<Mutex<HashMap<IpAddr, ...>>>` or composite `(site_code, IpAddr)`). Process restart (deploy, crash, OOM) clears all windows and active lockouts. The 15-minute code-validate and enroll lockouts therefore give an attacker a fresh window on every restart. Pruning at `MAX_TRACKED_IPS` and opportunistic window expiry exist, but persistence is absent. The `client_ip` extractor (trusted-proxies gate) is the single root for all of this plus audit; defaults are safe (loopback only) and the logic + tests are strong, but any misconfiguration of `CONNECT_TRUSTED_PROXIES` (or a future multi-hop proxy) bypasses the per-IP controls for the entire fleet.
**Concrete fix**: Document the in-memory limitation explicitly (restarts reset brute-force windows). For the support-code and enroll surfaces, consider a small persistent store (bounded TTL table in the existing Postgres, or a sidecar) keyed the same way, or accept the risk and keep restart frequency low. Keep the trusted-proxy extraction as the single source of truth (already is) and add a startup warning + metrics for "number of trusted proxies" and "XFF used vs. direct."
- **[SEVERITY: MEDIUM] server/src/relay/mod.rs:51 (VIEWER_INPUT_EVENTS_PER_SEC) + comment at top of file + visible input path (input_forward task, join_session returning raw InputSender, mpsc(64) in register_agent)**: The constant and comment state that viewer→agent input (mouse/key `SendInput`) is capped at 200 events/sec per viewer with excess "DROPPED (coalesced away)" to prevent a compromised viewer from flooding the target. In the provided code the path is a plain `tokio::sync::mpsc` channel (buffer 64) pumped verbatim by the spawned forwarder; no token bucket, per-second counter, or explicit drop/coalesce is visible at the point where viewers receive their `InputSender` or in the pump. Bounded buffer gives backpressure (senders slow down), but a burst up to the buffer depth (or sustained rate limited only by WS + agent processing) can still be injected.
**Concrete fix**: Implement the declared policy where the `InputSender` is handed out (in the viewer connection handler, not shown in full but referenced) or in the forwarder: a small per-viewer (or per-session) rate limiter that drops or coalesces (e.g., keep latest mouse move) when over the cap. Wire the existing constant. If backpressure alone is intentional, update the comment to match reality and shrink the buffer.
- **[SEVERITY: LOW] server/src/db/users.rs:238-269 (user_has_client_access) + callers marked dead_code**: The function correctly implements "admin or (explicit client in list) or (zero restrictions = legacy all-access)". It does a `get_user_by_id` (for role) + exact match query + count query on every call. When multi-tenancy (Phase 4) activates and/or more surfaces use this for history/machine/session reads, any missed call site or path that takes a raw `agent_id` / session id from user input without going through an equivalent check is an IDOR. Several machine/session history endpoints and the client_access surfaces are still `#[allow(dead_code)]`.
**Concrete fix**: Extract a small `can_access_machine` / `can_access_session` helper (incorporating `current_tenant_id()` once Phase 4 lands). Prefer query-level filters (join or `WHERE` with the user's client list or "no restrictions" subquery) over N+1 post-filter in handlers. Add explicit tests for a restricted non-admin user being denied another client's machine history / session list.
- **[SEVERITY: LOW] server/src/main.rs:589-611 (CORS layer) + 583 (security headers order)**: CORS is narrowly scoped (exact prod origin + localhosts, credentials true, limited methods/headers) — good. However, the layer ordering (security headers after some routes, TraceLayer, etc.) and the fact that the SPA fallback + `/downloads` nest + wildcard 404s exist means any future public asset or mis-mounted route could leak. CSP still includes `'unsafe-inline'` for script/style (required for the current dashboard) and `ws: wss:`.
**Concrete fix**: Keep the narrow allowlist. Consider moving to a nonce- or hash-based CSP for the SPA if feasible in a future iteration (reduces reliance on unsafe-inline). Ensure the wildcard `any` 404s (`/api/*rest`, `/ws/*rest`) stay in place so unknown API/WS paths never reach the SPA index.html.
- **[SEVERITY: LOW] Multiple files (e.g. relay/mod.rs:575, session/mod.rs:428, releases.rs:23, many `#[allow(dead_code)]` for "TODO(native-remote-control)" and integration API)**: Large surface of functions/fields/types marked dead_code because the "native remote control" / full integration API is not yet wired. This is maintainability debt and increases the chance that a future change will activate insecure or un-reviewed paths (e.g. the older support-code mark paths that are superseded by the single-use consume, the `is_code_valid` etc. that are no longer on the hot bind path).
**Concrete fix**: As the integration work lands, either delete the superseded functions or gate them behind a feature flag + explicit review. Reduce the number of `too_many_arguments` allows by introducing the params structs already referenced in the comments.
**Overall assessment**: The reviewed code has clearly undergone deliberate, defense-in-depth security work on exactly the high-risk areas for a remote-support relay (viewer token binding + revocation + session claim enforcement, per-agent `cak_` identity binding that prevents client-supplied values from seizing another machine's session, atomic single-use support-code consumption at bind time with durable DB marker, trusted-proxy-aware IP extraction as the root for rate limiting + audit, consent gate before any viewer join or streaming, reattach/supersede/reap TOCTOU hardening via recheck-under-write-lock, WS message bounds on both planes, and soft-delete + filtered queries for operator purge). DB access is uniformly parameterized. Concurrency uses a consistent lock order (sessions before agents/machine_uids) and the `remove_session_if` helper. No obvious SQL injection, trivial session takeover, or missing authz on the active paths in these files. Weaknesses are primarily operational (secret handling for bootstrap), process-lifetime-only state for rate limiting, a few audit correctness nits, a hardcoded/wrong URL in a public response, and declared-but-not-fully-visible input rate enforcement. The large number of `#[allow(dead_code)]` + "Task N / SPEC-016 / Phase 4" comments indicate this is an evolving, partially implemented surface—future work must re-review the newly activated paths.
**Single most important thing to fix**: The bootstrap admin credential handling in `server/src/main.rs:198-225` (and the log fallback). It places a high-privilege random password on disk in the process CWD with platform-incomplete ACLs and a path that can emit the secret in logs. For a remote-access control plane product this is the clearest high-impact, low-effort operational security gap in the reviewed files. Replace the file-write + log-fallback with an operator-supplied value or strict one-time stdout emission that is never persisted.

View File

@@ -0,0 +1 @@
EXIT=0 files=21

View File

@@ -0,0 +1,2 @@
# GuruConnect server review — grok — 2026-06-05

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# run-review.sh <gemini|grok> <subsystem> <files-list> <out-file>
set -uo pipefail
TOOL="$1"; SUB="$2"; LIST="$3"; OUT="$4"
ROOT="/d/claudetools"
case "$TOOL" in
gemini) SCRIPT="$ROOT/.claude/skills/agy/scripts/ask-gemini.sh" ;;
grok) SCRIPT="$ROOT/.claude/skills/grok/scripts/ask-grok.sh" ;;
*) echo "bad tool"; exit 2 ;;
esac
INSTR="You are doing an independent code review of the GuruConnect ${SUB} (a Rust/TypeScript remote-support/remote-control platform: agent <-> relay server <-> browser viewer, protobuf wire protocol, JWT auth, support-code session binding). Review ONLY the files provided. Produce a prioritized findings list. For EACH finding give: [SEVERITY: CRITICAL/HIGH/MEDIUM/LOW], file:line, the problem, and a concrete fix. Focus on: (1) security — auth/JWT, session takeover, support-code binding, input validation, SQL injection, secret handling, IDOR/authorization, rate-limit bypass; (2) correctness & concurrency — relay/session state races, deadlocks, panics/unwraps, error handling, resource leaks; (3) protocol/wire integrity; (4) architecture & maintainability. Be concrete and skeptical. End with a short overall assessment and the single most important thing to fix."
mapfile -t FILES < "$LIST"
echo "# GuruConnect ${SUB} review — ${TOOL} — 2026-06-05" > "$OUT"
echo "" >> "$OUT"
bash "$SCRIPT" review-files -i "$INSTR" "${FILES[@]}" >> "$OUT" 2>> "${OUT}.err"
echo "EXIT=$? files=${#FILES[@]}" >> "${OUT}.err"

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

@@ -0,0 +1,159 @@
-- Migration: 010_spec016_enrollment.sql
-- Purpose: SPEC-016 zero-touch per-site agent enrollment — server-side data model.
--
-- Adds the per-site enrollment-key table, a minimal sites table to anchor it,
-- and the machine-side columns the collision-gated self-registration flow needs.
--
-- Two-tier credential model (SPEC-016 §Security): a low-sensitivity, rotatable,
-- per-site ENROLLMENT KEY (the `cek_` secret stored hashed here) gates "may this
-- machine register at all", while the high-sensitivity per-machine `cak_`
-- operating credential (connect_agent_keys, migration 004) is minted on a
-- successful enroll. Compromise of an enrollment key is recovered by rotating one
-- site, not a fleet-wide re-key.
--
-- DEVIATION FROM SPEC (documented): SPEC-016 §DB-migration describes
-- `site_enrollment_keys.site_id` as `fk -> sites`, assuming a sites table already
-- exists. It does NOT — in the current schema "site" and "company/organization" are
-- free-text columns on connect_machines (migration 005), there is no relational
-- sites entity. This migration therefore CREATES a minimal `connect_sites` table
-- (the relational anchor the enrollment-key FK and the dashboard per-site key
-- display both require) keyed by a natural `site_code` and scoped per-tenant. It is
-- intentionally minimal (code + display name + tenant); richer site/company
-- modeling is left to future work. The free-text connect_machines.site /
-- .organization columns are untouched and continue to carry agent-reported labels.
--
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, ADD COLUMN IF NOT EXISTS. Applied on
-- server startup by sqlx::migrate!(); never pre-applied via psql. Ordered after 009.
-- See .claude/standards/gururmm/sqlx-migrations.md.
-- pgcrypto provides gen_random_uuid(); enabled in 001/004 but re-asserted for safety.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================================
-- connect_sites — relational anchor for per-site enrollment (see DEVIATION above)
-- ============================================================================
-- A site is the unit a single signed installer targets. `site_code` is the
-- non-secret, operator-facing identifier the installer carries and the agent sends
-- at /api/enroll (e.g. "ACME-PHX"). Uniqueness is per-tenant: the same human-chosen
-- code may legitimately exist in two tenants. tenant_id mirrors the nullable,
-- default-tenant-backfilled tenancy column used on every other scoped table
-- (migration 004); db::tenancy::current_tenant_id() resolves it for now.
CREATE TABLE IF NOT EXISTS connect_sites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Operator-facing site identifier the installer carries. Non-secret.
site_code TEXT NOT NULL,
-- Human-readable site / company display name for the dashboard.
display_name TEXT,
-- Default company label applied to machines enrolled at this site (mirrors the
-- free-text connect_machines.organization the agent otherwise self-reports).
company TEXT,
-- Tenancy-ready (Phase 4). Backfilled to the default tenant below.
tenant_id UUID,
-- RESERVED for future per-site enrollment POLICY work (SPEC-016 §out-of-scope):
-- default 'auto-approve'; a future 'pending-approval' value will gate new
-- enrollments. NOT enforced in Phase A — present so the policy SPEC needs no
-- schema change. Do not branch on this column yet.
enrollment_policy TEXT DEFAULT 'auto-approve',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Per-tenant uniqueness of the natural site_code so /api/enroll can resolve a site
-- deterministically within a tenant while the same code may exist across tenants.
-- COALESCE keeps the index usable while tenant_id is still nullable (Phase 1).
CREATE UNIQUE INDEX IF NOT EXISTS idx_connect_sites_tenant_code
ON connect_sites (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000001'::uuid), site_code);
-- Backfill the sites tenant_id to the default tenant (table is empty on a fresh DB;
-- no-op there, but keeps the migration self-consistent).
UPDATE connect_sites
SET tenant_id = '00000000-0000-0000-0000-000000000001'
WHERE tenant_id IS NULL;
-- ============================================================================
-- site_enrollment_keys — rotatable, hashed per-site enrollment secret + fingerprint
-- ============================================================================
-- Stores ONLY the Argon2id hash of the `cek_` secret; the plaintext is shown once
-- at issue/rotate and never recoverable. `version` is the monotonic rotation
-- counter; `fingerprint` is the non-secret short hex shown as `vN (XXXX)` in the
-- dashboard and baked into the installer filename. `active` marks the current key —
-- rotation flips the old key to active=false (blocking NEW enrollments from old
-- installers) and inserts a new active row; already-enrolled agents holding their
-- own `cak_` are unaffected. Multiple inactive (historical) rows may coexist per
-- site; at most one active row is intended (enforced by a partial unique index).
CREATE TABLE IF NOT EXISTS site_enrollment_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES connect_sites(id) ON DELETE CASCADE,
-- Argon2id hash of the `cek_` enrollment secret. Never the plaintext.
key_hash TEXT NOT NULL,
-- Monotonic rotation version (1, 2, 3, ...).
version INTEGER NOT NULL,
-- Non-secret short hex fingerprint code (the XXXX in `vN (XXXX)`), derived from
-- the secret. Stored so the dashboard / GET endpoint can show it without the
-- secret.
fingerprint TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Set when this key is rotated out (active flipped to false).
rotated_at TIMESTAMPTZ
);
-- Lookup index for the enroll hot path: resolve the active key for a site.
CREATE INDEX IF NOT EXISTS idx_site_enrollment_keys_site_active
ON site_enrollment_keys (site_id, active);
-- At most one ACTIVE enrollment key per site (the "current" installer key).
-- Partial unique index so any number of inactive historical rows may coexist.
CREATE UNIQUE INDEX IF NOT EXISTS idx_site_enrollment_keys_one_active
ON site_enrollment_keys (site_id)
WHERE active;
-- ============================================================================
-- connect_machines — site binding + enrollment-state collision gate
-- ============================================================================
-- machine_uid already exists (migration 008) with a partial UNIQUE index on
-- (machine_uid) WHERE machine_uid IS NOT NULL. SPEC-016 §item-1 / resolved-decision #4
-- call for the dedup key to be PER-TENANT — (tenant_id, machine_uid) — so the same
-- hardware legitimately present in two tenants stays two rows. tenant_id is the
-- scoping column that exists on connect_machines (migration 004); machines have no
-- direct site_id today, so site is tracked separately (site_id below) and tenancy is
-- the uniqueness scope, exactly as the spec states.
--
-- CRITICAL CONSTRAINT (why we ADD rather than REPLACE the 008 index here):
-- db::machines::upsert_machine (the live connect-path upsert) uses
-- `ON CONFLICT (machine_uid) WHERE machine_uid IS NOT NULL` as its conflict arbiter.
-- Postgres matches that arbiter to the EXACT index from migration 008. Dropping that
-- index would make the live upsert fail to find an arbiter and error at runtime —
-- breaking every un-keyed agent reconnect. So migration 008's global index is LEFT
-- IN PLACE (the connect path keeps working unchanged) and the per-tenant index is
-- added ALONGSIDE it. In single-tenant Phase 1 the two are equivalent (every row's
-- tenant_id is the default tenant), so the per-tenant index adds the SPEC-016 dedup
-- semantics without a redundant-uniqueness conflict: a (tenant, uid) pair that is
-- unique is also globally unique today. When multi-tenancy activates AND
-- upsert_machine's ON CONFLICT is updated to name (tenant_id, machine_uid), a future
-- migration drops the global 008 index. Documented as deferred; do not drop it now.
-- Optional FK to the site a machine enrolled under (NULL for legacy / support-code
-- machines that never enrolled through /api/enroll). A site change on re-enroll is
-- the "site move" SPEC-016 audits.
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS site_id UUID REFERENCES connect_sites(id) ON DELETE SET NULL;
-- enrollment_state: the collision gate (SPEC-016 §item-1/6). 'active' = live and
-- controllable (auto-approve posture); 'pending' = a machine_uid collision was
-- detected at enroll and an operator must confirm in the dashboard before the
-- endpoint may be controlled. Default 'active' so every legacy/connect-path row is
-- unaffected.
ALTER TABLE connect_machines
ADD COLUMN IF NOT EXISTS enrollment_state TEXT NOT NULL DEFAULT 'active'
CHECK (enrollment_state IN ('active', 'pending'));
-- Per-tenant machine_uid uniqueness (SPEC-016). Added ALONGSIDE migration 008's
-- global (machine_uid) index (see CRITICAL CONSTRAINT above — the connect-path
-- upsert's ON CONFLICT arbiter binds to the 008 index, which must survive). COALESCE
-- folds a NULL tenant_id to the default tenant so the index is well-defined while
-- tenancy is single-tenant (Phase 1); the WHERE clause excludes NULL machine_uid so
-- legacy un-keyed rows coexist freely.
CREATE UNIQUE INDEX IF NOT EXISTS idx_connect_machines_tenant_machine_uid
ON connect_machines (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000001'::uuid), machine_uid)
WHERE machine_uid IS NOT NULL;

1008
server/src/api/enroll.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,12 @@ pub mod auth;
pub mod auth_logout;
pub mod changelog;
pub mod downloads;
pub mod enroll;
pub mod machine_keys;
pub mod releases;
pub mod removal;
pub mod sessions;
pub mod sites;
pub mod users;
use axum::{
@@ -172,7 +175,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 +188,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 +211,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,
}))
}

217
server/src/api/sites.rs Normal file
View File

@@ -0,0 +1,217 @@
//! Site enrollment-key administration (SPEC-016, admin plane).
//!
//! Admin (dashboard JWT + admin role) endpoints for the per-site enrollment key
//! the dashboard surfaces and rotates:
//!
//! - `POST /api/sites/:id/enrollment-key/rotate` — regenerate the `cek_` secret,
//! bump the monotonic version, derive a new fingerprint, deactivate the prior
//! active key, and return the plaintext + fingerprint ONCE. Old installers can no
//! longer enroll NEW machines after this; already-enrolled agents (holding their
//! own `cak_`) are unaffected (SPEC-016 success-criterion #3). Doubles as
//! first-issue when a site has no key yet.
//! - `GET /api/sites/:id/enrollment-key` — read the CURRENT non-secret fingerprint
//! + version (never the secret). 404 if the site has no active key yet.
//!
//! Auth mirrors `api::machine_keys`: the [`crate::auth::AdminUser`] extractor gates
//! both routes, and they are mounted behind the JWT `auth_layer`.
//!
//! SECURITY: the plaintext `cek_` is returned exactly once (rotate response),
//! never persisted in plaintext and never logged. Read responses expose only the
//! version + fingerprint.
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::Serialize;
use uuid::Uuid;
use crate::auth::{enrollment_keys, AdminUser};
use crate::db;
use crate::AppState;
/// Standard error envelope (matches `api::machine_keys::ApiError`).
#[derive(Debug, Serialize)]
pub struct ApiError {
pub detail: String,
pub error_code: String,
pub status_code: u16,
}
impl ApiError {
fn new(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(),
}),
)
}
}
type ApiResult<T> = Result<T, (StatusCode, Json<ApiError>)>;
/// Response for a freshly rotated/issued enrollment key. `key` is present ONLY
/// here, once.
#[derive(Debug, Serialize)]
pub struct RotatedEnrollmentKey {
pub site_id: Uuid,
/// The plaintext `cek_` enrollment key. Shown exactly once — bake it into the
/// site installer now; the server keeps only its hash.
pub key: String,
/// Monotonic rotation version.
pub version: i32,
/// The non-secret short hex code (the `XXXX` in `vN (XXXX)`).
pub fingerprint: String,
/// Fully rendered operator-facing fingerprint, e.g. `v3 (7F2A)`.
pub fingerprint_label: String,
}
/// Non-secret current-key view for the GET endpoint.
#[derive(Debug, Serialize)]
pub struct EnrollmentKeyView {
pub site_id: Uuid,
pub version: i32,
pub fingerprint: String,
pub fingerprint_label: String,
pub active: bool,
}
fn require_db(state: &AppState) -> ApiResult<&db::Database> {
state.db.as_ref().ok_or_else(|| {
ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"DATABASE_UNAVAILABLE",
"Database not available",
)
})
}
/// Resolve a site by its UUID path segment, or a 404 envelope.
async fn resolve_site(db: &db::Database, site_id: Uuid) -> ApiResult<db::sites::Site> {
db::sites::get_site_by_id(db.pool(), site_id)
.await
.map_err(|e| {
tracing::error!("DB error resolving site: {}", e);
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"Internal server error",
)
})?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "SITE_NOT_FOUND", "Site not found"))
}
/// POST /api/sites/:id/enrollment-key/rotate — rotate (or first-issue) a site's
/// enrollment key. Returns the plaintext `cek_` + fingerprint once.
pub async fn rotate_enrollment_key(
AdminUser(admin): AdminUser,
State(state): State<AppState>,
Path(site_id): Path<Uuid>,
) -> ApiResult<(StatusCode, Json<RotatedEnrollmentKey>)> {
let db = require_db(&state)?;
let site = resolve_site(db, site_id).await?;
// Mint plaintext + Argon2id hash + fingerprint. Only the hash + fingerprint
// are persisted; the plaintext is surfaced once below.
let plaintext = enrollment_keys::generate_enrollment_key();
let key_hash = enrollment_keys::hash_enrollment_key(&plaintext).map_err(|e| {
tracing::error!("Failed to hash enrollment key: {}", e);
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"Failed to hash enrollment key",
)
})?;
let fingerprint = enrollment_keys::compute_fingerprint(&plaintext);
let new_key = db::enrollment_keys::rotate_key(db.pool(), site.id, &key_hash, &fingerprint)
.await
.map_err(|e| {
tracing::error!("DB error rotating enrollment key: {}", e);
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"Failed to rotate enrollment key",
)
})?;
let fingerprint_label =
enrollment_keys::render_fingerprint(new_key.version, &new_key.fingerprint);
// Audit WITHOUT key material (no plaintext, no hash).
if let Err(e) = db::events::log_enrollment_event(
db.pool(),
db::events::EventTypes::ENROLLMENT_KEY_ROTATED,
serde_json::json!({
"site_id": site.id,
"site_code": site.site_code,
"version": new_key.version,
"fingerprint": new_key.fingerprint,
"rotated_by": admin.username,
}),
None,
)
.await
{
tracing::warn!("[ENROLL] failed to write key-rotate audit event: {}", e);
}
tracing::info!(
"Admin {} rotated enrollment key for site {} to {}",
admin.username,
site.site_code,
fingerprint_label
);
Ok((
StatusCode::CREATED,
Json(RotatedEnrollmentKey {
site_id: site.id,
key: plaintext,
version: new_key.version,
fingerprint: new_key.fingerprint,
fingerprint_label,
}),
))
}
/// GET /api/sites/:id/enrollment-key — current non-secret fingerprint + version.
pub async fn get_enrollment_key(
AdminUser(_admin): AdminUser,
State(state): State<AppState>,
Path(site_id): Path<Uuid>,
) -> ApiResult<Json<EnrollmentKeyView>> {
let db = require_db(&state)?;
let site = resolve_site(db, site_id).await?;
let key = db::enrollment_keys::get_active_for_site(db.pool(), site.id)
.await
.map_err(|e| {
tracing::error!("DB error loading enrollment key: {}", e);
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"Internal server error",
)
})?
.ok_or_else(|| {
ApiError::new(
StatusCode::NOT_FOUND,
"NO_ENROLLMENT_KEY",
"Site has no active enrollment key",
)
})?;
let fingerprint_label = enrollment_keys::render_fingerprint(key.version, &key.fingerprint);
Ok(Json(EnrollmentKeyView {
site_id: site.id,
version: key.version,
fingerprint: key.fingerprint,
fingerprint_label,
active: key.active,
}))
}

View File

@@ -0,0 +1,191 @@
//! Per-site enrollment key minting, hashing, verification, and fingerprinting
//! (SPEC-016 zero-touch enrollment, auth layer).
//!
//! This is the low-sensitivity, rotatable side of the two-tier credential model
//! (SPEC-016 §Security). A per-site ENROLLMENT key (`cek_` prefix) gates "may
//! this machine register at all" at `POST /api/enroll`; a successful enroll mints
//! the high-sensitivity per-machine `cak_` operating credential
//! ([`crate::auth::agent_keys`]). Compromise of an enrollment key is contained to
//! one site and recovered by rotating it.
//!
//! Lifecycle owned here (the secret side):
//!
//! - [`generate_enrollment_key`] mints a high-entropy, `cek_`-prefixed plaintext
//! secret. Mirrors [`crate::auth::agent_keys::generate_agent_key`]'s entropy
//! approach (32 random bytes from the OS CSPRNG, hex-encoded) with a DISTINCT
//! prefix so the two key kinds are never confused in logs or storage. The
//! plaintext is shown to the operator exactly once at issue/rotate and is NEVER
//! persisted or logged.
//! - [`hash_enrollment_key`] / [`verify_enrollment_key`] use **Argon2id** (via
//! [`crate::auth::password`]). This DIFFERS from `cak_` (which uses SHA-256 for
//! a constant-shape equality lookup): SPEC-016 §2 explicitly requires the
//! enrollment key be "stored hashed (Argon2id, same as `cak_`/passwords)". The
//! trade-off is deliberate — enrollment keys are looked up by `(site, active)`
//! first (a small candidate set, usually one row) and only then verified, so the
//! per-verify KDF cost is bounded and not on a high-QPS path, while Argon2id
//! gives salted, GPU-resistant storage matching the password posture.
//! - [`compute_fingerprint`] derives the non-secret short HEX code shown as
//! `vN (XXXX)` (SPEC-016 resolved-decision #3 — hex, deliberately NOT the
//! GuruRMM word-style code, so the two products' artifacts are never visually
//! conflated).
//!
//! SECURITY: never log a plaintext key or its hash. Functions here return the
//! plaintext to the caller (issue/rotate endpoint) but emit no `tracing` output
//! containing key material.
use anyhow::Result;
use rand::RngCore;
use ring::digest;
/// Prefix marking a GuruConnect per-site enrollment key. Distinct from the
/// per-agent `cak_` prefix so the two key kinds are never confused.
pub const ENROLLMENT_KEY_PREFIX: &str = "cek_";
/// Number of random bytes behind an enrollment key (256 bits of entropy), matching
/// [`crate::auth::agent_keys`]. SPEC-016 §2 requires ≥256-bit.
const ENROLLMENT_KEY_RANDOM_BYTES: usize = 32;
/// Number of hex characters in the fingerprint code (the `XXXX` in `vN (XXXX)`).
/// Four hex chars = 16 bits — ample to let an operator tell two installers apart at
/// a glance; it is a non-secret display aid, not a security control.
const FINGERPRINT_HEX_LEN: usize = 4;
/// Generate a new high-entropy, `cek_`-prefixed per-site enrollment key (plaintext).
///
/// The returned string is the ONLY time the plaintext exists; the caller must
/// surface it to the operator once and store only [`hash_enrollment_key`] of it.
/// Uses the OS CSPRNG via `rand::rngs::OsRng`.
pub fn generate_enrollment_key() -> String {
let mut bytes = [0u8; ENROLLMENT_KEY_RANDOM_BYTES];
rand::rngs::OsRng.fill_bytes(&mut bytes);
format!("{}{}", ENROLLMENT_KEY_PREFIX, hex_encode(&bytes))
}
/// Hash an enrollment key for storage using Argon2id (SPEC-016 §2).
///
/// Delegates to [`crate::auth::password::hash_password`] so the KDF parameters and
/// salt generation match the password posture exactly. Returns the PHC-format
/// string Postgres stores in `site_enrollment_keys.key_hash`.
pub fn hash_enrollment_key(plaintext: &str) -> Result<String> {
crate::auth::password::hash_password(plaintext)
}
/// Verify a presented enrollment key against a stored Argon2id hash.
///
/// Returns `Ok(true)` on a match. A malformed stored hash or a mismatch yields
/// `Ok(false)` / an `Err` from the underlying verifier; the caller treats any
/// non-`Ok(true)` as a rejection. A cheap structural reject (`cek_` prefix) runs
/// first to skip the KDF on obviously-bogus input.
///
/// SECURITY: only compares; never logs the presented key or the hash.
pub fn verify_enrollment_key(presented: &str, stored_hash: &str) -> bool {
if !presented.starts_with(ENROLLMENT_KEY_PREFIX) {
return false;
}
crate::auth::password::verify_password(presented, stored_hash).unwrap_or(false)
}
/// Compute the non-secret short HEX fingerprint code for an enrollment key.
///
/// Derived as the first [`FINGERPRINT_HEX_LEN`] hex chars of the SHA-256 of the
/// plaintext secret, uppercased. This is a stable, non-reversible tag of the secret
/// (knowing the code does not reveal the key) used purely for display. Pair it with
/// the monotonic version via [`render_fingerprint`].
pub fn compute_fingerprint(plaintext: &str) -> String {
let d = digest::digest(&digest::SHA256, plaintext.as_bytes());
let hex = hex_encode(d.as_ref());
hex[..FINGERPRINT_HEX_LEN].to_ascii_uppercase()
}
/// Render the operator-facing fingerprint string `vN (XXXX)` (SPEC-016 §2).
///
/// `version` is the monotonic rotation counter; `code` is [`compute_fingerprint`].
/// Example: `render_fingerprint(3, "7F2A")` -> `"v3 (7F2A)"`.
pub fn render_fingerprint(version: i32, code: &str) -> String {
format!("v{} ({})", version, code)
}
/// Lowercase hex encoding without pulling in the `hex` crate (mirrors
/// [`crate::auth::agent_keys`]).
fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{:02x}", b);
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_key_is_prefixed_and_high_entropy() {
let key = generate_enrollment_key();
assert!(key.starts_with(ENROLLMENT_KEY_PREFIX));
assert_eq!(
key.len(),
ENROLLMENT_KEY_PREFIX.len() + ENROLLMENT_KEY_RANDOM_BYTES * 2
);
}
#[test]
fn generated_keys_are_unique() {
assert_ne!(generate_enrollment_key(), generate_enrollment_key());
}
#[test]
fn hash_and_verify_roundtrip() {
let key = generate_enrollment_key();
let hash = hash_enrollment_key(&key).expect("hash");
assert!(verify_enrollment_key(&key, &hash));
}
#[test]
fn verify_rejects_wrong_key() {
let key = generate_enrollment_key();
let other = generate_enrollment_key();
let hash = hash_enrollment_key(&key).expect("hash");
assert!(!verify_enrollment_key(&other, &hash));
}
#[test]
fn verify_rejects_unprefixed_input_without_touching_kdf() {
let key = generate_enrollment_key();
let hash = hash_enrollment_key(&key).expect("hash");
// A value lacking the cek_ prefix is structurally rejected before the KDF.
assert!(!verify_enrollment_key("not-a-key", &hash));
}
#[test]
fn verify_rejects_malformed_stored_hash() {
let key = generate_enrollment_key();
// A garbage stored hash must not panic and must reject.
assert!(!verify_enrollment_key(&key, "not-a-phc-hash"));
}
#[test]
fn fingerprint_is_stable_uppercase_hex_of_expected_len() {
let key = "cek_deadbeef";
let f1 = compute_fingerprint(key);
let f2 = compute_fingerprint(key);
assert_eq!(f1, f2);
assert_eq!(f1.len(), FINGERPRINT_HEX_LEN);
assert!(f1.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(f1, f1.to_ascii_uppercase());
}
#[test]
fn fingerprint_differs_per_key() {
assert_ne!(
compute_fingerprint("cek_aaa"),
compute_fingerprint("cek_bbb")
);
}
#[test]
fn render_fingerprint_matches_spec_shape() {
assert_eq!(render_fingerprint(3, "7F2A"), "v3 (7F2A)");
}
}

View File

@@ -4,6 +4,7 @@
//! validation for agents.
pub mod agent_keys;
pub mod enrollment_keys;
pub mod jwt;
pub mod password;
pub mod token_blacklist;

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

@@ -0,0 +1,141 @@
//! Per-site enrollment key database operations (SPEC-016 zero-touch enrollment).
//!
//! Backs the `site_enrollment_keys` table (migration 010). Stores ONLY the
//! Argon2id hash of the `cek_` secret plus the non-secret rotation metadata
//! (version, fingerprint, active flag). Computing the hash and minting the
//! plaintext is [`crate::auth::enrollment_keys`]'s job; this module is
//! hash-agnostic persistence and takes already-hashed values.
//!
//! Rotation invariant: at most one `active` row per site (enforced by a partial
//! unique index in migration 010). [`rotate_key`] deactivates the current active
//! row and inserts a new active one inside a single transaction so the invariant
//! is never transiently violated.
//!
//! All queries use runtime `sqlx::query()` / `sqlx::query_as()` per the codebase
//! convention (no compile-time `query!` macros, no `.sqlx` offline cache).
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
/// Per-site enrollment key record.
///
/// `key_hash` is the only representation of the secret the server stores; the
/// plaintext is shown once at issue/rotate and never persisted.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct EnrollmentKey {
pub id: Uuid,
pub site_id: Uuid,
pub key_hash: String,
pub version: i32,
pub fingerprint: String,
pub active: bool,
pub created_at: DateTime<Utc>,
pub rotated_at: Option<DateTime<Utc>>,
}
/// Fetch the active enrollment key for a site, if any.
///
/// This is the `/api/enroll` hot path: resolve the one active key whose hash the
/// presented `cek_` is verified against. The partial unique index guarantees at
/// most one active row, so `fetch_optional` is correct.
pub async fn get_active_for_site(
pool: &PgPool,
site_id: Uuid,
) -> Result<Option<EnrollmentKey>, sqlx::Error> {
sqlx::query_as::<_, EnrollmentKey>(
r#"
SELECT id, site_id, key_hash, version, fingerprint, active, created_at, rotated_at
FROM site_enrollment_keys
WHERE site_id = $1 AND active
"#,
)
.bind(site_id)
.fetch_optional(pool)
.await
}
/// Insert the FIRST enrollment key for a site at version 1 (initial issue).
///
/// Use [`rotate_key`] for subsequent rotations. Errors with a unique violation if
/// the site already has an active key (the caller should rotate instead).
#[allow(dead_code)] // Wired by site-admin issue flow; Phase A exposes rotation (which also covers first issue when none exists).
pub async fn insert_initial_key(
pool: &PgPool,
site_id: Uuid,
key_hash: &str,
fingerprint: &str,
) -> Result<EnrollmentKey, sqlx::Error> {
sqlx::query_as::<_, EnrollmentKey>(
r#"
INSERT INTO site_enrollment_keys (site_id, key_hash, version, fingerprint, active)
VALUES ($1, $2, 1, $3, true)
RETURNING id, site_id, key_hash, version, fingerprint, active, created_at, rotated_at
"#,
)
.bind(site_id)
.bind(key_hash)
.bind(fingerprint)
.fetch_one(pool)
.await
}
/// Rotate a site's enrollment key (SPEC-016 §2): deactivate the current active key
/// (if any) and insert a new active key at the next monotonic version, all in one
/// transaction.
///
/// Returns the newly-created active key. If the site has no key yet, this issues
/// version 1 (so rotation also serves as first-issue). The caller passes the
/// already-hashed new secret and its fingerprint; the plaintext is surfaced once by
/// the caller and never reaches this layer.
///
/// The transaction is what keeps the "at most one active key per site" invariant
/// (partial unique index) from being transiently violated between the UPDATE and
/// the INSERT.
pub async fn rotate_key(
pool: &PgPool,
site_id: Uuid,
new_key_hash: &str,
new_fingerprint: &str,
) -> Result<EnrollmentKey, sqlx::Error> {
let mut tx = pool.begin().await?;
// Highest existing version for this site (NULL -> 0 so the first key is v1).
let current_max: Option<i32> =
sqlx::query_scalar("SELECT MAX(version) FROM site_enrollment_keys WHERE site_id = $1")
.bind(site_id)
.fetch_one(&mut *tx)
.await?;
let next_version = current_max.unwrap_or(0) + 1;
// Deactivate the current active key (if any), stamping rotated_at.
sqlx::query(
r#"
UPDATE site_enrollment_keys
SET active = false, rotated_at = NOW()
WHERE site_id = $1 AND active
"#,
)
.bind(site_id)
.execute(&mut *tx)
.await?;
// Insert the new active key at the next version.
let new_key = sqlx::query_as::<_, EnrollmentKey>(
r#"
INSERT INTO site_enrollment_keys (site_id, key_hash, version, fingerprint, active)
VALUES ($1, $2, $3, $4, true)
RETURNING id, site_id, key_hash, version, fingerprint, active, created_at, rotated_at
"#,
)
.bind(site_id)
.bind(new_key_hash)
.bind(next_version)
.bind(new_fingerprint)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(new_key)
}

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,49 @@ 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";
// Zero-touch enrollment events (SPEC-016). Written by POST /api/enroll and the
// site enrollment-key rotation endpoint. These carry no session, so they are
// logged via `log_enrollment_event` with `session_id = NULL`; the structured
// detail (machine_uid, site_code, fingerprint, etc.) goes in `details` and the
// source IP in `ip_address`.
/// A new machine self-registered at a site and was minted its first `cak_`.
pub const ENROLL_NEW: &'static str = "enroll_new";
/// An existing machine_uid re-enrolled at the SAME site — the row was reused and
/// a fresh `cak_` minted (re-image / re-install).
pub const ENROLL_REUSE: &'static str = "enroll_reuse";
/// An existing machine_uid enrolled under a DIFFERENT site — the machine's site
/// binding was updated (a "site move"). Fires an alert.
///
/// NOTE (SPEC-016 Phase A): the unauthenticated enroll path does NOT perform this
/// move — a cross-site enroll is REFUSED (`ENROLL_SITE_CONFLICT`) rather than
/// silently repointing the machine. This event is reserved for the deliberate
/// Phase-B `--reassign` flow (and the dashboard move action) that supersede it.
#[allow(dead_code)] // reserved for Phase-B --reassign; not emitted by Phase A enroll
pub const ENROLL_SITE_MOVE: &'static str = "enroll_site_move";
/// An existing machine_uid presented a valid key for a DIFFERENT site than the one
/// the machine is currently bound to. Phase A REFUSES this (no move, no key minted)
/// as the accidental-move / cross-site-hijack guard; the deliberate move arrives
/// with the Phase-B `--reassign` flow + dashboard. Fires an alert.
pub const ENROLL_SITE_CONFLICT: &'static str = "enroll_site_conflict";
/// A machine_uid collision was detected at enroll — the endpoint dropped to
/// `pending` and awaits operator confirmation in the dashboard. Fires an alert.
pub const ENROLL_COLLISION_PENDING: &'static str = "enroll_collision_pending";
/// An enroll attempt failed enrollment-key verification (wrong/inactive key or
/// unknown site_code). Security audit trail for the open-registration surface.
pub const ENROLL_REJECTED: &'static str = "enroll_rejected";
/// An administrator rotated a site's enrollment key (new version + fingerprint;
/// old installers can no longer enroll NEW machines).
pub const ENROLLMENT_KEY_ROTATED: &'static str = "enrollment_key_rotated";
}
/// Log a session event
@@ -92,6 +137,93 @@ 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)
}
/// Log a zero-touch enrollment audit event (SPEC-016).
///
/// Shares the `connect_session_events` audit table but carries no session
/// (`session_id = NULL`, the FK column is nullable) and no viewer — enrollment is
/// an unauthenticated agent action, not a viewer/session event. The structured
/// detail (machine_uid, site_code, fingerprint version, decision, etc.) goes in
/// `details` and the agent's source IP in `ip_address`.
///
/// Best-effort: a failure to write the audit row must NOT fail the enroll (the
/// machine row and `cak_` already exist); the caller logs the error and proceeds,
/// matching how the relay and Task-5 removal treat audit writes.
pub async fn log_enrollment_event(
pool: &PgPool,
event_type: &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 (NULL, $1, NULL, NULL, $2, $3::inet)
RETURNING id
"#,
)
.bind(event_type)
.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,30 @@ 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>>,
/// Relational site binding for a machine enrolled via `/api/enroll` (SPEC-016,
/// migration 010). NULL for legacy / support-code / connect-path machines that
/// never enrolled through the zero-touch flow. A change of this on re-enroll is
/// the "site move" the enroll path audits.
pub site_id: Option<Uuid>,
/// Collision-gate state (SPEC-016, migration 010): `'active'` (live, auto-approve)
/// or `'pending'` (a machine_uid collision was detected at enroll; awaiting
/// operator confirmation before the endpoint may be controlled). Non-null with a
/// default of `'active'`; read NULL-tolerantly below for defense in depth.
pub enrollment_state: String,
}
impl<'r> FromRow<'r, PgRow> for Machine {
@@ -65,6 +89,17 @@ 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")?,
// Schema-nullable (migration 010); decode directly as Option.
site_id: row.try_get("site_id")?,
// Non-null with default 'active' (migration 010); read NULL-tolerantly
// (older snapshots / partial rows) and fall back to 'active'.
enrollment_state: row
.try_get::<Option<String>, _>("enrollment_state")?
.unwrap_or_else(|| "active".to_string()),
// 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,27 +132,219 @@ 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> {
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) WHERE machine_uid IS NOT NULL 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
}
}
}
/// Find a machine by the SPEC-016 per-tenant dedup key `(tenant_id, machine_uid)`.
///
/// This is the enroll-time dedup lookup: the same hardware re-enrolling (re-image /
/// re-install) resolves to its existing row within the tenant, while the same
/// hardware in a DIFFERENT tenant is a distinct row (resolved-decision #4). Tenant
/// scoping uses the same default-tenant fold as the unique index so the lookup
/// matches the uniqueness guarantee.
///
/// Unlike `get_machine_by_agent_id`, this deliberately does NOT filter
/// `deleted_at IS NULL`: a previously operator-purged machine that legitimately
/// re-enrolls must be found so the enroll path can revive it (clearing
/// `deleted_at`), mirroring the connect-path revive in `upsert_machine`.
pub async fn get_machine_by_tenant_uid(
pool: &PgPool,
tenant_id: Uuid,
machine_uid: &str,
) -> Result<Option<Machine>, sqlx::Error> {
sqlx::query_as::<_, Machine>(
r#"
SELECT * FROM connect_machines
WHERE machine_uid = $1
AND COALESCE(tenant_id, '00000000-0000-0000-0000-000000000001'::uuid) = $2
"#,
)
.bind(machine_uid)
.bind(tenant_id)
.fetch_optional(pool)
.await
}
/// Parameters for an enroll-time machine create/update (SPEC-016 `/api/enroll`).
///
/// `agent_id` is a freshly minted opaque id for a NEW enrollment (the agent's
/// config UUID story is Phase B; the server only needs a unique non-null value for
/// the `agent_id UNIQUE` column). On REUSE/MOVE the existing row's `agent_id` is
/// preserved (the FK target of any already-minted `cak_`), so the update path does
/// not touch it.
pub struct EnrollMachineParams<'a> {
pub agent_id: &'a str,
pub hostname: &'a str,
pub machine_uid: &'a str,
pub tenant_id: Uuid,
pub site_id: Uuid,
/// Company label (-> connect_machines.organization).
pub company: Option<&'a str>,
/// Site label (-> connect_machines.site) — the free-text label, distinct from
/// the relational site_id binding.
pub site_label: Option<&'a str>,
pub tags: &'a [String],
/// 'active' (auto-approve) or 'pending' (collision-gated).
pub enrollment_state: &'a str,
}
/// Insert a NEW machine row for a first-time enrollment (SPEC-016).
///
/// Carries the labels, the relational `site_id`, the per-tenant `machine_uid`, and
/// the collision-gate `enrollment_state`. Persistent + online. Returns the created
/// row (its `id` is the FK target for the `cak_` the caller mints next).
pub async fn insert_enrolled_machine(
pool: &PgPool,
p: &EnrollMachineParams<'_>,
) -> 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()
INSERT INTO connect_machines
(agent_id, hostname, is_persistent, status, last_seen, machine_uid,
tenant_id, site_id, organization, site, tags, enrollment_state)
VALUES ($1, $2, true, 'online', NOW(), $3, $4, $5, $6, $7, $8, $9)
RETURNING *
"#,
)
.bind(agent_id)
.bind(hostname)
.bind(is_persistent)
.bind(p.agent_id)
.bind(p.hostname)
.bind(p.machine_uid)
.bind(p.tenant_id)
.bind(p.site_id)
.bind(p.company)
.bind(p.site_label)
.bind(p.tags)
.bind(p.enrollment_state)
.fetch_one(pool)
.await
}
/// Update an EXISTING machine row on re-enroll / reuse / site-move (SPEC-016).
///
/// Refreshes hostname, site binding (`site_id`), labels, and `enrollment_state`,
/// and revives a soft-deleted row (`deleted_at = NULL`) — a re-enroll of a purged
/// host means it is live again, mirroring `upsert_machine`'s revive. Deliberately
/// does NOT change `agent_id`: the existing id is the FK target of any prior `cak_`.
/// Labels are COALESCE-merged so an enroll that omits a label does not wipe an
/// existing value; `tags` is overwritten only when a non-empty set is supplied
/// (matching `update_machine_metadata`'s convention).
pub async fn update_enrolled_machine(
pool: &PgPool,
machine_id: Uuid,
p: &EnrollMachineParams<'_>,
) -> Result<Machine, sqlx::Error> {
sqlx::query_as::<_, Machine>(
r#"
UPDATE connect_machines SET
hostname = $2,
site_id = $3,
organization = COALESCE($4, organization),
site = COALESCE($5, site),
tags = CASE WHEN $6::text[] = '{}' THEN tags ELSE $6 END,
enrollment_state = $7,
status = 'online',
last_seen = NOW(),
deleted_at = NULL
WHERE id = $1
RETURNING *
"#,
)
.bind(machine_id)
.bind(p.hostname)
.bind(p.site_id)
.bind(p.company)
.bind(p.site_label)
.bind(p.tags)
.bind(p.enrollment_state)
.fetch_one(pool)
.await
}
@@ -153,24 +380,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 +413,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 +441,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 +454,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 +502,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

@@ -4,10 +4,12 @@
//! Optional - server works without database if DATABASE_URL not set.
pub mod agent_keys;
pub mod enrollment_keys;
pub mod events;
pub mod machines;
pub mod releases;
pub mod sessions;
pub mod sites;
pub mod support_codes;
pub mod tenancy;
pub mod users;

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;
}
}

94
server/src/db/sites.rs Normal file
View File

@@ -0,0 +1,94 @@
//! Site database operations (SPEC-016 zero-touch enrollment).
//!
//! Backs the `connect_sites` table (migration 010): the relational anchor a
//! per-site enrollment key hangs off and the `/api/enroll` flow resolves by
//! `site_code`. See the migration header for why this table exists (the prior
//! schema modeled "site" only as a free-text column on `connect_machines`).
//!
//! All queries use runtime `sqlx::query()` / `sqlx::query_as()` per the codebase
//! convention (no compile-time `query!` macros, no `.sqlx` offline cache).
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
/// Site record from the database.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Site {
pub id: Uuid,
pub site_code: String,
pub display_name: Option<String>,
pub company: Option<String>,
pub tenant_id: Option<Uuid>,
/// RESERVED for future per-site enrollment POLICY work (SPEC-016 §out-of-scope).
/// Not enforced in Phase A.
pub enrollment_policy: Option<String>,
pub created_at: DateTime<Utc>,
}
/// Resolve a site by its operator-facing `site_code`, scoped to the given tenant.
///
/// Tenant scoping uses the same default-tenant fold as the unique index so the
/// lookup matches the uniqueness guarantee: `(COALESCE(tenant_id, default),
/// site_code)`. Returns `None` if no site with that code exists in the tenant.
pub async fn get_site_by_code(
pool: &PgPool,
site_code: &str,
tenant_id: Uuid,
) -> Result<Option<Site>, sqlx::Error> {
sqlx::query_as::<_, Site>(
r#"
SELECT id, site_code, display_name, company, tenant_id, enrollment_policy, created_at
FROM connect_sites
WHERE site_code = $1
AND COALESCE(tenant_id, '00000000-0000-0000-0000-000000000001'::uuid) = $2
"#,
)
.bind(site_code)
.bind(tenant_id)
.fetch_optional(pool)
.await
}
/// Fetch a site by its primary-key UUID.
pub async fn get_site_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Site>, sqlx::Error> {
sqlx::query_as::<_, Site>(
r#"
SELECT id, site_code, display_name, company, tenant_id, enrollment_policy, created_at
FROM connect_sites
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await
}
/// Insert a new site, returning the created row.
///
/// `tenant_id` is `None`-tolerant and resolved via `db::tenancy::current_tenant_id()`
/// at the call site. Errors with a unique-violation if `(tenant, site_code)` already
/// exists (the caller maps that to a 409).
#[allow(dead_code)] // Wired by the site-admin API (dashboard site CRUD); Phase A exposes key rotation, not site CRUD.
pub async fn insert_site(
pool: &PgPool,
site_code: &str,
display_name: Option<&str>,
company: Option<&str>,
tenant_id: Option<Uuid>,
) -> Result<Site, sqlx::Error> {
sqlx::query_as::<_, Site>(
r#"
INSERT INTO connect_sites (site_code, display_name, company, tenant_id)
VALUES ($1, $2, $3, $4)
RETURNING id, site_code, display_name, company, tenant_id, enrollment_policy, created_at
"#,
)
.bind(site_code)
.bind(display_name)
.bind(company)
.bind(tenant_id)
.fetch_one(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 {
@@ -381,13 +448,20 @@ async fn main() -> Result<()> {
)),
)
.route("/api/codes/:code/cancel", post(cancel_code))
// Zero-touch enrollment (SPEC-016). PUBLIC: no JWT — the per-site enrollment
// key in the body is the gate, and the handler applies its own
// per-(site_code, IP) rate limit / lockout (defense-in-depth). Mounted with
// the other public API routes.
.route("/api/enroll", post(api::enroll::enroll))
// WebSocket endpoints
.route("/ws/agent", get(relay::agent_ws_handler))
.route("/ws/viewer", get(relay::viewer_ws_handler))
// REST API - Sessions
.route("/api/sessions", get(list_sessions))
.route("/api/sessions/:id", get(get_session))
.route("/api/sessions/:id", delete(disconnect_session))
// 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 +469,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",
@@ -417,6 +503,18 @@ async fn main() -> Result<()> {
"/api/machines/:agent_id/keys/:key_id",
delete(api::machine_keys::revoke_key),
)
// Per-site enrollment key administration (SPEC-016, admin-only / JWT).
// Rotate regenerates the cek_ secret + fingerprint (old installers can no
// longer enroll new machines); GET returns the current non-secret
// fingerprint/version. Both gated by the AdminUser extractor.
.route(
"/api/sites/:id/enrollment-key",
get(api::sites::get_enrollment_key),
)
.route(
"/api/sites/:id/enrollment-key/rotate",
post(api::sites::rotate_enrollment_key),
)
// REST API - Releases and Version
.route("/api/version", get(api::releases::get_version)) // No auth - for agent polling
.route("/api/releases", get(api::releases::list_releases))
@@ -673,28 +771,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 +845,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

@@ -77,6 +77,19 @@ pub const CODE_VALIDATE_MAX_FAILURES: u32 = 10;
/// Support-code validate: how long an IP stays locked out once tripped.
pub const CODE_VALIDATE_LOCKOUT: Duration = Duration::from_secs(15 * 60);
/// Enroll (`POST /api/enroll`, SPEC-016): window length.
pub const ENROLL_WINDOW: Duration = Duration::from_secs(60);
/// Enroll: max requests per window per `(site_code, IP)`. A zero-touch site push
/// drives N machines through enroll near-simultaneously, so this is generous
/// (mass-deploy friendly) while still capping a runaway loop. Defense-in-depth: the
/// 256-bit enrollment key is the load-bearing gate, not this cap.
pub const ENROLL_MAX_PER_WINDOW: u32 = 60;
/// Enroll: consecutive FAILED enroll attempts (bad/inactive key, unknown site) from
/// one `(site_code, IP)` that trip the lockout.
pub const ENROLL_MAX_FAILURES: u32 = 20;
/// Enroll: how long a `(site_code, IP)` stays locked out once tripped.
pub const ENROLL_LOCKOUT: Duration = Duration::from_secs(15 * 60);
/// Hard cap on the number of distinct IPs tracked by any single limiter map.
/// Prevents an IP-rotating attacker from growing memory without bound. When the
/// cap is hit, the oldest-windowed entries are pruned. Generous for a real MSP
@@ -260,6 +273,150 @@ impl FailureLockout {
}
}
// ============================================================================
// Composite-key limiter for enrollment (keyed by (site_code, IP)) — SPEC-016
// ============================================================================
//
// The login / change-password / code-validate limiters above key purely on IP.
// SPEC-016 §3 wants the enroll defense keyed on `(site_code, source-IP)` so a noisy
// site push from one office IP cannot lock out a different site enrolling from the
// same egress IP. Rather than overload the IP-only maps, this is a small dedicated
// composite-key limiter + lockout. It is invoked from the enroll HANDLER (not a
// `from_fn` layer) because the `site_code` lives in the JSON body, which a
// pre-handler middleware cannot read without consuming it. Documented as
// defense-in-depth: the 256-bit enrollment key is the real gate.
/// Composite limiter key: the site_code and the real client IP.
type EnrollKey = (String, IpAddr);
/// Per-`(site_code, IP)` fixed-window limiter + consecutive-failure lockout.
///
/// Combines both protections behind one lock-guarded map so the enroll handler
/// makes a single allow/deny decision and reports success/failure into the same
/// structure. Self-pruning and size-capped, like the IP-only limiters.
#[derive(Clone)]
pub struct EnrollLimiter {
inner: std::sync::Arc<Mutex<HashMap<EnrollKey, EnrollEntry>>>,
max_per_window: u32,
window: Duration,
max_failures: u32,
cooldown: Duration,
}
#[derive(Debug, Clone, Copy)]
struct EnrollEntry {
window_started: Instant,
count: u32,
failures: u32,
locked_until: Option<Instant>,
last_seen: Instant,
}
impl EnrollLimiter {
pub fn new(
max_per_window: u32,
window: Duration,
max_failures: u32,
cooldown: Duration,
) -> Self {
Self {
inner: std::sync::Arc::new(Mutex::new(HashMap::new())),
max_per_window,
window,
max_failures,
cooldown,
}
}
fn entry_now() -> EnrollEntry {
let now = Instant::now();
EnrollEntry {
window_started: now,
count: 0,
failures: 0,
locked_until: None,
last_seen: now,
}
}
/// Admit one enroll attempt for `(site_code, ip)`. Returns `true` if allowed
/// (and counts it). Returns `false` if the key is currently locked out OR over
/// the per-window request cap. Clock injected for tests.
fn check_at(&self, site_code: &str, ip: IpAddr, now: Instant) -> bool {
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
if map.len() >= MAX_TRACKED_IPS {
let window = self.window;
let cooldown = self.cooldown;
map.retain(|_, e| {
e.locked_until.map(|u| now < u).unwrap_or(false)
|| now.duration_since(e.window_started) < window
|| now.duration_since(e.last_seen) < cooldown
});
}
let key = (site_code.to_string(), ip);
let e = map.entry(key).or_insert_with(Self::entry_now);
e.last_seen = now;
// Lockout takes precedence.
if let Some(until) = e.locked_until {
if now < until {
return false;
}
// Cooldown elapsed — clear it for a fresh start.
e.locked_until = None;
e.failures = 0;
}
// Roll the fixed window forward if elapsed.
if now.duration_since(e.window_started) >= self.window {
e.window_started = now;
e.count = 0;
}
if e.count >= self.max_per_window {
false
} else {
e.count += 1;
true
}
}
/// Admit one enroll attempt (real clock).
pub fn check(&self, site_code: &str, ip: IpAddr) -> bool {
self.check_at(site_code, ip, Instant::now())
}
fn record_failure_at(&self, site_code: &str, ip: IpAddr, now: Instant) {
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let key = (site_code.to_string(), ip);
let e = map.entry(key).or_insert_with(Self::entry_now);
e.last_seen = now;
e.failures = e.failures.saturating_add(1);
if e.failures >= self.max_failures {
e.locked_until = Some(now + self.cooldown);
}
}
/// Record a FAILED enroll attempt (bad key / unknown site) for the key,
/// tripping the lockout once the streak reaches `max_failures`.
pub fn record_failure(&self, site_code: &str, ip: IpAddr) {
self.record_failure_at(site_code, ip, Instant::now());
}
/// Record a SUCCESSFUL enroll for the key, resetting its failure streak.
pub fn record_success(&self, site_code: &str, ip: IpAddr) {
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let key = (site_code.to_string(), ip);
if let Some(e) = map.get_mut(&key) {
e.failures = 0;
e.locked_until = None;
e.last_seen = Instant::now();
}
}
}
// ============================================================================
// Shared rate-limit state (lives in AppState)
// ============================================================================
@@ -275,6 +432,9 @@ pub struct RateLimitState {
pub code_validate: RateLimiter,
/// Per-IP lockout on repeated failed code validations (brute-force defense).
pub code_validate_lockout: FailureLockout,
/// `POST /api/enroll` (SPEC-016): per-`(site_code, IP)` request cap +
/// consecutive-failure lockout. Invoked from the enroll handler.
pub enroll: EnrollLimiter,
}
impl RateLimitState {
@@ -290,6 +450,12 @@ impl RateLimitState {
CODE_VALIDATE_MAX_FAILURES,
CODE_VALIDATE_LOCKOUT,
),
enroll: EnrollLimiter::new(
ENROLL_MAX_PER_WINDOW,
ENROLL_WINDOW,
ENROLL_MAX_FAILURES,
ENROLL_LOCKOUT,
),
}
}
}
@@ -524,4 +690,51 @@ mod tests {
assert!(lockout.is_locked_at(ip(8), t0));
assert!(!lockout.is_locked_at(ip(9), t0)); // ip9 unaffected
}
// -- EnrollLimiter (composite (site_code, IP) key) --------------------------
#[test]
fn enroll_window_allows_up_to_cap_then_blocks() {
let lim = EnrollLimiter::new(2, Duration::from_secs(60), 100, Duration::from_secs(600));
let t0 = Instant::now();
assert!(lim.check_at("SITE-A", ip(1), t0)); // 1
assert!(lim.check_at("SITE-A", ip(1), t0)); // 2
assert!(!lim.check_at("SITE-A", ip(1), t0)); // over cap
}
#[test]
fn enroll_is_keyed_by_site_and_ip() {
let lim = EnrollLimiter::new(1, Duration::from_secs(60), 100, Duration::from_secs(600));
let t0 = Instant::now();
assert!(lim.check_at("SITE-A", ip(1), t0));
assert!(!lim.check_at("SITE-A", ip(1), t0)); // same key over cap
// Different site, same IP -> independent bucket.
assert!(lim.check_at("SITE-B", ip(1), t0));
// Same site, different IP -> independent bucket.
assert!(lim.check_at("SITE-A", ip(2), t0));
}
#[test]
fn enroll_lockout_trips_after_failures_and_blocks_check() {
let lim = EnrollLimiter::new(100, Duration::from_secs(60), 3, Duration::from_secs(600));
let t0 = Instant::now();
lim.record_failure_at("SITE-A", ip(1), t0);
lim.record_failure_at("SITE-A", ip(1), t0);
// Not yet tripped: a check still admits.
assert!(lim.check_at("SITE-A", ip(1), t0));
lim.record_failure_at("SITE-A", ip(1), t0); // 3rd -> trips
// Now locked out: check denies even though under the request cap.
assert!(!lim.check_at("SITE-A", ip(1), t0));
}
#[test]
fn enroll_success_resets_failure_streak() {
let lim = EnrollLimiter::new(100, Duration::from_secs(60), 2, Duration::from_secs(600));
let t0 = Instant::now();
lim.record_failure_at("SITE-A", ip(1), t0);
lim.record_success("SITE-A", ip(1)); // reset
lim.record_failure_at("SITE-A", ip(1), t0);
// Only one failure since reset -> not locked.
assert!(lim.check_at("SITE-A", ip(1), t0));
}
}

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,63 @@
# Session Log — 2026-06-03 — GuruConnect SPEC-018 review validation + fixes
## User
- **User:** Mike Swanson (mike)
- **Machine:** GURU-5070
- **Role:** admin
---
## Session Summary
Mike forwarded a thorough external code review of GuruConnect SPEC-018 Phase 1 (managed agent as LocalSystem service host; merge 11af9df) performed by a Grok reviewer persona and written to `D:\GrokTools\guru-connect-review-SPEC018.md`. Task: look over the project and validate.
Independently validated the two flagged bugs and Issue 6 by reading the actual code (not just relaying). Confirmed all three as real. Added a refinement the review missed: the non-functional managed fallback (Bug 1) *does* still work for a deprecated legacy-`api_key` managed binary, but is broken specifically for the modern SPEC-016 enrollment path — sharpening the fix.
Copied the review into the project at `reports/2026-06-03-spec018-review.md`, claimed a coord lock on `guruconnect`, created branch `fix/spec018-review-bugs`, and had the Coding Agent implement the three fixes. `cargo check -p guruconnect --target x86_64-pc-windows-msvc` passes clean (no errors/warnings). Filed Gitea issue #8 for the deferred lower-severity items. Changes remain uncommitted on the branch pending Mike's PR-vs-direct-to-main decision.
---
## Key Decisions
- **Validated, did not rubber-stamp.** Read the code at each cited location to confirm Bug 1 (main.rs:496), Bug 2 (config.rs:392), Issue 6 (startup.rs transmute) before acting.
- **Bug 1 fix = remove the fallback, surface an elevation error** (rather than build a degraded fallback). Matches `install_managed_service` docs; the managed model is elevated-install. The deprecated legacy-key edge case also errors now — acceptable and honest.
- **Bug 2 fix = read persisted agent_id from the TOML first**, generate only if absent — stops agent_id churn on every restart while keeping machine_uid/cak_ as the stable keys.
- **Issue 6 fix = typed `HKEY` from the windows crate** (no `HANDLE`+transmute). `install.rs` was already typed (no change).
- **Deferred Issues 3/4/5/7/8** (hot-path unwraps, panic-guard scope, nits) to Gitea #8 — lower severity, follow-ups.
- **No commit yet** — branch held for human review of diffs + PR-vs-main choice.
---
## Configuration Changes
**In submodule `projects/msp-tools/guru-connect` (branch `fix/spec018-review-bugs`, UNCOMMITTED):**
- `agent/src/config.rs` — added `Config::persisted_agent_id()`; embedded branch now `agent_id: Self::persisted_agent_id().unwrap_or_else(generate_agent_id)`; corrected comment.
- `agent/src/main.rs``run_permanent_agent_managed`: removed `run_agent_mode(None)` fallback, now `error!` + `Err(...)` requiring elevation; updated doc/inline comments.
- `agent/src/startup.rs` — replaced `transmute::<HANDLE,HKEY>` with `HKEY::default()` + `&mut hkey`; added SAFETY comments.
- Created `reports/2026-06-03-spec018-review.md` (copy of the external review).
- Stray untracked `tmp-spec018.diff` left untouched (from the Grok session).
---
## Commands & Outputs
- Validation greps/reads: `run_permanent_agent_managed` at main.rs:482, fallback at :496; `Config::load` embedded branch config.rs:382-409 (`agent_id: generate_agent_id()` unconditional, save() never read back); `resolve_agent_credential` main.rs:515 (load_cak permission_denied guard / enroll C1 read-back).
- `cargo check -p guruconnect --target x86_64-pc-windows-msvc` → Finished clean, no warnings from the changes.
- Coord lock id `0cfd6269-4548-46d4-8436-c829e42f79d8` (guruconnect / agent/src, ttl 2h, GURU-5070/claude-main).
---
## Pending / Incomplete Tasks
- **Awaiting Mike's decision:** push branch + open PR (recommended, matches SPEC-018 PR #7 convention) vs. commit straight to `main`.
- On decision: commit the 3 fixes + the review report, push, (PR/merge), then bump the parent-repo submodule pointer on next `/sync`, update the coord `guruconnect` component, and release lock `0cfd6269`.
- Deferred hardening: Gitea **guru-connect#8** (Issues 3/4/5/7/8).
---
## Reference Information
- External review: `D:\GrokTools\guru-connect-review-SPEC018.md` → copied to `reports/2026-06-03-spec018-review.md`.
- Branch: `fix/spec018-review-bugs` (off `main` @ 11af9df).
- Gitea issue: https://git.azcomputerguru.com/azcomputerguru/guru-connect/issues/8
- Files: `agent/src/{config.rs,main.rs,startup.rs}`.

View File

@@ -527,3 +527,60 @@ Reference: SPEC-002 §5; `agent/src/encoder/raw.rs` (salvaged), `proto/guruconne
- **Rate limiting:** hammer `/api/auth/login` and the code-validate route → confirm throttling/lockout.
- **Migrations:** fresh DB applies the v2 migrations cleanly; `_sqlx_migrations` consistent; `tenant_id`
populated with the default tenant.
---
## Task 9 [PROPOSED 2026-06-01 — provisioning model = TOFU auto-enroll, chosen by Mike]: `cak_` auto-enroll provisioning + shared-key retirement
> Context: Task 2 built the SERVER `cak_` machinery (mint/SHA-256 hash/verify in `auth/agent_keys.rs`,
> relay validation in `validate_agent_api_key`, admin issuance `POST /api/machines/:id/keys`). What's
> missing is how an AGENT obtains and uses a `cak_` — today agents still carry the deprecated shared
> `AGENT_API_KEY`, so `connect_agent_keys` is empty and the relay logs the DEPRECATED-shared-key warning
> for every agent. This task closes that with **trust-on-first-use auto-enroll** so the shared key can be
> retired (unblocks task list #5). NOTE: the agent already presents whatever is in its `api_key` slot and
> the relay auto-detects `cak_` vs shared — so a `cak_`-keyed agent needs **no change to its auth call**,
> only a way to *receive*, *persist*, and *prefer* a `cak_`.
**Flow (TOFU):**
1. **Bootstrap (first connect):** a fresh agent authenticates on `/ws/agent` with a bootstrap secret —
interim: the shared `AGENT_API_KEY` (embedded by the download endpoint); target: a single-use,
short-lived **enroll token** (more secure TOFU — see Security).
2. **Server issues on first connect:** when an agent authed via the bootstrap path (i.e. NOT already
`cak_`-keyed) connects and its machine has **no active (non-revoked) `cak_`**, the relay: resolves/creates
the machine row (existing `upsert_machine` on `machine_uid` — now functional after the 2026-06-01
ON CONFLICT fix), mints a `cak_` (`generate_agent_key` + `db::agent_keys::insert_agent_key` for that
`machine_id`), and sends the plaintext key to the agent **once** over a new server→agent message. Only
the hash is stored. **Idempotent:** never re-issue if an active key already exists for the machine.
3. **Agent receives + persists + prefers:** on `AgentKeyProvision`, the agent persists the `cak_` durably at
`%ProgramData%\GuruConnect\agent_key` (restricted ACL, same pattern as `machine_uid`). On startup it loads
the persisted `cak_` if present and uses it as its auth key, falling back to the embedded/bootstrap secret
only when no `cak_` is stored yet. After provisioning, every reconnect authenticates via `cak_` (no more
DEPRECATED-shared-key warning for that agent).
4. **Shared-key retirement (phased):** Phase A — shared key stays as the bootstrap so existing+new agents
self-enroll; monitor the relay WARN count → ~0. Phase B — once the fleet is `cak_`-keyed, restrict the
shared `AGENT_API_KEY` to enrollment-only or remove the env entirely (only `cak_` / enroll-token accepted).
This is the concrete completion of task-list #5.
**Protocol (4-artifact drift discipline):** add `AgentKeyProvision { string key = 1; }` (server→agent) to
`proto/guruconnect.proto` with a new reserved message ID; regenerate prost on both agent + server; the
hand-written `dashboard/src/lib/protobuf.ts` decoder does NOT need it (agent-plane only) but reserve the ID.
**Files:** `proto/guruconnect.proto` (new message); `server/src/relay/mod.rs` (issue+send on bootstrap connect
with no active key); `server/src/db/agent_keys.rs` (add `has_active_key(machine_id)` check; reuse insert);
`agent/src/transport/*` (handle inbound `AgentKeyProvision`); `agent/src/config.rs` + a small key-store module
(load/persist `cak_`, prefer over bootstrap).
**Security (TOFU):** the first connect trusts the bootstrap secret — a leaked shared key during the enroll
window could enroll a rogue agent; the secure target is a **single-use, short-lived enroll token** per
deployment instead of the shared key (shared-key bootstrap is interim convenience). The `cak_` is sent
plaintext once over the existing wss/TLS channel; only the hash is stored server-side; the agent stores it
locally with restricted ACLs. Revocation via the existing `DELETE /api/machines/:id/keys/:key_id` fails the
agent closed; on its next bootstrap connect it re-enrolls. The keyed-agent dedup (Task 3) keeps the
authenticated identity authoritative.
**Verification:** drop a current-build (signed 0.3.0+) agent configured with the shared-key bootstrap →
it connects, receives a `cak_`, persists it; restart → it authenticates via the `cak_` (relay shows NO
DEPRECATED-shared-key warning) and `connect_agent_keys` holds exactly one active key for the machine; issue
is idempotent across reconnects; revoke the key via the admin API → agent rejected, then re-enrolls on next
bootstrap connect. Reference: `auth/agent_keys.rs`, `api/machine_keys.rs`, `relay/mod.rs:266-309`
(`validate_agent_api_key`), `.claude/standards/security/credential-handling.md`.

Some files were not shown because too many files have changed in this diff Show More