82 Commits

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:20:15 -07:00
761bae5d01 spec: update SPEC-012 to include both Serial Console + PTY Shell modes
Major update to SPEC-012 adding dual-mode terminal access:

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:48:48 -07:00
a062a825ea spec: add SPEC-012 Headless Linux Mode (Direct TTY Access)
Comprehensive specification for terminal-based remote access to headless
Linux servers (no X11/Wayland GUI):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:23:03 -07:00
5e2325507f spec: add SPEC-010 Cross-Platform Agent Support (macOS and Linux)
Comprehensive specification for expanding agent support beyond Windows:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:48:09 -07:00
abc55abb0b fix(server): tolerate NULL connect_machines columns (tags decode bug)
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m37s
Build and Test / Build Agent (Windows) (push) Successful in 7m30s
Build and Test / Security Audit (push) Successful in 4m49s
Build and Test / Build Summary (push) Has been skipped
connect_machines.tags is text[] nullable with no default; the derived
FromRow decoded it as non-Option Vec<String>, so rows with NULL tags
threw "unexpected null" - breaking managed-session reconcile on startup
and the authed Machines list. Hit in production on the v2 cutover.

- Replace the derived FromRow on Machine with a manual impl that decodes
  every nullable-non-Option column as Option<T> with unwrap_or_default
  (tags, is_elevated, is_persistent, status, timestamps), fixing all six
  read sites at once. Public field types unchanged.
- migrations/007: backfill NULL tags to empty array, set DEFAULT '{}',
  set NOT NULL (no writer inserts NULL: upsert omits tags, metadata
  update binds a non-null array). Idempotent with the prod hot-patch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:17:12 -07:00
96b4fd7721 feat(dashboard): GuruConnect v2 Users admin view
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 4m43s
Build and Test / Build Agent (Windows) (push) Successful in 8m48s
Build and Test / Security Audit (push) Successful in 4m38s
Build and Test / Build Summary (push) Has been skipped
Admin-only user management: list, create, edit role/permissions/status,
reset password, and disable/delete, against the v2 users API.

- Admin-gated three ways: AdminRoute on /users (calm access-denied panel
  for non-admins, no redirect loop or data fetch), Sidebar hides the nav
  item, and every mutation relies on the server AdminUser 403 as the real
  authority. isAdmin is derived from the server-validated user, not the
  client token.
- Users table: role badge (admin/operator/viewer), permissions summary,
  enabled/disabled status, created, last-login. Sticky header, skeleton,
  empty/error states. Self row tagged "You".
- Create/edit use the real roles and permission strings
  (view/control/transfer/manage_users/manage_clients); admin permissions
  are server-implicit and shown locked. Passwords: typed or Web Crypto
  generated (rejection-sampled, copy-once reveal), type=password +
  autoComplete=new-password, cleared from state on open/close/success,
  never logged/persisted/in-URL; blank on edit means unchanged.
- Self-lockout guards: cannot disable, delete, or demote your own admin
  account (controls disabled + submit-handler checks, matched on the
  authoritative user id). Server mirrors self-disable/self-delete; the
  self-demotion guard is client-side (server todo filed).
- useUpdateUser sequences user-update then permissions-set; invalidates
  ["users"] on settled so the table reconciles after a partial failure,
  with an actionable message if only permissions failed.

Passed Code Review (no blockers after fixes) and local gates
(tsc/lint/build green). Completes the v2 dashboard view set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:18:40 -07:00
664f33d5ab feat(dashboard): GuruConnect v2 Support Codes view
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m27s
Build and Test / Build Agent (Windows) (push) Successful in 7m11s
Build and Test / Security Audit (push) Successful in 4m32s
Build and Test / Build Summary (push) Has been skipped
Generate, list, and cancel attended-support codes (XXX-XXX-XXX), built
on the v2 codes API and existing UI primitives.

- Codes table: code in mono, status badge (pending+pulse/connected/
  completed/cancelled), bound client/machine, created-by, created
  (relative + absolute tooltip). Sticky header, skeleton load,
  actionable empty/error states.
- Generate opens a focused reveal modal showing the code large in
  JetBrains Mono with copy and a read-aloud instruction; the code is
  announced character-by-character for screen readers. Mint is ref-
  guarded so it creates exactly one code per open (no StrictMode dupe).
- Cancel via confirm dialog (POST /api/codes/:code/cancel), disabled for
  non-cancellable statuses; invalidates the codes query. List polls 7s.
- Shared API client now tolerates non-JSON 200 bodies, so the cancel
  endpoint's plain-text "Code cancelled" success no longer surfaces as a
  failure. Error-envelope handling unchanged.

Passed Code Review (no blockers after fixes) and local gates
(tsc/lint/build green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:59:18 -07:00
67f3722b3c feat(server): serve dashboard SPA with deep-link fallback; remove v1 portal
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m26s
Build and Test / Build Agent (Windows) (push) Successful in 7m17s
Build and Test / Security Audit (push) Successful in 4m29s
Build and Test / Build Summary (push) Has been skipped
Axum now serves the v2 React/Vite dashboard SPA at / with a client-side
routing fallback, and the dead v1 HTML portal is removed (nothing was
live on the server to preserve).

- SPA served from server/static/app via ServeDir with a fallback to
  index.html, so deep links (/machines, /sessions) resolve to the SPA.
- /api/*rest and /ws/*rest return JSON 404 so unrouted API/WS paths never
  leak index.html to clients; real /api, /ws, /health, /metrics, and the
  /downloads nest keep precedence (matchit static-over-wildcard).
- Path-aware Cache-Control: hashed /assets immutable, index.html no-cache.
- Vite builds to server/static/app (base /); the artifact is gitignored
  and rebuilt at deploy time (npm ci && npm run build).
- Removed v1 portal files (login/dashboard/users/index/viewer .html) and
  their dead serve_* handlers; the SPA owns /, /login, /dashboard, /users.

Verified locally: server boots, / and deep links serve the SPA, unknown
/api path returns JSON 404 (not HTML), /health and /downloads intact.
cargo build + clippy -D warnings green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:44:13 -07:00
6ecb937eb6 feat(dashboard): GuruConnect v2 Sessions view (pass 2)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m9s
Build and Test / Build Server (Linux) (push) Successful in 10m41s
Build and Test / Security Audit (push) Successful in 4m25s
Build and Test / Build Summary (push) Successful in 10s
Active-sessions table with consent-state badges, viewer-token Join,
and disconnect, built on the v2 session API and existing UI primitives.

- Sessions table: machine, mode (managed/attended), consent badge
  (granted/pending+pulse/denied/not_required), viewers, started,
  duration, status. Sticky header, skeleton load, empty/error states.
- Join action mints a session-scoped viewer token
  (POST /api/sessions/:id/viewer-token) and reveals it with the
  /ws/viewer relay URL and copy buttons. The static viewer.html is
  intentionally not targeted: it sends the raw login JWT, which the v2
  viewer plane rejects. In-dashboard web viewer ships in a later pass.
- Authz split mirrors the server mint gate: admin or control permission
  gets Control; view permission gets View only; neither hides the action.
  Server remains authoritative; the minted token carries the signed
  access claim.
- Disconnect via confirm dialog (DELETE /api/sessions/:id), invalidates
  the sessions query. List polls every 8s so consent transitions surface.

Passed Code Review (no blockers) and local gates (tsc/lint/build green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:12:04 -07:00
43a9432b81 feat(dashboard): GuruConnect v2 operator console (pass 1)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m56s
Build and Test / Build Server (Linux) (push) Successful in 10m15s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 10s
React + Vite + TypeScript SPA: scaffold, operations-terminal design
system, Bearer-token auth, and the Machines view.

- Design system: OKLCH-tinted dark theme (ink-slate + signal-cyan),
  Hanken Grotesk + JetBrains Mono, status-color language
  (online/offline/granted/pending/denied/not_required), motion with
  prefers-reduced-motion honored.
- Auth: token in sessionStorage via ref (never React state), protected
  routes, 401 session teardown, admin-gated per-agent-key UI.
- Machines view: data table (sticky header, keyboard-activated rows,
  skeleton loading, actionable empty/error states), non-blocking detail
  drawer, delete confirm, admin key management with copy-once reveal.
- UI primitives: Modal (focus trap + inert + portal + dialogStack),
  Drawer, Table, Badge/StatusDot, toast, states.
- Typed API client normalizing the two error-envelope shapes.

Passed Code Review (no blockers), impeccable critique-and-polish, and
local gates (tsc/lint/build green). Dev-only Vite proxy to :3002.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:51:11 -07:00
f9bdecbfdb feat(agent,server): v2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m57s
Build and Test / Build Server (Linux) (push) Successful in 10m23s
Build and Test / Security Audit (push) Successful in 4m15s
Build and Test / Build Summary (push) Successful in 9s
SPEC-002 Phase 1 Task 7 (the last), code-reviewed APPROVED, locally verified
(cargo fmt + clippy -D warnings exit 0 + cargo test --workspace 89 pass + build).

- Encoder trait + factory: RawEncoder (salvaged, UNCHANGED) and H264Encoder,
  selected by negotiation; factory falls back to raw on H.264 init failure.
- Negotiation: agent advertises supports_h264 (MFTEnumEx HW probe, cached) in
  AgentStatus; server picks the codec via select_video_codec(supports, prefer)
  and stamps StartStream.video_codec; agent re-guards on local HW. Policy
  constant DEFAULT_PREFER_H264 = false, so RAW is negotiated for every session
  today - H.264 stays dormant until live hardware validation (Task 8).
- MF H.264 encoder (h264.rs, FIRST-CUT / compile-verified-only): HW encoder MFT,
  BGRA->NV12 (color.rs, unit-tested), sync drain, fall-back-to-raw on any failure.
- Viewer H.264 decoder (decoder.rs, FIRST-CUT): MF decoder on a dedicated COM
  thread; drops+logs on failure, raw render path untouched.
- proto additive: VideoCodec enum, StartStream.video_codec=3,
  SessionResponse.video_codec=5, AgentStatus.supports_h264=11.
- Raw+Zstd path byte-for-byte unchanged; remains the guaranteed default/fallback.

Review confirmed unsafe impl Send for H264Encoder is sound (single-owned &mut on
the block_on thread; session future never spawned) and every MF failure degrades
to raw. H.264 is NOT claimed functional - compile/clippy/build-verified only;
live validation + force-IDR + the no-spawn-invariant doc are Task 8 go-live gates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:35:04 -07:00
bb73ba667f feat(agent): v2 secure-session-core Task 6 - full key fidelity
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m1s
Build and Test / Build Server (Linux) (push) Successful in 11m32s
Build and Test / Security Audit (push) Successful in 4m31s
Build and Test / Build Summary (push) Successful in 11s
SPEC-002 Phase 1 Task 6, code-reviewed APPROVED (2 rounds), locally verified
(cargo fmt + clippy -D warnings exit 0 + cargo test --workspace 70 pass + build).

- Viewer WH_KEYBOARD_LL hook diverts system combos (Win/Win+R, Alt+Tab, Alt+Esc,
  Ctrl+Esc) to the remote as a full KeyEvent (vk + scan + is_extended + modifiers)
  and suppresses local handling - GATED on the viewer window having focus AND a
  "send system keys" toggle (default on; Pause/Break host-key), so it never bricks
  the technician's local keyboard when unfocused.
- Agent injection via SendInput KEYEVENTF_SCANCODE + correct KEYEVENTF_EXTENDEDKEY
  (right Ctrl/Alt, arrows, nav, Win, NumLock, numpad Divide) - layout-independent,
  extended-key-correct.
- Ctrl+Alt+Del completes through the SAS helper (SYSTEM SendSAS); installer sets
  the SoftwareSASGeneration policy; 3-tier fail-loud (no false success). SAS named
  pipe DACL tightened from NULL/Everyone to Authenticated Users.
- Modifier hygiene: viewer emits key-ups for held Ctrl/Alt/Shift/Win on focus loss
  / close so modifiers never stick on the remote.
- proto: KeyEvent.is_extended = 7 (additive; older agents derive the flag).

Closes Win+R / Ctrl+C-V / Ctrl+Alt+Del / arrows-vs-numpad fidelity. Live on-device
testing is plan Task 8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:16:26 -07:00
d0de888dd1 style(agent): clear 77 pre-existing clippy -D warnings
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m53s
Build and Test / Build Server (Linux) (push) Successful in 10m59s
Build and Test / Security Audit (push) Successful in 4m31s
Build and Test / Build Summary (push) Successful in 10s
CI never ran clippy on the agent crate (the build-server clippy job is
Linux-only and can't compile the Windows agent; build-agent only runs cargo
build), so 77 clippy -D-warnings errors had accumulated. Behavior-preserving
cleanup, code-reviewed APPROVED, locally verified (cargo clippy --workspace
--all-targets --all-features -- -D warnings exits 0; cargo test --workspace =
57 passed).

- let _ = on Win32 resource-teardown BOOL returns (gdi.rs); fallible
  BitBlt/GetDIBits stay error-handled
- removed unused imports/vars; idiom fixes (div_ceil, is_null, transmute
  annotations, match collapsing, useless_conversion)
- #[allow(dead_code)] + comment on genuine Task-6/7 scaffolding (vk consts,
  SpecialKey emission, SAS mgmt API, modifier tracking, GDI frame-diff fields)
- Cargo.lock: cargo pruned ~147 stale transitive entries (no version changes)

Follow-up: add cargo clippy -D warnings to the build-agent CI job so the agent
crate stays clippy-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 08:51:45 -07:00
fbf9e26f5a style(server,agent): fmt + clippy fixes for Task 5 (CI green)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m29s
Build and Test / Build Server (Linux) (push) Successful in 12m9s
Build and Test / Security Audit (push) Successful in 5m23s
Build and Test / Build Summary (push) Successful in 11s
9082e11 compiles + passes all 50 server tests on the build host; only blocked
CI on cargo fmt (4 files) and one clippy -D dead-code denial:
- cargo fmt --all (relay/mod.rs, session/mod.rs, agent consent/mod.rs + session/mod.rs)
- #[cfg_attr(not(test), allow(dead_code))] on session::get_consent_state (a
  read accessor currently exercised only by tests)
No logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:59:29 -07:00
9082e11490 feat(server,agent): v2 secure-session-core Task 5 - attended consent
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 5m42s
Build and Test / Build Agent (Windows) (push) Successful in 8m22s
Build and Test / Security Audit (push) Successful in 5m12s
Build and Test / Build Summary (push) Has been skipped
SPEC-002 Phase 1 Task 5, code-reviewed APPROVED. An attended (support-code)
session is invisible and inert to the technician until the end user accepts a
consent prompt on their own machine.

- proto: ConsentRequest / ConsentResponse + ConsentAccessMode enum (oneof
  fields 80/81; no existing field renumbered).
- server: ConsentState on Session; attended -> Pending, managed -> NotRequired;
  join_session refuses viewers unless Granted/NotRequired (single chokepoint -
  StartStream only fires from join_session, so no frames or input flow pre-
  consent); run_consent_handshake sends ConsentRequest, 60s timeout, granted ->
  proceed, denied/timeout/disconnect -> teardown (end_session denied, machine
  offline, support code released). consent_state persisted; consent_requested/
  granted/denied audited.
- agent: Windows MessageBox (topmost/system-modal) on spawn_blocking; anything
  but an explicit Yes = deny; non-Windows build is a fail-closed stub.

Not cargo-check-verified locally (no toolchain). Server verified on the build
host; the Windows agent half is verified by CI build-agent (Pluto).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:44:09 -07:00
8cb0b5b16b style(server): cargo fmt for trusted-proxy IP extractor (CI green)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m53s
Build and Test / Build Server (Linux) (push) Successful in 10m54s
Build and Test / Security Audit (push) Successful in 4m21s
Build and Test / Build Summary (push) Successful in 11s
5d5cd26 compiles + passes clippy -D warnings + all 45 tests on the build host;
only cargo fmt --check failed on one reflowed method chain in ip_extract.rs.
No logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:26:15 -07:00
5d5cd26572 fix(server): trusted-proxy client-IP extraction for rate-limit/audit keying
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 5m9s
Build and Test / Build Agent (Windows) (push) Successful in 7m38s
Build and Test / Security Audit (push) Successful in 4m59s
Build and Test / Build Summary (push) Has been skipped
Resolves coord todo 3c1f372a (Task-4 review SHOULD-FIX). Behind NPM-on-loopback,
ConnectInfo was 127.0.0.1 so the rate limiter + lockout bucketed every client
under one IP. New shared utils::ip_extract::client_ip() honors X-Real-IP /
X-Forwarded-For (rightmost-untrusted hop) ONLY when the TCP peer is a configured
trusted proxy (CONNECT_TRUSTED_PROXIES env, default loopback, fail-closed);
untrusted peers are keyed by their true peer IP (forged headers ignored). Wired
into the 3 rate-limit middleware, the validate_code lockout feed, and the agent/
viewer WS handlers so the limiter, lockout, and audit ip_address all key on the
real client consistently. 13 unit tests (spoof rejection, XFF walk, fail-safe
defaults). Code-reviewed APPROVED. Not cargo-check-verified locally (no toolchain);
build-host/CI verification follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:15:45 -07:00
21189423f2 fix(server): clippy fixes for Task 4 (CI green)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m3s
Build and Test / Build Server (Linux) (push) Successful in 10m19s
Build and Test / Security Audit (push) Successful in 4m10s
Build and Test / Build Summary (push) Successful in 9s
Task 4 (bfcdbb5) compiles and passes all 32 tests on the build host; only
clippy -D warnings blocked CI. Fixed the two denials:
- rate_limit.rs: converted a dangling /// doc block (no documented item) to //
  to clear clippy::empty_line_after_doc_comments
- db/events.rs: #[allow(dead_code)] on CONNECTION_REJECTED_EXPIRED_CODE and
  _CANCELLED_CODE (not-yet-wired audit-event constants), matching the file's
  existing STREAMING_STOPPED pattern; TODO comments note the rejection-event wiring

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:17:23 -07:00
bfcdbb5379 feat(server): v2 secure-session-core Task 4 - rate limit + single-use codes
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 6m12s
Build and Test / Build Agent (Windows) (push) Successful in 6m43s
Build and Test / Security Audit (push) Successful in 4m23s
Build and Test / Build Summary (push) Has been skipped
SPEC-002 Phase 1 Task 4 (the final keystone task), code-reviewed APPROVED.
Closes the audit's reusable-code HIGH and rate-limiting-disabled HIGH.

- Rebuilt rate limiting as a self-contained in-memory per-IP limiter (replaces
  the non-compiling tower_governor; removed that dep). Fixed-window caps wired
  to login (8/min), change-password (5/min), code-validate (15/min) -> 429;
  per-IP lockout after 10 consecutive failed code validations (15-min cooldown).
- Single-use support codes: atomic consume on first agent bind (in-memory
  Pending->Connected under write lock + DB conditional UPDATE), rejecting a
  second presenter; validate/preview does not consume.
- Widened code format: XXX-XXX-XXX, 31-char unambiguous alphabet (no 0/O/1/I/L),
  CSPRNG + rejection sampling, ~44.6 bits (replaces 6-digit numeric); migration
  006 widens the code columns to TEXT.

Completes the keystone (Tasks 1-4): every audit CRITICAL + HIGH in the secure
auth/session core is now addressed. Known follow-up todos (not blocking): (1)
trusted-proxy client-IP extraction (NPM-on-loopback collapses clients to
127.0.0.1); (2) multi-instance fail-closed DB single-use gate. Not
cargo-check-verified locally - build-host/CI verification follows this commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:04:54 -07:00
8a0193577b style(server): cargo fmt + clippy fixes for v2 keystone (CI green)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m29s
Build and Test / Build Server (Linux) (push) Successful in 10m23s
Build and Test / Security Audit (push) Successful in 4m17s
Build and Test / Build Summary (push) Successful in 11s
The Task 2/3/authz commits failed CI at the first gate (cargo fmt --all
--check), which short-circuited before clippy/build/test ran. Verified on the
build host (172.16.3.30): the v2 server compiles and all 18 tests pass; only
3 cosmetic issues blocked CI, all fixed here:
- cargo fmt --all (whitespace, 3 files)
- clippy unused_imports: drop ViewerClaims from auth/mod.rs re-export
- clippy doc_overindented_list_items: de-indent one doc line in sessions.rs
Testing Agent confirmed fmt + clippy -D warnings + build --release + test are
all green with these applied. No logic changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:19:26 -07:00
a453e7984e feat(server): viewer-token view-only/control split - closes CRITICAL #1
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m20s
Build and Test / Build Agent (Windows) (push) Successful in 6m9s
Build and Test / Security Audit (push) Successful in 4m21s
Build and Test / Build Summary (push) Has been skipped
Authz-strength fix (coord todo c8916c89), code-reviewed APPROVED. Replaces the
weak "view" gate (held by every role) with a permission-tiered access mode
stamped inside the signed viewer token:
- mint: is_admin() || has_permission("control") -> CONTROL token; else
  has_permission("view") -> VIEW_ONLY token; else 403.
- enforce: the relay drops MouseEvent/KeyEvent/SpecialKey for a VIEW_ONLY token
  before forwarding (video still streams); CONTROL tokens forward under the
  Task-3 throttle. Mode is unforgeable (in the signature) and unbypassable
  (all other viewer->agent payloads hit the catch-all and are never forwarded).
A low-privilege viewer-role user can now at most watch, never control. New
ViewerAccess enum (view_only|control) on ViewerClaims; 3 unit tests.

Audit CRITICAL #1 now fully closed (mechanism in Task 3 + this authz strength).
Not cargo-check-verified locally (no toolchain) - the push triggers CI
(clippy -D warnings + build + test) which is the verification gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:24:32 -07:00
0f258788f9 feat(server): v2 secure-session-core Task 3 - secure relay WS
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 4m3s
Build and Test / Build Agent (Windows) (push) Successful in 7m48s
Build and Test / Security Audit (push) Successful in 4m20s
Build and Test / Build Summary (push) Has been skipped
SPEC-002 Phase 1 Task 3 (specs/v2-secure-session-core), code-reviewed APPROVED.

- viewer_ws_handler: verify the session-scoped VIEWER token (validate_viewer_token
  sig+exp+purpose) + token_blacklist.is_revoked + session_id claim == requested
  session, before upgrade. Raw login JWTs no longer accepted on the viewer plane
  (closes audit CRITICAL #2; closes the *mechanism* of CRITICAL #1).
- mint_viewer_token: authz gate is_admin() || has_permission("view") -> 403.
- Agent identity binding: validate_agent_api_key returns AgentKeyAuth; a cak_-
  verified agent rebinds to the key's machine identity (fails closed if
  unresolvable), so a key for machine X cannot seize machine Y's session slot.
- Frame caps on both WS upgrades (agent 4 MiB, viewer 64 KiB) - closes WS-OOM HIGH.
- Viewer->agent input throttle (200 ev/s token bucket, bounded try_send) - closes
  input-injection MEDIUM.
- Startup managed-session reconcile clarified.

KNOWN FOLLOW-UPS (tracked todos): (1) authz STRENGTH - the "view" permission is
held by every default role incl. viewer, and a viewer token grants input control,
so the gate should be "control" or a VIEW_ONLY/CONTROL token split; CRITICAL #1 is
mechanism-closed, strength pending decision. (2) revoke minted viewer tokens on
logout (currently bounded only by 5-min TTL). Not cargo-check-verified (no toolchain
on the authoring host).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:13:03 -07:00
41691bfb2c feat(server): v2 secure-session-core Task 2 - auth rebuild
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m37s
Build and Test / Build Agent (Windows) (push) Successful in 6m37s
Build and Test / Security Audit (push) Successful in 4m10s
Build and Test / Build Summary (push) Has been skipped
SPEC-002 Phase 1 Task 2 (specs/v2-secure-session-core), code-reviewed APPROVED.

- DELETE the JWT-as-agent-key branch in relay validate_agent_api_key (audit
  CRITICAL): agent auth now = per-agent cak_ key (SHA-256 -> connect_agent_keys,
  revoked filtered) OR support code OR deprecated shared AGENT_API_KEY (warned).
  A user JWT can no longer authenticate an agent.
- auth/agent_keys.rs: cak_ gen (OsRng 256-bit) + SHA-256 hash + verify.
- auth/jwt.rs: ViewerClaims + create/validate_viewer_token (5-min TTL,
  purpose=viewer, session_id+tenant_id claims; non-interchangeable with login).
- Admin key issuance: POST/GET/DELETE /api/machines/:agent_id/keys.
- POST /api/sessions/:id/viewer-token mints a session-bound short-lived token.
- Migration 005: organization/site/tags on connect_machines (fixes the silent
  update_machine_metadata write, coord todo faf39fe0).

NOTE: viewer-token minting is gated by AuthenticatedUser only; the AUTHORIZATION
check (admin/permission gate) that closes audit CRITICAL #1 lands in Task 3 (the
viewer WS verification). The viewer WS path (relay/mod.rs:285) is untouched here.
Not cargo-check-verified (no toolchain on the authoring host) - self-reviewed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:57:12 -07:00
fef8111ff3 feat(server): v2 secure-session-core Task 1 - schema + per-agent keys
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m7s
Build and Test / Build Server (Linux) (push) Successful in 10m15s
Build and Test / Security Audit (push) Successful in 4m24s
Build and Test / Build Summary (push) Successful in 12s
SPEC-002 Phase 1 Task 1 (specs/v2-secure-session-core), code-reviewed APPROVED.

Migration 004 (idempotent, server-applied): tenants + seeded default tenant,
connect_agent_keys (hash-only, revocable, FK->connect_machines), nullable
tenant_id on all scoped tables (tenancy-ready, not tenant-yet), connect_sessions
is_managed/source/consent_state, connect_support_codes consumed_at. New db
modules agent_keys.rs (stores only key_hash) + tenancy.rs (DEFAULT_TENANT_ID,
Phase-4 switch point). Struct/query updates across machines/sessions/
support_codes/events/users. Runtime sqlx throughout (GC db layer already uses
it - no compile-time macros).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:33:26 -07:00
81e4b99a34 spec: add v2-secure-session-core shape spec
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m2s
Build and Test / Build Server (Linux) (push) Successful in 10m41s
Build and Test / Security Audit (push) Successful in 4m17s
Build and Test / Build Summary (push) Successful in 8s
Phase 1 of SPEC-002 (GuruConnect v2). Keystone-first plan: Tasks 1-4
rebuild the auth/session core that closes the 3 audit CRITICALs by design
(per-agent cak_ keys, plane separation, session-scoped viewer tokens,
blacklist+frame-caps+throttle on the relay WS, single-use rate-limited
support codes, tenancy-ready schema); Tasks 5-7 deliver attended consent,
native full key fidelity (WH_KEYBOARD_LL hook, scan-code injection, SAS
Ctrl+Alt+Del), and HW H.264 with raw+Zstd fallback. plan/shape/references/
standards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:15:37 -07:00
5c60a105c0 docs(spec): add SPEC-002 GuruConnect v2 modernization architecture
Some checks failed
Build and Test / Build Agent (Windows) (push) Successful in 6m34s
Build and Test / Build Server (Linux) (push) Has started running
Build and Test / Security Audit (push) Has been cancelled
Build and Test / Build Summary (push) Has been cancelled
Ground-up v2 re-architecture decided 2026-05-29 (Mike), grounded in the
2026-05-29 audit + adopted GuruRMM design principles. Greenfield salvaging
proven Rust cores (DXGI/GDI capture, input injection, SAS helper, prost codec,
CI). Native-first full key fidelity (Win+R/Ctrl+Alt+Del) + bidirectional file
transfer (clipboard cut/paste + drag-and-drop) as headline differentiators;
WebRTC fallback only. Hardened single-tenant, tenancy-ready schema. Standalone-
first + /api/integration/v1 RMM contract. Closes all audit CRITICALs by design.
Open decisions resolved: in-place repo reset, H.264 default, WSS-first web
transport, widened support codes, clean v1 cutover (no client migration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:08:23 -07:00
486debfc52 docs(audit): add inaugural gc-audit report 2026-05-29
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m14s
Build and Test / Build Server (Linux) (push) Successful in 10m29s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 10s
First /gc-audit run (also a dry run validating the skill). 7 passes.
4 CRITICAL (3 relay-plane auth failures: any-JWT session hijack,
viewer-WS blacklist bypass, JWT-accepted-as-agent-key; 1 functional:
dashboard protobuf.ts wire-incompatible). Plus deploy.yml stub leaving
prod 57 commits stale. Proposed roadmap/tech-debt deltas listed (not
yet applied, pending review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:46:26 -07:00
ccc6ba9c02 ci: enforce clippy -D warnings and cargo audit as hard gates
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 12m18s
Build and Test / Build Server (Linux) (push) Successful in 14m11s
Build and Test / Security Audit (push) Successful in 5m32s
Build and Test / Build Summary (push) Successful in 9s
Flip both CI gates from informational to hard-fail (SPEC-001 quality gates):
- clippy: `-- -D warnings` on the server crate. Cleared the debt via clippy --fix
  (unused imports/style), targeted #[allow(dead_code)] on native-remote-control
  future API, and #[allow(clippy::too_many_arguments)] on 3 protocol-mirroring fns.
- cargo audit: hard-fail with documented per-ID --ignore flags (rsa RUSTSEC-2023-0071
  unfixable/unreachable in active tree; gtk-rs + glib Linux-only tray backend not
  compiled into the Windows agent; proc-macro-error build-time). New advisories fail.
- Move [profile.release] to the workspace root (it was silently ignored in the server
  member), activating lto/codegen-units/strip.

No behavioral changes. Reviewed and gates verified passing on the build host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:18:50 +00:00
212 changed files with 34936 additions and 5355 deletions

View File

@@ -57,11 +57,10 @@ jobs:
- name: Check formatting
run: cd server && cargo fmt --all -- --check
# Informational (warn-only) for now. The pre-spec codebase has ~65 lint warnings,
# mostly dead-code for API the integration spec (native-remote-control) will wire.
# Re-tighten to `-- -D warnings` during the GC re-spec once that API is in use.
- name: Run Clippy (informational)
run: cd server && cargo clippy --all-targets --all-features
# Hard gate: clippy must pass with zero warnings (-D warnings). Dead-code that is
# future API surface for native-remote-control carries targeted #[allow(dead_code)].
- name: Run Clippy
run: cd server && cargo clippy --all-targets --all-features -- -D warnings
- name: Build server
run: |
@@ -143,12 +142,18 @@ jobs:
- name: Install cargo-audit
run: cargo install cargo-audit
# Informational (warn-only) for now, like clippy. GuruConnect is a single Cargo workspace,
# so one `cargo audit` at the root covers all members (agent + server) via the shared
# Cargo.lock. The pre-spec dependency tree has known advisories; re-tighten to a hard gate
# during the GC re-spec after a dependency refresh.
- name: Run security audit (informational)
run: cargo audit || echo "[WARNING] cargo audit reported advisories (informational; address in GC re-spec)"
# Hard gate: cargo audit must pass. GuruConnect is a single Cargo workspace, so one
# `cargo audit` at the root covers all members (agent + server) via the shared Cargo.lock.
# The advisories below are explicitly ignored with documented justifications; any NEW
# advisory fails the build.
# RUSTSEC-2023-0071 (rsa) ............. no fixed upgrade; optional/unreachable in active tree
# RUSTSEC-2024-0413/-0416/-0412/-0418/
# -0415/-0420/-0419 (gtk-rs GTK3) ..... Linux-only tray-icon backend, not compiled into shipping Windows agent
# RUSTSEC-2024-0429 (glib) ............ Linux-only tray-icon backend, not compiled into shipping Windows agent
# RUSTSEC-2024-0370 (proc-macro-error) build-time proc-macro dependency, no runtime impact
- name: Run security audit
run: |
cargo audit --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2024-0413 --ignore RUSTSEC-2024-0416 --ignore RUSTSEC-2024-0412 --ignore RUSTSEC-2024-0418 --ignore RUSTSEC-2024-0415 --ignore RUSTSEC-2024-0420 --ignore RUSTSEC-2024-0419 --ignore RUSTSEC-2024-0429 --ignore RUSTSEC-2024-0370
build-summary:
name: Build Summary

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" \

3
.gitignore vendored
View File

@@ -26,3 +26,6 @@ vendor/
# Generated files
*.generated.*
# Built SPA (Vite build output served by the server; rebuilt from dashboard/)
/server/static/app/

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)

150
Cargo.lock generated
View File

@@ -735,19 +735,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "dashmap"
version = "5.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -1075,31 +1062,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "forwarded-header-value"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
dependencies = [
"nonempty",
"thiserror 1.0.69",
]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -1167,19 +1129,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1398,26 +1353,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "governor"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b"
dependencies = [
"cfg-if",
"dashmap",
"futures",
"futures-timer",
"no-std-compat",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.8.6",
"smallvec",
"spinning_top",
]
[[package]]
name = "gtk"
version = "0.18.2"
@@ -1472,13 +1407,14 @@ dependencies = [
[[package]]
name = "guruconnect"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"anyhow",
"bytes",
"chrono",
"clap",
"futures-util",
"hex",
"hostname",
"image",
"muda",
@@ -1511,7 +1447,7 @@ dependencies = [
[[package]]
name = "guruconnect-server"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"anyhow",
"argon2",
@@ -1534,18 +1470,11 @@ dependencies = [
"toml 0.8.2",
"tower",
"tower-http",
"tower_governor",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -2362,24 +2291,6 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
[[package]]
name = "nonempty"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -3035,12 +2946,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -3218,21 +3123,6 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -3377,15 +3267,6 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3960,15 +3841,6 @@ dependencies = [
"lock_api",
]
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
@@ -4653,22 +4525,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tower_governor"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aea939ea6cfa7c4880f3e7422616624f97a567c16df67b53b11f0d03917a8e46"
dependencies = [
"axum",
"forwarded-header-value",
"governor",
"http",
"pin-project",
"thiserror 1.0.69",
"tower",
"tracing",
]
[[package]]
name = "tracing"
version = "0.1.44"

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"
@@ -25,3 +25,8 @@ anyhow = "1"
thiserror = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
[profile.release]
lto = true
codegen-units = 1
strip = true

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,10 +92,18 @@ 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",
"Win32_System_IO",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Ole",
"Win32_System_Variant",
"Win32_Media_MediaFoundation",
"Win32_Media_KernelStreaming",
"Win32_Media_DirectShow",
]}
# Windows service support

View File

@@ -4,7 +4,6 @@
//! The agent communicates with this service via named pipe IPC.
use std::ffi::OsString;
use std::io::{Read, Write as IoWrite};
use std::sync::mpsc;
use std::time::Duration;
@@ -37,7 +36,19 @@ const PIPE_READMODE_MESSAGE: u32 = 0x00000002;
const PIPE_WAIT: u32 = 0x00000000;
const PIPE_UNLIMITED_INSTANCES: u32 = 255;
const INVALID_HANDLE_VALUE: isize = -1;
const SECURITY_DESCRIPTOR_REVISION: u32 = 1;
/// SDDL revision passed to `ConvertStringSecurityDescriptorToSecurityDescriptorW`
/// (`SDDL_REVISION_1`).
const SDDL_REVISION_1: u32 = 1;
/// Restrictive DACL for the SAS named pipe, in SDDL form.
///
/// `D:` introduces the DACL; `(A;;GA;;;AU)` is an ACE granting GENERIC_ALL (`GA`) to
/// Authenticated Users (`AU`). Anonymous / null-session callers are NOT authenticated and
/// are therefore denied — closing the original NULL-DACL hole where any local process
/// (Everyone) could connect and make this SYSTEM service raise the secure-attention
/// screen. The agent runs in the interactive logon session and IS an authenticated user,
/// so it can still connect and request a SAS.
const PIPE_SDDL: &str = "D:(A;;GA;;;AU)";
// FFI declarations for named pipe operations
#[link(name = "kernel32")]
@@ -71,19 +82,23 @@ extern "system" {
lpOverlapped: *mut std::ffi::c_void,
) -> i32;
fn FlushFileBuffers(hFile: isize) -> i32;
fn LocalFree(hMem: *mut std::ffi::c_void) -> *mut std::ffi::c_void;
}
#[link(name = "advapi32")]
extern "system" {
fn InitializeSecurityDescriptor(pSecurityDescriptor: *mut u8, dwRevision: u32) -> i32;
fn SetSecurityDescriptorDacl(
pSecurityDescriptor: *mut u8,
bDaclPresent: i32,
pDacl: *mut std::ffi::c_void,
bDaclDefaulted: i32,
/// Build a self-relative security descriptor from an SDDL string. The descriptor is
/// allocated with `LocalAlloc` and must be released with `LocalFree`.
fn ConvertStringSecurityDescriptorToSecurityDescriptorW(
StringSecurityDescriptor: *const u16,
StringSDRevision: u32,
SecurityDescriptor: *mut *mut std::ffi::c_void,
SecurityDescriptorSize: *mut u32,
) -> i32;
}
// Field names mirror the Win32 SECURITY_ATTRIBUTES ABI struct.
#[allow(non_snake_case)]
#[repr(C)]
struct SECURITY_ATTRIBUTES {
nLength: u32,
@@ -280,26 +295,31 @@ fn run_pipe_server() -> Result<()> {
tracing::info!("Starting pipe server on {}", PIPE_NAME);
loop {
// Create security descriptor that allows everyone
let mut sd = [0u8; 256];
unsafe {
if InitializeSecurityDescriptor(sd.as_mut_ptr(), SECURITY_DESCRIPTOR_REVISION) == 0 {
tracing::error!("Failed to initialize security descriptor");
std::thread::sleep(Duration::from_secs(1));
continue;
}
// Set NULL DACL = allow everyone
if SetSecurityDescriptorDacl(sd.as_mut_ptr(), 1, std::ptr::null_mut(), 0) == 0 {
tracing::error!("Failed to set security descriptor DACL");
std::thread::sleep(Duration::from_secs(1));
continue;
}
// Build a restrictive security descriptor from SDDL: grant access only to
// Authenticated Users (excludes anonymous / null-session callers). See PIPE_SDDL.
let sddl: Vec<u16> = PIPE_SDDL.encode_utf16().chain(std::iter::once(0)).collect();
let mut sd_ptr: *mut std::ffi::c_void = std::ptr::null_mut();
let converted = unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl.as_ptr(),
SDDL_REVISION_1,
&mut sd_ptr,
std::ptr::null_mut(),
)
};
if converted == 0 || sd_ptr.is_null() {
let err = std::io::Error::last_os_error();
tracing::error!(
"Failed to build pipe security descriptor from SDDL: {}",
err
);
std::thread::sleep(Duration::from_secs(1));
continue;
}
let mut sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: sd.as_mut_ptr(),
lpSecurityDescriptor: sd_ptr as *mut u8,
bInheritHandle: 0,
};
@@ -320,6 +340,12 @@ fn run_pipe_server() -> Result<()> {
)
};
// CreateNamedPipeW copies the descriptor into the kernel object, so the SDDL-built
// copy can be freed now regardless of success.
unsafe {
LocalFree(sd_ptr);
}
if pipe == INVALID_HANDLE_VALUE {
tracing::error!("Failed to create named pipe");
std::thread::sleep(Duration::from_secs(1));
@@ -403,6 +429,69 @@ fn run_pipe_server() -> Result<()> {
}
}
/// Enable the `SoftwareSASGeneration` Winlogon policy so `SendSAS` is permitted.
///
/// Without this policy, `sas.dll!SendSAS` is a silent no-op even when called from
/// SYSTEM. The value lives at
/// `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\SoftwareSASGeneration`
/// and is a DWORD bitmask:
/// 0 = none, 1 = services, 2 = ease-of-access apps, 3 = both.
///
/// We set `1` (services) because the GuruConnect SAS helper runs as a SYSTEM service.
/// This is invoked from the SAS service installer; the broader agent installer should
/// ensure this runs (see `// TODO(installer)` below).
fn set_software_sas_policy() -> Result<()> {
use windows::core::PCWSTR;
use windows::Win32::System::Registry::{
RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_LOCAL_MACHINE, KEY_SET_VALUE,
REG_DWORD, REG_OPTION_NON_VOLATILE,
};
let subkey: Vec<u16> = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let value_name: Vec<u16> = "SoftwareSASGeneration"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// DWORD 1 = allow services to generate a software SAS.
let data: u32 = 1;
unsafe {
let mut hkey = HKEY::default();
let status = RegCreateKeyExW(
HKEY_LOCAL_MACHINE,
PCWSTR(subkey.as_ptr()),
0,
PCWSTR::null(),
REG_OPTION_NON_VOLATILE,
KEY_SET_VALUE,
None,
&mut hkey,
None,
);
if status.is_err() {
anyhow::bail!("RegCreateKeyExW(Policies\\System) failed: {:?}", status);
}
let set = RegSetValueExW(
hkey,
PCWSTR(value_name.as_ptr()),
0,
REG_DWORD,
Some(&data.to_ne_bytes()),
);
let _ = RegCloseKey(hkey);
if set.is_err() {
anyhow::bail!("RegSetValueExW(SoftwareSASGeneration) failed: {:?}", set);
}
}
Ok(())
}
/// Call SendSAS via sas.dll
fn send_sas() -> Result<()> {
unsafe {
@@ -505,6 +594,19 @@ fn install_service() -> Result<()> {
])
.output();
// Enable the SoftwareSASGeneration policy so SendSAS actually works from the
// SYSTEM service. TODO(installer): the top-level managed agent installer should
// also ensure this policy is set (and that this SAS service is installed) as part
// of unattended deployment, rather than relying on a manual SAS-service install.
match set_software_sas_policy() {
Ok(()) => println!("Enabled SoftwareSASGeneration policy (services)"),
Err(e) => println!(
"Warning: failed to set SoftwareSASGeneration policy: {}. \
Ctrl+Alt+Del may not reach the secure desktop until this is set.",
e
),
}
println!("\n** GuruConnect SAS Service installed successfully!");
println!("\nBinary: {:?}", binary_dest);
println!("\nStarting service...");

View File

@@ -32,6 +32,8 @@ pub struct Display {
}
/// Display info for protocol messages
// Future use: multi-display protocol negotiation.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct DisplayInfo {
pub displays: Vec<Display>,
@@ -40,11 +42,13 @@ pub struct DisplayInfo {
impl Display {
/// Total pixels in the display
#[allow(dead_code)]
pub fn pixel_count(&self) -> u32 {
self.width * self.height
}
/// Bytes needed for BGRA frame buffer
#[allow(dead_code)]
pub fn buffer_size(&self) -> usize {
(self.width * self.height * 4) as usize
}
@@ -60,7 +64,6 @@ pub fn enumerate_displays() -> Result<Vec<Display>> {
};
let mut displays = Vec::new();
let mut display_id = 0u32;
// Callback for EnumDisplayMonitors
unsafe extern "system" fn enum_callback(
@@ -148,6 +151,8 @@ pub fn enumerate_displays() -> Result<Vec<Display>> {
}
/// Get display info for protocol
// Future use: multi-display protocol negotiation.
#[allow(dead_code)]
pub fn get_display_info() -> Result<DisplayInfo> {
let displays = enumerate_displays()?;
let primary_id = displays

View File

@@ -32,6 +32,8 @@ pub struct DxgiCapturer {
staging_texture: Option<ID3D11Texture2D>,
width: u32,
height: u32,
// Future use: frame diffing against the previously captured frame.
#[allow(dead_code)]
last_frame: Option<Vec<u8>>,
}

View File

@@ -48,7 +48,7 @@ impl GdiCapturer {
let bitmap = CreateCompatibleBitmap(screen_dc, self.width as i32, self.height as i32);
if bitmap.is_invalid() {
DeleteDC(mem_dc);
let _ = DeleteDC(mem_dc);
ReleaseDC(HWND::default(), screen_dc);
anyhow::bail!("Failed to create compatible bitmap");
}
@@ -69,8 +69,8 @@ impl GdiCapturer {
SRCCOPY,
) {
SelectObject(mem_dc, old_bitmap);
DeleteObject(bitmap);
DeleteDC(mem_dc);
let _ = DeleteObject(bitmap);
let _ = DeleteDC(mem_dc);
ReleaseDC(HWND::default(), screen_dc);
anyhow::bail!("BitBlt failed: {}", e);
}
@@ -110,8 +110,8 @@ impl GdiCapturer {
// Cleanup
SelectObject(mem_dc, old_bitmap);
DeleteObject(bitmap);
DeleteDC(mem_dc);
let _ = DeleteObject(bitmap);
let _ = DeleteDC(mem_dc);
ReleaseDC(HWND::default(), screen_dc);
if lines == 0 {

View File

@@ -9,7 +9,7 @@ mod dxgi;
#[cfg(windows)]
mod gdi;
pub use display::{Display, DisplayInfo};
pub use display::Display;
use anyhow::Result;
use std::time::Instant;
@@ -33,6 +33,8 @@ pub struct CapturedFrame {
pub display_id: u32,
/// Regions that changed since last frame (if available)
// Populated by capturers; not yet consumed by the encoder pipeline.
#[allow(dead_code)]
pub dirty_rects: Option<Vec<DirtyRect>>,
}
@@ -53,9 +55,11 @@ pub trait Capturer: Send {
fn capture(&mut self) -> Result<Option<CapturedFrame>>;
/// Get the current display info
#[allow(dead_code)]
fn display(&self) -> &Display;
/// Check if capturer is still valid (display may have changed)
#[allow(dead_code)]
fn is_valid(&self) -> bool;
}

View File

@@ -6,17 +6,13 @@
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use tracing::{error, info, warn};
use tracing::info;
#[cfg(not(windows))]
use tracing::warn;
#[cfg(windows)]
use windows::core::PCWSTR;
#[cfg(windows)]
use windows::Win32::Foundation::*;
#[cfg(windows)]
use windows::Win32::Graphics::Gdi::*;
#[cfg(windows)]
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
#[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::*;
/// A chat message
@@ -29,11 +25,15 @@ pub struct ChatMessage {
}
/// Commands that can be sent to the chat window
// Show/Hide/Close are part of the chat control API but not yet driven by the session loop.
#[derive(Debug)]
pub enum ChatCommand {
#[allow(dead_code)]
Show,
#[allow(dead_code)]
Hide,
AddMessage(ChatMessage),
#[allow(dead_code)]
Close,
}
@@ -69,11 +69,13 @@ impl ChatController {
}
/// Show the chat window
#[allow(dead_code)]
pub fn show(&self) {
let _ = self.command_tx.send(ChatCommand::Show);
}
/// Hide the chat window
#[allow(dead_code)]
pub fn hide(&self) {
let _ = self.command_tx.send(ChatCommand::Hide);
}
@@ -93,16 +95,14 @@ impl ChatController {
}
/// Close the chat window
#[allow(dead_code)]
pub fn close(&self) {
let _ = self.command_tx.send(ChatCommand::Close);
}
}
#[cfg(windows)]
fn run_chat_window(command_rx: Receiver<ChatCommand>, message_tx: Sender<ChatMessage>) {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
fn run_chat_window(command_rx: Receiver<ChatCommand>, _message_tx: Sender<ChatMessage>) {
info!("Starting chat window thread");
// For now, we'll use a simple message box approach

View File

@@ -9,25 +9,46 @@ use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::io::{Read, Seek, SeekFrom};
use std::path::PathBuf;
use tracing::{info, warn};
use tracing::info;
use uuid::Uuid;
/// Magic marker for embedded configuration (10 bytes)
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)
@@ -196,7 +263,7 @@ impl Config {
/// Extract 6-digit support code from filename
fn extract_support_code(filename: &str) -> Option<String> {
// Look for patterns like "GuruConnect-123456" or "GuruConnect_123456"
for part in filename.split(|c| c == '-' || c == '_' || c == '.') {
for part in filename.split(['-', '_', '.']) {
let trimmed = part.trim();
if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) {
return Some(trimmed.to_string());
@@ -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
@@ -435,6 +574,8 @@ impl Config {
}
/// Example configuration file content
// Retained for documentation / config-template generation.
#[allow(dead_code)]
pub fn example_config() -> &'static str {
r#"# GuruConnect Agent Configuration

157
agent/src/consent/mod.rs Normal file
View File

@@ -0,0 +1,157 @@
//! Attended-mode consent prompt (Task 5).
//!
//! For an attended (support-code) session, the GuruConnect server sends the
//! agent a `ConsentRequest` before the technician's session is allowed to go
//! live. The agent shows the end user a native dialog ("Allow <technician> to
//! VIEW/CONTROL this computer?") and returns the user's choice as a
//! `ConsentResponse`. The server holds the session in `consent_state = pending`
//! and tears it down on a denial or timeout.
//!
//! v1 uses a Windows `MessageBox` (Yes/No, top-most, foreground). It is
//! synchronous and reliable on every supported Windows version (7 SP1+), needs
//! no extra windowing, and cannot be dismissed into an ambiguous state — the
//! only outcomes are Yes (allow), No (deny), or the box being closed (treated
//! as deny). A nicer custom branded dialog (countdown, technician avatar) is a
//! possible future refinement; it is not required for correctness.
//!
//! The decision is the end user's and is purely advisory to the agent: the
//! server is the enforcement point (it will not surface the session to the
//! technician until it receives a `granted` response). The agent simply relays
//! the human's choice.
/// Whether the technician requested view-only or full control, used only to
/// phrase the prompt. Mirrors `proto::ConsentAccessMode`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsentAccessMode {
View,
Control,
}
impl ConsentAccessMode {
/// Decode the proto enum value (defaults to the more conservative `Control`
/// wording on an unknown value so the prompt never under-states access).
pub fn from_proto(value: i32) -> Self {
match crate::proto::ConsentAccessMode::try_from(value) {
Ok(crate::proto::ConsentAccessMode::ConsentView) => ConsentAccessMode::View,
Ok(crate::proto::ConsentAccessMode::ConsentControl) => ConsentAccessMode::Control,
Err(_) => ConsentAccessMode::Control,
}
}
fn verb(self) -> &'static str {
match self {
ConsentAccessMode::View => "VIEW",
ConsentAccessMode::Control => "VIEW and CONTROL",
}
}
}
/// Build the consent prompt body shown to the end user.
fn prompt_body(technician_name: &str, access: ConsentAccessMode) -> String {
let who = if technician_name.trim().is_empty() {
"A support technician"
} else {
technician_name
};
format!(
"{who} is requesting a remote support session.\n\n\
If you allow this, they will be able to {verb} this computer.\n\n\
Do you want to allow this remote support session?",
who = who,
verb = access.verb()
)
}
/// Show the consent dialog and return the end user's decision.
///
/// Returns `true` if the user ALLOWED the session, `false` if they denied it or
/// the dialog was closed/could not be shown. Blocking — callers should run this
/// off the async runtime (e.g. `tokio::task::spawn_blocking`).
#[cfg(windows)]
pub fn prompt_consent(technician_name: &str, access: ConsentAccessMode) -> bool {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::UI::WindowsAndMessaging::{
MessageBoxW, IDYES, MB_ICONQUESTION, MB_SETFOREGROUND, MB_SYSTEMMODAL, MB_TOPMOST, MB_YESNO,
};
let title = "GuruConnect - Remote Support Request";
let body = prompt_body(technician_name, access);
let title_wide: Vec<u16> = OsStr::new(title)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let body_wide: Vec<u16> = OsStr::new(&body)
.encode_wide()
.chain(std::iter::once(0))
.collect();
// MB_YESNO - explicit Allow (Yes) / Deny (No)
// MB_ICONQUESTION - prompt styling
// MB_TOPMOST - sit above other windows so it cannot be hidden
// MB_SETFOREGROUND - bring to the foreground
// MB_SYSTEMMODAL - ensure visibility even from a service/elevated context
let result = unsafe {
MessageBoxW(
None,
PCWSTR(body_wide.as_ptr()),
PCWSTR(title_wide.as_ptr()),
MB_YESNO | MB_ICONQUESTION | MB_TOPMOST | MB_SETFOREGROUND | MB_SYSTEMMODAL,
)
};
// Any outcome other than an explicit "Yes" is a denial (including the box
// being closed, which returns IDNO/IDCANCEL-style values).
result == IDYES
}
/// Non-Windows stub. The agent is Windows-first; on other platforms there is no
/// native end-user consent surface yet, so we fail CLOSED (deny) rather than
/// silently allowing an unattended session.
///
// TODO(platform): provide a real consent dialog on macOS/Linux when the agent
// is ported there (e.g. a GTK/Cocoa modal). Until then, deny so a non-Windows
// build can never grant an attended session without an explicit human prompt.
#[cfg(not(windows))]
pub fn prompt_consent(_technician_name: &str, _access: ConsentAccessMode) -> bool {
tracing::warn!(
"Consent prompt requested on a non-Windows build; no native dialog available — denying"
);
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prompt_body_uses_control_wording() {
let body = prompt_body("Mike", ConsentAccessMode::Control);
assert!(body.contains("Mike"));
assert!(body.contains("VIEW and CONTROL"));
}
#[test]
fn prompt_body_uses_view_wording() {
let body = prompt_body("Mike", ConsentAccessMode::View);
assert!(body.contains("VIEW"));
assert!(!body.contains("CONTROL"));
}
#[test]
fn prompt_body_falls_back_on_empty_name() {
let body = prompt_body(" ", ConsentAccessMode::Control);
assert!(body.contains("A support technician"));
}
#[test]
fn access_mode_from_proto_defaults_to_control() {
// An out-of-range proto value must not under-state access.
assert_eq!(
ConsentAccessMode::from_proto(999),
ConsentAccessMode::Control
);
}
}

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

View File

@@ -0,0 +1,97 @@
//! Hardware video-encode capability detection (Task 7).
//!
//! Probes Windows Media Foundation for a HARDWARE H.264 encoder MFT at startup.
//! The result is cached and advertised to the server in `AgentStatus.supports_h264`
//! so the server can negotiate the codec (see `StartStream.video_codec`).
//!
//! Detection is intentionally cheap and side-effect-free: it only ENUMERATES the
//! available encoder MFTs (it does not create or initialize one). A `true` result
//! means a hardware H.264 encoder was advertised by the OS; it does NOT guarantee
//! the encoder will successfully initialize at stream time — the H.264 encoder
//! still falls back to raw on any init/feed failure.
//!
//! On non-Windows targets, or if MF is unavailable, this reports `false`.
use std::sync::OnceLock;
/// Cached capability result. Detection runs at most once per process.
static SUPPORTS_H264: OnceLock<bool> = OnceLock::new();
/// Return whether this machine has a hardware H.264 encoder, detecting once and
/// caching the result. Safe to call repeatedly and from any thread.
pub fn supports_hardware_h264() -> bool {
*SUPPORTS_H264.get_or_init(detect_hardware_h264)
}
/// Run the actual detection. Separated so the cached accessor stays trivial.
fn detect_hardware_h264() -> bool {
let supported = detect_inner();
if supported {
tracing::info!("Hardware H.264 encoder detected (Media Foundation)");
} else {
tracing::info!("No hardware H.264 encoder detected; raw+Zstd only");
}
supported
}
#[cfg(windows)]
fn detect_inner() -> bool {
// Enumerate hardware H.264 encoder MFTs. This is a read-only probe; it does
// not init D3D, COM apartments persistently, or create the encoder.
match unsafe { enumerate_hardware_h264() } {
Ok(found) => found,
Err(e) => {
tracing::warn!("H.264 capability probe failed: {e:#}; assuming no HW encoder");
false
}
}
}
#[cfg(not(windows))]
fn detect_inner() -> bool {
false
}
#[cfg(windows)]
unsafe fn enumerate_hardware_h264() -> anyhow::Result<bool> {
use windows::Win32::Media::MediaFoundation::{
MFMediaType_Video, MFTEnumEx, MFVideoFormat_H264, MFT_CATEGORY_VIDEO_ENCODER,
MFT_ENUM_FLAG_HARDWARE, MFT_ENUM_FLAG_SORTANDFILTER, MFT_ENUM_FLAG_TRANSCODE_ONLY,
MFT_REGISTER_TYPE_INFO,
};
// We only specify the OUTPUT type (H.264); input is left unconstrained so the
// probe matches encoders regardless of their preferred input subtype.
let output_type = MFT_REGISTER_TYPE_INFO {
guidMajorType: MFMediaType_Video,
guidSubtype: MFVideoFormat_H264,
};
let mut activate_ptr: *mut Option<windows::Win32::Media::MediaFoundation::IMFActivate> =
std::ptr::null_mut();
let mut count: u32 = 0;
// MFTEnumEx does not itself require MFStartup for a pure enumeration, but we
// guard with a Result so any HRESULT failure degrades to "no HW encoder".
MFTEnumEx(
MFT_CATEGORY_VIDEO_ENCODER,
MFT_ENUM_FLAG_HARDWARE | MFT_ENUM_FLAG_SORTANDFILTER | MFT_ENUM_FLAG_TRANSCODE_ONLY,
None, // input type: any
Some(&output_type as *const _),
&mut activate_ptr,
&mut count,
)?;
// Release every returned IMFActivate, then free the array CoTaskMemAlloc'd by MF.
let found = count > 0;
if !activate_ptr.is_null() {
let slice = std::slice::from_raw_parts_mut(activate_ptr, count as usize);
for entry in slice.iter_mut() {
// Dropping the Option<IMFActivate> releases the COM reference.
entry.take();
}
windows::Win32::System::Com::CoTaskMemFree(Some(activate_ptr as *const _));
}
Ok(found)
}

269
agent/src/encoder/color.rs Normal file
View File

@@ -0,0 +1,269 @@
//! Color-space conversion for the H.264 encode path (Task 7).
//!
//! Screen capture produces BGRA (4 bytes/pixel, B,G,R,A order — the DXGI/GDI
//! native layout). Media Foundation hardware H.264 encoders want NV12: a full-
//! resolution 8-bit Y (luma) plane followed by an interleaved half-resolution
//! U/V (chroma) plane. This module does that conversion in software.
//!
//! NV12 memory layout for a `width x height` frame (width/height assumed even):
//! - Y plane: `width * height` bytes, row-major.
//! - UV plane: `width * (height / 2)` bytes — for each 2x2 luma block one
//! (U, V) pair, so the plane is `(width/2)` (U,V) pairs per row over
//! `height/2` rows, i.e. `width` bytes per chroma row.
//!
//! Total size = `width * height * 3 / 2`.
//!
//! The coefficients are BT.601 "studio swing" (limited range, 16..235 luma),
//! which is what MF H.264 encoders expect by default. Chroma is computed by
//! averaging the 2x2 BGRA block before conversion (box downsample) to reduce
//! aliasing.
/// Size in bytes of an NV12 buffer for `width` x `height` (both even).
#[inline]
pub fn nv12_size(width: u32, height: u32) -> usize {
(width as usize * height as usize) * 3 / 2
}
/// BT.601 limited-range luma from 8-bit R,G,B.
#[inline]
fn rgb_to_y(r: i32, g: i32, b: i32) -> u8 {
// Y = 16 + (65.481*R + 128.553*G + 24.966*B) / 255, fixed-point.
// Using the common integer approximation:
// Y = ((66*R + 129*G + 25*B + 128) >> 8) + 16
let y = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16;
y.clamp(0, 255) as u8
}
/// BT.601 limited-range Cb (U) from 8-bit R,G,B.
#[inline]
fn rgb_to_u(r: i32, g: i32, b: i32) -> u8 {
let u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128;
u.clamp(0, 255) as u8
}
/// BT.601 limited-range Cr (V) from 8-bit R,G,B.
#[inline]
fn rgb_to_v(r: i32, g: i32, b: i32) -> u8 {
let v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128;
v.clamp(0, 255) as u8
}
/// Convert a tightly-packed BGRA frame into NV12, writing into `out`.
///
/// `bgra` must be at least `width * height * 4` bytes; `out` must be at least
/// `nv12_size(width, height)` bytes. `width` and `height` MUST be even (H.264
/// 4:2:0 requires even dimensions — the caller pads odd capture sizes). Returns
/// an error rather than panicking on a short buffer or odd dimension so the
/// encoder can fall back to raw.
pub fn bgra_to_nv12(
bgra: &[u8],
width: u32,
height: u32,
out: &mut [u8],
) -> Result<(), ColorConvertError> {
if width == 0 || height == 0 {
return Err(ColorConvertError::ZeroDimension);
}
if !width.is_multiple_of(2) || !height.is_multiple_of(2) {
return Err(ColorConvertError::OddDimension { width, height });
}
let w = width as usize;
let h = height as usize;
let expected_src = w * h * 4;
if bgra.len() < expected_src {
return Err(ColorConvertError::SrcTooSmall {
got: bgra.len(),
need: expected_src,
});
}
let need_out = nv12_size(width, height);
if out.len() < need_out {
return Err(ColorConvertError::DstTooSmall {
got: out.len(),
need: need_out,
});
}
let (y_plane, uv_plane) = out.split_at_mut(w * h);
// Luma: one sample per pixel.
for row in 0..h {
let src_row = row * w * 4;
let dst_row = row * w;
for col in 0..w {
let px = src_row + col * 4;
// BGRA order.
let b = bgra[px] as i32;
let g = bgra[px + 1] as i32;
let r = bgra[px + 2] as i32;
y_plane[dst_row + col] = rgb_to_y(r, g, b);
}
}
// Chroma: one (U,V) pair per 2x2 block, box-averaged.
let chroma_rows = h / 2;
let chroma_cols = w / 2;
for cy in 0..chroma_rows {
for cx in 0..chroma_cols {
let x0 = cx * 2;
let y0 = cy * 2;
let mut r_sum = 0i32;
let mut g_sum = 0i32;
let mut b_sum = 0i32;
for dy in 0..2 {
for dx in 0..2 {
let px = ((y0 + dy) * w + (x0 + dx)) * 4;
b_sum += bgra[px] as i32;
g_sum += bgra[px + 1] as i32;
r_sum += bgra[px + 2] as i32;
}
}
let r = r_sum / 4;
let g = g_sum / 4;
let b = b_sum / 4;
let uv_idx = (cy * chroma_cols + cx) * 2;
uv_plane[uv_idx] = rgb_to_u(r, g, b);
uv_plane[uv_idx + 1] = rgb_to_v(r, g, b);
}
}
Ok(())
}
/// Errors from BGRA->NV12 conversion. Surfaced (not panicked) so the H.264
/// encoder can downgrade to raw.
#[derive(Debug, thiserror::Error)]
pub enum ColorConvertError {
#[error("frame dimension is zero")]
ZeroDimension,
#[error("NV12 requires even dimensions, got {width}x{height}")]
OddDimension { width: u32, height: u32 },
#[error("source BGRA buffer too small: {got} < {need}")]
SrcTooSmall { got: usize, need: usize },
#[error("destination NV12 buffer too small: {got} < {need}")]
DstTooSmall { got: usize, need: usize },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nv12_size_is_3half() {
assert_eq!(nv12_size(2, 2), 6);
assert_eq!(nv12_size(4, 4), 24);
assert_eq!(nv12_size(1920, 1080), 1920 * 1080 * 3 / 2);
}
#[test]
fn rejects_odd_dimensions() {
let bgra = vec![0u8; 3 * 3 * 4];
let mut out = vec![0u8; nv12_size(4, 4)];
assert!(matches!(
bgra_to_nv12(&bgra, 3, 2, &mut out),
Err(ColorConvertError::OddDimension { .. })
));
assert!(matches!(
bgra_to_nv12(&bgra, 2, 3, &mut out),
Err(ColorConvertError::OddDimension { .. })
));
}
#[test]
fn rejects_short_source() {
let bgra = vec![0u8; 4]; // way too small for 2x2
let mut out = vec![0u8; nv12_size(2, 2)];
assert!(matches!(
bgra_to_nv12(&bgra, 2, 2, &mut out),
Err(ColorConvertError::SrcTooSmall { .. })
));
}
#[test]
fn rejects_short_dest() {
let bgra = vec![0u8; 2 * 2 * 4];
let mut out = vec![0u8; 1];
assert!(matches!(
bgra_to_nv12(&bgra, 2, 2, &mut out),
Err(ColorConvertError::DstTooSmall { .. })
));
}
/// A pure-black BGRA frame -> Y = 16 (limited-range black), U = V = 128.
#[test]
fn black_frame_maps_to_limited_range_black() {
let bgra = vec![0u8; 4 * 4 * 4]; // all zero => black, alpha 0
let mut out = vec![0u8; nv12_size(4, 4)];
bgra_to_nv12(&bgra, 4, 4, &mut out).unwrap();
// Y plane (first 16 bytes) all 16.
for &y in &out[..16] {
assert_eq!(y, 16, "black luma must be 16 (limited range)");
}
// UV plane all 128 (neutral chroma).
for &c in &out[16..] {
assert_eq!(c, 128, "black chroma must be neutral 128");
}
}
/// A pure-white BGRA frame -> Y = 235 (limited-range white), U = V = 128.
#[test]
fn white_frame_maps_to_limited_range_white() {
// B=255, G=255, R=255, A=255 for every pixel.
let bgra = vec![255u8; 2 * 2 * 4];
let mut out = vec![0u8; nv12_size(2, 2)];
bgra_to_nv12(&bgra, 2, 2, &mut out).unwrap();
// Y = ((66+129+25)*255 + 128) >> 8 + 16 = 235.
for &y in &out[..4] {
assert_eq!(y, 235, "white luma must be 235 (limited range)");
}
// Neutral chroma for a gray/white pixel.
assert_eq!(out[4], 128);
assert_eq!(out[5], 128);
}
/// A pure-red frame: luma below mid, V (Cr) well above 128, U (Cb) below 128.
#[test]
fn red_frame_has_high_cr_low_cb() {
// BGRA red: B=0, G=0, R=255, A=255.
let mut bgra = vec![0u8; 2 * 2 * 4];
for px in bgra.chunks_mut(4) {
px[0] = 0; // B
px[1] = 0; // G
px[2] = 255; // R
px[3] = 255; // A
}
let mut out = vec![0u8; nv12_size(2, 2)];
bgra_to_nv12(&bgra, 2, 2, &mut out).unwrap();
let u = out[4];
let v = out[5];
assert!(v > 200, "red must have high Cr (V), got {v}");
assert!(u < 128, "red must have Cb (U) below neutral, got {u}");
}
/// Conversion fills the whole NV12 buffer (no leftover zeros where data is
/// expected) for a non-trivial gradient — a sanity check on plane indexing.
#[test]
fn plane_indexing_covers_full_buffer() {
let w = 8u32;
let h = 8u32;
let mut bgra = vec![0u8; (w * h * 4) as usize];
for (i, px) in bgra.chunks_mut(4).enumerate() {
let v = (i % 256) as u8;
px[0] = v;
px[1] = v;
px[2] = v;
px[3] = 255;
}
let mut out = vec![0xAAu8; nv12_size(w, h)];
bgra_to_nv12(&bgra, w, h, &mut out).unwrap();
// Y plane should be fully written (gray ramp -> non-constant).
let y_plane = &out[..(w * h) as usize];
assert!(y_plane.windows(2).any(|p| p[0] != p[1]), "Y plane varies");
}
}

515
agent/src/encoder/h264.rs Normal file
View File

@@ -0,0 +1,515 @@
//! Hardware H.264 encoder via Windows Media Foundation (Task 7).
//!
//! FIRST-CUT / COMPILE-VERIFIED ONLY. This encoder is wired end-to-end (init ->
//! feed -> drain -> emit `EncodedFrame{h264}`) and is selected only when the
//! agent advertised hardware support AND the server negotiated H.264. It has NOT
//! been validated on real hardware with live frames — that is plan Task 8. On
//! ANY initialization or per-frame failure it surfaces an error; the encoder
//! factory (`create_encoder_for`) downgrades to the raw+Zstd encoder so a
//! session never breaks because of H.264.
//!
//! Pipeline:
//! BGRA capture --(color::bgra_to_nv12)--> NV12 sample --> MFT(H.264) --> H.264
//! Annex-B/length-prefixed elementary stream --> proto EncodedFrame.
//!
//! Design notes:
//! - The MFT is enumerated with `MFTEnumEx(MFT_CATEGORY_VIDEO_ENCODER,
//! MFT_ENUM_FLAG_HARDWARE, …, MFVideoFormat_H264)` (same probe as
//! `capability`). We `ActivateObject` the first match.
//! - Input is configured as NV12, output as H.264, with frame size, frame rate
//! and an average bitrate derived from `quality`.
//! - Both the SYNCHRONOUS MFT model (ProcessInput/ProcessOutput) and the
//! ASYNCHRONOUS hardware-MFT model (METransformNeedInput / METransformHaveOutput
//! events) exist. To keep this first cut bounded and predictable we DRAIN the
//! MFT synchronously after each input and treat `MF_E_TRANSFORM_NEED_MORE_INPUT`
//! as "no output this tick". A fully async event-driven loop is a Task-8
//! refinement (documented below).
//! - `MFT_MESSAGE_SET_D3D_MANAGER` is intentionally NOT set — we feed CPU NV12
//! buffers (software input samples), which every HW H.264 MFT accepts. D3D11
//! zero-copy is a later optimization.
#![cfg(windows)]
use super::{EncodedFrame, Encoder};
use crate::capture::CapturedFrame;
use crate::encoder::color;
use crate::proto::{video_frame, EncodedFrame as ProtoEncodedFrame, VideoFrame};
use anyhow::{anyhow, Context, Result};
use windows::Win32::Media::MediaFoundation::{
IMFActivate, IMFMediaType, IMFSample, IMFTransform, MFCreateMediaType, MFCreateMemoryBuffer,
MFCreateSample, MFMediaType_Video, MFShutdown, MFStartup, MFTEnumEx, MFVideoFormat_H264,
MFVideoFormat_NV12, MFVideoInterlace_Progressive, MFSTARTUP_LITE, MFT_CATEGORY_VIDEO_ENCODER,
MFT_ENUM_FLAG_HARDWARE, MFT_ENUM_FLAG_SORTANDFILTER, MFT_ENUM_FLAG_TRANSCODE_ONLY,
MFT_MESSAGE_COMMAND_FLUSH, 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_MT_AVG_BITRATE, MF_MT_FRAME_RATE,
MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE, MF_MT_MAJOR_TYPE, MF_MT_PIXEL_ASPECT_RATIO,
MF_MT_SUBTYPE,
};
/// Encoder-internal state, created once and reused per frame.
pub struct H264Encoder {
/// The activated encoder transform.
transform: IMFTransform,
/// Configured frame dimensions; a capture-size change forces re-init.
width: u32,
height: u32,
/// Quality (1-100) used to derive the bitrate; kept for re-init on resize.
quality: u32,
/// Frame sequence counter (mirrors RawEncoder).
sequence: u32,
/// Force the next frame to request a keyframe.
force_keyframe: bool,
/// Whether `MFT_MESSAGE_NOTIFY_BEGIN_STREAMING` was sent.
streaming: bool,
/// Reusable NV12 staging buffer (resized on dimension change).
nv12: Vec<u8>,
/// Input/output stream identifiers (most encoders use 0/0).
input_stream_id: u32,
output_stream_id: u32,
/// True if MF was started by THIS encoder and must be shut down on drop.
mf_started: bool,
}
// IMFTransform is a COM interface; it is not auto-Send. We only ever touch the
// encoder from the single capture/encode thread (the session owns it behind a
// &mut), so it is safe to move between threads as long as it is not shared.
unsafe impl Send for H264Encoder {}
impl H264Encoder {
/// Construct and fully initialize a hardware H.264 encoder. Returns an error
/// (so the factory can fall back to raw) if MF is unavailable, no hardware
/// encoder exists, or media-type negotiation fails. A default frame size is
/// used and re-negotiated on the first frame if the real capture differs.
pub fn new(quality: u32) -> Result<Self> {
// 1920x1080 default; re-init on the first frame if the capture differs.
Self::with_dimensions(quality, 1920, 1080)
}
fn with_dimensions(quality: u32, width: u32, height: u32) -> Result<Self> {
unsafe {
// MF must be initialized on this thread. MFSTARTUP_LITE avoids the
// sockets/network stack we don't need.
MFStartup(mf_version(), MFSTARTUP_LITE).context("MFStartup failed")?;
let mf_started = true;
let transform = match Self::activate_hw_encoder() {
Ok(t) => t,
Err(e) => {
// Balance the MFStartup we just did before bailing.
let _ = MFShutdown();
return Err(e);
}
};
let mut enc = Self {
transform,
width,
height,
quality,
sequence: 0,
force_keyframe: true,
streaming: false,
nv12: Vec::new(),
input_stream_id: 0,
output_stream_id: 0,
mf_started,
};
// `enc`'s Drop will shut MF down and release the transform on error.
enc.configure_media_types()?;
Ok(enc)
}
}
/// Enumerate hardware H.264 encoder MFTs and activate the first one.
unsafe fn activate_hw_encoder() -> Result<IMFTransform> {
let output_type = MFT_REGISTER_TYPE_INFO {
guidMajorType: MFMediaType_Video,
guidSubtype: MFVideoFormat_H264,
};
let mut activate_ptr: *mut Option<IMFActivate> = std::ptr::null_mut();
let mut count: u32 = 0;
MFTEnumEx(
MFT_CATEGORY_VIDEO_ENCODER,
MFT_ENUM_FLAG_HARDWARE | MFT_ENUM_FLAG_SORTANDFILTER | MFT_ENUM_FLAG_TRANSCODE_ONLY,
None,
Some(&output_type as *const _),
&mut activate_ptr,
&mut count,
)
.context("MFTEnumEx (hardware H.264) failed")?;
if count == 0 || activate_ptr.is_null() {
if !activate_ptr.is_null() {
windows::Win32::System::Com::CoTaskMemFree(Some(activate_ptr as *const _));
}
return Err(anyhow!("no hardware H.264 encoder MFT available"));
}
let slice = std::slice::from_raw_parts_mut(activate_ptr, count as usize);
// Activate the first usable encoder; release every IMFActivate.
let mut chosen: Option<IMFTransform> = None;
for entry in slice.iter_mut() {
if chosen.is_none() {
if let Some(activate) = entry.as_ref() {
if let Ok(transform) = activate.ActivateObject::<IMFTransform>() {
chosen = Some(transform);
}
}
}
// Release this IMFActivate reference.
entry.take();
}
windows::Win32::System::Com::CoTaskMemFree(Some(activate_ptr as *const _));
chosen.ok_or_else(|| anyhow!("failed to activate any hardware H.264 encoder MFT"))
}
/// Set the H.264 output type and NV12 input type, in the order MF requires
/// (output type FIRST for encoders, then the matching input type).
unsafe fn configure_media_types(&mut self) -> Result<()> {
// Discover the real stream identifiers (most encoders report 0/0).
let mut input_ids = [0u32; 1];
let mut output_ids = [0u32; 1];
// GetStreamIDs may return E_NOTIMPL meaning "ids are 0..n-1"; ignore err.
let _ = self.transform.GetStreamIDs(&mut input_ids, &mut output_ids);
// If GetStreamIDs populated nonzero ids use them, else default 0/0.
if input_ids[0] != 0 {
self.input_stream_id = input_ids[0];
}
if output_ids[0] != 0 {
self.output_stream_id = output_ids[0];
}
let fps_num = 30u32;
let fps_den = 1u32;
let bitrate = quality_to_bitrate(self.quality, self.width, self.height);
// ---- OUTPUT (H.264) ----
let out_type: IMFMediaType = MFCreateMediaType().context("MFCreateMediaType(out)")?;
out_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
out_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)?;
out_type.SetUINT32(&MF_MT_AVG_BITRATE, bitrate)?;
set_attr_size(&out_type, &MF_MT_FRAME_SIZE, self.width, self.height)?;
set_attr_ratio(&out_type, &MF_MT_FRAME_RATE, fps_num, fps_den)?;
set_attr_ratio(&out_type, &MF_MT_PIXEL_ASPECT_RATIO, 1, 1)?;
out_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?;
self.transform
.SetOutputType(self.output_stream_id, &out_type, 0)
.context("SetOutputType(H264)")?;
// ---- INPUT (NV12) ----
let in_type: IMFMediaType = MFCreateMediaType().context("MFCreateMediaType(in)")?;
in_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
in_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12)?;
set_attr_size(&in_type, &MF_MT_FRAME_SIZE, self.width, self.height)?;
set_attr_ratio(&in_type, &MF_MT_FRAME_RATE, fps_num, fps_den)?;
set_attr_ratio(&in_type, &MF_MT_PIXEL_ASPECT_RATIO, 1, 1)?;
in_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?;
self.transform
.SetInputType(self.input_stream_id, &in_type, 0)
.context("SetInputType(NV12)")?;
Ok(())
}
/// Begin streaming if not already started (idempotent).
unsafe fn ensure_streaming(&mut self) -> Result<()> {
if !self.streaming {
self.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0)
.context("NOTIFY_BEGIN_STREAMING")?;
self.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0)
.context("NOTIFY_START_OF_STREAM")?;
self.streaming = true;
}
Ok(())
}
/// Re-initialize the encoder for a new frame size (capture resolution change).
unsafe fn reinit_for_size(&mut self, width: u32, height: u32) -> Result<()> {
if self.streaming {
let _ = self.transform.ProcessMessage(MFT_MESSAGE_COMMAND_FLUSH, 0);
let _ = self
.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0);
let _ = self
.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0);
self.streaming = false;
}
self.width = width;
self.height = height;
self.force_keyframe = true;
self.configure_media_types()
}
/// Wrap an NV12 byte buffer into an `IMFSample` with the given timestamp.
/// A free associated fn (does not borrow `self`) so the caller can pass
/// `&self.nv12` without a clone while `self` is mutably borrowed elsewhere.
unsafe fn make_input_sample(nv12: &[u8], pts_100ns: i64) -> Result<IMFSample> {
let sample: IMFSample = MFCreateSample().context("MFCreateSample")?;
let buffer = MFCreateMemoryBuffer(nv12.len() as u32).context("MFCreateMemoryBuffer")?;
// Lock, copy NV12 in, set current length, unlock.
let mut data_ptr: *mut u8 = std::ptr::null_mut();
let mut max_len: u32 = 0;
buffer
.Lock(&mut data_ptr, Some(&mut max_len), None)
.context("IMFMediaBuffer::Lock")?;
if (max_len as usize) < nv12.len() || data_ptr.is_null() {
let _ = buffer.Unlock();
return Err(anyhow!("MF buffer too small for NV12 frame"));
}
std::ptr::copy_nonoverlapping(nv12.as_ptr(), data_ptr, nv12.len());
buffer.SetCurrentLength(nv12.len() as u32)?;
buffer.Unlock()?;
sample.AddBuffer(&buffer)?;
sample.SetSampleTime(pts_100ns)?;
// 33.367ms per frame at ~30fps, in 100ns units.
sample.SetSampleDuration(333_667)?;
Ok(sample)
}
/// Drain one available output sample, if any. Returns the encoded bytes and
/// whether the MFT flagged it a keyframe (clean point). `Ok(None)` means the
/// MFT needs more input before it can produce output this tick.
unsafe fn drain_one_output(&mut self) -> Result<Option<(Vec<u8>, bool)>> {
let stream_info: MFT_OUTPUT_STREAM_INFO = self
.transform
.GetOutputStreamInfo(self.output_stream_id)
.context("GetOutputStreamInfo")?;
// If the MFT does not allocate its own output samples we must provide one.
const MFT_OUTPUT_STREAM_PROVIDES_SAMPLES: u32 = 0x100;
let mft_provides = stream_info.dwFlags & MFT_OUTPUT_STREAM_PROVIDES_SAMPLES != 0;
let mut out_buffer = MFT_OUTPUT_DATA_BUFFER {
dwStreamID: self.output_stream_id,
..Default::default()
};
if !mft_provides {
let alloc_size = stream_info.cbSize.max(1);
let sample: IMFSample = MFCreateSample().context("MFCreateSample(out)")?;
let buffer = MFCreateMemoryBuffer(alloc_size).context("MFCreateMemoryBuffer(out)")?;
sample.AddBuffer(&buffer)?;
out_buffer.pSample = std::mem::ManuallyDrop::new(Some(sample));
}
let mut status: u32 = 0;
let mut bufs = [out_buffer];
let hr = self.transform.ProcessOutput(0, &mut bufs, &mut status);
// Take ownership of whatever sample is now in the buffer (ours or MFT's).
let produced = std::mem::ManuallyDrop::take(&mut bufs[0].pSample);
match hr {
Ok(()) => {
let Some(sample) = produced else {
return Ok(None);
};
let bytes = sample_to_vec(&sample)?;
let keyframe = sample_is_keyframe(&sample);
Ok(Some((bytes, keyframe)))
}
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => Ok(None),
Err(e) => Err(anyhow!("ProcessOutput failed: {e:#}")),
}
}
}
impl Encoder for H264Encoder {
fn encode(&mut self, frame: &CapturedFrame) -> Result<EncodedFrame> {
self.sequence = self.sequence.wrapping_add(1);
// H.264 4:2:0 needs even dimensions. Reject odd captures up front so we
// surface a clean error (the factory already fell back to raw if HW was
// missing; a per-frame error here lets the session log + continue).
if !frame.width.is_multiple_of(2) || !frame.height.is_multiple_of(2) {
return Err(anyhow!(
"H.264 requires even dimensions, got {}x{}",
frame.width,
frame.height
));
}
unsafe {
// Re-init on a resolution change.
if frame.width != self.width || frame.height != self.height {
self.reinit_for_size(frame.width, frame.height)
.context("H.264 re-init for new frame size")?;
}
self.ensure_streaming()?;
// BGRA -> NV12 into the reusable staging buffer.
let need = color::nv12_size(frame.width, frame.height);
if self.nv12.len() != need {
self.nv12.resize(need, 0);
}
color::bgra_to_nv12(&frame.data, frame.width, frame.height, &mut self.nv12)
.map_err(|e| anyhow!("BGRA->NV12 failed: {e}"))?;
// PTS in 100ns units derived from the frame's capture instant.
let pts_100ns = (frame.timestamp.elapsed().as_nanos() / 100) as i64;
let sample = Self::make_input_sample(&self.nv12, pts_100ns)?;
// Feed the encoder. NEED_MORE_INPUT is normal back-pressure handling;
// for the synchronous first cut we only push one frame per tick.
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!("ProcessInput failed: {e:#}")),
}
// Drain whatever output is ready.
let Some((data, mft_keyframe)) = self.drain_one_output()? else {
// No compressed output yet (encoder latency / GOP buffering).
// Emit an empty frame so the session skips sending this tick.
return Ok(EncodedFrame {
frame: VideoFrame::default(),
size: 0,
is_keyframe: false,
});
};
let is_keyframe = mft_keyframe || self.force_keyframe;
self.force_keyframe = false;
let size = data.len();
let encoded = ProtoEncodedFrame {
data,
keyframe: is_keyframe,
pts: pts_100ns,
dts: pts_100ns,
};
Ok(EncodedFrame {
frame: VideoFrame {
timestamp: frame.timestamp.elapsed().as_millis() as i64,
display_id: frame.display_id as i32,
sequence: self.sequence as i32,
encoding: Some(video_frame::Encoding::H264(encoded)),
},
size,
is_keyframe,
})
}
}
fn request_keyframe(&mut self) {
// A precise force-IDR uses the MFT codec API
// (CODECAPI_AVEncVideoForceKeyFrame); for the first cut we flag the next
// emitted frame as a keyframe so the viewer treats it as a clean point.
self.force_keyframe = true;
}
fn name(&self) -> &str {
"h264-mediafoundation"
}
}
impl Drop for H264Encoder {
fn drop(&mut self) {
unsafe {
if self.streaming {
let _ = self
.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0);
let _ = self
.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0);
}
// The IMFTransform releases when `self.transform` drops.
if self.mf_started {
let _ = MFShutdown();
}
}
}
}
/// MF version word expected by `MFStartup` (MF_VERSION = (MF_API_VERSION<<16)|MF_SDK_VERSION).
fn mf_version() -> u32 {
// MF_SDK_VERSION = 0x0002, MF_API_VERSION = 0x0070 -> 0x00020070.
0x0002_0070
}
/// Derive a target average bitrate (bps) from the 1-100 quality knob and the
/// frame area. Tuned conservatively for desktop content (mostly static).
fn quality_to_bitrate(quality: u32, width: u32, height: u32) -> u32 {
let q = quality.clamp(1, 100) as u64;
let pixels = (width as u64) * (height as u64);
// Base ~0.06 bits/pixel/frame at 30fps for q=100, scaled by quality.
// bps = pixels * 30 * bpp; bpp scales 0.01..0.10 with quality.
let bpp_milli = 10 + (q * 90 / 100); // 0.010 .. 0.100 in milli-bits
let bps = pixels.saturating_mul(30).saturating_mul(bpp_milli) / 1000;
bps.clamp(500_000, 50_000_000) as u32
}
/// Pack (width, height) into the 64-bit MF_MT_FRAME_SIZE attribute.
#[cfg(windows)]
unsafe fn set_attr_size(
media_type: &IMFMediaType,
key: &windows::core::GUID,
width: u32,
height: u32,
) -> Result<()> {
let packed = ((width as u64) << 32) | (height as u64);
media_type.SetUINT64(key, packed)?;
Ok(())
}
/// Pack (numerator, denominator) into a 64-bit ratio MF attribute.
#[cfg(windows)]
unsafe fn set_attr_ratio(
media_type: &IMFMediaType,
key: &windows::core::GUID,
num: u32,
den: u32,
) -> Result<()> {
let packed = ((num as u64) << 32) | (den as u64);
media_type.SetUINT64(key, packed)?;
Ok(())
}
/// Copy all bytes out of an `IMFSample` (single contiguous buffer) into a Vec.
#[cfg(windows)]
unsafe fn sample_to_vec(sample: &IMFSample) -> Result<Vec<u8>> {
let buffer = sample
.ConvertToContiguousBuffer()
.context("ConvertToContiguousBuffer")?;
let mut ptr: *mut u8 = std::ptr::null_mut();
let mut len: u32 = 0;
buffer
.Lock(&mut ptr, None, Some(&mut len))
.context("output buffer Lock")?;
let out = if ptr.is_null() || len == 0 {
Vec::new()
} else {
std::slice::from_raw_parts(ptr, len as usize).to_vec()
};
let _ = buffer.Unlock();
Ok(out)
}
/// Read the "clean point" (keyframe) flag off a sample, if present.
#[cfg(windows)]
unsafe fn sample_is_keyframe(sample: &IMFSample) -> bool {
use windows::Win32::Media::MediaFoundation::MFSampleExtension_CleanPoint;
sample
.GetUINT32(&MFSampleExtension_CleanPoint)
.map(|v| v != 0)
.unwrap_or(false)
}

View File

@@ -1,16 +1,27 @@
//! Frame encoding module
//!
//! Encodes captured frames for transmission. Supports:
//! - Raw BGRA + Zstd compression (lowest latency, LAN mode)
//! - VP9 software encoding (universal fallback)
//! - H264 hardware encoding (when GPU available)
//! - Raw BGRA + Zstd compression (lowest latency, LAN mode; the guaranteed
//! fallback and the current default).
//! - H.264 hardware encoding via Windows Media Foundation (Task 7) — the
//! negotiated upgrade. Compile-verified; validated on real hardware in plan
//! Task 8. On any init/feed failure the factory or encoder falls back to raw.
//!
//! Codec selection is driven by the negotiated `VideoCodec` the server sends on
//! `StartStream` (see `select_codec` / `create_encoder_for`). The capability the
//! agent advertises to the server is detected by `capability::supports_hardware_h264`.
mod capability;
pub(crate) mod color;
#[cfg(windows)]
mod h264;
mod raw;
pub use capability::supports_hardware_h264;
pub use raw::RawEncoder;
use crate::capture::CapturedFrame;
use crate::proto::{DirtyRect as ProtoDirtyRect, RawFrame, VideoFrame};
use crate::proto::{video_frame, VideoCodec, VideoFrame};
use anyhow::Result;
/// Encoded frame ready for transmission
@@ -23,30 +34,191 @@ pub struct EncodedFrame {
pub size: usize,
/// Whether this is a keyframe (full frame)
// Set by encoders; not yet read by the transmit path.
#[allow(dead_code)]
pub is_keyframe: bool,
}
/// Frame encoder trait
/// Frame encoder trait.
///
/// Every implementor turns a `CapturedFrame` (BGRA) into a wire `VideoFrame`
/// using one `video_frame::Encoding` variant. `RawEncoder` emits the `Raw`
/// variant; the H.264 encoder emits the `H264` variant. The factory
/// (`create_encoder_for`) selects the implementor from the negotiated codec.
pub trait Encoder: Send {
/// Encode a captured frame
fn encode(&mut self, frame: &CapturedFrame) -> Result<EncodedFrame>;
/// Request a keyframe on next encode
#[allow(dead_code)]
fn request_keyframe(&mut self);
/// Get encoder name/type
#[allow(dead_code)]
fn name(&self) -> &str;
}
/// Create an encoder based on configuration
pub fn create_encoder(codec: &str, quality: u32) -> Result<Box<dyn Encoder>> {
/// Map a configured/negotiated codec string to a `VideoCodec`.
///
/// Used when constructing an encoder from the agent's own `EncodingConfig`
/// (before any server negotiation). Unknown / "auto" / "raw" all resolve to raw
/// — the safe default. "h264" resolves to H.264 (which itself falls back to raw
/// if MF init fails).
///
/// Retained as the config-string entry point (used by `create_encoder` and the
/// unit tests); the live session negotiates via `select_codec` on a `VideoCodec`.
#[allow(dead_code)]
pub fn codec_from_str(codec: &str) -> VideoCodec {
match codec.to_lowercase().as_str() {
"raw" | "zstd" => Ok(Box::new(RawEncoder::new(quality)?)),
// "vp9" => Ok(Box::new(Vp9Encoder::new(quality)?)),
// "h264" => Ok(Box::new(H264Encoder::new(quality)?)),
"auto" | _ => {
// Default to raw for now (best for LAN)
Ok(Box::new(RawEncoder::new(quality)?))
}
"h264" => VideoCodec::H264,
// "h265"/"hevc" are future opt-in (TODO) — treat as raw for now so we
// never select an unimplemented codec.
_ => VideoCodec::Raw,
}
}
/// Choose the codec the agent will actually use for a stream, given the codec
/// the server negotiated and the agent's own hardware capability.
///
/// This is the agent-side guard that keeps the raw fallback authoritative:
/// - The server only negotiates H.264 when the agent advertised support, but we
/// re-check `supports_hardware_h264()` here so a stale/misconfigured server
/// selection can never force an unsupported codec.
/// - H.265 is not implemented; it degrades to raw.
/// - Anything else is raw.
pub fn select_codec(negotiated: VideoCodec, hardware_h264_available: bool) -> VideoCodec {
match negotiated {
VideoCodec::H264 if hardware_h264_available => VideoCodec::H264,
// Server asked for H.264 but we have no HW encoder -> raw.
VideoCodec::H264 => VideoCodec::Raw,
// HEVC not implemented yet (TODO: Task 7 opt-in / future).
VideoCodec::H265 => VideoCodec::Raw,
VideoCodec::Raw => VideoCodec::Raw,
}
}
/// Create an encoder for an explicit `VideoCodec`, with a transparent fallback
/// to raw if a hardware encoder cannot be constructed.
///
/// `quality` is the 1-100 quality knob (mapped per-codec). On H.264 init failure
/// this logs and returns a raw encoder so the session keeps working.
pub fn create_encoder_for(codec: VideoCodec, quality: u32) -> Result<Box<dyn Encoder>> {
match codec {
VideoCodec::H264 => {
#[cfg(windows)]
{
match h264::H264Encoder::new(quality) {
Ok(enc) => {
tracing::info!("Using hardware H.264 encoder (Media Foundation)");
Ok(Box::new(enc))
}
Err(e) => {
tracing::warn!(
"H.264 encoder init failed ({e:#}); falling back to raw+Zstd"
);
Ok(Box::new(RawEncoder::new(quality)?))
}
}
}
#[cfg(not(windows))]
{
tracing::warn!("H.264 unsupported on this platform; using raw+Zstd");
Ok(Box::new(RawEncoder::new(quality)?))
}
}
// Raw (and anything that resolved to raw) uses the salvaged encoder.
VideoCodec::Raw | VideoCodec::H265 => Ok(Box::new(RawEncoder::new(quality)?)),
}
}
/// Create an encoder based on a codec string (agent config path).
///
/// Backwards-compatible entry point that builds an encoder from a codec STRING
/// (e.g. `EncodingConfig.codec`). Resolves the string to a `VideoCodec`, applies
/// the hardware-availability guard, then builds the encoder. The live session
/// uses `select_codec` + `create_encoder_for` (negotiated `VideoCodec`) instead;
/// this remains for the config path and is covered by unit tests.
#[allow(dead_code)]
pub fn create_encoder(codec: &str, quality: u32) -> Result<Box<dyn Encoder>> {
let requested = codec_from_str(codec);
let chosen = select_codec(requested, supports_hardware_h264());
create_encoder_for(chosen, quality)
}
/// Build an `EncodedFrame` carrying a single `video_frame::Encoding` payload.
/// Shared helper so encoders don't each repeat the `VideoFrame` wrapper.
#[allow(dead_code)]
pub(crate) fn wrap_video_frame(
timestamp_ms: i64,
display_id: i32,
sequence: i32,
encoding: video_frame::Encoding,
size: usize,
is_keyframe: bool,
) -> EncodedFrame {
EncodedFrame {
frame: VideoFrame {
timestamp: timestamp_ms,
display_id,
sequence,
encoding: Some(encoding),
},
size,
is_keyframe,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codec_from_str_maps_known_and_unknown() {
assert_eq!(codec_from_str("h264"), VideoCodec::H264);
assert_eq!(codec_from_str("H264"), VideoCodec::H264);
assert_eq!(codec_from_str("raw"), VideoCodec::Raw);
assert_eq!(codec_from_str("zstd"), VideoCodec::Raw);
assert_eq!(codec_from_str("auto"), VideoCodec::Raw);
assert_eq!(codec_from_str("vp9"), VideoCodec::Raw);
// HEVC not implemented -> raw, never H265.
assert_eq!(codec_from_str("h265"), VideoCodec::Raw);
assert_eq!(codec_from_str("hevc"), VideoCodec::Raw);
assert_eq!(codec_from_str(""), VideoCodec::Raw);
}
#[test]
fn select_codec_honors_hardware_guard() {
// Server negotiated H.264 and HW is present -> H.264.
assert_eq!(select_codec(VideoCodec::H264, true), VideoCodec::H264);
// Server negotiated H.264 but no HW -> raw (never forced).
assert_eq!(select_codec(VideoCodec::H264, false), VideoCodec::Raw);
// Raw stays raw regardless of HW.
assert_eq!(select_codec(VideoCodec::Raw, true), VideoCodec::Raw);
assert_eq!(select_codec(VideoCodec::Raw, false), VideoCodec::Raw);
// HEVC always degrades to raw (unimplemented).
assert_eq!(select_codec(VideoCodec::H265, true), VideoCodec::Raw);
}
#[test]
fn raw_factory_always_succeeds() {
// Raw must always construct (the guaranteed fallback).
let enc = create_encoder_for(VideoCodec::Raw, 75).unwrap();
assert_eq!(enc.name(), "raw+zstd");
}
#[test]
fn create_encoder_string_path_resolves_to_raw_without_hw() {
// On a machine without a HW encoder (CI / non-Windows), "h264" must
// resolve to a working raw encoder, not an error.
let enc = create_encoder("h264", 75).unwrap();
// Without HW it is raw; with HW it would be the H.264 encoder. We only
// assert it constructed.
let _ = enc.name();
}
#[test]
fn create_encoder_auto_is_raw() {
let enc = create_encoder("auto", 75).unwrap();
assert_eq!(enc.name(), "raw+zstd");
}
}

View File

@@ -77,8 +77,8 @@ impl RawEncoder {
let mut dirty_rects = Vec::new();
let stride = (width * 4) as usize;
let blocks_x = (width + BLOCK_SIZE - 1) / BLOCK_SIZE;
let blocks_y = (height + BLOCK_SIZE - 1) / BLOCK_SIZE;
let blocks_x = width.div_ceil(BLOCK_SIZE);
let blocks_y = height.div_ceil(BLOCK_SIZE);
for by in 0..blocks_y {
for bx in 0..blocks_x {

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

@@ -1,4 +1,11 @@
//! Keyboard input simulation using Windows SendInput API
//!
//! Injection is **scan-code based** (`KEYEVENTF_SCANCODE`) rather than virtual-key
//! based. Scan codes are layout-independent: the same physical key produces the same
//! scan code regardless of the remote keyboard layout, so the remote machine's active
//! layout (not the technician's) decides what character a key produces. The viewer
//! still carries the virtual-key code for logic that needs it, and we fall back to
//! deriving a scan code from the VK when the wire frame did not supply one.
use anyhow::Result;
@@ -11,11 +18,13 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
/// Keyboard input controller
pub struct KeyboardController {
// Track modifier states for proper handling
#[allow(dead_code)]
/// Tracks which modifier keys this controller currently holds DOWN on the remote.
/// Used so a focus-loss / session-end re-sync can release any still-held modifier
/// and avoid "stuck" Ctrl/Alt/Shift/Win on the remote desktop.
modifiers: ModifierState,
}
/// Tracks the down/up state of each modifier the agent has injected.
#[derive(Default)]
struct ModifierState {
ctrl: bool,
@@ -24,6 +33,55 @@ struct ModifierState {
meta: bool,
}
impl ModifierState {
/// Record a modifier transition for `vk_code`. Returns `true` if `vk_code` is a
/// modifier key (and the state was updated), `false` otherwise.
fn record(&mut self, vk_code: u16, down: bool) -> bool {
match vk_code {
// VK_CONTROL / VK_LCONTROL / VK_RCONTROL
0x11 | 0xA2 | 0xA3 => {
self.ctrl = down;
true
}
// VK_MENU / VK_LMENU / VK_RMENU (Alt)
0x12 | 0xA4 | 0xA5 => {
self.alt = down;
true
}
// VK_SHIFT / VK_LSHIFT / VK_RSHIFT
0x10 | 0xA0 | 0xA1 => {
self.shift = down;
true
}
// VK_LWIN / VK_RWIN
0x5B | 0x5C => {
self.meta = down;
true
}
_ => false,
}
}
/// Return the VK codes of every modifier currently held down, then clear the state.
fn drain_held(&mut self) -> Vec<u16> {
let mut held = Vec::new();
if self.ctrl {
held.push(0x11);
}
if self.alt {
held.push(0x12);
}
if self.shift {
held.push(0x10);
}
if self.meta {
held.push(0x5B);
}
*self = ModifierState::default();
held
}
}
impl KeyboardController {
/// Create a new keyboard controller
pub fn new() -> Result<Self> {
@@ -32,28 +90,75 @@ impl KeyboardController {
})
}
/// Press a key down by virtual key code
/// Press a key down by virtual key code (scan code derived from the VK).
#[cfg(windows)]
pub fn key_down(&mut self, vk_code: u16) -> Result<()> {
self.send_key(vk_code, true)
self.send_key(vk_code, 0, false, true)
}
/// Release a key by virtual key code
/// Release a key by virtual key code (scan code derived from the VK).
#[cfg(windows)]
pub fn key_up(&mut self, vk_code: u16) -> Result<()> {
self.send_key(vk_code, false)
self.send_key(vk_code, 0, false, false)
}
/// Send a key event
/// Inject a full-fidelity key event.
///
/// `scan_code` is the hardware scan code captured by the viewer's low-level hook
/// (0 ⇒ derive it from `vk_code`). `is_extended` is the viewer-captured extended-key
/// flag (`LLKHF_EXTENDED`); when `false` the agent still derives the flag from the
/// VK / scan code so older viewers that don't set it stay correct.
#[cfg(windows)]
fn send_key(&mut self, vk_code: u16, down: bool) -> Result<()> {
// Get scan code from virtual key
let scan_code = unsafe { MapVirtualKeyW(vk_code as u32, MAPVK_VK_TO_VSC_EX) as u16 };
pub fn key_event_full(
&mut self,
vk_code: u16,
scan_code: u16,
is_extended: bool,
down: bool,
) -> Result<()> {
self.send_key(vk_code, scan_code, is_extended, down)
}
let mut flags = KEYBD_EVENT_FLAGS::default();
/// Release every modifier this controller currently holds down on the remote.
///
/// Called on viewer focus loss and at session end so a Ctrl/Alt/Shift/Win that was
/// pressed but whose key-up never arrived (e.g. the technician alt-tabbed away) does
/// not stay latched on the remote desktop.
#[cfg(windows)]
pub fn release_all_modifiers(&mut self) -> Result<()> {
for vk in self.modifiers.drain_held() {
// Emit the key-up directly; drain_held already cleared the tracked state.
if let Err(e) = self.send_key(vk, 0, false, false) {
tracing::warn!("Failed to release held modifier vk={:#x}: {}", vk, e);
} else {
tracing::debug!("Released stuck modifier vk={:#x} on focus loss", vk);
}
}
Ok(())
}
// Add extended key flag for certain keys
if Self::is_extended_key(vk_code) || (scan_code >> 8) == 0xE0 {
/// Send a key event using scan-code injection.
#[cfg(windows)]
fn send_key(
&mut self,
vk_code: u16,
scan_code: u16,
is_extended: bool,
down: bool,
) -> Result<()> {
// Track modifier state so we can release stuck modifiers later.
self.modifiers.record(vk_code, down);
// Prefer the viewer-supplied scan code; fall back to deriving one from the VK.
// MAPVK_VK_TO_VSC_EX yields a 0xE0-prefixed value for extended keys.
let mapped = unsafe { MapVirtualKeyW(vk_code as u32, MAPVK_VK_TO_VSC_EX) as u16 };
let effective_scan = if scan_code != 0 { scan_code } else { mapped };
let mut flags = KEYBD_EVENT_FLAGS::default() | KEYEVENTF_SCANCODE;
// Add the extended flag if the viewer flagged it, the VK is inherently
// extended, or the mapped scan code carries the 0xE0 extended prefix.
if is_extended || Self::is_extended_key(vk_code) || (mapped >> 8) == 0xE0 {
flags |= KEYEVENTF_EXTENDEDKEY;
}
@@ -61,12 +166,16 @@ impl KeyboardController {
flags |= KEYEVENTF_KEYUP;
}
// For scan-code injection the low byte of the scan code is what Windows uses;
// the 0xE0 prefix is conveyed via KEYEVENTF_EXTENDEDKEY, not the wScan value.
let w_scan = (effective_scan & 0x00FF) as u16;
let input = INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(vk_code),
wScan: scan_code,
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0),
wScan: w_scan,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
@@ -78,6 +187,7 @@ impl KeyboardController {
}
/// Type a unicode character
#[allow(dead_code)]
#[cfg(windows)]
pub fn type_char(&mut self, ch: char) -> Result<()> {
let mut inputs = Vec::new();
@@ -119,6 +229,7 @@ impl KeyboardController {
}
/// Type a string of text
#[allow(dead_code)]
#[cfg(windows)]
pub fn type_string(&mut self, text: &str) -> Result<()> {
for ch in text.chars() {
@@ -129,21 +240,35 @@ impl KeyboardController {
/// Send Secure Attention Sequence (Ctrl+Alt+Delete)
///
/// This uses a multi-tier approach:
/// 1. Try the GuruConnect SAS Service (runs as SYSTEM, handles via named pipe)
/// 2. Try the sas.dll directly (requires SYSTEM privileges)
/// 3. Fallback to key simulation (won't work on secure desktop)
/// Ctrl+Alt+Del is the Secure Attention Sequence and **cannot** be injected via
/// `SendInput` — Windows reserves it. It must be raised by `SendSAS`, which only
/// works when the caller runs as SYSTEM (or has SeTcbPrivilege) AND the
/// `SoftwareSASGeneration` Winlogon policy permits software-generated SAS. The
/// managed installer is responsible for installing the SAS helper service (running
/// as SYSTEM) and setting that policy. See `set_software_sas_policy` in
/// `bin/sas_service.rs` and the `// TODO(installer)` note there.
///
/// Tiers, in order:
/// 1. The GuruConnect SAS helper service (SYSTEM) via named-pipe IPC — the supported path.
/// 2. Direct `sas.dll!SendSAS` — only succeeds if THIS process is already SYSTEM with the policy.
/// 3. Fallback key simulation — will NOT reach the secure desktop; logged as a clear failure.
#[cfg(windows)]
pub fn send_sas(&mut self) -> Result<()> {
// Tier 1: Try the SAS service (named pipe IPC to SYSTEM service)
if let Ok(()) = crate::sas_client::request_sas() {
tracing::info!("SAS sent via GuruConnect SAS Service");
return Ok(());
match crate::sas_client::request_sas() {
Ok(()) => {
tracing::info!("SAS sent via GuruConnect SAS Service");
return Ok(());
}
Err(e) => {
tracing::warn!(
"SAS helper service unavailable ({}); trying direct sas.dll",
e
);
}
}
tracing::info!("SAS service not available, trying direct sas.dll...");
// Tier 2: Try using the sas.dll directly (requires SYSTEM privileges)
// Tier 2: Try using the sas.dll directly (requires SYSTEM + SoftwareSASGeneration)
use windows::core::PCWSTR;
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
@@ -154,49 +279,33 @@ impl KeyboardController {
if let Ok(lib) = lib {
let proc_name = b"SendSAS\0";
if let Some(proc) = GetProcAddress(lib, windows::core::PCSTR(proc_name.as_ptr())) {
// SendSAS takes a BOOL parameter: FALSE for Ctrl+Alt+Del
// SendSAS takes a BOOL parameter: FALSE for Ctrl+Alt+Del.
// It silently no-ops if the caller lacks privilege / the policy is
// unset, so we cannot detect success here — but it is the best
// effort short of the SYSTEM helper.
let send_sas: extern "system" fn(i32) = std::mem::transmute(proc);
send_sas(0); // FALSE = Ctrl+Alt+Del
tracing::info!("SAS sent via direct sas.dll call");
tracing::info!("SAS attempted via direct sas.dll call (effective only if SYSTEM + SoftwareSASGeneration policy set)");
return Ok(());
}
}
}
// Tier 3: Fallback - try sending the keys (won't work on secure desktop)
tracing::warn!("SAS service and sas.dll not available, Ctrl+Alt+Del may not work");
// VK codes
const VK_CONTROL: u16 = 0x11;
const VK_MENU: u16 = 0x12; // Alt
const VK_DELETE: u16 = 0x2E;
// Press keys
self.key_down(VK_CONTROL)?;
self.key_down(VK_MENU)?;
self.key_down(VK_DELETE)?;
// Release keys
self.key_up(VK_DELETE)?;
self.key_up(VK_MENU)?;
self.key_up(VK_CONTROL)?;
Ok(())
// Tier 3: SAS could not be delivered through any privileged path. A plain
// SendInput of Ctrl+Alt+Del never reaches the secure desktop, so report a
// clear, actionable error instead of pretending it worked.
let msg = "Ctrl+Alt+Del could not be delivered: the GuruConnect SAS helper \
service is not running and sas.dll!SendSAS is unavailable. Ensure the \
SAS service is installed (runs as SYSTEM) and the SoftwareSASGeneration \
policy is enabled by the installer.";
tracing::error!("{}", msg);
anyhow::bail!("{}", msg)
}
/// Check if a virtual key code is an extended key
#[cfg(windows)]
fn is_extended_key(vk: u16) -> bool {
matches!(
vk,
0x21..=0x28 | // Page Up, Page Down, End, Home, Arrow keys
0x2D | 0x2E | // Insert, Delete
0x5B | 0x5C | // Left/Right Windows keys
0x5D | // Applications key
0x6F | // Numpad Divide
0x90 | // Num Lock
0x91 // Scroll Lock
)
vk_is_extended(vk)
}
/// Send input events
@@ -221,6 +330,22 @@ impl KeyboardController {
anyhow::bail!("Keyboard input only supported on Windows")
}
#[cfg(not(windows))]
pub fn key_event_full(
&mut self,
_vk_code: u16,
_scan_code: u16,
_is_extended: bool,
_down: bool,
) -> Result<()> {
anyhow::bail!("Keyboard input only supported on Windows")
}
#[cfg(not(windows))]
pub fn release_all_modifiers(&mut self) -> Result<()> {
anyhow::bail!("Keyboard input only supported on Windows")
}
#[cfg(not(windows))]
pub fn type_char(&mut self, _ch: char) -> Result<()> {
anyhow::bail!("Keyboard input only supported on Windows")
@@ -290,3 +415,121 @@ pub mod vk {
pub const LMENU: u16 = 0xA4; // Left Alt
pub const RMENU: u16 = 0xA5; // Right Alt
}
/// Whether a Windows virtual-key code is an "extended" key.
///
/// Extended keys must be injected with `KEYEVENTF_EXTENDEDKEY`. This is the
/// platform-independent classifier so the determination can be unit-tested off-Windows;
/// the `#[cfg(windows)]` injection path delegates here. The viewer-captured
/// `LLKHF_EXTENDED` flag is authoritative when present; this is the fallback used when
/// the wire frame did not carry it (older viewers / VK-only synthesis).
pub fn vk_is_extended(vk: u16) -> bool {
matches!(
vk,
0x21..=0x28 | // Page Up, Page Down, End, Home, Arrow keys
0x2D | 0x2E | // Insert, Delete
0x5B | 0x5C | // Left/Right Windows keys
0x5D | // Applications key
0x6F | // Numpad Divide
0x90 | // Num Lock
0x91 | // Scroll Lock
0xA3 | // Right Control
0xA5 // Right Alt (AltGr)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extended_keys_are_flagged() {
// Arrows / navigation block.
for vk in [0x21u16, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28] {
assert!(vk_is_extended(vk), "vk={:#x} should be extended", vk);
}
// Insert / Delete.
assert!(vk_is_extended(0x2D));
assert!(vk_is_extended(0x2E));
// Win keys, Apps, NumLock, numpad Divide.
assert!(vk_is_extended(0x5B));
assert!(vk_is_extended(0x5C));
assert!(vk_is_extended(0x5D));
assert!(vk_is_extended(0x6F));
assert!(vk_is_extended(0x90));
// Right Ctrl / Right Alt.
assert!(vk_is_extended(0xA3));
assert!(vk_is_extended(0xA5));
}
#[test]
fn non_extended_keys_are_not_flagged() {
// Letters, digits, space, enter, left modifiers, numpad digits.
for vk in [
0x41u16, // A
0x5A, // Z
0x30, // 0
0x20, // Space
0x0D, // Enter
0xA0, // Left Shift
0xA2, // Left Control
0xA4, // Left Alt
0x60, // Numpad 0
0x6A, // Numpad Multiply (NOT extended; only Divide is)
] {
assert!(!vk_is_extended(vk), "vk={:#x} should NOT be extended", vk);
}
}
#[test]
fn modifier_state_records_ctrl_alt_shift_win() {
let mut m = ModifierState::default();
// Each of the VK aliases maps to its modifier flag.
assert!(m.record(0x11, true)); // VK_CONTROL
assert!(m.ctrl);
assert!(m.record(0xA4, true)); // VK_LMENU (Alt)
assert!(m.alt);
assert!(m.record(0xA0, true)); // VK_LSHIFT
assert!(m.shift);
assert!(m.record(0x5C, true)); // VK_RWIN
assert!(m.meta);
}
#[test]
fn modifier_state_ignores_non_modifiers() {
let mut m = ModifierState::default();
assert!(!m.record(0x41, true)); // 'A' is not a modifier
assert!(!m.ctrl && !m.alt && !m.shift && !m.meta);
}
#[test]
fn modifier_state_tracks_down_then_up() {
let mut m = ModifierState::default();
m.record(0x11, true); // Ctrl down
assert!(m.ctrl);
m.record(0x11, false); // Ctrl up
assert!(!m.ctrl);
}
#[test]
fn drain_held_returns_and_clears_held_modifiers() {
let mut m = ModifierState::default();
m.record(0xA2, true); // Left Ctrl -> ctrl
m.record(0x12, true); // Alt
// Shift and Win were never pressed.
let mut held = m.drain_held();
held.sort_unstable();
// Canonical VKs returned: Ctrl(0x11), Alt(0x12).
assert_eq!(held, vec![0x11u16, 0x12]);
// State is cleared after draining.
assert!(!m.ctrl && !m.alt && !m.shift && !m.meta);
// A second drain yields nothing.
assert!(m.drain_held().is_empty());
}
#[test]
fn drain_held_empty_when_nothing_pressed() {
let mut m = ModifierState::default();
assert!(m.drain_held().is_empty());
}
}

View File

@@ -5,6 +5,7 @@
mod keyboard;
mod mouse;
pub use keyboard::vk_is_extended;
pub use keyboard::KeyboardController;
pub use mouse::MouseController;
@@ -26,11 +27,13 @@ impl InputController {
}
/// Get mouse controller
#[allow(dead_code)]
pub fn mouse(&mut self) -> &mut MouseController {
&mut self.mouse
}
/// Get keyboard controller
#[allow(dead_code)]
pub fn keyboard(&mut self) -> &mut KeyboardController {
&mut self.keyboard
}
@@ -54,7 +57,8 @@ impl InputController {
self.mouse.scroll(delta_x, delta_y)
}
/// Press or release a key
/// Press or release a key by virtual-key code only (scan code derived from the VK).
#[allow(dead_code)]
pub fn key_event(&mut self, vk_code: u16, down: bool) -> Result<()> {
if down {
self.keyboard.key_down(vk_code)
@@ -63,7 +67,32 @@ impl InputController {
}
}
/// Inject a full-fidelity key event (VK + hardware scan code + extended-key flag).
///
/// This is the path used for relayed viewer keystrokes so that scan-code injection
/// (layout-independent) and the correct `KEYEVENTF_EXTENDEDKEY` flag are applied.
pub fn key_event_full(
&mut self,
vk_code: u16,
scan_code: u16,
is_extended: bool,
down: bool,
) -> Result<()> {
self.keyboard
.key_event_full(vk_code, scan_code, is_extended, down)
}
/// Release any modifier keys currently held down on the remote.
///
/// Invoked when the viewer loses focus or the session ends so a Ctrl/Alt/Shift/Win
/// whose key-up never arrived does not stay latched on the remote desktop.
#[allow(dead_code)]
pub fn release_all_modifiers(&mut self) -> Result<()> {
self.keyboard.release_all_modifiers()
}
/// Type a unicode character
#[allow(dead_code)]
pub fn type_unicode(&mut self, ch: char) -> Result<()> {
self.keyboard.type_char(ch)
}
@@ -80,7 +109,10 @@ pub enum MouseButton {
Left,
Right,
Middle,
// Extra mouse buttons; not yet produced by the viewer input mapping.
#[allow(dead_code)]
X1,
#[allow(dead_code)]
X2,
}

View File

@@ -6,7 +6,7 @@
//! - UAC elevation with graceful fallback
use anyhow::{anyhow, Result};
use tracing::{error, info, warn};
use tracing::{info, warn};
#[cfg(windows)]
use windows::{
@@ -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

@@ -15,10 +15,17 @@
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;
@@ -177,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<()> {
@@ -221,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) => {
@@ -231,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
@@ -259,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
@@ -321,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)
@@ -374,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);
}
@@ -452,7 +759,7 @@ fn show_error_box(_title: &str, message: &str) {
fn show_debug_console() {
unsafe {
let hwnd = GetConsoleWindow();
if hwnd.0 == std::ptr::null_mut() {
if hwnd.0.is_null() {
let _ = AllocConsole();
} else {
let _ = ShowWindow(hwnd, SW_SHOW);
@@ -472,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
}
}
};
@@ -505,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 {
@@ -526,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();
@@ -603,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
);
}
}

View File

@@ -5,13 +5,11 @@
use std::fs::OpenOptions;
use std::io::{Read, Write};
use std::time::Duration;
use anyhow::{Context, Result};
use tracing::{debug, error, info, warn};
const PIPE_NAME: &str = r"\\.\pipe\guruconnect-sas";
const TIMEOUT_MS: u64 = 5000;
/// Request Ctrl+Alt+Del (Secure Attention Sequence) via the SAS service
pub fn request_sas() -> Result<()> {
@@ -65,6 +63,8 @@ pub fn request_sas() -> Result<()> {
}
/// Check if the SAS service is available
// Used by the test module and the (not-yet-wired) SAS status API.
#[allow(dead_code)]
pub fn is_service_available() -> bool {
// Try to open the pipe
if let Ok(mut pipe) = OpenOptions::new().read(true).write(true).open(PIPE_NAME) {
@@ -81,6 +81,7 @@ pub fn is_service_available() -> bool {
}
/// Get information about SAS service status
#[allow(dead_code)]
pub fn get_service_status() -> String {
if is_service_available() {
"SAS service is running and responding".to_string()

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

@@ -11,7 +11,7 @@ use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
#[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
use crate::capture::{self, Capturer, Display};
use crate::capture::{self, Capturer};
use crate::chat::{ChatController, ChatMessage as ChatMsg};
use crate::config::Config;
use crate::encoder::{self, Encoder};
@@ -22,7 +22,7 @@ use crate::input::InputController;
fn show_debug_console() {
unsafe {
let hwnd = GetConsoleWindow();
if hwnd.0 == std::ptr::null_mut() {
if hwnd.0.is_null() {
let _ = AllocConsole();
tracing::info!("Debug console window opened");
} else {
@@ -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)
@@ -61,6 +71,10 @@ pub struct SessionManager {
input: Option<InputController>,
// Streaming state
current_viewer_id: Option<String>,
// Codec negotiated by the server for the current stream (Task 7). Set from
// StartStream.video_codec; the encoder is built from it (guarded by the
// agent's own hardware capability, with raw as the safe fallback).
negotiated_codec: crate::proto::VideoCodec,
// System info for status reports
hostname: String,
is_elevated: bool,
@@ -87,6 +101,8 @@ impl SessionManager {
encoder: None,
input: None,
current_viewer_id: None,
// Default to RAW until the server negotiates otherwise (StartStream).
negotiated_codec: crate::proto::VideoCodec::Raw,
hostname,
is_elevated,
start_time: Instant::now(),
@@ -97,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?;
@@ -130,7 +150,7 @@ impl SessionManager {
// Get primary display with panic protection
tracing::debug!("Enumerating displays...");
let primary_display = match std::panic::catch_unwind(|| capture::primary_display()) {
let primary_display = match std::panic::catch_unwind(capture::primary_display) {
Ok(result) => result?,
Err(e) => {
tracing::error!("Panic during display enumeration: {:?}", e);
@@ -168,14 +188,20 @@ impl SessionManager {
self.capturer = Some(capturer);
tracing::info!("Capturer created successfully");
// Create encoder with panic protection
// Create encoder from the NEGOTIATED codec (Task 7), guarded by the
// agent's own hardware capability. `create_encoder_for` selects the H.264
// encoder only if it can actually be constructed, otherwise it returns a
// working raw encoder — so this never breaks the session.
let chosen =
encoder::select_codec(self.negotiated_codec, encoder::supports_hardware_h264());
tracing::debug!(
"Creating encoder (codec={}, quality={})...",
self.config.encoding.codec,
"Creating encoder (negotiated={:?}, chosen={:?}, quality={})...",
self.negotiated_codec,
chosen,
self.config.encoding.quality
);
let encoder = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
encoder::create_encoder(&self.config.encoding.codec, self.config.encoding.quality)
encoder::create_encoder_for(chosen, self.config.encoding.quality)
})) {
Ok(result) => result?,
Err(e) => {
@@ -232,6 +258,13 @@ impl SessionManager {
organization: self.config.company.clone().unwrap_or_default(),
site: self.config.site.clone().unwrap_or_default(),
tags: self.config.tags.clone(),
// 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 {
@@ -262,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?;
@@ -284,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() {
@@ -336,6 +410,15 @@ impl SessionManager {
match payload {
message::Payload::StartStream(start) => {
tracing::info!("StartStream received from viewer: {}", start.viewer_id);
// Apply the server-negotiated codec (Task 7) BEFORE
// building the encoder. An older server that omits the
// field sends 0 = VIDEO_CODEC_RAW, preserving the raw
// default. `select_codec` (in init_streaming) re-guards
// against missing hardware.
self.negotiated_codec =
crate::proto::VideoCodec::try_from(start.video_codec)
.unwrap_or(crate::proto::VideoCodec::Raw);
tracing::info!("Server negotiated codec: {:?}", self.negotiated_codec);
if let Err(e) = self.init_streaming() {
tracing::error!("Failed to init streaming: {}", e);
} else {
@@ -369,6 +452,17 @@ impl SessionManager {
}
continue;
}
message::Payload::ConsentRequest(req) => {
// ATTENDED-MODE CONSENT (Task 5). The server is holding
// this session in `consent_state = pending` and will not
// surface it to the technician until we reply. Show the
// end user a native dialog and return their decision; the
// dialog blocks, so run it off the async runtime. If the
// user closes it / no choice is made, `prompt_consent`
// returns false (deny).
self.handle_consent_request(req.clone()).await;
continue;
}
_ => {}
}
}
@@ -498,6 +592,69 @@ impl SessionManager {
Ok(())
}
/// Handle an attended-mode `ConsentRequest` from the server (Task 5).
///
/// Shows the end user a native consent dialog (off the async runtime, since
/// it blocks) and sends a `ConsentResponse` carrying their decision. A
/// closed dialog / unavailable surface is treated as a DENY. The server
/// gates the whole session on this reply, so we always send a response (even
/// on send failure the server's consent timeout will deny).
async fn handle_consent_request(&mut self, req: crate::proto::ConsentRequest) {
use crate::consent::{prompt_consent, ConsentAccessMode};
use crate::proto::ConsentResponse;
let session_id = req.session_id.clone();
let technician_name = req.technician_name.clone();
let access = ConsentAccessMode::from_proto(req.access_mode);
tracing::info!(
"Consent requested for session {} by '{}' ({:?}); prompting end user",
session_id,
technician_name,
access
);
// The MessageBox blocks the calling thread, so it runs on the blocking
// pool to avoid stalling the tokio runtime. Note, however, that the main
// session loop `.await`s this method (see the ConsentRequest arm), so
// the loop is SUSPENDED for the user's entire think-time and does NOT
// process or respond to server heartbeats while the dialog is open.
// This is safe because CONSENT_TIMEOUT_SECS (60s, server-side) is within
// the server's 90s HEARTBEAT_TIMEOUT_SECS: the prompt resolves before the
// server would consider the agent dead, so the session is not torn down.
let granted = tokio::task::spawn_blocking(move || prompt_consent(&technician_name, access))
.await
.unwrap_or_else(|e| {
// The blocking task panicked — fail closed (deny).
tracing::error!("Consent dialog task failed: {}; denying", e);
false
});
tracing::info!(
"End user {} consent for session {}",
if granted { "GRANTED" } else { "DENIED" },
session_id
);
let response = Message {
payload: Some(message::Payload::ConsentResponse(ConsentResponse {
session_id,
granted,
reason: if granted {
String::new()
} else {
"user_declined".to_string()
},
})),
};
if let Some(transport) = self.transport.as_mut() {
if let Err(e) = transport.send(response).await {
tracing::error!("Failed to send ConsentResponse: {}", e);
}
}
}
/// Handle incoming message from server
async fn handle_message(&mut self, msg: Message) -> Result<()> {
match msg.payload {
@@ -548,18 +705,23 @@ impl SessionManager {
Some(message::Payload::KeyEvent(key)) => {
if let Some(input) = self.input.as_mut() {
input.key_event(key.vk_code as u16, key.down)?;
// Full-fidelity scan-code injection: pass the viewer-captured
// scan code and extended-key flag through. A scan_code of 0 (older
// viewers / synthesized events) makes the agent derive it from the VK.
input.key_event_full(
key.vk_code as u16,
key.scan_code as u16,
key.is_extended,
key.down,
)?;
}
}
Some(message::Payload::SpecialKey(special)) => {
if let Some(input) = self.input.as_mut() {
use crate::proto::SpecialKey;
match SpecialKey::try_from(special.key).ok() {
Some(SpecialKey::CtrlAltDel) => {
input.send_ctrl_alt_del()?;
}
_ => {}
if let Ok(SpecialKey::CtrlAltDel) = SpecialKey::try_from(special.key) {
input.send_ctrl_alt_del()?;
}
}
}
@@ -634,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

@@ -3,13 +3,13 @@
//! Handles adding/removing the agent from Windows startup.
use anyhow::Result;
use tracing::{error, info, warn};
use tracing::{info, warn};
#[cfg(windows)]
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,37 +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::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);
@@ -100,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() {
@@ -116,11 +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::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);
@@ -180,6 +184,8 @@ pub fn uninstall() -> Result<()> {
/// Install the SAS service if the binary is available
/// This allows the agent to send Ctrl+Alt+Del even without SYSTEM privileges
// Not yet wired into the CLI; retained as the SAS service management API.
#[allow(dead_code)]
#[cfg(windows)]
pub fn install_sas_service() -> Result<()> {
info!("Attempting to install SAS service...");
@@ -230,6 +236,8 @@ pub fn install_sas_service() -> Result<()> {
}
/// Uninstall the SAS service
// Not yet wired into the CLI; retained as the SAS service management API.
#[allow(dead_code)]
#[cfg(windows)]
pub fn uninstall_sas_service() -> Result<()> {
info!("Attempting to uninstall SAS service...");
@@ -244,16 +252,14 @@ pub fn uninstall_sas_service() -> Result<()> {
)),
];
for path_opt in paths.iter() {
if let Some(ref path) = path_opt {
if path.exists() {
let output = std::process::Command::new(path).arg("uninstall").output();
for path in paths.iter().flatten() {
if path.exists() {
let output = std::process::Command::new(path).arg("uninstall").output();
if let Ok(result) = output {
if result.status.success() {
info!("SAS service uninstalled successfully");
return Ok(());
}
if let Ok(result) = output {
if result.status.success() {
info!("SAS service uninstalled successfully");
return Ok(());
}
}
}
@@ -264,6 +270,8 @@ pub fn uninstall_sas_service() -> Result<()> {
}
/// Check if the SAS service is installed and running
// Not yet wired into the CLI; retained as the SAS service management API.
#[allow(dead_code)]
#[cfg(windows)]
pub fn check_sas_service() -> bool {
use crate::sas_client;

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));
}
@@ -82,7 +93,7 @@ impl WebSocketTransport {
// Send as binary WebSocket message
stream
.send(WsMessage::Binary(buf.into()))
.send(WsMessage::Binary(buf))
.await
.context("Failed to send message")?;
@@ -132,6 +143,7 @@ impl WebSocketTransport {
}
/// Receive a message (blocking)
#[allow(dead_code)]
pub async fn recv(&mut self) -> Result<Option<Message>> {
// Return buffered message if available
if let Some(msg) = self.incoming.pop_front() {
@@ -164,7 +176,7 @@ impl WebSocketTransport {
.context("Failed to decode protobuf message")?;
Ok(Some(msg))
}
WsMessage::Ping(data) => {
WsMessage::Ping(_data) => {
// Pong is sent automatically by tungstenite
tracing::trace!("Received ping");
Ok(None)
@@ -193,6 +205,7 @@ impl WebSocketTransport {
}
/// Close the connection
#[allow(dead_code)]
pub async fn close(&mut self) -> Result<()> {
let mut stream = self.stream.lock().await;
stream.close(None).await?;

View File

@@ -6,10 +6,10 @@
//! - End session
use anyhow::Result;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tracing::{info, warn};
use tracing::info;
use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent};
#[cfg(windows)]
@@ -28,7 +28,8 @@ pub enum TrayAction {
/// Tray icon controller
pub struct TrayController {
_tray_icon: TrayIcon,
menu: Menu,
// Kept alive for the lifetime of the tray icon; not read directly.
_menu: Menu,
end_session_item: MenuItem,
debug_item: MenuItem,
status_item: MenuItem,
@@ -86,7 +87,7 @@ impl TrayController {
Ok(Self {
_tray_icon: tray_icon,
menu,
_menu: menu,
end_session_item,
debug_item,
status_item,
@@ -124,14 +125,9 @@ impl TrayController {
}
// Check for tray icon events (like double-click)
if let Ok(event) = TrayIconEvent::receiver().try_recv() {
match event {
TrayIconEvent::DoubleClick { .. } => {
info!("Tray icon double-clicked");
return Some(TrayAction::ShowDetails);
}
_ => {}
}
if let Ok(TrayIconEvent::DoubleClick { .. }) = TrayIconEvent::receiver().try_recv() {
info!("Tray icon double-clicked");
return Some(TrayAction::ShowDetails);
}
None

View File

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

522
agent/src/viewer/decoder.rs Normal file
View File

@@ -0,0 +1,522 @@
//! H.264 video decoder for the native viewer (Task 7).
//!
//! FIRST-CUT / COMPILE-VERIFIED ONLY. Decodes an H.264 elementary stream
//! (`EncodedFrame{h264}`) via a Media Foundation H.264 decoder MFT into NV12,
//! then converts NV12 -> BGRA so it can flow through the EXISTING raw render
//! path (`render::FrameData { compressed: false, BGRA }`). Not yet validated on
//! real hardware with a live stream — that is plan Task 8. On decode-init
//! failure the decoder reports an error and the viewer logs it; the raw-frame
//! render path is untouched for raw sessions.
//!
//! The decoder is created lazily on the first H.264 frame (so a raw session
//! never spins up MF). It is `!Send` (COM), so it lives on the viewer's receive
//! task and is wrapped accordingly by the caller.
#![cfg(windows)]
use anyhow::{anyhow, Context, Result};
use windows::Win32::Media::MediaFoundation::{
IMFMediaType, IMFSample, IMFTransform, MFCreateMediaType, MFCreateMemoryBuffer, MFCreateSample,
MFMediaType_Video, MFShutdown, MFStartup, MFTEnumEx, MFVideoFormat_H264, MFVideoFormat_NV12,
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_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.
pub struct DecodedFrame {
pub width: u32,
pub height: u32,
/// BGRA pixels (4 bytes/px), ready for `render::FrameData`.
pub bgra: Vec<u8>,
}
/// Media Foundation H.264 decoder wrapper.
pub struct H264Decoder {
transform: IMFTransform,
width: u32,
height: u32,
streaming: bool,
input_stream_id: u32,
output_stream_id: u32,
mf_started: bool,
}
// NOTE: H264Decoder is intentionally NOT `Send`. It wraps COM interfaces with
// thread affinity and is created + used entirely on the dedicated `gc-h264-decode`
// OS thread (see viewer::spawn_h264_decode_worker), so it never crosses a thread
// boundary and does not need a Send assertion.
impl H264Decoder {
/// Construct an H.264 decoder MFT and set its input type to H.264. The
/// output type (NV12) is negotiated after the first frames decode the
/// sequence header (we (re)read the real frame size on a stream change).
pub fn new() -> Result<Self> {
unsafe {
MFStartup(mf_version(), MFSTARTUP_LITE).context("MFStartup (decoder)")?;
let transform = match activate_decoder() {
Ok(t) => t,
Err(e) => {
let _ = MFShutdown();
return Err(e);
}
};
let mut dec = Self {
transform,
width: 0,
height: 0,
streaming: false,
input_stream_id: 0,
output_stream_id: 0,
mf_started: true,
};
dec.configure_input()?;
Ok(dec)
}
}
/// Set the decoder input type to H.264 (no fixed frame size — the decoder
/// learns it from the bitstream).
unsafe fn configure_input(&mut self) -> Result<()> {
let in_type: IMFMediaType = MFCreateMediaType().context("MFCreateMediaType(dec in)")?;
in_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
in_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)?;
self.transform
.SetInputType(self.input_stream_id, &in_type, 0)
.context("SetInputType(H264 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.
unsafe fn read_output_size(&mut self) -> Result<(u32, u32)> {
let out_type = self
.transform
.GetOutputCurrentType(self.output_stream_id)
.context("GetOutputCurrentType")?;
let packed = out_type
.GetUINT64(&MF_MT_FRAME_SIZE)
.context("read MF_MT_FRAME_SIZE")?;
let width = (packed >> 32) as u32;
let height = (packed & 0xFFFF_FFFF) as u32;
Ok((width, height))
}
unsafe fn ensure_streaming(&mut self) -> Result<()> {
if !self.streaming {
self.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0)
.context("decoder BEGIN_STREAMING")?;
self.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0)
.context("decoder START_OF_STREAM")?;
self.streaming = true;
}
Ok(())
}
/// 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(out);
}
unsafe {
self.ensure_streaming()?;
let sample = make_input_sample(h264, pts_100ns)?;
// 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:#}")),
}
}
// 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 {
let stream_info: MFT_OUTPUT_STREAM_INFO = self
.transform
.GetOutputStreamInfo(self.output_stream_id)
.context("decoder GetOutputStreamInfo")?;
const MFT_OUTPUT_STREAM_PROVIDES_SAMPLES: u32 = 0x100;
let mft_provides = stream_info.dwFlags & MFT_OUTPUT_STREAM_PROVIDES_SAMPLES != 0;
let mut out_buffer = MFT_OUTPUT_DATA_BUFFER {
dwStreamID: self.output_stream_id,
..Default::default()
};
if !mft_provides {
let alloc = stream_info.cbSize.max(self.guess_nv12_size());
let sample: IMFSample = MFCreateSample().context("MFCreateSample(dec out)")?;
let buffer =
MFCreateMemoryBuffer(alloc).context("MFCreateMemoryBuffer(dec out)")?;
sample.AddBuffer(&buffer)?;
out_buffer.pSample = std::mem::ManuallyDrop::new(Some(sample));
}
let mut status: u32 = 0;
let mut bufs = [out_buffer];
let hr = self.transform.ProcessOutput(0, &mut bufs, &mut status);
let produced = std::mem::ManuallyDrop::take(&mut bufs[0].pSample);
match hr {
Ok(()) => {
// (Re)read the negotiated size in case it just became known.
if let Ok((w, h)) = self.read_output_size() {
self.width = w;
self.height = h;
}
let Some(sample) = produced else {
return Ok(None);
};
if self.width == 0 || self.height == 0 {
return Ok(None);
}
let nv12 = sample_to_vec(&sample)?;
let bgra = nv12_to_bgra(&nv12, self.width, self.height)?;
return Ok(Some(DecodedFrame {
width: self.width,
height: self.height,
bgra,
}));
}
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => return Ok(None),
// 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;
}
continue;
}
Err(e) => return Err(anyhow!("decoder ProcessOutput failed: {e:#}")),
}
}
}
/// Conservative NV12 buffer estimate when the decoder doesn't report cbSize.
fn guess_nv12_size(&self) -> u32 {
if self.width != 0 && self.height != 0 {
self.width * self.height * 3 / 2
} else {
// 1080p NV12 upper bound until the real size is known.
1920 * 1080 * 3 / 2
}
}
}
impl Drop for H264Decoder {
fn drop(&mut self) {
unsafe {
if self.streaming {
let _ = self
.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0);
let _ = self
.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0);
}
if self.mf_started {
let _ = MFShutdown();
}
}
}
}
/// Enumerate and activate an H.264 decoder MFT (hardware preferred, software
/// acceptable — decode does not require a HW encoder).
unsafe fn activate_decoder() -> Result<IMFTransform> {
let input_type = MFT_REGISTER_TYPE_INFO {
guidMajorType: MFMediaType_Video,
guidSubtype: MFVideoFormat_H264,
};
let mut activate_ptr: *mut Option<windows::Win32::Media::MediaFoundation::IMFActivate> =
std::ptr::null_mut();
let mut count: u32 = 0;
// Allow both HW and SW decoders; SYNCMFT keeps the simple ProcessInput/Output
// contract this first cut uses.
MFTEnumEx(
MFT_CATEGORY_VIDEO_DECODER,
MFT_ENUM_FLAG_SYNCMFT | MFT_ENUM_FLAG_SORTANDFILTER,
Some(&input_type as *const _),
None,
&mut activate_ptr,
&mut count,
)
.context("MFTEnumEx (H264 decoder)")?;
if count == 0 || activate_ptr.is_null() {
if !activate_ptr.is_null() {
windows::Win32::System::Com::CoTaskMemFree(Some(activate_ptr as *const _));
}
return Err(anyhow!("no H.264 decoder MFT available"));
}
let slice = std::slice::from_raw_parts_mut(activate_ptr, count as usize);
let mut chosen: Option<IMFTransform> = None;
for entry in slice.iter_mut() {
if chosen.is_none() {
if let Some(activate) = entry.as_ref() {
if let Ok(t) = activate.ActivateObject::<IMFTransform>() {
chosen = Some(t);
}
}
}
entry.take();
}
windows::Win32::System::Com::CoTaskMemFree(Some(activate_ptr as *const _));
chosen.ok_or_else(|| anyhow!("failed to activate H.264 decoder MFT"))
}
/// Wrap an H.264 access unit into an IMFSample.
unsafe fn make_input_sample(data: &[u8], pts_100ns: i64) -> Result<IMFSample> {
let sample: IMFSample = MFCreateSample().context("MFCreateSample(dec in)")?;
let buffer = MFCreateMemoryBuffer(data.len() as u32).context("MFCreateMemoryBuffer(dec in)")?;
let mut ptr: *mut u8 = std::ptr::null_mut();
let mut max_len: u32 = 0;
buffer
.Lock(&mut ptr, Some(&mut max_len), None)
.context("decoder input Lock")?;
if (max_len as usize) < data.len() || ptr.is_null() {
let _ = buffer.Unlock();
return Err(anyhow!("MF buffer too small for H.264 access unit"));
}
std::ptr::copy_nonoverlapping(data.as_ptr(), ptr, data.len());
buffer.SetCurrentLength(data.len() as u32)?;
buffer.Unlock()?;
sample.AddBuffer(&buffer)?;
sample.SetSampleTime(pts_100ns)?;
Ok(sample)
}
/// Copy a sample's contiguous bytes into a Vec.
unsafe fn sample_to_vec(sample: &IMFSample) -> Result<Vec<u8>> {
let buffer = sample
.ConvertToContiguousBuffer()
.context("decoder ConvertToContiguousBuffer")?;
let mut ptr: *mut u8 = std::ptr::null_mut();
let mut len: u32 = 0;
buffer
.Lock(&mut ptr, None, Some(&mut len))
.context("decoder output Lock")?;
let out = if ptr.is_null() || len == 0 {
Vec::new()
} else {
std::slice::from_raw_parts(ptr, len as usize).to_vec()
};
let _ = buffer.Unlock();
Ok(out)
}
/// MF version word for `MFStartup` (see encoder::h264).
fn mf_version() -> u32 {
0x0002_0070
}
/// Convert an NV12 buffer to BGRA (BT.601 limited range). Inverse of the
/// encoder's BGRA->NV12. Shared with the unit tests below.
pub fn nv12_to_bgra(nv12: &[u8], width: u32, height: u32) -> Result<Vec<u8>> {
let w = width as usize;
let h = height as usize;
let y_size = w * h;
let need = y_size * 3 / 2;
if nv12.len() < need {
return Err(anyhow!("NV12 buffer too small: {} < {}", nv12.len(), need));
}
let (y_plane, uv_plane) = nv12.split_at(y_size);
let mut bgra = vec![0u8; w * h * 4];
let chroma_cols = w / 2;
for row in 0..h {
for col in 0..w {
let y = y_plane[row * w + col] as i32;
let cx = col / 2;
let cy = row / 2;
let uv_idx = (cy * chroma_cols + cx) * 2;
let u = uv_plane[uv_idx] as i32;
let v = uv_plane[uv_idx + 1] as i32;
// BT.601 limited-range YUV -> RGB.
let c = y - 16;
let d = u - 128;
let e = v - 128;
let r = ((298 * c + 409 * e + 128) >> 8).clamp(0, 255);
let g = ((298 * c - 100 * d - 208 * e + 128) >> 8).clamp(0, 255);
let b = ((298 * c + 516 * d + 128) >> 8).clamp(0, 255);
let px = (row * w + col) * 4;
bgra[px] = b as u8;
bgra[px + 1] = g as u8;
bgra[px + 2] = r as u8;
bgra[px + 3] = 255;
}
}
Ok(bgra)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::encoder::color::{bgra_to_nv12, nv12_size};
/// Round-trip a solid color through BGRA->NV12->BGRA. Chroma subsampling and
/// limited-range rounding introduce small error, so allow a tolerance.
#[test]
fn nv12_bgra_roundtrip_is_approximately_lossless_for_solid_color() {
let w = 4u32;
let h = 4u32;
// Mid gray.
let mut bgra = vec![0u8; (w * h * 4) as usize];
for px in bgra.chunks_mut(4) {
px[0] = 120; // B
px[1] = 120; // G
px[2] = 120; // R
px[3] = 255;
}
let mut nv12 = vec![0u8; nv12_size(w, h)];
bgra_to_nv12(&bgra, w, h, &mut nv12).unwrap();
let back = nv12_to_bgra(&nv12, w, h).unwrap();
for (orig, got) in bgra.chunks(4).zip(back.chunks(4)) {
for ch in 0..3 {
let diff = (orig[ch] as i32 - got[ch] as i32).abs();
assert!(diff <= 6, "channel {ch} drift {diff} too large");
}
assert_eq!(got[3], 255, "alpha must be opaque");
}
}
#[test]
fn nv12_to_bgra_rejects_short_buffer() {
let nv12 = vec![0u8; 4];
assert!(nv12_to_bgra(&nv12, 16, 16).is_err());
}
#[test]
fn black_nv12_decodes_to_black_bgra() {
// Limited-range black: Y=16, UV=128.
let w = 2u32;
let h = 2u32;
let mut nv12 = vec![128u8; nv12_size(w, h)];
for y in nv12.iter_mut().take((w * h) as usize) {
*y = 16;
}
let bgra = nv12_to_bgra(&nv12, w, h).unwrap();
for px in bgra.chunks(4) {
assert!(px[0] <= 2 && px[1] <= 2 && px[2] <= 2, "near-black");
}
}
}

View File

@@ -1,9 +1,24 @@
//! Low-level keyboard hook for capturing all keys including Win key
//! Low-level keyboard hook for capturing system key combinations.
//!
//! `WH_KEYBOARD_LL` is a GLOBAL hook: the OS invokes it for ALL desktop input regardless
//! of which window is focused. We therefore gate diversion on the viewer's focus state.
//! ONLY when the viewer window actually has focus AND "send system keys to remote" is
//! enabled does the hook DIVERT the system combinations the local shell would otherwise
//! consume — the Windows key, Win+R, Win+E, Alt+Tab, Ctrl+Esc, Alt+Esc — and forward them
//! to the remote as full-fidelity `KeyEvent`s (virtual key + hardware scan code +
//! extended-key flag + modifier snapshot), returning 1 from the hook proc to suppress the
//! local handling. All other keys flow through the normal viewer input path.
//!
//! When the toggle is OFF, the viewer is not focused, or the key is not a system combo,
//! the hook diverts NOTHING — it falls through to `CallNextHookEx` and every key reaches
//! the local OS unchanged. This keeps the technician's own Start menu / Alt+Tab working
//! while the viewer sits unfocused in the background.
use super::InputEvent;
#[cfg(windows)]
use crate::proto;
use anyhow::Result;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::mpsc;
#[cfg(windows)]
use tracing::trace;
@@ -12,36 +27,83 @@ use tracing::trace;
use windows::{
Win32::Foundation::{LPARAM, LRESULT, WPARAM},
Win32::UI::WindowsAndMessaging::{
CallNextHookEx, DispatchMessageW, GetMessageW, PeekMessageW, SetWindowsHookExW,
TranslateMessage, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, MSG, PM_REMOVE,
WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT,
LLKHF_EXTENDED, WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
},
};
#[cfg(windows)]
use std::sync::OnceLock;
/// Global toggle: when `true`, system key combinations are diverted to the remote;
/// when `false`, the hook is transparent and the local OS handles them. Default ON.
///
/// Lives at module scope because the `WH_KEYBOARD_LL` callback is a bare `extern "system"`
/// function with no user context pointer, so its state must be reachable statically.
static SEND_SYSTEM_KEYS: AtomicBool = AtomicBool::new(true);
/// Set whether system key combinations are forwarded to the remote (vs. handled locally).
///
/// Part of the programmatic toggle API (alongside `toggle_send_system_keys`, which the
/// Pause/Break host key drives). Retained for a future viewer menu / tray item and used
/// by the unit tests; not yet called from non-test code, hence the allow.
#[allow(dead_code)]
pub fn set_send_system_keys(enabled: bool) {
SEND_SYSTEM_KEYS.store(enabled, Ordering::Relaxed);
}
/// Flip the "send system keys to remote" toggle and return the new value.
pub fn toggle_send_system_keys() -> bool {
// fetch_xor(true) flips the bit and returns the PREVIOUS value; invert for the new one.
!SEND_SYSTEM_KEYS.fetch_xor(true, Ordering::Relaxed)
}
/// Current state of the "send system keys to remote" toggle.
///
/// Part of the programmatic toggle API; used by the unit tests and available for a
/// viewer menu / status indicator. Not yet read from non-test code, hence the allow.
#[allow(dead_code)]
pub fn send_system_keys_enabled() -> bool {
SEND_SYSTEM_KEYS.load(Ordering::Relaxed)
}
/// Whether the viewer window currently has input focus. Default `false`.
///
/// `WH_KEYBOARD_LL` is a GLOBAL hook fired for all desktop input, so it must NOT divert
/// system combos while the viewer is unfocused — otherwise the technician's own local
/// Win key / Alt+Tab / Ctrl+Esc would be suppressed and pushed to the remote. The render
/// loop updates this on `WindowEvent::Focused`. Lives at module scope for the same reason
/// as `SEND_SYSTEM_KEYS`: the bare `extern "system"` callback has no user-context pointer.
static VIEWER_FOCUSED: AtomicBool = AtomicBool::new(false);
/// Record whether the viewer window has input focus (drives the hook's focus gate).
pub fn set_viewer_focused(focused: bool) {
VIEWER_FOCUSED.store(focused, Ordering::Relaxed);
}
/// Current focus state as seen by the keyboard hook.
///
/// Used by the unit tests and available for diagnostics; not yet read from non-test code
/// beyond the hook callback itself, hence the allow.
#[allow(dead_code)]
pub fn viewer_focused() -> bool {
VIEWER_FOCUSED.load(Ordering::Relaxed)
}
#[cfg(windows)]
static INPUT_TX: OnceLock<mpsc::Sender<InputEvent>> = OnceLock::new();
#[cfg(windows)]
static mut HOOK_HANDLE: HHOOK = HHOOK(std::ptr::null_mut());
/// Virtual key codes for special keys
/// Virtual key codes for keys the hook reasons about.
#[cfg(windows)]
mod vk {
pub const VK_LWIN: u32 = 0x5B;
pub const VK_RWIN: u32 = 0x5C;
pub const VK_APPS: u32 = 0x5D;
pub const VK_LSHIFT: u32 = 0xA0;
pub const VK_RSHIFT: u32 = 0xA1;
pub const VK_LCONTROL: u32 = 0xA2;
pub const VK_RCONTROL: u32 = 0xA3;
pub const VK_LMENU: u32 = 0xA4; // Left Alt
pub const VK_RMENU: u32 = 0xA5; // Right Alt
pub const VK_TAB: u32 = 0x09;
pub const VK_ESCAPE: u32 = 0x1B;
pub const VK_SNAPSHOT: u32 = 0x2C; // Print Screen
}
#[cfg(windows)]
@@ -52,10 +114,10 @@ pub struct KeyboardHook {
#[cfg(windows)]
impl KeyboardHook {
pub fn new(input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
// Store the sender globally for the hook callback
INPUT_TX
.set(input_tx)
.map_err(|_| anyhow::anyhow!("Input TX already set"))?;
// Store the sender globally for the hook callback. If it was already set (e.g.
// a previous viewer instance in the same process), reuse the existing one rather
// than failing — the hook handle itself is what we re-install.
let _ = INPUT_TX.set(input_tx);
unsafe {
let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_hook_proc), None, 0)?;
@@ -78,42 +140,78 @@ impl Drop for KeyboardHook {
}
}
/// Decide whether a key event is a SYSTEM combination we must divert to the remote.
///
/// `vk_code` is the key; `alt`/`ctrl` are the modifier state at the moment of the event
/// (from `GetAsyncKeyState`). The Windows-key combos (Win, Win+R, Win+E) are recognized
/// by matching the Win keys themselves, so the held-Win state is not needed here. Pure
/// functions like this keep the (untestable) hook callback thin and unit-testable.
#[cfg(windows)]
fn is_system_combo(vk_code: u32, alt: bool, ctrl: bool) -> bool {
match vk_code {
// The Windows keys and the Applications (context-menu) key: always divert so the
// local Start menu / Win+R / Win+E / Win+E etc. do not fire. With Win forwarded
// down to the remote, subsequent letters (R, E, ...) compose there naturally.
vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS => true,
// Alt+Tab and Alt+Esc — the local window-switcher would otherwise eat these.
vk::VK_TAB if alt => true,
vk::VK_ESCAPE if alt => true,
// Ctrl+Esc opens the local Start menu; divert it.
vk::VK_ESCAPE if ctrl => true,
_ => false,
}
}
#[cfg(windows)]
unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
if code >= 0 {
let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
let vk_code = kb_struct.vkCode;
let scan_code = kb_struct.scanCode;
// LLKHF_EXTENDED (bit 0) marks extended keys (right Ctrl/Alt, arrows, etc.).
let is_extended = (kb_struct.flags.0 & LLKHF_EXTENDED.0) != 0;
let is_down = wparam.0 as u32 == WM_KEYDOWN || wparam.0 as u32 == WM_SYSKEYDOWN;
let is_up = wparam.0 as u32 == WM_KEYUP || wparam.0 as u32 == WM_SYSKEYUP;
if is_down || is_up {
// Check if this is a key we want to intercept (Win key, Alt+Tab, etc.)
let should_intercept = matches!(vk_code, vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS);
let forwarding = SEND_SYSTEM_KEYS.load(Ordering::Relaxed);
let focused = VIEWER_FOCUSED.load(Ordering::Relaxed);
let modifiers = current_modifiers();
// Send the key event to the remote
if let Some(tx) = INPUT_TX.get() {
let event = proto::KeyEvent {
down: is_down,
key_type: proto::KeyEventType::KeyVk as i32,
vk_code,
scan_code,
unicode: String::new(),
modifiers: Some(get_current_modifiers()),
};
// Divert ONLY a SYSTEM combo, ONLY while forwarding is enabled, and ONLY while
// the viewer window has focus. This is a global hook, so without the focus gate
// we would swallow the technician's own Win/Alt+Tab/Ctrl+Esc while the viewer
// sits unfocused in the background. When any condition is false we fall through
// to CallNextHookEx and suppress nothing — the local OS handles the key. Ordinary
// keys are left to the normal winit viewer input path (they are NOT forwarded
// here to avoid double-injection).
let divert =
forwarding && focused && is_system_combo(vk_code, modifiers.alt, modifiers.ctrl);
let _ = tx.try_send(InputEvent::Key(event));
trace!(
"Key hook: vk={:#x} scan={} down={}",
vk_code,
scan_code,
is_down
);
}
if divert {
if let Some(tx) = INPUT_TX.get() {
let event = proto::KeyEvent {
down: is_down,
key_type: proto::KeyEventType::KeyVk as i32,
vk_code,
scan_code,
unicode: String::new(),
is_extended,
modifiers: Some(modifiers),
};
// For Win key, consume the event so it doesn't open Start menu locally
if should_intercept {
let _ = tx.try_send(InputEvent::Key(event));
trace!(
"System-key hook diverted: vk={:#x} scan={} ext={} down={}",
vk_code,
scan_code,
is_extended,
is_down
);
}
// Suppress local handling of the diverted system combo.
return LRESULT(1);
}
}
@@ -123,7 +221,7 @@ unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam:
}
#[cfg(windows)]
fn get_current_modifiers() -> proto::Modifiers {
fn current_modifiers() -> proto::Modifiers {
use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
unsafe {
@@ -138,18 +236,6 @@ fn get_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)]
@@ -163,6 +249,73 @@ impl KeyboardHook {
}
}
#[cfg(not(windows))]
#[allow(dead_code)]
pub fn pump_messages() {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggle_defaults_on_and_flips() {
// Default is ON.
set_send_system_keys(true);
assert!(send_system_keys_enabled());
// Toggling flips and returns the NEW value.
assert!(!toggle_send_system_keys());
assert!(!send_system_keys_enabled());
assert!(toggle_send_system_keys());
assert!(send_system_keys_enabled());
// Explicit set wins.
set_send_system_keys(false);
assert!(!send_system_keys_enabled());
set_send_system_keys(true);
}
#[test]
fn viewer_focus_flag_defaults_off_and_tracks() {
// The hook starts gated CLOSED (unfocused) so a background viewer never swallows
// the technician's local system keys until it actually gains focus.
set_viewer_focused(false);
assert!(!viewer_focused());
set_viewer_focused(true);
assert!(viewer_focused());
set_viewer_focused(false);
assert!(!viewer_focused());
}
#[cfg(windows)]
#[test]
fn win_keys_always_divert() {
// Win / Apps keys divert regardless of modifier state.
assert!(is_system_combo(vk::VK_LWIN, false, false));
assert!(is_system_combo(vk::VK_RWIN, false, false));
assert!(is_system_combo(vk::VK_APPS, false, false));
}
#[cfg(windows)]
#[test]
fn alt_tab_and_alt_esc_divert_only_with_alt() {
assert!(is_system_combo(vk::VK_TAB, true, false)); // Alt+Tab
assert!(!is_system_combo(vk::VK_TAB, false, false)); // plain Tab -> local path
assert!(is_system_combo(vk::VK_ESCAPE, true, false)); // Alt+Esc
}
#[cfg(windows)]
#[test]
fn ctrl_esc_diverts_only_with_ctrl() {
assert!(is_system_combo(vk::VK_ESCAPE, false, true)); // Ctrl+Esc
assert!(!is_system_combo(vk::VK_ESCAPE, false, false)); // plain Esc -> local path
}
#[cfg(windows)]
#[test]
fn ordinary_keys_never_divert() {
// 'R' is NOT itself a "system combo" — Win was already diverted (and forwarded
// down), so R flows through the normal viewer path and composes Win+R on the remote.
assert!(!is_system_combo(0x52, false, false)); // 'R'
assert!(!is_system_combo(0x41, false, false)); // 'A'
assert!(!is_system_combo(vk::VK_TAB, false, true)); // Ctrl+Tab is app-level, not a shell combo
}
}

View File

@@ -3,6 +3,8 @@
//! This module provides the viewer functionality for connecting to remote
//! GuruConnect sessions with low-level keyboard hooks for Win key capture.
#[cfg(windows)]
mod decoder;
mod input;
mod render;
mod transport;
@@ -26,9 +28,84 @@ pub enum ViewerEvent {
pub enum InputEvent {
Mouse(proto::MouseEvent),
Key(proto::KeyEvent),
// Not yet emitted by the viewer input path (special-key fidelity is pending).
#[allow(dead_code)]
SpecialKey(proto::SpecialKeyEvent),
}
/// Spawn the dedicated H.264 decode worker thread (Task 7, Windows only).
///
/// Returns a sender for `(h264_access_unit, pts_100ns)`. The worker lazily
/// creates the Media Foundation decoder on the first frame; if creation fails it
/// logs once and then silently drops subsequent frames (the raw render path is
/// never affected). Each decoded frame is converted to BGRA and delivered to the
/// viewer as an uncompressed `FrameData`, reusing the existing render path.
#[cfg(windows)]
fn spawn_h264_decode_worker(
viewer_tx: mpsc::Sender<ViewerEvent>,
) -> std::sync::mpsc::Sender<(Vec<u8>, i64)> {
let (tx, rx) = std::sync::mpsc::channel::<(Vec<u8>, i64)>();
std::thread::Builder::new()
.name("gc-h264-decode".to_string())
.spawn(move || {
let mut decoder: Option<decoder::H264Decoder> = None;
let mut init_failed = false;
while let Ok((data, pts)) = rx.recv() {
if init_failed {
continue;
}
if decoder.is_none() {
match decoder::H264Decoder::new() {
Ok(d) => {
info!("H.264 decoder initialized (Media Foundation)");
decoder = Some(d);
}
Err(e) => {
error!(
"H.264 decoder init failed: {e:#}; H.264 frames will be dropped"
);
init_failed = true;
continue;
}
}
}
let dec = decoder.as_mut().expect("decoder present after init");
match dec.decode(&data, pts) {
// 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;
}
}
Err(e) => {
warn!("H.264 decode error: {e:#}");
}
}
}
})
.expect("failed to spawn H.264 decode worker thread");
tx
}
/// Run the viewer to connect to a remote session
pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()> {
info!("GuruConnect Viewer starting");
@@ -75,13 +152,23 @@ pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()
}
});
// H.264 decode worker (Task 7, Windows only). The Media Foundation decoder
// wraps COM interfaces with thread affinity, so it runs on a DEDICATED OS
// thread (not a tokio task, which can migrate across workers at await
// points). The receive task forwards H.264 access units to it over a std
// channel; the worker decodes to BGRA and pushes a FrameData back through
// the viewer channel via `blocking_send`. On decoder-init failure the worker
// logs and drops H.264 frames (the raw path is unaffected).
#[cfg(windows)]
let h264_tx = spawn_h264_decode_worker(viewer_tx.clone());
// Spawn task to receive messages from server
let viewer_tx_recv = viewer_tx.clone();
let receive_task = tokio::spawn(async move {
while let Some(msg) = ws_receiver.recv().await {
match msg.payload {
Some(proto::message::Payload::VideoFrame(frame)) => {
if let Some(proto::video_frame::Encoding::Raw(raw)) = frame.encoding {
Some(proto::message::Payload::VideoFrame(frame)) => match frame.encoding {
Some(proto::video_frame::Encoding::Raw(raw)) => {
let frame_data = render::FrameData {
width: raw.width as u32,
height: raw.height as u32,
@@ -91,7 +178,23 @@ pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()
};
let _ = viewer_tx_recv.send(ViewerEvent::Frame(frame_data)).await;
}
}
Some(proto::video_frame::Encoding::H264(enc)) => {
// Forward to the decode worker (Windows). On other
// platforms H.264 is never negotiated, so this is dead.
#[cfg(windows)]
{
if h264_tx.send((enc.data, enc.pts)).is_err() {
warn!("H.264 decode worker unavailable; dropping frame");
}
}
#[cfg(not(windows))]
{
let _ = enc;
}
}
// VP9/H265 not implemented on the viewer (raw + H.264 only).
_ => {}
},
Some(proto::message::Payload::CursorPosition(pos)) => {
let _ = viewer_tx_recv
.send(ViewerEvent::CursorPosition(pos.x, pos.y, pos.visible))

View File

@@ -1,6 +1,5 @@
//! Window rendering and frame display
#[cfg(windows)]
use super::input;
use super::{InputEvent, ViewerEvent};
use crate::proto;
@@ -25,9 +24,55 @@ pub struct FrameData {
pub height: u32,
pub data: Vec<u8>,
pub compressed: bool,
// Carried through from the wire frame; the renderer does not branch on it yet.
#[allow(dead_code)]
pub is_keyframe: bool,
}
/// Viewer-local tracker of which modifier keys are currently held down on the remote.
///
/// Mirrors what the viewer has forwarded so that on focus loss it can emit explicit
/// key-ups for anything still pressed, preventing a stuck Ctrl/Alt/Shift/Win.
#[derive(Default)]
struct ViewerModifierState {
ctrl: bool,
alt: bool,
shift: bool,
meta: bool,
}
impl ViewerModifierState {
/// Record a modifier transition for `vk_code`.
fn update(&mut self, vk_code: u32, down: bool) {
match vk_code {
0x11 | 0xA2 | 0xA3 => self.ctrl = down, // Ctrl / LCtrl / RCtrl
0x12 | 0xA4 | 0xA5 => self.alt = down, // Alt / LAlt / RAlt
0x10 | 0xA0 | 0xA1 => self.shift = down, // Shift / LShift / RShift
0x5B | 0x5C => self.meta = down, // LWin / RWin
_ => {}
}
}
/// Return the canonical VK of every held modifier, then clear all state.
fn drain_held(&mut self) -> Vec<u16> {
let mut held = Vec::new();
if self.ctrl {
held.push(0x11u16);
}
if self.alt {
held.push(0x12);
}
if self.shift {
held.push(0x10);
}
if self.meta {
held.push(0x5B);
}
*self = ViewerModifierState::default();
held
}
}
struct ViewerApp {
window: Option<Arc<Window>>,
surface: Option<softbuffer::Surface<Arc<Window>, Arc<Window>>>,
@@ -38,6 +83,7 @@ struct ViewerApp {
input_tx: mpsc::Sender<InputEvent>,
mouse_x: i32,
mouse_y: i32,
modifiers: ViewerModifierState,
#[cfg(windows)]
keyboard_hook: Option<input::KeyboardHook>,
}
@@ -54,6 +100,7 @@ impl ViewerApp {
input_tx,
mouse_x: 0,
mouse_y: 0,
modifiers: ViewerModifierState::default(),
#[cfg(windows)]
keyboard_hook: None,
}
@@ -214,24 +261,56 @@ impl ViewerApp {
let _ = self.input_tx.try_send(InputEvent::Mouse(event));
}
fn send_key_event(&self, key: PhysicalKey, state: ElementState) {
fn send_key_event(&mut self, key: PhysicalKey, state: ElementState) {
let vk_code = match key {
PhysicalKey::Code(code) => keycode_to_vk(code),
_ => return,
};
if vk_code == 0 {
return;
}
let down = state == ElementState::Pressed;
// Track modifier state locally so focus loss can release anything still held.
self.modifiers.update(vk_code, down);
// The winit path has no hardware scan code; the agent derives one from the VK.
// The extended-key flag is derived from the VK so extended keys (arrows, etc.)
// still inject correctly without a captured LLKHF_EXTENDED bit.
let event = proto::KeyEvent {
down: state == ElementState::Pressed,
down,
key_type: proto::KeyEventType::KeyVk as i32,
vk_code,
scan_code: 0,
unicode: String::new(),
is_extended: crate::input::vk_is_extended(vk_code as u16),
modifiers: Some(proto::Modifiers::default()),
};
let _ = self.input_tx.try_send(InputEvent::Key(event));
}
/// Release every modifier this viewer currently believes is held on the remote.
///
/// Invoked on focus loss and at window close so that a Ctrl/Alt/Shift/Win whose
/// key-up the viewer never saw (because focus left mid-press) is explicitly released
/// on the remote, preventing a "stuck modifier".
fn release_held_modifiers(&mut self) {
for vk in self.modifiers.drain_held() {
let event = proto::KeyEvent {
down: false,
key_type: proto::KeyEventType::KeyVk as i32,
vk_code: vk as u32,
scan_code: 0,
unicode: String::new(),
is_extended: crate::input::vk_is_extended(vk),
modifiers: Some(proto::Modifiers::default()),
};
let _ = self.input_tx.try_send(InputEvent::Key(event));
}
}
fn screen_to_frame_coords(&self, x: f64, y: f64) -> (i32, i32) {
let Some(window) = &self.window else {
return (x as i32, y as i32);
@@ -316,6 +395,8 @@ impl ApplicationHandler for ViewerApp {
match event {
WindowEvent::CloseRequested => {
info!("Window close requested");
// Release any modifiers still held so the remote isn't left latched.
self.release_held_modifiers();
event_loop.exit();
}
WindowEvent::RedrawRequested => {
@@ -343,13 +424,39 @@ impl ApplicationHandler for ViewerApp {
};
self.send_mouse_wheel(dx, dy);
}
WindowEvent::KeyboardInput { event, .. } => {
// Note: This handles keys that aren't captured by the low-level hook
// The hook handles Win key and other special keys
if !event.repeat {
self.send_key_event(event.physical_key, event.state);
// Focus changes drive the low-level hook's focus gate. The hook is GLOBAL
// (fires for all desktop input), so it must only divert system keys while the
// viewer is focused; we flip `set_viewer_focused` here. On blur we also release
// any held modifiers so they don't stay latched on the remote — winit's hook
// pump only runs while we have focus, so this is the safety net for a modifier
// pressed-but-not-released across the blur.
WindowEvent::Focused(focused) => {
input::set_viewer_focused(focused);
if focused {
debug!("Viewer gained focus; system-key forwarding active");
} else {
debug!("Viewer lost focus; releasing held modifiers on remote");
self.release_held_modifiers();
}
}
// Note: This handles keys that aren't captured by the low-level hook.
// The hook handles the Windows key and other diverted system combinations.
WindowEvent::KeyboardInput { event, .. } if !event.repeat => {
// Host key: Pause/Break toggles "send system keys to remote". It is
// intercepted locally (not forwarded) so the technician can flip the
// behavior without affecting the remote. Only act on key-down.
if matches!(event.physical_key, PhysicalKey::Code(KeyCode::Pause))
&& event.state == ElementState::Pressed
{
let enabled = input::toggle_send_system_keys();
info!(
"Send-system-keys toggled {} (Pause/Break host key)",
if enabled { "ON" } else { "OFF" }
);
return;
}
self.send_key_event(event.physical_key, event.state);
}
_ => {}
}
}
@@ -358,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)

12
dashboard/.env.example Normal file
View File

@@ -0,0 +1,12 @@
# GuruConnect dashboard — environment.
# Copy to `.env.local` for local overrides (gitignored via `*.local`).
# Base URL for the GuruConnect API. Leave UNSET to use same-origin (the
# production default — the dashboard is served by the GC server itself).
#
# In `npm run dev`, leave this unset too: Vite proxies `/api` and `/ws` to the
# local GC server (see vite.config.ts), so same-origin requests just work.
#
# Set it only to point the dashboard at a *different* host (e.g. a remote
# server while developing the UI locally):
# VITE_API_URL=https://connect.azcomputerguru.com

5
dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
*.local
.vite
node_modules/.tmp

138
dashboard/README.md Normal file
View File

@@ -0,0 +1,138 @@
# GuruConnect Operator Dashboard (v2)
React + Vite + TypeScript SPA — the operator console for GuruConnect v2. A dark
"operations terminal" UI for managing the remote-support fleet.
> **Pass 1 scope.** This pass ships the scaffold, design system, app shell,
> auth, the typed API client, and the **Machines** view. Sessions, Codes, and
> Users are nav stubs only (disabled in the sidebar) and arrive in later passes.
## Stack
- **React 18** + **React Router 6** (client-side routing)
- **Vite 5** (dev server + build)
- **TypeScript** (strict)
- **@tanstack/react-query** (server-state, polling, cache invalidation)
- **@fontsource** — Hanken Grotesk (UI) + JetBrains Mono (technical data)
No component/icon libraries — primitives and icons are hand-built to keep the
console aesthetic and the bundle lean.
## Scripts
```bash
npm install
npm run dev # Vite dev server (proxies /api + /ws to the local GC server)
npm run build # tsc -b && vite build -> dist/
npm run preview # serve the production build locally
npm run typecheck # tsc --noEmit
npm run lint # eslint
```
## Project layout
```
src/
api/ Typed API client + response interfaces (source of truth: server/src/api/*.rs)
client.ts fetch wrapper: base URL, bearer token, dual error-envelope normalization
types.ts TS mirrors of the Rust response structs
auth.ts login / me / logout
machines.ts list / get / history / delete + admin key endpoints
stubs.ts sessions / codes / users — scaffolds for later passes
auth/ AuthProvider (token in memory + sessionStorage), context, ProtectedRoute
components/
ui/ Reusable primitives: Button, Badge/StatusDot, Table, Panel,
Modal, ConfirmDialog, Input/Field, Spinner, States, Toast
layout/ AppShell, Sidebar, Topbar, PageHeader, inline SVG icons
features/
auth/ LoginPage
machines/ MachinesPage + detail / delete / admin-keys modals + hooks
lib/ time formatting, clipboard, relay-status probe
styles/ tokens.css (design tokens)
```
## Design system — "operations terminal"
Dark control-room console. Tokens live in `src/styles/tokens.css`; primitive
styles in `src/components/ui/*.css`.
- **Surfaces:** `--bg #0b0f14`, `--panel #141b22`, `--panel-2 #0e1419`
- **Accent (signal cyan):** `--accent #22d3bf` — primary actions + live state
- **Status language (dot + label, used everywhere):** ok/online `--ok`,
pending `--warn` (soft pulse), denied/offline/error `--bad`, neutral
`--neutral`. Mapping centralised in `components/ui/status.ts`.
- **Type:** Hanken Grotesk for UI; **JetBrains Mono for all technical data**
(agent IDs, support codes, IPs, versions, timestamps, key fingerprints).
- **Motion (restrained):** staggered row fade-in, the consent pulse, the live
relay pip, hover transitions. All disabled under `prefers-reduced-motion`.
## Auth
`POST /api/auth/login``{ token, user }`. The token is held in an in-memory
ref and mirrored to **sessionStorage** (never localStorage), so it clears when
the tab closes. `GET /api/auth/me` restores the session on reload;
`POST /api/auth/logout` revokes it server-side. The client attaches
`Authorization: Bearer <token>` to every request and bounces to `/login` on any
401. Admin-only UI (per-agent key management) is gated on `role === "admin"`.
The API uses **two** error envelopes — `{ error }` and
`{ detail, error_code, status_code }`. `api/client.ts` extracts a message from
whichever is present (and falls back to plain-text bodies that some routes
return), so callers see one normalized `ApiError`.
## Dev proxy
`vite.config.ts` proxies `/api` and `/ws` to the local GC server
(`http://localhost:3002`). Run the Rust server locally, then `npm run dev`
same-origin requests reach the backend with no CORS setup.
To develop the UI against a *remote* backend instead, set `VITE_API_URL`
(see `.env.example`).
## Production serving — WIRED
The SPA is served by the GC Axum server from the server root. No manual copy
step: `vite.config.ts` sets `build.outDir` to `../server/static/app/`, so the
build lands exactly where the server serves it.
### Build & deploy flow
```bash
# from dashboard/
npm run build # tsc -b && vite build -> ../server/static/app/
```
That single command refreshes the served SPA. `emptyOutDir` clears only
`server/static/app/` (the dedicated SPA subdir), so the v1 portal files in the
static root are never touched.
### How the server serves it (`server/src/main.rs`)
- `base` is **`/`** (absolute asset paths). The SPA uses `BrowserRouter`, so a
hard reload of a deep link (`/machines`) must still load `/assets/*`; relative
(`./`) paths would resolve against the deep-link path and 404. Absolute is
required.
- The Router's `fallback_service` is `ServeDir::new("static/app")` with
`.fallback(ServeFile::new("static/app/index.html"))`. Real files under
`/assets/*` are served from disk; any other unmatched path returns
`index.html` (HTTP **200**) so React Router resolves the route.
- **Precedence / safety:** the fallback runs only after every explicit
`/api/*`, `/ws/*`, `/health`, `/metrics` route and the `/downloads` nest. Two
catch-all routes — `/api/*rest` and `/ws/*rest` — return a JSON **404** for
unrouted API/WS paths, so the SPA fallback never answers an API/WS path with
HTML (which would break this client's error-envelope parsing).
- **Caching:** `/assets/*` (content-hashed) → `immutable`, one year;
`index.html` and everything else → `no-cache, must-revalidate`.
### Build output in git
`server/static/app/` is a build artifact. Whether to commit it or `.gitignore`
it depends on the deploy model (server-side `npm run build` vs shipping the
repo's static dir). Decide at commit time. The old `dashboard/dist/` path is no
longer used.
### Sub-path mounting (not used)
The dashboard is mounted at the server root. If it is ever moved under a
sub-path, switch Vite `base` to that path and pass the same `basename` to
`<BrowserRouter>`.

View File

@@ -0,0 +1,32 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "node_modules"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
);

13
dashboard/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>GuruConnect — Operator Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3331
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,37 @@
{
"name": "@guruconnect/dashboard",
"version": "0.2.0",
"description": "GuruConnect Remote Desktop Viewer Components",
"version": "0.3.0",
"description": "GuruConnect v2 operator dashboard",
"author": "AZ Computer Guru",
"license": "Proprietary",
"main": "src/components/index.ts",
"types": "src/components/index.ts",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"typescript": "^5.0.0"
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"fzstd": "^0.1.1"
"@fontsource/hanken-grotesk": "^5.0.8",
"@fontsource/jetbrains-mono": "^5.0.18",
"@tanstack/react-query": "^5.59.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0",
"typescript": "^5.6.2",
"typescript-eslint": "^8.7.0",
"vite": "^5.4.8"
}
}

51
dashboard/src/App.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Navigate, Route, BrowserRouter, Routes } from "react-router-dom";
import { AdminRoute } from "./auth/AdminRoute";
import { AuthProvider } from "./auth/AuthProvider";
import { ProtectedRoute } from "./auth/ProtectedRoute";
import { AppShell } from "./components/layout/AppShell";
import { ToastProvider } from "./components/ui/toast";
import { LoginPage } from "./features/auth/LoginPage";
import { SupportCodesPage } from "./features/codes/SupportCodesPage";
import { MachinesPage } from "./features/machines/MachinesPage";
import { SessionsPage } from "./features/sessions/SessionsPage";
import { UsersPage } from "./features/users/UsersPage";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ToastProvider>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<AppShell />}>
<Route path="/machines" element={<MachinesPage />} />
<Route path="/sessions" element={<SessionsPage />} />
<Route path="/codes" element={<SupportCodesPage />} />
{/* Users is admin-only: AdminRoute renders an access-denied
panel for non-admins instead of the view. */}
<Route element={<AdminRoute />}>
<Route path="/users" element={<UsersPage />} />
</Route>
<Route path="/" element={<Navigate to="/machines" replace />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/machines" replace />} />
</Routes>
</AuthProvider>
</ToastProvider>
</BrowserRouter>
</QueryClientProvider>
);
}

20
dashboard/src/api/auth.ts Normal file
View File

@@ -0,0 +1,20 @@
import { http } from "./client";
import type { LoginRequest, LoginResponse, User } from "./types";
/** POST /api/auth/login — exchange credentials for a JWT + user record. */
export function login(credentials: LoginRequest): Promise<LoginResponse> {
// skipAuthRedirect: a 401 here is "bad credentials", not "session expired".
return http.post<LoginResponse>("/api/auth/login", credentials, {
skipAuthRedirect: true,
});
}
/** GET /api/auth/me — restore the current user from a stored token. */
export function getMe(): Promise<User> {
return http.get<User>("/api/auth/me");
}
/** POST /api/auth/logout — revoke the current token server-side. */
export function logout(): Promise<{ message: string }> {
return http.post<{ message: string }>("/api/auth/logout");
}

138
dashboard/src/api/client.ts Normal file
View File

@@ -0,0 +1,138 @@
// Typed fetch wrapper for the GuruConnect API.
//
// Responsibilities:
// - Resolve the base URL (VITE_API_URL, default same-origin).
// - Attach `Authorization: Bearer <token>` from a pluggable token provider.
// - Normalize the *two* inconsistent server error envelopes into one
// ApiError shape so callers/UI never have to branch on which one came back.
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
/** A normalized API error. `code` is present only for the structured envelope. */
export class ApiError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status: number, code?: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
}
}
// The token lives in memory in the auth layer. We read it through a provider so
// the client has no hard dependency on React state and stays testable.
let tokenProvider: () => string | null = () => null;
export function setTokenProvider(provider: () => string | null): void {
tokenProvider = provider;
}
// Called when any request returns 401 — lets the auth layer tear down session
// state and bounce to /login. Set by AuthProvider.
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(handler: (() => void) | null): void {
onUnauthorized = handler;
}
interface RequestOptions {
method?: string;
body?: unknown;
// Suppress the global 401 handler (used by the login call itself).
skipAuthRedirect?: boolean;
signal?: AbortSignal;
}
/** The server's two error envelopes, unioned. We extract a message from either. */
interface ErrorEnvelope {
error?: string;
detail?: string;
error_code?: string;
status_code?: number;
}
function buildUrl(path: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) return path;
return `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
async function extractError(res: Response): Promise<ApiError> {
let message = `Request failed (${res.status})`;
let code: string | undefined;
const raw = await res.text();
if (raw) {
try {
const env = JSON.parse(raw) as ErrorEnvelope;
// Handle BOTH envelopes: `{error}` and `{detail, error_code, status_code}`.
const msg = env.detail ?? env.error;
if (typeof msg === "string" && msg.length > 0) message = msg;
if (typeof env.error_code === "string") code = env.error_code;
} catch {
// Non-JSON body (e.g. the machines routes return plain &'static str on
// error). Use the trimmed text as the message if it looks sane.
const trimmed = raw.trim();
if (trimmed && trimmed.length < 300) message = trimmed;
}
}
return new ApiError(message, res.status, code);
}
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {};
const token = tokenProvider();
if (token) headers["Authorization"] = `Bearer ${token}`;
let body: BodyInit | undefined;
if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(opts.body);
}
let res: Response;
try {
res = await fetch(buildUrl(path), {
method: opts.method ?? "GET",
headers,
body,
signal: opts.signal,
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
throw new ApiError("Network error — could not reach the server.", 0);
}
if (res.status === 401 && !opts.skipAuthRedirect) {
onUnauthorized?.();
}
if (!res.ok) {
throw await extractError(res);
}
// 204 No Content / empty body.
if (res.status === 204) return undefined as T;
const text = await res.text();
if (!text) return undefined as T;
// Most success responses are JSON, but some routes return a plain-text body
// on 200 (e.g. cancel returns "Code cancelled"). Tolerate non-JSON so a
// successful call isn't surfaced as a SyntaxError failure.
try {
return JSON.parse(text) as T;
} catch {
return undefined as T;
}
}
export const http = {
get: <T>(path: string, signal?: AbortSignal) =>
request<T>(path, { method: "GET", signal }),
post: <T>(path: string, body?: unknown, opts?: Partial<RequestOptions>) =>
request<T>(path, { method: "POST", body, ...opts }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: "PUT", body }),
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};

View File

@@ -0,0 +1,39 @@
import { http } from "./client";
import type { CreateCodeRequest, SupportCode } from "./types";
/**
* GET /api/codes — the active support codes (server returns only `pending` and
* `connected`, newest first is NOT guaranteed by the in-memory map, so the view
* sorts). Requires an authenticated dashboard JWT; any authenticated user may
* list. (See server/src/main.rs::list_codes.)
*/
export function listCodes(signal?: AbortSignal): Promise<SupportCode[]> {
return http.get<SupportCode[]>("/api/codes", signal);
}
/**
* POST /api/codes — generate a new one-time support code. The server creates an
* in-memory `pending` code (and persists a durable row for the single-use
* guard) and returns the full `SupportCode`, including the `XXX-XXX-XXX` value
* the tech reads to the end user. `technician_name` attributes the code to the
* operator. Requires an authenticated dashboard JWT.
* (See server/src/main.rs::create_code.)
*/
export function createCode(body: CreateCodeRequest): Promise<SupportCode> {
return http.post<SupportCode>("/api/codes", body);
}
/**
* POST /api/codes/:code/cancel — revoke an un-redeemed (or connected) code. The
* server flips a `pending`/`connected` code to `cancelled` and returns 200
* "Code cancelled"; a code that cannot be cancelled (already completed /
* cancelled / unknown) returns 400 "Cannot cancel code", which the typed client
* surfaces as an ApiError with that message. Requires an authenticated JWT.
* (See server/src/main.rs::cancel_code.)
*
* The path segment is the code itself; it can contain hyphens, so it is
* URL-encoded defensively even though the unambiguous alphabet is path-safe.
*/
export function cancelCode(code: string): Promise<void> {
return http.post<void>(`/api/codes/${encodeURIComponent(code)}/cancel`);
}

View File

@@ -0,0 +1,7 @@
export * from "./types";
export { ApiError, http, setTokenProvider, setUnauthorizedHandler } from "./client";
export * as authApi from "./auth";
export * as codesApi from "./codes";
export * as machinesApi from "./machines";
export * as stubsApi from "./stubs";
export * as usersApi from "./users";

View File

@@ -0,0 +1,99 @@
import { http } from "./client";
import type {
BulkRemoveResponse,
CreatedKey,
DeleteMachineParams,
DeleteMachineResponse,
KeyMetadata,
Machine,
MachineHistory,
} from "./types";
/** GET /api/machines — the real machines endpoint (NOT /api/sessions). */
export function listMachines(signal?: AbortSignal): Promise<Machine[]> {
return http.get<Machine[]>("/api/machines", signal);
}
/** GET /api/machines/:agent_id — single machine. */
export function getMachine(agentId: string): Promise<Machine> {
return http.get<Machine>(`/api/machines/${encodeURIComponent(agentId)}`);
}
/** GET /api/machines/:agent_id/history — past sessions + events. */
export function getMachineHistory(
agentId: string,
signal?: AbortSignal,
): Promise<MachineHistory> {
return http.get<MachineHistory>(
`/api/machines/${encodeURIComponent(agentId)}/history`,
signal,
);
}
/**
* 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()}` : "";
return http.del<DeleteMachineResponse>(
`/api/machines/${encodeURIComponent(agentId)}${suffix}`,
);
}
/**
* 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). */
export function listMachineKeys(agentId: string): Promise<KeyMetadata[]> {
return http.get<KeyMetadata[]>(
`/api/machines/${encodeURIComponent(agentId)}/keys`,
);
}
/**
* POST /api/machines/:agent_id/keys — mint a new per-agent key (admin only).
* The plaintext `key` is returned ONCE in the response — never again.
*/
export function createMachineKey(agentId: string): Promise<CreatedKey> {
return http.post<CreatedKey>(
`/api/machines/${encodeURIComponent(agentId)}/keys`,
);
}
/** DELETE /api/machines/:agent_id/keys/:key_id — revoke a key (admin only). */
export function revokeMachineKey(
agentId: string,
keyId: string,
): Promise<void> {
return http.del<void>(
`/api/machines/${encodeURIComponent(agentId)}/keys/${encodeURIComponent(keyId)}`,
);
}

View File

@@ -0,0 +1,55 @@
import { http } from "./client";
import type {
RemoveSessionResponse,
Session,
ViewerTokenResponse,
} from "./types";
/**
* GET /api/sessions — all live sessions known to the relay's in-memory session
* manager (active + offline-persistent). Requires an authenticated dashboard
* JWT; any authenticated user may list.
*/
export function listSessions(signal?: AbortSignal): Promise<Session[]> {
return http.get<Session[]>("/api/sessions", signal);
}
/**
* POST /api/sessions/:id/viewer-token — mint a short-lived, session-scoped
* viewer token. The server decides the access mode from the caller's
* permissions: admin or `control` permission gets a `control` token, otherwise
* a `view_only` token. A caller with neither `control` nor `view` gets 403.
* The access mode is stamped into the signed token; this response only echoes
* it. (See server/src/api/sessions.rs::mint_viewer_token.)
*/
export function mintViewerToken(
sessionId: string,
): Promise<ViewerTokenResponse> {
return http.post<ViewerTokenResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/viewer-token`,
);
}
/**
* 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

@@ -0,0 +1,18 @@
// Scaffolds for later passes. These endpoints exist on the server but their
// views (Sessions, Codes, Users) are out of scope for pass 1. Typed signatures
// are stubbed here so the API surface is discoverable and future passes can
// flesh out the response interfaces against the Rust source.
//
// Intentionally minimal: do NOT build UI against these yet.
import { http } from "./client";
/** GET /api/sessions — active/historical sessions. Pass 2. */
export function listSessions(signal?: AbortSignal): Promise<unknown[]> {
return http.get<unknown[]>("/api/sessions", signal);
}
/** GET /api/users — dashboard users (admin). Pass 2. */
export function listUsers(signal?: AbortSignal): Promise<unknown[]> {
return http.get<unknown[]>("/api/users", signal);
}

360
dashboard/src/api/types.ts Normal file
View File

@@ -0,0 +1,360 @@
// Typed mirrors of the GuruConnect server API responses.
// Shapes match server/src/api/*.rs exactly. Keep in sync with the Rust source
// of truth — these are hand-maintained, not generated.
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
export type Role = "admin" | "operator" | "viewer";
export type Permission =
| "view"
| "control"
| "transfer"
| "manage_users"
| "manage_clients";
/**
* The canonical role set the server accepts (server/src/api/users.rs
* `valid_roles`). The Users admin editor must offer exactly these — sending any
* other value is a 400.
*/
export const ROLES: readonly Role[] = ["admin", "operator", "viewer"] as const;
/**
* The canonical permission set the server accepts (server/src/api/users.rs
* `valid_permissions`). These are the exact strings the rest of the app checks
* (`view`/`control` gate viewer-token minting; `manage_users` gates the admin
* plane). The permission editor must use these — an invented string is a 400.
*/
export const PERMISSIONS: readonly Permission[] = [
"view",
"control",
"transfer",
"manage_users",
"manage_clients",
] as const;
/**
* The server's role-default permissions (server/src/api/users.rs, the `match
* request.role` block). When a user is created without an explicit permission
* list the server seeds these. The create form mirrors them so the checkboxes
* preview exactly what the server will store.
*/
export const ROLE_DEFAULT_PERMISSIONS: Record<Role, Permission[]> = {
admin: ["view", "control", "transfer", "manage_users", "manage_clients"],
operator: ["view", "control", "transfer"],
viewer: ["view"],
};
export interface User {
id: string;
username: string;
email: string | null;
// role/permission come from the server as plain strings; widen defensively.
role: Role | string;
permissions: (Permission | string)[];
}
/**
* Full admin-plane view of a user. Mirrors `api::users::UserInfo`
* (server/src/api/users.rs) exactly — every field the list/create/get/update
* endpoints return. The password hash is NEVER serialized by the server, so it
* has no place in this type. `enabled` is the server's active/disabled flag
* (a disabled user cannot log in). `email` and `last_login` are nullable.
*/
export interface UserAdmin {
id: string;
username: string;
email: string | null;
role: Role | string;
enabled: boolean;
created_at: string; // RFC3339
last_login: string | null; // RFC3339
permissions: (Permission | string)[];
}
/**
* Body for `POST /api/users`. Mirrors `api::users::CreateUserRequest`.
* `password` is required (server enforces >= 8 chars). `permissions` is
* optional: when omitted the server seeds role-default permissions, so the
* create UI sends it only when the admin overrides the defaults.
*/
export interface CreateUserRequest {
username: string;
password: string;
email?: string | null;
role: Role | string;
permissions?: (Permission | string)[];
}
/**
* Body for `PUT /api/users/:id`. Mirrors `api::users::UpdateUserRequest`.
* `role` and `enabled` are required (the server always re-applies them).
* `password`, when present, sets a new password (server enforces >= 8 chars);
* omit it to leave the password unchanged. Permissions are NOT updated here —
* they go through the dedicated permissions endpoint.
*/
export interface UpdateUserRequest {
email?: string | null;
role: Role | string;
enabled: boolean;
password?: string;
}
export interface LoginResponse {
token: string;
user: User;
}
export interface LoginRequest {
username: string;
password: string;
}
// ---------------------------------------------------------------------------
// Machines
// ---------------------------------------------------------------------------
export type MachineStatus = "online" | "offline";
export interface Machine {
id: string;
agent_id: string;
hostname: string;
os_version: string | null;
is_elevated: boolean;
is_persistent: boolean;
first_seen: string; // RFC3339
last_seen: string; // RFC3339
status: MachineStatus | string;
}
export interface SessionRecord {
id: string;
started_at: string;
ended_at: string | null;
duration_secs: number | null;
is_support_session: boolean;
support_code: string | null;
status: string;
}
export interface EventRecord {
id: number;
session_id: string;
event_type: string;
timestamp: string;
viewer_id: string | null;
viewer_name: string | null;
details: unknown | null;
ip_address: string | null;
}
export interface MachineHistory {
machine: Machine;
sessions: SessionRecord[];
events: EventRecord[];
exported_at: string;
}
export interface DeleteMachineParams {
/** Send an uninstall command to the agent if it is online. */
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 {
success: boolean;
message: string;
uninstall_sent: boolean;
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)
// ---------------------------------------------------------------------------
/**
* Attended-consent state. Mirrors `connect_sessions.consent_state` and
* `session::ConsentState::as_db_str`. Managed/persistent sessions are
* `not_required`; attended (support-code) sessions move
* `pending` -> `granted` | `denied`. A viewer may only join `not_required` or
* `granted` (the relay refuses the others).
*/
export type ConsentState =
| "not_required"
| "pending"
| "granted"
| "denied";
/** A technician/viewer currently watching a session. Mirrors `ViewerInfoApi`. */
export interface SessionViewer {
id: string;
name: string;
connected_at: string; // RFC3339
}
/**
* Live session as returned by GET /api/sessions. Field names mirror
* `api::SessionInfo` (server/src/api/mod.rs) exactly. This is in-memory relay
* state, not the historical `connect_sessions` row (that is `SessionRecord`).
*/
export interface Session {
id: string;
agent_id: string;
agent_name: string;
started_at: string; // RFC3339
viewer_count: number;
viewers: SessionViewer[];
is_streaming: boolean;
is_online: boolean;
is_persistent: boolean;
last_heartbeat: string; // RFC3339
os_version: string | null;
is_elevated: boolean;
uptime_secs: number;
display_count: number;
agent_version: string | null;
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";
/**
* Response from POST /api/sessions/:id/viewer-token. Mirrors
* `api::sessions::ViewerTokenResponse`. The signed token carries the
* authoritative access claim; `access` here is the echoed mode.
*/
export interface ViewerTokenResponse {
token: string;
session_id: string;
expires_in_secs: number;
access: ViewerAccess | string;
}
// ---------------------------------------------------------------------------
// Support codes (attended-support, one-time)
// ---------------------------------------------------------------------------
/**
* Lifecycle state of a support code. Mirrors `support_codes::CodeStatus`
* (`#[serde(rename_all = "lowercase")]`), serialized as the `status` field:
* pending — generated, waiting for an end user to redeem it (single-use).
* connected — redeemed; an attended session is now bound to it.
* completed — that session ended normally.
* cancelled — revoked by a tech before it was redeemed.
* `GET /api/codes` returns only `pending` and `connected` (the active set);
* `completed`/`cancelled` are modeled for completeness and defensive rendering.
*/
export type CodeStatus =
| "pending"
| "connected"
| "completed"
| "cancelled";
/**
* A support code as returned by `POST /api/codes` and `GET /api/codes`. Field
* names mirror `support_codes::SupportCode` (serde default snake_case) exactly.
*
* NOTE: the in-memory `SupportCode` the API serializes has NO `expires_at`
* field (only the durable DB row does); codes are short-lived and the dashboard
* surfaces liveness via the poll + status, not an absolute expiry. `code` is the
* grouped `XXX-XXX-XXX` value the tech reads to the end user.
*/
export interface SupportCode {
code: string;
session_id: string; // UUID
created_by: string;
created_at: string; // RFC3339
status: CodeStatus | string;
client_name: string | null;
client_machine: string | null;
connected_at: string | null; // RFC3339, set when redeemed
}
/**
* Body for `POST /api/codes`. Mirrors `support_codes::CreateCodeRequest`. Both
* fields are optional; the server stamps `created_by` from `technician_name`
* (falling back to "Unknown"). `technician_id` is accepted but currently unused
* server-side. We send `technician_name` so the code is attributed to the
* signed-in operator.
*/
export interface CreateCodeRequest {
technician_id?: string;
technician_name?: string;
}
// ---------------------------------------------------------------------------
// Per-agent keys (admin plane)
// ---------------------------------------------------------------------------
export interface KeyMetadata {
id: string;
machine_id: string;
created_at: string;
last_used_at: string | null;
revoked_at: string | null;
}
/** Returned exactly once when a key is minted. `key` is plaintext `cak_...`. */
export interface CreatedKey {
id: string;
machine_id: string;
key: string;
created_at: string;
}

View File

@@ -0,0 +1,58 @@
import { http } from "./client";
import type {
CreateUserRequest,
Permission,
UpdateUserRequest,
UserAdmin,
} from "./types";
// Admin-plane user management. Every endpoint here is admin-gated server-side
// (the `AdminUser` extractor in server/src/auth/mod.rs returns 403 for a
// non-admin). The dashboard mirrors that gate so a non-admin never reaches
// these calls, but the server is the authority.
/** GET /api/users — list every user (admin only). */
export function listUsers(signal?: AbortSignal): Promise<UserAdmin[]> {
return http.get<UserAdmin[]>("/api/users", signal);
}
/**
* POST /api/users — create a user (admin only). Returns the created user.
* The plaintext password is sent in the body but NEVER echoed back in the
* response (the server's UserInfo has no password field).
*/
export function createUser(body: CreateUserRequest): Promise<UserAdmin> {
return http.post<UserAdmin>("/api/users", body);
}
/**
* PUT /api/users/:id — update role / enabled / email, and optionally set a new
* password (admin only). Permissions are NOT changed here — use setPermissions.
*/
export function updateUser(
id: string,
body: UpdateUserRequest,
): Promise<UserAdmin> {
return http.put<UserAdmin>(`/api/users/${encodeURIComponent(id)}`, body);
}
/**
* PUT /api/users/:id/permissions — replace a user's permission set (admin
* only). Returns 200 with no body.
*/
export function setUserPermissions(
id: string,
permissions: (Permission | string)[],
): Promise<void> {
return http.put<void>(`/api/users/${encodeURIComponent(id)}/permissions`, {
permissions,
});
}
/**
* DELETE /api/users/:id — permanently delete a user (admin only). The server
* refuses to delete the caller's own account (400). Returns 204.
*/
export function deleteUser(id: string): Promise<void> {
return http.del<void>(`/api/users/${encodeURIComponent(id)}`);
}

View File

@@ -0,0 +1,56 @@
import { Link, Outlet } from "react-router-dom";
import { Panel } from "../components/ui/Panel";
import { useAuth } from "./AuthContext";
/**
* Route gate for admin-only sections (the Users plane). Sits inside
* ProtectedRoute, so the user is already authenticated here — this only checks
* the admin role.
*
* A non-admin who navigates to an admin route sees a calm, explicit
* access-denied panel (NOT a redirect loop and NOT a 403 toast storm). The
* server remains the real authority: the underlying /api/users calls are
* admin-gated server-side, so this is defense-in-depth plus correct UX.
*/
export function AdminRoute() {
const { isAdmin } = useAuth();
if (!isAdmin) {
return (
<div className="page">
<div className="denied">
<Panel>
<div className="denied__body">
<span className="denied__badge" aria-hidden="true">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</span>
<h1 className="denied__title">Admins only</h1>
<p className="denied__msg">
User management is restricted to administrators. Your account
does not have admin access. If you need it, ask an administrator
to update your role.
</p>
<Link to="/machines" className="btn btn--primary denied__link">
Back to Machines
</Link>
</div>
</Panel>
</div>
</div>
);
}
return <Outlet />;
}

View File

@@ -0,0 +1,21 @@
import { createContext, useContext } from "react";
import type { Permission, Role, User } from "../api/types";
export interface AuthState {
user: User | null;
/** True while restoring a session from a stored token on first load. */
initializing: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
isAdmin: boolean;
hasRole: (role: Role) => boolean;
hasPermission: (perm: Permission) => boolean;
}
export const AuthContext = createContext<AuthState | null>(null);
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
return ctx;
}

View File

@@ -0,0 +1,100 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as authApi from "../api/auth";
import { setTokenProvider, setUnauthorizedHandler } from "../api/client";
import type { Permission, Role, User } from "../api/types";
import { AuthContext, type AuthState } from "./AuthContext";
const STORAGE_KEY = "gc.token";
/**
* Token storage policy: the source of truth is an in-memory ref (survives
* re-renders, never serialized into React state to avoid accidental logging).
* It is mirrored into sessionStorage — NOT localStorage — so it clears when the
* tab closes and never leaks across browser sessions.
*/
export function AuthProvider({ children }: { children: React.ReactNode }) {
const tokenRef = useRef<string | null>(sessionStorage.getItem(STORAGE_KEY));
const [user, setUser] = useState<User | null>(null);
const [initializing, setInitializing] = useState(true);
const setToken = useCallback((token: string | null) => {
tokenRef.current = token;
if (token) sessionStorage.setItem(STORAGE_KEY, token);
else sessionStorage.removeItem(STORAGE_KEY);
}, []);
// Wire the API client to read our token and to notify us on 401.
useEffect(() => {
setTokenProvider(() => tokenRef.current);
}, []);
const clearSession = useCallback(() => {
setToken(null);
setUser(null);
}, [setToken]);
useEffect(() => {
setUnauthorizedHandler(clearSession);
return () => setUnauthorizedHandler(null);
}, [clearSession]);
// Restore session on first load if a token is present.
useEffect(() => {
let cancelled = false;
async function restore() {
if (!tokenRef.current) {
setInitializing(false);
return;
}
try {
const me = await authApi.getMe();
if (!cancelled) setUser(me);
} catch {
// Invalid/expired token — clear it. The 401 handler also fires, but
// guard here for non-401 failures too.
if (!cancelled) clearSession();
} finally {
if (!cancelled) setInitializing(false);
}
}
void restore();
return () => {
cancelled = true;
};
}, [clearSession]);
const login = useCallback(
async (username: string, password: string) => {
const res = await authApi.login({ username, password });
setToken(res.token);
setUser(res.user);
},
[setToken],
);
const logout = useCallback(async () => {
try {
// Best-effort server-side revocation; clear locally regardless.
await authApi.logout();
} catch {
// ignore — token may already be invalid
} finally {
clearSession();
}
}, [clearSession]);
const value = useMemo<AuthState>(() => {
const role = user?.role;
return {
user,
initializing,
login,
logout,
isAdmin: role === "admin",
hasRole: (r: Role) => role === r,
hasPermission: (p: Permission) => user?.permissions.includes(p) ?? false,
};
}, [user, initializing, login, logout]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

View File

@@ -0,0 +1,27 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { Spinner } from "../components/ui/Spinner";
import { useAuth } from "./AuthContext";
/**
* Gate for authenticated routes. While restoring a session from a stored token
* we show a spinner (avoids a login-flash on reload). No user -> /login,
* preserving the attempted location for post-login return.
*/
export function ProtectedRoute() {
const { user, initializing } = useAuth();
const location = useLocation();
if (initializing) {
return (
<div className="auth-gate">
<Spinner label="Restoring session" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return <Outlet />;
}

View File

@@ -1,215 +0,0 @@
/**
* RemoteViewer Component
*
* Canvas-based remote desktop viewer that connects to a GuruConnect
* agent via the relay server. Handles frame rendering and input capture.
*/
import React, { useRef, useEffect, useCallback, useState } from 'react';
import { useRemoteSession, createMouseEvent, createKeyEvent } from '../hooks/useRemoteSession';
import type { VideoFrame, ConnectionStatus, MouseEventType } from '../types/protocol';
interface RemoteViewerProps {
serverUrl: string;
sessionId: string;
className?: string;
onStatusChange?: (status: ConnectionStatus) => void;
autoConnect?: boolean;
showStatusBar?: boolean;
}
export const RemoteViewer: React.FC<RemoteViewerProps> = ({
serverUrl,
sessionId,
className = '',
onStatusChange,
autoConnect = true,
showStatusBar = true,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
// Display dimensions from received frames
const [displaySize, setDisplaySize] = useState({ width: 1920, height: 1080 });
// Frame buffer for rendering
const frameBufferRef = useRef<ImageData | null>(null);
// Handle incoming video frames
const handleFrame = useCallback((frame: VideoFrame) => {
if (!frame.raw || !canvasRef.current) return;
const { width, height, data, compressed, isKeyframe } = frame.raw;
// Update display size if changed
if (width !== displaySize.width || height !== displaySize.height) {
setDisplaySize({ width, height });
}
// Get or create context
if (!ctxRef.current) {
ctxRef.current = canvasRef.current.getContext('2d', {
alpha: false,
desynchronized: true,
});
}
const ctx = ctxRef.current;
if (!ctx) return;
// For MVP, we assume raw BGRA frames
// In production, handle compressed frames with fzstd
let frameData = data;
// Create or reuse ImageData
if (!frameBufferRef.current ||
frameBufferRef.current.width !== width ||
frameBufferRef.current.height !== height) {
frameBufferRef.current = ctx.createImageData(width, height);
}
const imageData = frameBufferRef.current;
// Convert BGRA to RGBA for canvas
const pixels = imageData.data;
const len = Math.min(frameData.length, pixels.length);
for (let i = 0; i < len; i += 4) {
pixels[i] = frameData[i + 2]; // R <- B
pixels[i + 1] = frameData[i + 1]; // G <- G
pixels[i + 2] = frameData[i]; // B <- R
pixels[i + 3] = 255; // A (opaque)
}
// Draw to canvas
ctx.putImageData(imageData, 0, 0);
}, [displaySize]);
// Set up session
const { status, connect, disconnect, sendMouseEvent, sendKeyEvent } = useRemoteSession({
serverUrl,
sessionId,
onFrame: handleFrame,
onStatusChange,
});
// Auto-connect on mount
useEffect(() => {
if (autoConnect) {
connect();
}
return () => {
disconnect();
};
}, [autoConnect, connect, disconnect]);
// Update canvas size when display size changes
useEffect(() => {
if (canvasRef.current) {
canvasRef.current.width = displaySize.width;
canvasRef.current.height = displaySize.height;
// Reset context reference
ctxRef.current = null;
frameBufferRef.current = null;
}
}, [displaySize]);
// Get canvas rect for coordinate translation
const getCanvasRect = useCallback(() => {
return canvasRef.current?.getBoundingClientRect() ?? new DOMRect();
}, []);
// Mouse event handlers
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 0);
sendMouseEvent(event);
}, [getCanvasRect, displaySize, sendMouseEvent]);
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 1);
sendMouseEvent(event);
}, [getCanvasRect, displaySize, sendMouseEvent]);
const handleMouseUp = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 2);
sendMouseEvent(event);
}, [getCanvasRect, displaySize, sendMouseEvent]);
const handleWheel = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
e.preventDefault();
const baseEvent = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 3);
sendMouseEvent({
...baseEvent,
wheelDeltaX: Math.round(e.deltaX),
wheelDeltaY: Math.round(e.deltaY),
});
}, [getCanvasRect, displaySize, sendMouseEvent]);
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault(); // Prevent browser context menu
}, []);
// Keyboard event handlers
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLCanvasElement>) => {
e.preventDefault();
const event = createKeyEvent(e, true);
sendKeyEvent(event);
}, [sendKeyEvent]);
const handleKeyUp = useCallback((e: React.KeyboardEvent<HTMLCanvasElement>) => {
e.preventDefault();
const event = createKeyEvent(e, false);
sendKeyEvent(event);
}, [sendKeyEvent]);
return (
<div ref={containerRef} className={`remote-viewer ${className}`}>
<canvas
ref={canvasRef}
tabIndex={0}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onWheel={handleWheel}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
style={{
width: '100%',
height: 'auto',
aspectRatio: `${displaySize.width} / ${displaySize.height}`,
cursor: 'none', // Hide cursor, remote cursor is shown in frame
outline: 'none',
backgroundColor: '#1a1a1a',
}}
/>
{showStatusBar && (
<div className="remote-viewer-status" style={{
display: 'flex',
justifyContent: 'space-between',
padding: '4px 8px',
backgroundColor: '#333',
color: '#fff',
fontSize: '12px',
fontFamily: 'monospace',
}}>
<span>
{status.connected ? (
<span style={{ color: '#4ade80' }}>Connected</span>
) : (
<span style={{ color: '#f87171' }}>Disconnected</span>
)}
</span>
<span>{displaySize.width}x{displaySize.height}</span>
{status.fps !== undefined && <span>{status.fps} FPS</span>}
{status.latencyMs !== undefined && <span>{status.latencyMs}ms</span>}
</div>
)}
</div>
);
};
export default RemoteViewer;

View File

@@ -1,187 +0,0 @@
/**
* Session Controls Component
*
* Toolbar for controlling the remote session (quality, displays, special keys)
*/
import React, { useState } from 'react';
import type { QualitySettings, Display } from '../types/protocol';
interface SessionControlsProps {
displays?: Display[];
currentDisplay?: number;
onDisplayChange?: (displayId: number) => void;
quality?: QualitySettings;
onQualityChange?: (settings: QualitySettings) => void;
onSpecialKey?: (key: 'ctrl-alt-del' | 'lock-screen' | 'print-screen') => void;
onDisconnect?: () => void;
}
export const SessionControls: React.FC<SessionControlsProps> = ({
displays = [],
currentDisplay = 0,
onDisplayChange,
quality,
onQualityChange,
onSpecialKey,
onDisconnect,
}) => {
const [showQuality, setShowQuality] = useState(false);
const handleQualityPreset = (preset: 'auto' | 'low' | 'balanced' | 'high') => {
onQualityChange?.({
preset,
codec: 'auto',
});
};
return (
<div className="session-controls" style={{
display: 'flex',
gap: '8px',
padding: '8px',
backgroundColor: '#222',
borderBottom: '1px solid #444',
}}>
{/* Display selector */}
{displays.length > 1 && (
<select
value={currentDisplay}
onChange={(e) => onDisplayChange?.(Number(e.target.value))}
style={{
padding: '4px 8px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '4px',
}}
>
{displays.map((d) => (
<option key={d.id} value={d.id}>
{d.name || `Display ${d.id + 1}`}
{d.isPrimary ? ' (Primary)' : ''}
</option>
))}
</select>
)}
{/* Quality dropdown */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowQuality(!showQuality)}
style={{
padding: '4px 12px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Quality: {quality?.preset || 'auto'}
</button>
{showQuality && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
backgroundColor: '#333',
border: '1px solid #555',
borderRadius: '4px',
zIndex: 100,
}}>
{(['auto', 'low', 'balanced', 'high'] as const).map((preset) => (
<button
key={preset}
onClick={() => {
handleQualityPreset(preset);
setShowQuality(false);
}}
style={{
display: 'block',
width: '100%',
padding: '8px 16px',
backgroundColor: quality?.preset === preset ? '#444' : 'transparent',
color: '#fff',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
}}
>
{preset.charAt(0).toUpperCase() + preset.slice(1)}
</button>
))}
</div>
)}
</div>
{/* Special keys */}
<button
onClick={() => onSpecialKey?.('ctrl-alt-del')}
title="Send Ctrl+Alt+Delete"
style={{
padding: '4px 12px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Ctrl+Alt+Del
</button>
<button
onClick={() => onSpecialKey?.('lock-screen')}
title="Lock Screen (Win+L)"
style={{
padding: '4px 12px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Lock
</button>
<button
onClick={() => onSpecialKey?.('print-screen')}
title="Print Screen"
style={{
padding: '4px 12px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '4px',
cursor: 'pointer',
}}
>
PrtSc
</button>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Disconnect */}
<button
onClick={onDisconnect}
style={{
padding: '4px 12px',
backgroundColor: '#dc2626',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Disconnect
</button>
</div>
);
};
export default SessionControls;

View File

@@ -1,22 +0,0 @@
/**
* GuruConnect Dashboard Components
*
* Export all components for use in GuruRMM dashboard
*/
export { RemoteViewer } from './RemoteViewer';
export { SessionControls } from './SessionControls';
// Re-export types
export type {
ConnectionStatus,
Display,
DisplayInfo,
QualitySettings,
VideoFrame,
MouseEvent as ProtoMouseEvent,
KeyEvent as ProtoKeyEvent,
} from '../types/protocol';
// Re-export hooks
export { useRemoteSession, createMouseEvent, createKeyEvent } from '../hooks/useRemoteSession';

View File

@@ -0,0 +1,17 @@
import { Outlet } from "react-router-dom";
import "./layout.css";
import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar";
/** Persistent chrome: left sidebar + top bar around the routed page. */
export function AppShell() {
return (
<div className="shell">
<Sidebar />
<Topbar />
<main className="main">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
interface PageHeaderProps {
title: string;
subtitle?: ReactNode;
/** Primary action slot, right-aligned. */
actions?: ReactNode;
}
/** Standard page title block with an action slot. */
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
return (
<div className="page__header">
<div className="page__titles">
<h1>{title}</h1>
{subtitle && <div className="page__subtitle">{subtitle}</div>}
</div>
{actions && <div className="page__actions">{actions}</div>}
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { NavLink } from "react-router-dom";
import type { ComponentType, SVGProps } from "react";
import { useAuth } from "../../auth/AuthContext";
import {
CodesIcon,
MachinesIcon,
SessionsIcon,
UsersIcon,
} from "./icons";
interface NavItem {
to: string;
label: string;
Icon: ComponentType<SVGProps<SVGSVGElement>>;
/** Pass-1 stubs are disabled until their views land in later passes. */
enabled: boolean;
/** Only render for admins (the underlying route is admin-gated). */
adminOnly?: boolean;
}
const NAV: NavItem[] = [
{ to: "/machines", label: "Machines", Icon: MachinesIcon, enabled: true },
{ to: "/sessions", label: "Sessions", Icon: SessionsIcon, enabled: true },
{ to: "/codes", label: "Codes", Icon: CodesIcon, enabled: true },
{
to: "/users",
label: "Users",
Icon: UsersIcon,
enabled: true,
adminOnly: true,
},
];
export function Sidebar() {
const { isAdmin } = useAuth();
// Hide admin-only items from non-admins entirely (the route also gates them,
// and the API is admin-gated server-side — this keeps the UX honest).
const items = NAV.filter((item) => !item.adminOnly || isAdmin);
return (
<aside className="sidebar">
<div className="sidebar__brand">
<span className="sidebar__logo" aria-hidden="true">
GC
</span>
<span className="sidebar__name">
GuruConnect
<small>Operator Console</small>
</span>
</div>
<nav className="sidebar__nav" aria-label="Primary">
<span className="sidebar__section">Operations</span>
{items.map(({ to, label, Icon, enabled }) =>
enabled ? (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`navlink${isActive ? " navlink--active" : ""}`
}
>
<span className="navlink__icon">
<Icon />
</span>
{label}
</NavLink>
) : (
<span
key={to}
className="navlink navlink--disabled"
aria-disabled="true"
title={`${label} — coming in a later pass`}
>
<span className="navlink__icon">
<Icon />
</span>
{label}
<span className="navlink__soon">Soon</span>
</span>
),
)}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,51 @@
import { useAuth } from "../../auth/AuthContext";
import { useRelayStatus } from "../../lib/useRelayStatus";
import { Badge } from "../ui/Badge";
import { Button } from "../ui/Button";
import { LogoutIcon } from "./icons";
function roleTone(role: string | undefined): "accent" | "ok" | "neutral" {
if (role === "admin") return "accent";
if (role === "operator") return "ok";
return "neutral";
}
export function Topbar() {
const { user, logout } = useAuth();
const { live, checking } = useRelayStatus();
const relayClass = live ? "relay relay--live" : "relay relay--down";
const relayLabel = checking ? "probing" : live ? "live" : "offline";
return (
<header className="topbar">
<div
className={relayClass}
title="GuruConnect relay connection"
aria-label={`Relay ${relayLabel}`}
>
<span className="relay__pip" aria-hidden="true" />
<span>Relay</span>
<span className="relay__label mono">{relayLabel}</span>
</div>
<div className="topbar__spacer" />
<div className="topbar__user">
<div className="topbar__id">
<span className="topbar__username">{user?.username}</span>
</div>
<Badge tone={roleTone(user?.role)}>{user?.role ?? "—"}</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => void logout()}
aria-label="Log out"
>
<LogoutIcon width={15} height={15} />
Logout
</Button>
</div>
</header>
);
}

View File

@@ -0,0 +1,172 @@
// Inline stroke icons (no icon-library dependency). 18px on a 24 viewBox.
import type { SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement>;
function base(props: IconProps) {
return {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
...props,
};
}
export function MachinesIcon(props: IconProps) {
return (
<svg {...base(props)}>
<rect x="2" y="4" width="20" height="13" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
);
}
export function SessionsIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M4 6h16M4 12h16M4 18h10" />
</svg>
);
}
export function CodesIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M8 6 3 12l5 6M16 6l5 6-5 6M13 4l-2 16" />
</svg>
);
}
export function UsersIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11" />
</svg>
);
}
export function LogoutIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" />
</svg>
);
}
export function KeyIcon(props: IconProps) {
return (
<svg {...base(props)}>
<circle cx="7.5" cy="15.5" r="4.5" />
<path d="m10.7 12.3 8.3-8.3M16 6l3 3M14 8l2 2" />
</svg>
);
}
export function TrashIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
</svg>
);
}
export function InfoIcon(props: IconProps) {
return (
<svg {...base(props)}>
<circle cx="12" cy="12" r="9" />
<path d="M12 16v-4M12 8h.01" />
</svg>
);
}
export function SearchIcon(props: IconProps) {
return (
<svg {...base(props)}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
);
}
export function RefreshIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M21 12a9 9 0 1 1-3-6.7L21 8M21 3v5h-5" />
</svg>
);
}
export function CopyIcon(props: IconProps) {
return (
<svg {...base(props)}>
<rect x="9" y="9" width="12" height="12" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);
}
export function JoinIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" />
</svg>
);
}
export function StopIcon(props: IconProps) {
return (
<svg {...base(props)}>
<rect x="5" y="5" width="14" height="14" rx="2" />
</svg>
);
}
export function PlusIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M12 5v14M5 12h14" />
</svg>
);
}
export function EditIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
);
}
export function EyeIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export function EyeOffIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M9.9 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 7 10 7a18 18 0 0 1-2.16 3.19M6.6 6.6A18 18 0 0 0 2 11s3.5 7 10 7a9 9 0 0 0 5.4-1.6" />
<path d="m9.5 9.5a3 3 0 0 0 4.2 4.2M3 3l18 18" />
</svg>
);
}
export function ShuffleIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5" />
</svg>
);
}

View File

@@ -0,0 +1,215 @@
/* ============================================================= App shell === */
.shell {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: var(--topbar-h) 1fr;
grid-template-areas:
"sidebar topbar"
"sidebar main";
height: 100vh;
overflow: hidden;
}
/* ================================================================ Sidebar === */
.sidebar {
grid-area: sidebar;
background: var(--panel-2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
}
.sidebar__brand {
display: flex;
align-items: center;
gap: 10px;
height: var(--topbar-h);
padding: 0 16px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.sidebar__logo {
width: 26px;
height: 26px;
border-radius: 6px;
background: linear-gradient(135deg, var(--accent), var(--accent-press));
display: grid;
place-items: center;
color: var(--accent-ink);
font-weight: 800;
font-size: 14px;
flex: 0 0 auto;
}
.sidebar__name {
font-weight: 700;
font-size: 15px;
letter-spacing: 0.01em;
}
.sidebar__name small {
display: block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
}
.sidebar__nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 10px;
overflow-y: auto;
}
.sidebar__section {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
padding: 14px 10px 6px;
}
.navlink {
display: flex;
align-items: center;
gap: 11px;
height: 38px;
padding: 0 11px;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
border: 1px solid transparent;
}
.navlink:hover {
background: var(--panel);
color: var(--text);
}
.navlink--active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent-ring);
}
.navlink--disabled {
color: var(--text-faint);
cursor: not-allowed;
pointer-events: none;
}
.navlink__icon {
flex: 0 0 auto;
width: 18px;
height: 18px;
display: grid;
place-items: center;
}
.navlink__soon {
margin-left: auto;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
border: 1px solid var(--border);
border-radius: 999px;
padding: 1px 6px;
}
/* ================================================================= Topbar === */
.topbar {
grid-area: topbar;
display: flex;
align-items: center;
gap: 16px;
padding: 0 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.topbar__spacer {
flex: 1;
}
.relay {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel-2);
}
.relay__pip {
width: 7px;
height: 7px;
border-radius: 50%;
}
.relay--live .relay__pip {
background: var(--ok);
box-shadow: 0 0 8px var(--ok);
animation: gc-live 1.8s var(--ease) infinite;
}
.relay--down .relay__pip {
background: var(--bad);
}
.relay__label.mono {
font-size: 11px;
}
.topbar__user {
display: flex;
align-items: center;
gap: 10px;
}
.topbar__id {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: 1.2;
}
.topbar__username {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
/* ================================================================== Main === */
.main {
grid-area: main;
overflow-y: auto;
min-height: 0;
}
.page {
padding: 22px 24px 40px;
max-width: 1320px;
margin: 0 auto;
}
.page__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.page__titles h1 {
font-size: 22px;
font-weight: 700;
margin: 0;
letter-spacing: -0.015em;
}
.page__subtitle {
color: var(--text-muted);
font-size: 13px;
margin-top: 3px;
}
.page__actions {
display: flex;
align-items: center;
gap: 10px;
}
.auth-gate {
display: grid;
place-items: center;
height: 100vh;
}

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import type { StatusTone } from "./status";
import { StatusDot } from "./StatusDot";
type BadgeTone = StatusTone | "accent";
interface BadgeProps {
tone?: BadgeTone;
/** Render a leading status dot inside the badge. */
dot?: boolean;
children: ReactNode;
}
/**
* A pill label using the status vocabulary. With `dot`, pairs the label with a
* matching StatusDot so the dot+label convention reads consistently.
*/
export function Badge({ tone = "neutral", dot = false, children }: BadgeProps) {
return (
<span className={`badge badge--${tone}`}>
{dot && tone !== "accent" && <StatusDot tone={tone} />}
{children}
</span>
);
}

View File

@@ -0,0 +1,53 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
type Variant = "primary" | "ghost" | "danger";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
/** Compact 28px height for table-row actions and tight toolbars. */
size?: "sm" | "md";
/** Stretch to fill the container width (e.g. login submit). */
block?: boolean;
/** Show a spinner and disable while an async action is in flight. */
loading?: boolean;
children: ReactNode;
}
/**
* The one button. Variants map to the design language:
* - primary: accent-solid, the single high-signal action per surface
* - ghost: bordered, secondary
* - danger: destructive (delete machine, revoke key)
*/
export function Button({
variant = "ghost",
size = "md",
block = false,
loading = false,
disabled,
className,
children,
...rest
}: ButtonProps) {
const classes = [
"btn",
`btn--${variant}`,
size === "sm" && "btn--sm",
block && "btn--block",
className,
]
.filter(Boolean)
.join(" ");
return (
<button
className={classes}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...rest}
>
{loading && <span className="btn__spin" aria-hidden="true" />}
{children}
</button>
);
}

View File

@@ -0,0 +1,55 @@
import type { ReactNode } from "react";
import { Button } from "./Button";
import { Modal } from "./Modal";
interface ConfirmDialogProps {
open: boolean;
title: string;
body: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
/** Style the confirm button as destructive. */
danger?: boolean;
/** Disable controls + spin the confirm button while the action runs. */
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
/** Small yes/no confirmation built on Modal. */
export function ConfirmDialog({
open,
title,
body,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
danger = false,
busy = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
return (
<Modal
open={open}
title={title}
onClose={busy ? () => {} : onCancel}
dismissable={!busy}
footer={
<>
<Button variant="ghost" onClick={onCancel} disabled={busy}>
{cancelLabel}
</Button>
<Button
variant={danger ? "danger" : "primary"}
onClick={onConfirm}
loading={busy}
>
{confirmLabel}
</Button>
</>
}
>
{body}
</Modal>
);
}

View File

@@ -0,0 +1,150 @@
import { useEffect, useId, useRef } from "react";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import {
hasOpenDialog,
isTopDialog,
popDialog,
pushDialog,
} from "./dialogStack";
interface DrawerProps {
open: boolean;
title: ReactNode;
/** Accessible name when `title` is not plain text. */
ariaLabel?: string;
/** Optional secondary line under the title (status, id). */
subtitle?: ReactNode;
onClose: () => void;
/** Sticky footer slot for actions. */
footer?: ReactNode;
children: ReactNode;
}
const FOCUSABLE =
'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])';
/**
* Right-anchored side panel for read and inspect flows (machine detail and
* history) where a modal would over-interrupt. Shares the dialog a11y contract:
* Tab focus is trapped, the rest of the page is inert, Escape closes, and focus
* returns to the trigger on close.
*/
export function Drawer({
open,
title,
ariaLabel,
subtitle,
onClose,
footer,
children,
}: DrawerProps) {
const panelRef = useRef<HTMLDivElement>(null);
const lastFocused = useRef<HTMLElement | null>(null);
const titleId = useId();
useEffect(() => {
if (!open) return;
const panel = panelRef.current;
lastFocused.current = document.activeElement as HTMLElement | null;
const token = pushDialog();
const root = document.getElementById("root");
root?.setAttribute("inert", "");
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
(first ?? panel)?.focus();
function onKey(e: KeyboardEvent) {
if (!isTopDialog(token)) return;
if (e.key === "Escape") {
e.stopPropagation();
onClose();
return;
}
if (e.key !== "Tab" || !panel) return;
const items = Array.from(
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
).filter((el) => el.offsetParent !== null || el === document.activeElement);
if (items.length === 0) {
e.preventDefault();
panel.focus();
return;
}
const firstEl = items[0];
const lastEl = items[items.length - 1];
const active = document.activeElement as HTMLElement;
if (e.shiftKey && (active === firstEl || active === panel)) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && active === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
document.addEventListener("keydown", onKey, true);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey, true);
document.body.style.overflow = prevOverflow;
popDialog(token);
if (!hasOpenDialog()) root?.removeAttribute("inert");
lastFocused.current?.focus?.();
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
className="drawer__scrim"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<aside
ref={panelRef}
className="drawer"
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : titleId}
tabIndex={-1}
>
<header className="drawer__head">
<div className="drawer__titles">
<h2 className="drawer__title" id={titleId}>
{title}
</h2>
{subtitle && <div className="drawer__subtitle">{subtitle}</div>}
</div>
<button
type="button"
className="iconbtn"
onClick={onClose}
aria-label="Close panel"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</header>
<div className="drawer__body">{children}</div>
{footer && <footer className="drawer__footer">{footer}</footer>}
</aside>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,36 @@
import { forwardRef } from "react";
import type { InputHTMLAttributes, ReactNode } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
/** Render technical/data values in JetBrains Mono. */
mono?: boolean;
}
/** Bare styled text input. Compose with <Field> for a labeled control. */
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ mono, className, ...rest },
ref,
) {
const classes = ["input", mono && "input--mono", className]
.filter(Boolean)
.join(" ");
return <input ref={ref} className={classes} {...rest} />;
});
interface FieldProps {
label: string;
htmlFor: string;
children: ReactNode;
}
/** Label + control wrapper for forms. */
export function Field({ label, htmlFor, children }: FieldProps) {
return (
<div className="field">
<label className="field__label" htmlFor={htmlFor}>
{label}
</label>
{children}
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { useEffect, useId, useRef } from "react";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import {
hasOpenDialog,
isTopDialog,
popDialog,
pushDialog,
} from "./dialogStack";
interface ModalProps {
open: boolean;
title: ReactNode;
/** Accessible name for the dialog when `title` is not plain text. */
ariaLabel?: string;
onClose: () => void;
/** Footer slot, typically the action buttons. */
footer?: ReactNode;
/** Wider layout for content-heavy dialogs (key management). */
wide?: boolean;
/** Disable overlay-click and Escape dismissal (e.g. during a pending action). */
dismissable?: boolean;
children: ReactNode;
}
const FOCUSABLE =
'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])';
/**
* Accessible modal dialog. Closes on Escape and overlay click (unless
* `dismissable` is false), traps Tab focus inside, marks the rest of the page
* inert, and restores focus to the trigger on close.
*/
export function Modal({
open,
title,
ariaLabel,
onClose,
footer,
wide,
dismissable = true,
children,
}: ModalProps) {
const panelRef = useRef<HTMLDivElement>(null);
const lastFocused = useRef<HTMLElement | null>(null);
const titleId = useId();
useEffect(() => {
if (!open) return;
const panel = panelRef.current;
lastFocused.current = document.activeElement as HTMLElement | null;
const token = pushDialog();
// Mark everything outside the dialog inert so focus and clicks can't reach
// the page behind. Dialogs are portaled to <body>, so this targets #root.
const root = document.getElementById("root");
root?.setAttribute("inert", "");
// Move focus to the first focusable control, falling back to the panel.
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
(first ?? panel)?.focus();
function onKey(e: KeyboardEvent) {
// Only the topmost dialog reacts (don't close a stack all at once).
if (!isTopDialog(token)) return;
if (e.key === "Escape" && dismissable) {
e.stopPropagation();
onClose();
return;
}
if (e.key !== "Tab" || !panel) return;
// Cycle focus within the dialog.
const items = Array.from(
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
).filter((el) => el.offsetParent !== null || el === document.activeElement);
if (items.length === 0) {
e.preventDefault();
panel.focus();
return;
}
const firstEl = items[0];
const lastEl = items[items.length - 1];
const active = document.activeElement as HTMLElement;
if (e.shiftKey && (active === firstEl || active === panel)) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && active === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
document.addEventListener("keydown", onKey, true);
// Lock background scroll while the dialog is open.
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey, true);
document.body.style.overflow = prevOverflow;
popDialog(token);
// Only lift inert once the last dialog has closed.
if (!hasOpenDialog()) root?.removeAttribute("inert");
lastFocused.current?.focus?.();
};
}, [open, dismissable, onClose]);
if (!open) return null;
const labelledBy = ariaLabel ? undefined : titleId;
return createPortal(
<div
className="modal__overlay"
onMouseDown={(e) => {
if (e.target === e.currentTarget && dismissable) onClose();
}}
>
<div
ref={panelRef}
className={`modal${wide ? " modal--wide" : ""}`}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={labelledBy}
tabIndex={-1}
>
<header className="modal__head">
<h2 className="modal__title" id={titleId}>
{title}
</h2>
{dismissable && (
<button
type="button"
className="iconbtn"
onClick={onClose}
aria-label="Close dialog"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
)}
</header>
<div className="modal__body">{children}</div>
{footer && <footer className="modal__footer">{footer}</footer>}
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
interface PanelProps {
/** Optional header title. When omitted, no header bar is rendered. */
title?: ReactNode;
/** Optional right-aligned header slot (actions, counts). */
actions?: ReactNode;
/** Remove default body padding (e.g. when embedding a flush table). */
flush?: boolean;
className?: string;
children: ReactNode;
}
/** A bordered surface card. The base building block for content panels. */
export function Panel({ title, actions, flush, className, children }: PanelProps) {
return (
<section className={["panel", className].filter(Boolean).join(" ")}>
{(title || actions) && (
<header className="panel__header">
{title ? <h2 className="panel__title">{title}</h2> : <span />}
{actions}
</header>
)}
<div className={flush ? undefined : "panel__body"}>{children}</div>
</section>
);
}

View File

@@ -0,0 +1,14 @@
interface SpinnerProps {
/** Optional caption rendered under the ring. */
label?: string;
}
/** Indeterminate loading ring with an optional label. */
export function Spinner({ label }: SpinnerProps) {
return (
<div className="spinner" role="status" aria-live="polite">
<span className="spinner__ring" aria-hidden="true" />
{label && <span>{label}</span>}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
interface StateProps {
title: string;
message?: ReactNode;
/** Optional action (e.g. a retry button). */
action?: ReactNode;
}
/** Neutral "nothing here" placeholder. */
export function EmptyState({ title, message, action }: StateProps) {
return (
<div className="state">
<div className="state__title">{title}</div>
{message && <div className="state__msg">{message}</div>}
{action}
</div>
);
}
/** Error placeholder — surfaces a failure instead of silently empty. */
export function ErrorState({ title, message, action }: StateProps) {
return (
<div className="state state--error" role="alert">
<div className="state__title">{title}</div>
{message && <div className="state__msg">{message}</div>}
{action}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import type { StatusTone } from "./status";
interface StatusDotProps {
tone: StatusTone;
/** Accessible label describing what the dot represents. */
label?: string;
}
/**
* A small colored status dot. `warn` pulses (consent-pending language). When a
* `label` is given it is an accessible image; without one (e.g. paired with a
* visible label inside a Badge) it is decorative and hidden from assistive tech.
*/
export function StatusDot({ tone, label }: StatusDotProps) {
return (
<span
className={`statusdot statusdot--${tone}`}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
title={label}
/>
);
}

View File

@@ -0,0 +1,95 @@
import type { ReactNode } from "react";
import "./table.css";
export interface Column<T> {
/** Unique column key. */
key: string;
/** Header label. Omit for the status / actions rails. */
header?: ReactNode;
/** Cell renderer. */
render: (row: T) => ReactNode;
/** Extra class on the <td> (e.g. dt__status, dt__actions). */
cellClass?: string;
}
interface TableProps<T> {
columns: Column<T>[];
rows: T[];
rowKey: (row: T) => string;
/** Optional per-row activation (opens detail). Bound to click, Enter, Space. */
onRowClick?: (row: T) => void;
/** Accessible label for the row's primary activation, e.g. the hostname. */
rowLabel?: (row: T) => string;
/** Cap the staggered fade-in so large lists don't crawl in. */
maxStaggerRows?: number;
}
/**
* Dense, console-style data table. Sticky header, hover highlight, hover-
* revealed row actions, and a staggered fade-in on mount (capped so big lists
* appear promptly). Column-driven so callers compose cells declaratively.
*/
export function Table<T>({
columns,
rows,
rowKey,
onRowClick,
rowLabel,
maxStaggerRows = 14,
}: TableProps<T>) {
return (
<div className="dt-wrap">
<table className="dt">
<thead>
<tr>
{columns.map((c) => (
<th key={c.key} className={c.cellClass}>
{c.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const delay = i < maxStaggerRows ? `${i * 22}ms` : "0ms";
return (
<tr
key={rowKey(row)}
style={{
animationDelay: delay,
cursor: onRowClick ? "pointer" : undefined,
}}
onClick={onRowClick ? () => onRowClick(row) : undefined}
tabIndex={onRowClick ? 0 : undefined}
aria-label={
onRowClick && rowLabel
? `Open detail for ${rowLabel(row)}`
: undefined
}
onKeyDown={
onRowClick
? (e) => {
// Activate on Enter or Space, the standard for a
// button-like row. Space must not scroll the page.
if (e.key === "Enter" || e.key === " ") {
if (e.target !== e.currentTarget) return;
e.preventDefault();
onRowClick(row);
}
}
: undefined
}
>
{columns.map((c) => (
<td key={c.key} className={c.cellClass}>
{c.render(row)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,54 @@
interface TableSkeletonProps {
/** Header labels, rendered in the sticky head so columns line up. */
headers: string[];
/** Number of placeholder rows. */
rows?: number;
/** Per-column placeholder bar widths (CSS lengths). Falls back to a default. */
widths?: string[];
}
/**
* Skeleton table that mirrors the real table's layout while data loads. Shows
* the column structure so the page does not jump when rows arrive, and reads as
* progress without a blocking spinner.
*/
export function TableSkeleton({
headers,
rows = 8,
widths = [],
}: TableSkeletonProps) {
const colWidths =
widths.length === headers.length
? widths
: headers.map((_, i) => (i === 0 ? "8px" : `${60 + ((i * 23) % 40)}%`));
return (
<div className="dt-wrap" aria-hidden="true">
<table className="dt">
<thead>
<tr>
{headers.map((h, i) => (
<th key={i} className={i === 0 ? "dt__status" : undefined}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, r) => (
<tr key={r}>
{headers.map((_, c) => (
<td key={c} className={c === 0 ? "dt__status" : undefined}>
<span
className={`dt__skel${c === 0 ? " dt__skel--dot" : ""}`}
style={c === 0 ? undefined : { width: colWidths[c] }}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,28 @@
// A tiny module-level stack so only the topmost open dialog (Modal or Drawer)
// reacts to Escape and owns the background `inert` toggle. This keeps stacked
// dialogs (e.g. a confirm on top of a management modal) from all closing at once.
const stack: symbol[] = [];
/** Push a dialog onto the stack. Returns its token. */
export function pushDialog(): symbol {
const token = Symbol("dialog");
stack.push(token);
return token;
}
/** Remove a dialog from the stack by token. */
export function popDialog(token: symbol): void {
const i = stack.lastIndexOf(token);
if (i !== -1) stack.splice(i, 1);
}
/** True when `token` is the topmost open dialog. */
export function isTopDialog(token: symbol): boolean {
return stack.length > 0 && stack[stack.length - 1] === token;
}
/** True when any dialog is open. */
export function hasOpenDialog(): boolean {
return stack.length > 0;
}

View File

@@ -0,0 +1,137 @@
// Central status-language mapping. Every status indicator in the app resolves
// through here so the dot color + label vocabulary stays consistent:
// ok = online / granted / success -> --ok (green)
// warn = pending (gets the consent pulse) -> --warn (amber)
// bad = denied / offline / error -> --bad (red)
// neutral = not_required / unknown -> --neutral (slate)
export type StatusTone = "ok" | "warn" | "bad" | "neutral";
/** Badge tones available to features (StatusTone plus the brand `accent`). */
export type BadgeTone = StatusTone | "accent";
/**
* Map a user role to a badge tone. `admin` is the elevated, distinct tone and
* gets the brand `accent` so it reads as "privileged" at a glance; `operator`
* is a normal active role (`ok`); `viewer` is the least-privileged, muted
* (`neutral`). An unknown role falls back to `neutral`.
*/
export function roleTone(role: string): BadgeTone {
switch (role) {
case "admin":
return "accent";
case "operator":
return "ok";
case "viewer":
default:
return "neutral";
}
}
/** Title-case label for a role; passes unknown roles through verbatim. */
export function roleLabel(role: string): string {
switch (role) {
case "admin":
return "Admin";
case "operator":
return "Operator";
case "viewer":
return "Viewer";
default:
return role;
}
}
/**
* Map a user's enabled flag to a status tone. An enabled account is healthy
* (`ok`); a disabled one is a deliberate block and reads as `bad` so it stands
* out in the table (a disabled user is an exception worth seeing).
*/
export function userStatusTone(enabled: boolean): StatusTone {
return enabled ? "ok" : "bad";
}
/** Human label for a user's enabled flag. */
export function userStatusLabel(enabled: boolean): string {
return enabled ? "Active" : "Disabled";
}
/** Map a machine `status` string to a tone. */
export function machineTone(status: string): StatusTone {
return status === "online" ? "ok" : "bad";
}
/** Map an attended-consent state to a tone. `pending` pulses. */
export function consentTone(state: string): StatusTone {
switch (state) {
case "granted":
return "ok";
case "pending":
return "warn";
case "denied":
return "bad";
case "not_required":
default:
return "neutral";
}
}
/**
* Human label for an attended-consent state. Kept here next to `consentTone`
* so the color and the words for a given state never drift apart. `pending` is
* phrased as the active wait it represents (a tech is blocked on it).
*/
export function consentLabel(state: string): string {
switch (state) {
case "granted":
return "Granted";
case "pending":
return "Awaiting consent";
case "denied":
return "Denied";
case "not_required":
return "Not required";
default:
return state;
}
}
/**
* Map a support-code lifecycle status to a tone. `pending` is the live,
* waiting-to-be-redeemed state and gets the same `warn` pulse the
* awaiting-consent state uses — it reads as "active, watch this". A redeemed
* (`connected`) code is a positive terminal-for-the-tech outcome -> `ok`.
* `completed`/`cancelled` are spent and read as muted `neutral`.
*/
export function codeTone(status: string): StatusTone {
switch (status) {
case "pending":
return "warn";
case "connected":
return "ok";
case "completed":
case "cancelled":
default:
return "neutral";
}
}
/**
* Human label for a support-code status. Next to `codeTone` so wording and
* color never drift. `pending` is phrased as the active wait (the tech is
* watching for the end user to redeem it).
*/
export function codeLabel(status: string): string {
switch (status) {
case "pending":
return "Awaiting redeem";
case "connected":
return "Redeemed";
case "completed":
return "Completed";
case "cancelled":
return "Cancelled";
default:
return status;
}
}

View File

@@ -0,0 +1,196 @@
/* ============================================================ Data table === */
.dt {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.dt thead th {
position: sticky;
top: 0;
z-index: 2;
background: var(--panel-2);
text-align: left;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-faint);
padding: 0 14px;
height: 36px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.dt tbody td {
padding: 0 14px;
height: var(--row-h);
border-bottom: 1px solid var(--border);
color: var(--text);
vertical-align: middle;
white-space: nowrap;
}
.dt tbody tr {
transition: background var(--dur-fast) var(--ease);
animation: gc-row-in var(--dur) var(--ease) both;
}
.dt tbody tr:hover {
background: var(--panel-2);
}
.dt tbody tr:hover .dt__rowactions,
.dt tbody tr:focus-within .dt__rowactions {
opacity: 1;
}
/* Keyboard focus on the row itself reads as a clear inset ring. */
.dt tbody tr:focus-visible {
outline: none;
background: var(--panel-2);
box-shadow: inset 0 0 0 1px var(--accent-ring);
}
/* Status-dot column — fixed narrow left rail. */
.dt__status {
width: 30px;
padding-left: 16px !important;
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);
font-size: 12px;
color: var(--text-muted);
}
.dt__strong {
font-weight: 600;
color: var(--text);
}
.dt__muted {
color: var(--text-muted);
}
/* Right-aligned row actions. Dimmed at rest, full on row hover/focus, but
always present and reachable by keyboard and touch (never pointer-events:none,
which would hide them from Tab and tap). */
.dt__actions {
width: 1%;
text-align: right;
}
.dt__rowactions {
display: inline-flex;
gap: 6px;
justify-content: flex-end;
opacity: 0.5;
transition: opacity var(--dur-fast) var(--ease);
}
/* When any action button is keyboard-focused, surface the whole group. */
.dt__rowactions:focus-within {
opacity: 1;
}
@media (hover: none) {
/* Touch devices have no hover: keep actions fully legible at all times. */
.dt__rowactions {
opacity: 1;
}
}
.dt-wrap {
max-height: calc(100vh - 230px);
overflow: auto;
}
/* Skeleton loading rows: preview the table shape instead of a bare spinner. */
.dt__skel {
display: inline-block;
height: 10px;
border-radius: 999px;
background:
linear-gradient(
90deg,
var(--border) 0%,
var(--border-strong) 50%,
var(--border) 100%
);
background-size: 200% 100%;
animation: gc-shimmer 1.4s var(--ease) infinite;
vertical-align: middle;
}
.dt__skel--dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* Search / toolbar above the table. */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.searchbox {
position: relative;
flex: 0 0 320px;
max-width: 100%;
}
.searchbox__icon {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: var(--text-faint);
pointer-events: none;
}
.searchbox .input {
width: 100%;
padding-left: 34px;
}
.toolbar__count {
margin-left: auto;
font-size: 12px;
color: var(--text-muted);
}
.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,25 @@
import { createContext, useContext } from "react";
export type ToastTone = "success" | "error" | "info";
export interface ToastItem {
id: number;
tone: ToastTone;
title: string;
message?: string;
}
export interface ToastApi {
success: (title: string, message?: string) => void;
error: (title: string, message?: string) => void;
info: (title: string, message?: string) => void;
}
export const ToastContext = createContext<ToastApi | null>(null);
/** Imperative toast notifications. Auto-dismiss after a few seconds. */
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within <ToastProvider>");
return ctx;
}

View File

@@ -0,0 +1,116 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import {
ToastContext,
type ToastApi,
type ToastItem,
type ToastTone,
} from "./toast-context";
const AUTO_DISMISS_MS = 4500;
/** Mounts the toast stack and provides the imperative toast API to descendants. */
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const nextId = useRef(1);
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const push = useCallback(
(tone: ToastTone, title: string, message?: string) => {
const id = nextId.current++;
setToasts((prev) => [...prev, { id, tone, title, message }]);
window.setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
},
[dismiss],
);
const api = useMemo<ToastApi>(
() => ({
success: (title, message) => push("success", title, message),
error: (title, message) => push("error", title, message),
info: (title, message) => push("info", title, message),
}),
[push],
);
return (
<ToastContext.Provider value={api}>
{children}
{/* Polite region for success/info; errors below are assertive. */}
<div className="toast-stack">
{toasts.map((t) => (
<div
key={t.id}
className={`toast toast--${t.tone}`}
role={t.tone === "error" ? "alert" : "status"}
aria-live={t.tone === "error" ? "assertive" : "polite"}
>
<span className={`toast__icon toast__icon--${t.tone}`} aria-hidden="true">
<ToastGlyph tone={t.tone} />
</span>
<div className="toast__body">
<div className="toast__title">{t.title}</div>
{t.message && <div className="toast__msg">{t.message}</div>}
</div>
<button
type="button"
className="iconbtn"
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
function ToastGlyph({ tone }: { tone: ToastTone }) {
const common = {
width: 16,
height: 16,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
if (tone === "success") {
return (
<svg {...common}>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
if (tone === "error") {
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v5M12 16h.01" />
</svg>
);
}
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 8h.01" />
</svg>
);
}

View File

@@ -0,0 +1,454 @@
/* ------------------------------------------------------------------ Button */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
height: 34px;
padding: 0 14px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
cursor: pointer;
white-space: nowrap;
transition:
background var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease),
opacity var(--dur-fast) var(--ease);
user-select: none;
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn--sm {
height: 28px;
padding: 0 10px;
font-size: 12px;
}
.btn--primary {
background: var(--accent);
color: var(--accent-ink);
}
.btn--primary:hover:not(:disabled) {
background: var(--accent-press);
}
.btn--ghost {
background: transparent;
border-color: var(--border-strong);
color: var(--text);
}
.btn--ghost:hover:not(:disabled) {
background: var(--panel);
border-color: var(--accent);
color: var(--accent);
}
.btn--danger {
background: transparent;
border-color: var(--bad-line);
color: var(--bad);
}
.btn--danger:hover:not(:disabled) {
background: var(--bad-soft);
border-color: var(--bad);
}
.btn--block {
width: 100%;
}
.btn__spin {
width: 13px;
height: 13px;
border-radius: 50%;
border: 2px solid currentColor;
border-top-color: transparent;
opacity: 0.85;
animation: gc-spin 0.7s linear infinite;
}
/* --------------------------------------------------------- Status dot/badge */
.statusdot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.statusdot--ok {
background: var(--ok);
box-shadow: 0 0 6px var(--ok-soft);
}
.statusdot--warn {
background: var(--warn);
animation: gc-pulse 1.6s var(--ease) infinite;
}
.statusdot--bad {
background: var(--bad);
}
.statusdot--neutral {
background: var(--neutral);
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
border: 1px solid var(--border);
color: var(--text-muted);
background: var(--panel-2);
}
.badge--ok {
color: var(--ok);
background: var(--ok-soft);
border-color: transparent;
}
.badge--warn {
color: var(--warn);
background: var(--warn-soft);
border-color: transparent;
}
.badge--bad {
color: var(--bad);
background: var(--bad-soft);
border-color: transparent;
}
.badge--neutral {
color: var(--text-muted);
background: var(--neutral-soft);
border-color: transparent;
}
.badge--accent {
color: var(--accent);
background: var(--accent-soft);
border-color: transparent;
}
/* ------------------------------------------------------------- Card / Panel */
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-1);
}
.panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.panel__title {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.005em;
color: var(--text);
margin: 0;
}
.panel__body {
padding: 16px;
}
/* ------------------------------------------------------------------ Spinner */
.spinner {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--text-muted);
font-size: 13px;
}
.spinner__ring {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--border-strong);
border-top-color: var(--accent);
animation: gc-spin 0.8s linear infinite;
}
@keyframes gc-spin {
to {
transform: rotate(360deg);
}
}
/* ----------------------------------------------------- Empty / Error states */
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 48px 24px;
text-align: center;
color: var(--text-muted);
}
.state__title {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.state__msg {
font-size: 13px;
max-width: 380px;
}
.state--error .state__title {
color: var(--bad);
}
/* --------------------------------------------------------------------- Modal */
/* Shared icon button (modal close, toast dismiss). 28px square hit target. */
.iconbtn {
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
flex: 0 0 auto;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition:
color var(--dur-fast) var(--ease),
background var(--dur-fast) var(--ease);
}
.iconbtn:hover {
color: var(--text);
background: var(--panel-2);
}
.iconbtn:focus-visible {
outline: none;
color: var(--text);
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring);
}
.modal__overlay {
position: fixed;
inset: 0;
background: oklch(15% 0.01 var(--brand-hue) / 0.66);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: var(--z-modal);
animation: gc-fade 120ms var(--ease);
}
@keyframes gc-fade {
from {
opacity: 0;
}
}
.modal {
width: 100%;
max-width: 460px;
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-pop);
animation: gc-pop 140ms var(--ease);
}
@keyframes gc-pop {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
.modal--wide {
max-width: 720px;
}
.modal__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
}
.modal__title {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
}
.modal__body {
padding: 18px;
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px;
border-top: 1px solid var(--border);
}
/* --------------------------------------------------------------------- Drawer */
.drawer__scrim {
position: fixed;
inset: 0;
background: oklch(15% 0.01 var(--brand-hue) / 0.5);
display: flex;
justify-content: flex-end;
z-index: var(--z-drawer);
animation: gc-fade 120ms var(--ease);
}
.drawer {
width: min(520px, 100%);
height: 100%;
display: flex;
flex-direction: column;
background: var(--panel);
border-left: 1px solid var(--border-strong);
box-shadow: var(--shadow-pop);
animation: gc-drawer-in var(--dur-panel) var(--ease-out);
}
.drawer__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.drawer__titles {
min-width: 0;
}
.drawer__title {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
display: flex;
align-items: center;
gap: 10px;
}
.drawer__subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--text-muted);
}
.drawer__body {
padding: 18px;
overflow-y: auto;
flex: 1 1 auto;
min-height: 0;
}
.drawer__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px;
border-top: 1px solid var(--border);
flex: 0 0 auto;
}
/* --------------------------------------------------------------------- Toast */
.toast-stack {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: var(--z-toast);
max-width: 360px;
}
.toast {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius);
background: var(--panel);
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-2);
font-size: 13px;
animation: gc-toast-in 180ms var(--ease);
}
@keyframes gc-toast-in {
from {
opacity: 0;
transform: translateX(12px);
}
}
.toast__icon {
display: grid;
place-items: center;
width: 26px;
height: 26px;
flex: 0 0 auto;
border-radius: 50%;
}
.toast__icon--success {
color: var(--ok);
background: var(--ok-soft);
}
.toast__icon--error {
color: var(--bad);
background: var(--bad-soft);
}
.toast__icon--info {
color: var(--accent);
background: var(--accent-soft);
}
.toast__body {
flex: 1;
color: var(--text);
}
.toast__title {
font-weight: 600;
margin-bottom: 2px;
}
.toast__msg {
color: var(--text-muted);
word-break: break-word;
}
/* --------------------------------------------------------------------- Input */
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field__label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.02em;
}
.input {
height: 36px;
padding: 0 12px;
background: var(--panel-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 14px;
font-family: inherit;
transition:
border-color var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease);
}
.input::placeholder {
color: var(--text-faint);
}
.input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input--mono {
font-family: var(--font-mono);
}

View File

@@ -0,0 +1,106 @@
import { useState } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
import { Button } from "../../components/ui/Button";
import { Field, Input } from "../../components/ui/Input";
import "./login.css";
interface LocationState {
from?: { pathname: string };
}
export function LoginPage() {
const { user, login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Already authenticated — bounce to the app.
if (user) return <Navigate to="/machines" replace />;
const from = (location.state as LocationState | null)?.from?.pathname ?? "/machines";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await login(username, password);
navigate(from, { replace: true });
} catch (err) {
if (err instanceof ApiError) {
setError(
err.status === 401
? "Invalid username or password."
: err.message,
);
} else {
setError("Could not sign in. Please try again.");
}
} finally {
setSubmitting(false);
}
}
return (
<div className="login">
<div className="login__scanlines" aria-hidden="true" />
<form className="login__card" onSubmit={handleSubmit}>
<div className="login__brand">
<span className="login__logo" aria-hidden="true">
GC
</span>
<div>
<div className="login__title">GuruConnect</div>
<div className="login__sub">Operator Console</div>
</div>
</div>
<Field label="Username" htmlFor="username">
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
required
/>
</Field>
<Field label="Password" htmlFor="password">
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Field>
{error && (
<div className="login__error" role="alert">
{error}
</div>
)}
<Button
type="submit"
variant="primary"
block
loading={submitting}
disabled={!username || !password}
>
Sign in
</Button>
<div className="login__foot mono">GuruConnect · Operator Console</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,91 @@
.login {
position: relative;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(
1100px 520px at 50% -10%,
oklch(78% 0.13 184 / 0.08),
transparent 60%
),
var(--bg);
overflow: hidden;
}
/* Faint console scanlines for control-room texture. */
.login__scanlines {
position: absolute;
inset: 0;
pointer-events: none;
background-image: repeating-linear-gradient(
to bottom,
oklch(93% 0.008 var(--brand-hue) / 0.016) 0px,
oklch(93% 0.008 var(--brand-hue) / 0.016) 1px,
transparent 1px,
transparent 3px
);
mask-image: radial-gradient(70% 60% at 50% 40%, black, transparent);
}
.login__card {
position: relative;
z-index: 1;
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 16px;
padding: 28px 26px 22px;
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-2);
}
.login__brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.login__logo {
width: 40px;
height: 40px;
border-radius: 9px;
background: linear-gradient(135deg, var(--accent), var(--accent-press));
display: grid;
place-items: center;
color: var(--accent-ink);
font-weight: 800;
font-size: 17px;
}
.login__title {
font-size: 19px;
font-weight: 700;
letter-spacing: -0.01em;
}
.login__sub {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
}
.login__error {
font-size: 13px;
color: var(--bad);
background: var(--bad-soft);
border: 1px solid var(--bad-line);
border-radius: var(--radius-sm);
padding: 9px 12px;
}
.login__foot {
text-align: center;
font-size: 11px;
color: var(--text-faint);
margin-top: 4px;
}

View File

@@ -0,0 +1,61 @@
import { ApiError } from "../../api/client";
import type { SupportCode } from "../../api/types";
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
import { useToast } from "../../components/ui/toast-context";
import { useCancelCode } from "./hooks";
interface CancelCodeDialogProps {
/** The code to cancel, or null when the dialog is closed. */
code: SupportCode | null;
onClose: () => void;
}
/**
* Confirm + cancel a support code. Cancelling is consequential: a code cannot
* be un-cancelled, and if it has not been redeemed yet the end user can no
* longer use it. We confirm first, then invalidate the list so the row drops.
*/
export function CancelCodeDialog({ code, onClose }: CancelCodeDialogProps) {
const toast = useToast();
const cancel = useCancelCode();
const open = code != null;
function onConfirm() {
if (!code) return;
cancel.mutate(code.code, {
onSuccess: () => {
toast.success("Code cancelled", `${code.code} can no longer be used.`);
onClose();
},
onError: (err) => {
toast.error(
"Could not cancel code",
err instanceof ApiError ? err.message : "The relay did not respond.",
);
},
});
}
return (
<ConfirmDialog
open={open}
title="Cancel this code?"
danger
busy={cancel.isPending}
confirmLabel="Cancel code"
cancelLabel="Keep it"
onConfirm={onConfirm}
onCancel={onClose}
body={
code ? (
<p>
This permanently revokes <strong className="mono">{code.code}</strong>.{" "}
{code.status === "connected"
? "An attended session is bound to it; cancelling ends that connection."
: "The end user will not be able to redeem it. This cannot be undone."}
</p>
) : null
}
/>
);
}

View File

@@ -0,0 +1,153 @@
import { useEffect, useRef, useState } from "react";
import { ApiError } from "../../api/client";
import type { SupportCode } from "../../api/types";
import { Button } from "../../components/ui/Button";
import { Modal } from "../../components/ui/Modal";
import { Spinner } from "../../components/ui/Spinner";
import { ErrorState } from "../../components/ui/States";
import { CopyIcon } from "../../components/layout/icons";
import { useClipboard } from "../../lib/useClipboard";
import { useGenerateCode } from "./hooks";
import "./codes.css";
interface GenerateCodeModalProps {
/** Whether the generate dialog is open. */
open: boolean;
/** Operator name to attribute the code to (server stamps `created_by`). */
technicianName?: string;
onClose: () => void;
}
/**
* Generate-a-code flow. Opening the dialog mints a fresh code immediately, then
* reveals it large in JetBrains Mono so the tech can read it to the end user
* over the phone. The code is the single high-signal element on this surface;
* everything else is secondary. There is no per-second countdown — the
* SupportCode the API returns has no `expires_at`, and a redeem/cancel surfaces
* through the table's poll, so a timer here would be both impossible to source
* accurately and a needless render storm.
*/
export function GenerateCodeModal({
open,
technicianName,
onClose,
}: GenerateCodeModalProps) {
const generate = useGenerateCode();
const { copied, copy } = useClipboard();
const [result, setResult] = useState<SupportCode | null>(null);
// Minting a code is a durable single-use side effect. Guard it behind a ref so
// StrictMode's mount->cleanup->mount double-invoke can't fire two real POSTs
// per open; re-arm on close so the next open mints fresh.
const mintedFor = useRef(false);
// Mint once when the dialog opens; reset on close so a re-open mints a fresh
// code. Minting in an effect (not on a button click) lets the dialog own the
// loading/error/success states cleanly, mirroring JoinSessionModal.
useEffect(() => {
if (!open) {
setResult(null);
generate.reset();
mintedFor.current = false; // re-arm for the next open
return;
}
if (mintedFor.current) return; // StrictMode remount: already minted
mintedFor.current = true;
let cancelled = false;
generate
.mutateAsync({ technician_name: technicianName })
.then((res) => {
if (!cancelled) setResult(res);
})
.catch(() => {
// Surfaced via generate.isError below.
});
return () => {
cancelled = true;
};
// Mint exactly once per open. The mutation object is stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
if (!open) return null;
return (
<Modal
open={open}
title="Support code"
onClose={onClose}
footer={
<Button variant="primary" onClick={onClose}>
Done
</Button>
}
>
{generate.isPending && !result ? (
<div className="codegen__loading">
<Spinner label="Generating code…" />
</div>
) : generate.isError ? (
<ErrorState
title="Could not generate a code"
message={
generate.error instanceof ApiError
? generate.error.message
: "The relay did not return a code. Check the relay status, then try again."
}
action={
<Button
variant="primary"
onClick={() =>
void generate
.mutateAsync({ technician_name: technicianName })
.then(setResult)
.catch(() => {})
}
>
Try again
</Button>
}
/>
) : result ? (
<>
<p className="codegen__lede">
Read this code to the end user. It starts an attended support session
and can be used once.
</p>
<div className="codegen__codewrap">
<output className="codegen__code" aria-label={`Support code ${spell(result.code)}`}>
{result.code}
</output>
<Button
variant="ghost"
size="sm"
onClick={() => void copy(result.code)}
aria-label={copied ? "Code copied to clipboard" : "Copy code to clipboard"}
>
<CopyIcon width={14} height={14} />
{copied ? "Copied" : "Copy"}
</Button>
</div>
<p className="codegen__hint">
It stays active until the user redeems it or you cancel it. Once
redeemed it cannot be used again.
</p>
</>
) : null}
</Modal>
);
}
/**
* Spell a grouped code out for the screen-reader label so it is announced
* character by character ("K, 7, P, ...") instead of as a mangled word. The
* visible code stays the compact `XXX-XXX-XXX` form.
*/
function spell(code: string): string {
return code
.replace(/-/g, " ")
.split("")
.filter((c) => c !== " ")
.join(" ");
}

View File

@@ -0,0 +1,249 @@
import { useMemo, useState } from "react";
import { ApiError } from "../../api/client";
import type { SupportCode } from "../../api/types";
import { useAuth } from "../../auth/AuthContext";
import { PageHeader } from "../../components/layout/PageHeader";
import { PlusIcon, RefreshIcon, SearchIcon, TrashIcon } from "../../components/layout/icons";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
import { Input } from "../../components/ui/Input";
import { Panel } from "../../components/ui/Panel";
import { EmptyState, ErrorState } from "../../components/ui/States";
import { codeLabel, codeTone } from "../../components/ui/status";
import { Table, type Column } from "../../components/ui/Table";
import { TableSkeleton } from "../../components/ui/TableSkeleton";
import { absoluteTime, relativeTime } from "../../lib/time";
import { CancelCodeDialog } from "./CancelCodeDialog";
import { GenerateCodeModal } from "./GenerateCodeModal";
import { useSupportCodes } from "./hooks";
import "./codes.css";
/** A code is still cancellable only while it is pending or connected. */
function canCancel(status: string): boolean {
return status === "pending" || status === "connected";
}
export function SupportCodesPage() {
const { user } = useAuth();
const codesQuery = useSupportCodes();
const [filter, setFilter] = useState("");
const [generating, setGenerating] = useState(false);
const [cancelFor, setCancelFor] = useState<SupportCode | null>(null);
const { data } = codesQuery;
const codes = useMemo(() => data ?? [], [data]);
// Newest first: the in-memory map the server returns has no guaranteed order,
// and the code a tech just generated should be at the top where they expect
// it.
const sorted = useMemo(
() =>
[...codes].sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
),
[codes],
);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return sorted;
return sorted.filter(
(c) =>
c.code.toLowerCase().includes(q) ||
c.created_by.toLowerCase().includes(q) ||
(c.client_machine?.toLowerCase().includes(q) ?? false),
);
}, [sorted, filter]);
const pendingCount = useMemo(
() => codes.filter((c) => c.status === "pending").length,
[codes],
);
const columns: Column<SupportCode>[] = [
{
key: "code",
header: "Code",
render: (c) => <span className="cdt__code">{c.code}</span>,
},
{
key: "status",
header: "Status",
render: (c) => (
<Badge tone={codeTone(c.status)} dot>
{codeLabel(c.status)}
</Badge>
),
},
{
key: "bound",
header: "Bound to",
render: (c) =>
c.client_machine || c.client_name ? (
<div className="cdt__bound">
<span className="dt__strong">
{c.client_machine ?? c.client_name}
</span>
{c.client_machine && c.client_name && (
<span className="cdt__boundsub">{c.client_name}</span>
)}
</div>
) : (
<span className="dt__muted">Not redeemed</span>
),
},
{
key: "created_by",
header: "Created by",
render: (c) => <span className="dt__strong">{c.created_by}</span>,
},
{
key: "created",
header: "Created",
render: (c) => (
<span className="dt__mono" title={absoluteTime(c.created_at)}>
{relativeTime(c.created_at)}
</span>
),
},
{
key: "actions",
header: "",
cellClass: "dt__actions",
render: (c) => {
const cancellable = canCancel(c.status);
return (
<span className="dt__rowactions" onClick={(e) => e.stopPropagation()}>
<Button
variant="danger"
size="sm"
onClick={() => setCancelFor(c)}
disabled={!cancellable}
title={
cancellable
? undefined
: `${codeLabel(c.status)} codes cannot be cancelled`
}
aria-label={`Cancel code ${c.code}`}
>
<TrashIcon width={14} height={14} />
Cancel
</Button>
</span>
);
},
},
];
return (
<div className="page">
<PageHeader
title="Support codes"
subtitle="One-time codes for attended support. Generate a code, read it to the end user, and they redeem it to start a session."
actions={
<>
<Button
variant="ghost"
onClick={() => void codesQuery.refetch()}
loading={codesQuery.isFetching}
>
<RefreshIcon width={15} height={15} />
Refresh
</Button>
<Button variant="primary" onClick={() => setGenerating(true)}>
<PlusIcon width={15} height={15} />
Generate code
</Button>
</>
}
/>
<Panel flush>
<div style={{ padding: "14px 16px 0" }}>
<div className="toolbar">
<div className="searchbox">
<span className="searchbox__icon">
<SearchIcon width={15} height={15} />
</span>
<Input
placeholder="Filter by code, machine, or creator"
value={filter}
onChange={(e) => setFilter(e.target.value)}
aria-label="Filter support codes"
/>
</div>
<div className="toolbar__count">
<span className="mono">{pendingCount}</span> awaiting redeem ·{" "}
<span className="mono">{codes.length}</span> active
</div>
</div>
</div>
{codesQuery.isLoading ? (
<>
<span className="visually-hidden" role="status">
Loading support codes
</span>
<TableSkeleton
headers={[
"Code",
"Status",
"Bound to",
"Created by",
"Created",
"",
]}
/>
</>
) : codesQuery.isError ? (
<ErrorState
title="Could not load support codes"
message={
codesQuery.error instanceof ApiError
? codesQuery.error.message
: "The GuruConnect relay did not respond. Check the relay status, then retry."
}
action={
<Button variant="primary" onClick={() => void codesQuery.refetch()}>
Retry
</Button>
}
/>
) : filtered.length === 0 ? (
filter ? (
<EmptyState
title="No matching codes"
message={`Nothing matches "${filter}". Clear the filter to see every active code.`}
action={
<Button variant="ghost" onClick={() => setFilter("")}>
Clear filter
</Button>
}
/>
) : (
<EmptyState
title="No active codes"
message="Generate a code, read it to the end user over the phone, and they redeem it to start an attended session. Each code works once."
action={
<Button variant="primary" onClick={() => setGenerating(true)}>
<PlusIcon width={15} height={15} />
Generate code
</Button>
}
/>
)
) : (
<Table columns={columns} rows={filtered} rowKey={(c) => c.code} />
)}
</Panel>
<GenerateCodeModal
open={generating}
technicianName={user?.username}
onClose={() => setGenerating(false)}
/>
<CancelCodeDialog code={cancelFor} onClose={() => setCancelFor(null)} />
</div>
);
}

View File

@@ -0,0 +1,80 @@
/* ===================================================== Support codes table */
/* The code in the row: mono, accent, slightly larger than body so it reads as
the identifier it is. Tracks the table's mono idiom but with brand color. */
.cdt__code {
font-family: var(--font-mono);
font-feature-settings: "ss01", "zero";
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--accent);
white-space: nowrap;
}
/* Bound-to cell: machine over a dimmer client name, the same two-line idiom
the sessions table uses for machine/agent-id. */
.cdt__bound {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.cdt__boundsub {
font-size: 11px;
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
}
/* ===================================================== Generate-code dialog */
.codegen__loading {
display: flex;
justify-content: center;
padding: 32px 0;
}
.codegen__lede {
margin: 0 0 18px;
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
}
/* The hero: the code itself, large, mono, accent, with a copy button. This is
read aloud over the phone, so it is the single dominant element on the
surface and is sized for unmistakable legibility. */
.codegen__codewrap {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 22px 20px;
border-radius: var(--radius);
background: var(--accent-soft);
border: 1px solid var(--accent-ring);
}
.codegen__code {
font-family: var(--font-mono);
/* ss01 = stylistic alt; zero = slashed zero. The unambiguous alphabet has no
0, but the feature is harmless and keeps mono rendering consistent. */
font-feature-settings: "ss01", "zero";
font-size: clamp(28px, 7vw, 38px);
font-weight: 700;
letter-spacing: 0.06em;
line-height: 1.1;
color: var(--accent);
user-select: all;
/* Never wrap the grouped code across lines — it must read as one token. */
white-space: nowrap;
}
.codegen__hint {
margin: 16px 0 0;
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
}

View File

@@ -0,0 +1,55 @@
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import * as codesApi from "../../api/codes";
import type { CreateCodeRequest } from "../../api/types";
const CODES_KEY = ["codes"] as const;
/**
* List the active support codes. Polls on a short interval because codes are
* short-lived: a `pending` code can be redeemed (-> `connected`) or expire out
* of the active set at any moment, and a tech who just read a code aloud is
* watching for exactly that transition. The interval is tight (like the
* sessions poll) so the redeem shows up on its own without a manual refresh.
*/
export function useSupportCodes() {
return useQuery({
queryKey: CODES_KEY,
queryFn: ({ signal }) => codesApi.listCodes(signal),
refetchInterval: 7_000,
staleTime: 3_500,
});
}
/**
* Generate a new support code, then invalidate the list so the new `pending`
* code appears in the table. The created code is returned to the caller so the
* generate flow can surface it prominently (it is read to the end user).
*/
export function useGenerateCode() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreateCodeRequest) => codesApi.createCode(body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: CODES_KEY });
},
});
}
/**
* Cancel (revoke) a support code, then invalidate the list so the row drops out
* of the active set. Cancelling an un-redeemed code is irreversible, so the UI
* confirms first; this hook is the action behind that confirmation.
*/
export function useCancelCode() {
const qc = useQueryClient();
return useMutation({
mutationFn: (code: string) => codesApi.cancelCode(code),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: CODES_KEY });
},
});
}

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

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