Compare commits
43 Commits
c98692e424
...
fix/spec01
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eaabdd6a5 | |||
| 11af9dff8e | |||
| a0e0d5f1e7 | |||
| 7602b4346a | |||
| 55b9c97b28 | |||
| 94c07c2431 | |||
| 4c49b73a71 | |||
| 367906bd54 | |||
| 52477e4c4a | |||
| 87c6e17d4a | |||
| 6a000d012f | |||
| d0b8db070f | |||
| 89c3718266 | |||
| 4106fc4bc4 | |||
| 0f02f23765 | |||
| 59e40c8019 | |||
| c286a29b9d | |||
| 18429f6fe3 | |||
| 3b9e4068c9 | |||
| 87f229509b | |||
| 40c7d860cc | |||
| 0059b21db6 | |||
| f950511e3e | |||
| 16017456aa | |||
|
|
e967cce1a1 | ||
| 16586c4a1b | |||
| 96f9c0ab45 | |||
| 5ee6675337 | |||
| cef1928379 | |||
| 4e80573cbd | |||
| ffca7f0cee | |||
| 97780304e7 | |||
| afbf0d81b8 | |||
| b45c683a51 | |||
| 5637e4c1f9 | |||
| b3e8f32734 | |||
| 92bc522c3a | |||
| df51d40094 | |||
| 7be8f454e0 | |||
| 761bae5d01 | |||
| a062a825ea | |||
| b1862800a1 | |||
| 5e2325507f |
@@ -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" \
|
||||
|
||||
75
CHANGELOG.md
75
CHANGELOG.md
@@ -6,20 +6,66 @@ All notable changes to GuruConnect are documented here. Format follows
|
||||
Per-version entries below are generated from conventional commits (`feat:`, `fix:`, `perf:`)
|
||||
by the release workflow; per-component changelogs are also written to
|
||||
`changelogs/<component>/v<version>.md` and served at `/api/changelog/...`.
|
||||
## [0.3.0] - 2026-06-01
|
||||
|
||||
### Added
|
||||
|
||||
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
|
||||
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
|
||||
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
|
||||
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
|
||||
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
|
||||
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
|
||||
- GuruConnect v2 Users admin view (96b4fd77)
|
||||
- GuruConnect v2 Support Codes view (664f33d5)
|
||||
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
|
||||
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
|
||||
- GuruConnect v2 operator console (pass 1) (43a9432b)
|
||||
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
|
||||
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
|
||||
- V2 secure-session-core Task 5 - attended consent (9082e114)
|
||||
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
|
||||
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
|
||||
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
|
||||
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
|
||||
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Make native H.264 viewer render live frames (97780304)
|
||||
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
|
||||
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
|
||||
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
|
||||
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
|
||||
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
|
||||
- Clippy fixes for Task 4 (CI green) (21189423)
|
||||
|
||||
### Security
|
||||
|
||||
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
|
||||
|
||||
### Spec
|
||||
|
||||
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
|
||||
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
|
||||
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
|
||||
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
|
||||
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
|
||||
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
|
||||
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
|
||||
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
|
||||
- Add SPEC-009 feature-rich documented API (7ab87384)
|
||||
- Add SPEC-008 valuable error messages (65eff5cf)
|
||||
- Add SPEC-007 managed-agent installer builder (008d2bf3)
|
||||
- Add SPEC-006 universal machine search (0eb38520)
|
||||
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
|
||||
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
|
||||
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
|
||||
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
|
||||
- Add v2-secure-session-core shape spec (81e4b99a)
|
||||
|
||||
## [0.2.2] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- Drop broken jsign --info verify step in release (5727ccf3)
|
||||
|
||||
## [0.2.1] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- Use jsign 7.1 for Azure Trusted Signing (e7f38ce2)
|
||||
|
||||
## [0.2.0] - 2026-05-29
|
||||
|
||||
### Added
|
||||
|
||||
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
|
||||
@@ -28,6 +74,11 @@ by the release workflow; per-component changelogs are also written to
|
||||
|
||||
- Use Self:: for static method calls (cc35d111)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Drop broken jsign --info verify step in release (5727ccf3)
|
||||
- Use jsign 7.1 for Azure Trusted Signing (e7f38ce2)
|
||||
|
||||
### Security
|
||||
|
||||
- Require authentication for all WebSocket and API endpoints (4614df04)
|
||||
|
||||
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -1407,13 +1407,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guruconnect"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hostname",
|
||||
"image",
|
||||
"muda",
|
||||
@@ -1446,7 +1447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guruconnect-server"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -6,7 +6,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.2"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
authors = ["AZ Computer Guru"]
|
||||
license = "Proprietary"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "guruconnect"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
authors = ["AZ Computer Guru"]
|
||||
description = "GuruConnect Remote Desktop - Agent and Viewer"
|
||||
@@ -47,6 +47,7 @@ toml = "0.8"
|
||||
# Crypto
|
||||
ring = "0.17"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
# HTTP client for updates
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
@@ -91,6 +92,7 @@ windows = { version = "0.58", features = [
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Environment",
|
||||
"Win32_Security",
|
||||
"Win32_Security_Cryptography",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_SystemServices",
|
||||
|
||||
@@ -16,18 +16,39 @@ use uuid::Uuid;
|
||||
const MAGIC_MARKER: &[u8] = b"GURUCONFIG";
|
||||
|
||||
/// Embedded configuration data (appended to executable)
|
||||
///
|
||||
/// SPEC-016 Phase B: a managed-install config now carries the per-site
|
||||
/// `enrollment_key` + `site_code` so the agent can self-register on first run.
|
||||
/// The legacy `api_key` is retained (defaulted) for backward-compat with older
|
||||
/// pre-enrollment installers; a fresh site installer carries only the enrollment
|
||||
/// credentials and the agent obtains its per-machine `cak_` via `/api/enroll`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbeddedConfig {
|
||||
/// Server WebSocket URL
|
||||
pub server_url: String,
|
||||
/// API key for authentication
|
||||
pub api_key: String,
|
||||
/// DEPRECATED shared/legacy API key for authentication. Optional — a
|
||||
/// SPEC-016 site installer omits it and enrolls for a per-machine `cak_`.
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Per-site enrollment key (`cek_`), the low-sensitivity registration gate
|
||||
/// (SPEC-016 §Security). Presented to `/api/enroll`; never logged.
|
||||
#[serde(default)]
|
||||
pub enrollment_key: Option<String>,
|
||||
/// Per-site code identifying which site this installer enrolls into.
|
||||
#[serde(default)]
|
||||
pub site_code: Option<String>,
|
||||
/// Company/organization name
|
||||
#[serde(default)]
|
||||
pub company: Option<String>,
|
||||
/// Site/location name
|
||||
#[serde(default)]
|
||||
pub site: Option<String>,
|
||||
/// Department label (reserved — SPEC-007 AgentStatus parity).
|
||||
#[serde(default)]
|
||||
pub department: Option<String>,
|
||||
/// Device-type label (reserved — SPEC-007 AgentStatus parity).
|
||||
#[serde(default)]
|
||||
pub device_type: Option<String>,
|
||||
/// Tags for categorization
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
@@ -52,9 +73,28 @@ pub struct Config {
|
||||
/// Server WebSocket URL (e.g., wss://connect.example.com/ws)
|
||||
pub server_url: String,
|
||||
|
||||
/// Agent API key for authentication
|
||||
/// Operating credential used to authenticate the persistent WS connection.
|
||||
///
|
||||
/// SPEC-016 Phase B: the AUTHORITATIVE credential is a per-machine `cak_`
|
||||
/// obtained at first-run enrollment and stored encrypted at rest (see
|
||||
/// [`crate::credential_store`]); it is loaded into this field before connect.
|
||||
/// A non-empty value carried in config is the DEPRECATED shared/legacy
|
||||
/// `api_key`, kept only for transition compatibility. Empty means "not yet
|
||||
/// enrolled / no credential" — the run-mode wiring must enroll first.
|
||||
#[serde(default)]
|
||||
pub api_key: String,
|
||||
|
||||
/// Per-site enrollment key (`cek_`) — present only for a not-yet-enrolled
|
||||
/// managed install. Never persisted to the on-disk TOML (it is install-time
|
||||
/// material, delivered by the site wrapper); never logged.
|
||||
#[serde(skip)]
|
||||
pub enrollment_key: Option<String>,
|
||||
|
||||
/// Per-site code identifying which site to enroll into (paired with
|
||||
/// `enrollment_key`). Not persisted to the on-disk TOML.
|
||||
#[serde(skip)]
|
||||
pub site_code: Option<String>,
|
||||
|
||||
/// Unique agent identifier (generated on first run)
|
||||
#[serde(default = "generate_agent_id")]
|
||||
pub agent_id: String,
|
||||
@@ -70,6 +110,14 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub site: Option<String>,
|
||||
|
||||
/// Department label (reserved — SPEC-007 AgentStatus parity).
|
||||
#[serde(default)]
|
||||
pub department: Option<String>,
|
||||
|
||||
/// Device-type label (reserved — SPEC-007 AgentStatus parity).
|
||||
#[serde(default)]
|
||||
pub device_type: Option<String>,
|
||||
|
||||
/// Tags for categorization (from embedded config)
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
@@ -91,6 +139,25 @@ fn generate_agent_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Layer SPEC-016 enrollment material from the environment onto a `Config`.
|
||||
///
|
||||
/// `GURUCONNECT_ENROLLMENT_KEY` / `GURUCONNECT_SITE_CODE` only OVERRIDE when set
|
||||
/// and non-empty, so embedded/install-time values already present on the config
|
||||
/// are preserved. Used by the file and env load paths (the embedded path already
|
||||
/// carries these from the install blob).
|
||||
fn apply_enrollment_env(config: &mut Config) {
|
||||
if let Ok(v) = std::env::var("GURUCONNECT_ENROLLMENT_KEY") {
|
||||
if !v.is_empty() {
|
||||
config.enrollment_key = Some(v);
|
||||
}
|
||||
}
|
||||
if let Ok(v) = std::env::var("GURUCONNECT_SITE_CODE") {
|
||||
if !v.is_empty() {
|
||||
config.site_code = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CaptureConfig {
|
||||
/// Target frames per second (1-60)
|
||||
@@ -310,6 +377,26 @@ impl Config {
|
||||
false
|
||||
}
|
||||
|
||||
/// Best-effort read of a previously-persisted `agent_id` from the on-disk
|
||||
/// TOML at [`Self::config_path`].
|
||||
///
|
||||
/// The embedded blob never carries an `agent_id` (it is minted at first
|
||||
/// run), so for a managed agent the only stable source across restarts is
|
||||
/// the TOML that a prior run wrote via [`Self::save`]. Returns `Some(id)`
|
||||
/// only when the file exists, parses, and contains a non-empty `agent_id`;
|
||||
/// any missing-file / read / parse error yields `None` so the caller falls
|
||||
/// back to generating a fresh id.
|
||||
fn persisted_agent_id() -> Option<String> {
|
||||
let config_path = Self::config_path();
|
||||
let contents = std::fs::read_to_string(&config_path).ok()?;
|
||||
let parsed: Config = toml::from_str(&contents).ok()?;
|
||||
if parsed.agent_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parsed.agent_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration from embedded config, file, or environment
|
||||
pub fn load() -> Result<Self> {
|
||||
// Priority 1: Try loading from embedded config
|
||||
@@ -317,18 +404,33 @@ impl Config {
|
||||
info!("Using embedded configuration");
|
||||
let config = Config {
|
||||
server_url: embedded.server_url,
|
||||
api_key: embedded.api_key,
|
||||
agent_id: generate_agent_id(),
|
||||
// Legacy/shared api_key if the installer carried one; empty
|
||||
// otherwise (the SPEC-016 path enrolls for a per-machine cak_).
|
||||
api_key: embedded.api_key.unwrap_or_default(),
|
||||
enrollment_key: embedded.enrollment_key,
|
||||
site_code: embedded.site_code,
|
||||
// The embedded blob carries no agent_id, and load() always
|
||||
// prefers this embedded path — so a freshly generated id would
|
||||
// never be read back, churning the agent_id on every restart.
|
||||
// Reuse the id a prior run persisted to the TOML if present;
|
||||
// only mint a new one when none exists yet.
|
||||
agent_id: Self::persisted_agent_id().unwrap_or_else(generate_agent_id),
|
||||
hostname_override: None,
|
||||
company: embedded.company,
|
||||
site: embedded.site,
|
||||
department: embedded.department,
|
||||
device_type: embedded.device_type,
|
||||
tags: embedded.tags,
|
||||
support_code: None,
|
||||
capture: CaptureConfig::default(),
|
||||
encoding: EncodingConfig::default(),
|
||||
};
|
||||
|
||||
// Save to file for persistence (so agent_id is preserved)
|
||||
// Persist so a freshly-minted agent_id is available to read back on
|
||||
// the next launch (the embedded path always wins, so the TOML is the
|
||||
// only place the stable id can live). The #[serde(skip)] enrollment
|
||||
// fields are intentionally NOT written to the on-disk TOML — they are
|
||||
// install-time material only.
|
||||
let _ = config.save();
|
||||
return Ok(config);
|
||||
}
|
||||
@@ -349,8 +451,12 @@ impl Config {
|
||||
let _ = config.save();
|
||||
}
|
||||
|
||||
// support_code is always None when loading from file (set via CLI)
|
||||
// support_code is always None when loading from file (set via CLI).
|
||||
config.support_code = None;
|
||||
// The enrollment fields are #[serde(skip)], so a file never carries
|
||||
// them; layer them in from the environment for testing / a
|
||||
// file-delivered managed install that supplies them out-of-band.
|
||||
apply_enrollment_env(&mut config);
|
||||
|
||||
return Ok(config);
|
||||
}
|
||||
@@ -365,18 +471,23 @@ impl Config {
|
||||
let agent_id =
|
||||
std::env::var("GURUCONNECT_AGENT_ID").unwrap_or_else(|_| generate_agent_id());
|
||||
|
||||
let config = Config {
|
||||
let mut config = Config {
|
||||
server_url,
|
||||
api_key,
|
||||
enrollment_key: None,
|
||||
site_code: None,
|
||||
agent_id,
|
||||
hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(),
|
||||
company: None,
|
||||
site: None,
|
||||
department: None,
|
||||
device_type: None,
|
||||
tags: Vec::new(),
|
||||
support_code: None,
|
||||
capture: CaptureConfig::default(),
|
||||
encoding: EncodingConfig::default(),
|
||||
};
|
||||
apply_enrollment_env(&mut config);
|
||||
|
||||
// Save config with generated agent_id for persistence
|
||||
let _ = config.save();
|
||||
@@ -384,6 +495,34 @@ impl Config {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Derive the HTTPS API base (e.g. `https://connect.example.com`) from the
|
||||
/// agent's WebSocket `server_url` (e.g. `wss://connect.example.com/ws/agent`).
|
||||
///
|
||||
/// `/api/enroll` is REST/HTTPS while the persistent transport is `wss`, so we
|
||||
/// reuse the same host/authority and swap scheme + drop the WS path. Mapping:
|
||||
/// `wss` -> `https`, `ws` -> `http` (dev). Returns an error if `server_url`
|
||||
/// has no parseable host.
|
||||
pub fn https_base(&self) -> Result<String> {
|
||||
let parsed = url::Url::parse(&self.server_url)
|
||||
.with_context(|| format!("invalid server_url: {}", self.server_url))?;
|
||||
let scheme = match parsed.scheme() {
|
||||
"wss" | "https" => "https",
|
||||
"ws" | "http" => "http",
|
||||
other => {
|
||||
return Err(anyhow!(
|
||||
"unsupported server_url scheme '{other}' (expected ws/wss)"
|
||||
))
|
||||
}
|
||||
};
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("server_url has no host: {}", self.server_url))?;
|
||||
Ok(match parsed.port() {
|
||||
Some(port) => format!("{scheme}://{host}:{port}"),
|
||||
None => format!("{scheme}://{host}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the configuration file path
|
||||
fn config_path() -> PathBuf {
|
||||
// Check for config in current directory first
|
||||
|
||||
413
agent/src/credential_store.rs
Normal file
413
agent/src/credential_store.rs
Normal file
@@ -0,0 +1,413 @@
|
||||
//! At-rest storage for the per-machine operating credential (`cak_`).
|
||||
//!
|
||||
//! SPEC-016 Phase B, item 4 + §Security. The `cak_` minted by `/api/enroll` is
|
||||
//! the high-sensitivity, per-machine, independently-revocable operating
|
||||
//! credential. It is stored with **two independent layers** (Mike's locked
|
||||
//! decision — "BOTH layers"):
|
||||
//!
|
||||
//! 1. **DPAPI-machine encryption** (`CryptProtectData` with
|
||||
//! `CRYPTPROTECT_LOCAL_MACHINE`): the on-disk bytes are a DPAPI blob keyed to
|
||||
//! THIS machine. A copied/exfiltrated file is inert on any other box — DPAPI
|
||||
//! machine keys do not leave the machine.
|
||||
//! 2. **SYSTEM/Administrators-only ACL** on the containing directory + file: a
|
||||
//! non-admin user cannot even read the ciphertext. Inheritance is removed and
|
||||
//! only `SYSTEM` and `BUILTIN\Administrators` are granted full control.
|
||||
//!
|
||||
//! Local admin / SYSTEM can always recover the value — that is accepted (SPEC-016
|
||||
//! §Security): the blast radius of one leaked `cak_` is a single, independently
|
||||
//! revocable machine.
|
||||
//!
|
||||
//! Storage location (chosen over an HKLM value): a file under
|
||||
//! `%ProgramData%\GuruConnect\credentials\agent.cak`. Rationale — the agent
|
||||
//! already keeps its config and the `machine_uid` fallback seed under
|
||||
//! `%ProgramData%\GuruConnect`, so co-locating keeps a single protected
|
||||
//! directory; and a directory/file ACL applied via `icacls` is auditable with far
|
||||
//! less unsafe FFI than building a registry-key security descriptor by hand. Both
|
||||
//! storage shapes are explicitly permitted by the spec.
|
||||
//!
|
||||
//! SECURITY: the plaintext `cak_` is NEVER logged. Errors describe the operation,
|
||||
//! not the value.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Failure classes for [`load_cak`], so callers can distinguish an *operational*
|
||||
/// problem (the file exists but this process cannot open/read it — e.g. running in
|
||||
/// the wrong security context against a SYSTEM-only-ACL'd store) from the real
|
||||
/// *tamper / wrong-machine* signal (the file was read successfully but DPAPI
|
||||
/// decryption failed).
|
||||
///
|
||||
/// The distinction matters for the run-mode resolver (`main.rs`):
|
||||
/// - [`LoadCakError::Io`] is recoverable/actionable — log it and STOP (do not
|
||||
/// silently re-enroll over a store we simply can't read in this context).
|
||||
/// - [`LoadCakError::Decrypt`] is a hard tamper signal — STOP, do not re-enroll.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LoadCakError {
|
||||
/// The store path could not be resolved (e.g. `%ProgramData%` unset).
|
||||
#[error("could not resolve credential store path: {0}")]
|
||||
Path(String),
|
||||
|
||||
/// An IO/open/read error reaching the stored blob — INCLUDING
|
||||
/// `PermissionDenied` (the running context lacks rights to the SYSTEM-only
|
||||
/// store). Operational, not a tamper signal.
|
||||
#[error("credential store is present but could not be read in this context: {source}")]
|
||||
Io {
|
||||
/// Whether this was specifically an access-denied error (drives the
|
||||
/// run-mode fail-fast guard in `main.rs`).
|
||||
permission_denied: bool,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// The blob was read successfully but DPAPI decryption FAILED — the real
|
||||
/// tamper / wrong-machine / corruption signal. A hard stop; never re-enroll.
|
||||
#[error("stored credential failed to decrypt (wrong machine, tampered, or corrupted): {0}")]
|
||||
Decrypt(String),
|
||||
}
|
||||
|
||||
/// Directory holding the protected credential file.
|
||||
fn credentials_dir() -> Result<PathBuf> {
|
||||
let program_data =
|
||||
std::env::var("ProgramData").context("ProgramData environment variable is not set")?;
|
||||
Ok(PathBuf::from(program_data)
|
||||
.join("GuruConnect")
|
||||
.join("credentials"))
|
||||
}
|
||||
|
||||
/// Full path to the DPAPI-encrypted `cak_` blob.
|
||||
fn cak_path() -> Result<PathBuf> {
|
||||
Ok(credentials_dir()?.join("agent.cak"))
|
||||
}
|
||||
|
||||
/// Persist `cak` encrypted at rest.
|
||||
///
|
||||
/// Ordering is security-critical (H2 — TOCTOU): the directory ACL is locked
|
||||
/// BEFORE any secret bytes touch the filesystem, and the temp file is written
|
||||
/// INSIDE the already-locked directory, so no ciphertext ever exists at a path
|
||||
/// carrying an inherited (potentially world-readable) ACL:
|
||||
///
|
||||
/// 1. `create_dir_all(dir)` — ensure the directory exists.
|
||||
/// 2. `lock_down_acl(dir)` — remove inherited ACEs and grant SYSTEM +
|
||||
/// Administrators full control, made inheritable `(OI)(CI)` so children
|
||||
/// created afterward are covered. This is an explicit precondition for the
|
||||
/// write that follows — NOT an unstated inheritance assumption.
|
||||
/// 3. DPAPI-machine-encrypt the plaintext.
|
||||
/// 4. Write the ciphertext to a temp file inside the now-locked directory, then
|
||||
/// rename over the target (atomic-ish replace).
|
||||
/// 5. `lock_down_acl(file)` — assert the file's own ACL (belt-and-suspenders; the
|
||||
/// file already inherits the directory's restrictive ACEs).
|
||||
/// 6. C1 read-back: immediately attempt [`load_cak`] to PROVE the running
|
||||
/// security context can read its own store. If it cannot (e.g. a non-SYSTEM
|
||||
/// run wrote a SYSTEM-only store it can no longer read), fail HERE at enroll
|
||||
/// time with an actionable error — rather than silently bricking on the next
|
||||
/// boot when the steady-state path tries to load it.
|
||||
///
|
||||
/// Returns an error (never logs the plaintext) on any failure so the caller can
|
||||
/// surface it / retry.
|
||||
pub fn store_cak(cak: &str) -> Result<()> {
|
||||
// 1 + 2: lock the directory ACL BEFORE writing any secret (H2 / TOCTOU).
|
||||
let dir = credentials_dir()?;
|
||||
std::fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("failed to create credentials dir {dir:?}"))?;
|
||||
lock_down_acl(&dir).context("failed to restrict credentials directory ACL")?;
|
||||
|
||||
// 3: encrypt only after the destination directory is locked down.
|
||||
let ciphertext = dpapi_protect(cak.as_bytes()).context("DPAPI encryption of cak_ failed")?;
|
||||
|
||||
// 4: write the temp file INSIDE the already-locked directory, then rename.
|
||||
let path = cak_path()?;
|
||||
let tmp = path.with_extension("cak.tmp");
|
||||
std::fs::write(&tmp, &ciphertext)
|
||||
.with_context(|| format!("failed to write temp credential file {tmp:?}"))?;
|
||||
std::fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to place credential file {path:?}"))?;
|
||||
|
||||
// 5: assert the file ACL too (the file already inherits the dir's ACEs).
|
||||
lock_down_acl(&path).context("failed to restrict credential file ACL")?;
|
||||
|
||||
// 6: C1 read-back — confirm THIS context can read back what it just wrote.
|
||||
// Catches the "wrote a SYSTEM-only store from a non-SYSTEM context" footgun at
|
||||
// enroll time instead of as a silent brick on the next launch.
|
||||
match load_cak() {
|
||||
Ok(Some(_)) => {
|
||||
tracing::info!("[ENROLL] stored per-machine credential (encrypted at rest)");
|
||||
Ok(())
|
||||
}
|
||||
Ok(None) => Err(anyhow!(
|
||||
"stored the credential but read-back returned nothing — refusing to proceed \
|
||||
with an unverifiable credential store"
|
||||
)),
|
||||
Err(LoadCakError::Io {
|
||||
permission_denied: true,
|
||||
..
|
||||
}) => Err(anyhow!(
|
||||
"[ENROLL] wrote the credential store but cannot read it back in THIS security \
|
||||
context (access denied). The store is ACL'd to SYSTEM + Administrators by \
|
||||
design; the managed agent must run as the GuruConnect SYSTEM service (see \
|
||||
SPEC-018) to read it. Refusing to leave an unreadable store behind."
|
||||
)),
|
||||
Err(e) => Err(anyhow::Error::new(e)
|
||||
.context("stored the credential but the immediate read-back verification failed")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and decrypt the stored `cak_`, or `Ok(None)` if no credential is stored.
|
||||
///
|
||||
/// Error classification (M1) — the caller MUST treat these differently:
|
||||
/// - `Ok(None)` -> no store yet (NotFound or empty); enroll is fine.
|
||||
/// - [`LoadCakError::Io`] -> the store exists but is unreadable in this
|
||||
/// context (open/read error, INCLUDING access-denied). Operational; the caller
|
||||
/// logs it and STOPS — it must NOT silently re-enroll over a store it merely
|
||||
/// cannot read here.
|
||||
/// - [`LoadCakError::Decrypt`] -> the bytes were read but DPAPI decryption
|
||||
/// FAILED (wrong machine / tampered / corrupted). A hard tamper signal; STOP.
|
||||
///
|
||||
/// Only a successful READ whose decrypt fails is the tamper signal — an IO or
|
||||
/// permission error is never conflated with tamper.
|
||||
pub fn load_cak() -> std::result::Result<Option<String>, LoadCakError> {
|
||||
let path = cak_path().map_err(|e| LoadCakError::Path(e.to_string()))?;
|
||||
let ciphertext = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => {
|
||||
let permission_denied = e.kind() == std::io::ErrorKind::PermissionDenied;
|
||||
return Err(LoadCakError::Io {
|
||||
permission_denied,
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
};
|
||||
if ciphertext.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Reaching here means the READ succeeded — so a decrypt failure now IS the real
|
||||
// tamper / wrong-machine signal (never conflated with an IO/permission error).
|
||||
let plaintext =
|
||||
dpapi_unprotect(&ciphertext).map_err(|e| LoadCakError::Decrypt(e.to_string()))?;
|
||||
let cak = String::from_utf8(plaintext)
|
||||
.map_err(|e| LoadCakError::Decrypt(format!("decrypted bytes were not valid UTF-8: {e}")))?;
|
||||
if cak.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(cak))
|
||||
}
|
||||
|
||||
/// Remove the stored credential (e.g. on revocation / forced re-enroll).
|
||||
/// Succeeds if the file is already absent.
|
||||
///
|
||||
/// Part of the store/load/clear API the spec requires (SPEC-016 item 4). Not yet
|
||||
/// called from a code path — the relay-side `cak_` revocation / forced re-enroll
|
||||
/// flow that drives it is the deferred SPEC-016 Phase B/D server work (the
|
||||
/// `TODO(SPEC-016 Phase B/D): consider revoking existing cak_ on collision` note
|
||||
/// in `server/src/api/enroll.rs`) — so it is retained as part of the complete
|
||||
/// store API and explicitly allowed dead until that server work lands.
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_cak() -> Result<()> {
|
||||
let path = cak_path()?;
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {
|
||||
tracing::info!("[ENROLL] cleared stored per-machine credential");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e).with_context(|| format!("failed to remove {path:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DPAPI (machine scope)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// DPAPI-machine-encrypt `plaintext` into a self-contained blob.
|
||||
fn dpapi_protect(plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
use windows::Win32::Security::Cryptography::{
|
||||
CryptProtectData, CRYPTPROTECT_LOCAL_MACHINE, CRYPT_INTEGER_BLOB,
|
||||
};
|
||||
|
||||
// CryptProtectData requires a mutable input pointer in the struct, though it
|
||||
// does not modify the bytes; copy into a local Vec to get a *mut without
|
||||
// aliasing the caller's slice.
|
||||
let mut input = plaintext.to_vec();
|
||||
let in_blob = CRYPT_INTEGER_BLOB {
|
||||
cbData: u32::try_from(input.len()).context("plaintext too large for DPAPI")?,
|
||||
pbData: input.as_mut_ptr(),
|
||||
};
|
||||
let mut out_blob = CRYPT_INTEGER_BLOB::default();
|
||||
|
||||
// SAFETY: in_blob points at a valid, sized buffer; out_blob is owned here and
|
||||
// its pbData is allocated by DPAPI (freed via LocalFree below). No prompt
|
||||
// struct / entropy / reserved args.
|
||||
unsafe {
|
||||
CryptProtectData(
|
||||
&in_blob,
|
||||
windows::core::PCWSTR::null(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CRYPTPROTECT_LOCAL_MACHINE,
|
||||
&mut out_blob,
|
||||
)
|
||||
.context("CryptProtectData failed")?;
|
||||
}
|
||||
|
||||
let result = copy_and_free_blob(&out_blob);
|
||||
// Best-effort scrub of the transient plaintext copy.
|
||||
input.iter_mut().for_each(|b| *b = 0);
|
||||
|
||||
result.ok_or_else(|| anyhow!("CryptProtectData returned an empty/invalid blob"))
|
||||
}
|
||||
|
||||
/// DPAPI-decrypt a blob previously produced by [`dpapi_protect`] on this machine.
|
||||
fn dpapi_unprotect(ciphertext: &[u8]) -> Result<Vec<u8>> {
|
||||
use windows::Win32::Security::Cryptography::{
|
||||
CryptUnprotectData, CRYPTPROTECT_LOCAL_MACHINE, CRYPT_INTEGER_BLOB,
|
||||
};
|
||||
|
||||
let mut input = ciphertext.to_vec();
|
||||
let in_blob = CRYPT_INTEGER_BLOB {
|
||||
cbData: u32::try_from(input.len()).context("ciphertext too large for DPAPI")?,
|
||||
pbData: input.as_mut_ptr(),
|
||||
};
|
||||
let mut out_blob = CRYPT_INTEGER_BLOB::default();
|
||||
|
||||
// SAFETY: as in dpapi_protect — valid sized input, owned output freed below.
|
||||
unsafe {
|
||||
CryptUnprotectData(
|
||||
&in_blob,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CRYPTPROTECT_LOCAL_MACHINE,
|
||||
&mut out_blob,
|
||||
)
|
||||
.context("CryptUnprotectData failed")?;
|
||||
}
|
||||
|
||||
copy_and_free_blob(&out_blob)
|
||||
.ok_or_else(|| anyhow!("CryptUnprotectData returned an empty/invalid blob"))
|
||||
}
|
||||
|
||||
/// Copy a DPAPI output blob into an owned `Vec` and `LocalFree` the DPAPI buffer.
|
||||
///
|
||||
/// Returns `Some(bytes)` on success, `None` if the blob is null/empty. Always
|
||||
/// frees `pbData` when non-null (DPAPI allocates it with `LocalAlloc`).
|
||||
fn copy_and_free_blob(
|
||||
blob: &windows::Win32::Security::Cryptography::CRYPT_INTEGER_BLOB,
|
||||
) -> Option<Vec<u8>> {
|
||||
use windows::Win32::Foundation::{LocalFree, HLOCAL};
|
||||
|
||||
if blob.pbData.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: DPAPI guarantees pbData points at cbData valid bytes on success.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(blob.pbData, blob.cbData as usize).to_vec() };
|
||||
// SAFETY: pbData was allocated by DPAPI via LocalAlloc; free it once.
|
||||
unsafe {
|
||||
let _ = LocalFree(HLOCAL(blob.pbData as *mut core::ffi::c_void));
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ACL hardening
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Restrict `path` (file or directory) to SYSTEM + Administrators full control,
|
||||
/// removing inherited ACEs so a permissive parent grant cannot leak read access.
|
||||
///
|
||||
/// Implemented via `icacls` — the documented, auditable mechanism — rather than
|
||||
/// hand-rolling a security descriptor through `SetNamedSecurityInfoW` (hundreds
|
||||
/// of lines of SID/ACL FFI). `icacls` ships on every supported Windows target.
|
||||
/// A failure here is surfaced (the caller treats inability to lock down the
|
||||
/// credential store as a hard error) but the well-known SIDs `*S-1-5-18`
|
||||
/// (LocalSystem) and `*S-1-5-32-544` (BUILTIN\Administrators) are language- and
|
||||
/// locale-independent, so this does not break on localized Windows.
|
||||
fn lock_down_acl(path: &std::path::Path) -> Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
let path_str = path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("credential path is not valid UTF-8: {path:?}"))?;
|
||||
|
||||
// /inheritance:r -> remove inherited ACEs (drop the permissive parent grant)
|
||||
// /grant:r -> replace any existing explicit grants for the principal
|
||||
// *S-1-5-18 -> LocalSystem; *S-1-5-32-544 -> BUILTIN\Administrators
|
||||
let output = Command::new("icacls")
|
||||
.arg(path_str)
|
||||
.args([
|
||||
"/inheritance:r",
|
||||
"/grant:r",
|
||||
"*S-1-5-18:(OI)(CI)F",
|
||||
"/grant:r",
|
||||
"*S-1-5-32-544:(OI)(CI)F",
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.context("failed to invoke icacls to harden credential ACL")?;
|
||||
|
||||
if !output.status.success() {
|
||||
// icacls writes its diagnostics to stdout; surface the code only (no
|
||||
// credential material is ever passed to icacls, only the path).
|
||||
return Err(anyhow!(
|
||||
"icacls failed to harden {path_str} (exit {:?})",
|
||||
output.status.code()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// DPAPI round-trips on the same machine: protect then unprotect must recover
|
||||
/// the exact plaintext. (Runs on the build/test host, which IS the same
|
||||
/// machine — the machine-scope key is available to any process here.)
|
||||
#[test]
|
||||
fn dpapi_roundtrip_recovers_plaintext() {
|
||||
let secret = b"cak_test_value_0123456789abcdef";
|
||||
let blob = dpapi_protect(secret).expect("DPAPI protect should succeed on this machine");
|
||||
assert_ne!(
|
||||
blob.as_slice(),
|
||||
secret.as_slice(),
|
||||
"ciphertext must differ from plaintext"
|
||||
);
|
||||
let recovered = dpapi_unprotect(&blob).expect("DPAPI unprotect should succeed");
|
||||
assert_eq!(recovered, secret, "round-trip must recover the exact bytes");
|
||||
}
|
||||
|
||||
/// A non-empty plaintext yields a non-empty, differing blob, and an empty
|
||||
/// input is handled (DPAPI accepts zero-length and round-trips to empty).
|
||||
#[test]
|
||||
fn dpapi_roundtrip_handles_varied_lengths() {
|
||||
for plaintext in [b"x".as_slice(), b"cak_".as_slice(), &[0u8; 256]] {
|
||||
let blob = dpapi_protect(plaintext).expect("protect");
|
||||
let back = dpapi_unprotect(&blob).expect("unprotect");
|
||||
assert_eq!(back.as_slice(), plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tampering with the ciphertext must make decryption FAIL rather than return
|
||||
/// garbage — DPAPI authenticates its blobs.
|
||||
#[test]
|
||||
fn dpapi_rejects_tampered_blob() {
|
||||
let mut blob = dpapi_protect(b"cak_tamper_target").expect("protect");
|
||||
// Flip a byte in the middle of the blob.
|
||||
let mid = blob.len() / 2;
|
||||
blob[mid] ^= 0xFF;
|
||||
assert!(
|
||||
dpapi_unprotect(&blob).is_err(),
|
||||
"a tampered DPAPI blob must fail to decrypt"
|
||||
);
|
||||
}
|
||||
}
|
||||
384
agent/src/enroll.rs
Normal file
384
agent/src/enroll.rs
Normal 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
673
agent/src/identity.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,10 +16,16 @@ mod capture;
|
||||
mod chat;
|
||||
mod config;
|
||||
mod consent;
|
||||
#[cfg(windows)]
|
||||
mod credential_store;
|
||||
mod encoder;
|
||||
mod enroll;
|
||||
mod identity;
|
||||
mod input;
|
||||
mod install;
|
||||
mod sas_client;
|
||||
#[cfg(windows)]
|
||||
mod service;
|
||||
mod session;
|
||||
mod startup;
|
||||
mod transport;
|
||||
@@ -178,6 +184,12 @@ enum Commands {
|
||||
/// Show detailed version and build information
|
||||
#[command(name = "version-info")]
|
||||
VersionInfo,
|
||||
|
||||
/// Internal: entry point invoked by the Windows Service Control Manager to run
|
||||
/// the managed agent as a LocalSystem service (SPEC-018). Not for interactive
|
||||
/// use — running it by hand fails because there is no controlling SCM.
|
||||
#[command(name = "service-run", hide = true)]
|
||||
ServiceRun,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -222,7 +234,24 @@ fn main() -> Result<()> {
|
||||
Some(Commands::Install {
|
||||
user_only,
|
||||
elevated,
|
||||
}) => run_install(user_only || elevated),
|
||||
}) => {
|
||||
// `run_install`'s parameter is `force_user_install` — when true it
|
||||
// skips the UAC re-elevation attempt and installs in-place with
|
||||
// whatever rights this process already has.
|
||||
//
|
||||
// - `user_only`: the user explicitly asked for a per-user install;
|
||||
// honour it directly.
|
||||
// - `elevated`: this is the internal, already-elevated re-exec spawned
|
||||
// by `try_elevate_and_install` ("install --elevated"). It must NOT
|
||||
// attempt to elevate AGAIN (that would loop / re-prompt), so we pass
|
||||
// force=true here too. This is correct even though it routes through
|
||||
// the "user install" parameter, because the re-exec genuinely runs
|
||||
// elevated: `is_elevated()` returns true inside `install()`, so the
|
||||
// path resolves to Program Files and the LocalSystem service installs
|
||||
// normally. The flag only suppresses re-elevation; it does not force a
|
||||
// per-user (non-elevated) install when we are already elevated.
|
||||
run_install(user_only || elevated)
|
||||
}
|
||||
Some(Commands::Uninstall) => run_uninstall(),
|
||||
Some(Commands::Launch { url }) => run_launch(&url),
|
||||
Some(Commands::VersionInfo) => {
|
||||
@@ -232,6 +261,21 @@ fn main() -> Result<()> {
|
||||
println!("{}", build_info::full_version());
|
||||
Ok(())
|
||||
}
|
||||
Some(Commands::ServiceRun) => {
|
||||
// SPEC-018 Phase 1: SCM-invoked entry. Hand off to the service
|
||||
// dispatcher, which calls back into the control loop and runs the
|
||||
// managed-agent logic as SYSTEM. Blocks until the service stops.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
service::run_dispatcher()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Err(anyhow::anyhow!(
|
||||
"service-run is a Windows-only entry point (SPEC-018)"
|
||||
))
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// No subcommand - detect mode from filename or embedded config
|
||||
// Legacy: if support_code arg provided, use that
|
||||
@@ -260,16 +304,31 @@ fn main() -> Result<()> {
|
||||
run_agent_mode(Some(code))
|
||||
}
|
||||
RunMode::PermanentAgent => {
|
||||
// Embedded config found - run as permanent agent
|
||||
// Embedded config found - managed/persistent agent.
|
||||
info!("Permanent agent mode detected (embedded config)");
|
||||
if !install::is_protocol_handler_registered() {
|
||||
// First run - install then run as agent
|
||||
info!("First run - installing agent");
|
||||
if let Err(e) = install::install(false) {
|
||||
warn!("Installation failed: {}", e);
|
||||
}
|
||||
|
||||
// SPEC-018: managed mode runs as the LocalSystem service, not as
|
||||
// an interactive process. The service is the single autostart.
|
||||
// - If the service is already installed, the service is (or
|
||||
// will be) running the agent — this interactive invocation
|
||||
// must NOT spawn a second agent. Exit quietly.
|
||||
// - On first run, install (which installs + starts the service
|
||||
// and removes the legacy HKCU Run entry), then exit and let
|
||||
// the service carry the agent as SYSTEM.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
run_permanent_agent_managed()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if !install::is_protocol_handler_registered() {
|
||||
info!("First run - installing agent");
|
||||
if let Err(e) = install::install(false) {
|
||||
warn!("Installation failed: {}", e);
|
||||
}
|
||||
}
|
||||
run_agent_mode(None)
|
||||
}
|
||||
run_agent_mode(None)
|
||||
}
|
||||
RunMode::Default => {
|
||||
// No special mode detected - use legacy logic
|
||||
@@ -322,7 +381,239 @@ fn run_agent_mode(support_code: Option<String>) -> Result<()> {
|
||||
|
||||
// Run the agent
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(run_agent(config))
|
||||
rt.block_on(async move {
|
||||
// SPEC-016 Phase B: resolve the operating credential before connecting.
|
||||
// Support sessions are unaffected — they authenticate by support code, not
|
||||
// by a per-machine cak_, so we only resolve enrollment for a managed agent.
|
||||
if config.support_code.is_none() {
|
||||
resolve_agent_credential(&mut config).await?;
|
||||
}
|
||||
run_agent(config, None).await
|
||||
})
|
||||
}
|
||||
|
||||
/// SPEC-018 Phase 1: run the managed/persistent agent as the LocalSystem service.
|
||||
///
|
||||
/// Invoked from the service control loop ([`service::run_service`]) once the
|
||||
/// service has reported `Running`. This is the same persistent-agent logic as
|
||||
/// [`run_agent_mode`] (load config, resolve/enroll the per-machine `cak_` per
|
||||
/// SPEC-016, hold the relay connection) — but it runs **as SYSTEM**, so the
|
||||
/// SYSTEM-ACL'd `cak_` store is finally readable in-context, and it observes the
|
||||
/// SCM `shutdown` flag for a graceful stop.
|
||||
///
|
||||
/// Returns `Ok(())` when the agent loop exits because a stop was requested, and
|
||||
/// `Err` only on an unrecoverable *local* fault (e.g. no usable credential and no
|
||||
/// enrollment material) — network errors are retried inside the loop and never
|
||||
/// surface here.
|
||||
///
|
||||
/// Phase 2 seam: this is where the session broker is wired in — the runtime
|
||||
/// started here will own the broker that spawns the per-session capture/input
|
||||
/// worker (`CreateProcessAsUserW`) and the IPC server. Phase 1 connects/enrolls
|
||||
/// only; it does not capture a desktop (a Session-0 SYSTEM process cannot).
|
||||
#[cfg(windows)]
|
||||
pub fn run_managed_agent_service(
|
||||
shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
) -> Result<()> {
|
||||
info!("Loading managed-agent configuration (running as SYSTEM)");
|
||||
|
||||
let mut config = config::Config::load()?;
|
||||
// The service ONLY ever runs the managed/persistent path. A support session is
|
||||
// an interactive, user-launched flow and must never be carried by the service.
|
||||
config.support_code = None;
|
||||
|
||||
info!("Server: {}", config.server_url);
|
||||
if let Some(ref company) = config.company {
|
||||
info!("Company: {}", company);
|
||||
}
|
||||
if let Some(ref site) = config.site {
|
||||
info!("Site: {}", site);
|
||||
}
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
|
||||
// SPEC-018 (finding M): this future runs across the `extern "system"` service
|
||||
// entry point (ffi_service_main -> service_main -> run_service -> here). A
|
||||
// panic that unwound across that FFI boundary is undefined behaviour (the C
|
||||
// ABI cannot carry a Rust unwind) and would abort the process instead of
|
||||
// taking the intended ServiceSpecific(1) fault path. Catch it here and convert
|
||||
// it into an `Err`, which `run_service` maps to ServiceExitCode::ServiceSpecific(1)
|
||||
// so the SCM applies its configured recovery (restart) cleanly. `Running` is
|
||||
// already reported before we get here, so a fault does not strand StartPending.
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
rt.block_on(async move {
|
||||
// SPEC-016 Phase B: resolve the operating credential before connecting.
|
||||
// Running as SYSTEM, the SYSTEM+Administrators-ACL'd cak_ store is now
|
||||
// readable in-context, so the Phase B fail-fast guard is not hit on this
|
||||
// path (it remains as a safety net for any non-SYSTEM invocation).
|
||||
resolve_agent_credential(&mut config).await?;
|
||||
run_agent(config, Some(shutdown)).await
|
||||
})
|
||||
}));
|
||||
|
||||
match outcome {
|
||||
Ok(result) => result,
|
||||
Err(panic) => {
|
||||
// Recover a human-readable message from the panic payload for the log;
|
||||
// do not re-panic (that would unwind across the FFI boundary again).
|
||||
let detail = panic
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| panic.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "non-string panic payload".to_string());
|
||||
error!("managed-agent runtime panicked: {detail}");
|
||||
Err(anyhow::anyhow!("managed-agent runtime panicked: {detail}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SPEC-018 Phase 1: handle an interactive launch of a MANAGED agent binary (one
|
||||
/// carrying embedded config, detected as [`config::RunMode::PermanentAgent`]).
|
||||
///
|
||||
/// Managed mode runs as the LocalSystem service, never as an interactive process:
|
||||
/// - If the service is already installed, the service is (or will be) running
|
||||
/// the agent as SYSTEM, so this interactive invocation must NOT spawn a second
|
||||
/// agent — it exits quietly.
|
||||
/// - On first run, install (which installs + starts the service and removes the
|
||||
/// legacy `HKCU\…\Run` autostart), then exit and let the service carry the
|
||||
/// agent. The managed install REQUIRES elevation: the per-machine credential
|
||||
/// store is SYSTEM-only, so the SPEC-016 enrollment path cannot authenticate
|
||||
/// from a non-elevated, in-process context. There is therefore no in-process
|
||||
/// fallback — if the install fails, we return an actionable error telling the
|
||||
/// operator to re-run as Administrator.
|
||||
#[cfg(windows)]
|
||||
fn run_permanent_agent_managed() -> Result<()> {
|
||||
if service::is_service_installed() {
|
||||
info!(
|
||||
"Managed service already installed; the service runs the agent as SYSTEM — \
|
||||
this interactive instance has nothing to do"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("First run - installing managed agent service");
|
||||
if let Err(e) = install::install(false) {
|
||||
// No in-process fallback: a managed agent authenticates with a per-machine
|
||||
// cak_ whose credential store is ACL'd to SYSTEM only. Running the agent in
|
||||
// this non-elevated process would either fail to read an existing cak_
|
||||
// (permission denied against the SYSTEM-only ACL) or, on a fresh machine,
|
||||
// fail enrollment's C1 store-and-read-back verification — leaving the
|
||||
// machine with no working agent while pretending otherwise. Surface a clear,
|
||||
// actionable error instead.
|
||||
error!(
|
||||
"Managed agent install failed ({e:#}). The managed service must be installed \
|
||||
elevated (Administrator) — the per-machine credential store is SYSTEM-only and \
|
||||
an in-process fallback cannot authenticate. Re-run as Administrator."
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"managed agent install failed ({e:#}); the managed service must be installed \
|
||||
elevated (Administrator) — the per-machine credential store is SYSTEM-only and \
|
||||
an in-process fallback cannot authenticate. Re-run as Administrator."
|
||||
));
|
||||
}
|
||||
|
||||
info!("Managed agent service installed; handing off to the service");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the per-machine operating credential for a managed agent (SPEC-016
|
||||
/// Phase B, run-mode wiring).
|
||||
///
|
||||
/// Precedence:
|
||||
/// 1. A `cak_` already stored encrypted at rest -> load it and connect with it
|
||||
/// (the steady-state path; no network call, no re-enroll).
|
||||
/// 2. No stored `cak_` but an `enrollment_key` + `site_code` are present ->
|
||||
/// run first-run enrollment to obtain + persist a `cak_`, then connect.
|
||||
/// 3. Neither a stored `cak_` nor enrollment material, but a non-empty
|
||||
/// `api_key` is configured -> use it as the DEPRECATED shared/legacy key
|
||||
/// (transition compatibility only; logged at WARNING).
|
||||
/// 4. Nothing usable -> error; a managed agent cannot authenticate.
|
||||
async fn resolve_agent_credential(config: &mut config::Config) -> Result<()> {
|
||||
// 1. Stored per-machine cak_ (steady state).
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use credential_store::LoadCakError;
|
||||
match credential_store::load_cak() {
|
||||
Ok(Some(cak)) => {
|
||||
info!("Using stored per-machine credential (cak_)");
|
||||
config.api_key = cak;
|
||||
// Any leftover enrollment material is now moot.
|
||||
config.enrollment_key = None;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("No stored per-machine credential; will enroll if configured");
|
||||
}
|
||||
// C1 / M1 — the store exists but THIS security context cannot read it
|
||||
// (access-denied against the SYSTEM-only ACL). This is the brick the
|
||||
// C1 guard prevents: a non-SYSTEM run could write the store but never
|
||||
// read it back. Fail fast with an actionable message; do NOT loop and
|
||||
// do NOT silently re-enroll. The SYSTEM+Administrators ACL is correct
|
||||
// for the target (Option A) and is deliberately kept.
|
||||
//
|
||||
// SPEC-018 (this spec): the managed agent now runs as the GuruConnect
|
||||
// SYSTEM service ([`run_managed_agent_service`]), so on the production
|
||||
// managed path the store IS readable in-context and this branch is NOT
|
||||
// hit. The guard is intentionally retained as a harmless safety net for
|
||||
// any non-SYSTEM invocation (e.g. someone running the managed binary
|
||||
// interactively): it still fails fast with an actionable message rather
|
||||
// than bricking. Do NOT remove it in Phase 1.
|
||||
Err(LoadCakError::Io {
|
||||
permission_denied: true,
|
||||
source,
|
||||
}) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"[ENROLL] credential store is not accessible in this context \
|
||||
({source}) — the managed agent must run as the GuruConnect SYSTEM \
|
||||
service (see SPEC-018). Refusing to re-enroll."
|
||||
));
|
||||
}
|
||||
// M1 — other IO error reaching the store (not access-denied): also
|
||||
// operational, not a tamper signal. Surface it; do not re-enroll over a
|
||||
// store we simply could not read.
|
||||
Err(e @ LoadCakError::Io { .. }) => {
|
||||
return Err(anyhow::Error::new(e).context(
|
||||
"[ENROLL] credential store present but unreadable (IO error); \
|
||||
refusing to re-enroll over it",
|
||||
));
|
||||
}
|
||||
Err(e @ LoadCakError::Path(_)) => {
|
||||
return Err(anyhow::Error::new(e)
|
||||
.context("[ENROLL] could not resolve the credential store path"));
|
||||
}
|
||||
// M1 — the bytes were read but failed to DECRYPT: the real tamper /
|
||||
// wrong-machine signal. Hard stop; never silently re-enroll over it.
|
||||
Err(e @ LoadCakError::Decrypt(_)) => {
|
||||
return Err(anyhow::Error::new(e).context(
|
||||
"[ENROLL] stored credential failed to decrypt — possible tamper or \
|
||||
copy from another machine; refusing to silently re-enroll",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. First-run enrollment (the SPEC-016 zero-touch path). run_enrollment only
|
||||
// returns once a cak_ is stored (it retries network/429/collision-pending
|
||||
// internally); a returned Err is an unrecoverable local fault.
|
||||
if config.enrollment_key.is_some() && config.site_code.is_some() {
|
||||
info!("Enrollment material present; running first-run enrollment");
|
||||
enroll::run_enrollment(config).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. DEPRECATED shared/legacy api_key fallback (transition only).
|
||||
if !config.api_key.is_empty() {
|
||||
warn!(
|
||||
"Connecting with a DEPRECATED shared/legacy api_key. Migrate this agent \
|
||||
to a per-site enrollment (SPEC-016); the shared key path will be removed."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 4. Nothing usable.
|
||||
Err(anyhow::anyhow!(
|
||||
"no operating credential available: no stored cak_, no enrollment_key/site_code, \
|
||||
and no legacy api_key — this managed agent cannot authenticate"
|
||||
))
|
||||
}
|
||||
|
||||
/// Run in viewer mode (connect to remote session)
|
||||
@@ -375,7 +666,22 @@ fn run_install(force_user_install: bool) -> Result<()> {
|
||||
fn run_uninstall() -> Result<()> {
|
||||
info!("Uninstalling GuruConnect...");
|
||||
|
||||
// Remove from startup
|
||||
// SPEC-018: remove the managed LocalSystem service and the legacy HKCU Run
|
||||
// autostart. Idempotent — no error if the service was never installed (an
|
||||
// attended/viewer install has no service), so this is safe for every install
|
||||
// shape. Requires Administrator to delete the service; a non-elevated uninstall
|
||||
// still clears the per-user autostart below.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Err(e) = install::uninstall_managed_service() {
|
||||
warn!(
|
||||
"Failed to remove managed service (may require Administrator): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from startup (covers non-elevated / attended / viewer installs).
|
||||
if let Err(e) = startup::remove_from_startup() {
|
||||
warn!("Failed to remove from startup: {}", e);
|
||||
}
|
||||
@@ -473,31 +779,62 @@ fn cleanup_on_exit() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the agent main loop
|
||||
async fn run_agent(config: config::Config) -> Result<()> {
|
||||
/// Run the agent main loop.
|
||||
///
|
||||
/// `service_shutdown`, when present, is the SCM cooperative-stop flag (SPEC-018):
|
||||
/// the managed-agent service passes it so the loop exits promptly on
|
||||
/// `Stop`/`Shutdown`. It is `None` for the interactive/user-launched paths, which
|
||||
/// stop via the tray exit / server control messages instead.
|
||||
async fn run_agent(
|
||||
config: config::Config,
|
||||
service_shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
) -> Result<()> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let elevated = install::is_elevated();
|
||||
let running_as_service = service_shutdown.is_some();
|
||||
let mut session = session::SessionManager::new(config.clone(), elevated);
|
||||
let is_support_session = config.support_code.is_some();
|
||||
let hostname = config.hostname();
|
||||
|
||||
// Add to startup
|
||||
if let Err(e) = startup::add_to_startup() {
|
||||
// Helper: has the SCM asked us to stop?
|
||||
let stop_requested = |flag: &Option<std::sync::Arc<std::sync::atomic::AtomicBool>>| -> bool {
|
||||
flag.as_ref()
|
||||
.map(|f| f.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
// Autostart persistence:
|
||||
// - As the SYSTEM service (SPEC-018), the SERVICE itself is the managed
|
||||
// autostart — do NOT write the per-user HKCU\…\Run entry (that would be a
|
||||
// second, redundant autostart, and writing it from SYSTEM lands in the
|
||||
// wrong hive). The service install/uninstall owns lifecycle.
|
||||
// - Interactive/user-launched runs keep the existing HKCU Run behavior.
|
||||
if running_as_service {
|
||||
info!("Running as the GuruConnect SYSTEM service; service is the autostart (skipping HKCU Run)");
|
||||
} else if let Err(e) = startup::add_to_startup() {
|
||||
warn!("Failed to add to startup: {}", e);
|
||||
}
|
||||
|
||||
// Create tray icon
|
||||
let tray = match tray::TrayController::new(
|
||||
&hostname,
|
||||
config.support_code.as_deref(),
|
||||
is_support_session,
|
||||
) {
|
||||
Ok(t) => {
|
||||
info!("Tray icon created");
|
||||
Some(t)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create tray icon: {}", e);
|
||||
None
|
||||
// A Session-0 SYSTEM service has no interactive desktop, so a tray icon is
|
||||
// both impossible and meaningless there (SPEC-018 Phase 2 moves the user-facing
|
||||
// surface into the per-session worker). Only create the tray off the service.
|
||||
let tray = if running_as_service {
|
||||
None
|
||||
} else {
|
||||
match tray::TrayController::new(
|
||||
&hostname,
|
||||
config.support_code.as_deref(),
|
||||
is_support_session,
|
||||
) {
|
||||
Ok(t) => {
|
||||
info!("Tray icon created");
|
||||
Some(t)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create tray icon: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -506,6 +843,12 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
||||
|
||||
// Connect to server and run main loop
|
||||
loop {
|
||||
// SPEC-018: honour an SCM stop request before (re)connecting.
|
||||
if stop_requested(&service_shutdown) {
|
||||
info!("Service stop requested; exiting agent loop");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Connecting to server...");
|
||||
|
||||
if is_support_session {
|
||||
@@ -527,11 +870,22 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
||||
}
|
||||
|
||||
if let Err(e) = session
|
||||
.run_with_tray(tray.as_ref(), chat_ctrl.as_ref())
|
||||
.run_with_tray(tray.as_ref(), chat_ctrl.as_ref(), service_shutdown.as_ref())
|
||||
.await
|
||||
{
|
||||
let error_msg = e.to_string();
|
||||
|
||||
// SPEC-018 (finding H): the connected session loop broke
|
||||
// because the SCM asked the service to stop. The loop already
|
||||
// closed the WebSocket cleanly; treat this as a graceful stop
|
||||
// (no reconnect) so the service transitions StopPending ->
|
||||
// Stopped. Only the service path can produce this (it is the
|
||||
// only caller that passes a shutdown flag).
|
||||
if error_msg.contains(session::SERVICE_STOP_SENTINEL) {
|
||||
info!("Service stop requested during session; exiting agent loop");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if error_msg.contains("USER_EXIT") {
|
||||
info!("Session ended by user");
|
||||
cleanup_on_exit();
|
||||
@@ -604,6 +958,47 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
||||
}
|
||||
|
||||
info!("Reconnecting in 5 seconds...");
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
// SPEC-018: poll the SCM stop flag during the backoff so a service stop is
|
||||
// honoured within ~250ms instead of waiting the full reconnect delay.
|
||||
if service_shutdown.is_some() {
|
||||
for _ in 0..20 {
|
||||
if stop_requested(&service_shutdown) {
|
||||
info!("Service stop requested during reconnect backoff; exiting agent loop");
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
|
||||
}
|
||||
} else {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::CommandFactory;
|
||||
|
||||
/// SPEC-018 finding N1: pin the clap subcommand name to the constant the SCM
|
||||
/// is registered with. The service is installed with `SERVICE_RUN_ARG` as its
|
||||
/// launch argument; when the SCM starts it, clap must route that exact token
|
||||
/// into [`Commands::ServiceRun`]. If the `#[command(name = "service-run")]`
|
||||
/// attribute and the constant ever drift apart, the SCM would start the binary
|
||||
/// but clap would fail to match the subcommand and the process would fall
|
||||
/// through to default (non-service) mode and exit. Asserting against the live
|
||||
/// clap metadata (not a second string literal) makes that drift impossible.
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn service_run_subcommand_matches_scm_launch_arg() {
|
||||
let cmd = Cli::command();
|
||||
let has_matching_subcommand = cmd
|
||||
.get_subcommands()
|
||||
.any(|sc| sc.get_name() == service::SERVICE_RUN_ARG);
|
||||
assert!(
|
||||
has_matching_subcommand,
|
||||
"no clap subcommand named '{}' (the SCM launch arg); the ServiceRun \
|
||||
#[command(name = ...)] attribute drifted from service::SERVICE_RUN_ARG",
|
||||
service::SERVICE_RUN_ARG
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
520
agent/src/service/mod.rs
Normal file
520
agent/src/service/mod.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,18 @@ use crate::proto::{message, AgentStatus, ChatMessage, Heartbeat, HeartbeatAck, M
|
||||
use crate::transport::WebSocketTransport;
|
||||
use crate::tray::{TrayAction, TrayController};
|
||||
use anyhow::Result;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Sentinel error string returned by [`SessionManager::run_with_tray`] when the
|
||||
/// loop breaks because the SCM asked the managed-agent service to stop (SPEC-018,
|
||||
/// finding H). The outer `run_agent` loop matches on this to treat the exit as a
|
||||
/// graceful service stop (clean WS close, no reconnect) rather than a session
|
||||
/// error. Only the service path passes a shutdown flag, so only the service path
|
||||
/// can ever produce this.
|
||||
pub const SERVICE_STOP_SENTINEL: &str = "SERVICE_STOP";
|
||||
|
||||
// Heartbeat interval (30 seconds)
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
|
||||
// Status report interval (60 seconds)
|
||||
@@ -103,12 +113,16 @@ impl SessionManager {
|
||||
pub async fn connect(&mut self) -> Result<()> {
|
||||
self.state = SessionState::Connecting;
|
||||
|
||||
// Deterministic, recomputable identity reported alongside agent_id
|
||||
// (v2 stable-identity Task 1). Cached after the first call.
|
||||
let machine_uid = crate::identity::machine_uid();
|
||||
let transport = WebSocketTransport::connect(
|
||||
&self.config.server_url,
|
||||
&self.config.agent_id,
|
||||
&self.config.api_key,
|
||||
Some(&self.hostname),
|
||||
self.config.support_code.as_deref(),
|
||||
Some(&machine_uid),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -247,6 +261,10 @@ impl SessionManager {
|
||||
// Advertise hardware H.264 capability so the server can negotiate the
|
||||
// codec (Task 7). Detected once and cached by the encoder module.
|
||||
supports_h264: encoder::supports_hardware_h264(),
|
||||
// Deterministic, recomputable hardware identity (v2 stable-identity
|
||||
// Task 1). Reported alongside the unchanged random agent_id; cached
|
||||
// after the first (registry) read.
|
||||
machine_uid: crate::identity::machine_uid(),
|
||||
};
|
||||
|
||||
let msg = Message {
|
||||
@@ -277,16 +295,34 @@ impl SessionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the session main loop with tray and chat event processing
|
||||
/// Run the session main loop with tray and chat event processing.
|
||||
///
|
||||
/// `service_shutdown` (SPEC-018 finding H) is the SCM cooperative-stop flag.
|
||||
/// It is `Some(flag)` ONLY on the managed-agent service path; the
|
||||
/// attended/viewer/interactive callers pass `None` and behave EXACTLY as
|
||||
/// before. When present, the flag is polled on every idle tick (the natural
|
||||
/// ~100ms seam below) so an SCM Stop/Shutdown received while CONNECTED breaks
|
||||
/// this inner loop promptly — instead of only being observed by the outer
|
||||
/// `run_agent` reconnect loop, which never runs while a session is connected.
|
||||
/// On a set flag the loop closes the WebSocket cleanly (via the shared exit
|
||||
/// path at the bottom) and returns the [`SERVICE_STOP_SENTINEL`] error, which
|
||||
/// the outer loop maps to a graceful stop.
|
||||
pub async fn run_with_tray(
|
||||
&mut self,
|
||||
tray: Option<&TrayController>,
|
||||
chat: Option<&ChatController>,
|
||||
service_shutdown: Option<&Arc<AtomicBool>>,
|
||||
) -> Result<()> {
|
||||
if self.transport.is_none() {
|
||||
anyhow::bail!("Not connected");
|
||||
}
|
||||
|
||||
// Helper: has the SCM asked the service to stop? Always false off the
|
||||
// service path (where `service_shutdown` is `None`).
|
||||
let stop_requested = |flag: Option<&Arc<AtomicBool>>| -> bool {
|
||||
flag.is_some_and(|f| f.load(Ordering::SeqCst))
|
||||
};
|
||||
|
||||
// Send initial status
|
||||
self.send_status().await?;
|
||||
|
||||
@@ -299,6 +335,29 @@ impl SessionManager {
|
||||
|
||||
// Main loop
|
||||
loop {
|
||||
// SPEC-018 (finding H): honour an SCM stop request received while the
|
||||
// session is CONNECTED. The outer `run_agent` loop only observes the
|
||||
// flag between connection attempts, but a managed agent spends its
|
||||
// entire connected life inside THIS loop — so without this check an
|
||||
// SCM Stop while connected would not break out until the connection
|
||||
// dropped on its own. Breaking here falls through to the shared exit
|
||||
// path below, which closes the transport cleanly (clean WS close);
|
||||
// the sentinel tells the outer loop this was a graceful stop.
|
||||
if stop_requested(service_shutdown) {
|
||||
tracing::info!("Service stop requested; ending connected session loop");
|
||||
self.release_streaming();
|
||||
self.state = SessionState::Disconnected;
|
||||
if let Some(transport) = self.transport.as_mut() {
|
||||
// Best-effort clean WebSocket close (sends a Close frame). A
|
||||
// failure here just means the peer/socket is already gone; the
|
||||
// service still stops cleanly.
|
||||
if let Err(e) = transport.close().await {
|
||||
tracing::warn!("error during clean WebSocket close on service stop: {}", e);
|
||||
}
|
||||
}
|
||||
return Err(anyhow::anyhow!(SERVICE_STOP_SENTINEL));
|
||||
}
|
||||
|
||||
// Process tray events
|
||||
if let Some(t) = tray {
|
||||
if let Some(action) = t.process_events() {
|
||||
@@ -737,3 +796,47 @@ impl SessionManager {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// SPEC-018 finding H: the connected-stop contract. When the SCM sets the
|
||||
/// shutdown flag, `run_with_tray` returns an error whose message contains
|
||||
/// [`SERVICE_STOP_SENTINEL`]; the outer `run_agent` loop recognises a graceful
|
||||
/// stop with `error_msg.contains(SERVICE_STOP_SENTINEL)`. This pins that the
|
||||
/// error the loop constructs on stop actually satisfies that match — so the
|
||||
/// two halves (producer here, consumer in `main.rs`) cannot drift.
|
||||
///
|
||||
/// A full end-to-end test of the in-loop interrupt would need a live connected
|
||||
/// transport (a real or mocked server), which is an integration concern; this
|
||||
/// unit test instead pins the wire contract the interrupt relies on.
|
||||
#[test]
|
||||
fn service_stop_sentinel_is_matched_by_outer_loop_check() {
|
||||
let produced = anyhow::anyhow!(SERVICE_STOP_SENTINEL);
|
||||
assert!(
|
||||
produced.to_string().contains(SERVICE_STOP_SENTINEL),
|
||||
"the stop error must contain the sentinel the outer loop matches on"
|
||||
);
|
||||
assert!(
|
||||
!SERVICE_STOP_SENTINEL.is_empty(),
|
||||
"the sentinel must be a non-empty, distinctive token"
|
||||
);
|
||||
}
|
||||
|
||||
/// The shutdown-flag check is a no-op (always `false`) when no flag is passed,
|
||||
/// i.e. on the attended/viewer/interactive paths — guaranteeing the new
|
||||
/// parameter is a pure addition that cannot alter non-service behaviour
|
||||
/// (SPEC-018 finding H: "no regression").
|
||||
#[test]
|
||||
fn no_shutdown_flag_never_requests_stop() {
|
||||
let none: Option<&Arc<AtomicBool>> = None;
|
||||
let check = |flag: Option<&Arc<AtomicBool>>| flag.is_some_and(|f| f.load(Ordering::SeqCst));
|
||||
assert!(!check(none));
|
||||
|
||||
let set = Arc::new(AtomicBool::new(true));
|
||||
assert!(check(Some(&set)));
|
||||
let unset = Arc::new(AtomicBool::new(false));
|
||||
assert!(!check(Some(&unset)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use tracing::{info, warn};
|
||||
use windows::core::PCWSTR;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Registry::{
|
||||
RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegSetValueExW, HKEY_CURRENT_USER, KEY_WRITE,
|
||||
RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_WRITE,
|
||||
REG_SZ,
|
||||
};
|
||||
|
||||
@@ -42,40 +42,39 @@ pub fn add_to_startup() -> Result<()> {
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
// SAFETY: FFI into the Win32 registry API. `key_path`/`value_name`/`value_data`
|
||||
// are NUL-terminated wide strings that outlive the calls. `RegOpenKeyExW`
|
||||
// writes the opened key into `hkey`; we only use it after confirming success,
|
||||
// and always pair it with `RegCloseKey`.
|
||||
unsafe {
|
||||
let mut hkey = windows::Win32::Foundation::HANDLE::default();
|
||||
let mut hkey = HKEY::default();
|
||||
|
||||
// Open the Run key
|
||||
// Open the Run key. RegOpenKeyExW takes a `*mut HKEY` out-param.
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR(key_path.as_ptr()),
|
||||
0,
|
||||
KEY_WRITE,
|
||||
&mut hkey as *mut _ as *mut _,
|
||||
&mut hkey,
|
||||
);
|
||||
|
||||
if result.is_err() {
|
||||
anyhow::bail!("Failed to open registry key: {:?}", result);
|
||||
}
|
||||
|
||||
let hkey_raw = std::mem::transmute::<
|
||||
windows::Win32::Foundation::HANDLE,
|
||||
windows::Win32::System::Registry::HKEY,
|
||||
>(hkey);
|
||||
|
||||
// Set the value
|
||||
let data_bytes =
|
||||
std::slice::from_raw_parts(value_data.as_ptr() as *const u8, value_data.len() * 2);
|
||||
|
||||
let set_result = RegSetValueExW(
|
||||
hkey_raw,
|
||||
hkey,
|
||||
PCWSTR(value_name.as_ptr()),
|
||||
0,
|
||||
REG_SZ,
|
||||
Some(data_bytes),
|
||||
);
|
||||
|
||||
let _ = RegCloseKey(hkey_raw);
|
||||
let _ = RegCloseKey(hkey);
|
||||
|
||||
if set_result.is_err() {
|
||||
anyhow::bail!("Failed to set registry value: {:?}", set_result);
|
||||
@@ -103,15 +102,19 @@ pub fn remove_from_startup() -> Result<()> {
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
// SAFETY: FFI into the Win32 registry API. `key_path`/`value_name` are
|
||||
// NUL-terminated wide strings that outlive the calls. `RegOpenKeyExW` writes
|
||||
// the opened key into `hkey`; we only use it after confirming success, and
|
||||
// always pair it with `RegCloseKey`.
|
||||
unsafe {
|
||||
let mut hkey = windows::Win32::Foundation::HANDLE::default();
|
||||
let mut hkey = HKEY::default();
|
||||
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR(key_path.as_ptr()),
|
||||
0,
|
||||
KEY_WRITE,
|
||||
&mut hkey as *mut _ as *mut _,
|
||||
&mut hkey,
|
||||
);
|
||||
|
||||
if result.is_err() {
|
||||
@@ -119,14 +122,9 @@ pub fn remove_from_startup() -> Result<()> {
|
||||
return Ok(()); // Not an error if key doesn't exist
|
||||
}
|
||||
|
||||
let hkey_raw = std::mem::transmute::<
|
||||
windows::Win32::Foundation::HANDLE,
|
||||
windows::Win32::System::Registry::HKEY,
|
||||
>(hkey);
|
||||
let delete_result = RegDeleteValueW(hkey, PCWSTR(value_name.as_ptr()));
|
||||
|
||||
let delete_result = RegDeleteValueW(hkey_raw, PCWSTR(value_name.as_ptr()));
|
||||
|
||||
let _ = RegCloseKey(hkey_raw);
|
||||
let _ = RegCloseKey(hkey);
|
||||
|
||||
if delete_result.is_err() {
|
||||
warn!("Registry value may not exist: {:?}", delete_result);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ use windows::Win32::Media::MediaFoundation::{
|
||||
MFSTARTUP_LITE, MFT_CATEGORY_VIDEO_DECODER, MFT_ENUM_FLAG_SORTANDFILTER, MFT_ENUM_FLAG_SYNCMFT,
|
||||
MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, MFT_MESSAGE_NOTIFY_END_OF_STREAM,
|
||||
MFT_MESSAGE_NOTIFY_END_STREAMING, MFT_MESSAGE_NOTIFY_START_OF_STREAM, MFT_OUTPUT_DATA_BUFFER,
|
||||
MFT_OUTPUT_STREAM_INFO, MFT_REGISTER_TYPE_INFO, MF_E_TRANSFORM_NEED_MORE_INPUT,
|
||||
MF_E_TRANSFORM_STREAM_CHANGE, MF_MT_FRAME_SIZE, MF_MT_MAJOR_TYPE, MF_MT_SUBTYPE,
|
||||
MFT_OUTPUT_STREAM_INFO, MFT_REGISTER_TYPE_INFO, MF_E_NOTACCEPTING,
|
||||
MF_E_TRANSFORM_NEED_MORE_INPUT, MF_E_TRANSFORM_STREAM_CHANGE, MF_E_TRANSFORM_TYPE_NOT_SET,
|
||||
MF_MT_FRAME_SIZE, MF_MT_MAJOR_TYPE, MF_MT_SUBTYPE,
|
||||
};
|
||||
|
||||
/// A decoded NV12 frame and its dimensions, ready for NV12 -> BGRA conversion.
|
||||
@@ -91,15 +92,32 @@ impl H264Decoder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the decoder output type to NV12 once the stream size is known.
|
||||
unsafe fn configure_output_nv12(&mut self) -> Result<()> {
|
||||
let out_type: IMFMediaType = MFCreateMediaType().context("MFCreateMediaType(dec out)")?;
|
||||
out_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
|
||||
out_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12)?;
|
||||
self.transform
|
||||
.SetOutputType(self.output_stream_id, &out_type, 0)
|
||||
.context("SetOutputType(NV12 decode)")?;
|
||||
Ok(())
|
||||
/// Negotiate the decoder's NV12 output type by ENUMERATING the available
|
||||
/// output types it offers (these carry the decoder-negotiated frame size),
|
||||
/// then setting the NV12 one. The Microsoft H.264 decoder MFT rejects a
|
||||
/// hand-built, underspecified output type, so we must select from what it
|
||||
/// exposes after it has parsed enough of the bitstream. Driven by a
|
||||
/// STREAM_CHANGE / TYPE_NOT_SET round-trip — never set eagerly.
|
||||
unsafe fn negotiate_output_type(&mut self) -> Result<()> {
|
||||
let mut index: u32 = 0;
|
||||
// GetOutputAvailableType returns Err (MF_E_NO_MORE_TYPES) past the last
|
||||
// entry, which ends the enumeration.
|
||||
while let Ok(mt) = self
|
||||
.transform
|
||||
.GetOutputAvailableType(self.output_stream_id, index)
|
||||
{
|
||||
let subtype = mt
|
||||
.GetGUID(&MF_MT_SUBTYPE)
|
||||
.context("read available output subtype")?;
|
||||
if subtype == MFVideoFormat_NV12 {
|
||||
self.transform
|
||||
.SetOutputType(self.output_stream_id, &mt, 0)
|
||||
.context("SetOutputType(NV12 decode)")?;
|
||||
return Ok(());
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
Err(anyhow!("decoder offered no NV12 output type"))
|
||||
}
|
||||
|
||||
/// Read the negotiated output frame size from the decoder's current output type.
|
||||
@@ -129,42 +147,79 @@ impl H264Decoder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Feed one H.264 access unit and return a decoded BGRA frame if one is
|
||||
/// produced this tick. `Ok(None)` means the decoder needs more input (normal
|
||||
/// while it buffers the first GOP).
|
||||
pub fn decode(&mut self, h264: &[u8], pts_100ns: i64) -> Result<Option<DecodedFrame>> {
|
||||
/// Feed one H.264 access unit and return all BGRA frames the decoder emits
|
||||
/// in response. A single input access unit can legitimately yield zero, one,
|
||||
/// or more decoded frames, so the result is a `Vec`.
|
||||
///
|
||||
/// This implements the Media Foundation MFT streaming contract: `ProcessInput`
|
||||
/// may return `MF_E_NOTACCEPTING`, which is NOT an error — it means the decoder
|
||||
/// has pending output that must be fully drained via `ProcessOutput` before it
|
||||
/// will accept the next input. The previous implementation treated NOTACCEPTING
|
||||
/// as fatal and only drained one frame per call, so once the MFT filled up it
|
||||
/// rejected every subsequent frame (0xC00D36B5) and nothing rendered. We now
|
||||
/// drain on back-pressure, retry the same (unconsumed) sample, then drain ALL
|
||||
/// ready outputs before returning.
|
||||
pub fn decode(&mut self, h264: &[u8], pts_100ns: i64) -> Result<Vec<DecodedFrame>> {
|
||||
let mut out = Vec::new();
|
||||
if h264.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(out);
|
||||
}
|
||||
unsafe {
|
||||
self.ensure_streaming()?;
|
||||
|
||||
let sample = make_input_sample(h264, pts_100ns)?;
|
||||
match self
|
||||
.transform
|
||||
.ProcessInput(self.input_stream_id, &sample, 0)
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => {}
|
||||
Err(e) => return Err(anyhow!("decoder ProcessInput failed: {e:#}")),
|
||||
|
||||
// Submit the sample, tolerating back-pressure. On NOTACCEPTING the
|
||||
// sample is NOT consumed, so we drain pending output and re-submit the
|
||||
// same `&sample`.
|
||||
loop {
|
||||
match self
|
||||
.transform
|
||||
.ProcessInput(self.input_stream_id, &sample, 0)
|
||||
{
|
||||
// Input accepted (or accepted while still wanting more).
|
||||
Ok(()) => break,
|
||||
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => break,
|
||||
// Back-pressure: drain a pending output, then retry the SAME
|
||||
// sample (it was not consumed).
|
||||
Err(e) if e.code() == MF_E_NOTACCEPTING => {
|
||||
match self.drain_one()? {
|
||||
Some(frame) => {
|
||||
out.push(frame);
|
||||
continue;
|
||||
}
|
||||
// Pathological: decoder won't accept input yet has
|
||||
// nothing to drain. Don't spin — warn once and drop
|
||||
// this access unit.
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"H.264 decoder reported NOTACCEPTING with no drainable output; dropping access unit"
|
||||
);
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(anyhow!("decoder ProcessInput failed: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
self.drain_one()
|
||||
// Drain every output the decoder has ready for this input.
|
||||
while let Some(frame) = self.drain_one()? {
|
||||
out.push(frame);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain one decoded output sample, handling the initial NV12 output-type
|
||||
/// negotiation (`MF_E_TRANSFORM_STREAM_CHANGE`).
|
||||
unsafe fn drain_one(&mut self) -> Result<Option<DecodedFrame>> {
|
||||
// Tracks whether we have already (re)negotiated the output type during
|
||||
// THIS drain call. Guards against spinning forever if the decoder keeps
|
||||
// surfacing TYPE_NOT_SET / STREAM_CHANGE without making progress.
|
||||
let mut negotiated = false;
|
||||
loop {
|
||||
// If we have not yet set an output type, do so now (NV12). The first
|
||||
// ProcessOutput typically returns STREAM_CHANGE until this is set.
|
||||
if self.width == 0 {
|
||||
// Try to set NV12 output; ignore failures here (the decoder may
|
||||
// require a STREAM_CHANGE round-trip first).
|
||||
let _ = self.configure_output_nv12();
|
||||
}
|
||||
|
||||
let stream_info: MFT_OUTPUT_STREAM_INFO = self
|
||||
.transform
|
||||
.GetOutputStreamInfo(self.output_stream_id)
|
||||
@@ -214,11 +269,26 @@ impl H264Decoder {
|
||||
}));
|
||||
}
|
||||
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => return Ok(None),
|
||||
Err(e) if e.code() == MF_E_TRANSFORM_STREAM_CHANGE => {
|
||||
// The decoder learned the frame size: (re)negotiate NV12 out,
|
||||
// record the size, and retry the drain.
|
||||
self.configure_output_nv12()
|
||||
// Both of these mean "you must (re)negotiate the output type now."
|
||||
// STREAM_CHANGE fires once the decoder has parsed the sequence
|
||||
// header and learned the real frame size; depending on input
|
||||
// timing the MS decoder may surface TYPE_NOT_SET instead. Handle
|
||||
// them identically: enumerate the decoder's available output
|
||||
// types, set the NV12 one, record the negotiated size, and retry.
|
||||
Err(e)
|
||||
if e.code() == MF_E_TRANSFORM_STREAM_CHANGE
|
||||
|| e.code() == MF_E_TRANSFORM_TYPE_NOT_SET =>
|
||||
{
|
||||
// We already negotiated once this drain yet the decoder still
|
||||
// demands a type: bail rather than spin forever.
|
||||
if negotiated {
|
||||
return Err(anyhow!(
|
||||
"decoder still reports output type not set after renegotiation: {e:#}"
|
||||
));
|
||||
}
|
||||
self.negotiate_output_type()
|
||||
.context("decoder output renegotiation after stream change")?;
|
||||
negotiated = true;
|
||||
if let Ok((w, h)) = self.read_output_size() {
|
||||
self.width = w;
|
||||
self.height = h;
|
||||
|
||||
@@ -27,9 +27,8 @@ use tracing::trace;
|
||||
use windows::{
|
||||
Win32::Foundation::{LPARAM, LRESULT, WPARAM},
|
||||
Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, DispatchMessageW, PeekMessageW, SetWindowsHookExW, TranslateMessage,
|
||||
UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, LLKHF_EXTENDED, MSG, PM_REMOVE,
|
||||
WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
|
||||
CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT,
|
||||
LLKHF_EXTENDED, WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -237,18 +236,6 @@ fn current_modifiers() -> proto::Modifiers {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pump Windows message queue (required for hooks to work).
|
||||
#[cfg(windows)]
|
||||
pub fn pump_messages() {
|
||||
unsafe {
|
||||
let mut msg = MSG::default();
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-Windows stubs
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
@@ -262,10 +249,6 @@ impl KeyboardHook {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
pub fn pump_messages() {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -74,20 +74,27 @@ fn spawn_h264_decode_worker(
|
||||
|
||||
let dec = decoder.as_mut().expect("decoder present after init");
|
||||
match dec.decode(&data, pts) {
|
||||
Ok(Some(decoded)) => {
|
||||
let frame = render::FrameData {
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
data: decoded.bgra,
|
||||
compressed: false, // already BGRA
|
||||
is_keyframe: false,
|
||||
};
|
||||
if viewer_tx.blocking_send(ViewerEvent::Frame(frame)).is_err() {
|
||||
// Viewer closed; stop the worker.
|
||||
// One input access unit may yield zero, one, or more frames.
|
||||
Ok(frames) => {
|
||||
let mut viewer_closed = false;
|
||||
for decoded in frames {
|
||||
let frame = render::FrameData {
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
data: decoded.bgra,
|
||||
compressed: false, // already BGRA
|
||||
is_keyframe: false,
|
||||
};
|
||||
if viewer_tx.blocking_send(ViewerEvent::Frame(frame)).is_err() {
|
||||
// Viewer closed; stop the worker.
|
||||
viewer_closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if viewer_closed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None) => { /* decoder buffering; no output this tick */ }
|
||||
Err(e) => {
|
||||
warn!("H.264 decode error: {e:#}");
|
||||
}
|
||||
|
||||
@@ -465,9 +465,11 @@ impl ApplicationHandler for ViewerApp {
|
||||
// Keep checking for events
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
|
||||
// Process Windows messages for keyboard hook
|
||||
#[cfg(windows)]
|
||||
input::pump_messages();
|
||||
// NOTE: do NOT manually pump the Win32 message queue here. winit's own
|
||||
// run_app loop already pumps this thread's messages (which also services
|
||||
// the low-level keyboard hook). A manual PeekMessage/DispatchMessage pump
|
||||
// inside about_to_wait steals winit's messages and re-enters its window
|
||||
// proc, freezing the event loop after one iteration (blank viewer).
|
||||
|
||||
// Request redraw periodically to check for new frames
|
||||
if let Some(window) = &self.window {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
58
changelogs/agent/v0.3.0.md
Normal file
58
changelogs/agent/v0.3.0.md
Normal 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)
|
||||
|
||||
58
changelogs/dashboard/v0.3.0.md
Normal file
58
changelogs/dashboard/v0.3.0.md
Normal 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)
|
||||
|
||||
58
changelogs/server/v0.3.0.md
Normal file
58
changelogs/server/v0.3.0.md
Normal 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)
|
||||
|
||||
4
dashboard/package-lock.json
generated
4
dashboard/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@guruconnect/dashboard",
|
||||
"version": "2.0.0",
|
||||
"version": "0.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@guruconnect/dashboard",
|
||||
"version": "2.0.0",
|
||||
"version": "0.2.2",
|
||||
"license": "Proprietary",
|
||||
"dependencies": {
|
||||
"@fontsource/hanken-grotesk": "^5.0.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@guruconnect/dashboard",
|
||||
"version": "2.0.0",
|
||||
"version": "0.3.0",
|
||||
"description": "GuruConnect v2 operator dashboard",
|
||||
"author": "AZ Computer Guru",
|
||||
"license": "Proprietary",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { http } from "./client";
|
||||
import type {
|
||||
BulkRemoveResponse,
|
||||
CreatedKey,
|
||||
DeleteMachineParams,
|
||||
DeleteMachineResponse,
|
||||
@@ -29,12 +30,21 @@ export function getMachineHistory(
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE /api/machines/:agent_id — remove a machine, optionally uninstall/export. */
|
||||
/**
|
||||
* DELETE /api/machines/:agent_id — remove a machine (admin only).
|
||||
*
|
||||
* Two server-side modes, selected by the query flags:
|
||||
* - `purge: true` → soft-delete + purge the in-memory session (Task 5
|
||||
* operator removal of ghost rows). Mutually exclusive with uninstall/export.
|
||||
* - otherwise → the legacy hard delete, optionally commanding the agent
|
||||
* to uninstall and/or returning full history in the response.
|
||||
*/
|
||||
export function deleteMachine(
|
||||
agentId: string,
|
||||
params: DeleteMachineParams = {},
|
||||
): Promise<DeleteMachineResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.purge) qs.set("purge", "true");
|
||||
if (params.uninstall) qs.set("uninstall", "true");
|
||||
if (params.export) qs.set("export", "true");
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : "";
|
||||
@@ -43,6 +53,22 @@ export function deleteMachine(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/machines/bulk-remove — remove many machines at once (admin only).
|
||||
* Each id is soft-deleted + its session purged when `purge` is true. Invalid or
|
||||
* unknown ids are reported per-id in the response rather than failing the batch;
|
||||
* the server caps the batch at 500.
|
||||
*/
|
||||
export function bulkRemoveMachines(
|
||||
ids: string[],
|
||||
purge = true,
|
||||
): Promise<BulkRemoveResponse> {
|
||||
return http.post<BulkRemoveResponse>("/api/machines/bulk-remove", {
|
||||
ids,
|
||||
purge,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Admin: per-agent keys --------------------------------------------------
|
||||
|
||||
/** GET /api/machines/:agent_id/keys — list key metadata (admin only). */
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { http } from "./client";
|
||||
import type { Session, ViewerTokenResponse } from "./types";
|
||||
import type {
|
||||
RemoveSessionResponse,
|
||||
Session,
|
||||
ViewerTokenResponse,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* GET /api/sessions — all live sessions known to the relay's in-memory session
|
||||
@@ -27,10 +31,25 @@ export function mintViewerToken(
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/sessions/:id — disconnect/end a live session. The relay sends a
|
||||
* Disconnect to the agent. Returns 200 on success, 404 if the session is not
|
||||
* found. Requires an authenticated dashboard JWT (not admin-gated server-side).
|
||||
* DELETE /api/sessions/:id — disconnect/end a live session (admin only). The
|
||||
* relay sends a Disconnect to the agent. Returns 200 on success, 404 if the
|
||||
* session is not live in memory. This is the live-only path (no `purge`); it
|
||||
* does not soft-delete any persisted row.
|
||||
*/
|
||||
export function endSession(sessionId: string): Promise<void> {
|
||||
return http.del<void>(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/sessions/:id?purge=true — operator removal of a session (admin
|
||||
* only). Soft-deletes the persisted `connect_sessions` row and drops any live
|
||||
* in-memory session, clearing a ghost/stale session from the console. 404 only
|
||||
* when neither a live nor a persisted session exists.
|
||||
*/
|
||||
export function purgeSession(
|
||||
sessionId: string,
|
||||
): Promise<RemoveSessionResponse> {
|
||||
return http.del<RemoveSessionResponse>(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}?purge=true`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +164,13 @@ export interface DeleteMachineParams {
|
||||
uninstall?: boolean;
|
||||
/** Include full history in the delete response before removal. */
|
||||
export?: boolean;
|
||||
/**
|
||||
* Operator-removal (Task 5): soft-delete the machine and purge its in-memory
|
||||
* session so a ghost row disappears from the console. Selects the server's
|
||||
* `?purge=true` path (admin-only). Mutually exclusive with the legacy
|
||||
* `uninstall`/`export` hard-delete options.
|
||||
*/
|
||||
purge?: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteMachineResponse {
|
||||
@@ -173,6 +180,38 @@ export interface DeleteMachineResponse {
|
||||
history: MachineHistory | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-id outcome in a bulk machine removal. Mirrors
|
||||
* `api::removal::BulkRemoveItem`. `status` is one of `removed` | `not_found` |
|
||||
* `invalid` | `error` (widened to string for forward compatibility).
|
||||
*/
|
||||
export interface BulkRemoveItem {
|
||||
agent_id: string;
|
||||
status: "removed" | "not_found" | "invalid" | "error" | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body for `POST /api/machines/bulk-remove`. Mirrors
|
||||
* `api::removal::BulkRemoveRequest`. The server caps the batch at 500 ids and
|
||||
* defaults `purge` to true; we always send it explicitly for the operator
|
||||
* removal workflow.
|
||||
*/
|
||||
export interface BulkRemoveRequest {
|
||||
ids: string[];
|
||||
purge: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary body for a bulk removal. Mirrors `api::removal::BulkRemoveResponse`.
|
||||
* `requested` is the batch size, `removed` the count that actually soft-deleted,
|
||||
* and `results` the per-id outcomes.
|
||||
*/
|
||||
export interface BulkRemoveResponse {
|
||||
requested: number;
|
||||
removed: number;
|
||||
results: BulkRemoveItem[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sessions (live relay state)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -221,6 +260,18 @@ export interface Session {
|
||||
consent_state: ConsentState | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from `DELETE /api/sessions/:id?purge=true`. Mirrors
|
||||
* `api::removal::RemoveSessionResponse`. `soft_deleted` is whether a persisted
|
||||
* `connect_sessions` row was marked deleted (false when the session was only
|
||||
* live in memory, e.g. an attended session that never persisted).
|
||||
*/
|
||||
export interface RemoveSessionResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
soft_deleted: boolean;
|
||||
}
|
||||
|
||||
/** Access mode the relay grants a minted viewer token. */
|
||||
export type ViewerAccess = "control" | "view_only";
|
||||
|
||||
|
||||
@@ -53,6 +53,33 @@
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
/* Selection column — fixed narrow rail to the left of the status dot. */
|
||||
.dt__select {
|
||||
width: 34px;
|
||||
padding-left: 16px !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
/* Generous hit target around the checkbox; the label also stops row-click. */
|
||||
.dt__checkwrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
margin: -6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dt__check {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dt__check:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Cell affordances. */
|
||||
.dt__mono {
|
||||
font-family: var(--font-mono);
|
||||
@@ -151,3 +178,19 @@
|
||||
.toolbar__count .mono {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Bulk-action bar: replaces the count readout when rows are selected. */
|
||||
.bulkbar {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.bulkbar__count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.bulkbar__count .mono {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
112
dashboard/src/features/machines/BulkRemoveMachinesDialog.tsx
Normal file
112
dashboard/src/features/machines/BulkRemoveMachinesDialog.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,13 @@ interface DeleteMachineDialogProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* INTENTIONALLY UNWIRED. This legacy per-row delete dialog was superseded by
|
||||
* the admin-only purge Remove (RemoveMachineDialog / BulkRemoveMachinesDialog)
|
||||
* and currently has no caller. It is kept — not deleted — because it is the
|
||||
* only remaining caller pattern for the `uninstall`/`export` machine-delete
|
||||
* params, pending a future "full uninstall/export" admin action that will
|
||||
* re-wire it. Do not treat its lack of references as a wiring bug.
|
||||
*
|
||||
* Destructive machine removal with two options:
|
||||
* - uninstall: also command the agent to uninstall (only meaningful online)
|
||||
* - export: return full history in the delete response before removal
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ApiError } from "../../api/client";
|
||||
import type { Machine } from "../../api/types";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
@@ -21,9 +21,10 @@ import { Table, type Column } from "../../components/ui/Table";
|
||||
import { TableSkeleton } from "../../components/ui/TableSkeleton";
|
||||
import { absoluteTime, relativeTime } from "../../lib/time";
|
||||
import "./machines.css";
|
||||
import { DeleteMachineDialog } from "./DeleteMachineDialog";
|
||||
import { BulkRemoveMachinesDialog } from "./BulkRemoveMachinesDialog";
|
||||
import { MachineDetailDrawer } from "./MachineDetailDrawer";
|
||||
import { MachineKeysModal } from "./MachineKeysModal";
|
||||
import { RemoveMachineDialog } from "./RemoveMachineDialog";
|
||||
import { useMachines } from "./hooks";
|
||||
|
||||
export function MachinesPage() {
|
||||
@@ -33,7 +34,14 @@ export function MachinesPage() {
|
||||
|
||||
const [detailFor, setDetailFor] = useState<Machine | null>(null);
|
||||
const [keysFor, setKeysFor] = useState<Machine | null>(null);
|
||||
const [deleteFor, setDeleteFor] = useState<Machine | null>(null);
|
||||
const [removeFor, setRemoveFor] = useState<Machine | null>(null);
|
||||
|
||||
// Bulk-select state (admin only). Keyed by agent_id so selection survives the
|
||||
// 20s poll re-ordering. A stale id (machine removed elsewhere) is harmless —
|
||||
// it is reconciled against the current list before any action.
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data } = machinesQuery;
|
||||
const machines = useMemo(() => data ?? [], [data]);
|
||||
@@ -53,7 +61,103 @@ export function MachinesPage() {
|
||||
[machines],
|
||||
);
|
||||
|
||||
// Selection is scoped to the currently-visible (filtered) rows.
|
||||
const selectedVisible = useMemo(
|
||||
() => filtered.filter((m) => selected.has(m.agent_id)),
|
||||
[filtered, selected],
|
||||
);
|
||||
const allVisibleSelected =
|
||||
filtered.length > 0 && selectedVisible.length === filtered.length;
|
||||
const someVisibleSelected =
|
||||
selectedVisible.length > 0 && !allVisibleSelected;
|
||||
|
||||
// Drop selections that no longer exist (removed, or filtered away by a poll
|
||||
// that dropped the machine) so the "Remove selected (N)" count stays truthful.
|
||||
useEffect(() => {
|
||||
setSelected((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const live = new Set(machines.map((m) => m.agent_id));
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
for (const id of prev) {
|
||||
if (live.has(id)) next.add(id);
|
||||
else changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [machines]);
|
||||
|
||||
// Reflect the partial state on the header checkbox (indeterminate is DOM-only).
|
||||
useEffect(() => {
|
||||
if (selectAllRef.current) {
|
||||
selectAllRef.current.indeterminate = someVisibleSelected;
|
||||
}
|
||||
}, [someVisibleSelected]);
|
||||
|
||||
function toggleOne(agentId: string, checked: boolean) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(agentId);
|
||||
else next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAllVisible(checked: boolean) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const m of filtered) {
|
||||
if (checked) next.add(m.agent_id);
|
||||
else next.delete(m.agent_id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
const columns: Column<Machine>[] = [
|
||||
// Admin-only selection rail. Built conditionally so non-admins never see it.
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
key: "select",
|
||||
cellClass: "dt__select",
|
||||
header: (
|
||||
<label className="dt__checkwrap">
|
||||
<input
|
||||
ref={selectAllRef}
|
||||
type="checkbox"
|
||||
className="dt__check"
|
||||
checked={allVisibleSelected}
|
||||
onChange={(e) => toggleAllVisible(e.target.checked)}
|
||||
aria-label={
|
||||
allVisibleSelected
|
||||
? "Deselect all machines"
|
||||
: "Select all machines"
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
render: (m: Machine) => (
|
||||
<label
|
||||
className="dt__checkwrap"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="dt__check"
|
||||
checked={selected.has(m.agent_id)}
|
||||
onChange={(e) => toggleOne(m.agent_id, e.target.checked)}
|
||||
aria-label={`Select ${m.hostname}`}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
} satisfies Column<Machine>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "status",
|
||||
header: "",
|
||||
@@ -132,15 +236,18 @@ export function MachinesPage() {
|
||||
Keys
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setDeleteFor(m)}
|
||||
aria-label={`Remove ${m.hostname}`}
|
||||
>
|
||||
<TrashIcon width={14} height={14} />
|
||||
Delete
|
||||
</Button>
|
||||
{/* Removal is admin-only, mirroring the server's 403 for non-admins. */}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setRemoveFor(m)}
|
||||
aria-label={`Remove ${m.hostname}`}
|
||||
>
|
||||
<TrashIcon width={14} height={14} />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -177,10 +284,40 @@ export function MachinesPage() {
|
||||
aria-label="Filter machines"
|
||||
/>
|
||||
</div>
|
||||
<div className="toolbar__count">
|
||||
<span className="mono">{onlineCount}</span> online ·{" "}
|
||||
<span className="mono">{machines.length}</span> total
|
||||
</div>
|
||||
{/* Bar count tracks the VISIBLE selected rows — the exact set the
|
||||
bulk dialog will state and remove. Under an active filter that
|
||||
hides some selected rows, this keeps bar == dialog == removed.
|
||||
Keyed off selectedVisible so a "Remove selected (0)" bar never
|
||||
shows when the only selections are filtered out of view. */}
|
||||
{isAdmin && selectedVisible.length > 0 ? (
|
||||
<div className="bulkbar" role="status">
|
||||
<span className="bulkbar__count">
|
||||
<span className="mono">{selectedVisible.length}</span> selected
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearSelection}
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setBulkOpen(true)}
|
||||
aria-label={`Remove ${selectedVisible.length} selected machines`}
|
||||
>
|
||||
<TrashIcon width={14} height={14} />
|
||||
Remove selected ({selectedVisible.length})
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="toolbar__count">
|
||||
<span className="mono">{onlineCount}</span> online ·{" "}
|
||||
<span className="mono">{machines.length}</span> total
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,7 +327,11 @@ export function MachinesPage() {
|
||||
Loading machines
|
||||
</span>
|
||||
<TableSkeleton
|
||||
headers={["", "Hostname", "OS", "Mode", "Last seen", "Agent ID", ""]}
|
||||
headers={
|
||||
isAdmin
|
||||
? ["", "", "Hostname", "OS", "Mode", "Last seen", "Agent ID", ""]
|
||||
: ["", "Hostname", "OS", "Mode", "Last seen", "Agent ID", ""]
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : machinesQuery.isError ? (
|
||||
@@ -239,7 +380,20 @@ export function MachinesPage() {
|
||||
{isAdmin && (
|
||||
<MachineKeysModal machine={keysFor} onClose={() => setKeysFor(null)} />
|
||||
)}
|
||||
<DeleteMachineDialog machine={deleteFor} onClose={() => setDeleteFor(null)} />
|
||||
{isAdmin && (
|
||||
<RemoveMachineDialog
|
||||
machine={removeFor}
|
||||
onClose={() => setRemoveFor(null)}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<BulkRemoveMachinesDialog
|
||||
open={bulkOpen}
|
||||
agentIds={selectedVisible.map((m) => m.agent_id)}
|
||||
onClose={() => setBulkOpen(false)}
|
||||
onRemoved={clearSelection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
73
dashboard/src/features/machines/RemoveMachineDialog.tsx
Normal file
73
dashboard/src/features/machines/RemoveMachineDialog.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ApiError } from "../../api/client";
|
||||
import type { Machine } from "../../api/types";
|
||||
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
|
||||
import { useToast } from "../../components/ui/toast-context";
|
||||
import { useDeleteMachine } from "./hooks";
|
||||
|
||||
interface RemoveMachineDialogProps {
|
||||
/** The machine to remove, or null when the dialog is closed. */
|
||||
machine: Machine | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator removal (Task 5): purge a single machine from the console. This is
|
||||
* the soft-delete path (`?purge=true`) used to clear ghost/stale rows — it does
|
||||
* NOT uninstall the agent or export history (that is the separate full-delete
|
||||
* dialog). On confirm the row is soft-deleted and its live session dropped; a
|
||||
* genuinely-reconnecting machine re-appears on its next check-in.
|
||||
*/
|
||||
export function RemoveMachineDialog({
|
||||
machine,
|
||||
onClose,
|
||||
}: RemoveMachineDialogProps) {
|
||||
const toast = useToast();
|
||||
const remove = useDeleteMachine();
|
||||
|
||||
function onConfirm() {
|
||||
if (!machine) return;
|
||||
remove.mutate(
|
||||
{ agentId: machine.agent_id, params: { purge: true } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
"Machine removed",
|
||||
`${machine.hostname} was removed from the console.`,
|
||||
);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
"Could not remove machine",
|
||||
err instanceof ApiError
|
||||
? `${err.message}${err.code ? ` (${err.code})` : ""}`
|
||||
: "The server did not respond. The machine was not removed.",
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={machine != null}
|
||||
title="Remove machine?"
|
||||
danger
|
||||
busy={remove.isPending}
|
||||
confirmLabel="Remove machine"
|
||||
cancelLabel="Keep machine"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onClose}
|
||||
body={
|
||||
machine ? (
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Remove <strong>{machine.hostname}</strong> from the GuruConnect
|
||||
console. Its live session is dropped and the row disappears from the
|
||||
list. If this machine is genuinely still in service it re-appears the
|
||||
next time its agent checks in.
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,21 @@ export function useDeleteMachine() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-remove (purge) many machines, then invalidate the list so the removed
|
||||
* rows drop. Resolves with the per-id summary so the caller can report how many
|
||||
* actually removed vs. were not found / invalid.
|
||||
*/
|
||||
export function useBulkRemoveMachines() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (ids: string[]) => machinesApi.bulkRemoveMachines(ids, true),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: MACHINES_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Admin: per-agent keys --------------------------------------------------
|
||||
|
||||
export function useMachineKeys(agentId: string | null, enabled: boolean) {
|
||||
|
||||
68
dashboard/src/features/sessions/RemoveSessionDialog.tsx
Normal file
68
dashboard/src/features/sessions/RemoveSessionDialog.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { ApiError } from "../../api/client";
|
||||
import type { Session } from "../../api/types";
|
||||
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
|
||||
import { useToast } from "../../components/ui/toast-context";
|
||||
import { usePurgeSession } from "./hooks";
|
||||
|
||||
interface RemoveSessionDialogProps {
|
||||
/** The session to remove, or null when the dialog is closed. */
|
||||
session: Session | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator removal (Task 5): purge a session from the console. Unlike "End"
|
||||
* (which only disconnects a live agent), this soft-deletes the persisted row and
|
||||
* drops any in-memory session, clearing a ghost/stale session — including one
|
||||
* for an agent that is already offline.
|
||||
*/
|
||||
export function RemoveSessionDialog({
|
||||
session,
|
||||
onClose,
|
||||
}: RemoveSessionDialogProps) {
|
||||
const toast = useToast();
|
||||
const purge = usePurgeSession();
|
||||
|
||||
function onConfirm() {
|
||||
if (!session) return;
|
||||
purge.mutate(session.id, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
"Session removed",
|
||||
`Cleared the session for ${session.agent_name}.`,
|
||||
);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
"Could not remove session",
|
||||
err instanceof ApiError
|
||||
? `${err.message}${err.code ? ` (${err.code})` : ""}`
|
||||
: "The relay did not respond. The session was not removed.",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={session != null}
|
||||
title="Remove session?"
|
||||
danger
|
||||
busy={purge.isPending}
|
||||
confirmLabel="Remove session"
|
||||
cancelLabel="Keep session"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onClose}
|
||||
body={
|
||||
session ? (
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Remove the session for <strong>{session.agent_name}</strong> from the
|
||||
console. Any live connection is dropped and the row disappears from
|
||||
the list. This clears a stale or ghost session and cannot be undone.
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
RefreshIcon,
|
||||
SearchIcon,
|
||||
StopIcon,
|
||||
TrashIcon,
|
||||
} from "../../components/layout/icons";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
@@ -21,6 +22,7 @@ import { TableSkeleton } from "../../components/ui/TableSkeleton";
|
||||
import { absoluteTime, relativeTime } from "../../lib/time";
|
||||
import { EndSessionDialog } from "./EndSessionDialog";
|
||||
import { JoinSessionModal } from "./JoinSessionModal";
|
||||
import { RemoveSessionDialog } from "./RemoveSessionDialog";
|
||||
import { useSessions } from "./hooks";
|
||||
import "./sessions.css";
|
||||
|
||||
@@ -44,6 +46,7 @@ export function SessionsPage() {
|
||||
|
||||
const [joinFor, setJoinFor] = useState<Session | null>(null);
|
||||
const [endFor, setEndFor] = useState<Session | null>(null);
|
||||
const [removeFor, setRemoveFor] = useState<Session | null>(null);
|
||||
|
||||
// The same authz split the server enforces at mint time: admin OR the
|
||||
// `control` permission yields a control token; otherwise the floor is `view`.
|
||||
@@ -193,6 +196,19 @@ export function SessionsPage() {
|
||||
<StopIcon width={14} height={14} />
|
||||
End
|
||||
</Button>
|
||||
{/* Operator removal (admin only): purge a stale/ghost session,
|
||||
including offline ones the live-only "End" cannot clear. */}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setRemoveFor(s)}
|
||||
aria-label={`Remove session on ${s.agent_name}`}
|
||||
>
|
||||
<TrashIcon width={14} height={14} />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@@ -301,6 +317,12 @@ export function SessionsPage() {
|
||||
onClose={() => setJoinFor(null)}
|
||||
/>
|
||||
<EndSessionDialog session={endFor} onClose={() => setEndFor(null)} />
|
||||
{isAdmin && (
|
||||
<RemoveSessionDialog
|
||||
session={removeFor}
|
||||
onClose={() => setRemoveFor(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,3 +44,17 @@ export function useEndSession() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator-remove (purge) a session: soft-delete the persisted row + drop any
|
||||
* live in-memory session, then invalidate the list so the ghost row disappears.
|
||||
*/
|
||||
export function usePurgeSession() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => sessionsApi.purgeSession(sessionId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: SESSIONS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,11 +16,16 @@ stack. It ships independently of GuruRMM and integrates with it via a versioned
|
||||
> match, blacklist-on-WS, agent-plane rejects user JWTs via per-agent `cak_` keys). The feature specs below
|
||||
> (SPEC-003–009) are **work-items inside the later v2 phases** — see the mapping.
|
||||
>
|
||||
> **Remaining to formally exit Phase 1:** secure-session-core **Task 8** (end-to-end verification +
|
||||
> `/gc-audit --pass=security` re-audit + the manual CRITICAL checks) and Code-Review sign-off on Tasks 3–5
|
||||
> (implemented without a local toolchain at the time; since built + deployed). Live HW-H.264 validation is
|
||||
> also pending — raw+Zstd remains the shipping default. ~~Sprint 0 (relay-auth CRITICAL hotfix)~~ **not
|
||||
> needed — those fixes shipped in Tasks 2–3.**
|
||||
> **Phase 1 formally EXITED (2026-05-31).** secure-session-core **Task 8** is complete — end-to-end
|
||||
> functional verification (live CRITICAL boundary checks against the deployed binary: login-JWT→401,
|
||||
> wrong-session viewer token→403, JWT-as-agent-key→401) **plus the `/gc-audit --pass=security` re-audit:
|
||||
> PASS, 0 CRITICAL/HIGH/MEDIUM/LOW** ([report](../reports/2026-05-31-gc-audit.md)). Code-Review sign-off on
|
||||
> Tasks 3–5 landed earlier. On top of Phase 1, **SPEC-004 (Tasks 2/4/5 — machine_uid dedup, session
|
||||
> reaping, operator removal API+UI) is implemented, reviewed, deployed, and the 11 live ghost rows were
|
||||
> purged**; the agent is now **auto-versioned + Azure-Trusted-Signing-signed via `release.yml`** with
|
||||
> **v0.3.0 published** as the stable release. ~~Sprint 0 (relay-auth CRITICAL hotfix)~~ **not needed.**
|
||||
> Still pending (NOT a Phase-1 blocker): live HW-H.264 cross-GPU validation — **raw+Zstd remains the
|
||||
> shipping default** (`DEFAULT_PREFER_H264=false`) until H.264 is validated across GPUs.
|
||||
|
||||
### v2 phase mapping of current specs
|
||||
|
||||
@@ -43,8 +48,9 @@ stack. It ships independently of GuruRMM and integrates with it via a versioned
|
||||
|
||||
Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](specs/SPEC-001-operational-tooling-parity.md).
|
||||
|
||||
- [ ] **Code signing — Azure Trusted Signing in CI** — P1 — sign the Windows agent `.exe` via `jsign` (TRUSTEDSIGNING) in Gitea Actions, reusing the shared ACG cert profile. (SPEC-001 §2)
|
||||
- [ ] **Automatic versioning** — P1 — conventional-commit-driven version bump across agent/server/dashboard, embedded via `build.rs`. (SPEC-001 §3)
|
||||
- [x] **Code signing — Azure Trusted Signing in CI** — P1 — Windows agent `.exe` signed via `jsign` (TRUSTEDSIGNING) in `release.yml`, fail-closed (never publishes unsigned). Shipped with v0.3.0. (SPEC-001 §2)
|
||||
- [ ] **Signed beta/test release channel** — **P1 — NOW** — every binary we hand to a tester must be signed, but signing today only runs on a deliberate full `release.yml` dispatch; the automatic `build-and-test.yml` agent artifact is explicitly **unsigned**. Add a `channel: stable | beta` `workflow_dispatch` input to `release.yml`: `beta` signs the agent and publishes a prerelease-tagged Gitea release (e.g. `v0.4.0-beta.1`) **skipping the semver bump + changelog**; `stable` keeps the existing full path. Keeps signing secrets out of PR-triggered runs. (SPEC-001 §2)
|
||||
- [x] **Automatic versioning** — P1 — conventional-commit-driven version bump computed at dispatch in `release.yml`, embedded via `build.rs`. Shipped with v0.3.0. (SPEC-001 §3)
|
||||
- [ ] **Changelog generation & API** — P2 — `CHANGELOG.md` + per-version changelogs from conventional commits, served at `/api/changelog/...`. (SPEC-001 §4)
|
||||
- [ ] **Feature-request workflow** — P2 — `/gc-feature-request` skill producing `docs/specs/SPEC-NNN-*.md` and updating this roadmap. (SPEC-001 §1)
|
||||
- [ ] **Roadmap / ADR / spec tracking** — P1 — this file + `ARCHITECTURE_DECISIONS.md` + `docs/specs/`. (SPEC-001 §5) — *bootstrapped*
|
||||
@@ -61,6 +67,10 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
|
||||
- [x] Support-code (attended) and persistent (unattended) agent modes
|
||||
- [x] Protobuf-over-WSS transport, Zstd frame compression
|
||||
- [~] React/TS web viewer (`dashboard/src/components/RemoteViewer.tsx`) — embeddable session viewer
|
||||
- [ ] **Headless Linux mode (direct TTY access)** — P2 — Terminal-based remote access for Linux servers without GUI. PTY spawn (`openpty`), xterm.js web viewer, full ANSI/VT100 support. Enables server management, container debugging, emergency recovery via GuruConnect dashboard with audit logging. SSH replacement with centralized auth. ([SPEC-012](specs/SPEC-012-headless-linux-tty.md))
|
||||
- [ ] **Managed-agent SYSTEM service host + session broker** — P1 — convert the persistent agent from `HKCU Run` (user context) to a LocalSystem **service** that runs unattended (login screen, no user, across reboots) and spawns a per-session capture/input worker into the active desktop (Session 0 can't capture directly). Unblocks SPEC-016 Phase B end-to-end (the SYSTEM-ACL'd `cak_` store becomes readable; removes the Phase B fail-fast guard), enables true unattended access, and is the **broker primitive SPEC-013 builds on**. ([SPEC-018](specs/SPEC-018-managed-agent-service-host.md))
|
||||
- [ ] **Windows session selection and backstage mode** — P2 — Enumerate and switch between Windows user sessions (Terminal Services/RDP/Fast User Switching) and access Session 0 (backstage) for system-level admin tasks. ScreenConnect parity: session selector shows all logged-on users, instant switching without reconnect. Backstage mode provides terminal/command interface for services management without disrupting any user desktop. Critical for multi-user server environments. ([SPEC-013](specs/SPEC-013-session-selection-and-backstage.md))
|
||||
- [ ] **Configurable notification overlay on viewer connection** — P2 — Display a semi-transparent on-screen notification when a technician connects, showing technician name and company. Dashboard-configurable message template (supports `{{technician_name}}`, `{{company}}`, `{{time}}`), duration (5-60s), position (top-left/right, bottom-left/right, center), and dismissible behavior. Increases transparency and user awareness during remote support sessions. Compliance-friendly for privacy policies requiring user notification. ([SPEC-015](specs/SPEC-015-notification-overlay.md))
|
||||
- [ ] Multi-monitor switching — P2
|
||||
- [ ] File transfer — P3 (out of scope for native-remote-control v1)
|
||||
- [ ] Session recording — P3 (out of scope for native-remote-control v1)
|
||||
@@ -78,12 +88,15 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
|
||||
- [x] Sessions / machines / support-codes / events
|
||||
- [ ] **Full machine inventory in the connection DB** — P2 — persist per-machine device inventory (OS+locale+install, CPU/RAM, mfr/model/serial, external WAN IP captured server-side + private LAN IP + MAC, logged-on user, idle, time zone, uptime, local-admin) on `connect_machines`, refreshed each `AgentStatus`, shown in the dashboard machine detail (ScreenConnect "Guest Info" parity). Data layer for SPEC-002 Phase 2; closes GC side of agent-IP gap (todo 7459428e). **[→ v2 Phase 2]** ([SPEC-003](specs/SPEC-003-machine-inventory.md))
|
||||
- [ ] **Stable machine identity + session lifecycle reaping + operator removal** — P1 — give the agent a deterministic machine-derived `machine_uid` (Windows `MachineGuid`-based) so the same box can't register duplicates (root cause: `agent_id` is a config-file random UUID that a portable/misconfigured run regenerates each launch); key registration on it; add TTL reaping + same-machine supersede as defense-in-depth; and admin-gated per-row + multi-select bulk removal of stale sessions/units. Identity must be bound to the per-machine agent key (spoof guard). Fixes ghost-session accumulation seen on the live console (15 sessions / 0 live, ~10 orphans for one machine). **[→ v2 Phase 1]** ([SPEC-004](specs/SPEC-004-session-lifecycle-and-removal.md))
|
||||
- [ ] **Zero-touch per-site agent enrollment** — P1 — ScreenConnect-class managed enrollment: one signed installer per site, machines self-register on first run and the server mints a per-machine `cak_` bound to a deterministic `machine_uid` (dedups re-installs). Per-site **rotatable** enrollment key (long secret + `vN (XXXX)` fingerprint) — rotating blocks new enrollments from old installers, leaves enrolled agents untouched. Auto-approve + new-enrollment/site-move alert. **Sign base agent once (CI, shipped) + per-site signed wrapper that writes site config around the signed bytes — resolves SPEC-007's signature-vs-appended-config question.** Anticipated/deferred: enrollment policy + licensing, `--enroll-key`/`--reassign` flag overrides, technician-assisted interactive install. **[→ v2 Phase 1]** ([SPEC-016](specs/SPEC-016-zero-touch-enrollment.md))
|
||||
- [ ] **Machines list view — dual connection indicators + rich rows** — P2 — ScreenConnect "Access"-list parity: per-row Host/Guest two-segment connection bar (Guest=agent online, Host=viewer connected, with names + durations) and rich inline metadata (company, site, device type, tags, logged-on user + idle, client version in red when outdated). Server-enriches `/api/machines` with live session state + SPEC-003 inventory. **[→ v2 Phase 2]** ([SPEC-005](specs/SPEC-005-machines-list-view-parity.md))
|
||||
- [ ] Machines "by Company" tree nav with per-company counts — P3 — left-nav grouping sidebar (screenshot parity). Follow-up sub-item of SPEC-005.
|
||||
- [ ] **Universal machine search ("everything is searchable")** — P2 — server-side `?q=` on `/api/machines` matching case-insensitive substring across ALL attributes (OS, logged-on user, external/private IP, company, site, tag, serial, MAC, version, …), pg_trgm GIN-indexed; multi-term AND + optional field-scoped syntax (`os:`, `user:`, `ip:`). Replaces the hostname-only client filter. Depends on SPEC-003 (attrs must be persisted). **[→ v2 Phase 2]** ([SPEC-006](specs/SPEC-006-universal-machine-search.md))
|
||||
- [ ] **Managed-agent installer builder ("Build Installer")** — P2 — dashboard wizard to build a pre-labeled persistent-agent installer (Name/Company/Site/Department/Device Type/Tag/Type) with Download / Copy URL / Send Link, reusing the existing embed-config download path; adds department + device_type to EmbeddedConfig/AgentStatus so labels persist at install time. Pairs with revocable per-machine keys; signature-vs-appended-config is the key open question. **[→ v2 Phase 2]** ([SPEC-007](specs/SPEC-007-managed-agent-installer-builder.md))
|
||||
- [ ] **Managed-agent installer builder ("Build Installer")** — P2 — dashboard wizard to build a pre-labeled persistent-agent installer (Name/Company/Site/Department/Device Type/Tag/Type) with Download / Copy URL / Send Link, reusing the existing embed-config download path; adds department + device_type to EmbeddedConfig/AgentStatus so labels persist at install time. Pairs with revocable per-machine keys; the signature-vs-appended-config question is resolved by SPEC-016 (sign-once base + per-site signed wrapper, no PE append). **[→ v2 Phase 2]** ([SPEC-007](specs/SPEC-007-managed-agent-installer-builder.md))
|
||||
- [ ] **Valuable error messages (structured errors + no silent swallows)** — P2 — one structured API error envelope with stable codes + a correlation id that also lands in the logs; contextual tracing on server/agent; sweep the 37 `let _ =` swallows (the pattern that hid the migration-005 bug); dashboard surfaces the real cause + id instead of a generic line. **[→ v2 Phase 0/1 conventions]** ([SPEC-008](specs/SPEC-008-valuable-error-messages.md))
|
||||
- [ ] **Feature-rich, fully-documented management API** — P2 — everything the console can do, callable by API: OpenAPI 3.x generated from code (utoipa) + browsable docs at `/api/docs`, long-lived revocable scoped API tokens (PAT-style, distinct from the 24h JWT + agent keys), an API-completeness gap audit, and consistent pagination/error conventions. Distinct from the ADR-001 RMM integration contract. **[→ v2 Phase 3]** ([SPEC-009](specs/SPEC-009-feature-rich-documented-api.md))
|
||||
- [ ] **Branding and white-label configuration** — P2 — Allow MSPs to customize logo, colors, and product name for white-labeled remote support. Dashboard admin settings page with logo upload (PNG/SVG, max 2MB), brand hue slider (OKLCH 0-360°, default 184=cyan), product name override, company name, and favicon. Agent tray tooltip uses custom product name from registry. Singleton database table with public GET endpoint for unauthenticated rendering. CSS variables (`--brand-hue`, `--accent`, `--panel`) for dynamic theming. **[→ v2 Phase 2]** ([SPEC-014](specs/SPEC-014-branding-whitelabel.md))
|
||||
- [ ] **End-user (sub-user) remote access** — P2 (may be P3) — let a client pay for their employees to reach their *own* machines from home: a deny-by-default `end_user` login role, a locked-down end-user portal listing only granted machines, and Connect reusing the existing session-scoped viewer-token + relay path. Grant primitive already exists (`user_client_access`, migration 002); directory sync (AD/Entra/Google) is a separate future spec. **[→ new capability, post v2-console]** ([SPEC-017](specs/SPEC-017-end-user-remote-access.md))
|
||||
- [ ] Programmatic session pre-create + viewer-token (integration contract) — P2
|
||||
|
||||
## Security & Infrastructure
|
||||
@@ -93,5 +106,6 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- [ ] macOS / Linux remote-control agents — P3
|
||||
- [ ] **Cross-platform agent support (macOS and Linux)** — P2 — Enable remote control beyond Windows with native agents for macOS 12+ and Ubuntu 22.04+ LTS. Platform abstraction layer for capture/input/encoding, VideoToolbox (macOS) and VA-API (Linux) H.264 encoding, .app/.deb/.rpm packaging. Unblocks multi-platform MSP adoption. ([SPEC-010](specs/SPEC-010-cross-platform-agents.md))
|
||||
- [ ] **Mobile agent support (iOS and Android as remote control targets)** — P3 — Native mobile apps for supervised support sessions. iOS: ReplayKit screen sharing (view-only, no input injection due to sandboxing). Android: MediaProjection screen capture + Accessibility Service input injection. Support-code authentication, push notifications (APNs/FCM), consent-first model. Foreground-only operation (OS limitation). Distinct from GuruRMM SPEC-017 (MDM/management). ([SPEC-011](specs/SPEC-011-mobile-agents.md))
|
||||
- [ ] Auto-update for the agent — P3
|
||||
|
||||
358
docs/specs/SPEC-010-cross-platform-agents.md
Normal file
358
docs/specs/SPEC-010-cross-platform-agents.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# SPEC-010: Cross-Platform Agent Support (macOS and Linux)
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2
|
||||
**Requested By:** Mike Swanson (2026-05-30)
|
||||
**Estimated Effort:** X-Large
|
||||
|
||||
## Overview
|
||||
|
||||
Expand GuruConnect's agent support beyond Windows to include macOS and Linux platforms, enabling remote control and support across the full spectrum of enterprise and home computing environments. This cross-platform expansion addresses a critical market gap—ScreenConnect, Splashtop, and AnyDesk all support macOS/Linux, and GuruConnect's Windows-only limitation blocks adoption by multi-platform organizations. The primary technical challenge is abstracting platform-specific screen capture, input injection, and video encoding while maintaining the core protobuf-over-WSS transport and session model. Success criteria: feature parity with the Windows agent (capture, input, tray presence, persistent/support-code modes, H.264 encoding) on macOS 12+ and Ubuntu 22.04+ LTS.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
**macOS Agent (Priority 1):**
|
||||
- Screen capture via ScreenCaptureKit API (macOS 13+) with AVFoundation fallback (macOS 12)
|
||||
- Input injection via CGEvent
|
||||
- H.264 encoding via VideoToolbox
|
||||
- Menu bar icon (NSStatusItem) with status and exit option
|
||||
- `guruconnect://` protocol handler registration
|
||||
- .app bundle packaging and code signing
|
||||
- Screen Recording permission prompt and validation
|
||||
- Universal binary (x86_64 + arm64)
|
||||
|
||||
**Linux Agent (Priority 2):**
|
||||
- Screen capture via X11 (XShm) with Wayland pipewire fallback detection
|
||||
- Input injection via XTest (X11) or uinput (Wayland)
|
||||
- H.264 encoding via VA-API (hardware) or software fallback (libx264)
|
||||
- System tray icon via StatusNotifier/AppIndicator
|
||||
- `guruconnect://` protocol handler (.desktop file)
|
||||
- .deb packaging (Ubuntu/Debian) and .rpm (Fedora/RHEL)
|
||||
- x86_64 binary
|
||||
|
||||
**Shared Cross-Platform:**
|
||||
- Unified agent codebase with platform-specific modules behind traits
|
||||
- Same protobuf protocol (no wire format changes)
|
||||
- Same persistent/support-code authentication modes
|
||||
- Same AgentStatus metadata reporting (OS, architecture, uptime, logged-on user)
|
||||
- Viewer compatibility: existing native Windows viewer and web viewer work unchanged
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- **FreeBSD/BSD support** — defer; assess demand post-launch
|
||||
- **Wayland-native screen capture** — v1 uses X11 compatibility layer; native Wayland portal support is Phase 2
|
||||
- **Multi-monitor switching on macOS/Linux** — already deferred on Windows (P2 roadmap item); cross-platform parity follows
|
||||
- **macOS < 12** — 12.x is the minimum (released Oct 2021, 95%+ adoption)
|
||||
- **Linux distros outside Ubuntu 22.04+ / Debian 11+ / Fedora 38+ / RHEL 9+** — official support limited; community builds acceptable
|
||||
|
||||
## Architecture
|
||||
|
||||
### Agent Refactor: Platform Abstraction Layer
|
||||
|
||||
Current Windows agent (`agent/src/`) tightly couples platform APIs. Refactor into:
|
||||
|
||||
```
|
||||
agent/src/
|
||||
├── main.rs # CLI entry + platform dispatch
|
||||
├── session/ # Session logic (platform-agnostic)
|
||||
├── transport/ # WebSocket (unchanged)
|
||||
├── platform/ # NEW: trait definitions
|
||||
│ ├── mod.rs # PlatformCapture, PlatformInput, PlatformTray traits
|
||||
│ ├── windows/ # Windows impl (existing code refactored)
|
||||
│ │ ├── capture.rs # DXGI + GDI behind PlatformCapture
|
||||
│ │ ├── input.rs # Win32 SendInput behind PlatformInput
|
||||
│ │ ├── encoder.rs # Media Foundation H.264
|
||||
│ │ └── tray.rs # Win32 shell tray
|
||||
│ ├── macos/ # NEW: macOS impl
|
||||
│ │ ├── capture.rs # ScreenCaptureKit/AVFoundation
|
||||
│ │ ├── input.rs # CGEvent
|
||||
│ │ ├── encoder.rs # VideoToolbox H.264
|
||||
│ │ └── tray.rs # NSStatusItem (Objective-C via objc2 crate)
|
||||
│ └── linux/ # NEW: Linux impl
|
||||
│ ├── capture.rs # X11 XShm / pipewire detection
|
||||
│ ├── input.rs # XTest / uinput
|
||||
│ ├── encoder.rs # VA-API / libx264
|
||||
│ └── tray.rs # StatusNotifier
|
||||
├── encoder/ # Color conversion, raw fallback (platform-agnostic)
|
||||
├── viewer/ # Native viewer (winit cross-platform, unchanged)
|
||||
├── config.rs # Config (unchanged)
|
||||
└── install.rs # Installation (platform-specific #[cfg] blocks)
|
||||
```
|
||||
|
||||
**Key traits:**
|
||||
|
||||
```rust
|
||||
// agent/src/platform/mod.rs
|
||||
pub trait PlatformCapture: Send {
|
||||
fn capture_frame(&mut self) -> Result<CapturedFrame>;
|
||||
fn get_dimensions(&self) -> (u32, u32);
|
||||
}
|
||||
|
||||
pub trait PlatformInput: Send {
|
||||
fn inject_mouse(&mut self, event: MouseEvent) -> Result<()>;
|
||||
fn inject_keyboard(&mut self, event: KeyboardEvent) -> Result<()>;
|
||||
}
|
||||
|
||||
pub trait PlatformEncoder: Send {
|
||||
fn encode(&mut self, frame: &[u8], width: u32, height: u32) -> Result<Vec<u8>>;
|
||||
fn set_bitrate(&mut self, kbps: u32);
|
||||
}
|
||||
|
||||
pub trait PlatformTray: Send {
|
||||
fn show(&mut self, status: &str) -> Result<()>;
|
||||
fn poll_events(&mut self) -> Vec<TrayEvent>;
|
||||
}
|
||||
```
|
||||
|
||||
### Protobuf Changes
|
||||
|
||||
**None.** The existing `AgentStatus` message already carries OS/arch metadata; viewer consumes frames opaquely. Protocol remains unchanged.
|
||||
|
||||
### Database Schema
|
||||
|
||||
**No migration required.** The `connect_machines` table's `os` and `architecture` fields (part of SPEC-003 inventory) already accommodate macOS/Linux values.
|
||||
|
||||
### Agent Build & Packaging
|
||||
|
||||
**Cargo.toml adjustments:**
|
||||
|
||||
```toml
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-graphics = "0.23"
|
||||
core-foundation = "0.9"
|
||||
objc2 = "0.5"
|
||||
objc2-foundation = "0.2"
|
||||
objc2-app-kit = "0.2"
|
||||
security-framework = "2.9"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
x11 = { version = "2.21", features = ["xlib", "xtest", "xrandr"] }
|
||||
libva = "0.4"
|
||||
```
|
||||
|
||||
**macOS packaging:**
|
||||
|
||||
- `.app` bundle structure: `GuruConnect.app/Contents/MacOS/guruconnect`, `Info.plist` with protocol handler
|
||||
- Code signing: `codesign --sign "Developer ID Application: Arizona Computer Guru LLC"`
|
||||
- Notarization via `notarytool` (Apple requirement for macOS 10.15+)
|
||||
- Universal binary: `cargo build --release --target x86_64-apple-darwin && cargo build --release --target aarch64-apple-darwin && lipo -create ...`
|
||||
|
||||
**Linux packaging:**
|
||||
|
||||
- `.deb` via `cargo-deb`:
|
||||
```toml
|
||||
[package.metadata.deb]
|
||||
maintainer = "AZ Computer Guru <support@azcomputerguru.com>"
|
||||
depends = "libx11-6, libxext6, libxtst6"
|
||||
section = "net"
|
||||
assets = [
|
||||
["target/release/guruconnect", "usr/bin/", "755"],
|
||||
["deploy/linux/guruconnect.desktop", "usr/share/applications/", "644"],
|
||||
]
|
||||
```
|
||||
- `.rpm` via `cargo-generate-rpm`
|
||||
|
||||
### Distribution
|
||||
|
||||
- **Server downloads:** `/downloads/guruconnect-macos.dmg`, `/downloads/guruconnect-linux.deb`, `/downloads/guruconnect-linux.rpm`
|
||||
- **Dashboard detection:** browser User-Agent → suggest correct download
|
||||
- **Auto-update (out of scope for v1):** defer cross-platform updater to Phase 2
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Files to Create
|
||||
|
||||
**macOS:**
|
||||
- `agent/src/platform/macos/capture.rs` — ScreenCaptureKit stream + AVFoundation fallback
|
||||
- `agent/src/platform/macos/input.rs` — CGEventCreateMouseEvent, CGEventCreateKeyboardEvent, CGEventPost
|
||||
- `agent/src/platform/macos/encoder.rs` — VTCompressionSession (VideoToolbox)
|
||||
- `agent/src/platform/macos/tray.rs` — NSStatusBar + NSMenu via objc2
|
||||
- `agent/Info.plist.template` — bundle metadata, protocol handler (`CFBundleURLSchemes`)
|
||||
|
||||
**Linux:**
|
||||
- `agent/src/platform/linux/capture.rs` — X11Display::open, XShmGetImage; pipewire detection fallback (log warning)
|
||||
- `agent/src/platform/linux/input.rs` — XTestFakeMotionEvent, XTestFakeButtonEvent, XTestFakeKeyEvent
|
||||
- `agent/src/platform/linux/encoder.rs` — libva VAConfigAttrib + VABufferType; software fallback via openh264
|
||||
- `agent/src/platform/linux/tray.rs` — dbus org.kde.StatusNotifierItem protocol
|
||||
- `deploy/linux/guruconnect.desktop` — .desktop file with `MimeType=x-scheme-handler/guruconnect`
|
||||
|
||||
**Shared:**
|
||||
- `agent/src/platform/mod.rs` — trait definitions + platform factory `fn create_platform() -> Box<dyn Platform>`
|
||||
- Refactor `agent/src/capture/{dxgi,gdi}.rs` → `agent/src/platform/windows/capture.rs`
|
||||
- Refactor `agent/src/input/{mouse,keyboard}.rs` → `agent/src/platform/windows/input.rs`
|
||||
- Refactor `agent/src/encoder/h264.rs` → `agent/src/platform/windows/encoder.rs` (Media Foundation)
|
||||
|
||||
### Key Logic
|
||||
|
||||
**macOS screen capture (ScreenCaptureKit, macOS 13+):**
|
||||
|
||||
```rust
|
||||
// agent/src/platform/macos/capture.rs
|
||||
use core_graphics::display::CGDisplay;
|
||||
use objc2::rc::Id;
|
||||
use objc2_foundation::NSArray;
|
||||
use objc2_screen_capture_kit::{SCStreamConfiguration, SCStream, SCContentFilter, SCStreamOutputType};
|
||||
|
||||
pub struct MacOSCapture {
|
||||
stream: Id<SCStream>,
|
||||
latest_frame: Arc<Mutex<Option<Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl PlatformCapture for MacOSCapture {
|
||||
fn capture_frame(&mut self) -> Result<CapturedFrame> {
|
||||
// ScreenCaptureKit delivers frames to delegate callback
|
||||
let frame = self.latest_frame.lock().unwrap().clone()
|
||||
.ok_or(anyhow!("No frame available"))?;
|
||||
Ok(CapturedFrame { data: frame, width: self.width, height: self.height })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**macOS input injection:**
|
||||
|
||||
```rust
|
||||
// agent/src/platform/macos/input.rs
|
||||
use core_graphics::event::{CGEvent, CGEventType, CGMouseButton, EventField};
|
||||
|
||||
impl PlatformInput for MacOSInput {
|
||||
fn inject_mouse(&mut self, event: MouseEvent) -> Result<()> {
|
||||
let cg_event = match event.button {
|
||||
MouseButton::Left => CGEvent::new_mouse_event(
|
||||
None, CGEventType::LeftMouseDown,
|
||||
CGPoint::new(event.x as f64, event.y as f64),
|
||||
CGMouseButton::Left
|
||||
)?,
|
||||
// ...
|
||||
};
|
||||
cg_event.post(CGEventTapLocation::HID);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Linux X11 capture:**
|
||||
|
||||
```rust
|
||||
// agent/src/platform/linux/capture.rs
|
||||
use x11::xlib::{XOpenDisplay, XDefaultRootWindow, XGetImage, ZPixmap};
|
||||
use x11::xshm::{XShmAttach, XShmGetImage};
|
||||
|
||||
pub struct LinuxCapture {
|
||||
display: *mut Display,
|
||||
root: Window,
|
||||
shm_info: XShmSegmentInfo,
|
||||
}
|
||||
|
||||
impl PlatformCapture for LinuxCapture {
|
||||
fn capture_frame(&mut self) -> Result<CapturedFrame> {
|
||||
let img = unsafe { XShmGetImage(self.display, self.root, ...) };
|
||||
// Convert XImage BGRA → frame buffer
|
||||
Ok(CapturedFrame { data: converted, width, height })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Permission handling (macOS Screen Recording):**
|
||||
|
||||
```rust
|
||||
// agent/src/platform/macos/permissions.rs
|
||||
use objc2_av_foundation::AVCaptureDevice;
|
||||
|
||||
pub fn check_screen_recording_permission() -> bool {
|
||||
// macOS 10.15+ requires explicit Screen Recording permission
|
||||
// Trigger prompt on first run; subsequent checks use TCC database status
|
||||
AVCaptureDevice::authorizationStatusForMediaType(AVMediaTypeVideo) == AVAuthorizationStatusAuthorized
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### macOS Security
|
||||
|
||||
- **Screen Recording permission:** User must grant permission in System Settings → Privacy & Security → Screen Recording. Agent detects denial and logs instructions.
|
||||
- **Gatekeeper:** Code signing + notarization required to avoid "unidentified developer" block.
|
||||
- **Hardened runtime:** Enable hardened runtime entitlements (`com.apple.security.cs.allow-unsigned-executable-memory` for JIT, if needed).
|
||||
- **Input injection:** No additional permission for CGEvent (runs as the logged-on user).
|
||||
|
||||
### Linux Security
|
||||
|
||||
- **X11 security:** XTest extension enabled by default; uinput requires `/dev/uinput` write access (typically granted to `input` group).
|
||||
- **Wayland sandboxing:** v1 runs under XWayland (X11 compatibility); native Wayland requires PipeWire portal (user consent per-session).
|
||||
- **AppArmor/SELinux:** Agent may need profile exemptions for screen capture on hardened distros.
|
||||
|
||||
### Authentication
|
||||
|
||||
**No protocol changes.** macOS/Linux agents authenticate with the same support-code or persistent agent key (`AGENT_API_KEY` or per-agent `cak_*` key from SPEC-004). Relay server validates identically.
|
||||
|
||||
### Audit Events
|
||||
|
||||
Existing `events` table logs agent connections with `os` and `architecture` fields. No schema change needed.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Mock platform traits for capture/input/encoder modules
|
||||
- Test frame conversion (BGRA → RGB, stride alignment)
|
||||
- Test protobuf message serialization (unchanged, but validate on new platforms)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- **macOS test rig:** macOS 13 VM (UTM or Parallels), run agent, verify viewer connects and displays screen
|
||||
- **Linux test rig:** Ubuntu 22.04 LXC container (X11 forwarded), verify capture + input
|
||||
- **Cross-platform viewer:** existing Windows native viewer connects to macOS/Linux agents
|
||||
|
||||
### Manual Testing Scenarios
|
||||
|
||||
1. **macOS install:** Download `.app`, drag to Applications, launch, grant Screen Recording permission, enter support code, verify viewer connects.
|
||||
2. **Linux install:** `sudo dpkg -i guruconnect.deb`, run `guruconnect --support-code ABC123`, verify tray icon, test remote control.
|
||||
3. **Encoding quality:** Compare H.264 output from VideoToolbox (macOS), VA-API (Linux), Media Foundation (Windows) — validate frame rate and bitrate parity.
|
||||
4. **Input fidelity:** Test full keyboard (including modifiers, function keys) and mouse (click, drag, scroll) on all three platforms.
|
||||
5. **Protocol handler:** Click `guruconnect://connect?session=...` link, verify viewer launches with session ID pre-filled.
|
||||
|
||||
### CI/CD Additions
|
||||
|
||||
- **macOS build:** Gitea Actions runner on macOS (M1 or x86_64), build universal binary, sign, notarize
|
||||
- **Linux build:** Existing `ubuntu-latest` runner, build `.deb` and `.rpm` via cargo-deb/cargo-generate-rpm
|
||||
- **Artifact upload:** Upload binaries to `server/static/downloads/` on release tag
|
||||
|
||||
## Effort Estimate & Dependencies
|
||||
|
||||
**Size:** X-Large (12-16 weeks, 1 developer)
|
||||
|
||||
**Breakdown:**
|
||||
- Platform abstraction refactor (Windows code): 2 weeks
|
||||
- macOS implementation (capture, input, encoder, tray): 4 weeks
|
||||
- Linux implementation (X11 capture, input, VA-API encoder, tray): 3 weeks
|
||||
- Packaging and code signing (macOS notarization, .deb/.rpm): 2 weeks
|
||||
- Testing, documentation, CI/CD integration: 2 weeks
|
||||
- Buffer for platform-specific edge cases: 1-3 weeks
|
||||
|
||||
**Dependencies:**
|
||||
- **SPEC-002 v2 Phase 1 completion** — per-agent keys and secure session core must be stable (already shipped)
|
||||
- **SPEC-004 deterministic machine identity** — macOS/Linux need stable `machine_uid` derivation (macOS: `IOPlatformUUID`, Linux: `/etc/machine-id`)
|
||||
- **Code signing infrastructure:** Apple Developer Program account + Azure Trusted Signing already in place (for Windows); reuse for macOS
|
||||
- **Test infrastructure:** macOS VM or bare-metal test host, Linux LXC container with X11
|
||||
|
||||
**Unblocks:**
|
||||
- Multi-platform MSP adoption (organizations with mixed Windows/macOS/Linux fleets)
|
||||
- Education/developer market (high macOS/Linux penetration)
|
||||
- SPEC-003 machine inventory (macOS/Linux populate same fields)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Wayland native support timeline?** — v1 uses X11 compatibility (XWayland); when do we commit to PipeWire portal? Defer to Phase 2 pending Wayland adoption metrics.
|
||||
2. **ARM Linux (Raspberry Pi, ARM servers)?** — Ubuntu 22.04 arm64 is viable; add as a build target? Defer to community request.
|
||||
3. **macOS Ventura (13.0) vs. Monterey (12.0) adoption split?** — ScreenCaptureKit (13.0+) is preferred; fallback to AVFoundation for 12.x. Monitor 12.x usage after 6 months; deprecate if <5%.
|
||||
4. **Linux H.264 licensing?** — VA-API uses hardware codecs (no license issue); software fallback via openh264 (Cisco BSD license, royalty-free). Confirm distro packaging allows bundling.
|
||||
5. **System tray on headless Linux servers?** — Agent can run headless (tray is optional); `--no-tray` flag for server deployments.
|
||||
|
||||
---
|
||||
|
||||
**Cross-references:**
|
||||
- ADR-001: GuruConnect is a standalone product (no RMM coupling)
|
||||
- SPEC-002: v2 modernization architecture (agent key model)
|
||||
- SPEC-003: Machine inventory (OS/arch fields)
|
||||
- SPEC-004: Stable machine identity (macOS IOPlatformUUID, Linux /etc/machine-id)
|
||||
482
docs/specs/SPEC-011-mobile-agents.md
Normal file
482
docs/specs/SPEC-011-mobile-agents.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# SPEC-011: Mobile Agent Support (iOS and Android as Remote Control Targets)
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P3
|
||||
**Requested By:** Mike Swanson (2026-05-30)
|
||||
**Estimated Effort:** X-Large (16-20 weeks, requires mobile development expertise)
|
||||
|
||||
## Overview
|
||||
|
||||
Enable remote viewing and control of iOS/Android mobile devices by building native GuruConnect agent apps for the App Store and Google Play. Unlike desktop agents that run persistently in the background, mobile agents operate within OS sandbox constraints: they require user consent to share the screen, must remain in the foreground during sessions, and (on iOS) cannot inject input at all. The primary use case is **supervised support sessions**—a user opens the app, shares their screen with a technician, and the technician can see the screen (both platforms) and remotely control it (Android only). This is fundamentally different from desktop remote control; it's an attended, consent-driven support tool constrained by mobile OS security models.
|
||||
|
||||
**Use Cases:**
|
||||
- Support technician walks a customer through app setup while viewing their mobile screen in real-time
|
||||
- Android device troubleshooting with remote control (tap, swipe, type) via Accessibility Service
|
||||
- iOS device screen sharing for demonstration or guided support (view-only, user retains control)
|
||||
|
||||
**Success Criteria:**
|
||||
- iOS app (iOS 14+) shares screen via ReplayKit with user consent; viewer sees live screen; no input injection
|
||||
- Android app (Android 10+) shares screen via MediaProjection and accepts remote input via Accessibility Service
|
||||
- Both apps connect using same protobuf-over-WSS protocol and support-code authentication as desktop agents
|
||||
- Push notifications wake the app when a support session is requested
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
**iOS Agent (View-Only):**
|
||||
- Native Swift/SwiftUI app targeting iOS 14+ and iPadOS 14+
|
||||
- Screen capture via ReplayKit 2 (`RPScreenRecorder`, `RPBroadcastSampleHandler`)
|
||||
- H.264 encoding via VideoToolbox
|
||||
- User consent required: "Start Broadcast" button triggers system permission prompt
|
||||
- Foreground-only operation (app must remain visible during session)
|
||||
- Support-code authentication (6-digit code entry)
|
||||
- Push notification (APNs) to alert user of incoming support request
|
||||
- **NO input injection** (iOS sandboxing prevents third-party input; user controls their own device)
|
||||
- Displays "Session Active" banner with duration and "Stop Sharing" button
|
||||
|
||||
**Android Agent (View + Control):**
|
||||
- Native Kotlin/Jetpack Compose app targeting Android 10+ (API 29+)
|
||||
- Screen capture via MediaProjection API (requires user consent per session)
|
||||
- H.264 encoding via MediaCodec
|
||||
- Input injection via Accessibility Service (user must enable in Settings → Accessibility)
|
||||
- Foreground service with persistent notification during session
|
||||
- Support-code authentication (6-digit code entry)
|
||||
- Push notification (FCM) to alert user of incoming support request
|
||||
- Displays ongoing notification: "GuruConnect session active - Tap to stop"
|
||||
|
||||
**Shared Cross-Platform:**
|
||||
- Same protobuf protocol (`AgentStatus`, `FrameData`, `InputEvent`) as desktop agents
|
||||
- Support-code-only authentication (persistent agent mode deferred to Phase 2)
|
||||
- Relay server unchanged (mobile agents are just another platform)
|
||||
- Dashboard shows mobile devices with OS icon (iOS/Android) and "Mobile" badge
|
||||
- Existing native/web viewers display mobile screens without modification
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- **Persistent/unattended agent mode** — v1 is attended-only (user must open the app and consent)
|
||||
- **iOS input injection** — technically impossible without jailbreak or Apple Private APIs (violates App Store guidelines)
|
||||
- **Background screen capture** — both iOS and Android require the app to be foreground during capture
|
||||
- **File transfer** — defer to Phase 2 (not in desktop agents yet per roadmap)
|
||||
- **Chat** — defer to Phase 2 (desktop agents have it, but deprioritized for mobile v1)
|
||||
- **Multi-device support in single app** — one mobile device = one agent instance
|
||||
- **Tablet-optimized UI** — v1 UI is phone-first; iPad/Android tablet use same layout
|
||||
|
||||
## Architecture
|
||||
|
||||
### iOS App Structure
|
||||
|
||||
```
|
||||
GuruConnectMobile-iOS/
|
||||
├── App/
|
||||
│ ├── GuruConnectApp.swift # SwiftUI app entry
|
||||
│ ├── ContentView.swift # Main UI (support code entry, status)
|
||||
│ ├── SessionView.swift # Active session UI (duration, stop button)
|
||||
│ └── Info.plist # Capabilities, permissions
|
||||
├── Broadcast/ # ReplayKit broadcast extension
|
||||
│ ├── SampleHandler.swift # RPBroadcastSampleHandler subclass
|
||||
│ ├── VideoEncoder.swift # VideoToolbox H.264 encoding
|
||||
│ └── Info.plist # Extension config
|
||||
├── Shared/
|
||||
│ ├── Protocol/ # Protobuf messages (Swift codegen)
|
||||
│ ├── Transport/ # WebSocket client (Starscream)
|
||||
│ ├── Authentication.swift # Support code validation
|
||||
│ └── PushNotifications.swift # APNs registration + handling
|
||||
└── GuruConnectMobile.xcodeproj
|
||||
```
|
||||
|
||||
**ReplayKit architecture:**
|
||||
- Main app registers broadcast extension via `RPSystemBroadcastPickerView`
|
||||
- User taps "Start Broadcast" → system shows app picker → user selects GuruConnect
|
||||
- Extension (`SampleHandler`) receives CMSampleBuffers in `processSampleBuffer(_:with:)`
|
||||
- Extension encodes H.264 via VideoToolbox, sends frames to shared App Group container
|
||||
- Main app reads from shared container, sends frames via WebSocket to relay server
|
||||
- App Group required: shared data between app and extension (`group.com.azcomputerguru.guruconnect`)
|
||||
|
||||
### Android App Structure
|
||||
|
||||
```
|
||||
GuruConnectMobile-Android/
|
||||
├── app/src/main/
|
||||
│ ├── java/com/azcomputerguru/guruconnect/
|
||||
│ │ ├── MainActivity.kt # Jetpack Compose UI
|
||||
│ │ ├── SessionActivity.kt # Active session screen
|
||||
│ │ ├── ScreenCaptureService.kt # Foreground service, MediaProjection
|
||||
│ │ ├── InputAccessibilityService.kt # AccessibilityService for input injection
|
||||
│ │ ├── VideoEncoder.kt # MediaCodec H.264 encoding
|
||||
│ │ ├── WebSocketClient.kt # OkHttp WebSocket
|
||||
│ │ ├── ProtobufHandler.kt # Protobuf serialization
|
||||
│ │ └── PushMessagingService.kt # FCM receiver
|
||||
│ ├── res/
|
||||
│ │ ├── layout/ # XML layouts (if not full Compose)
|
||||
│ │ ├── values/strings.xml
|
||||
│ │ └── xml/accessibility_service_config.xml
|
||||
│ └── AndroidManifest.xml # Permissions, services
|
||||
├── proto/ # Protobuf definitions (shared with server)
|
||||
└── build.gradle
|
||||
```
|
||||
|
||||
**MediaProjection architecture:**
|
||||
- User grants MediaProjection permission via `MediaProjectionManager.createScreenCaptureIntent()`
|
||||
- `ScreenCaptureService` (foreground service) creates `VirtualDisplay` → frames to `ImageReader`
|
||||
- `VideoEncoder` encodes frames with `MediaCodec` (H.264)
|
||||
- `WebSocketClient` sends encoded frames to relay server
|
||||
- `InputAccessibilityService` receives `InputEvent` protobuf messages, dispatches `AccessibilityService.dispatchGesture()`
|
||||
|
||||
### Protobuf Changes
|
||||
|
||||
**Minor additions to support mobile-specific metadata:**
|
||||
|
||||
```protobuf
|
||||
// proto/guruconnect.proto
|
||||
|
||||
message AgentStatus {
|
||||
// Existing fields...
|
||||
optional MobileCapabilities mobile_capabilities = 20;
|
||||
}
|
||||
|
||||
message MobileCapabilities {
|
||||
bool supports_input_injection = 1; // false for iOS, true for Android (if Accessibility enabled)
|
||||
bool requires_foreground = 2; // true for both (can't capture in background)
|
||||
bool requires_user_consent = 3; // true for both (MediaProjection/ReplayKit consent)
|
||||
}
|
||||
|
||||
message InputEvent {
|
||||
// Existing MouseEvent/KeyboardEvent...
|
||||
optional TouchEvent touch_event = 3; // NEW: mobile touch events
|
||||
}
|
||||
|
||||
message TouchEvent {
|
||||
enum Action {
|
||||
DOWN = 0;
|
||||
MOVE = 1;
|
||||
UP = 2;
|
||||
}
|
||||
Action action = 1;
|
||||
float x = 2; // normalized 0.0-1.0
|
||||
float y = 3;
|
||||
int32 pointer_id = 4; // for multi-touch
|
||||
}
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
|
||||
**No migration required.** Mobile devices populate existing `connect_machines` table with:
|
||||
- `os`: "iOS" or "Android"
|
||||
- `os_version`: "17.2.1", "14.0", etc.
|
||||
- `architecture`: "arm64", "aarch64"
|
||||
- `device_type`: "iPhone 15 Pro", "Samsung Galaxy S24", etc. (from device model identifier)
|
||||
|
||||
### Push Notifications
|
||||
|
||||
**iOS (APNs):**
|
||||
- App registers for push on launch: `UNUserNotificationCenter.requestAuthorization()`
|
||||
- Server stores APNs device token in `connect_machines.push_token`
|
||||
- When viewer requests session, server sends APNs push: `{"aps": {"alert": "Support session requested", "sound": "default"}, "session_id": "..."}`
|
||||
- User taps notification → app opens, auto-fills support code, prompts to start broadcast
|
||||
|
||||
**Android (FCM):**
|
||||
- App registers with Firebase on launch, uploads FCM token to server
|
||||
- Server sends FCM push when session requested
|
||||
- User taps notification → `MainActivity` opens with support code pre-filled
|
||||
|
||||
### Input Injection (Android Only)
|
||||
|
||||
**AccessibilityService setup:**
|
||||
1. User enables service in Settings → Accessibility → GuruConnect → toggle ON
|
||||
2. App declares service in `AndroidManifest.xml`:
|
||||
```xml
|
||||
<service android:name=".InputAccessibilityService"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter><action android:name="android.accessibilityservice.AccessibilityService" /></intent-filter>
|
||||
<meta-data android:name="android.accessibilityservice.config"
|
||||
android:resource="@xml/accessibility_service_config" />
|
||||
</service>
|
||||
```
|
||||
3. During session, relay sends `InputEvent` (touch/swipe) → service dispatches:
|
||||
```kotlin
|
||||
val path = Path().apply { moveTo(x, y) }
|
||||
val gesture = GestureDescription.Builder()
|
||||
.addStroke(GestureDescription.StrokeDescription(path, 0, duration))
|
||||
.build()
|
||||
dispatchGesture(gesture, null, null)
|
||||
```
|
||||
|
||||
**iOS: No input injection.** Relay server detects `mobile_capabilities.supports_input_injection = false` and disables input controls in viewer UI (show "View-Only Mode" banner).
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Files to Create
|
||||
|
||||
**iOS (Swift/SwiftUI):**
|
||||
- `GuruConnectMobile-iOS/App/GuruConnectApp.swift` — App entry, scene setup
|
||||
- `GuruConnectMobile-iOS/App/ContentView.swift` — Support code entry, connection status
|
||||
- `GuruConnectMobile-iOS/App/SessionView.swift` — Active session UI (timer, stop button)
|
||||
- `GuruConnectMobile-iOS/Broadcast/SampleHandler.swift` — ReplayKit extension, frame capture
|
||||
- `GuruConnectMobile-iOS/Broadcast/VideoEncoder.swift` — VideoToolbox H.264 encoding
|
||||
- `GuruConnectMobile-iOS/Shared/Transport/WebSocketClient.swift` — Starscream WebSocket wrapper
|
||||
- `GuruConnectMobile-iOS/Shared/Protocol/Protobuf.swift` — Swift protobuf codegen
|
||||
- `GuruConnectMobile-iOS/Shared/PushNotifications.swift` — APNs registration + handling
|
||||
|
||||
**Android (Kotlin/Jetpack Compose):**
|
||||
- `app/src/main/java/.../MainActivity.kt` — Compose UI, support code entry
|
||||
- `app/src/main/java/.../SessionActivity.kt` — Active session screen
|
||||
- `app/src/main/java/.../ScreenCaptureService.kt` — MediaProjection foreground service
|
||||
- `app/src/main/java/.../InputAccessibilityService.kt` — Accessibility service for input
|
||||
- `app/src/main/java/.../VideoEncoder.kt` — MediaCodec H.264 encoding
|
||||
- `app/src/main/java/.../WebSocketClient.kt` — OkHttp WebSocket
|
||||
- `app/src/main/java/.../ProtobufHandler.kt` — Protobuf serialization (protobuf-javalite)
|
||||
- `app/src/main/java/.../PushMessagingService.kt` — FCM message receiver
|
||||
|
||||
**Server (minor additions):**
|
||||
- `server/src/push/` — NEW module for APNs/FCM push sending
|
||||
- `server/src/push/apns.rs` — APNs HTTP/2 client (via `a2` crate)
|
||||
- `server/src/push/fcm.rs` — FCM HTTP v1 client
|
||||
- `server/src/push/mod.rs` — Unified `send_push_notification(device_token, session_id)` API
|
||||
- `server/src/api/devices.rs` — NEW: `POST /api/devices/:id/push-token` to store APNs/FCM tokens
|
||||
- `proto/guruconnect.proto` — Add `MobileCapabilities` and `TouchEvent` messages
|
||||
|
||||
**Shared:**
|
||||
- `proto/guruconnect.proto` — Update with mobile messages (protobuf source of truth)
|
||||
|
||||
### Key Logic
|
||||
|
||||
**iOS ReplayKit screen capture:**
|
||||
|
||||
```swift
|
||||
// GuruConnectMobile-iOS/Broadcast/SampleHandler.swift
|
||||
import ReplayKit
|
||||
import VideoToolbox
|
||||
|
||||
class SampleHandler: RPBroadcastSampleHandler {
|
||||
var encoder: VideoEncoder?
|
||||
var wsClient: WebSocketClient?
|
||||
|
||||
override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) {
|
||||
encoder = VideoEncoder()
|
||||
wsClient = WebSocketClient(url: "wss://connect.azcomputerguru.com/ws/agent")
|
||||
wsClient?.connect(supportCode: setupInfo?["supportCode"] as? String)
|
||||
}
|
||||
|
||||
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
|
||||
guard sampleBufferType == .video else { return }
|
||||
|
||||
if let encoded = encoder?.encode(sampleBuffer) {
|
||||
let frameData = FrameData(data: encoded, width: 1920, height: 1080)
|
||||
wsClient?.send(frameData)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Android MediaProjection screen capture:**
|
||||
|
||||
```kotlin
|
||||
// ScreenCaptureService.kt
|
||||
class ScreenCaptureService : Service() {
|
||||
private lateinit var mediaProjection: MediaProjection
|
||||
private lateinit var virtualDisplay: VirtualDisplay
|
||||
private lateinit var imageReader: ImageReader
|
||||
private val encoder = VideoEncoder()
|
||||
private val wsClient = WebSocketClient()
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val resultCode = intent?.getIntExtra("resultCode", 0) ?: return START_NOT_STICKY
|
||||
val data = intent.getParcelableExtra<Intent>("data") ?: return START_NOT_STICKY
|
||||
|
||||
val projection = MediaProjectionManager.getMediaProjection(resultCode, data)
|
||||
imageReader = ImageReader.newInstance(1920, 1080, PixelFormat.RGBA_8888, 2)
|
||||
|
||||
virtualDisplay = projection.createVirtualDisplay(
|
||||
"GuruConnect",
|
||||
1920, 1080, densityDpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
imageReader.surface, null, null
|
||||
)
|
||||
|
||||
imageReader.setOnImageAvailableListener({ reader ->
|
||||
val image = reader.acquireLatestImage()
|
||||
val encoded = encoder.encode(image)
|
||||
wsClient.sendFrame(encoded)
|
||||
image.close()
|
||||
}, backgroundHandler)
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Android Accessibility Service input injection:**
|
||||
|
||||
```kotlin
|
||||
// InputAccessibilityService.kt
|
||||
class InputAccessibilityService : AccessibilityService() {
|
||||
|
||||
fun injectTouch(x: Float, y: Float, action: TouchEvent.Action) {
|
||||
val displayMetrics = resources.displayMetrics
|
||||
val absX = x * displayMetrics.widthPixels
|
||||
val absY = y * displayMetrics.heightPixels
|
||||
|
||||
val path = Path().apply { moveTo(absX, absY) }
|
||||
val duration = if (action == TouchEvent.Action.DOWN || action == TouchEvent.Action.UP) 10L else 50L
|
||||
|
||||
val gesture = GestureDescription.Builder()
|
||||
.addStroke(GestureDescription.StrokeDescription(path, 0, duration))
|
||||
.build()
|
||||
|
||||
dispatchGesture(gesture, object : GestureResultCallback() {
|
||||
override fun onCompleted(gestureDescription: GestureDescription) {
|
||||
Log.d("GC", "Touch injected: ($absX, $absY)")
|
||||
}
|
||||
}, null)
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {}
|
||||
override fun onInterrupt() {}
|
||||
}
|
||||
```
|
||||
|
||||
**Push notification handling (iOS):**
|
||||
|
||||
```swift
|
||||
// PushNotifications.swift
|
||||
import UserNotifications
|
||||
|
||||
class PushNotificationHandler: NSObject, UNUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
if let sessionId = userInfo["session_id"] as? String {
|
||||
// Navigate to SessionView with pre-filled support code
|
||||
NotificationCenter.default.post(name: .sessionRequested, object: sessionId)
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### iOS Security
|
||||
|
||||
- **Screen Recording permission:** User must explicitly start ReplayKit broadcast; cannot be triggered remotely
|
||||
- **App Sandbox:** Extension runs in separate sandbox from main app; shared data via App Group only
|
||||
- **APNs authentication:** Server uses APNs auth key (`.p8` file) with Team ID + Key ID
|
||||
- **No input injection:** Not a security choice—iOS platform limitation (actually a security feature)
|
||||
|
||||
### Android Security
|
||||
|
||||
- **MediaProjection consent:** User must grant permission via system dialog; consent required per session (cannot be saved)
|
||||
- **Accessibility Service risk:** Granting Accessibility permission is high-privilege; app must clearly explain why (remote support) and warn user
|
||||
- **Foreground service:** Session runs as foreground service with persistent notification (user always aware)
|
||||
- **FCM authentication:** Server uses FCM service account key (JSON) for authenticated sends
|
||||
|
||||
### Authentication
|
||||
|
||||
**Support code only in v1:**
|
||||
- User enters 6-digit support code from dashboard
|
||||
- Agent authenticates via `POST /api/auth/support-code` (same as desktop agents)
|
||||
- Viewer token issued, session begins
|
||||
|
||||
**Persistent agent mode deferred to Phase 2:**
|
||||
- Requires secure storage of agent key (iOS Keychain, Android EncryptedSharedPreferences)
|
||||
- Requires background keep-alive (iOS: silent push, Android: foreground service)
|
||||
|
||||
### Privacy
|
||||
|
||||
- **Consent-first model:** User must actively grant screen sharing permission each session
|
||||
- **No background capture:** OS prevents capturing screen when app is backgrounded (security feature)
|
||||
- **User can stop anytime:** "Stop Sharing" button (iOS) or notification action (Android)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- iOS: XCTest for protobuf serialization, support code validation
|
||||
- Android: JUnit + MockK for input event handling, encoder logic
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- **iOS test rig:** iPhone 14 Pro (iOS 17) physical device or simulator (ReplayKit requires real device for broadcast extension)
|
||||
- **Android test rig:** Pixel 6 (Android 14) physical device (MediaProjection requires real device)
|
||||
- Test flow: enter support code → grant permissions → viewer connects → verify frames received
|
||||
|
||||
### Manual Testing Scenarios
|
||||
|
||||
1. **iOS attended session:**
|
||||
- User opens app, enters support code, taps "Start Broadcast", selects GuruConnect
|
||||
- Viewer connects, sees iPhone home screen, user navigates Settings
|
||||
- Verify: frames display correctly, input controls disabled (view-only banner shown)
|
||||
- User taps "Stop Sharing" → session ends gracefully
|
||||
|
||||
2. **Android attended session with input:**
|
||||
- User opens app, enables Accessibility Service in Settings
|
||||
- User enters support code, grants MediaProjection permission
|
||||
- Viewer connects, remotely taps app icon, swipes, types text
|
||||
- Verify: input events execute on device, foreground notification shows
|
||||
- User swipes down notification, taps "Stop" → session ends
|
||||
|
||||
3. **Push notification wake:**
|
||||
- Viewer requests session from dashboard
|
||||
- Push notification appears on locked phone
|
||||
- User taps notification → app opens with support code pre-filled
|
||||
- User grants screen sharing → session starts
|
||||
|
||||
4. **Low bandwidth:** Throttle connection to 1 Mbps, verify H.264 adapts, frames remain usable
|
||||
|
||||
### App Store / Play Store Review
|
||||
|
||||
- **iOS App Store:** Requires detailed privacy policy explaining screen recording usage, ReplayKit justification in app review notes
|
||||
- **Google Play:** Requires Accessibility Service usage justification ("remote support for user's own device with their explicit consent")
|
||||
|
||||
## Effort Estimate & Dependencies
|
||||
|
||||
**Size:** X-Large (16-20 weeks, 1 developer with mobile experience)
|
||||
|
||||
**Breakdown:**
|
||||
- iOS app + ReplayKit extension: 5 weeks
|
||||
- Android app + MediaProjection service: 4 weeks
|
||||
- Android Accessibility Service input injection: 2 weeks
|
||||
- Push notification backend (APNs + FCM): 2 weeks
|
||||
- Server protobuf additions + mobile capabilities handling: 1 week
|
||||
- Viewer UI adjustments (touch event handling, view-only mode): 1 week
|
||||
- App Store + Play Store submission, review cycles: 2 weeks
|
||||
- Testing, edge cases, OS compatibility: 2 weeks
|
||||
- Buffer: 1-3 weeks
|
||||
|
||||
**Dependencies:**
|
||||
- **Apple Developer Program enrollment** ($99/year) — required for APNs + App Store distribution
|
||||
- **Google Play Developer account** ($25 one-time) — required for Play Store distribution
|
||||
- **Firebase project setup** (free tier) — for FCM push notifications
|
||||
- **SPEC-002 v2 Phase 1 completion** — per-agent keys model must be stable (already shipped)
|
||||
- **Mobile development expertise** — Swift/SwiftUI + Kotlin/Jetpack Compose; consider contract hire if not in-house
|
||||
|
||||
**Unblocks:**
|
||||
- Mobile support parity with competitors (ScreenConnect, TeamViewer, Splashtop all have mobile agents)
|
||||
- "Show me the problem" use case for phone/tablet support
|
||||
- BYOD enterprise support (employees request help on personal iOS/Android devices)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **iOS view-only limitation — acceptable to market?** — Competitors (TeamViewer, Splashtop) also view-only on iOS due to platform constraints. Document prominently in UI/marketing.
|
||||
|
||||
2. **Android Accessibility Service friction — how to onboard?** — Most users don't know how to enable Accessibility. Need step-by-step wizard with screenshots. Alternatively: offer view-only Android mode (no Accessibility required) as Phase 1, add input in Phase 2.
|
||||
|
||||
3. **Foreground-only sessions — user can't multitask?** — Correct. iOS/Android stop screen capture when app backgrounds. This is an OS limitation, not a bug. Frame UI as "supervised support session" not "unattended monitoring."
|
||||
|
||||
4. **Push notification reliability?** — APNs/FCM are best-effort, not guaranteed. If push fails, user can manually open app and enter support code. Fallback: dashboard shows "waiting for device" with code to give user over phone.
|
||||
|
||||
5. **Cross-platform viewer compatibility?** — Existing native Windows viewer and web viewer already handle arbitrary frame sizes (mobile screens are just smaller). Touch events map to mouse clicks for non-touch-aware viewers.
|
||||
|
||||
6. **App Store/Play Store approval risk?** — Accessibility Service apps face extra scrutiny on Android. Emphasize "user-initiated remote support" positioning, not "remote monitoring." Provide detailed privacy policy. Low risk if framed correctly.
|
||||
|
||||
7. **Multi-touch support?** — v1 supports single-touch only (maps to mouse). Multi-touch (pinch-zoom, two-finger gestures) deferred to Phase 2 (requires `TouchEvent.pointer_id` array).
|
||||
|
||||
---
|
||||
|
||||
**Cross-references:**
|
||||
- GuruRMM SPEC-017: Mobile Device Support (MDM, inventory, lock/wipe) — complementary, not overlapping
|
||||
- SPEC-002: v2 modernization architecture (per-agent keys)
|
||||
- SPEC-010: Cross-platform agents (macOS/Linux) — similar platform abstraction approach
|
||||
- ADR-001: GuruConnect is standalone (no RMM coupling for this feature)
|
||||
931
docs/specs/SPEC-012-headless-linux-tty.md
Normal file
931
docs/specs/SPEC-012-headless-linux-tty.md
Normal file
@@ -0,0 +1,931 @@
|
||||
# SPEC-012: Headless Linux Mode (Serial Console + PTY Shell Access)
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2
|
||||
**Requested By:** Mike Swanson (2026-05-30)
|
||||
**Estimated Effort:** Medium (5-7 weeks)
|
||||
|
||||
## Overview
|
||||
|
||||
Enable GuruConnect agent support for headless Linux servers (no X11/Wayland GUI) by providing two modes of terminal access: **Serial Console Mode** for boot-level access (GRUB, kernel messages, panics) and **PTY Shell Mode** for normal server management. This addresses critical server management use cases—from emergency recovery to routine administration—without requiring SSH. The viewer displays a terminal emulator (xterm.js web viewer) connected to either the system serial console (`/dev/ttyS0`) or a pseudo-TTY shell session. Serial Console Mode provides true "remote console" access like KVM-over-IP or IPMI Serial-over-LAN, seeing everything the physical monitor would show. PTY Shell Mode provides an interactive shell for normal management tasks. Success criteria: technician can access GRUB bootloader, view kernel boot messages, handle kernel panics, AND perform routine server management—all via GuruConnect dashboard with centralized authentication and audit logging.
|
||||
|
||||
**Use Cases:**
|
||||
- **Boot-level access:** GRUB menu selection, kernel parameter editing, single-user mode
|
||||
- **Emergency recovery:** Kernel panic diagnosis, filesystem repair, systemd rescue shell
|
||||
- **Server management:** Package updates, configuration changes, log review (normal shell access)
|
||||
- **Container debugging:** Exec into running containers via GuruConnect
|
||||
- **MSP consolidation:** One tool for desktop support (GUI), server boot recovery (console), and server management (shell)
|
||||
|
||||
**Success Criteria:**
|
||||
- **Serial Console Mode:** View GRUB bootloader, kernel boot messages, kernel panics, login prompts—as if sitting at physical console
|
||||
- **PTY Shell Mode:** Interactive shell (bash/zsh) with full ANSI color, cursor control, vim/nano/htop support
|
||||
- GuruConnect agent runs on Ubuntu Server 22.04 minimal install (no desktop packages)
|
||||
- Dashboard mode selector: "Console" vs. "Shell" per agent (user chooses at connection time)
|
||||
- Same protobuf-over-WSS transport, support-code and persistent-agent authentication
|
||||
- Audit logging: session recording for both console and shell modes
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
**Mode 1: Serial Console Mode (True Remote Console)**
|
||||
- Open system serial console device (`/dev/ttyS0` or `/dev/console`) for raw I/O
|
||||
- Relay all bytes bidirectionally: console output → `TerminalData` → viewer; viewer input → `TerminalInput` → console
|
||||
- **Sees everything:** GRUB bootloader menu, kernel boot messages, systemd startup, login prompts, kernel panics
|
||||
- **Boot-time interaction:** Select GRUB entries, edit kernel parameters, boot into single-user mode
|
||||
- Requires root privileges (serial console access restricted to root)
|
||||
- Requires serial console enabled on target server (GRUB + kernel parameters configured)
|
||||
- No PTY spawning—direct device I/O, like `screen /dev/ttyS0 115200`
|
||||
- Agent config flag: `console_mode: true` + `console_device: "/dev/ttyS0"`
|
||||
|
||||
**Mode 2: PTY Shell Mode (Interactive Shell)**
|
||||
- Detect headless environment (no DISPLAY, no X11/Wayland libraries) at runtime
|
||||
- Spawn pseudo-TTY (PTY) via `openpty()` + fork/exec shell (`/bin/bash -l` or user's `$SHELL`)
|
||||
- Terminal I/O: read PTY output → encode as protobuf `TerminalData` → send via WebSocket
|
||||
- Input: receive protobuf `TerminalInput` → write to PTY master
|
||||
- Terminal resize: handle `TerminalResize` message → send `SIGWINCH` to PTY
|
||||
- Fallback shell selection: `$SHELL` env var → `/bin/bash` → `/bin/sh`
|
||||
- Graceful PTY cleanup on session end (send exit command, wait for shell exit, close PTY)
|
||||
- Standard user privileges (runs as agent service user)
|
||||
|
||||
**Mode Selection:**
|
||||
- Dashboard shows mode selector when connecting to headless agent: "Console" vs. "Shell"
|
||||
- "Console" button: viewer sends `mode: console` in connection request → agent opens `/dev/ttyS0`
|
||||
- "Shell" button: viewer sends `mode: shell` in connection request → agent spawns PTY
|
||||
- Agent config specifies default mode if serial console unavailable
|
||||
- If serial console device doesn't exist or permission denied, fall back to PTY shell mode with warning
|
||||
|
||||
**Both Modes Share:**
|
||||
- Same agent binary: `guruconnect` detects headless and offers both modes
|
||||
- Same xterm.js viewer (handles both serial console and PTY identically)
|
||||
|
||||
**Viewer (Web Viewer):**
|
||||
- xterm.js-based terminal emulator embedded in `viewer.html`
|
||||
- Connects to same `/ws/viewer` endpoint with session JWT
|
||||
- Relay server detects `TerminalData` frames (not `FrameData`) and routes accordingly
|
||||
- Terminal controls: resize on window resize, copy/paste support, configurable font size
|
||||
- Session toolbar: connection status, terminal size (e.g., "80x24"), reconnect button
|
||||
|
||||
**Viewer (Native Desktop Viewer - optional Phase 2):**
|
||||
- Defer native viewer terminal support to Phase 2
|
||||
- v1: web viewer only for terminal sessions (show "Open in browser" prompt if launched via `guruconnect://`)
|
||||
|
||||
**Protobuf Protocol:**
|
||||
- New message types: `TerminalData` (PTY output), `TerminalInput` (keyboard input), `TerminalResize` (window size)
|
||||
- `AgentStatus` includes `terminal_mode: bool` flag (true for headless agents)
|
||||
- Dashboard shows terminal icon for headless agents, camera icon for GUI agents
|
||||
|
||||
**Dashboard:**
|
||||
- Detect `terminal_mode: true` in agent status
|
||||
- "Connect" button opens web viewer in terminal mode (not screen capture mode)
|
||||
- Agent list shows "Terminal" badge for headless agents
|
||||
|
||||
**Session Recording (Audit):**
|
||||
- Log all terminal I/O to `events` table or separate `terminal_sessions` table
|
||||
- Playback: recorded session can be replayed as "terminal recording" (asciicast format or raw PTY dump)
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- **GUI mode on headless agents** — v1 is terminal-only; no attempt to start Xvfb or launch GUI apps
|
||||
- **SSH key management** — agent uses GuruConnect auth (support code / agent key), not SSH keys
|
||||
- **File transfer via terminal** — defer to SPEC (file transfer is a separate roadmap item for all agent types)
|
||||
- **Multi-user terminal sessions** — v1 is single-session console/PTY; no tmux/screen built-in sharing
|
||||
- **Windows terminal mode** — defer; Windows Server typically has GUI (RDP) or SSH (OpenSSH)
|
||||
- **macOS terminal mode** — defer; macOS servers are rare and typically have GUI access
|
||||
- **Framebuffer capture (`/dev/fb0`)** — defer; serial console is more reliable and doesn't require framebuffer device
|
||||
|
||||
### Serial Console Setup Requirements (Mode 1)
|
||||
|
||||
To use Serial Console Mode, the target Linux server must be configured to output to serial console. This is a **one-time setup per server** (typically done during provisioning):
|
||||
|
||||
**Step 1: Configure GRUB**
|
||||
|
||||
Edit `/etc/default/grub`:
|
||||
```bash
|
||||
# Enable serial console output at 115200 baud
|
||||
GRUB_TERMINAL="serial console"
|
||||
GRUB_SERIAL_COMMAND="serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1"
|
||||
|
||||
# Kernel console output to both VGA (tty0) and serial (ttyS0)
|
||||
GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0,115200n8"
|
||||
```
|
||||
|
||||
Update GRUB and reboot:
|
||||
```bash
|
||||
sudo update-grub # Debian/Ubuntu
|
||||
# OR
|
||||
sudo grub2-mkconfig -o /boot/grub2/grub.cfg # RHEL/CentOS
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
**Step 2: Enable getty on Serial Console**
|
||||
|
||||
Ensure a login prompt appears on serial console after boot:
|
||||
```bash
|
||||
sudo systemctl enable serial-getty@ttyS0.service
|
||||
sudo systemctl start serial-getty@ttyS0.service
|
||||
```
|
||||
|
||||
**Step 3: Verify**
|
||||
|
||||
Test serial console locally before configuring GuruConnect:
|
||||
```bash
|
||||
sudo screen /dev/ttyS0 115200
|
||||
# Should see kernel messages, login prompt
|
||||
```
|
||||
|
||||
**What This Provides:**
|
||||
- ✓ GRUB bootloader menu visible via serial console
|
||||
- ✓ Kernel boot messages stream to serial console
|
||||
- ✓ Login prompt on `/dev/ttyS0` after boot
|
||||
- ✓ Kernel panics output to serial console
|
||||
- ✓ systemd rescue shell accessible via serial console
|
||||
|
||||
**Compatibility:**
|
||||
- Physical servers: Uses hardware serial port (COM1 = ttyS0)
|
||||
- Virtual machines: VMware/Proxmox/KVM expose virtual serial port; configure VM to attach serial port
|
||||
- Cloud VMs: AWS, GCP, Azure offer "Serial Console" feature (already configured); GuruConnect agent can relay it
|
||||
|
||||
## Architecture
|
||||
|
||||
### Agent Mode Selection
|
||||
|
||||
**Connection request handling:**
|
||||
|
||||
```rust
|
||||
// agent/src/session/terminal.rs
|
||||
pub async fn handle_terminal_session(
|
||||
ws: WebSocketClient,
|
||||
mode: TerminalMode, // Console or Shell
|
||||
support_code: String
|
||||
) -> Result<()> {
|
||||
match mode {
|
||||
TerminalMode::Console => run_console_session(ws, support_code).await,
|
||||
TerminalMode::Shell => run_shell_session(ws, support_code).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TerminalMode {
|
||||
Console, // Serial console (/dev/ttyS0)
|
||||
Shell, // PTY shell session
|
||||
}
|
||||
```
|
||||
|
||||
### Agent Serial Console Handling (Mode 1)
|
||||
|
||||
**Serial device open:**
|
||||
|
||||
```rust
|
||||
// agent/src/platform/linux/console.rs
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
pub struct ConsoleSession {
|
||||
device_fd: RawFd,
|
||||
device_path: String, // "/dev/ttyS0" or "/dev/console"
|
||||
}
|
||||
|
||||
impl ConsoleSession {
|
||||
pub fn open(device_path: &str) -> Result<Self> {
|
||||
// Open serial console device for read/write
|
||||
// Requires root privileges
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(device_path)
|
||||
.context("Failed to open serial console - requires root")?;
|
||||
|
||||
let device_fd = file.as_raw_fd();
|
||||
|
||||
// Configure terminal settings (115200 baud, 8N1)
|
||||
unsafe {
|
||||
let mut termios: libc::termios = std::mem::zeroed();
|
||||
if libc::tcgetattr(device_fd, &mut termios) != 0 {
|
||||
return Err(anyhow!("tcgetattr failed"));
|
||||
}
|
||||
|
||||
// Set baud rate to 115200
|
||||
libc::cfsetispeed(&mut termios, libc::B115200);
|
||||
libc::cfsetospeed(&mut termios, libc::B115200);
|
||||
|
||||
// 8N1 (8 data bits, no parity, 1 stop bit)
|
||||
termios.c_cflag &= !libc::CSIZE;
|
||||
termios.c_cflag |= libc::CS8;
|
||||
termios.c_cflag &= !(libc::PARENB | libc::PARODD);
|
||||
termios.c_cflag &= !libc::CSTOPB;
|
||||
|
||||
// Raw mode (no line buffering, no echo)
|
||||
libc::cfmakeraw(&mut termios);
|
||||
|
||||
if libc::tcsetattr(device_fd, libc::TCSANOW, &termios) != 0 {
|
||||
return Err(anyhow!("tcsetattr failed"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ConsoleSession {
|
||||
device_fd,
|
||||
device_path: device_path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read(&self, buf: &mut [u8]) -> Result<usize> {
|
||||
unsafe {
|
||||
let n = libc::read(self.device_fd, buf.as_mut_ptr() as *mut _, buf.len());
|
||||
if n < 0 {
|
||||
Err(anyhow!("Console read failed"))
|
||||
} else {
|
||||
Ok(n as usize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&self, data: &[u8]) -> Result<()> {
|
||||
unsafe {
|
||||
let n = libc::write(self.device_fd, data.as_ptr() as *const _, data.len());
|
||||
if n < 0 {
|
||||
Err(anyhow!("Console write failed"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConsoleSession {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.device_fd); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Console session loop:**
|
||||
|
||||
```rust
|
||||
// agent/src/session/console.rs
|
||||
pub async fn run_console_session(ws: WebSocketClient, support_code: String) -> Result<()> {
|
||||
// Try /dev/ttyS0 first, fall back to /dev/console
|
||||
let console = ConsoleSession::open("/dev/ttyS0")
|
||||
.or_else(|_| ConsoleSession::open("/dev/console"))?;
|
||||
|
||||
// Status update: terminal mode, console
|
||||
ws.send(AgentStatus {
|
||||
terminal_mode: true,
|
||||
console_mode: true, // NEW flag
|
||||
os: "Linux".to_string(),
|
||||
// ...
|
||||
}).await?;
|
||||
|
||||
let mut buf = vec![0u8; 4096];
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Read console output, send to relay
|
||||
Ok(n) = tokio::task::spawn_blocking({
|
||||
let fd = console.device_fd;
|
||||
move || unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) }
|
||||
}) => {
|
||||
if n > 0 {
|
||||
ws.send(TerminalData {
|
||||
data: buf[..n as usize].to_vec(),
|
||||
}).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Receive input from relay, write to console
|
||||
Some(msg) = ws.recv() => {
|
||||
match msg {
|
||||
Message::TerminalInput(input) => {
|
||||
console.write(&input.data)?;
|
||||
}
|
||||
Message::Disconnect => break,
|
||||
// Note: Resize ignored for serial console (not applicable)
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Agent PTY Handling (Mode 2)
|
||||
|
||||
**Headless detection:**
|
||||
|
||||
```rust
|
||||
// agent/src/platform/linux/headless.rs
|
||||
pub fn is_headless() -> bool {
|
||||
// Check if DISPLAY is unset and no X11/Wayland session detected
|
||||
std::env::var("DISPLAY").is_err() &&
|
||||
std::env::var("WAYLAND_DISPLAY").is_err() &&
|
||||
!std::path::Path::new("/tmp/.X11-unix").exists()
|
||||
}
|
||||
```
|
||||
|
||||
**PTY spawn:**
|
||||
|
||||
```rust
|
||||
// agent/src/platform/linux/pty.rs
|
||||
use libc::{openpty, fork, execvp, dup2, STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO, winsize, TIOCSWINSZ};
|
||||
use std::os::unix::io::RawFd;
|
||||
|
||||
pub struct PtySession {
|
||||
master_fd: RawFd,
|
||||
child_pid: libc::pid_t,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
}
|
||||
|
||||
impl PtySession {
|
||||
pub fn spawn(shell: &str, cols: u16, rows: u16) -> Result<Self> {
|
||||
let mut master_fd: RawFd = 0;
|
||||
let mut slave_fd: RawFd = 0;
|
||||
let mut winsize = winsize {
|
||||
ws_row: rows,
|
||||
ws_col: cols,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
if openpty(&mut master_fd, &mut slave_fd, std::ptr::null_mut(),
|
||||
std::ptr::null(), &mut winsize as *mut _) != 0 {
|
||||
return Err(anyhow!("openpty failed"));
|
||||
}
|
||||
|
||||
let pid = fork();
|
||||
if pid == 0 {
|
||||
// Child process: exec shell
|
||||
dup2(slave_fd, STDIN_FILENO);
|
||||
dup2(slave_fd, STDOUT_FILENO);
|
||||
dup2(slave_fd, STDERR_FILENO);
|
||||
libc::close(master_fd);
|
||||
libc::close(slave_fd);
|
||||
|
||||
let shell_cstr = CString::new(shell)?;
|
||||
let args = [shell_cstr.as_ptr(), std::ptr::null()];
|
||||
execvp(shell_cstr.as_ptr(), args.as_ptr());
|
||||
std::process::exit(1); // exec failed
|
||||
} else {
|
||||
// Parent process: close slave, return master FD
|
||||
libc::close(slave_fd);
|
||||
Ok(PtySession {
|
||||
master_fd,
|
||||
child_pid: pid,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self, buf: &mut [u8]) -> Result<usize> {
|
||||
unsafe {
|
||||
let n = libc::read(self.master_fd, buf.as_mut_ptr() as *mut _, buf.len());
|
||||
if n < 0 {
|
||||
Err(anyhow!("PTY read failed"))
|
||||
} else {
|
||||
Ok(n as usize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&self, data: &[u8]) -> Result<()> {
|
||||
unsafe {
|
||||
let n = libc::write(self.master_fd, data.as_ptr() as *const _, data.len());
|
||||
if n < 0 {
|
||||
Err(anyhow!("PTY write failed"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
|
||||
self.cols = cols;
|
||||
self.rows = rows;
|
||||
let winsize = winsize {
|
||||
ws_row: rows,
|
||||
ws_col: cols,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
};
|
||||
unsafe {
|
||||
if libc::ioctl(self.master_fd, TIOCSWINSZ, &winsize as *const _) != 0 {
|
||||
return Err(anyhow!("TIOCSWINSZ failed"));
|
||||
}
|
||||
}
|
||||
// Send SIGWINCH to child process group
|
||||
unsafe { libc::kill(-self.child_pid, libc::SIGWINCH); }
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PtySession {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
libc::close(self.master_fd);
|
||||
// Send SIGTERM to child, wait briefly, then SIGKILL if still alive
|
||||
libc::kill(self.child_pid, libc::SIGTERM);
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
libc::waitpid(self.child_pid, std::ptr::null_mut(), libc::WNOHANG);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Agent session loop:**
|
||||
|
||||
```rust
|
||||
// agent/src/session/terminal.rs
|
||||
pub async fn run_terminal_session(ws: WebSocketClient, support_code: String) -> Result<()> {
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
|
||||
let mut pty = PtySession::spawn(&shell, 80, 24)?;
|
||||
|
||||
// Status update: terminal mode
|
||||
ws.send(AgentStatus {
|
||||
terminal_mode: true,
|
||||
os: "Linux".to_string(),
|
||||
// ...
|
||||
}).await?;
|
||||
|
||||
let mut buf = vec![0u8; 4096];
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Read PTY output, send to relay
|
||||
Ok(n) = tokio::task::spawn_blocking({
|
||||
let master = pty.master_fd;
|
||||
move || unsafe { libc::read(master, buf.as_mut_ptr() as *mut _, buf.len()) }
|
||||
}) => {
|
||||
if n > 0 {
|
||||
ws.send(TerminalData {
|
||||
data: buf[..n as usize].to_vec(),
|
||||
}).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Receive input from relay, write to PTY
|
||||
Some(msg) = ws.recv() => {
|
||||
match msg {
|
||||
Message::TerminalInput(input) => {
|
||||
pty.write(&input.data)?;
|
||||
}
|
||||
Message::TerminalResize(resize) => {
|
||||
pty.resize(resize.cols, resize.rows)?;
|
||||
}
|
||||
Message::Disconnect => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Protobuf Protocol Extensions
|
||||
|
||||
```protobuf
|
||||
// proto/guruconnect.proto
|
||||
|
||||
message AgentStatus {
|
||||
// Existing fields...
|
||||
optional bool terminal_mode = 21; // true for headless agents
|
||||
optional bool console_mode = 22; // true for serial console mode, false for PTY shell mode
|
||||
}
|
||||
|
||||
message TerminalData {
|
||||
bytes data = 1; // Raw terminal output (PTY or serial console, includes ANSI escape sequences)
|
||||
}
|
||||
|
||||
message TerminalInput {
|
||||
bytes data = 1; // Keyboard input from viewer (UTF-8 encoded)
|
||||
}
|
||||
|
||||
message TerminalResize {
|
||||
uint32 cols = 1; // Terminal width (characters)
|
||||
uint32 rows = 2; // Terminal height (lines)
|
||||
// Note: Resize only applies to PTY shell mode; serial console ignores this
|
||||
}
|
||||
|
||||
enum TerminalModeRequest {
|
||||
SHELL = 0; // Request PTY shell session
|
||||
CONSOLE = 1; // Request serial console session
|
||||
}
|
||||
|
||||
message SessionRequest {
|
||||
// Existing fields...
|
||||
optional TerminalModeRequest terminal_mode_request = 10; // NEW: viewer specifies console vs. shell
|
||||
}
|
||||
|
||||
// Update AgentMessage and ViewerMessage unions
|
||||
message AgentMessage {
|
||||
oneof message {
|
||||
AgentStatus status = 1;
|
||||
FrameData frame = 2;
|
||||
TerminalData terminal_data = 10; // NEW
|
||||
}
|
||||
}
|
||||
|
||||
message ViewerMessage {
|
||||
oneof message {
|
||||
InputEvent input = 1;
|
||||
TerminalInput terminal_input = 10; // NEW
|
||||
TerminalResize terminal_resize = 11; // NEW
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Relay Server Changes
|
||||
|
||||
**Route terminal vs. screen capture sessions:**
|
||||
|
||||
```rust
|
||||
// server/src/relay/mod.rs
|
||||
async fn handle_agent_message(msg: AgentMessage, session: &Session) {
|
||||
match msg.message {
|
||||
Some(agent_message::Message::Status(status)) => {
|
||||
session.terminal_mode = status.terminal_mode.unwrap_or(false);
|
||||
// Store in DB: UPDATE sessions SET terminal_mode = ? WHERE id = ?
|
||||
}
|
||||
Some(agent_message::Message::TerminalData(data)) => {
|
||||
// Forward to viewer WebSocket
|
||||
if let Some(viewer_ws) = session.viewer_ws.lock().await.as_mut() {
|
||||
viewer_ws.send(ViewerMessage {
|
||||
message: Some(viewer_message::Message::TerminalData(data))
|
||||
}).await?;
|
||||
}
|
||||
// Optional: append to terminal_recording buffer for audit
|
||||
}
|
||||
Some(agent_message::Message::Frame(frame)) => {
|
||||
// Existing screen capture logic...
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_viewer_message(msg: ViewerMessage, session: &Session) {
|
||||
match msg.message {
|
||||
Some(viewer_message::Message::TerminalInput(input)) => {
|
||||
// Forward to agent WebSocket
|
||||
if let Some(agent_ws) = session.agent_ws.lock().await.as_mut() {
|
||||
agent_ws.send(AgentMessage {
|
||||
message: Some(agent_message::Message::TerminalInput(input))
|
||||
}).await?;
|
||||
}
|
||||
}
|
||||
Some(viewer_message::Message::TerminalResize(resize)) => {
|
||||
// Forward resize to agent
|
||||
if let Some(agent_ws) = session.agent_ws.lock().await.as_mut() {
|
||||
agent_ws.send(AgentMessage {
|
||||
message: Some(agent_message::Message::TerminalResize(resize))
|
||||
}).await?;
|
||||
}
|
||||
}
|
||||
Some(viewer_message::Message::Input(input)) => {
|
||||
// Existing GUI input logic...
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Web Viewer (xterm.js)
|
||||
|
||||
**HTML template:**
|
||||
|
||||
```html
|
||||
<!-- server/static/viewer-terminal.html -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>GuruConnect Terminal</title>
|
||||
<link rel="stylesheet" href="/vendor/xterm/xterm.css" />
|
||||
<script src="/vendor/xterm/xterm.js"></script>
|
||||
<script src="/vendor/xterm/xterm-addon-fit.js"></script>
|
||||
<style>
|
||||
#terminal { height: 100vh; }
|
||||
.toolbar { background: #333; color: #fff; padding: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<span id="status">Connecting...</span>
|
||||
<span id="size" style="float: right;">80x24</span>
|
||||
</div>
|
||||
<div id="terminal"></div>
|
||||
<script>
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
}
|
||||
});
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
|
||||
const ws = new WebSocket(`wss://${location.host}/ws/viewer?token=${TOKEN}&session=${SESSION_ID}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
document.getElementById('status').textContent = 'Connected';
|
||||
// Send initial terminal size
|
||||
ws.send(encodeTerminalResize(term.cols, term.rows));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = decodeProtobuf(event.data);
|
||||
if (msg.terminal_data) {
|
||||
term.write(new Uint8Array(msg.terminal_data.data));
|
||||
}
|
||||
};
|
||||
|
||||
term.onData((data) => {
|
||||
ws.send(encodeTerminalInput(data));
|
||||
});
|
||||
|
||||
term.onResize(({ cols, rows }) => {
|
||||
document.getElementById('size').textContent = `${cols}x${rows}`;
|
||||
ws.send(encodeTerminalResize(cols, rows));
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => fitAddon.fit());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Dashboard Mode Selector
|
||||
|
||||
```javascript
|
||||
// server/static/dashboard.js
|
||||
function renderAgentRow(agent) {
|
||||
const icon = agent.terminal_mode
|
||||
? '<i class="icon-terminal"></i> Terminal'
|
||||
: '<i class="icon-screen"></i> Screen';
|
||||
|
||||
// For headless agents, show mode selector (Console vs. Shell)
|
||||
let connectButtons;
|
||||
if (agent.terminal_mode && agent.online) {
|
||||
connectButtons = `
|
||||
<div class="terminal-mode-selector">
|
||||
<button class="btn-console" onclick="connectToTerminal('${agent.id}', 'console')"
|
||||
title="Serial console access (GRUB, boot, panics)">
|
||||
Console
|
||||
</button>
|
||||
<button class="btn-shell" onclick="connectToTerminal('${agent.id}', 'shell')"
|
||||
title="Interactive shell (bash/zsh)">
|
||||
Shell
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
} else if (!agent.terminal_mode && agent.online) {
|
||||
// GUI agent
|
||||
connectButtons = `<button onclick="connectToAgent('${agent.id}')">Connect</button>`;
|
||||
} else {
|
||||
connectButtons = '<span>Offline</span>';
|
||||
}
|
||||
|
||||
return `<tr>
|
||||
<td>${agent.name}</td>
|
||||
<td>${icon}</td>
|
||||
<td>${agent.os} ${agent.os_version}</td>
|
||||
<td>${connectButtons}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function connectToAgent(agentId) {
|
||||
// GUI agent connection
|
||||
window.open(`/viewer.html?session=${agentId}&token=${JWT}`, '_blank');
|
||||
}
|
||||
|
||||
function connectToTerminal(agentId, mode) {
|
||||
// Terminal agent connection with mode parameter
|
||||
window.open(`/viewer-terminal.html?session=${agentId}&token=${JWT}&mode=${mode}`, '_blank');
|
||||
}
|
||||
```
|
||||
|
||||
**Dashboard UI for headless agents:**
|
||||
- Shows two buttons: "Console" and "Shell"
|
||||
- "Console" button: opens serial console session (GRUB, boot messages, panics)
|
||||
- "Shell" button: opens PTY shell session (normal server management)
|
||||
- Tooltip on hover explains each mode
|
||||
- Mode parameter passed to viewer via URL query string
|
||||
|
||||
### Database Schema
|
||||
|
||||
**Minor addition to `sessions` table:**
|
||||
|
||||
```sql
|
||||
-- migrations/012_terminal_mode.sql
|
||||
ALTER TABLE connect_sessions ADD COLUMN terminal_mode BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Optional: separate table for terminal recordings
|
||||
CREATE TABLE IF NOT EXISTS terminal_recordings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID REFERENCES connect_sessions(id) ON DELETE CASCADE,
|
||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
recording_data BYTEA, -- asciicast JSON or raw PTY dump (compressed)
|
||||
size_bytes BIGINT,
|
||||
INDEX idx_terminal_recordings_session (session_id)
|
||||
);
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Files to Create
|
||||
|
||||
**Agent (Linux-specific):**
|
||||
- `agent/src/platform/linux/console.rs` — NEW: Serial console device I/O (`/dev/ttyS0`, termios config)
|
||||
- `agent/src/session/console.rs` — NEW: Console session loop (serial device ↔ WebSocket)
|
||||
- `agent/src/platform/linux/pty.rs` — PTY spawn, I/O, resize (openpty, fork, exec)
|
||||
- `agent/src/platform/linux/headless.rs` — Headless detection logic
|
||||
- `agent/src/session/terminal.rs` — Mode dispatcher (console vs. shell), shell session loop
|
||||
|
||||
**Server:**
|
||||
- `server/src/relay/terminal.rs` — Terminal message routing (TerminalData/Input/Resize)
|
||||
- `server/static/viewer-terminal.html` — xterm.js-based web terminal viewer
|
||||
- `server/static/vendor/xterm/` — xterm.js library files (CDN or bundled)
|
||||
- `server/migrations/012_terminal_mode.sql` — Schema update
|
||||
|
||||
**Protobuf:**
|
||||
- `proto/guruconnect.proto` — Add TerminalData, TerminalInput, TerminalResize messages
|
||||
|
||||
**Dashboard:**
|
||||
- `server/static/dashboard.js` — Detect `terminal_mode`, render terminal icon, route to terminal viewer
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
```toml
|
||||
# agent/Cargo.toml (Linux-specific)
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2" # openpty, fork, exec, ioctl
|
||||
nix = "0.27" # Safe wrappers for POSIX APIs
|
||||
```
|
||||
|
||||
**xterm.js (web viewer):**
|
||||
- Version: 5.3.0+ (latest stable)
|
||||
- Addons: `xterm-addon-fit` (auto-resize)
|
||||
- Delivery: CDN link or bundled in `server/static/vendor/xterm/`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Serial Console Access (Mode 1)
|
||||
|
||||
- **Requires root privileges:** Opening `/dev/ttyS0` or `/dev/console` requires root access
|
||||
- **Implication:** Agent must run as root for console mode, OR use capabilities (`CAP_SYS_TTY_CONFIG`)
|
||||
- **Boot-level control:** Serial console grants full boot-time control (GRUB menu, kernel parameters, single-user mode)
|
||||
- **Risk:** Attacker with console access can modify bootloader, disable security features, boot into recovery
|
||||
- **Mitigation 1:** Restrict console mode to authorized users only (dashboard RBAC: "console_access" permission)
|
||||
- **Mitigation 2:** Require MFA for console mode sessions (stronger auth than shell mode)
|
||||
- **Mitigation 3:** Audit logging: record ALL console I/O with immutable timestamps
|
||||
- **Mitigation 4:** Alert on console mode connections (notify admin when console session starts)
|
||||
|
||||
**Recommended deployment:**
|
||||
- Run agent as unprivileged user for shell mode (default)
|
||||
- For console mode: either run agent as root OR grant `CAP_SYS_TTY_CONFIG` capability via systemd unit
|
||||
|
||||
### Shell Access Risk (Mode 2)
|
||||
|
||||
- **Privilege escalation:** PTY spawns shell as the agent's user (typically unprivileged `guruconnect` service user)
|
||||
- **Mitigation 1:** Run agent as unprivileged user, use `sudo` for privileged commands
|
||||
- **Mitigation 2:** Add `allowed_commands` whitelist (optional Phase 2 feature) — restrict to specific binaries
|
||||
- **Mitigation 3:** Audit logging: record all terminal I/O for compliance review
|
||||
|
||||
### Authentication
|
||||
|
||||
**Same as GUI agents:**
|
||||
- Support-code for ad-hoc sessions (6-digit, time-limited)
|
||||
- Persistent agent key for managed servers (per-agent `cak_*` key from SPEC-004)
|
||||
- Viewer JWT token required for WebSocket connection
|
||||
|
||||
### Session Recording (Compliance)
|
||||
|
||||
- **Optional toggle:** dashboard setting "Record terminal sessions" (default: ON for compliance)
|
||||
- **Storage:** `terminal_recordings` table (BYTEA column, compressed)
|
||||
- **Playback:** Admin dashboard can replay terminal sessions as asciicast (xterm.js built-in playback)
|
||||
- **Retention:** configurable (default: 90 days, auto-purge older recordings)
|
||||
|
||||
### Input Sanitization
|
||||
|
||||
- **No sanitization needed:** PTY handles raw bytes; ANSI escape sequences are terminal-native
|
||||
- **DoS risk:** Malicious viewer could spam resize events; rate-limit `TerminalResize` (max 10/sec)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- PTY spawn/cleanup: verify `openpty()` success, shell exec, FD management
|
||||
- Terminal I/O: mock PTY master FD, test read/write buffers
|
||||
- Protobuf serialization: TerminalData/Input/Resize round-trip
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- **Headless VM:** Ubuntu Server 22.04 minimal (no desktop packages)
|
||||
- **Agent install:** `guruconnect` binary, systemd service, no X11 deps
|
||||
- **Connect flow:** Dashboard → "Connect" → xterm.js viewer → type `ls`, verify output
|
||||
- **Resize:** Browser window resize → PTY receives SIGWINCH → `htop` redraws correctly
|
||||
- **Session cleanup:** Close viewer → PTY process exits gracefully
|
||||
|
||||
### Manual Testing Scenarios
|
||||
|
||||
1. **Basic shell interaction:**
|
||||
- Connect to headless agent via dashboard
|
||||
- Type `ls -la`, verify colorized output
|
||||
- Run `vim test.txt`, verify cursor movement, editing, save/quit
|
||||
- Run `htop`, verify full-screen TUI app renders correctly
|
||||
|
||||
2. **Terminal resize:**
|
||||
- Start session at default 80x24
|
||||
- Resize browser window to 120x40
|
||||
- Run `tput cols; tput lines` → verify output matches
|
||||
- Run `htop` → verify UI scales to new dimensions
|
||||
|
||||
3. **Multi-line output:**
|
||||
- Run `dmesg | head -100` → verify scrollback works
|
||||
- Run `journalctl -f` → verify live log streaming
|
||||
|
||||
4. **Session recording playback:**
|
||||
- Perform session actions (ls, vim, htop)
|
||||
- End session
|
||||
- Admin dashboard → "View Recording" → verify asciicast playback
|
||||
|
||||
5. **Privilege escalation (sudo):**
|
||||
- Agent runs as `guruconnect` user (non-root)
|
||||
- Connect via terminal
|
||||
- Run `sudo apt update` → enter sudo password → verify command executes
|
||||
- Run `whoami` → verify shows `root` after sudo
|
||||
|
||||
### Performance
|
||||
|
||||
- **Latency target:** <100ms round-trip for input (same as GUI mode)
|
||||
- **Bandwidth:** ~1-5 KB/sec for typical terminal I/O (much lower than screen capture)
|
||||
- **Stress test:** Run `yes` command (infinite output) → verify relay doesn't OOM, rate-limit applied
|
||||
|
||||
## Effort Estimate & Dependencies
|
||||
|
||||
**Size:** Medium (5-7 weeks, 1 developer)
|
||||
|
||||
**Breakdown:**
|
||||
- Serial console implementation (Linux agent): 1.5 weeks
|
||||
- PTY implementation (Linux agent): 1.5 weeks
|
||||
- Mode selection + dispatcher: 0.5 weeks
|
||||
- Protobuf protocol updates (mode enum, console_mode flag): 0.5 weeks
|
||||
- Relay server terminal routing: 1 week
|
||||
- xterm.js web viewer integration: 1 week
|
||||
- Dashboard mode selector UI + routing: 0.5 weeks
|
||||
- Session recording + playback (both modes): 1 week
|
||||
- Testing (console + shell modes), edge cases, systemd integration: 1.5 weeks
|
||||
- Documentation (setup guide for serial console): 0.5 weeks
|
||||
|
||||
**Dependencies:**
|
||||
- **SPEC-010 Linux agent base** — PTY mode extends the Linux agent; can be implemented in parallel with SPEC-010's GUI capture
|
||||
- **xterm.js library** — mature, well-tested (used by VS Code, Jupyter, many commercial products)
|
||||
- **libc/nix crates** — standard Rust POSIX bindings
|
||||
- **SPEC-004 per-agent keys** — already shipped for persistent agent auth
|
||||
|
||||
**Unblocks:**
|
||||
- **Boot-level access** (GRUB menu, kernel parameters, single-user mode) via serial console mode
|
||||
- **Emergency recovery** (kernel panics, filesystem repair, systemd rescue shell) via serial console
|
||||
- **Server management** (Linux VMs, containers, bare metal) via shell mode
|
||||
- **SSH replacement** with centralized audit logging and GuruConnect auth
|
||||
- **Container debugging** (exec into running containers via GuruConnect)
|
||||
- **KVM-over-IP alternative** (serial console provides text-mode equivalent to IPMI Serial-over-LAN)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Serial console permissions - root vs. capabilities?** — Opening `/dev/ttyS0` requires root. Options: (a) run agent as root for console mode, (b) use Linux capabilities (`CAP_SYS_TTY_CONFIG`), (c) add agent user to `dialout` group (may not work for `/dev/console`). Recommend (b) via systemd unit: `AmbientCapabilities=CAP_SYS_TTY_CONFIG`.
|
||||
|
||||
2. **Default mode if serial console unavailable?** — If `/dev/ttyS0` doesn't exist or permission denied, fall back to shell mode automatically or show error? Recommend auto-fallback with warning message in viewer.
|
||||
|
||||
3. **Serial console baud rate?** — v1 hardcodes 115200 (industry standard). Phase 2: make configurable if slower links needed (9600, 38400).
|
||||
|
||||
4. **Shell selection (PTY mode)?** — v1: `$SHELL` env var → `/bin/bash` → `/bin/sh`. Phase 2: dashboard setting to override shell per agent (`/bin/zsh`, `/bin/fish`).
|
||||
|
||||
5. **Concurrent sessions?** — v1: one console/shell session per agent connection (like SSH). Phase 2: tmux/screen integration for multi-viewer session sharing.
|
||||
|
||||
6. **Terminal recording format?** — Asciicast (JSON, industry standard, xterm.js playback support) vs. raw dump (more compact, custom playback). Recommend asciicast for v1.
|
||||
|
||||
7. **Command whitelisting (shell mode)?** — Optional Phase 2 feature. v1 is unrestricted shell access (same as SSH). Add `allowed_commands` array to agent config if compliance requires it.
|
||||
|
||||
8. **RBAC for console vs. shell access?** — Should some users only have shell access (not console, which grants boot-level control)? Recommend yes: add `console_access` permission, separate from `shell_access`.
|
||||
|
||||
9. **MFA for console mode?** — Given boot-level control risk, require MFA for console mode sessions? Defer to Phase 2 (MFA is a broader GuruConnect feature).
|
||||
|
||||
10. **Windows/macOS terminal mode?** — Defer. Windows Server typically uses RDP or SSH (OpenSSH built-in since Server 2019). macOS servers are rare. Linux headless servers are the primary use case.
|
||||
|
||||
11. **File upload/download via terminal?** — v1: use standard tools (`scp`, `rsync`, `wget`). Phase 2: integrate with SPEC (file transfer) for dashboard-native upload/download.
|
||||
|
||||
---
|
||||
|
||||
**Cross-references:**
|
||||
- SPEC-010: Cross-platform agents (macOS/Linux GUI) — headless mode extends Linux agent with PTY alternative
|
||||
- SPEC-004: Stable machine identity — headless agents use same deterministic `machine_uid` (`/etc/machine-id`)
|
||||
- ADR-001: GuruConnect is standalone — headless mode doesn't require GuruRMM integration
|
||||
- Future: File transfer spec (roadmap item) — will integrate with terminal mode for `scp`-like functionality
|
||||
717
docs/specs/SPEC-013-session-selection-and-backstage.md
Normal file
717
docs/specs/SPEC-013-session-selection-and-backstage.md
Normal file
@@ -0,0 +1,717 @@
|
||||
# SPEC-013: Windows Session Selection and Backstage Mode
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2
|
||||
**Requested By:** Mike Swanson (2026-05-30)
|
||||
**Estimated Effort:** Large
|
||||
|
||||
## Overview
|
||||
|
||||
Enable GuruConnect to enumerate and switch between multiple Windows user sessions (Terminal Services/RDP/Fast User Switching) and access Session 0 (backstage mode) for system-level administrative tasks. This addresses a critical gap for MSPs managing multi-user Windows servers and workstations — ScreenConnect's session selector allows technicians to see all logged-on users and switch between them instantly, and the backstage mode provides a command/task-manager interface for Session 0 (services) without disrupting any user's desktop. Success criteria: technicians can view and select from all active sessions in the dashboard/viewer, switch sessions mid-stream without reconnecting, and enter backstage mode to run commands and view services when no user desktop is needed.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
**Session Enumeration and Switching:**
|
||||
- Agent enumerates all Windows sessions via `WTSEnumerateSessions` API at startup and on-demand
|
||||
- Reports session list to server: session ID, user name, session state (active/disconnected), session type (console/RDP)
|
||||
- Viewer displays session selector UI (dropdown or list) showing all available sessions
|
||||
- User selects a session → server sends `SwitchSession` message → agent switches capture/input to that session
|
||||
- Session switching without WebSocket disconnect (same session token, just changes the desktop being captured)
|
||||
- Dashboard shows session count and logged-on users per machine
|
||||
|
||||
**Backstage Mode (Session 0 Access):**
|
||||
- Agent can enter "backstage" mode targeting Session 0 (services session)
|
||||
- Backstage mode provides a terminal/command interface (not DXGI screen capture, since Session 0 has no interactive desktop in modern Windows)
|
||||
- Dashboard/viewer shows backstage UI with:
|
||||
- Command prompt / PowerShell terminal (PTY-like, text-based)
|
||||
- Running services list (via `EnumServicesStatusEx`)
|
||||
- Running processes list (via `EnumProcesses` / `CreateToolhelp32Snapshot`)
|
||||
- System info panel (OS version, uptime, logged-on sessions)
|
||||
- Backstage mode uses `SessionType::BACKSTAGE` (already in protobuf line 42)
|
||||
- Agent runs commands in Session 0 context via `CreateProcessAsUser` with Session 0 token
|
||||
|
||||
**Protobuf Extensions:**
|
||||
- New messages: `SessionInfo`, `SessionList`, `SwitchSession`, `BackstageCommand`, `BackstageOutput`
|
||||
- Extend `AgentStatus` to include session list
|
||||
- Extend `StartStream` to specify target session ID
|
||||
|
||||
**Security:**
|
||||
- Session switching requires elevated (admin) agent process
|
||||
- Backstage mode is admin-only (gated server-side by user role)
|
||||
- Audit log records all session switches and backstage commands
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- **Session shadowing** (viewing a session while another user is active in it) — defer to Phase 2; complex Terminal Services permission model
|
||||
- **Session connect/disconnect control** (logging users off, disconnecting RDP sessions) — defer; high-impact action
|
||||
- **Cross-session clipboard** — clipboard currently session-scoped; multi-session clipboard is a future enhancement
|
||||
- **Linux/macOS session switching** — this spec is Windows-only; cross-platform session concepts differ significantly
|
||||
- **Backstage GUI tools** (registry editor, event log viewer) — v1 is terminal/command-only; GUI tools are future enhancements
|
||||
- **Session 0 screen capture** — Session 0 has no interactive desktop on modern Windows (Session 0 isolation, Vista+); backstage is terminal/data-only
|
||||
|
||||
## Architecture
|
||||
|
||||
### Agent Changes
|
||||
|
||||
**Session Management Module** (`agent/src/session/mod.rs` or new `agent/src/sessions/`)
|
||||
|
||||
- **Enumerate sessions:**
|
||||
```rust
|
||||
use windows::Win32::System::RemoteDesktop::{
|
||||
WTSEnumerateSessionsW, WTSFreeMemory, WTSQuerySessionInformationW,
|
||||
WTS_SESSION_INFOW, WTSUserName, WTSSessionInfo, WTS_CURRENT_SERVER_HANDLE
|
||||
};
|
||||
|
||||
pub struct SessionInfo {
|
||||
pub session_id: u32,
|
||||
pub user_name: String,
|
||||
pub state: SessionState, // Active, Disconnected, Idle
|
||||
pub session_type: SessionType, // Console, RDP
|
||||
}
|
||||
|
||||
pub fn enumerate_sessions() -> Result<Vec<SessionInfo>> {
|
||||
// Call WTSEnumerateSessionsW
|
||||
// For each session, call WTSQuerySessionInformationW(WTSUserName, WTSSessionInfo)
|
||||
// Filter out Session 0 (services) from user session list
|
||||
}
|
||||
```
|
||||
|
||||
- **Switch session desktop:**
|
||||
```rust
|
||||
use windows::Win32::System::RemoteDesktop::WTSGetActiveConsoleSessionId;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{OpenDesktop, CloseDesktop, SwitchDesktop};
|
||||
|
||||
pub struct SessionContext {
|
||||
pub session_id: u32,
|
||||
pub desktop_handle: HDESK, // Handle to the session's desktop
|
||||
}
|
||||
|
||||
pub fn switch_to_session(session_id: u32) -> Result<SessionContext> {
|
||||
// OpenDesktop for the target session (desktop name: "WinSta0\\Default" or session-specific)
|
||||
// SwitchDesktop to make it the active desktop for capture
|
||||
// Reinitialize DXGI capturer on the new desktop
|
||||
}
|
||||
```
|
||||
|
||||
- **Backstage mode (Session 0 command execution):**
|
||||
```rust
|
||||
use windows::Win32::System::Threading::{CreateProcessAsUserW, PROCESS_INFORMATION};
|
||||
use windows::Win32::Security::{LogonUserW, DuplicateTokenEx};
|
||||
|
||||
pub struct BackstageSession {
|
||||
pub session_id: u32, // Always 0 for backstage
|
||||
pub process_handle: HANDLE,
|
||||
pub stdout_pipe: HANDLE,
|
||||
}
|
||||
|
||||
pub fn execute_backstage_command(command: &str) -> Result<BackstageOutput> {
|
||||
// CreateProcessAsUserW with Session 0 token (obtained via WTSQueryUserToken for Session 0)
|
||||
// Capture stdout/stderr via anonymous pipes
|
||||
// Return output as text (no screen capture)
|
||||
}
|
||||
|
||||
pub fn list_services() -> Result<Vec<ServiceInfo>> {
|
||||
// EnumServicesStatusEx(SC_MANAGER_ENUMERATE_SERVICE)
|
||||
}
|
||||
|
||||
pub fn list_processes() -> Result<Vec<ProcessInfo>> {
|
||||
// CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS)
|
||||
}
|
||||
```
|
||||
|
||||
**Capture Module Changes** (`agent/src/capture/mod.rs`, `agent/src/capture/dxgi.rs`)
|
||||
|
||||
- Refactor `DxgiCapturer::new()` to accept a `session_id` parameter
|
||||
- `OpenDesktop` for the target session before initializing DXGI duplication
|
||||
- Store current session ID in capturer state; reinitialize on session switch
|
||||
- Backstage mode does NOT use DXGI — it's terminal/data-only
|
||||
|
||||
**Input Injection Changes** (`agent/src/input/mod.rs`)
|
||||
|
||||
- Ensure `SendInput` targets the correct session's desktop (already handles this via desktop handle)
|
||||
- Backstage mode does NOT inject input (command execution only)
|
||||
|
||||
**New Files:**
|
||||
- `agent/src/sessions/mod.rs` — session enumeration, switching, backstage execution
|
||||
- `agent/src/sessions/wts.rs` — WTS API wrappers (enumerate, query, get token)
|
||||
- `agent/src/sessions/backstage.rs` — Session 0 command execution, service/process enumeration
|
||||
|
||||
### Relay Server Changes
|
||||
|
||||
**Session State Management** (`server/src/relay/mod.rs` or `server/src/session/`)
|
||||
|
||||
- Extend `SessionState` struct to track current session ID:
|
||||
```rust
|
||||
pub struct SessionState {
|
||||
pub session_id: String,
|
||||
pub session_type: SessionType, // SCREEN_CONTROL, VIEW_ONLY, BACKSTAGE
|
||||
pub target_session_id: Option<u32>, // Windows session ID (1, 2, 3, etc.) or 0 for backstage
|
||||
// ... existing fields
|
||||
}
|
||||
```
|
||||
|
||||
- Handle `SessionList` message from agent (periodic update of available sessions)
|
||||
- Store session list in `SessionState` for dashboard queries
|
||||
- Handle `SwitchSession` message from viewer → forward to agent
|
||||
- Handle `BackstageCommand` message from viewer → forward to agent
|
||||
- Handle `BackstageOutput` message from agent → forward to viewer
|
||||
|
||||
**API Endpoints** (`server/src/api/sessions.rs`)
|
||||
|
||||
- `GET /api/sessions/:session_id/windows-sessions` — return list of Windows sessions for this agent
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{"id": 1, "user": "jdoe", "state": "Active", "type": "Console"},
|
||||
{"id": 2, "user": "admin", "state": "Disconnected", "type": "RDP"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Backstage commands are sent via WebSocket, not HTTP (same as mouse/keyboard input)
|
||||
|
||||
**Database Schema** (`server/migrations/`)
|
||||
|
||||
- Extend `connect_sessions` table:
|
||||
```sql
|
||||
ALTER TABLE connect_sessions ADD COLUMN windows_session_id INT NULL;
|
||||
ALTER TABLE connect_sessions ADD COLUMN session_type VARCHAR(20) DEFAULT 'screen_control';
|
||||
```
|
||||
|
||||
- Extend `events` table to log session switches:
|
||||
```sql
|
||||
INSERT INTO events (session_id, event_type, details) VALUES
|
||||
('...', 'session_switched', '{"from_session": 1, "to_session": 2, "user": "admin"}');
|
||||
```
|
||||
|
||||
### Protobuf Changes (`proto/guruconnect.proto`)
|
||||
|
||||
```protobuf
|
||||
// Session information (Windows Terminal Services session)
|
||||
message SessionInfo {
|
||||
uint32 session_id = 1; // Windows session ID (1, 2, 3, etc.)
|
||||
string user_name = 2; // Logged-on user name
|
||||
SessionState state = 3; // Active, Disconnected, Idle
|
||||
WindowsSessionType type = 4; // Console, RDP
|
||||
}
|
||||
|
||||
enum SessionState {
|
||||
SESSION_ACTIVE = 0;
|
||||
SESSION_DISCONNECTED = 1;
|
||||
SESSION_IDLE = 2;
|
||||
}
|
||||
|
||||
enum WindowsSessionType {
|
||||
SESSION_CONSOLE = 0; // Local console session
|
||||
SESSION_RDP = 1; // Remote Desktop session
|
||||
}
|
||||
|
||||
// List of available Windows sessions (agent -> server)
|
||||
message SessionList {
|
||||
repeated SessionInfo sessions = 1;
|
||||
uint32 current_session_id = 2; // Which session the agent is currently capturing
|
||||
}
|
||||
|
||||
// Switch to a different Windows session (viewer -> agent)
|
||||
message SwitchSession {
|
||||
uint32 target_session_id = 1; // Session ID to switch to (0 for backstage)
|
||||
}
|
||||
|
||||
// Backstage command execution (viewer -> agent)
|
||||
message BackstageCommand {
|
||||
BackstageCommandType command_type = 1;
|
||||
string command_text = 2; // For EXEC_COMMAND: PowerShell or CMD command
|
||||
}
|
||||
|
||||
enum BackstageCommandType {
|
||||
BACKSTAGE_EXEC_COMMAND = 0; // Execute a command in Session 0
|
||||
BACKSTAGE_LIST_SERVICES = 1; // Enumerate running services
|
||||
BACKSTAGE_LIST_PROCESSES = 2; // Enumerate running processes
|
||||
BACKSTAGE_SYSTEM_INFO = 3; // Get system information
|
||||
}
|
||||
|
||||
// Backstage output (agent -> viewer)
|
||||
message BackstageOutput {
|
||||
BackstageCommandType command_type = 1;
|
||||
oneof output {
|
||||
CommandOutput command_output = 10;
|
||||
ServiceList service_list = 11;
|
||||
ProcessList process_list = 12;
|
||||
SystemInfo system_info = 13;
|
||||
}
|
||||
}
|
||||
|
||||
message CommandOutput {
|
||||
string stdout = 1;
|
||||
string stderr = 2;
|
||||
int32 exit_code = 3;
|
||||
}
|
||||
|
||||
message ServiceList {
|
||||
repeated ServiceInfo services = 1;
|
||||
}
|
||||
|
||||
message ServiceInfo {
|
||||
string name = 1;
|
||||
string display_name = 2;
|
||||
ServiceStatus status = 3; // Running, Stopped, Paused
|
||||
string startup_type = 4; // Automatic, Manual, Disabled
|
||||
}
|
||||
|
||||
enum ServiceStatus {
|
||||
SERVICE_RUNNING = 0;
|
||||
SERVICE_STOPPED = 1;
|
||||
SERVICE_PAUSED = 2;
|
||||
}
|
||||
|
||||
message ProcessList {
|
||||
repeated ProcessInfo processes = 1;
|
||||
}
|
||||
|
||||
message ProcessInfo {
|
||||
uint32 pid = 1;
|
||||
string name = 2;
|
||||
string user = 3;
|
||||
uint64 memory_kb = 4;
|
||||
}
|
||||
|
||||
message SystemInfo {
|
||||
string os_version = 1;
|
||||
string hostname = 2;
|
||||
uint64 uptime_secs = 3;
|
||||
repeated SessionInfo logged_on_sessions = 4;
|
||||
}
|
||||
|
||||
// Extend Message wrapper to include new message types
|
||||
message Message {
|
||||
oneof payload {
|
||||
// ... existing messages ...
|
||||
|
||||
// Session switching (field numbers 90-99)
|
||||
SessionList session_list = 90;
|
||||
SwitchSession switch_session = 91;
|
||||
BackstageCommand backstage_command = 92;
|
||||
BackstageOutput backstage_output = 93;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dashboard/Viewer Changes
|
||||
|
||||
**Dashboard** (`server/static/dashboard.html` or React component)
|
||||
|
||||
- Machine detail view shows "Sessions (3)" next to online status
|
||||
- Click sessions count → modal showing session list with Switch button
|
||||
- "Enter Backstage" button for admin users (gated by `user.role === 'admin'`)
|
||||
|
||||
**Native Viewer** (`agent/src/viewer/mod.rs`)
|
||||
|
||||
- Session selector dropdown in viewer toolbar
|
||||
- Populate dropdown from `SessionList` message
|
||||
- On selection change → send `SwitchSession` message
|
||||
- "Backstage Mode" button (toggle) — switches to terminal UI instead of video frames
|
||||
|
||||
**Web Viewer** (`dashboard/src/components/RemoteViewer.tsx`)
|
||||
|
||||
- Same session selector dropdown as native viewer
|
||||
- Backstage mode shows a terminal-like UI (xterm.js or custom) displaying command output
|
||||
- Text input for commands, buttons for List Services / List Processes / System Info
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Files to Create
|
||||
|
||||
**Agent:**
|
||||
- `agent/src/sessions/mod.rs` (300 lines) — session enumeration, switching, state
|
||||
- `agent/src/sessions/wts.rs` (200 lines) — WTS API wrappers
|
||||
- `agent/src/sessions/backstage.rs` (400 lines) — Session 0 command execution, service/process enum
|
||||
|
||||
**Server:**
|
||||
- `server/src/relay/sessions.rs` (150 lines) — session list state, switch logic
|
||||
- `server/migrations/00N_session_selection.sql` (50 lines) — schema changes
|
||||
|
||||
**Dashboard:**
|
||||
- `server/static/js/session-selector.js` (200 lines) — session dropdown UI
|
||||
- `server/static/css/backstage.css` (100 lines) — backstage terminal styling
|
||||
|
||||
### Files to Modify
|
||||
|
||||
**Agent:**
|
||||
- `agent/src/capture/dxgi.rs:42` — Add session ID parameter to `DxgiCapturer::new()`
|
||||
- `agent/src/capture/mod.rs:76` — Call `OpenDesktop` for target session before DXGI init
|
||||
- `agent/src/session/mod.rs:120` — Extend session loop to handle `SwitchSession` and `BackstageCommand`
|
||||
- `agent/src/main.rs:15` — Add `mod sessions;`
|
||||
|
||||
**Server:**
|
||||
- `server/src/relay/mod.rs:85` — Handle `SessionList`, `SwitchSession`, `BackstageCommand`, `BackstageOutput` messages
|
||||
- `server/src/api/sessions.rs:40` — Add endpoint for session list query
|
||||
- `server/src/db/events.rs:25` — Log session switch events
|
||||
|
||||
**Proto:**
|
||||
- `proto/guruconnect.proto:454` — Add new message types (as shown above)
|
||||
|
||||
### Key Logic
|
||||
|
||||
**Agent Session Enumeration:**
|
||||
|
||||
```rust
|
||||
// agent/src/sessions/wts.rs
|
||||
use windows::Win32::System::RemoteDesktop::{
|
||||
WTSEnumerateSessionsW, WTSQuerySessionInformationW, WTSFreeMemory,
|
||||
WTS_SESSION_INFOW, WTSUserName, WTSConnectState, WTS_CURRENT_SERVER_HANDLE
|
||||
};
|
||||
|
||||
pub fn enumerate_sessions() -> Result<Vec<SessionInfo>> {
|
||||
let mut sessions = Vec::new();
|
||||
let mut session_info_ptr: *mut WTS_SESSION_INFOW = std::ptr::null_mut();
|
||||
let mut count: u32 = 0;
|
||||
|
||||
unsafe {
|
||||
if WTSEnumerateSessionsW(
|
||||
WTS_CURRENT_SERVER_HANDLE,
|
||||
0, // Reserved
|
||||
1, // Version
|
||||
&mut session_info_ptr,
|
||||
&mut count
|
||||
).as_bool() {
|
||||
let session_array = std::slice::from_raw_parts(session_info_ptr, count as usize);
|
||||
|
||||
for wts_session in session_array {
|
||||
// Skip Session 0 (services) for user session list
|
||||
if wts_session.SessionId == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Query user name
|
||||
let mut user_name_ptr: *mut u16 = std::ptr::null_mut();
|
||||
let mut bytes_returned: u32 = 0;
|
||||
|
||||
if WTSQuerySessionInformationW(
|
||||
WTS_CURRENT_SERVER_HANDLE,
|
||||
wts_session.SessionId,
|
||||
WTSUserName,
|
||||
&mut user_name_ptr as *mut _ as *mut _,
|
||||
&mut bytes_returned
|
||||
).as_bool() {
|
||||
let user_name = String::from_utf16_lossy(
|
||||
std::slice::from_raw_parts(user_name_ptr, (bytes_returned / 2) as usize)
|
||||
);
|
||||
|
||||
sessions.push(SessionInfo {
|
||||
session_id: wts_session.SessionId,
|
||||
user_name,
|
||||
state: map_wts_state(wts_session.State),
|
||||
session_type: if wts_session.pWinStationName.to_string()?.contains("RDP") {
|
||||
WindowsSessionType::RDP
|
||||
} else {
|
||||
WindowsSessionType::Console
|
||||
}
|
||||
});
|
||||
|
||||
WTSFreeMemory(user_name_ptr as *mut _);
|
||||
}
|
||||
}
|
||||
|
||||
WTSFreeMemory(session_info_ptr as *mut _);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
```
|
||||
|
||||
**Agent Session Switching:**
|
||||
|
||||
```rust
|
||||
// agent/src/sessions/mod.rs
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
OpenDesktopW, CloseDesktop, SwitchDesktop, HDESK
|
||||
};
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
pub struct SessionManager {
|
||||
current_session_id: u32,
|
||||
desktop_handle: Option<HDESK>,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn switch_session(&mut self, target_session_id: u32) -> Result<()> {
|
||||
tracing::info!("Switching from session {} to {}", self.current_session_id, target_session_id);
|
||||
|
||||
// Close current desktop handle
|
||||
if let Some(handle) = self.desktop_handle.take() {
|
||||
unsafe { CloseDesktop(handle) };
|
||||
}
|
||||
|
||||
// Open the target session's desktop
|
||||
// Desktop name format: "WinSta0\\Default" for console session
|
||||
let desktop_name = format!("WinSta0\\Default");
|
||||
let desktop_name_wide: Vec<u16> = desktop_name.encode_utf16().chain(Some(0)).collect();
|
||||
|
||||
let desktop_handle = unsafe {
|
||||
OpenDesktopW(
|
||||
PCWSTR::from_raw(desktop_name_wide.as_ptr()),
|
||||
0, // Flags
|
||||
false.into(), // Inherit handles
|
||||
0x0001 | 0x0002 | 0x0004 | 0x0008, // DESKTOP_READOBJECTS | DESKTOP_CREATEWINDOW | etc.
|
||||
)?
|
||||
};
|
||||
|
||||
// Switch to the new desktop
|
||||
unsafe {
|
||||
SwitchDesktop(desktop_handle)?;
|
||||
}
|
||||
|
||||
self.current_session_id = target_session_id;
|
||||
self.desktop_handle = Some(desktop_handle);
|
||||
|
||||
// Reinitialize screen capturer for the new desktop
|
||||
// (caller must recreate DxgiCapturer)
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Backstage Command Execution:**
|
||||
|
||||
```rust
|
||||
// agent/src/sessions/backstage.rs
|
||||
use windows::Win32::System::Threading::{CreateProcessW, PROCESS_INFORMATION, STARTUPINFOW};
|
||||
use windows::Win32::System::Pipes::{CreatePipe, ReadFile};
|
||||
|
||||
pub fn execute_command(command: &str) -> Result<CommandOutput> {
|
||||
// Create anonymous pipes for stdout/stderr
|
||||
let mut stdout_read: HANDLE = HANDLE::default();
|
||||
let mut stdout_write: HANDLE = HANDLE::default();
|
||||
|
||||
unsafe {
|
||||
CreatePipe(&mut stdout_read, &mut stdout_write, None, 0)?;
|
||||
}
|
||||
|
||||
// Launch cmd.exe /c <command> with redirected output
|
||||
let command_line = format!("cmd.exe /c {}", command);
|
||||
let mut command_line_wide: Vec<u16> = command_line.encode_utf16().chain(Some(0)).collect();
|
||||
|
||||
let mut startup_info = STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
|
||||
hStdOutput: stdout_write,
|
||||
hStdError: stdout_write,
|
||||
dwFlags: STARTF_USESTDHANDLES,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut process_info = PROCESS_INFORMATION::default();
|
||||
|
||||
unsafe {
|
||||
CreateProcessW(
|
||||
None,
|
||||
PWSTR::from_raw(command_line_wide.as_mut_ptr()),
|
||||
None,
|
||||
None,
|
||||
true.into(), // Inherit handles
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
&startup_info,
|
||||
&mut process_info
|
||||
)?;
|
||||
|
||||
CloseHandle(stdout_write);
|
||||
|
||||
// Read output
|
||||
let mut output = Vec::new();
|
||||
let mut buffer = [0u8; 4096];
|
||||
let mut bytes_read: u32 = 0;
|
||||
|
||||
loop {
|
||||
if ReadFile(stdout_read, Some(&mut buffer), Some(&mut bytes_read), None).is_ok() {
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
output.extend_from_slice(&buffer[..bytes_read as usize]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for process to exit
|
||||
WaitForSingleObject(process_info.hProcess, INFINITE);
|
||||
|
||||
let mut exit_code: u32 = 0;
|
||||
GetExitCodeProcess(process_info.hProcess, &mut exit_code)?;
|
||||
|
||||
CloseHandle(process_info.hProcess);
|
||||
CloseHandle(process_info.hThread);
|
||||
CloseHandle(stdout_read);
|
||||
|
||||
Ok(CommandOutput {
|
||||
stdout: String::from_utf8_lossy(&output).to_string(),
|
||||
stderr: String::new(),
|
||||
exit_code: exit_code as i32,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **Session switching requires elevated agent:** Agent must run as SYSTEM or Administrator to call WTS APIs and `OpenDesktop` across sessions
|
||||
- **Backstage mode is admin-only:** Server checks `user.role === 'admin'` before allowing `BackstageCommand` messages
|
||||
- **Audit logging:** All session switches and backstage commands are logged to `events` table with:
|
||||
- Timestamp
|
||||
- Technician user ID
|
||||
- Session ID (from/to)
|
||||
- Command text (for backstage)
|
||||
- Output (sanitized, no secrets)
|
||||
|
||||
### Input Validation
|
||||
|
||||
- **Session ID bounds checking:** Agent validates `target_session_id` exists in `WTSEnumerateSessions` results before switching
|
||||
- **Command injection prevention:** Backstage commands are executed via `cmd.exe /c` with proper escaping; no shell metacharacter expansion
|
||||
- **Output sanitization:** Command output is truncated to 100KB max to prevent memory exhaustion
|
||||
|
||||
### Threat Model
|
||||
|
||||
- **Malicious session switch:** Attacker with viewer access tries to switch to a privileged session (e.g., admin RDP session) → mitigated by server-side session ownership validation (can only switch sessions on machines you have access to)
|
||||
- **Backstage command abuse:** Attacker executes destructive commands (e.g., `rd /s /q C:\`) → mitigated by admin-only role gate + audit logging
|
||||
- **Session 0 privilege escalation:** Attacker uses backstage to run code as SYSTEM → inherent risk, same as any admin tool; audit log is the control
|
||||
|
||||
### Audit Events
|
||||
|
||||
**New event types:**
|
||||
- `session_switched` — Log when technician switches Windows sessions
|
||||
- `backstage_command` — Log all backstage command executions
|
||||
- `backstage_service_query` — Log service list queries
|
||||
- `backstage_process_query` — Log process list queries
|
||||
|
||||
**Event schema:**
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
id UUID PRIMARY KEY,
|
||||
session_id UUID REFERENCES connect_sessions(id),
|
||||
user_id UUID REFERENCES users(id),
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
details JSONB NOT NULL, -- {"from_session": 1, "to_session": 2, "command": "ipconfig", "exit_code": 0}
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**Agent (Rust):**
|
||||
- `sessions::wts::test_enumerate_sessions()` — mock WTS API, verify session parsing
|
||||
- `sessions::backstage::test_execute_command()` — mock CreateProcess, verify output capture
|
||||
- `sessions::test_switch_session()` — mock OpenDesktop/SwitchDesktop, verify state change
|
||||
|
||||
**Server (Rust):**
|
||||
- `relay::sessions::test_session_list_update()` — verify session list parsing and storage
|
||||
- `relay::sessions::test_switch_session_message()` — verify message forwarding to agent
|
||||
- `api::sessions::test_get_sessions_endpoint()` — verify API response format
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**End-to-end session switching:**
|
||||
1. Start agent on Windows machine with 2 logged-on users (console + RDP)
|
||||
2. Connect viewer to session
|
||||
3. Agent sends `SessionList` with 2 sessions
|
||||
4. Viewer sends `SwitchSession(2)`
|
||||
5. Agent switches to RDP session, restarts DXGI capture
|
||||
6. Viewer receives frames from RDP session desktop
|
||||
7. Verify session switch logged in `events` table
|
||||
|
||||
**Backstage command execution:**
|
||||
1. Admin user connects to agent
|
||||
2. Enter backstage mode
|
||||
3. Execute command: `ipconfig /all`
|
||||
4. Verify output displayed in viewer
|
||||
5. Execute command: `sc query wuauserv` (query Windows Update service)
|
||||
6. Verify service status returned
|
||||
7. Verify commands logged with full details
|
||||
|
||||
### Manual Testing Scenarios
|
||||
|
||||
1. **Multi-user server:**
|
||||
- Windows Server 2019 with 3 RDP users logged in
|
||||
- Enumerate sessions → verify all 3 shown in dropdown
|
||||
- Switch between sessions → verify screen updates correctly
|
||||
- Verify no session 0 in dropdown (backstage mode is separate)
|
||||
|
||||
2. **Fast User Switching workstation:**
|
||||
- Windows 10 workstation with 2 users (one active, one locked)
|
||||
- Enumerate sessions → verify both shown with correct state
|
||||
- Switch to locked session → verify lock screen is captured
|
||||
|
||||
3. **Backstage mode:**
|
||||
- Connect to agent
|
||||
- Enter backstage → verify no video frames, just terminal UI
|
||||
- Run `hostname` → verify output
|
||||
- List services → verify service table displayed
|
||||
- List processes → verify process list
|
||||
- Exit backstage → return to normal screen control
|
||||
|
||||
4. **Permission enforcement:**
|
||||
- Non-admin user tries to enter backstage → verify 403 Forbidden
|
||||
- Non-admin user tries to send `BackstageCommand` → server rejects
|
||||
|
||||
5. **Audit logging:**
|
||||
- Perform session switch + backstage commands
|
||||
- Query `events` table → verify all actions logged with timestamps, user IDs, details
|
||||
|
||||
### CI/CD Additions
|
||||
|
||||
- **Windows test VM:** Gitea Actions Windows runner with 2 test users configured (via Local Users and Groups)
|
||||
- **Automated test:** PowerShell script to create RDP session, run integration test, verify session switch
|
||||
- **Audit log verification:** Integration test queries events table after actions, asserts correct event_type and details
|
||||
|
||||
## Effort Estimate & Dependencies
|
||||
|
||||
**Size:** Large (8-10 weeks, 1 developer)
|
||||
|
||||
**Breakdown:**
|
||||
- Agent session enumeration + WTS API wrappers: 1.5 weeks
|
||||
- Agent session switching (OpenDesktop, SwitchDesktop, DXGI reinit): 2 weeks
|
||||
- Agent backstage mode (command execution, service/process enum): 2 weeks
|
||||
- Server session state management + protobuf: 1 week
|
||||
- Dashboard session selector UI: 1 week
|
||||
- Backstage terminal UI (web viewer): 1.5 weeks
|
||||
- Testing (integration tests, manual scenarios, audit verification): 1.5 weeks
|
||||
- Buffer for Windows session edge cases (disconnected sessions, fast user switching quirks): 1 week
|
||||
|
||||
**Dependencies:**
|
||||
- **SPEC-002 v2 Phase 1 completion** — per-agent keys and secure session core must be stable (already shipped)
|
||||
- **Admin role enforcement** — server must have role-based access control for backstage gating (already exists: `users.role`)
|
||||
- **Event logging infrastructure** — audit events table and logging (already exists: `events` table)
|
||||
|
||||
**Unblocks:**
|
||||
- Multi-user server management (critical for MSPs with Windows Server environments)
|
||||
- Session 0 administrative tasks without GUI disruption (backstage mode)
|
||||
- SPEC-005 machines list view (can show session count and logged-on users per machine)
|
||||
- Future: session recording (can record specific session, not just active console)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Session 0 GUI in older Windows?** — Windows XP/Server 2003 allowed interactive Session 0. GuruConnect targets Windows 7+ where Session 0 is non-interactive. Confirm backstage terminal-only approach is acceptable, or add a "force capture Session 0 desktop" option for legacy systems?
|
||||
|
||||
2. **Session switching during active control?** — If technician is moving the mouse and switches sessions mid-action, should the agent queue the switch until input is idle, or switch immediately? (Recommend: immediate switch, input events are session-scoped.)
|
||||
|
||||
3. **Disconnected session handling?** — If technician switches to a disconnected RDP session, should the agent attempt to reconnect it (via `WTSConnectSession`), or just capture the "disconnected" screen? (Recommend: just capture; reconnection is a high-impact action.)
|
||||
|
||||
4. **Backstage command timeout?** — Long-running commands (e.g., `chkdsk`) could hang. Implement a timeout (e.g., 60 seconds) and kill the process? (Recommend: yes, 60s default, configurable.)
|
||||
|
||||
5. **Clipboard across sessions?** — Current clipboard implementation is session-scoped. Should session switching clear the clipboard state, or try to preserve it? (Recommend: clear on switch; cross-session clipboard is a future enhancement.)
|
||||
|
||||
---
|
||||
|
||||
**Cross-references:**
|
||||
- SPEC-002: v2 modernization (protobuf already has `BACKSTAGE = 2`)
|
||||
- SPEC-005: Machines list view (can show session count + logged-on users)
|
||||
- SPEC-008: Structured errors (session switch failures need clear error codes)
|
||||
- REQUIREMENTS.md:498 — "Backstage Tools (No Screen Required)"
|
||||
- REQUIREMENTS.md:727 — "Backstage / Silent Support Mode"
|
||||
722
docs/specs/SPEC-014-branding-whitelabel.md
Normal file
722
docs/specs/SPEC-014-branding-whitelabel.md
Normal file
@@ -0,0 +1,722 @@
|
||||
# SPEC-014: Branding and White-Label Configuration
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2
|
||||
**Requested By:** Mike Swanson (2026-05-30)
|
||||
**Estimated Effort:** Medium
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Enable MSPs to customize GuruConnect branding (logo, colors, company name) to match their corporate identity or provide fully white-labeled remote support services. This addresses a competitive gap — ScreenConnect, Splashtop, and AnyDesk all offer white-labeling for MSPs who resell remote support under their own brand. MSPs using GuruConnect want their technicians and end-users to see "Acme Remote Support" instead of "GuruConnect", with Acme's logo and colors throughout the interface.
|
||||
|
||||
**Use Cases:**
|
||||
- **MSP brand consistency:** MSP wants dashboard, viewer, and agent tray to display their company branding
|
||||
- **White-label reselling:** MSP sells remote support services under a completely different product name
|
||||
- **Multi-brand MSP:** Large MSP operates multiple service brands with different branding per brand
|
||||
|
||||
**Success Criteria:**
|
||||
- MSP can upload custom logo, set primary/accent colors (brand hue), configure product name
|
||||
- Dashboard displays custom branding on login page and throughout UI
|
||||
- Native viewer shows custom product name in window title
|
||||
- Windows agent tray icon tooltip shows custom product name
|
||||
- Support code page shows custom branding
|
||||
- Changes apply instance-wide (single-tenant v1)
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
**Dashboard Branding:**
|
||||
- Custom logo upload (SVG/PNG, displayed in sidebar and login page)
|
||||
- Brand hue customization (OKLCH hue override for `--brand-hue` CSS variable)
|
||||
- Accent color customization (overrides `--accent` variable)
|
||||
- Product name (replaces "GuruConnect" in page titles, headers, login page)
|
||||
- Company name (footer copyright)
|
||||
- Custom favicon
|
||||
|
||||
**Viewer Branding:**
|
||||
- Native viewer window title uses custom product name
|
||||
- Web viewer page title and header use custom product name
|
||||
|
||||
**Agent Branding (Windows):**
|
||||
- Tray icon tooltip shows custom product name
|
||||
- System tray menu header shows custom product name
|
||||
|
||||
**Support Code Page:**
|
||||
- Displays custom logo
|
||||
- Shows custom company name
|
||||
- Uses custom brand colors
|
||||
|
||||
**Configuration Management:**
|
||||
- Admin-only branding settings page in dashboard
|
||||
- Logo upload with drag-and-drop and preview
|
||||
- OKLCH hue slider with live preview (0-360 degrees)
|
||||
- Accent color picker with OKLCH preview
|
||||
- Text inputs for product name and company name
|
||||
- Default values fallback to GuruConnect branding
|
||||
|
||||
### Explicitly Out of Scope
|
||||
|
||||
- **Per-tenant branding:** v1 is instance-wide (one branding config per GuruConnect deployment). Multi-tenant per-organization branding deferred to v2.
|
||||
- **Full theme customization:** Cannot change surface colors, typography, spacing, layout. v1 only overrides brand hue and accent color.
|
||||
- **Agent installer branding:** Agent `.exe` branding (file properties, icon) requires compile-time changes. Defer to v2.
|
||||
- **Email branding:** GuruConnect doesn't send emails yet. If email notifications are added later, inherit branding config.
|
||||
- **Custom domain support:** v1 uses the same deployment URL. Custom domain requires nginx/DNS configuration outside this spec.
|
||||
- **Mobile app branding:** No mobile GuruConnect app exists (see SPEC-011).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
**Relay Server (`server/src/`):**
|
||||
- New `branding_config` database table storing logo path, colors, names
|
||||
- New API endpoints: `GET /api/branding`, `PUT /api/branding`, `POST /api/branding/logo`
|
||||
- Serve uploaded logos from `server/static/branding/` directory
|
||||
- Support code page reads branding config for rendering
|
||||
|
||||
**Dashboard (`dashboard/src/`):**
|
||||
- New Settings page: Branding tab (`BrandingSettings.tsx`)
|
||||
- Logo upload component with drag-and-drop and preview
|
||||
- OKLCH hue slider (0-360 degrees) with live preview
|
||||
- Accent color picker (OKLCH)
|
||||
- Text inputs for product name and company name
|
||||
- Apply branding via CSS variables injection
|
||||
- Login page uses branding config
|
||||
|
||||
**Agent (`agent/src/`):**
|
||||
- Read product name from registry: `HKLM\SOFTWARE\GuruConnect\ProductName`
|
||||
- Tray icon tooltip and menu header use custom product name
|
||||
- No icon changes in v1 (compile-time resource)
|
||||
|
||||
**Native Viewer (`agent/src/viewer/`):**
|
||||
- Window title uses product name from config file or registry
|
||||
- No other visual changes in v1
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Admin configures branding:**
|
||||
- Admin navigates to Dashboard > Settings > Branding
|
||||
- Uploads logo → `POST /api/branding/logo` → server saves to `server/static/branding/logo.png`
|
||||
- Sets brand hue (e.g., 280 for purple) → `PUT /api/branding` → server updates database
|
||||
|
||||
2. **Dashboard applies branding:**
|
||||
- Root component fetches `GET /api/branding` on mount
|
||||
- Injects CSS variables: `--brand-hue: 280deg`, `--accent: oklch(78% 0.13 280)`
|
||||
- Replaces "GuruConnect" text with `productName`
|
||||
- Renders logo from `/branding/logo.png`
|
||||
- Updates favicon reference
|
||||
|
||||
3. **Support code page applies branding:**
|
||||
- Server-side renders support code page with branding config
|
||||
- Injects logo, product name, brand hue into HTML template
|
||||
|
||||
4. **Agent uses branding:**
|
||||
- Agent reads `HKLM\SOFTWARE\GuruConnect\ProductName` on startup
|
||||
- Tray tooltip: "{ProductName} Agent — Online"
|
||||
- Tray menu header: "{ProductName}"
|
||||
|
||||
### Database Schema
|
||||
|
||||
```sql
|
||||
-- New table: branding_config (singleton, one row)
|
||||
CREATE TABLE branding_config (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
product_name VARCHAR(100) NOT NULL DEFAULT 'GuruConnect',
|
||||
company_name VARCHAR(200) NOT NULL DEFAULT 'Arizona Computer Guru',
|
||||
logo_filename VARCHAR(200), -- e.g., "logo.png" (stored in server/static/branding/)
|
||||
favicon_filename VARCHAR(200), -- e.g., "favicon.ico"
|
||||
brand_hue INTEGER NOT NULL DEFAULT 184, -- OKLCH hue, 0-360 degrees (184 = cyan)
|
||||
accent_color VARCHAR(50) NOT NULL DEFAULT 'oklch(78% 0.13 184)', -- Full OKLCH color value
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Insert default row
|
||||
INSERT INTO branding_config (product_name, company_name, brand_hue, accent_color)
|
||||
VALUES ('GuruConnect', 'Arizona Computer Guru', 184, 'oklch(78% 0.13 184)');
|
||||
|
||||
-- Enforce singleton: only one row allowed
|
||||
CREATE UNIQUE INDEX branding_config_singleton ON branding_config ((true));
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
**`GET /api/branding`** (Public, no auth required for dashboard/support code rendering)
|
||||
- Returns branding config JSON
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"product_name": "Acme Remote Support",
|
||||
"company_name": "Acme IT Solutions",
|
||||
"logo_url": "/branding/logo.png",
|
||||
"favicon_url": "/branding/favicon.ico",
|
||||
"brand_hue": 280,
|
||||
"accent_color": "oklch(78% 0.13 280)"
|
||||
}
|
||||
```
|
||||
|
||||
**`PUT /api/branding`** (Admin only, requires JWT with admin role)
|
||||
- Updates branding config (text fields and colors only)
|
||||
- Request body:
|
||||
```json
|
||||
{
|
||||
"product_name": "Acme Remote Support",
|
||||
"company_name": "Acme IT Solutions",
|
||||
"brand_hue": 280,
|
||||
"accent_color": "oklch(78% 0.13 280)"
|
||||
}
|
||||
```
|
||||
- Validates brand_hue range (0-360)
|
||||
- Validates accent_color is valid OKLCH syntax
|
||||
- Returns updated config
|
||||
|
||||
**`POST /api/branding/logo`** (Admin only, multipart/form-data)
|
||||
- Uploads custom logo (PNG or SVG, max 2MB)
|
||||
- Validates image dimensions (recommended 200x40px, max 400x100px)
|
||||
- Saves to `server/static/branding/logo.{png|svg}`
|
||||
- Updates `branding_config.logo_filename`
|
||||
- Returns updated config
|
||||
|
||||
**`POST /api/branding/favicon`** (Admin only, multipart/form-data)
|
||||
- Uploads custom favicon (ICO, PNG, SVG, max 100KB)
|
||||
- Saves to `server/static/branding/favicon.{ico|png|svg}`
|
||||
- Updates `branding_config.favicon_filename`
|
||||
|
||||
**`DELETE /api/branding/logo`** (Admin only)
|
||||
- Removes custom logo, reverts to default GuruConnect logo
|
||||
- Deletes file from disk
|
||||
- Sets `logo_filename` to NULL
|
||||
|
||||
**`DELETE /api/branding/favicon`** (Admin only)
|
||||
- Removes custom favicon, reverts to default
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Relay Server (`server/src/`)
|
||||
|
||||
**Files to create:**
|
||||
- `server/src/api/branding.rs` (250 lines) — API routes, logo upload handler, validation
|
||||
- `server/src/db/branding.rs` (100 lines) — Database queries for branding config
|
||||
- `server/migrations/NNN_branding_config.sql` (40 lines) — Schema as shown above
|
||||
|
||||
**Files to modify:**
|
||||
- `server/src/api/mod.rs` — Add branding routes
|
||||
- `server/src/main.rs` — Create `server/static/branding/` directory on startup
|
||||
- `server/static/support_code.html` (if it exists) — Inject branding config into template
|
||||
|
||||
**Branding API implementation:**
|
||||
|
||||
```rust
|
||||
// server/src/api/branding.rs
|
||||
use axum::{
|
||||
extract::{Multipart, State},
|
||||
http::StatusCode,
|
||||
routing::{delete, get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::auth::AdminOnly;
|
||||
use crate::db::branding as db;
|
||||
use crate::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/branding", get(get_branding))
|
||||
.route("/api/branding", put(update_branding))
|
||||
.route("/api/branding/logo", post(upload_logo))
|
||||
.route("/api/branding/logo", delete(delete_logo))
|
||||
.route("/api/branding/favicon", post(upload_favicon))
|
||||
.route("/api/branding/favicon", delete(delete_favicon))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BrandingConfig {
|
||||
pub product_name: String,
|
||||
pub company_name: String,
|
||||
pub logo_url: Option<String>,
|
||||
pub favicon_url: Option<String>,
|
||||
pub brand_hue: i32, // 0-360 OKLCH hue
|
||||
pub accent_color: String, // Full OKLCH value, e.g., "oklch(78% 0.13 280)"
|
||||
}
|
||||
|
||||
// GET /api/branding (public, no auth)
|
||||
async fn get_branding(State(state): State<AppState>) -> Result<Json<BrandingConfig>, StatusCode> {
|
||||
let config = db::get_branding_config(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
// PUT /api/branding (admin only)
|
||||
async fn update_branding(
|
||||
_admin: AdminOnly,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<UpdateBrandingRequest>,
|
||||
) -> Result<Json<BrandingConfig>, StatusCode> {
|
||||
// Validate brand hue (0-360)
|
||||
if req.brand_hue < 0 || req.brand_hue > 360 {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Validate accent color (basic OKLCH syntax check)
|
||||
if !req.accent_color.starts_with("oklch(") {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
let config = db::update_branding_config(&state.db, req)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
// POST /api/branding/logo (admin only, multipart upload)
|
||||
async fn upload_logo(
|
||||
_admin: AdminOnly,
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<BrandingConfig>, StatusCode> {
|
||||
// Extract file from multipart
|
||||
let mut file_data: Option<Vec<u8>> = None;
|
||||
let mut file_ext: Option<String> = None;
|
||||
|
||||
while let Some(field) = multipart.next_field().await.ok().flatten() {
|
||||
if field.name().unwrap_or("") == "logo" {
|
||||
let content_type = field.content_type().unwrap_or("").to_string();
|
||||
let bytes = field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
// Validate size (max 2MB)
|
||||
if bytes.len() > 2 * 1024 * 1024 {
|
||||
return Err(StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
|
||||
// Determine extension from content type
|
||||
file_ext = match content_type.as_str() {
|
||||
"image/png" => Some("png".to_string()),
|
||||
"image/svg+xml" => Some("svg".to_string()),
|
||||
_ => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
|
||||
file_data = Some(bytes.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
let (data, ext) = match (file_data, file_ext) {
|
||||
(Some(d), Some(e)) => (d, e),
|
||||
_ => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
|
||||
// Save to server/static/branding/logo.{ext}
|
||||
let branding_dir = PathBuf::from("server/static/branding");
|
||||
tokio::fs::create_dir_all(&branding_dir)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let filename = format!("logo.{}", ext);
|
||||
let file_path = branding_dir.join(&filename);
|
||||
|
||||
tokio::fs::write(&file_path, &data)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Update database
|
||||
db::set_logo_filename(&state.db, &filename)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Return updated config
|
||||
let config = db::get_branding_config(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(config))
|
||||
}
|
||||
```
|
||||
|
||||
### Dashboard (`dashboard/src/`)
|
||||
|
||||
**Files to create:**
|
||||
- `dashboard/src/features/settings/BrandingSettings.tsx` (300 lines) — Branding configuration page
|
||||
- `dashboard/src/components/BrandingProvider.tsx` (150 lines) — React context for branding config
|
||||
- `dashboard/src/components/ui/ColorPicker.tsx` (100 lines) — OKLCH hue slider component
|
||||
- `dashboard/src/components/ui/LogoUpload.tsx` (150 lines) — Drag-and-drop logo upload
|
||||
|
||||
**Files to modify:**
|
||||
- `dashboard/src/main.tsx` — Wrap app in `BrandingProvider`
|
||||
- `dashboard/src/features/auth/LoginPage.tsx` — Use branding config for logo and colors
|
||||
- `dashboard/index.html:7` — Update page title with product name
|
||||
- `dashboard/src/styles/tokens.css:12` — Allow `--brand-hue` override via inline style
|
||||
|
||||
**Branding Provider:**
|
||||
|
||||
```tsx
|
||||
// dashboard/src/components/BrandingProvider.tsx
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { brandingApi } from "../api/client";
|
||||
|
||||
interface BrandingConfig {
|
||||
product_name: string;
|
||||
company_name: string;
|
||||
logo_url?: string;
|
||||
favicon_url?: string;
|
||||
brand_hue: number;
|
||||
accent_color: string;
|
||||
}
|
||||
|
||||
const BrandingContext = createContext<BrandingConfig | null>(null);
|
||||
|
||||
export function BrandingProvider({ children }: { children: ReactNode }) {
|
||||
const { data: branding } = useQuery({
|
||||
queryKey: ["branding"],
|
||||
queryFn: brandingApi.get,
|
||||
staleTime: Infinity, // Branding rarely changes
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!branding) return;
|
||||
|
||||
// Apply CSS variables
|
||||
document.documentElement.style.setProperty("--brand-hue", String(branding.brand_hue));
|
||||
if (branding.accent_color) {
|
||||
document.documentElement.style.setProperty("--accent", branding.accent_color);
|
||||
}
|
||||
|
||||
// Update page title
|
||||
document.title = `${branding.product_name} — Operator Console`;
|
||||
|
||||
// Update favicon if custom
|
||||
if (branding.favicon_url) {
|
||||
const link = document.querySelector("link[rel='icon']") as HTMLLinkElement;
|
||||
if (link) {
|
||||
link.href = branding.favicon_url;
|
||||
}
|
||||
}
|
||||
}, [branding]);
|
||||
|
||||
return (
|
||||
<BrandingContext.Provider value={branding || null}>
|
||||
{children}
|
||||
</BrandingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBranding() {
|
||||
const ctx = useContext(BrandingContext);
|
||||
return ctx || {
|
||||
product_name: "GuruConnect",
|
||||
company_name: "Arizona Computer Guru",
|
||||
brand_hue: 184,
|
||||
accent_color: "oklch(78% 0.13 184)",
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Branding Settings Page:**
|
||||
|
||||
```tsx
|
||||
// dashboard/src/features/settings/BrandingSettings.tsx
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Upload } from "lucide-react";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { LogoUpload } from "../../components/ui/LogoUpload";
|
||||
import { useBranding } from "../../components/BrandingProvider";
|
||||
import { brandingApi } from "../../api/client";
|
||||
|
||||
export function BrandingSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const branding = useBranding();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
product_name: branding.product_name,
|
||||
company_name: branding.company_name,
|
||||
brand_hue: branding.brand_hue,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: brandingApi.update,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["branding"] });
|
||||
},
|
||||
});
|
||||
|
||||
const logoMutation = useMutation({
|
||||
mutationFn: brandingApi.uploadLogo,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["branding"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate({
|
||||
...formData,
|
||||
accent_color: `oklch(78% 0.13 ${formData.brand_hue})`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">Branding Configuration</h2>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Logo</label>
|
||||
{branding.logo_url && (
|
||||
<img
|
||||
src={branding.logo_url}
|
||||
alt="Current logo"
|
||||
className="h-12 mb-4 max-w-[300px]"
|
||||
/>
|
||||
)}
|
||||
<LogoUpload onUpload={(file) => logoMutation.mutate(file)} />
|
||||
</div>
|
||||
|
||||
{/* Product Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Product Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.product_name}
|
||||
onChange={(e) => setFormData({ ...formData, product_name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-panel border border-border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Company Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Company Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.company_name}
|
||||
onChange={(e) => setFormData({ ...formData, company_name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-panel border border-border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Brand Hue Slider */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Brand Hue (OKLCH)</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="360"
|
||||
value={formData.brand_hue}
|
||||
onChange={(e) => setFormData({ ...formData, brand_hue: parseInt(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<div
|
||||
className="w-12 h-12 rounded border border-border"
|
||||
style={{ background: `oklch(78% 0.13 ${formData.brand_hue})` }}
|
||||
/>
|
||||
<span className="text-sm text-muted">{formData.brand_hue}° — oklch(78% 0.13 {formData.brand_hue})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<Button onClick={handleSave} disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Agent (`agent/src/`)
|
||||
|
||||
**Files to modify:**
|
||||
- `agent/src/tray/mod.rs` (Windows) — Read product name from registry, use in tooltip and menu
|
||||
- `agent/src/main.rs` — Optionally write default product name to registry on first run
|
||||
|
||||
**Registry key:**
|
||||
|
||||
```
|
||||
HKLM\SOFTWARE\GuruConnect\ProductName (REG_SZ): "Acme Remote Support"
|
||||
```
|
||||
|
||||
**Agent reads branding:**
|
||||
|
||||
```rust
|
||||
// agent/src/tray/mod.rs (Windows)
|
||||
use winreg::RegKey;
|
||||
use winreg::enums::*;
|
||||
|
||||
fn get_product_name() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
if let Ok(gc_key) = hklm.open_subkey("SOFTWARE\\GuruConnect") {
|
||||
if let Ok(name) = gc_key.get_value::<String, _>("ProductName") {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"GuruConnect".to_string() // Default fallback
|
||||
}
|
||||
|
||||
// Tray tooltip
|
||||
let tooltip = format!("{} Agent — {}", get_product_name(), if online { "Online" } else { "Offline" });
|
||||
|
||||
// Tray menu header
|
||||
let menu_header = get_product_name();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **Branding GET endpoint is public** — allows unauthenticated dashboard/login/support code pages to render with custom branding
|
||||
- **All mutation endpoints (PUT, POST, DELETE) require admin JWT** — only admins can change branding
|
||||
- **Logo upload validates file type and size** — only PNG/SVG allowed, max 2MB
|
||||
- **Stored files served from static directory** — no code execution risk
|
||||
|
||||
### Input Validation
|
||||
|
||||
- **Brand hue validation:** Must be 0-360 integer
|
||||
- **Accent color validation:** Must start with `oklch(` (basic syntax check)
|
||||
- **Product/company name length limits:** Max 100/200 chars
|
||||
- **Logo filename sanitization:** Prevent path traversal (e.g., `../../../etc/passwd.png`)
|
||||
|
||||
### Audit Logging
|
||||
|
||||
**New event types:**
|
||||
- `branding_updated` — Log all branding changes
|
||||
- `logo_uploaded` — Log logo uploads with filename and size
|
||||
- `logo_deleted` — Log logo deletions
|
||||
|
||||
**Event schema:**
|
||||
```sql
|
||||
INSERT INTO events (event_type, user_id, details) VALUES
|
||||
('branding_updated', '...', '{"changes": {"brand_hue": {"old": 184, "new": 280}}}');
|
||||
```
|
||||
|
||||
### Threat Model
|
||||
|
||||
- **Logo upload abuse:** Attacker uploads malicious SVG with embedded script → mitigated by CSP and serving files with `Content-Type: image/svg+xml`
|
||||
- **CSS injection via OKLCH values:** Attacker injects CSS via accent_color field → mitigated by basic `oklch(` prefix validation
|
||||
- **Phishing via fake branding:** Attacker configures branding to impersonate another MSP → mitigated by audit logging and admin-only access
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**Server (Rust):**
|
||||
- `api/branding_test.rs` — Test CRUD operations, validation (hue range, OKLCH syntax)
|
||||
- `db/branding_test.rs` — Test singleton enforcement, default values
|
||||
|
||||
**Dashboard (TypeScript):**
|
||||
- `BrandingSettings.test.tsx` — Test form inputs, logo upload, hue slider
|
||||
- `BrandingProvider.test.tsx` — Test context, CSS variable injection
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**End-to-end branding flow:**
|
||||
1. Admin navigates to Settings > Branding
|
||||
2. Uploads custom logo (PNG, 50KB)
|
||||
3. Sets product name to "Acme Remote Support", brand hue to 280 (purple)
|
||||
4. Clicks Save
|
||||
5. Refreshes page → verify branding persists (logo, product name, purple hue)
|
||||
6. Logs out → verify login page shows custom logo and purple accent
|
||||
7. Generates support code → verify support code page shows custom branding
|
||||
|
||||
### Manual Testing Scenarios
|
||||
|
||||
1. **Logo upload:**
|
||||
- Upload PNG (valid) → verify preview, save, reload
|
||||
- Upload SVG (valid) → verify preview
|
||||
- Upload JPEG (invalid) → verify error
|
||||
- Upload 3MB file → verify 413 error
|
||||
|
||||
2. **Brand hue slider:**
|
||||
- Drag slider to 0 (red) → verify live preview updates
|
||||
- Drag to 120 (green) → verify preview
|
||||
- Drag to 240 (blue) → verify preview
|
||||
- Drag to 280 (purple) → verify preview
|
||||
- Save → verify dashboard sidebar color changes
|
||||
|
||||
3. **Product name:**
|
||||
- Change to "Acme Remote Support" → verify page title, sidebar header
|
||||
- Verify login page shows new name
|
||||
- Verify support code page shows new name
|
||||
|
||||
4. **Agent branding:**
|
||||
- Set registry key manually: `HKLM\SOFTWARE\GuruConnect\ProductName = "Acme Remote Support"`
|
||||
- Restart agent → verify tray tooltip shows "Acme Remote Support Agent"
|
||||
- Right-click tray icon → verify menu header shows "Acme Remote Support"
|
||||
|
||||
### CI/CD Additions
|
||||
|
||||
- **Branding table seed:** Add default branding row to test database
|
||||
- **Logo upload mock:** Mock multipart upload in integration tests
|
||||
- **Hue validation tests:** Parametrized tests for valid/invalid hue values
|
||||
|
||||
---
|
||||
|
||||
## Effort Estimate & Dependencies
|
||||
|
||||
**Size:** Medium (4-6 weeks, 1 developer)
|
||||
|
||||
**Breakdown:**
|
||||
- Server branding API + database: 1 week
|
||||
- Dashboard branding settings page: 1.5 weeks
|
||||
- Dashboard branding provider + CSS variable injection: 1 week
|
||||
- Agent registry key reading: 0.5 weeks
|
||||
- Testing (integration + manual): 1 week
|
||||
- Documentation: 0.5 weeks
|
||||
|
||||
**Dependencies:**
|
||||
- None — branding is standalone with no hard dependencies
|
||||
|
||||
**Unblocks:**
|
||||
- Multi-tenant per-organization branding (v2)
|
||||
- GuruRMM integration with white-labeled GuruConnect (RMM can inherit branding)
|
||||
- Custom domain support (future)
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should viewer (native + web) support full branding or just product name?** — v1 spec only changes product name in window/page title. Full visual branding (logo in viewer, custom colors) deferred to v2.
|
||||
|
||||
2. **Should agent tray icon be dynamically loaded or compiled-in?** — v1 uses compiled-in icon (simplicity). Dynamic icon loading from server would enable branding changes without reinstalling agents, but adds complexity. Recommendation: static for v1, defer to v2.
|
||||
|
||||
3. **Should support code page be server-rendered or client-side?** — If server-rendered, branding is injected at render time. If client-side React, it fetches branding config. Recommendation: server-rendered for simplicity (support code page is standalone, not part of dashboard SPA).
|
||||
|
||||
4. **Should branding be per-tenant or global?** — v1 spec is global (one branding config per GuruConnect instance). If GuruConnect becomes multi-tenant in the future, add `tenant_id` FK to `branding_config`. Recommendation: start global, add per-tenant FK in v2 if multi-tenancy is implemented.
|
||||
|
||||
5. **Should dashboard support custom fonts?** — Not in v1 (uses Hanken Grotesk and JetBrains Mono). Custom font upload is complex (licensing, performance). Recommendation: defer to v2.
|
||||
|
||||
---
|
||||
|
||||
**Cross-references:**
|
||||
- SPEC-002: v2 modernization (branding can be part of Phase 2/3)
|
||||
- SPEC-005: Machines list view (could show custom branding in UI)
|
||||
- ADR-001: GuruConnect is standalone (branding is GuruConnect-owned, not coupled to GuruRMM)
|
||||
|
||||
---
|
||||
|
||||
**Next Steps:**
|
||||
1. Review specification with Mike
|
||||
2. Create database migration (`NNN_branding_config.sql`)
|
||||
3. Implement server API (`server/src/api/branding.rs`)
|
||||
4. Implement dashboard UI (`dashboard/src/features/settings/BrandingSettings.tsx`)
|
||||
5. Update agent tray to read product name from registry
|
||||
6. Test end-to-end branding flow
|
||||
7. Document in user guide
|
||||
1051
docs/specs/SPEC-015-notification-overlay.md
Normal file
1051
docs/specs/SPEC-015-notification-overlay.md
Normal file
File diff suppressed because it is too large
Load Diff
244
docs/specs/SPEC-016-zero-touch-enrollment.md
Normal file
244
docs/specs/SPEC-016-zero-touch-enrollment.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# SPEC-016: Zero-Touch Per-Site Agent Enrollment
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P1
|
||||
**Requested By:** Mike (2026-06-02)
|
||||
**Estimated Effort:** X-Large
|
||||
|
||||
## Overview
|
||||
|
||||
Give GuruConnect a ScreenConnect-class managed-agent enrollment flow: a technician runs
|
||||
**one signed installer per site** on every machine at that site — no per-machine key
|
||||
minting, no flags, no typing — and each machine **self-registers** on first run, the
|
||||
server minting it a per-machine `cak_` key bound to a stable, machine-derived
|
||||
`machine_uid`. Each site installer carries a **rotatable per-site enrollment key** (a long
|
||||
server-generated secret) plus a short human-readable **fingerprint** (`vN (XXXX)`) so an
|
||||
operator can tell at a glance whether an installer is current. Rotating a site's key blocks
|
||||
*new* enrollments from old installers while leaving already-enrolled machines untouched
|
||||
(they hold their own `cak_`).
|
||||
|
||||
This is the missing piece that turns the v2 secure-session-core (SPEC-004 per-agent keys +
|
||||
`machine_uid`) into a real product workflow, and it **resolves SPEC-007's open
|
||||
signature-vs-appended-config question**: the agent binary is signed **once** in CI
|
||||
(already shipped via `release.yml`), and per-site customization rides in a thin **signed
|
||||
wrapper** that writes site config to the endpoint at install time — never appended into the
|
||||
signed PE.
|
||||
|
||||
**Success criteria:**
|
||||
1. A tech installs one site installer on N machines; all N appear in the console under the
|
||||
correct company/site, each as a distinct, deduplicated machine — zero per-machine setup.
|
||||
2. Re-installing / re-imaging the same hardware **reuses** the existing machine row (no
|
||||
ghost duplicates — the failure mode SPEC-004 documents).
|
||||
3. Rotating a site's enrollment key makes old installers unable to enroll new machines,
|
||||
while every already-enrolled agent keeps working.
|
||||
4. Every distributed installer is **validly Authenticode-signed** (SmartScreen/WDAC clean).
|
||||
|
||||
## Background — what exists today (confirmed in code)
|
||||
|
||||
- **Embedded config is append-based and breaks signing.** `server/src/api/downloads.rs`
|
||||
(`download_agent`, ~`:152`) reads `static/downloads/guruconnect.exe` and **appends**
|
||||
`MAGIC_MARKER` + `len:u32` + JSON (`:196`) to the end of the PE. The agent reads it back
|
||||
in `agent/src/config.rs` (`read_embedded_config`, `:223`). Appending bytes after a signed
|
||||
PE invalidates the Authenticode signature — so the current customization path and the
|
||||
newly-shipped CI signing are mutually exclusive.
|
||||
- **No self-registration exists.** Per-agent `cak_` keys are minted **admin-only** in
|
||||
`server/src/api/machine_keys.rs` (`create_key`, `:119`; "Admin issued a per-agent key",
|
||||
`:146`). There is no endpoint where an agent first-run exchanges an enrollment credential
|
||||
for its own key.
|
||||
- **Relay already accepts per-agent keys.** `server/src/relay/mod.rs`
|
||||
(`validate_agent_api_key`, `:417`) calls `crate::auth::agent_keys::verify_agent_key`
|
||||
(`:422`) — the `cak_` path — then falls back to the **deprecated** shared `AGENT_API_KEY`
|
||||
(`:444`, logs a "migrate to per-agent `cak_`" warning).
|
||||
- **Key primitives exist.** `server/src/auth/agent_keys.rs`: `generate_agent_key` mints a
|
||||
`cak_`-prefixed high-entropy key (`:36`/`:46`); `verify_agent_key` (`:71`).
|
||||
`server/src/db/agent_keys.rs` already inserts into `connect_agent_keys (machine_id,
|
||||
key_hash, tenant_id)` (`:47`) — the v2 tenancy column is present (migration
|
||||
`004_v2_secure_session_core.sql`).
|
||||
- **Identity is a random config UUID, not machine-derived** — the root cause of duplicates
|
||||
per SPEC-004 (`agent/src/config.rs` `generate_agent_id`, `:90`).
|
||||
- **Agent mode dispatch:** `agent/src/main.rs` `Commands::Install` (`:160`) → `run_install`;
|
||||
`agent/src/config.rs` `detect_run_mode` (`:162`) returns `RunMode::PermanentAgent` when
|
||||
embedded config is present.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1 (CORE)
|
||||
|
||||
1. **`machine_uid` — deterministic machine identity (hardware-salted, per-tenant).** Derive
|
||||
a stable id from the Windows `MachineGuid`
|
||||
(`HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`) **salted with stable hardware
|
||||
signals** (SMBIOS UUID / motherboard + disk serial), independent of the config-file
|
||||
`agent_id`. Hardware-derived salt is deliberate: it **survives an OS reinstall/re-image
|
||||
on the same hardware** (so the row is reused — the re-image dedup goal) while keeping
|
||||
distinct physical boxes distinct (a per-install *random* salt would break re-image dedup
|
||||
and is rejected). Uniqueness is scoped **per-tenant** — dedup key `(tenant_id,
|
||||
machine_uid)` — so the same hardware legitimately present in two tenants stays two
|
||||
independent rows. (Shared root with SPEC-004; whichever lands first owns the impl, the
|
||||
other consumes it.) Used as the dedup key for register/move.
|
||||
|
||||
**Collision-gated activation.** The residual collision case is VMs/templates that share a
|
||||
hardware UUID (some hypervisors clone the SMBIOS UUID). When the server detects a
|
||||
`machine_uid` collision (a seemingly-different endpoint resolving to an existing uid), the
|
||||
endpoint does **not** auto-activate: it drops to a **pending** state, fires an alert, and
|
||||
an operator must confirm in the dashboard that the collided endpoint may activate. This is
|
||||
the one deliberate exception to auto-approve (see item 6).
|
||||
|
||||
2. **Per-site enrollment key + fingerprint.**
|
||||
- Long (≥256-bit) server-generated secret per site, stored **hashed** (Argon2id, same
|
||||
as `cak_`/passwords), never recoverable in plaintext after issue.
|
||||
- A non-secret **fingerprint** = monotonic version + short derived code in **hex**,
|
||||
rendered `vN (XXXX)` (e.g. `v3 (7F2A)`), shown in the dashboard, baked into the
|
||||
installer filename, and reported by the agent at enrollment. Hex is deliberate —
|
||||
**not** the RMM word-style code (`GREEN-FALCON`) — so GuruConnect and GuruRMM
|
||||
artifacts are never visually conflated.
|
||||
- **Rotate** regenerates the secret and bumps the version; old installers are rejected
|
||||
for *new* enrollments; existing agents (holding `cak_`) are unaffected.
|
||||
|
||||
3. **Self-registration endpoint.** New `POST /api/enroll` (public, unauthenticated by JWT —
|
||||
gated by the enrollment key) accepting `{ site_code, enrollment_key, machine_uid,
|
||||
hostname, labels{company,site,department,device_type,tags} }`:
|
||||
- Verify `(site_code, enrollment_key)` against the current per-site key.
|
||||
- **Dedup by `machine_uid`** within the site: if the machine exists, reuse the row and
|
||||
rotate its `cak_`; else create the machine row.
|
||||
- Mint a `cak_` (reuse `generate_agent_key`), store hashed via `db::agent_keys` bound to
|
||||
`machine_id` (+ `tenant_id` from the site), return the plaintext `cak_` **once**.
|
||||
- Emit an audit event + **new-enrollment alert** (and a **site-move** alert when an
|
||||
existing `machine_uid` enrolls under a different site).
|
||||
- **Rate-limit + lockout** per `(site_code, source-IP)` as defense-in-depth (the key is
|
||||
long, so this is belt-and-suspenders, not load-bearing).
|
||||
|
||||
4. **Agent first-run enrollment.** On `RunMode::PermanentAgent` with no stored `cak_`:
|
||||
read site config → call `/api/enroll` with `machine_uid` → persist the returned `cak_`
|
||||
to a SYSTEM-only protected store (HKLM under a SYSTEM-only ACL, or DPAPI-machine) →
|
||||
connect to `wss://connect.azcomputerguru.com/ws/agent` using the `cak_`. On subsequent
|
||||
runs, use the stored `cak_` directly (no re-enroll).
|
||||
|
||||
5. **Sign-once base + per-site signed wrapper (resolves SPEC-007 open question).**
|
||||
- The base agent is signed once in CI (`release.yml`, already shipped) and stays
|
||||
byte-identical for everyone.
|
||||
- Per-site customization (labels + enrollment key + fingerprint) is delivered to the
|
||||
endpoint **at install time** via a signing-safe channel — NOT appended to the signed
|
||||
PE. **v1 produces BOTH a signed bootstrapper `.exe` and a signed MSI per site**
|
||||
(ScreenConnect parity — manual installs grab the `.exe`, GPO/Intune fleet pushes take
|
||||
the MSI), both wrapping the same sign-once agent and writing the site config to the
|
||||
protected config location. The two differ only in packaging (bootstrapper stub vs. WiX
|
||||
bundle); both are signed.
|
||||
- **Deprecate the append path** in `downloads.rs` for managed installs (keep only for
|
||||
attended/support-code if still needed), eliminating the signature-invalidation defect.
|
||||
|
||||
6. **Auto-approve posture (with collision-gate exception).** A self-registered machine is
|
||||
live and controllable immediately (ScreenConnect parity); the new-enrollment alert is the
|
||||
tripwire. The **one** exception is a detected `machine_uid` collision (item 1), which
|
||||
gates the endpoint to **pending** until an operator confirms it in the dashboard.
|
||||
|
||||
### Explicitly out of scope (ANTICIPATED — reserve room, do NOT build in v1)
|
||||
|
||||
The v1 data model and agent mode-dispatch must leave room for these without building them:
|
||||
|
||||
- **Per-site enrollment POLICY** — a `sites.enrollment_policy` field (default
|
||||
`auto-approve`; future `pending-approval`) plus per-seat/per-endpoint licensing controls.
|
||||
Commercial, multi-tenant (the `tenant_id` column already exists). Its own future SPEC.
|
||||
- **Flag overrides** — `--enroll-key` / `--site-code` (generic installer, key supplied on
|
||||
the command line) and `--reassign` (move an existing machine to a new site, gated by
|
||||
possession of the destination site's key, with an **explicit accidental-move guard**:
|
||||
a different-site re-run refuses unless `--reassign` is passed) + cross-client move policy.
|
||||
Backend (`machine_uid` + authorized site + `cak_`) is designed to support it; CLI surface
|
||||
is deferred.
|
||||
- **Technician-assisted interactive install** — `--technician` on a generic installer:
|
||||
prompts for the tech's own server credentials, and on auth presents a **validated**
|
||||
Company/Site/tags picker from the live authorized list (authz-by-identity, full audit
|
||||
trail). Heaviest path (interactive UI + auth/list callback); deferred.
|
||||
|
||||
All three converge on the **same backend operation** delivered in v1: `machine_uid` +
|
||||
authorized site + issued `cak_`. v1 only ships the per-site-embedded-key door.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Agent** (`agent/`): compute `machine_uid`; first-run enroll → store `cak_`; use stored
|
||||
`cak_` thereafter; read site config from the wrapper-written location instead of an
|
||||
appended PE blob. Touches `config.rs` (`EmbeddedConfig`/`detect_run_mode`/storage),
|
||||
`main.rs` (`Install`/run-mode), a new `enroll` client module, transport auth.
|
||||
- **Relay-server** (`server/`): new `POST /api/enroll`; per-site key issue/rotate/verify;
|
||||
`machine_uid` dedup + site-move on register; audit + alert emission; rate-limit/lockout.
|
||||
Touches `api/` (new `enroll.rs`, `sites` key endpoints), `auth/agent_keys.rs`,
|
||||
`db/agent_keys.rs`, `relay/mod.rs` (enrollment vs. connect), `main.rs` routes.
|
||||
- **Dashboard**: per-site enrollment-key display (fingerprint `vN (XXXX)`), **Rotate**
|
||||
action, "current installer" download wired to the signed wrapper build. (Builder UI is
|
||||
SPEC-007; this spec supplies the key/fingerprint/rotation it consumes.)
|
||||
- **DB migration:** `site_enrollment_keys` (or columns on the site): `site_id`,
|
||||
`key_hash`, `version`, `fingerprint`, `created_at`, `rotated_at`, `active`. Reserve
|
||||
`sites.enrollment_policy` (nullable, default `auto-approve`) for the anticipated policy
|
||||
work. `connect_machines` gains `machine_uid` (unique per tenant/site).
|
||||
- **Protobuf** (`proto/guruconnect.proto`): no wire change required for enrollment if
|
||||
`/api/enroll` is REST; `AgentStatus` label fields per SPEC-007 (`department`,
|
||||
`device_type`) ride along if landed together.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **Two-tier credential model:** low-sensitivity **enrollment key** (gates "may register",
|
||||
shared per site, rotatable) vs. high-sensitivity **per-machine `cak_`** (operating
|
||||
credential, per-machine revocation). Compromise of an enrollment key is recovered by
|
||||
rotating one site — no fleet-wide re-key.
|
||||
- **Enrollment keys stored hashed** (Argon2id); plaintext shown once at issue/rotate.
|
||||
- **`cak_` at rest on the endpoint** is stored as a **DPAPI-machine-encrypted blob inside a
|
||||
SYSTEM-ACL'd location** (HKLM value or `ProgramData` file) — both layers: the SYSTEM ACL
|
||||
stops non-admin users reading it, and DPAPI-machine encryption makes a copied file/export
|
||||
inert off the box. (Local admin/SYSTEM can always recover it; that is accepted — blast
|
||||
radius of one leaked `cak_` is a single, independently-revocable machine.)
|
||||
- **`machine_uid` binding** is the spoof-guard SPEC-004 wants: a `cak_` is bound to a
|
||||
`machine_uid`; a different box presenting another box's `cak_` is detectable.
|
||||
- **Authorization model** for moves/enrolls is possession-of-destination-key in v1
|
||||
(identity-based authz deferred to the technician-assisted path).
|
||||
- **Open registration risk** is mitigated by requiring `(site_code + long key)` and
|
||||
rate-limit/lockout; auto-approve is acceptable because the enrollment key is the gate and
|
||||
every enrollment/site-move fires an alert.
|
||||
- **Audit events:** enroll, re-enroll/reuse, site-move, key-rotate — all logged with
|
||||
`machine_uid`, site, and source IP.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit:** `machine_uid` derivation stability; enrollment-key verify/rotate; fingerprint
|
||||
derivation; `cak_` mint/hash/verify; dedup decision (new vs. reuse vs. move).
|
||||
- **Integration:** enroll new → row + `cak_` issued; re-enroll same `machine_uid` → reuse,
|
||||
no duplicate; enroll with rotated (old) key → rejected; old `cak_` still connects after
|
||||
rotation; rate-limit/lockout trips; site-move emits alert.
|
||||
- **Manual:** build a site wrapper installer → run on a clean VM → appears in console under
|
||||
correct site, immediately controllable; re-image VM → same row reused; `signtool verify
|
||||
/pa` passes on the distributed wrapper and the laid-down agent.
|
||||
|
||||
## Effort estimate & dependencies
|
||||
|
||||
- **Size:** X-Large (agent + relay + DB migration + CI build/sign wrapper + dashboard
|
||||
key/rotation surface).
|
||||
- **Depends on:** SPEC-004 `machine_uid` (shared root); the CI signing already shipped
|
||||
(SPEC-001 §2 / `release.yml`).
|
||||
- **Unblocks:** SPEC-007 (installer builder gets a real per-site key + the signing
|
||||
resolution), and the parked managed-agent test deployment on the internal beta machines.
|
||||
- **Relationship to v2 phases:** sits with the Phase-1 secure-session-core (per-agent keys
|
||||
+ identity) and feeds Phase-2 dashboard work.
|
||||
|
||||
## Resolved decisions (2026-06-02, Mike)
|
||||
|
||||
1. **Wrapper shape — BOTH.** v1 ships a signed bootstrapper `.exe` *and* a signed MSI per
|
||||
site (ScreenConnect offers both; manual installs use the `.exe`, GPO/Intune fleet pushes
|
||||
use the MSI). Same sign-once agent inside each.
|
||||
2. **`cak_` storage — BOTH layers.** DPAPI-machine-encrypted blob stored in a SYSTEM-ACL'd
|
||||
location. Non-admins can't read it; a stolen copy is inert off the box.
|
||||
3. **Fingerprint — hex (`7F2A`).** Deliberately *not* the RMM word-code style, so the two
|
||||
products' artifacts are never visually conflated.
|
||||
4. **`machine_uid` — per-tenant scope, hardware-derived salt, collision-gated.** Dedup key
|
||||
`(tenant_id, machine_uid)`; salt from stable hardware signals (survives same-hardware
|
||||
re-image, separates distinct boxes); detected collisions (e.g. template-cloned VMs
|
||||
sharing a hardware UUID) drop to pending + alert and require dashboard confirmation to
|
||||
activate.
|
||||
5. **Attended (support-code) path — unchanged.** `download_support` is filename-based
|
||||
(`GuruConnect-<code>.exe`), not append-based, so renaming never breaks the signature —
|
||||
it is already signing-safe. Only the managed `download_agent` append path is retired.
|
||||
|
||||
## Remaining for planning
|
||||
|
||||
- Exact stable-hardware signal set for the salt (SMBIOS UUID alone vs. + motherboard/disk
|
||||
serial) and hypervisor behavior matrix (which hypervisors duplicate the SMBIOS UUID on
|
||||
clone → exercise the collision-gate).
|
||||
- MSI authoring approach (WiX) and whether per-site config rides as a per-site MSI vs. a
|
||||
base MSI + property/transform.
|
||||
180
docs/specs/SPEC-017-end-user-remote-access.md
Normal file
180
docs/specs/SPEC-017-end-user-remote-access.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# SPEC-017: End-User (Sub-User) Remote Access
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2 (may settle to P3 depending on client demand)
|
||||
**Requested By:** Mike (2026-06-02)
|
||||
**Estimated Effort:** Large
|
||||
|
||||
## Overview
|
||||
|
||||
Let a client pay for their own employees to remotely reach **their own work machines** from home
|
||||
through GuruConnect — the Splashtop-Business / unattended-end-user-access model, layered on top of the
|
||||
MSP-technician console GuruConnect ships today. An MSP admin (or, later, a delegated client-company
|
||||
admin) provisions a list of **end-users** and grants each one access to specific managed machines. The
|
||||
end-user signs into a locked-down **end-user portal**, sees only the machines granted to them, and
|
||||
connects — reusing the existing persistent-agent + session-scoped-viewer-token + relay path.
|
||||
|
||||
Success criteria: an `end_user`-role account can log in at a separate portal, see exactly the machines
|
||||
in its grant set (and no others, across no other tenant), launch a control session to an online granted
|
||||
machine, and is hard-denied from every technician/admin API, the agent plane, and any machine it was
|
||||
not granted — with each login and machine access written to the audit log.
|
||||
|
||||
This is a net-new **sellable capability**, not a console-MVP blocker. It is sequenced after the v2
|
||||
console foundations it depends on (tenancy, machine identity, persistent enrollment), which is why it is
|
||||
P2 rather than P1.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
- A new **`end_user`** value for `users.role`, provisioned by an MSP admin, with **deny-by-default**
|
||||
authority: no console permissions, no agent-plane access, machine reach limited strictly to its
|
||||
`user_client_access` grant set within its own tenant.
|
||||
- A **separate end-user login + portal** route (locked-down): lists only granted machines with
|
||||
online/offline state and a Connect action. No admin nav, no other users/machines/companies.
|
||||
- **Admin UI + API** to create/disable end-users and assign/revoke per-machine grants, reusing the
|
||||
existing `user_client_access` table.
|
||||
- **Connect flow** that reuses the landed session-scoped viewer-token mechanism (`ViewerClaims`,
|
||||
`jwt.rs:114`) and the relay enforcement path — no new transport.
|
||||
- A new `connect_sessions.source` value **`end_user`** (migration widening the existing CHECK).
|
||||
- **Audit**: end-user login success/failure and each machine-access grant-check written to
|
||||
`connect_session_events`.
|
||||
- Rate limiting + lockout on the public end-user login.
|
||||
|
||||
### Explicitly out of scope (v1)
|
||||
- **Directory sync (AD / Entra-365 / Google) → end-user list** — its own future spec; v1 is manual
|
||||
list management only.
|
||||
- **Self-service seat purchasing / billing automation.** v1 records/counts seats per tenant; real
|
||||
metering and Syncro/billing wiring is deferred.
|
||||
- **Delegated client-company-admin role** (a client managing its own end-users/grants) — noted as a
|
||||
fast-follow; v1 grants are MSP-admin-managed.
|
||||
- Per-session view-only-vs-control *policy* per end-user (v1 = Control of one's own machine; the
|
||||
`ViewerAccess` split still exists at the token layer).
|
||||
- File transfer, session recording (already out of scope for the broader product v1).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Principal model — `end_user` is a constrained variant of the login plane
|
||||
GuruConnect already has three credential planes that must stay separate (audit-hardened in v2 Phase 1):
|
||||
1. **Login `Claims`** (`jwt.rs:11`) — dashboard users; `role ∈ {admin, operator, viewer}` today.
|
||||
2. **Session-scoped `ViewerClaims`** (`jwt.rs:114`) — 5-min, one session, `purpose=viewer`.
|
||||
3. **Agent `cak_` keys** (`connect_agent_keys`, migration 004) — agents only.
|
||||
|
||||
`end_user` is added as a **fourth role on the login plane** — it issues a normal login JWT
|
||||
(`create_token`, `jwt.rs:161`) carrying `role: "end_user"` and an **empty permission list**. The
|
||||
separation guarantees the v2 audit established are preserved: an `end_user` JWT still cannot be used as
|
||||
a viewer token (lacks `purpose`) nor as an agent key (agent plane rejects user JWTs).
|
||||
|
||||
**Critical authz inversion:** `user_client_access` today documents "no entries = access to all (for
|
||||
admins)" (migration 002, line 25-26). The grant check **must branch on role** — for `end_user`, an
|
||||
empty grant set means **zero** machines, never all. Authz is deny-by-default and grant-scoped; the
|
||||
admin-bypass in `Claims::has_permission` (`jwt.rs:28-33`) must never fire for `end_user`.
|
||||
|
||||
### Agent / Relay-server / Viewer / Dashboard responsibilities
|
||||
- **Agent:** no changes. End-users connect to existing **persistent/unattended** managed agents
|
||||
(consent `not_required` — it is the user's own machine). Optionally honors the SPEC-015 notification
|
||||
overlay if a per-machine policy requires it.
|
||||
- **Relay-server:** no transport change. New end-user auth + portal + connect endpoints; the
|
||||
grant-check + viewer-token mint is the only new server logic on the hot path.
|
||||
- **Viewer:** reuse the React/TS web viewer (`dashboard/src/components/RemoteViewer.tsx`) — the
|
||||
end-user portal embeds the same component with a Control-mode viewer token.
|
||||
- **Dashboard:** new **role-gated end-user portal** route (recommended separate from the technician
|
||||
console — see Open Questions), plus admin screens for end-user + grant management.
|
||||
|
||||
### Database (migrations)
|
||||
- **`user_client_access`** — reused as the grant table; no schema change (already
|
||||
`user_id UUID × client_id UUID → connect_machines(id)`, unique pair, migration 002).
|
||||
- New migration `011_end_user_access.sql`:
|
||||
- Widen `connect_sessions.source` CHECK to `('standalone','gururmm','end_user')` (currently
|
||||
`('standalone','gururmm')`, migration 004 line 99-102).
|
||||
- Optional `users` columns for the external principal: `mfa_secret TEXT NULL`,
|
||||
`must_change_password BOOLEAN NOT NULL DEFAULT false`, and a partial index for fast
|
||||
`role='end_user'` listing per `tenant_id`.
|
||||
- (Seat tracking, if landed in v1: a lightweight per-tenant `end_user` count view or a
|
||||
`tenant_seats` row — kept minimal.)
|
||||
- Grants are tenant-contained: insert path validates `machine.tenant_id == end_user.tenant_id`.
|
||||
|
||||
### API endpoints / WS messages
|
||||
- `POST /api/enduser/auth/login` — public, rate-limited; returns an `end_user` login JWT.
|
||||
- `GET /api/enduser/machines` — lists only the caller's granted, in-tenant machines + presence.
|
||||
- `POST /api/enduser/machines/:id/connect` — grant-checked; creates a `source=end_user` session and
|
||||
mints a Control `ViewerClaims` token (`create_viewer_token`, `jwt.rs:233`) for that session.
|
||||
- Admin: `POST /api/users` (role=end_user), `POST /api/users/:id/grants`,
|
||||
`DELETE /api/users/:id/grants/:machine_id`, `GET /api/users?role=end_user`.
|
||||
- No new protobuf messages — the WS viewer path and `guruconnect.proto` are unchanged.
|
||||
|
||||
## Implementation details
|
||||
- `server/src/auth/jwt.rs` — extend the role vocabulary doc (`Claims.role`, line 16-17); add an
|
||||
`is_end_user()` helper and ensure `has_permission` cannot grant `end_user` anything beyond explicit
|
||||
permissions (the admin short-circuit at line 30 must be guarded).
|
||||
- `server/src/auth/mod.rs` — `AuthenticatedUser` (line 29+) gains role-aware helpers; add an extractor
|
||||
/ middleware that rejects non-`end_user` on the `/api/enduser/*` namespace and rejects `end_user` on
|
||||
every console/admin route (deny-by-default allowlist).
|
||||
- `server/src/api/` — new `enduser` handler module (login, machines, connect); admin user+grant
|
||||
handlers extended for `role=end_user` and `user_client_access` writes.
|
||||
- Grant check (shared fn): `machine_id ∈ user_client_access[user] AND machine.tenant_id == user.tenant_id`;
|
||||
used by both `GET /machines` and `connect`.
|
||||
- Session create stamps `source='end_user'`, `is_managed=true`/unattended, `consent_state='not_required'`,
|
||||
then mints the viewer token via the existing path so relay enforcement is unchanged.
|
||||
- `dashboard/src/` — end-user portal route (role-gated), reusing `RemoteViewer.tsx`; admin grant-matrix
|
||||
UI. White-label (SPEC-014) applies to the portal as the most client-facing surface.
|
||||
- Migration `server/migrations/011_end_user_access.sql` as above (idempotent; applied by
|
||||
`sqlx::migrate!` per the migration standard).
|
||||
|
||||
## Security considerations
|
||||
- **Preserve the plane separation** audited in v2 Phase 1 — `end_user` is login-plane only; it can
|
||||
never satisfy `validate_viewer_token` or the agent `cak_` path.
|
||||
- **Deny-by-default, grant-scoped:** empty `user_client_access` for an `end_user` = no access; the
|
||||
admin-bypass must not apply. Every `/api/enduser/*` call re-checks the grant + tenant server-side
|
||||
(never trust a machine id from the client).
|
||||
- **Tenant containment:** an `end_user` and its grants live in one tenant; cross-tenant grants are
|
||||
rejected at write and re-validated at connect. (Full tenant isolation lands with Phase 4; v1 enforces
|
||||
via explicit `tenant_id` equality checks.)
|
||||
- **External-user trust:** these accounts are public-internet-facing from home. Require
|
||||
rate-limiting + lockout on `/api/enduser/auth/login`; support (recommend require) **TOTP MFA** for
|
||||
`end_user` — schema column included so MFA can be v1 or an immediate fast-follow without a second
|
||||
migration. Argon2id passwords (existing standard).
|
||||
- **Audit:** log each end-user login (success/failure, source IP) and each machine access to
|
||||
`connect_session_events`; the unattended access is to the user's *own* machine but must be fully
|
||||
traceable. Optionally enforce the SPEC-015 overlay per machine policy.
|
||||
- **Threat model:** stolen end-user creds reach only that user's granted machines (blast radius =
|
||||
grant set), never the console, never the agent plane, never another tenant. Disabling the account
|
||||
(`users.enabled=false`) immediately revokes portal + future tokens; the 5-min viewer-token TTL bounds
|
||||
any in-flight session.
|
||||
|
||||
## Testing strategy
|
||||
- **Unit:** grant-check fn (granted / not-granted / cross-tenant / empty-set-for-end_user = deny);
|
||||
`has_permission` never elevates `end_user`; role-namespace middleware (end_user→console = 403,
|
||||
technician→/api/enduser = 403).
|
||||
- **Integration:** end-user login → list shows only granted machines → connect mints a Control viewer
|
||||
token for a `source=end_user` session → relay admits; connect to a non-granted / other-tenant machine
|
||||
→ 403; disabled account → login + token use rejected.
|
||||
- **Manual:** full portal walkthrough from an off-network browser; MFA enrol + challenge; audit rows
|
||||
present for login and access; white-label branding renders on the portal.
|
||||
|
||||
## Effort estimate & dependencies
|
||||
- **Size:** Large (new principal + portal + admin grant UI + auth namespace; transport/agent untouched
|
||||
and the grant table already exists, which holds it below X-Large).
|
||||
- **Depends on (must precede / strongly preferred):**
|
||||
- **Tenancy** (`tenants` + `tenant_id`, migration 004) — needed for containment; full isolation is
|
||||
Phase 4 but v1 uses explicit tenant checks.
|
||||
- **Stable machine identity + persistent enrollment** (SPEC-004 / 008 `machine_uid`, SPEC-016
|
||||
zero-touch `cak_`) — end-users reach persistent managed agents.
|
||||
- **Session-scoped viewer tokens** (v2 Phase 1, landed) — reused directly.
|
||||
- **Pairs with:** SPEC-014 (white-label — the portal is the client-facing surface), SPEC-003/005
|
||||
(machine inventory/list — portal machine rows), SPEC-015 (optional connect-notification overlay).
|
||||
- **Unblocks:** the directory-sync spec (AD/Entra/Google → end-user list), delegated client-admin role,
|
||||
and per-seat billing — all of which build on the `end_user` principal defined here.
|
||||
|
||||
## Open questions
|
||||
1. **Same console vs separate end-user portal?** Recommendation: **separate, role-gated route** —
|
||||
smaller attack surface, no risk of leaking technician controls, cleaner white-label. Confirm before
|
||||
build.
|
||||
2. **End-users in the existing `users` table (role=end_user) vs a dedicated `end_users` table?**
|
||||
Recommendation: reuse `users` (the grant FK `user_client_access.user_id` already points there) with
|
||||
hard role guardrails. Revisit if mixing external + internal principals in one table proves risky.
|
||||
3. **MFA in v1 or immediate fast-follow?** Schema is included either way; decide enforcement timing.
|
||||
4. **Who administers grants in v1** — MSP admin only (assumed), or ship the delegated client-company
|
||||
admin role together? (Affects scope/effort materially.)
|
||||
5. **Seat/licensing enforcement depth for v1** — count-and-display vs hard-cap vs billing-integrated.
|
||||
6. **Default access mode** — Control assumed (own machine); should an admin be able to pin a machine to
|
||||
view-only for a given end-user? (Token layer already supports it.)
|
||||
146
docs/specs/SPEC-018-managed-agent-service-host.md
Normal file
146
docs/specs/SPEC-018-managed-agent-service-host.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# SPEC-018: Managed-Agent SYSTEM Service Host + Session Broker
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P1 (blocks SPEC-016 Phase B end-to-end runtime and SPEC-013)
|
||||
**Requested By:** Mike (2026-06-02)
|
||||
**Estimated Effort:** X-Large
|
||||
|
||||
## Overview
|
||||
|
||||
Convert the managed/persistent GuruConnect agent from a user-context `HKCU\…\Run` autostart into a
|
||||
**Windows SYSTEM service** that runs unattended — at the login screen, with no user logged in, across
|
||||
reboots — and **brokers per-session capture/input worker processes** into the active interactive
|
||||
desktop. A SYSTEM service lives in the isolated **Session 0** and cannot capture or inject the
|
||||
interactive desktop directly, so the service spawns a worker into the target user session (the
|
||||
ScreenConnect architecture).
|
||||
|
||||
This is foundational, not cosmetic. It unblocks three things at once:
|
||||
1. **SPEC-016 Phase B end-to-end runtime** — the per-machine `cak_` store is ACL'd to SYSTEM +
|
||||
Administrators; today the agent runs as the interactive *user* and can't read its own store (the
|
||||
Phase B C1 *fail-fast guard* exists precisely because of this). Running as SYSTEM makes the store
|
||||
readable and removes the guard.
|
||||
2. **True unattended access** — a user-context agent only runs while that user is logged in. Reaching
|
||||
a rebooted server or a machine sitting at the login screen (table-stakes for remote support)
|
||||
requires SYSTEM.
|
||||
3. **SPEC-013 session selection / backstage** — the session-broker primitive built here is the
|
||||
substrate SPEC-013's session-switching UX drives.
|
||||
|
||||
**Success criteria:** the managed agent installs as an auto-start SYSTEM service; it holds the relay
|
||||
connection and performs SPEC-016 enrollment as SYSTEM (reading/writing the SYSTEM-ACL'd `cak_`); it
|
||||
spawns a capture/input worker into the active interactive session and relays frames; the worker is
|
||||
respawned/retargeted on logon/logoff/console-connect; and the Phase B fail-fast guard is removed
|
||||
because the store is now readable in-context.
|
||||
|
||||
## Background — why this is needed (confirmed in code)
|
||||
|
||||
- The persistent agent autostarts via `HKCU\…\Run` (`agent/src/startup.rs:21`, `STARTUP_KEY` = HKCU)
|
||||
→ interactive-user token, not SYSTEM. The only SYSTEM service today is the separate `sas_service`
|
||||
(Secure Attention Sequence helper).
|
||||
- SPEC-016 Phase B (`agent/src/credential_store.rs`) ACLs the `cak_` store to `*S-1-5-18` (SYSTEM) +
|
||||
`*S-1-5-32-544` (Administrators). In the current user context the agent writes but cannot read it
|
||||
back → the Phase B fail-fast guard (`agent/src/main.rs` `resolve_agent_credential`) emits
|
||||
"must run as the GuruConnect SYSTEM service (see SPEC-018)" instead of bricking.
|
||||
- Capture/input live in the agent process (`agent/src/capture/`, `agent/src/input/`); a Session-0
|
||||
SYSTEM service cannot drive these against the interactive desktop without a per-session worker.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
1. **Windows service install/lifecycle** (`agent/src/install.rs` + a new service module): register the
|
||||
managed agent as a **LocalSystem auto-start service** (`CreateServiceW` / a service crate),
|
||||
configure failure/recovery (restart on crash), and **replace the HKCU `Run` autostart for managed
|
||||
mode** (remove the Run entry on service install). Clean uninstall (stop + delete service).
|
||||
2. **Service control loop** (Session 0, SYSTEM): owns the persistent WSS connection to the relay,
|
||||
performs SPEC-016 enrollment as SYSTEM (now able to read/write the `cak_` store), and dispatches
|
||||
session/connect requests to workers. Handles `SERVICE_CONTROL_STOP`/`SHUTDOWN` and
|
||||
`SERVICE_CONTROL_SESSIONCHANGE`.
|
||||
3. **Session broker:** enumerate sessions (`WTSEnumerateSessionsW`), resolve the active interactive
|
||||
session (`WTSGetActiveConsoleSessionId`), obtain its user token (`WTSQueryUserToken` →
|
||||
`DuplicateTokenEx`), and spawn a **per-session capture/input worker** into that session's desktop
|
||||
(`CreateProcessAsUserW`, `winsta0\default`). The worker does DXGI capture + input injection in the
|
||||
user's session; the service relays frames over the existing transport.
|
||||
4. **Service ↔ worker IPC:** a local, ACL'd channel (named pipe `\\.\pipe\guruconnect-<sessionId>`)
|
||||
carrying frames/input/control; pipe ACL restricted to SYSTEM + the target session user.
|
||||
5. **Session-change handling:** on logon/logoff/console-connect/disconnect/lock/unlock, (re)spawn or
|
||||
retarget the worker so the active desktop is always the one being served.
|
||||
6. **Remove the SPEC-016 Phase B fail-fast guard** once the service runs as SYSTEM (the store is
|
||||
readable in-context); keep the SYSTEM+Administrators ACL.
|
||||
|
||||
### Explicitly out of scope (anticipated, separate specs)
|
||||
|
||||
- **Session-selection / backstage UX** — the operator-facing picker and Session-0/secure-desktop
|
||||
command surface are **SPEC-013**; this spec only provides the broker primitive it drives.
|
||||
- **Login-screen / secure-desktop (winlogon) capture** beyond the broker hook — the hard
|
||||
Secure-Desktop case is coordinated with SPEC-013; v1 here targets the active interactive session.
|
||||
- **macOS/Linux service equivalents** — future SPEC-010 (cross-platform agents).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Agent splits into two roles:**
|
||||
- **service-host** (LocalSystem, Session 0): service lifecycle, relay transport, SPEC-016
|
||||
enrollment + `cak_` store, session broker, IPC server.
|
||||
- **session-worker** (per interactive session, user token): DXGI/GDI capture, input injection,
|
||||
IPC client. Spawned by the service via `CreateProcessAsUserW`.
|
||||
- **Service install** (`install.rs`): `CreateServiceW` with `SERVICE_AUTO_START`, `SERVICE_WIN32_OWN_PROCESS`,
|
||||
recovery actions; uninstall stops + deletes. Replaces managed-mode `HKCU Run`.
|
||||
- **Token handoff:** `WTSGetActiveConsoleSessionId` → `WTSQueryUserToken` → `DuplicateTokenEx`
|
||||
(primary token) → `CreateProcessAsUserW` with `lpDesktop = "winsta0\\default"`.
|
||||
- **IPC:** named pipe per session, length-prefixed protobuf (reuse `proto/` message types where
|
||||
sensible), pipe security descriptor granting only SYSTEM + the session user.
|
||||
- **Session events:** the service registers for `SERVICE_CONTROL_SESSIONCHANGE` and reacts to
|
||||
`WTS_CONSOLE_CONNECT`, `WTS_SESSION_LOGON/LOGOFF`, `WTS_SESSION_LOCK/UNLOCK`.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **LocalSystem is maximal privilege** — minimize the service's attack surface; validate every
|
||||
relay-delivered command; never spawn a worker except into a legitimately-enumerated active session.
|
||||
- **IPC pipe must be ACL'd** (SYSTEM + the specific session user only) so a non-admin user can't
|
||||
inject capture/input commands by connecting to the pipe.
|
||||
- **Token hygiene:** close duplicated tokens promptly; don't leak SYSTEM or user primary tokens.
|
||||
- The SPEC-016 `cak_` store (SYSTEM-ACL'd) is now correctly readable; the fail-fast guard is removed
|
||||
but the ACL stays.
|
||||
- **Audit:** service start/stop, enrollment-as-SYSTEM, worker spawn, session attach/retarget — written
|
||||
to the existing event pipeline.
|
||||
|
||||
## Implementation details
|
||||
|
||||
- New service module (e.g. `agent/src/service/{mod.rs, broker.rs, ipc.rs}`); worker entry split out of
|
||||
the current capture path. New `Commands` variants or an internal `--service`/`--session-worker`
|
||||
dispatch in `agent/src/main.rs`.
|
||||
- `install.rs`: service create/recovery/delete; drop the managed-mode HKCU `Run` write.
|
||||
- `windows` crate features: `Win32_System_Services`, `Win32_System_RemoteDesktop`
|
||||
(`WTS*`), `Win32_Security`, `Win32_System_Threading` (`CreateProcessAsUserW`),
|
||||
`Win32_System_Pipes`.
|
||||
- Remove the `resolve_agent_credential` fail-fast guard branch added in SPEC-016 Phase B.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Service:** install → auto-start on boot → stop → uninstall on a clean VM.
|
||||
- **`cak_` end-to-end:** SYSTEM service enrolls (SPEC-016), stores + reads the `cak_`, connects — the
|
||||
integration test SPEC-016 Phase B currently cannot run.
|
||||
- **Session broker:** worker spawns into the active session; capture/input work; survives logoff→logon
|
||||
(respawn) and console-connect (retarget); fast-user-switch retarget.
|
||||
- **Security:** non-admin cannot connect to the IPC pipe; worker runs with the user's token (not
|
||||
SYSTEM) in the user's desktop.
|
||||
|
||||
## Effort estimate & dependencies
|
||||
|
||||
- **Size:** X-Large (service host + worker split + token-handoff + IPC + session-change handling +
|
||||
install/uninstall).
|
||||
- **Depends on:** SPEC-016 (enrollment + `cak_` store); the existing capture/input cores.
|
||||
- **Unblocks:** SPEC-016 Phase B end-to-end runtime (and the parked managed-agent enrollment test on
|
||||
the internal beta machines); **SPEC-013** (session selection builds on this broker).
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Service vs. SYSTEM scheduled task** — a true Windows service (recovery, SCM integration) is the
|
||||
standard, robust choice; recommend service. Lock in planning.
|
||||
2. **One multi-session worker vs. one worker per session** — per-session worker is simpler to reason
|
||||
about and isolates a crash to one session; confirm.
|
||||
3. **IPC transport** — named pipe (recommended) vs. local TCP/loopback; pipe ACLing is the cleaner
|
||||
security story.
|
||||
4. **Login-screen / Secure-Desktop capture** — how much (if any) in this spec vs. deferred to SPEC-013
|
||||
(it needs a worker in the winlogon/secure desktop, a distinct hard problem).
|
||||
5. **Migration** — on upgrade, cleanly transition existing HKCU-`Run` managed installs to the service
|
||||
(remove the Run entry, install the service) without a gap.
|
||||
@@ -317,6 +317,14 @@ message AgentStatus {
|
||||
// negotiation (see StartStream.video_codec). Detected once and cached;
|
||||
// false on non-Windows / no HW encoder / MF unavailable.
|
||||
bool supports_h264 = 11;
|
||||
// Deterministic, recomputable hardware identity (v2 stable-identity Task 1).
|
||||
// Opaque "muid_<hex>" derived by SHA-256 hashing the OS machine GUID
|
||||
// (Windows: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid); non-Windows /
|
||||
// registry-failure falls back to a persisted random UUID. Reported ALONGSIDE
|
||||
// agent_id (which is unchanged). The server-side dedup that consumes this is a
|
||||
// separate task; until then it is informational. Empty only if the agent
|
||||
// predates this field.
|
||||
string machine_uid = 12;
|
||||
}
|
||||
|
||||
// Server commands agent to uninstall itself
|
||||
|
||||
129
reports/2026-05-31-gc-audit.md
Normal file
129
reports/2026-05-31-gc-audit.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# GuruConnect Audit Report — 2026-05-31
|
||||
|
||||
**Auditor:** Claude (claude-opus-4-8[1m])
|
||||
**Passes:** Security & Remote-Session Integrity (`--pass=security` only)
|
||||
**Previous audit:** 2026-05-30 (`reports/2026-05-30-gc-audit.md`)
|
||||
**Scope note:** v2 **Phase-1 EXIT gate** re-audit. Confirms the three relay CRITICALs stay closed and
|
||||
the prior net-new HIGH is fixed, and assesses the net-new SPEC-004 surface (Tasks 2/4/5 — machine_uid
|
||||
dedup, session reaping, operator removal) now committed + deployed. Includes **live** boundary tests
|
||||
against the running production binary, not just a code re-derivation.
|
||||
|
||||
**Code under audit:** working tree at tag **v0.3.0 / e967cce** = the binary deployed to prod
|
||||
172.16.3.30:3002 (deployed this session from 96f9c0a; e967cce adds only the version bump + changelog).
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
| Pass | Total | Critical | High | Medium | Low | Info |
|
||||
|------|-------|----------|------|--------|-----|------|
|
||||
| Security & Session | 4 | 0 | 0 | 0 | 0 | 4 |
|
||||
|
||||
**Phase-1 security EXIT gate: PASS.** The relay/server plane is clean. All three 2026-05-29 CRITICALs
|
||||
remain CLOSED (verified in code AND live against the deployed server). The prior net-new HIGH (agent
|
||||
auto-update TLS bypass) and the prior LOW (chat content logged at INFO) are both remediated. The
|
||||
net-new SPEC-004 surface (operator removal, machine_uid dedup gate, session reaper/supersede) audits
|
||||
clean with the keyed-identity security invariant intact end-to-end. No net-new findings.
|
||||
|
||||
**Requires action:** none.
|
||||
|
||||
---
|
||||
|
||||
## Live functional verification (deployed binary, 172.16.3.30:3002)
|
||||
|
||||
Forged tokens (HS256, real `JWT_SECRET`) exercised the WS auth boundaries directly. Each illegitimate
|
||||
access was REJECTED (4xx, never a 101 upgrade):
|
||||
|
||||
| Check | Result | Proves |
|
||||
|-------|--------|--------|
|
||||
| Login-shape JWT on `/ws/viewer` | **401** | Login token not accepted as a viewer token (`purpose=="viewer"` enforced) — CRITICAL #1 |
|
||||
| Validly-signed viewer token for session AAAA used on session BBBB | **403** | Session binding enforced — a correctly-signed token is refused for the wrong session — CRITICAL #1 |
|
||||
| Login JWT used as agent `api_key` on `/ws/agent` | **401** | Agent plane rejects JWTs (no JWT branch) — CRITICAL #3 |
|
||||
| Wrong-signature token on `/ws/viewer` | **401** | Signature validation holds (control) |
|
||||
|
||||
The session-bind case is the decisive one: a token that WOULD be accepted for its own session is
|
||||
rejected 403 for a different session, proving the binding rather than mere signature validation.
|
||||
|
||||
---
|
||||
|
||||
## The three relay CRITICALs — verdict
|
||||
|
||||
| CRITICAL | Verdict | Enforced at |
|
||||
|----------|---------|-------------|
|
||||
| #1 any-JWT-joins-any-session | **CLOSED** | mint authz `api/sessions.rs` (is_admin \|\| permission); viewer WS `relay/mod.rs:496` `validate_viewer_token` (sig+expiry+`purpose=="viewer"`); session-bind `relay/mod.rs:527-534` (`claim != requested → 403`) |
|
||||
| #2 viewer-WS blacklist | **CLOSED** (TTL-bounded residual unchanged) | `relay/mod.rs:509` `token_blacklist.is_revoked` before upgrade. Residual: logout revokes login JWT not minted viewer tokens (5-min TTL) — same tracked MEDIUM, no regression |
|
||||
| #3 JWT-accepted-as-agent-key | **CLOSED**, fails closed | `relay/mod.rs:417` `validate_agent_api_key` — no JWT branch; only `cak_` (`auth/agent_keys.rs`, SHA-256 vs `connect_agent_keys`, `revoked_at IS NULL`) or deprecated shared key (WARN). Unresolved machine → 503 (`:303`); client `agent_id` overridden by key identity (`:283`) |
|
||||
|
||||
Live results match these code paths exactly.
|
||||
|
||||
---
|
||||
|
||||
## Prior HIGH — FIXED
|
||||
|
||||
**Agent auto-update TLS bypass → MITM-RCE: CLOSED.** `agent/src/update.rs:21` `dev_insecure_tls()` is
|
||||
`cfg!(debug_assertions)` AND env-var gated, so a release build's `cfg!` compiles out and the agent
|
||||
ALWAYS verifies certs. Both `check_for_update` (`:64`) and `download_update` (`:130`) consume it; unit
|
||||
test `test_dev_insecure_tls_release_is_always_false` (`:362`) asserts the release invariant. No
|
||||
`danger_accept_invalid_certs(true)` reachable in production. A signed-manifest defense-in-depth TODO is
|
||||
filed at `install_update` (`:189`) (= tracked task #10, not an exit blocker).
|
||||
|
||||
---
|
||||
|
||||
## Pass 5: Security & Remote-Session Integrity — net-new SPEC-004 surface
|
||||
|
||||
### [INFO] Operator removal API (`server/src/api/removal.rs`) — clean, admin-gated
|
||||
Every removal handler takes the `AdminUser` extractor as its first argument (runs before any DB
|
||||
mutation): `remove_machine` (`:88`), `remove_session` (`:321`), `bulk_remove_machines` (`:471`).
|
||||
`AdminUser` (`auth/mod.rs:141`) validates JWT (signature + expiry + blacklist `:97`) then requires
|
||||
`is_admin()` else 403 (`:146`). Soft-deletes are parameterized + idempotent (`WHERE … AND deleted_at IS
|
||||
NULL`); bulk bounded (MAX_BATCH 500) with per-id UUID validation + isolated failures; audit
|
||||
(`db/events.rs:126`) records actor + target + trusted-proxy IP, best-effort (cannot be suppressed by
|
||||
attacker-controlled input). Removal is admin-role-gated globally (not per-tenant ACL) — same Phase-1
|
||||
posture as viewer-mint, per-tenant narrowing deferred to SPEC-002 Phase 4. Acceptable by context.
|
||||
|
||||
### [INFO] machine_uid dedup security gate — invariant holds
|
||||
Gate at `relay/mod.rs:352`: `effective_machine_uid = if is_keyed_agent { None } else { claimed }`. The
|
||||
suppressed value (not the raw claim) flows to `register_agent` and `upsert_machine`. Keyed (`cak_`)
|
||||
agents take the agent_id-keyed upsert branch and never write/touch a `ON CONFLICT (machine_uid)` row, so
|
||||
a valid key for machine X cannot repoint machine Y via a claimed uid. An un-keyed uid-spoof can only
|
||||
match a uid-bearing row — which the keyed connect path never creates; the only residual is a legacy
|
||||
pre-keying row, and the startup L1 fix (`main.rs:267-288` via `keyed_machine_ids`, fail-closed on query
|
||||
error) ensures keyed machines are never uid-indexed on restore.
|
||||
|
||||
### [INFO] Session reaper + same-machine supersede — clean, TOCTOU closed
|
||||
`reap_stale_persistent` (`:875`) and supersede (`:322`) select under a read lock then re-assert the full
|
||||
predicate under the write lock via `remove_session_if` (`:755`). Predicate requires
|
||||
`!is_online && is_persistent && viewers.is_empty()` (+ TTL / same-uid) — an online, viewer-attached, or
|
||||
support session is never reaped/superseded. Un-keyed uid-spoof blast radius = denial-of-persistence on
|
||||
an offline same-uid session at worst, never a hijack. Lock order matches `register_agent`; predicate is
|
||||
synchronous (no await under lock).
|
||||
|
||||
### [INFO] General posture — confirmed, no regressions
|
||||
Runtime sqlx parameterized everywhere (no `format!`-built SQL); migrations 008/009 idempotent. Frame
|
||||
caps: agent 4 MiB / viewer 64 KiB applied before upgrade. Input throttle retained. `/api/auth/login`
|
||||
rate-limited (`main.rs:397`). `JWT_SECRET` panics if <32 (`main.rs:143`); agent keys SHA-256; Argon2id
|
||||
passwords; no secret/token/code/PII logged. **Chat content no longer logged** (prior LOW fixed —
|
||||
`relay/mod.rs:829,1428` now log length only).
|
||||
|
||||
---
|
||||
|
||||
## Definitive answers
|
||||
|
||||
- **(a) Any non-admin removal path?** NO — all three removal handlers gate on `AdminUser` (JWT+blacklist+`is_admin`→403) before any DB mutation.
|
||||
- **(b) Any uid-spoof that repoints/hijacks another machine's row or session (not just denial)?** NO — keyed identity is authoritative and uid-suppressed across connect → upsert → reattach → startup restore. Worst case for an un-keyed spoof is denial-of-persistence on an offline same-uid session.
|
||||
- **(c) Any auth-plane bypass (agent↔viewer credential crossover)?** NO — viewer plane requires a `purpose=="viewer"` session-bound minted token; agent plane requires a `cak_`/shared key with no JWT branch. Confirmed in code and live.
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
**Phase-1 security EXIT gate: PASS.** Relay/server plane clean; prior HIGH + LOW remediated; SPEC-004
|
||||
surface sound with the keyed-identity invariant intact across the connect path, DB upsert, in-memory
|
||||
reattach, and startup restore. No new CRITICAL/HIGH/MEDIUM/LOW.
|
||||
|
||||
**Tracked, deferred-by-design (not exit blockers):**
|
||||
- Viewer-token logout revocation residual (MEDIUM, TTL-bounded) — `v2-secure-session-core/plan.md`.
|
||||
- Update-binary signature verification (defense-in-depth, task #10) — TODO at `update.rs:189`.
|
||||
|
||||
*Note: only `--pass=security` was run. API-surface, Rust-quality, TypeScript, protocol-integrity,
|
||||
docs-reconciliation, and CI/CD passes were not executed this run.*
|
||||
79
reports/2026-06-03-spec018-review.md
Normal file
79
reports/2026-06-03-spec018-review.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Code Review Notes: GuruConnect SPEC-018 Phase 1 (Managed Agent as LocalSystem Service Host)
|
||||
|
||||
**Review date:** 2026-06-03
|
||||
**Reviewer:** Grok (meticulous code reviewer persona)
|
||||
**Scope:** SPEC-018 Phase 1 changes (merge 11af9df, including review-fix commit a0e0d5f). Diff: `git diff 55b9c97..11af9df` (agent/src/{install.rs,main.rs,service/mod.rs,session/mod.rs}). Full reads of: `agent/src/service/mod.rs`, relevant sections + full run_* functions in `agent/src/main.rs`, `agent/src/session/mod.rs`, `agent/src/credential_store.rs`, `agent/src/install.rs`, `agent/src/startup.rs`, `agent/src/config.rs`, `agent/src/identity.rs`, `agent/src/enroll.rs`, `agent/src/transport/websocket.rs`, `agent/Cargo.toml`, and light greps/server-side enrollment in `server/src/api/enroll.rs` + rate_limit. Also cross-checked CLAUDE.md constraints, Windows service practices, auth paths, shutdown propagation, panic guard, and recent SPEC-016 enrollment/identity code.
|
||||
**Commands used for exploration:** `git log --oneline 55b9c97..11af9df`, `git diff ... --name-only`, file reads (chunked for large), rg/grep for `\.unwrap\(\)`, `\.expect\(`, `unsafe`, `AtomicBool|shutdown|SERVICE_STOP_SENTINEL`, `println!`, `enroll|machine_uid`, etc. No code changes performed.
|
||||
|
||||
## Summary
|
||||
The SPEC-018 Phase 1 implementation is high-quality, security-conscious, and largely correct for its narrow scope (enrollment + relay only; no capture/input/desktop in Session 0). It correctly introduces the LocalSystem service as the single autostart for managed agents (embedded config), wires cooperative shutdown via `Arc<AtomicBool>` (observed both in outer `run_agent` reconnects and inner `SessionManager::run_with_tray` connected loop via `SERVICE_STOP_SENTINEL`), uses `panic::catch_unwind(AssertUnwindSafe)` + downcast around the Tokio runtime to protect the SCM FFI boundary (mapping to `ServiceSpecific(1)` for recovery), implements idempotent SCM install/uninstall with bounded retry for `ERROR_SERVICE_MARKED_FOR_DELETE`, configures crash recovery via `sc failure`, skips tray/autostart HKCU Run for the service path (preventing double-agents and Session-0 nonsense), and documents Phase 2 seams (broker, `CreateProcessAsUserW`, `SESSIONCHANGE`, IPC) extensively in comments. Adherence to CLAUDE.md is excellent: `tracing` (no `println` in core paths), `anyhow`/`thiserror`, async, no hardcoded secrets, strict auth (managed path always requires cak_/enroll material or legacy key; support codes only for interactive `run_agent_mode`; no unauth agent paths), UUIDs/soft-delete conventions (on server), etc. High-privilege SYSTEM surface is handled with care (ACL'd credential store readable only in-context, fail-fast guards retained as safety net, best-effort status reporting).
|
||||
|
||||
However, there are a handful of correctness bugs (especially the non-elevated managed "fallback" that doesn't actually run an agent, and unconditional `agent_id` churn for all embedded-config managed agents on every restart), several `unwrap()`s in hot paths (session loop), a stale `#[allow(dead_code)]`, and some pre-existing but now-in-scope fragile unsafe/transmute patterns in registry code touched by the autostart changes. Service lifecycle, status reporting, and install/retry logic are solid with only minor nits. Phase 2 gaps are clearly and repeatedly called out (no omissions). Light broader review of recent enrollment/identity (SPEC-016) work (which this builds directly on) found related issues in config load priority + agent_id stability and a couple of test-only unwraps. No evidence of race conditions in the AtomicBool propagation or handler blocking. Overall: mergeable with fixes for the flagged bugs; strong engineering but not flawless on edge cases for a SYSTEM RCE surface.
|
||||
|
||||
**Issue counts (by severity):** 2 bug, 1 bug/suggestion, 4 suggestion/nit, 1 nit. Total 8. (No issues found in: auth enforcement for agents, use of tracing/anyhow, panic guard intent + sentinel contract (tests pin it), no-double-agent logic on happy path, credential ACL + C1 verification when running as SYSTEM, Windows service handler non-blocking + recovery config, or Phase 2 documentation.)
|
||||
|
||||
## Issues
|
||||
|
||||
### Issue 1 -- Severity: bug
|
||||
- File: agent/src/main.rs:496 (inside `run_permanent_agent_managed`, called from PermanentAgent detection at 320 and 186)
|
||||
- Description: When `install::install(false)` fails (e.g., the managed binary is launched interactively without Administrator rights), the code does `warn!(... falling back to in-process agent for this run); return run_agent_mode(None);`. The surrounding comment (and similar in 294-296) claims this ensures "the machine is not left with no agent at all." However, for a managed/PermanentAgent binary (which has embedded `enrollment_key` + `site_code` or a prior `cak_`), `run_agent_mode` → `resolve_agent_credential` will *always* fail: (a) if a `cak_` exists, `load_cak` hits `LoadCakError::Io { permission_denied: true }` and the explicit guard returns a hard error ("must run as the GuruConnect SYSTEM service"); (b) if no `cak_` yet, it calls `enroll::run_enrollment` → `credential_store::store_cak`, whose C1 read-back verification (`load_cak` immediately after write) will see the SYSTEM+Admins ACL and return the perm-denied error, causing `store_cak` and thus enrollment to fail. The "fallback" path therefore exits with error (no relay connection) in the exact case it was meant to save. This is a direct correctness gap for the high-privilege managed path.
|
||||
- Suggestion: Remove the fallback for the `has_embedded_config()` / managed case (surface a clear error requiring elevation for first-run managed install, as already stated in `install_managed_service` docs). Or introduce a true degraded fallback (e.g., force a per-user cak_ path or legacy api_key mode) with loud warnings. Update all comments and the "falling back" log. Consider a distinct code path or flag so non-elevated managed binaries don't pretend to provide agent functionality.
|
||||
- Status: open
|
||||
|
||||
### Issue 2 -- Severity: bug
|
||||
- File: agent/src/config.rs:392 (in `Config::load` embedded branch, lines 382-408; also interacts with `detect_run_mode:255`, `has_embedded_config:285`, `read_embedded_config:290`, and PermanentAgent paths in main.rs:166+)
|
||||
- Description: Embedded config (the trigger for `RunMode::PermanentAgent` / managed installs per SPEC-016) never includes an `agent_id`. On every `load()` via the embedded priority-1 path, it does `agent_id: generate_agent_id()` (fresh v4 UUID) unconditionally, then `let _ = config.save()`. Because `load()` always prefers `read_embedded_config().is_ok()` (the exe still carries the magic blob post-install), the toml written by `save()` is *never read back* for agent_id on subsequent launches. Consequence: every service start/restart (and every interactive launch of a managed binary) produces a brand-new `agent_id`. This value is passed to `WebSocketTransport::connect(..., &self.config.agent_id, ...)` (session/mod.rs:121) and used for server-side connection identity/tracking. While `machine_uid` + the `cak_`-bound machine row are the stable dedup keys (per identity.rs and server enroll), churning `agent_id` on every restart is still observable churn in logs, sessions, and any agent_id-keyed state. The comment "Save to file for persistence (so agent_id is preserved)" is actively false for the embedded case that managed agents always take.
|
||||
- Suggestion: In the embedded construction block, first try loading an existing `agent_id` from the on-disk toml (via a helper or the file-load logic) if present and non-empty; only generate if absent. Alternatively, persist the generated agent_id into the embedded blob at installer creation time (or treat the server's minted agent_id as authoritative and stop sending client-generated one for managed agents). This is in the "recent enrollment/identity code" under light broader review.
|
||||
- Status: open
|
||||
|
||||
### Issue 3 -- Severity: bug
|
||||
- File: agent/src/session/mod.rs:386 (recv path), 486 (chat poll), 559 (frame send) — all inside `run_with_tray` after the `if self.transport.is_none() { bail }` guard at 316 and before the connectivity check at 579-587.
|
||||
- Description: Direct `.unwrap()` on `self.transport.as_mut().unwrap()` (and `.as_ref()` in the is_connected check is safe, but the mut ones are not). These execute on *every* message, every outgoing chat, and every video frame while streaming/connected. Although current control flow (transport only set in `connect()`, never set to `None` inside the loop, `release_streaming` doesn't touch it, and the bottom-of-loop check breaks before next iteration) makes them "safe" today, this violates the review rule to flag `unwrap()` in hot paths. A future change (e.g., error path that clears transport, or making run_with_tray re-entrant, or transport becoming Option in more states) turns this into a panic of the entire agent (especially bad under the SYSTEM service). Also present in the stop-sentinel path (which does use `if let Some` for close).
|
||||
- Suggestion: Replace with `.expect("transport must be Some inside run_with_tray after the NotConnected guard and while the outer loop considers us connected")` (or better, hold the transport behind a non-Option after the initial check, or use `if let Some(t) = ... { ... } else { break; }` guards). Audit similar patterns elsewhere in the connected loop.
|
||||
- Status: open
|
||||
|
||||
### Issue 4 -- Severity: suggestion
|
||||
- File: agent/src/main.rs:442-466 (the `catch_unwind` + match in `run_managed_agent_service`); related: service/mod.rs:103-107 (service_main), 112 (run_service body), 167 (the call site), 250-256 (comment explaining finding M)
|
||||
- Description: The required panic guard across the SCM FFI boundary (`extern "system" ffi_service_main -> service_main -> run_service -> run_managed...`) exists and correctly converts unwind to `Err(...)` → `ServiceExitCode::ServiceSpecific(1)` (so SCM recovery restarts instead of UB/abort). The downcast logic is careful (handles &str/String, falls back without re-panic). However, the `catch_unwind(AssertUnwindSafe(|| { rt.block_on(...) }))` only covers the *inner agent runtime*. Panics originating in `run_service()` itself (e.g., in initial `service_control_handler::register`, the two `set_status` calls before/after the agent loop, the `if let Err` logging, or `set_status_with_exit` for Stopped) would still unwind out of `run_service`/`service_main` across the FFI with no guard. The top-level `if let Err(e) = run_service()` in service_main cannot catch a panic.
|
||||
- Suggestion: Wrap more of `run_service()` (or the whole body after the shutdown flag setup) in an outer `catch_unwind`, or add a guard inside `service_main`. This makes the "panic guard across SCM FFI" complete rather than scoped only to the "finding M" agent block_on. (Current scope is probably sufficient in practice, as panics are unlikely in the thin status/reporting code.)
|
||||
- Status: open
|
||||
|
||||
### Issue 5 -- Severity: suggestion
|
||||
- File: agent/src/service/mod.rs:353 (`std::thread::sleep(BACKOFF)` inside `create_service_with_retry` loop), 470-476 (`stop_if_running` 10x 500ms polling loop); also called from install.rs:327/369 and startup paths changed by SPEC-018.
|
||||
- Description: These blocking sleeps are *only* in the synchronous install/uninstall code paths (CLI `guruconnect install` / uninstall, which run under the current user's context before handing off to the service). They are not inside the SCM control `event_handler` closure (which correctly does only `store(true)` + `NoError` / `NotImplemented` with no I/O or waits — satisfying "no blocking in handler"). However, they are still blocking the main thread during (re)install, and the fixed polling is a minor source of non-determinism/race on slow SCM. The retry logic itself (gated on `deleted_existing`, bounded attempts) is a clear improvement over the prior fixed 2s sleep.
|
||||
- Suggestion: Acceptable for install paths. Consider `std::thread::sleep` → a small async sleep if these ever move under tokio, or document "install-time only." The 500ms stop poll is fine for best-effort delete prep.
|
||||
- Status: open
|
||||
|
||||
### Issue 6 -- Severity: bug (pre-existing, but now in scope due to SPEC-018 touching autostart/install/uninstall paths)
|
||||
- File: agent/src/startup.rs:61 (`std::mem::transmute::<HANDLE, HKEY>(hkey)` after `RegOpenKeyExW`), 122-125 (identical in `remove_from_startup`), plus similar raw pointer casts and unsafe registry blocks in install.rs:152+ (protocol handler), 380+ (is_protocol_handler_registered). Also related unsafe in credential_store (DPAPI) and identity (RegGetValueW) which are part of the enrollment/identity surface reviewed.
|
||||
- Description: The transmute from a `HANDLE` (obtained via the windows crate's `RegOpenKeyExW` call site that takes a `*mut _`) to `HKEY` for subsequent `RegSetValueExW`/`RegDeleteValueW` is a fragile, non-idiomatic, and potentially unsound hack. The `windows` 0.58 crate provides properly typed `HKEY` and safe-ish wrappers; mixing `HANDLE` + transmute + `as *mut _` casts risks wrong handle values, leaks, or UB. This code predates SPEC-018 but is executed on the managed install path (which does `remove_from_startup` inside `install_managed_service` and `uninstall_managed_service`).
|
||||
- Suggestion: Refactor to use the crate's typed registry APIs directly (pass `&mut HKEY` to the open call, or use `RegOpenKeyExW` overloads that return the handle type). Add `// SAFETY:` comments with justification for any remaining FFI. This is a correctness + maintainability issue for registry paths now used by the SYSTEM service host.
|
||||
- Status: open
|
||||
|
||||
### Issue 7 -- Severity: nit
|
||||
- File: agent/src/transport/websocket.rs:208 (`pub async fn close(&mut self) -> Result<()> { ... }`)
|
||||
- Description: The method has `#[allow(dead_code)]` (and the call site in the pre-SPEC-018 code may have been conditional). SPEC-018 now calls it unconditionally in the service-stop path: `session/mod.rs:1134` (`if let Some(transport) = self.transport.as_mut() { if let Err(e) = transport.close().await { ... } }`). The allow is now misleading (though harmless, as the fn is live).
|
||||
- Suggestion: Remove `#[allow(dead_code)]`.
|
||||
- Status: open
|
||||
|
||||
### Issue 8 -- Severity: nit (light broader review of recent enrollment/identity code)
|
||||
- File: agent/src/enroll.rs:329,359,360,373,380 (all in `#[cfg(test)]`); also scattered test unwraps in credential_store.rs:379+ (DPAPI tests), encoder tests, etc.
|
||||
- Description: Tests use `.unwrap()` liberally (e.g., `serde_json::to_value(&req).unwrap()`, response parses, `dpapi_protect(...).expect(...)`). This is conventional and acceptable in unit tests (they are expected to succeed on the test host), but still "remaining .unwrap()". No runtime impact. (The production paths in enroll use proper error classification into `AttemptError` and never unwrap on HTTP/JSON.)
|
||||
- Suggestion: For hygiene, change test unwraps to `?` inside `#[test] fn` (which can return Result) or `.expect("test precondition")`. Not a priority.
|
||||
- Status: open
|
||||
|
||||
**No issues found in these areas (explicitly checked):**
|
||||
- Authentication / never accepting unauthenticated agents: Managed service path always forces `config.support_code = None` and goes through `resolve_agent_credential` (which requires cak_, enrollment material, or deprecated api_key; errors hard on nothing usable). Support-code sessions are exclusively the interactive `run_agent_mode` path. Viewer WS requires JWT (out of scope here). `run_dispatcher` / `service-run` cannot be usefully invoked interactively.
|
||||
- Cooperative shutdown + graceful WS close: AtomicBool (SeqCst) set only in handler; polled at top of connected loop (every ~1-100ms) *and* in reconnect backoff (250ms); sentinel error drives clean `transport.close()` (sends WS Close frame) + `return Ok(())` with no reconnect. Tests pin the sentinel contract and "no regression for non-service paths."
|
||||
- Service lifecycle / status / recovery / no blocking in handler: Correct `StartPending` → `Running` (accept STOP|SHUTDOWN) → (on stop) `StopPending` → `Stopped` (with Win32(0) or ServiceSpecific(1)). Handler closure is trivial move + store + match. `configure_recovery` + `sc failure` for restart-on-crash. Idempotent create/delete with retry for SCM delete races. `is_service_installed` is total (never panics).
|
||||
- No double-agents / autostart hygiene (happy path): `run_permanent_agent_managed` early-exits if service installed; managed install removes HKCU Run (best-effort); service path skips `add_to_startup` and tray creation entirely. Non-managed paths untouched.
|
||||
- Credential handling as SYSTEM: `run_managed_agent_service` runs as SYSTEM → `load_cak` (and `store_cak` during enroll) succeed because ACL grants SYSTEM full control. The perm-denied guard + C1 read-back in store_cak are retained exactly as safety net for non-SYSTEM invocations of managed binaries. No secrets logged.
|
||||
- Use of tracing/anyhow/thiserror/async/clippy-adjacent style: New code uses `tracing::{error,info,warn}`, `anyhow::Result` + `?` + `.context`, `thiserror` for `LoadCakError`. No `println!` in agent runtime (only in version-info / fallback message boxes / separate sas_service bin).
|
||||
- Phase 2 gaps called out: Explicitly and repeatedly in `service/mod.rs` module-level docs (lines 24-41), inline comments (e.g., 124, 422 in main, 1085+ in session for the "no capture yet" rationale and seams for broker/SESSIONCHANGE/CreateProcessAsUser/IPC). No silent omissions.
|
||||
- Other CLAUDE.md: Single static binary target, Win7+ (PS/CIM used for identity are present), no redist deps (icacls/sc are inbox), DB conventions on server side (UUID PKs, snake_case, events for audit), etc.
|
||||
|
||||
**Executive verdict:** SPEC-018 Phase 1 is a solid, well-defended implementation of the SYSTEM service host with correct lifecycle, shutdown, and guardrails. The two "bug" issues (non-functional fallback on install failure for managed agents; agent_id churn on every restart) are real correctness problems that should be fixed before wider managed deployment, as they affect reliability of the high-privilege path and identity stability. The unwraps and registry transmute are lower-severity but worth cleaning for a SYSTEM binary. The rest of the work (including the excellent documentation of what's deliberately left for Phase 2) meets or exceeds the project's standards and the requirements in CLAUDE.md. Recommend addressing Issues 1-3 (and 6) prior to release; the rest can be follow-ups. The changes do not introduce new auth holes or unauthenticated agent paths.
|
||||
|
||||
**File written:** D:\GrokTools\guru-connect-review-SPEC018.md
|
||||
|
||||
(End of review notes.)
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "guruconnect-server"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
authors = ["AZ Computer Guru"]
|
||||
description = "GuruConnect Remote Desktop Relay Server"
|
||||
|
||||
41
server/migrations/008_machine_uid.sql
Normal file
41
server/migrations/008_machine_uid.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- Migration: 008_machine_uid.sql
|
||||
-- Purpose: Give connect_machines a deterministic, recomputable hardware identity
|
||||
-- (machine_uid) and dedup registrations on it (SPEC-004 / v2-stable-identity
|
||||
-- Task 2).
|
||||
--
|
||||
-- Today connect_machines is keyed only on `agent_id` — a random UUID the agent
|
||||
-- persists in its config (generate_agent_id()). A lost/missing config produces a
|
||||
-- fresh UUID, and because upsert_machine dedups `ON CONFLICT (agent_id)`, that
|
||||
-- fresh id inserts a NEW row: the duplicate-registration bug (15 rows for 5 real
|
||||
-- hosts in production). The agent (Task 1) now derives a stable `machine_uid` from
|
||||
-- durable hardware/OS identifiers (Windows MachineGuid, hashed; recomputable) and
|
||||
-- reports it on the connect handshake and on AgentStatus. This migration persists
|
||||
-- it and adds the uniqueness needed to dedup the un-keyed / shared-key / config-loss
|
||||
-- fleet on that stable identity.
|
||||
--
|
||||
-- SECURITY (SPEC-004): a client-asserted machine_uid is spoofable and is therefore
|
||||
-- NOT a trust boundary. For per-agent (`cak_`) keyed agents the key's machine
|
||||
-- binding stays authoritative (the server dedups those on their authenticated
|
||||
-- agent_id, never on a claimed uid). machine_uid is a *correctness* aid for the
|
||||
-- un-keyed path only. The UNIQUE index below enforces "one row per machine_uid"
|
||||
-- at the schema level so a concurrent/racing un-keyed insert cannot create a
|
||||
-- duplicate behind the application-level ON CONFLICT.
|
||||
--
|
||||
-- Idempotent: ADD COLUMN IF NOT EXISTS + CREATE UNIQUE INDEX IF NOT EXISTS. The
|
||||
-- column is NULLABLE: legacy rows and agents that do not report a uid (older agents,
|
||||
-- some support-code clients) carry NULL, and the partial index excludes NULLs so any
|
||||
-- number of un-keyed rows may coexist without a uid. Applied on server startup by
|
||||
-- sqlx::migrate!(); never pre-applied via psql. Ordered after 007.
|
||||
-- See .claude/standards/gururmm/sqlx-migrations.md.
|
||||
|
||||
-- 1. machine_uid: deterministic hardware identity reported by the agent. NULLABLE
|
||||
-- so legacy rows and non-reporting agents are unaffected.
|
||||
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS machine_uid TEXT;
|
||||
|
||||
-- 2. Enforce one row per machine_uid, but ONLY for rows that actually have one.
|
||||
-- A partial UNIQUE index (WHERE machine_uid IS NOT NULL) lets unlimited legacy
|
||||
-- NULL rows coexist while making a non-null machine_uid a true dedup key — this
|
||||
-- is what upsert_machine's `ON CONFLICT (machine_uid)` arbiter binds to.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_connect_machines_machine_uid
|
||||
ON connect_machines (machine_uid)
|
||||
WHERE machine_uid IS NOT NULL;
|
||||
43
server/migrations/009_session_machine_soft_delete.sql
Normal file
43
server/migrations/009_session_machine_soft_delete.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- Migration: 009_session_machine_soft_delete.sql
|
||||
-- Purpose: Give connect_machines and connect_sessions a soft-delete marker
|
||||
-- (deleted_at) so an operator can PURGE a stale machine/session —
|
||||
-- removing it from the live console — without destroying its audit
|
||||
-- history (SPEC-004 / v2-stable-identity Task 5).
|
||||
--
|
||||
-- Task 5 is the operator-removal mechanism that finally purges the ~14 live
|
||||
-- ghost connect_machines rows left by the duplicate-registration bug. The
|
||||
-- live-only admin "disconnect" (DELETE /api/sessions/:id) and the legacy hard
|
||||
-- DELETE /api/machines/:agent_id stay as they were; the NEW purge path
|
||||
-- (`?purge=true`) sets deleted_at, drops the in-memory session via
|
||||
-- SessionManager::remove_session, and writes an audit row. A soft delete keeps
|
||||
-- the row (and its connect_session_events history) for the audit trail per the
|
||||
-- project convention "prefer deleted_at over hard deletes" (CLAUDE.md), while
|
||||
-- every list/get query filters `deleted_at IS NULL` so the purged unit
|
||||
-- disappears from the dashboard and the startup reconcile never restores it.
|
||||
--
|
||||
-- Idempotent: ADD COLUMN IF NOT EXISTS. The columns are NULLABLE with no default,
|
||||
-- so adding them is a metadata-only change on Postgres (no table rewrite, no row
|
||||
-- locks held for a scan) — safe to apply online. A NULL deleted_at means "live";
|
||||
-- a non-null timestamp means "removed at that instant". Applied on server startup
|
||||
-- by sqlx::migrate!(); never pre-applied via psql. Ordered after 008.
|
||||
-- See .claude/standards/gururmm/sqlx-migrations.md.
|
||||
|
||||
-- 1. connect_sessions.deleted_at: when set, the session was operator-purged and is
|
||||
-- excluded from every list/get query. NULL = live.
|
||||
ALTER TABLE connect_sessions ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- 2. connect_machines.deleted_at: when set, the machine was operator-purged. This
|
||||
-- is the marker that hides the ghost duplicate rows from /api/machines and the
|
||||
-- startup reconcile. NULL = live.
|
||||
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- 3. Partial indexes so the hot "live rows only" filter (deleted_at IS NULL) used
|
||||
-- by every list query stays an index scan as the soft-deleted set grows. The
|
||||
-- predicate matches the WHERE clause the queries use.
|
||||
CREATE INDEX IF NOT EXISTS idx_connect_machines_live
|
||||
ON connect_machines (hostname)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_connect_sessions_live
|
||||
ON connect_sessions (started_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
159
server/migrations/010_spec016_enrollment.sql
Normal file
159
server/migrations/010_spec016_enrollment.sql
Normal file
@@ -0,0 +1,159 @@
|
||||
-- Migration: 010_spec016_enrollment.sql
|
||||
-- Purpose: SPEC-016 zero-touch per-site agent enrollment — server-side data model.
|
||||
--
|
||||
-- Adds the per-site enrollment-key table, a minimal sites table to anchor it,
|
||||
-- and the machine-side columns the collision-gated self-registration flow needs.
|
||||
--
|
||||
-- Two-tier credential model (SPEC-016 §Security): a low-sensitivity, rotatable,
|
||||
-- per-site ENROLLMENT KEY (the `cek_` secret stored hashed here) gates "may this
|
||||
-- machine register at all", while the high-sensitivity per-machine `cak_`
|
||||
-- operating credential (connect_agent_keys, migration 004) is minted on a
|
||||
-- successful enroll. Compromise of an enrollment key is recovered by rotating one
|
||||
-- site, not a fleet-wide re-key.
|
||||
--
|
||||
-- DEVIATION FROM SPEC (documented): SPEC-016 §DB-migration describes
|
||||
-- `site_enrollment_keys.site_id` as `fk -> sites`, assuming a sites table already
|
||||
-- exists. It does NOT — in the current schema "site" and "company/organization" are
|
||||
-- free-text columns on connect_machines (migration 005), there is no relational
|
||||
-- sites entity. This migration therefore CREATES a minimal `connect_sites` table
|
||||
-- (the relational anchor the enrollment-key FK and the dashboard per-site key
|
||||
-- display both require) keyed by a natural `site_code` and scoped per-tenant. It is
|
||||
-- intentionally minimal (code + display name + tenant); richer site/company
|
||||
-- modeling is left to future work. The free-text connect_machines.site /
|
||||
-- .organization columns are untouched and continue to carry agent-reported labels.
|
||||
--
|
||||
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, ADD COLUMN IF NOT EXISTS. Applied on
|
||||
-- server startup by sqlx::migrate!(); never pre-applied via psql. Ordered after 009.
|
||||
-- See .claude/standards/gururmm/sqlx-migrations.md.
|
||||
|
||||
-- pgcrypto provides gen_random_uuid(); enabled in 001/004 but re-asserted for safety.
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- ============================================================================
|
||||
-- connect_sites — relational anchor for per-site enrollment (see DEVIATION above)
|
||||
-- ============================================================================
|
||||
-- A site is the unit a single signed installer targets. `site_code` is the
|
||||
-- non-secret, operator-facing identifier the installer carries and the agent sends
|
||||
-- at /api/enroll (e.g. "ACME-PHX"). Uniqueness is per-tenant: the same human-chosen
|
||||
-- code may legitimately exist in two tenants. tenant_id mirrors the nullable,
|
||||
-- default-tenant-backfilled tenancy column used on every other scoped table
|
||||
-- (migration 004); db::tenancy::current_tenant_id() resolves it for now.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connect_sites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Operator-facing site identifier the installer carries. Non-secret.
|
||||
site_code TEXT NOT NULL,
|
||||
-- Human-readable site / company display name for the dashboard.
|
||||
display_name TEXT,
|
||||
-- Default company label applied to machines enrolled at this site (mirrors the
|
||||
-- free-text connect_machines.organization the agent otherwise self-reports).
|
||||
company TEXT,
|
||||
-- Tenancy-ready (Phase 4). Backfilled to the default tenant below.
|
||||
tenant_id UUID,
|
||||
-- RESERVED for future per-site enrollment POLICY work (SPEC-016 §out-of-scope):
|
||||
-- default 'auto-approve'; a future 'pending-approval' value will gate new
|
||||
-- enrollments. NOT enforced in Phase A — present so the policy SPEC needs no
|
||||
-- schema change. Do not branch on this column yet.
|
||||
enrollment_policy TEXT DEFAULT 'auto-approve',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Per-tenant uniqueness of the natural site_code so /api/enroll can resolve a site
|
||||
-- deterministically within a tenant while the same code may exist across tenants.
|
||||
-- COALESCE keeps the index usable while tenant_id is still nullable (Phase 1).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_connect_sites_tenant_code
|
||||
ON connect_sites (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000001'::uuid), site_code);
|
||||
|
||||
-- Backfill the sites tenant_id to the default tenant (table is empty on a fresh DB;
|
||||
-- no-op there, but keeps the migration self-consistent).
|
||||
UPDATE connect_sites
|
||||
SET tenant_id = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE tenant_id IS NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- site_enrollment_keys — rotatable, hashed per-site enrollment secret + fingerprint
|
||||
-- ============================================================================
|
||||
-- Stores ONLY the Argon2id hash of the `cek_` secret; the plaintext is shown once
|
||||
-- at issue/rotate and never recoverable. `version` is the monotonic rotation
|
||||
-- counter; `fingerprint` is the non-secret short hex shown as `vN (XXXX)` in the
|
||||
-- dashboard and baked into the installer filename. `active` marks the current key —
|
||||
-- rotation flips the old key to active=false (blocking NEW enrollments from old
|
||||
-- installers) and inserts a new active row; already-enrolled agents holding their
|
||||
-- own `cak_` are unaffected. Multiple inactive (historical) rows may coexist per
|
||||
-- site; at most one active row is intended (enforced by a partial unique index).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS site_enrollment_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL REFERENCES connect_sites(id) ON DELETE CASCADE,
|
||||
-- Argon2id hash of the `cek_` enrollment secret. Never the plaintext.
|
||||
key_hash TEXT NOT NULL,
|
||||
-- Monotonic rotation version (1, 2, 3, ...).
|
||||
version INTEGER NOT NULL,
|
||||
-- Non-secret short hex fingerprint code (the XXXX in `vN (XXXX)`), derived from
|
||||
-- the secret. Stored so the dashboard / GET endpoint can show it without the
|
||||
-- secret.
|
||||
fingerprint TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
-- Set when this key is rotated out (active flipped to false).
|
||||
rotated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Lookup index for the enroll hot path: resolve the active key for a site.
|
||||
CREATE INDEX IF NOT EXISTS idx_site_enrollment_keys_site_active
|
||||
ON site_enrollment_keys (site_id, active);
|
||||
|
||||
-- At most one ACTIVE enrollment key per site (the "current" installer key).
|
||||
-- Partial unique index so any number of inactive historical rows may coexist.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_site_enrollment_keys_one_active
|
||||
ON site_enrollment_keys (site_id)
|
||||
WHERE active;
|
||||
|
||||
-- ============================================================================
|
||||
-- connect_machines — site binding + enrollment-state collision gate
|
||||
-- ============================================================================
|
||||
-- machine_uid already exists (migration 008) with a partial UNIQUE index on
|
||||
-- (machine_uid) WHERE machine_uid IS NOT NULL. SPEC-016 §item-1 / resolved-decision #4
|
||||
-- call for the dedup key to be PER-TENANT — (tenant_id, machine_uid) — so the same
|
||||
-- hardware legitimately present in two tenants stays two rows. tenant_id is the
|
||||
-- scoping column that exists on connect_machines (migration 004); machines have no
|
||||
-- direct site_id today, so site is tracked separately (site_id below) and tenancy is
|
||||
-- the uniqueness scope, exactly as the spec states.
|
||||
--
|
||||
-- CRITICAL CONSTRAINT (why we ADD rather than REPLACE the 008 index here):
|
||||
-- db::machines::upsert_machine (the live connect-path upsert) uses
|
||||
-- `ON CONFLICT (machine_uid) WHERE machine_uid IS NOT NULL` as its conflict arbiter.
|
||||
-- Postgres matches that arbiter to the EXACT index from migration 008. Dropping that
|
||||
-- index would make the live upsert fail to find an arbiter and error at runtime —
|
||||
-- breaking every un-keyed agent reconnect. So migration 008's global index is LEFT
|
||||
-- IN PLACE (the connect path keeps working unchanged) and the per-tenant index is
|
||||
-- added ALONGSIDE it. In single-tenant Phase 1 the two are equivalent (every row's
|
||||
-- tenant_id is the default tenant), so the per-tenant index adds the SPEC-016 dedup
|
||||
-- semantics without a redundant-uniqueness conflict: a (tenant, uid) pair that is
|
||||
-- unique is also globally unique today. When multi-tenancy activates AND
|
||||
-- upsert_machine's ON CONFLICT is updated to name (tenant_id, machine_uid), a future
|
||||
-- migration drops the global 008 index. Documented as deferred; do not drop it now.
|
||||
|
||||
-- Optional FK to the site a machine enrolled under (NULL for legacy / support-code
|
||||
-- machines that never enrolled through /api/enroll). A site change on re-enroll is
|
||||
-- the "site move" SPEC-016 audits.
|
||||
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS site_id UUID REFERENCES connect_sites(id) ON DELETE SET NULL;
|
||||
|
||||
-- enrollment_state: the collision gate (SPEC-016 §item-1/6). 'active' = live and
|
||||
-- controllable (auto-approve posture); 'pending' = a machine_uid collision was
|
||||
-- detected at enroll and an operator must confirm in the dashboard before the
|
||||
-- endpoint may be controlled. Default 'active' so every legacy/connect-path row is
|
||||
-- unaffected.
|
||||
ALTER TABLE connect_machines
|
||||
ADD COLUMN IF NOT EXISTS enrollment_state TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (enrollment_state IN ('active', 'pending'));
|
||||
|
||||
-- Per-tenant machine_uid uniqueness (SPEC-016). Added ALONGSIDE migration 008's
|
||||
-- global (machine_uid) index (see CRITICAL CONSTRAINT above — the connect-path
|
||||
-- upsert's ON CONFLICT arbiter binds to the 008 index, which must survive). COALESCE
|
||||
-- folds a NULL tenant_id to the default tenant so the index is well-defined while
|
||||
-- tenancy is single-tenant (Phase 1); the WHERE clause excludes NULL machine_uid so
|
||||
-- legacy un-keyed rows coexist freely.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_connect_machines_tenant_machine_uid
|
||||
ON connect_machines (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000001'::uuid), machine_uid)
|
||||
WHERE machine_uid IS NOT NULL;
|
||||
1008
server/src/api/enroll.rs
Normal file
1008
server/src/api/enroll.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,12 @@ pub mod auth;
|
||||
pub mod auth_logout;
|
||||
pub mod changelog;
|
||||
pub mod downloads;
|
||||
pub mod enroll;
|
||||
pub mod machine_keys;
|
||||
pub mod releases;
|
||||
pub mod removal;
|
||||
pub mod sessions;
|
||||
pub mod sites;
|
||||
pub mod users;
|
||||
|
||||
use axum::{
|
||||
@@ -172,7 +175,7 @@ impl From<db::sessions::DbSession> for SessionRecord {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EventRecord {
|
||||
pub id: i64,
|
||||
pub session_id: String,
|
||||
pub session_id: Option<String>,
|
||||
pub event_type: String,
|
||||
pub timestamp: String,
|
||||
pub viewer_id: Option<String>,
|
||||
@@ -185,7 +188,7 @@ impl From<db::events::SessionEvent> for EventRecord {
|
||||
fn from(e: db::events::SessionEvent) -> Self {
|
||||
Self {
|
||||
id: e.id,
|
||||
session_id: e.session_id.to_string(),
|
||||
session_id: e.session_id.map(|id| id.to_string()),
|
||||
event_type: e.event_type,
|
||||
timestamp: e.timestamp.to_rfc3339(),
|
||||
viewer_id: e.viewer_id,
|
||||
@@ -208,12 +211,17 @@ pub struct MachineHistory {
|
||||
/// Query parameters for machine deletion
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeleteMachineParams {
|
||||
/// If true, send uninstall command to agent (if online)
|
||||
/// If true, send uninstall command to agent (if online). Legacy (non-purge) path.
|
||||
#[serde(default)]
|
||||
pub uninstall: bool,
|
||||
/// If true, include history in response before deletion
|
||||
/// If true, include history in response before deletion. Legacy (non-purge) path.
|
||||
#[serde(default)]
|
||||
pub export: bool,
|
||||
/// If true, take the Task-5 SOFT-DELETE path: set `connect_machines.deleted_at`,
|
||||
/// drop the live in-memory session, and audit the removal — instead of the legacy
|
||||
/// hard delete. This is the operator-removal mechanism that purges ghost rows.
|
||||
#[serde(default)]
|
||||
pub purge: bool,
|
||||
}
|
||||
|
||||
/// Response for machine deletion
|
||||
|
||||
612
server/src/api/removal.rs
Normal file
612
server/src/api/removal.rs
Normal file
@@ -0,0 +1,612 @@
|
||||
//! Operator-removal endpoints (admin plane) — SPEC-004 / v2-stable-identity Task 5.
|
||||
//!
|
||||
//! Lets an administrator PURGE stale machines and sessions: soft-delete the DB row
|
||||
//! (`deleted_at = NOW()`, retaining the audit history), drop the live in-memory
|
||||
//! session via [`SessionManager::remove_session`], and write an audit row to
|
||||
//! `connect_session_events`. This is the mechanism that removes the ghost duplicate
|
||||
//! `connect_machines` rows left by the historical duplicate-registration bug.
|
||||
//!
|
||||
//! Removal semantics vs. the pre-existing live-only controls:
|
||||
//! - `DELETE /api/sessions/:id` (live-only "disconnect") sends a `Disconnect`
|
||||
//! message to the agent. It does NOT touch the DB. Left unchanged.
|
||||
//! - `DELETE /api/machines/:agent_id` WITHOUT `?purge=true` keeps the legacy
|
||||
//! behavior (optional `?uninstall=true` admin command, optional `?export=true`
|
||||
//! history dump, then a HARD delete). Left unchanged.
|
||||
//! - `?purge=true` on either route is the NEW soft-delete path defined here.
|
||||
//!
|
||||
//! Auth: dashboard JWT + admin role (the [`AdminUser`] extractor). A non-admin gets
|
||||
//! 403. Every purge is audited with the acting admin and the target id.
|
||||
//!
|
||||
//! Errors use the shared [`ApiError`] envelope; raw `sqlx` strings are never sent to
|
||||
//! the client (they are logged server-side via `tracing`).
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AdminUser;
|
||||
use crate::db;
|
||||
use crate::db::events::EventTypes;
|
||||
use crate::proto;
|
||||
use crate::AppState;
|
||||
|
||||
use super::machine_keys::ApiError;
|
||||
use super::{DeleteMachineParams, DeleteMachineResponse, MachineHistory};
|
||||
|
||||
type ApiResult<T> = Result<T, (StatusCode, Json<ApiError>)>;
|
||||
|
||||
/// Build the shared error envelope (mirrors `machine_keys`/`sessions`).
|
||||
fn err(status: StatusCode, code: &str, detail: &str) -> (StatusCode, Json<ApiError>) {
|
||||
(
|
||||
status,
|
||||
Json(ApiError {
|
||||
detail: detail.to_string(),
|
||||
error_code: code.to_string(),
|
||||
status_code: status.as_u16(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve the live `Database` handle or 503 (DB is optional in this server).
|
||||
fn require_db(state: &AppState) -> ApiResult<&db::Database> {
|
||||
state.db.as_ref().ok_or_else(|| {
|
||||
err(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"DATABASE_UNAVAILABLE",
|
||||
"Database not available",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Real client IP for the audit row (trusted-proxy aware, matching the rest of the
|
||||
/// server). Best-effort: a missing/garbled forwarded header just yields the peer.
|
||||
fn audit_ip(state: &AppState, addr: SocketAddr, headers: &HeaderMap) -> IpAddr {
|
||||
crate::utils::ip_extract::client_ip(&addr, headers, &state.trusted_proxies)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Single-machine removal — DELETE /api/machines/:agent_id[?purge=true]
|
||||
// ============================================================================
|
||||
|
||||
/// DELETE /api/machines/:agent_id
|
||||
///
|
||||
/// Admin-only. Two modes, selected by the `purge` query flag:
|
||||
/// - `?purge=true` → SOFT-DELETE (Task 5): set `connect_machines.deleted_at`,
|
||||
/// drop the machine's live in-memory session via `remove_session`, and write a
|
||||
/// `machine_removed` audit event. The row + its history are retained; the
|
||||
/// machine disappears from `/api/machines` and is not restored on startup.
|
||||
/// - otherwise → the legacy HARD delete (optional uninstall command + history
|
||||
/// export, then a cascading `DELETE`), preserved verbatim for compatibility.
|
||||
pub async fn remove_machine(
|
||||
AdminUser(admin): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Path(agent_id): Path<String>,
|
||||
Query(params): Query<DeleteMachineParams>,
|
||||
) -> ApiResult<Json<DeleteMachineResponse>> {
|
||||
// `agent_id` is a UUID minted by the agent (`generate_agent_id()`); validate the
|
||||
// shape before touching the DB so a malformed path is a clean 400, not a lookup.
|
||||
if Uuid::parse_str(&agent_id).is_err() {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"INVALID_AGENT_ID",
|
||||
"agent_id must be a valid UUID",
|
||||
));
|
||||
}
|
||||
|
||||
let db = require_db(&state)?;
|
||||
|
||||
// The machine must currently exist and be live (the get filters soft-deleted),
|
||||
// so a re-purge or an unknown id is a clean 404 rather than a silent success.
|
||||
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error loading machine for removal: {}", e);
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Internal server error",
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
err(
|
||||
StatusCode::NOT_FOUND,
|
||||
"MACHINE_NOT_FOUND",
|
||||
"Machine not found",
|
||||
)
|
||||
})?;
|
||||
|
||||
if params.purge {
|
||||
return purge_machine(
|
||||
&state,
|
||||
db,
|
||||
&admin,
|
||||
machine,
|
||||
audit_ip(&state, addr, &headers),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// -------- Legacy hard-delete path (unchanged behavior) --------
|
||||
let history = if params.export {
|
||||
let sessions = db::sessions::get_sessions_for_machine(db.pool(), machine.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error exporting sessions: {}", e);
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Internal server error",
|
||||
)
|
||||
})?;
|
||||
let events = db::events::get_events_for_machine(db.pool(), machine.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error exporting events: {}", e);
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Internal server error",
|
||||
)
|
||||
})?;
|
||||
Some(MachineHistory {
|
||||
machine: super::MachineInfo::from(machine.clone()),
|
||||
sessions: sessions
|
||||
.into_iter()
|
||||
.map(super::SessionRecord::from)
|
||||
.collect(),
|
||||
events: events.into_iter().map(super::EventRecord::from).collect(),
|
||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut uninstall_sent = false;
|
||||
if params.uninstall {
|
||||
if let Some(session) = state.sessions.get_session_by_agent(&agent_id).await {
|
||||
if session.is_online {
|
||||
uninstall_sent = state
|
||||
.sessions
|
||||
.send_admin_command(
|
||||
session.id,
|
||||
proto::AdminCommandType::AdminUninstall,
|
||||
"Deleted by administrator",
|
||||
)
|
||||
.await;
|
||||
if uninstall_sent {
|
||||
tracing::info!("Sent uninstall command to agent {}", agent_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.sessions.remove_agent(&agent_id).await;
|
||||
|
||||
db::machines::delete_machine(db.pool(), &agent_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error hard-deleting machine: {}", e);
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Failed to delete machine",
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"Admin {} hard-deleted machine {} (uninstall_sent: {})",
|
||||
admin.username,
|
||||
agent_id,
|
||||
uninstall_sent
|
||||
);
|
||||
|
||||
Ok(Json(DeleteMachineResponse {
|
||||
success: true,
|
||||
message: format!("Machine {} deleted", machine.hostname),
|
||||
uninstall_sent,
|
||||
history,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Soft-delete + in-memory removal + audit for one machine (the `?purge=true` path).
|
||||
async fn purge_machine(
|
||||
state: &AppState,
|
||||
db: &db::Database,
|
||||
admin: &crate::auth::AuthenticatedUser,
|
||||
machine: db::machines::Machine,
|
||||
ip: IpAddr,
|
||||
) -> ApiResult<Json<DeleteMachineResponse>> {
|
||||
// 1. Soft-delete the DB row. 0 rows means it was purged between the load and
|
||||
// here (a race) — treat as already-removed success, idempotent.
|
||||
let affected = db::machines::soft_delete_machine(db.pool(), &machine.agent_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error soft-deleting machine: {}", e);
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Failed to remove machine",
|
||||
)
|
||||
})?;
|
||||
|
||||
// 2. Drop the live in-memory session (agents + machine_uids indexes) so the
|
||||
// purged machine cannot keep streaming or be reattached. `remove_agent`
|
||||
// resolves agent_id -> session_id and routes through `remove_session`.
|
||||
let removed_session = state.sessions.remove_agent(&machine.agent_id).await;
|
||||
|
||||
// 3. Audit. Anchor to the machine's last session when known so the event links
|
||||
// to that machine's history; the detail JSON independently carries the ids.
|
||||
let details = serde_json::json!({
|
||||
"target_kind": "machine",
|
||||
"agent_id": machine.agent_id,
|
||||
"machine_id": machine.id,
|
||||
"hostname": machine.hostname,
|
||||
"machine_uid": machine.machine_uid,
|
||||
"purge": true,
|
||||
"soft_deleted": affected > 0,
|
||||
"in_memory_session_removed": removed_session.map(|s| s.to_string()),
|
||||
});
|
||||
if let Err(e) = db::events::log_admin_removal(
|
||||
db.pool(),
|
||||
machine.last_session_id,
|
||||
EventTypes::MACHINE_REMOVED,
|
||||
&admin.user_id,
|
||||
&admin.username,
|
||||
details,
|
||||
Some(ip),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Audit is best-effort: the purge already happened. Log loudly; do not fail
|
||||
// the request (mirrors how the relay treats audit-write failures).
|
||||
tracing::error!("Failed to write machine_removed audit event: {}", e);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Admin {} purged machine {} (agent_id {}, soft_deleted={}, session_removed={})",
|
||||
admin.username,
|
||||
machine.hostname,
|
||||
machine.agent_id,
|
||||
affected > 0,
|
||||
removed_session.is_some()
|
||||
);
|
||||
|
||||
Ok(Json(DeleteMachineResponse {
|
||||
success: true,
|
||||
message: format!("Machine {} removed", machine.hostname),
|
||||
uninstall_sent: false,
|
||||
history: None,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Single-session removal — DELETE /api/sessions/:id[?purge=true]
|
||||
// ============================================================================
|
||||
|
||||
/// Query flag for the session removal route.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PurgeParams {
|
||||
/// When true, soft-delete the session row + remove it in-memory + audit. When
|
||||
/// false/absent, fall back to the live-only disconnect (unchanged behavior).
|
||||
#[serde(default)]
|
||||
pub purge: bool,
|
||||
}
|
||||
|
||||
/// Result body for a session removal.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RemoveSessionResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
/// Whether the row was soft-deleted in the DB (false if there was no DB row,
|
||||
/// e.g. a live support session that never persisted, or it was already purged).
|
||||
pub soft_deleted: bool,
|
||||
}
|
||||
|
||||
/// DELETE /api/sessions/:id?purge=true
|
||||
///
|
||||
/// Admin-only soft-delete of a session: set `connect_sessions.deleted_at`, drop the
|
||||
/// live in-memory session via `remove_session`, and write a `session_removed` audit
|
||||
/// event. WITHOUT `?purge=true` this delegates to the live-only disconnect so the
|
||||
/// pre-existing semantics are preserved.
|
||||
pub async fn remove_session(
|
||||
AdminUser(admin): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<PurgeParams>,
|
||||
) -> ApiResult<Json<RemoveSessionResponse>> {
|
||||
let session_id = Uuid::parse_str(&id).map_err(|_| {
|
||||
err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"INVALID_SESSION_ID",
|
||||
"session id must be a valid UUID",
|
||||
)
|
||||
})?;
|
||||
|
||||
if !params.purge {
|
||||
// Live-only disconnect (unchanged): send a Disconnect to the agent. 404 if
|
||||
// the session is not live in memory.
|
||||
let disconnected = state
|
||||
.sessions
|
||||
.disconnect_session(session_id, "Disconnected by administrator")
|
||||
.await;
|
||||
if disconnected {
|
||||
tracing::info!(
|
||||
"Admin {} disconnected session {} (live-only)",
|
||||
admin.username,
|
||||
session_id
|
||||
);
|
||||
return Ok(Json(RemoveSessionResponse {
|
||||
success: true,
|
||||
message: "Session disconnected".to_string(),
|
||||
soft_deleted: false,
|
||||
}));
|
||||
}
|
||||
return Err(err(
|
||||
StatusCode::NOT_FOUND,
|
||||
"SESSION_NOT_FOUND",
|
||||
"Session not found",
|
||||
));
|
||||
}
|
||||
|
||||
let db = require_db(&state)?;
|
||||
|
||||
// A session may be live in memory, persisted in the DB, or both. Soft-delete the
|
||||
// DB row (no-op if there is none) and drop any in-memory session. The purge is a
|
||||
// 404 only when NEITHER exists.
|
||||
let in_memory = state.sessions.get_session(session_id).await.is_some();
|
||||
let soft_deleted = db::sessions::soft_delete_session(db.pool(), session_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error soft-deleting session: {}", e);
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Failed to remove session",
|
||||
)
|
||||
})?
|
||||
> 0;
|
||||
|
||||
if !in_memory && !soft_deleted {
|
||||
return Err(err(
|
||||
StatusCode::NOT_FOUND,
|
||||
"SESSION_NOT_FOUND",
|
||||
"Session not found",
|
||||
));
|
||||
}
|
||||
|
||||
state.sessions.remove_session(session_id).await;
|
||||
|
||||
let details = serde_json::json!({
|
||||
"target_kind": "session",
|
||||
"session_id": session_id,
|
||||
"purge": true,
|
||||
"soft_deleted": soft_deleted,
|
||||
"was_live": in_memory,
|
||||
});
|
||||
if let Err(e) = db::events::log_admin_removal(
|
||||
db.pool(),
|
||||
Some(session_id),
|
||||
EventTypes::SESSION_REMOVED,
|
||||
&admin.user_id,
|
||||
&admin.username,
|
||||
details,
|
||||
Some(audit_ip(&state, addr, &headers)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to write session_removed audit event: {}", e);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Admin {} purged session {} (soft_deleted={}, was_live={})",
|
||||
admin.username,
|
||||
session_id,
|
||||
soft_deleted,
|
||||
in_memory
|
||||
);
|
||||
|
||||
Ok(Json(RemoveSessionResponse {
|
||||
success: true,
|
||||
message: "Session removed".to_string(),
|
||||
soft_deleted,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bulk machine removal — POST /api/machines/bulk-remove
|
||||
// ============================================================================
|
||||
|
||||
/// Request body for the bulk machine removal.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BulkRemoveRequest {
|
||||
/// `agent_id`s to remove. Each is validated as a UUID; unknown/invalid ids are
|
||||
/// reported per-id rather than failing the whole batch.
|
||||
pub ids: Vec<String>,
|
||||
/// When true, soft-delete + in-memory removal + audit (Task 5). When false, the
|
||||
/// legacy hard delete is applied to each id. Defaults to true: the bulk endpoint
|
||||
/// exists for the purge workflow.
|
||||
#[serde(default = "default_true")]
|
||||
pub purge: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Per-id outcome in a bulk response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BulkRemoveItem {
|
||||
pub agent_id: String,
|
||||
/// `removed` | `not_found` | `invalid` | `error`.
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Summary body for a bulk removal.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BulkRemoveResponse {
|
||||
pub requested: usize,
|
||||
pub removed: usize,
|
||||
pub results: Vec<BulkRemoveItem>,
|
||||
}
|
||||
|
||||
/// POST /api/machines/bulk-remove
|
||||
///
|
||||
/// Admin-only. Removes many machines in one call, auditing ONE `machine_removed`
|
||||
/// event per actually-removed id (matching the single-machine granularity). Invalid
|
||||
/// or unknown ids are reported in the per-id results and never abort the batch. With
|
||||
/// `purge=true` (default) each removal is the Task-5 soft-delete; otherwise the
|
||||
/// legacy hard delete.
|
||||
pub async fn bulk_remove_machines(
|
||||
AdminUser(admin): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<BulkRemoveRequest>,
|
||||
) -> ApiResult<Json<BulkRemoveResponse>> {
|
||||
if body.ids.is_empty() {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"EMPTY_ID_LIST",
|
||||
"ids must contain at least one agent_id",
|
||||
));
|
||||
}
|
||||
// Guard against an unbounded batch.
|
||||
const MAX_BATCH: usize = 500;
|
||||
if body.ids.len() > MAX_BATCH {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"BATCH_TOO_LARGE",
|
||||
"ids exceeds the maximum batch size of 500",
|
||||
));
|
||||
}
|
||||
|
||||
let db = require_db(&state)?;
|
||||
let ip = audit_ip(&state, addr, &headers);
|
||||
|
||||
let requested = body.ids.len();
|
||||
let mut results = Vec::with_capacity(requested);
|
||||
let mut removed = 0usize;
|
||||
|
||||
for agent_id in body.ids {
|
||||
// Validate shape first.
|
||||
if Uuid::parse_str(&agent_id).is_err() {
|
||||
results.push(BulkRemoveItem {
|
||||
agent_id,
|
||||
status: "invalid".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Must exist + be live.
|
||||
let machine = match db::machines::get_machine_by_agent_id(db.pool(), &agent_id).await {
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => {
|
||||
results.push(BulkRemoveItem {
|
||||
agent_id,
|
||||
status: "not_found".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("DB error loading machine {} in bulk: {}", agent_id, e);
|
||||
results.push(BulkRemoveItem {
|
||||
agent_id,
|
||||
status: "error".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if body.purge {
|
||||
match db::machines::soft_delete_machine(db.pool(), &agent_id).await {
|
||||
Ok(affected) => {
|
||||
let removed_session = state.sessions.remove_agent(&agent_id).await;
|
||||
let details = serde_json::json!({
|
||||
"target_kind": "machine",
|
||||
"agent_id": machine.agent_id,
|
||||
"machine_id": machine.id,
|
||||
"hostname": machine.hostname,
|
||||
"machine_uid": machine.machine_uid,
|
||||
"purge": true,
|
||||
"bulk": true,
|
||||
"soft_deleted": affected > 0,
|
||||
"in_memory_session_removed": removed_session.map(|s| s.to_string()),
|
||||
});
|
||||
if let Err(e) = db::events::log_admin_removal(
|
||||
db.pool(),
|
||||
machine.last_session_id,
|
||||
EventTypes::MACHINE_REMOVED,
|
||||
&admin.user_id,
|
||||
&admin.username,
|
||||
details,
|
||||
Some(ip),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to write machine_removed audit event (bulk) for {}: {}",
|
||||
agent_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
removed += 1;
|
||||
results.push(BulkRemoveItem {
|
||||
agent_id,
|
||||
status: "removed".to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("DB error soft-deleting machine {} in bulk: {}", agent_id, e);
|
||||
results.push(BulkRemoveItem {
|
||||
agent_id,
|
||||
status: "error".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy hard-delete per id.
|
||||
state.sessions.remove_agent(&agent_id).await;
|
||||
match db::machines::delete_machine(db.pool(), &agent_id).await {
|
||||
Ok(()) => {
|
||||
removed += 1;
|
||||
results.push(BulkRemoveItem {
|
||||
agent_id,
|
||||
status: "removed".to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("DB error hard-deleting machine {} in bulk: {}", agent_id, e);
|
||||
results.push(BulkRemoveItem {
|
||||
agent_id,
|
||||
status: "error".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Admin {} bulk-removed {}/{} machine(s) (purge={})",
|
||||
admin.username,
|
||||
removed,
|
||||
requested,
|
||||
body.purge
|
||||
);
|
||||
|
||||
Ok(Json(BulkRemoveResponse {
|
||||
requested,
|
||||
removed,
|
||||
results,
|
||||
}))
|
||||
}
|
||||
217
server/src/api/sites.rs
Normal file
217
server/src/api/sites.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
//! Site enrollment-key administration (SPEC-016, admin plane).
|
||||
//!
|
||||
//! Admin (dashboard JWT + admin role) endpoints for the per-site enrollment key
|
||||
//! the dashboard surfaces and rotates:
|
||||
//!
|
||||
//! - `POST /api/sites/:id/enrollment-key/rotate` — regenerate the `cek_` secret,
|
||||
//! bump the monotonic version, derive a new fingerprint, deactivate the prior
|
||||
//! active key, and return the plaintext + fingerprint ONCE. Old installers can no
|
||||
//! longer enroll NEW machines after this; already-enrolled agents (holding their
|
||||
//! own `cak_`) are unaffected (SPEC-016 success-criterion #3). Doubles as
|
||||
//! first-issue when a site has no key yet.
|
||||
//! - `GET /api/sites/:id/enrollment-key` — read the CURRENT non-secret fingerprint
|
||||
//! + version (never the secret). 404 if the site has no active key yet.
|
||||
//!
|
||||
//! Auth mirrors `api::machine_keys`: the [`crate::auth::AdminUser`] extractor gates
|
||||
//! both routes, and they are mounted behind the JWT `auth_layer`.
|
||||
//!
|
||||
//! SECURITY: the plaintext `cek_` is returned exactly once (rotate response),
|
||||
//! never persisted in plaintext and never logged. Read responses expose only the
|
||||
//! version + fingerprint.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{enrollment_keys, AdminUser};
|
||||
use crate::db;
|
||||
use crate::AppState;
|
||||
|
||||
/// Standard error envelope (matches `api::machine_keys::ApiError`).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiError {
|
||||
pub detail: String,
|
||||
pub error_code: String,
|
||||
pub status_code: u16,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn new(status: StatusCode, code: &str, detail: &str) -> (StatusCode, Json<ApiError>) {
|
||||
(
|
||||
status,
|
||||
Json(ApiError {
|
||||
detail: detail.to_string(),
|
||||
error_code: code.to_string(),
|
||||
status_code: status.as_u16(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type ApiResult<T> = Result<T, (StatusCode, Json<ApiError>)>;
|
||||
|
||||
/// Response for a freshly rotated/issued enrollment key. `key` is present ONLY
|
||||
/// here, once.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RotatedEnrollmentKey {
|
||||
pub site_id: Uuid,
|
||||
/// The plaintext `cek_` enrollment key. Shown exactly once — bake it into the
|
||||
/// site installer now; the server keeps only its hash.
|
||||
pub key: String,
|
||||
/// Monotonic rotation version.
|
||||
pub version: i32,
|
||||
/// The non-secret short hex code (the `XXXX` in `vN (XXXX)`).
|
||||
pub fingerprint: String,
|
||||
/// Fully rendered operator-facing fingerprint, e.g. `v3 (7F2A)`.
|
||||
pub fingerprint_label: String,
|
||||
}
|
||||
|
||||
/// Non-secret current-key view for the GET endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EnrollmentKeyView {
|
||||
pub site_id: Uuid,
|
||||
pub version: i32,
|
||||
pub fingerprint: String,
|
||||
pub fingerprint_label: String,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
fn require_db(state: &AppState) -> ApiResult<&db::Database> {
|
||||
state.db.as_ref().ok_or_else(|| {
|
||||
ApiError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"DATABASE_UNAVAILABLE",
|
||||
"Database not available",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a site by its UUID path segment, or a 404 envelope.
|
||||
async fn resolve_site(db: &db::Database, site_id: Uuid) -> ApiResult<db::sites::Site> {
|
||||
db::sites::get_site_by_id(db.pool(), site_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error resolving site: {}", e);
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Internal server error",
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "SITE_NOT_FOUND", "Site not found"))
|
||||
}
|
||||
|
||||
/// POST /api/sites/:id/enrollment-key/rotate — rotate (or first-issue) a site's
|
||||
/// enrollment key. Returns the plaintext `cek_` + fingerprint once.
|
||||
pub async fn rotate_enrollment_key(
|
||||
AdminUser(admin): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(site_id): Path<Uuid>,
|
||||
) -> ApiResult<(StatusCode, Json<RotatedEnrollmentKey>)> {
|
||||
let db = require_db(&state)?;
|
||||
let site = resolve_site(db, site_id).await?;
|
||||
|
||||
// Mint plaintext + Argon2id hash + fingerprint. Only the hash + fingerprint
|
||||
// are persisted; the plaintext is surfaced once below.
|
||||
let plaintext = enrollment_keys::generate_enrollment_key();
|
||||
let key_hash = enrollment_keys::hash_enrollment_key(&plaintext).map_err(|e| {
|
||||
tracing::error!("Failed to hash enrollment key: {}", e);
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Failed to hash enrollment key",
|
||||
)
|
||||
})?;
|
||||
let fingerprint = enrollment_keys::compute_fingerprint(&plaintext);
|
||||
|
||||
let new_key = db::enrollment_keys::rotate_key(db.pool(), site.id, &key_hash, &fingerprint)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error rotating enrollment key: {}", e);
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Failed to rotate enrollment key",
|
||||
)
|
||||
})?;
|
||||
|
||||
let fingerprint_label =
|
||||
enrollment_keys::render_fingerprint(new_key.version, &new_key.fingerprint);
|
||||
|
||||
// Audit WITHOUT key material (no plaintext, no hash).
|
||||
if let Err(e) = db::events::log_enrollment_event(
|
||||
db.pool(),
|
||||
db::events::EventTypes::ENROLLMENT_KEY_ROTATED,
|
||||
serde_json::json!({
|
||||
"site_id": site.id,
|
||||
"site_code": site.site_code,
|
||||
"version": new_key.version,
|
||||
"fingerprint": new_key.fingerprint,
|
||||
"rotated_by": admin.username,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[ENROLL] failed to write key-rotate audit event: {}", e);
|
||||
}
|
||||
tracing::info!(
|
||||
"Admin {} rotated enrollment key for site {} to {}",
|
||||
admin.username,
|
||||
site.site_code,
|
||||
fingerprint_label
|
||||
);
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(RotatedEnrollmentKey {
|
||||
site_id: site.id,
|
||||
key: plaintext,
|
||||
version: new_key.version,
|
||||
fingerprint: new_key.fingerprint,
|
||||
fingerprint_label,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /api/sites/:id/enrollment-key — current non-secret fingerprint + version.
|
||||
pub async fn get_enrollment_key(
|
||||
AdminUser(_admin): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(site_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<EnrollmentKeyView>> {
|
||||
let db = require_db(&state)?;
|
||||
let site = resolve_site(db, site_id).await?;
|
||||
|
||||
let key = db::enrollment_keys::get_active_for_site(db.pool(), site.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error loading enrollment key: {}", e);
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Internal server error",
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
ApiError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"NO_ENROLLMENT_KEY",
|
||||
"Site has no active enrollment key",
|
||||
)
|
||||
})?;
|
||||
|
||||
let fingerprint_label = enrollment_keys::render_fingerprint(key.version, &key.fingerprint);
|
||||
|
||||
Ok(Json(EnrollmentKeyView {
|
||||
site_id: site.id,
|
||||
version: key.version,
|
||||
fingerprint: key.fingerprint,
|
||||
fingerprint_label,
|
||||
active: key.active,
|
||||
}))
|
||||
}
|
||||
191
server/src/auth/enrollment_keys.rs
Normal file
191
server/src/auth/enrollment_keys.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
//! Per-site enrollment key minting, hashing, verification, and fingerprinting
|
||||
//! (SPEC-016 zero-touch enrollment, auth layer).
|
||||
//!
|
||||
//! This is the low-sensitivity, rotatable side of the two-tier credential model
|
||||
//! (SPEC-016 §Security). A per-site ENROLLMENT key (`cek_` prefix) gates "may
|
||||
//! this machine register at all" at `POST /api/enroll`; a successful enroll mints
|
||||
//! the high-sensitivity per-machine `cak_` operating credential
|
||||
//! ([`crate::auth::agent_keys`]). Compromise of an enrollment key is contained to
|
||||
//! one site and recovered by rotating it.
|
||||
//!
|
||||
//! Lifecycle owned here (the secret side):
|
||||
//!
|
||||
//! - [`generate_enrollment_key`] mints a high-entropy, `cek_`-prefixed plaintext
|
||||
//! secret. Mirrors [`crate::auth::agent_keys::generate_agent_key`]'s entropy
|
||||
//! approach (32 random bytes from the OS CSPRNG, hex-encoded) with a DISTINCT
|
||||
//! prefix so the two key kinds are never confused in logs or storage. The
|
||||
//! plaintext is shown to the operator exactly once at issue/rotate and is NEVER
|
||||
//! persisted or logged.
|
||||
//! - [`hash_enrollment_key`] / [`verify_enrollment_key`] use **Argon2id** (via
|
||||
//! [`crate::auth::password`]). This DIFFERS from `cak_` (which uses SHA-256 for
|
||||
//! a constant-shape equality lookup): SPEC-016 §2 explicitly requires the
|
||||
//! enrollment key be "stored hashed (Argon2id, same as `cak_`/passwords)". The
|
||||
//! trade-off is deliberate — enrollment keys are looked up by `(site, active)`
|
||||
//! first (a small candidate set, usually one row) and only then verified, so the
|
||||
//! per-verify KDF cost is bounded and not on a high-QPS path, while Argon2id
|
||||
//! gives salted, GPU-resistant storage matching the password posture.
|
||||
//! - [`compute_fingerprint`] derives the non-secret short HEX code shown as
|
||||
//! `vN (XXXX)` (SPEC-016 resolved-decision #3 — hex, deliberately NOT the
|
||||
//! GuruRMM word-style code, so the two products' artifacts are never visually
|
||||
//! conflated).
|
||||
//!
|
||||
//! SECURITY: never log a plaintext key or its hash. Functions here return the
|
||||
//! plaintext to the caller (issue/rotate endpoint) but emit no `tracing` output
|
||||
//! containing key material.
|
||||
|
||||
use anyhow::Result;
|
||||
use rand::RngCore;
|
||||
use ring::digest;
|
||||
|
||||
/// Prefix marking a GuruConnect per-site enrollment key. Distinct from the
|
||||
/// per-agent `cak_` prefix so the two key kinds are never confused.
|
||||
pub const ENROLLMENT_KEY_PREFIX: &str = "cek_";
|
||||
|
||||
/// Number of random bytes behind an enrollment key (256 bits of entropy), matching
|
||||
/// [`crate::auth::agent_keys`]. SPEC-016 §2 requires ≥256-bit.
|
||||
const ENROLLMENT_KEY_RANDOM_BYTES: usize = 32;
|
||||
|
||||
/// Number of hex characters in the fingerprint code (the `XXXX` in `vN (XXXX)`).
|
||||
/// Four hex chars = 16 bits — ample to let an operator tell two installers apart at
|
||||
/// a glance; it is a non-secret display aid, not a security control.
|
||||
const FINGERPRINT_HEX_LEN: usize = 4;
|
||||
|
||||
/// Generate a new high-entropy, `cek_`-prefixed per-site enrollment key (plaintext).
|
||||
///
|
||||
/// The returned string is the ONLY time the plaintext exists; the caller must
|
||||
/// surface it to the operator once and store only [`hash_enrollment_key`] of it.
|
||||
/// Uses the OS CSPRNG via `rand::rngs::OsRng`.
|
||||
pub fn generate_enrollment_key() -> String {
|
||||
let mut bytes = [0u8; ENROLLMENT_KEY_RANDOM_BYTES];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
format!("{}{}", ENROLLMENT_KEY_PREFIX, hex_encode(&bytes))
|
||||
}
|
||||
|
||||
/// Hash an enrollment key for storage using Argon2id (SPEC-016 §2).
|
||||
///
|
||||
/// Delegates to [`crate::auth::password::hash_password`] so the KDF parameters and
|
||||
/// salt generation match the password posture exactly. Returns the PHC-format
|
||||
/// string Postgres stores in `site_enrollment_keys.key_hash`.
|
||||
pub fn hash_enrollment_key(plaintext: &str) -> Result<String> {
|
||||
crate::auth::password::hash_password(plaintext)
|
||||
}
|
||||
|
||||
/// Verify a presented enrollment key against a stored Argon2id hash.
|
||||
///
|
||||
/// Returns `Ok(true)` on a match. A malformed stored hash or a mismatch yields
|
||||
/// `Ok(false)` / an `Err` from the underlying verifier; the caller treats any
|
||||
/// non-`Ok(true)` as a rejection. A cheap structural reject (`cek_` prefix) runs
|
||||
/// first to skip the KDF on obviously-bogus input.
|
||||
///
|
||||
/// SECURITY: only compares; never logs the presented key or the hash.
|
||||
pub fn verify_enrollment_key(presented: &str, stored_hash: &str) -> bool {
|
||||
if !presented.starts_with(ENROLLMENT_KEY_PREFIX) {
|
||||
return false;
|
||||
}
|
||||
crate::auth::password::verify_password(presented, stored_hash).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Compute the non-secret short HEX fingerprint code for an enrollment key.
|
||||
///
|
||||
/// Derived as the first [`FINGERPRINT_HEX_LEN`] hex chars of the SHA-256 of the
|
||||
/// plaintext secret, uppercased. This is a stable, non-reversible tag of the secret
|
||||
/// (knowing the code does not reveal the key) used purely for display. Pair it with
|
||||
/// the monotonic version via [`render_fingerprint`].
|
||||
pub fn compute_fingerprint(plaintext: &str) -> String {
|
||||
let d = digest::digest(&digest::SHA256, plaintext.as_bytes());
|
||||
let hex = hex_encode(d.as_ref());
|
||||
hex[..FINGERPRINT_HEX_LEN].to_ascii_uppercase()
|
||||
}
|
||||
|
||||
/// Render the operator-facing fingerprint string `vN (XXXX)` (SPEC-016 §2).
|
||||
///
|
||||
/// `version` is the monotonic rotation counter; `code` is [`compute_fingerprint`].
|
||||
/// Example: `render_fingerprint(3, "7F2A")` -> `"v3 (7F2A)"`.
|
||||
pub fn render_fingerprint(version: i32, code: &str) -> String {
|
||||
format!("v{} ({})", version, code)
|
||||
}
|
||||
|
||||
/// Lowercase hex encoding without pulling in the `hex` crate (mirrors
|
||||
/// [`crate::auth::agent_keys`]).
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
let _ = write!(s, "{:02x}", b);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generated_key_is_prefixed_and_high_entropy() {
|
||||
let key = generate_enrollment_key();
|
||||
assert!(key.starts_with(ENROLLMENT_KEY_PREFIX));
|
||||
assert_eq!(
|
||||
key.len(),
|
||||
ENROLLMENT_KEY_PREFIX.len() + ENROLLMENT_KEY_RANDOM_BYTES * 2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_keys_are_unique() {
|
||||
assert_ne!(generate_enrollment_key(), generate_enrollment_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_and_verify_roundtrip() {
|
||||
let key = generate_enrollment_key();
|
||||
let hash = hash_enrollment_key(&key).expect("hash");
|
||||
assert!(verify_enrollment_key(&key, &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_wrong_key() {
|
||||
let key = generate_enrollment_key();
|
||||
let other = generate_enrollment_key();
|
||||
let hash = hash_enrollment_key(&key).expect("hash");
|
||||
assert!(!verify_enrollment_key(&other, &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_unprefixed_input_without_touching_kdf() {
|
||||
let key = generate_enrollment_key();
|
||||
let hash = hash_enrollment_key(&key).expect("hash");
|
||||
// A value lacking the cek_ prefix is structurally rejected before the KDF.
|
||||
assert!(!verify_enrollment_key("not-a-key", &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_malformed_stored_hash() {
|
||||
let key = generate_enrollment_key();
|
||||
// A garbage stored hash must not panic and must reject.
|
||||
assert!(!verify_enrollment_key(&key, "not-a-phc-hash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_stable_uppercase_hex_of_expected_len() {
|
||||
let key = "cek_deadbeef";
|
||||
let f1 = compute_fingerprint(key);
|
||||
let f2 = compute_fingerprint(key);
|
||||
assert_eq!(f1, f2);
|
||||
assert_eq!(f1.len(), FINGERPRINT_HEX_LEN);
|
||||
assert!(f1.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_eq!(f1, f1.to_ascii_uppercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_differs_per_key() {
|
||||
assert_ne!(
|
||||
compute_fingerprint("cek_aaa"),
|
||||
compute_fingerprint("cek_bbb")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_fingerprint_matches_spec_shape() {
|
||||
assert_eq!(render_fingerprint(3, "7F2A"), "v3 (7F2A)");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! validation for agents.
|
||||
|
||||
pub mod agent_keys;
|
||||
pub mod enrollment_keys;
|
||||
pub mod jwt;
|
||||
pub mod password;
|
||||
pub mod token_blacklist;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Per-agent key record from the database.
|
||||
@@ -142,3 +143,27 @@ pub async fn touch_last_used(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error>
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the set of `connect_machines.id` values that are bound to at least one
|
||||
/// active (non-revoked) per-agent `cak_` key.
|
||||
///
|
||||
/// Used by the startup restore loop (`main.rs`) to enforce the same keyed/un-keyed
|
||||
/// distinction the relay applies at connect time (SPEC-004 Task 2). For a KEYED
|
||||
/// machine the key→machine binding is the authoritative identity, so its restored
|
||||
/// session must NOT be indexed by a client-asserted `machine_uid` — otherwise an
|
||||
/// un-keyed agent spoofing that uid could reattach the keyed machine's offline
|
||||
/// session after a restart. Membership here lets the caller pass `machine_uid =
|
||||
/// None` for keyed machines while still indexing un-keyed ones for legitimate
|
||||
/// uid-based reattach.
|
||||
pub async fn keyed_machine_ids(pool: &PgPool) -> Result<HashSet<Uuid>, sqlx::Error> {
|
||||
let ids = sqlx::query_scalar::<_, Uuid>(
|
||||
r#"
|
||||
SELECT DISTINCT machine_id
|
||||
FROM connect_agent_keys
|
||||
WHERE revoked_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(ids.into_iter().collect())
|
||||
}
|
||||
|
||||
141
server/src/db/enrollment_keys.rs
Normal file
141
server/src/db/enrollment_keys.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! Per-site enrollment key database operations (SPEC-016 zero-touch enrollment).
|
||||
//!
|
||||
//! Backs the `site_enrollment_keys` table (migration 010). Stores ONLY the
|
||||
//! Argon2id hash of the `cek_` secret plus the non-secret rotation metadata
|
||||
//! (version, fingerprint, active flag). Computing the hash and minting the
|
||||
//! plaintext is [`crate::auth::enrollment_keys`]'s job; this module is
|
||||
//! hash-agnostic persistence and takes already-hashed values.
|
||||
//!
|
||||
//! Rotation invariant: at most one `active` row per site (enforced by a partial
|
||||
//! unique index in migration 010). [`rotate_key`] deactivates the current active
|
||||
//! row and inserts a new active one inside a single transaction so the invariant
|
||||
//! is never transiently violated.
|
||||
//!
|
||||
//! All queries use runtime `sqlx::query()` / `sqlx::query_as()` per the codebase
|
||||
//! convention (no compile-time `query!` macros, no `.sqlx` offline cache).
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Per-site enrollment key record.
|
||||
///
|
||||
/// `key_hash` is the only representation of the secret the server stores; the
|
||||
/// plaintext is shown once at issue/rotate and never persisted.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct EnrollmentKey {
|
||||
pub id: Uuid,
|
||||
pub site_id: Uuid,
|
||||
pub key_hash: String,
|
||||
pub version: i32,
|
||||
pub fingerprint: String,
|
||||
pub active: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub rotated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Fetch the active enrollment key for a site, if any.
|
||||
///
|
||||
/// This is the `/api/enroll` hot path: resolve the one active key whose hash the
|
||||
/// presented `cek_` is verified against. The partial unique index guarantees at
|
||||
/// most one active row, so `fetch_optional` is correct.
|
||||
pub async fn get_active_for_site(
|
||||
pool: &PgPool,
|
||||
site_id: Uuid,
|
||||
) -> Result<Option<EnrollmentKey>, sqlx::Error> {
|
||||
sqlx::query_as::<_, EnrollmentKey>(
|
||||
r#"
|
||||
SELECT id, site_id, key_hash, version, fingerprint, active, created_at, rotated_at
|
||||
FROM site_enrollment_keys
|
||||
WHERE site_id = $1 AND active
|
||||
"#,
|
||||
)
|
||||
.bind(site_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Insert the FIRST enrollment key for a site at version 1 (initial issue).
|
||||
///
|
||||
/// Use [`rotate_key`] for subsequent rotations. Errors with a unique violation if
|
||||
/// the site already has an active key (the caller should rotate instead).
|
||||
#[allow(dead_code)] // Wired by site-admin issue flow; Phase A exposes rotation (which also covers first issue when none exists).
|
||||
pub async fn insert_initial_key(
|
||||
pool: &PgPool,
|
||||
site_id: Uuid,
|
||||
key_hash: &str,
|
||||
fingerprint: &str,
|
||||
) -> Result<EnrollmentKey, sqlx::Error> {
|
||||
sqlx::query_as::<_, EnrollmentKey>(
|
||||
r#"
|
||||
INSERT INTO site_enrollment_keys (site_id, key_hash, version, fingerprint, active)
|
||||
VALUES ($1, $2, 1, $3, true)
|
||||
RETURNING id, site_id, key_hash, version, fingerprint, active, created_at, rotated_at
|
||||
"#,
|
||||
)
|
||||
.bind(site_id)
|
||||
.bind(key_hash)
|
||||
.bind(fingerprint)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rotate a site's enrollment key (SPEC-016 §2): deactivate the current active key
|
||||
/// (if any) and insert a new active key at the next monotonic version, all in one
|
||||
/// transaction.
|
||||
///
|
||||
/// Returns the newly-created active key. If the site has no key yet, this issues
|
||||
/// version 1 (so rotation also serves as first-issue). The caller passes the
|
||||
/// already-hashed new secret and its fingerprint; the plaintext is surfaced once by
|
||||
/// the caller and never reaches this layer.
|
||||
///
|
||||
/// The transaction is what keeps the "at most one active key per site" invariant
|
||||
/// (partial unique index) from being transiently violated between the UPDATE and
|
||||
/// the INSERT.
|
||||
pub async fn rotate_key(
|
||||
pool: &PgPool,
|
||||
site_id: Uuid,
|
||||
new_key_hash: &str,
|
||||
new_fingerprint: &str,
|
||||
) -> Result<EnrollmentKey, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Highest existing version for this site (NULL -> 0 so the first key is v1).
|
||||
let current_max: Option<i32> =
|
||||
sqlx::query_scalar("SELECT MAX(version) FROM site_enrollment_keys WHERE site_id = $1")
|
||||
.bind(site_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let next_version = current_max.unwrap_or(0) + 1;
|
||||
|
||||
// Deactivate the current active key (if any), stamping rotated_at.
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE site_enrollment_keys
|
||||
SET active = false, rotated_at = NOW()
|
||||
WHERE site_id = $1 AND active
|
||||
"#,
|
||||
)
|
||||
.bind(site_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Insert the new active key at the next version.
|
||||
let new_key = sqlx::query_as::<_, EnrollmentKey>(
|
||||
r#"
|
||||
INSERT INTO site_enrollment_keys (site_id, key_hash, version, fingerprint, active)
|
||||
VALUES ($1, $2, $3, $4, true)
|
||||
RETURNING id, site_id, key_hash, version, fingerprint, active, created_at, rotated_at
|
||||
"#,
|
||||
)
|
||||
.bind(site_id)
|
||||
.bind(new_key_hash)
|
||||
.bind(next_version)
|
||||
.bind(new_fingerprint)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(new_key)
|
||||
}
|
||||
@@ -11,7 +11,9 @@ use uuid::Uuid;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct SessionEvent {
|
||||
pub id: i64,
|
||||
pub session_id: Uuid,
|
||||
/// Nullable: admin-removal audit rows (Task 5) carry no session, so this is
|
||||
/// `None` for those. Session/viewer events always populate it.
|
||||
pub session_id: Option<Uuid>,
|
||||
pub event_type: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub viewer_id: Option<String>,
|
||||
@@ -58,6 +60,49 @@ impl EventTypes {
|
||||
/// A `ConsentRequest` was sent to the agent for an attended session (the
|
||||
/// prompt is now awaiting the end user's decision).
|
||||
pub const CONSENT_REQUESTED: &'static str = "consent_requested";
|
||||
|
||||
// Operator-removal events (Task 5). Written by the admin purge endpoints. The
|
||||
// acting admin is recorded in `viewer_id`/`viewer_name` (the only actor fields
|
||||
// the audit table carries) and the structured purge detail (target id, purge
|
||||
// flag, in-memory removal result) goes in `details`.
|
||||
/// An administrator soft-deleted (purged) a machine and dropped its live session.
|
||||
pub const MACHINE_REMOVED: &'static str = "machine_removed";
|
||||
/// An administrator soft-deleted (purged) a session and dropped it in-memory.
|
||||
pub const SESSION_REMOVED: &'static str = "session_removed";
|
||||
|
||||
// Zero-touch enrollment events (SPEC-016). Written by POST /api/enroll and the
|
||||
// site enrollment-key rotation endpoint. These carry no session, so they are
|
||||
// logged via `log_enrollment_event` with `session_id = NULL`; the structured
|
||||
// detail (machine_uid, site_code, fingerprint, etc.) goes in `details` and the
|
||||
// source IP in `ip_address`.
|
||||
/// A new machine self-registered at a site and was minted its first `cak_`.
|
||||
pub const ENROLL_NEW: &'static str = "enroll_new";
|
||||
/// An existing machine_uid re-enrolled at the SAME site — the row was reused and
|
||||
/// a fresh `cak_` minted (re-image / re-install).
|
||||
pub const ENROLL_REUSE: &'static str = "enroll_reuse";
|
||||
/// An existing machine_uid enrolled under a DIFFERENT site — the machine's site
|
||||
/// binding was updated (a "site move"). Fires an alert.
|
||||
///
|
||||
/// NOTE (SPEC-016 Phase A): the unauthenticated enroll path does NOT perform this
|
||||
/// move — a cross-site enroll is REFUSED (`ENROLL_SITE_CONFLICT`) rather than
|
||||
/// silently repointing the machine. This event is reserved for the deliberate
|
||||
/// Phase-B `--reassign` flow (and the dashboard move action) that supersede it.
|
||||
#[allow(dead_code)] // reserved for Phase-B --reassign; not emitted by Phase A enroll
|
||||
pub const ENROLL_SITE_MOVE: &'static str = "enroll_site_move";
|
||||
/// An existing machine_uid presented a valid key for a DIFFERENT site than the one
|
||||
/// the machine is currently bound to. Phase A REFUSES this (no move, no key minted)
|
||||
/// as the accidental-move / cross-site-hijack guard; the deliberate move arrives
|
||||
/// with the Phase-B `--reassign` flow + dashboard. Fires an alert.
|
||||
pub const ENROLL_SITE_CONFLICT: &'static str = "enroll_site_conflict";
|
||||
/// A machine_uid collision was detected at enroll — the endpoint dropped to
|
||||
/// `pending` and awaits operator confirmation in the dashboard. Fires an alert.
|
||||
pub const ENROLL_COLLISION_PENDING: &'static str = "enroll_collision_pending";
|
||||
/// An enroll attempt failed enrollment-key verification (wrong/inactive key or
|
||||
/// unknown site_code). Security audit trail for the open-registration surface.
|
||||
pub const ENROLL_REJECTED: &'static str = "enroll_rejected";
|
||||
/// An administrator rotated a site's enrollment key (new version + fingerprint;
|
||||
/// old installers can no longer enroll NEW machines).
|
||||
pub const ENROLLMENT_KEY_ROTATED: &'static str = "enrollment_key_rotated";
|
||||
}
|
||||
|
||||
/// Log a session event
|
||||
@@ -92,6 +137,93 @@ pub async fn log_event(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Log an operator-removal audit event (Task 5).
|
||||
///
|
||||
/// Writes to the same `connect_session_events` audit table as [`log_event`], but
|
||||
/// shaped for an admin action rather than a viewer/session event: the acting admin
|
||||
/// is recorded in `viewer_id` (the admin's user id) and `viewer_name` (username) —
|
||||
/// the only actor-bearing columns the table has — and structured detail (target id,
|
||||
/// `purge` flag, in-memory removal result) goes in `details`.
|
||||
///
|
||||
/// `session_id` is the audit anchor. For a session purge it is the purged session.
|
||||
/// For a machine purge it is the machine's `last_session_id` when known (linking the
|
||||
/// event to that machine's history) or `None` (the `session_id` FK column is
|
||||
/// nullable). The FK is `ON DELETE CASCADE`, so anchoring to a session that later
|
||||
/// gets hard-deleted would cascade the audit row away — acceptable here because the
|
||||
/// Task-5 flow soft-deletes (never hard-deletes) and the detail JSON independently
|
||||
/// carries the target id.
|
||||
///
|
||||
/// Best-effort: a failure to write the audit row is logged by the caller and does
|
||||
/// not roll back the purge (the soft-delete + in-memory removal already happened),
|
||||
/// matching how the relay treats audit writes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn log_admin_removal(
|
||||
pool: &PgPool,
|
||||
session_id: Option<Uuid>,
|
||||
event_type: &str,
|
||||
actor_user_id: &str,
|
||||
actor_username: &str,
|
||||
details: JsonValue,
|
||||
ip_address: Option<IpAddr>,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let ip_str = ip_address.map(|ip| ip.to_string());
|
||||
|
||||
let result = sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
INSERT INTO connect_session_events
|
||||
(session_id, event_type, viewer_id, viewer_name, details, ip_address)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::inet)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(event_type)
|
||||
.bind(actor_user_id)
|
||||
.bind(actor_username)
|
||||
.bind(details)
|
||||
.bind(ip_str)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Log a zero-touch enrollment audit event (SPEC-016).
|
||||
///
|
||||
/// Shares the `connect_session_events` audit table but carries no session
|
||||
/// (`session_id = NULL`, the FK column is nullable) and no viewer — enrollment is
|
||||
/// an unauthenticated agent action, not a viewer/session event. The structured
|
||||
/// detail (machine_uid, site_code, fingerprint version, decision, etc.) goes in
|
||||
/// `details` and the agent's source IP in `ip_address`.
|
||||
///
|
||||
/// Best-effort: a failure to write the audit row must NOT fail the enroll (the
|
||||
/// machine row and `cak_` already exist); the caller logs the error and proceeds,
|
||||
/// matching how the relay and Task-5 removal treat audit writes.
|
||||
pub async fn log_enrollment_event(
|
||||
pool: &PgPool,
|
||||
event_type: &str,
|
||||
details: JsonValue,
|
||||
ip_address: Option<IpAddr>,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let ip_str = ip_address.map(|ip| ip.to_string());
|
||||
|
||||
let result = sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
INSERT INTO connect_session_events
|
||||
(session_id, event_type, viewer_id, viewer_name, details, ip_address)
|
||||
VALUES (NULL, $1, NULL, NULL, $2, $3::inet)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(event_type)
|
||||
.bind(details)
|
||||
.bind(ip_str)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get events for a session
|
||||
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
||||
pub async fn get_session_events(
|
||||
|
||||
@@ -50,6 +50,30 @@ pub struct Machine {
|
||||
/// impl) so it can never error at decode time.
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Deterministic, recomputable hardware identity reported by the agent
|
||||
/// (`AgentStatus.machine_uid` / connect query param). Column added in migration
|
||||
/// 008. NULLABLE: legacy rows and agents that do not report a uid carry `None`.
|
||||
/// For un-keyed agents this is the dedup key (`upsert_machine` keys
|
||||
/// `ON CONFLICT (machine_uid)` when present); for `cak_`-keyed agents the key's
|
||||
/// machine binding stays authoritative and the claimed uid is NOT used to dedup
|
||||
/// (see `upsert_machine`).
|
||||
pub machine_uid: Option<String>,
|
||||
/// Soft-delete marker (migration 009). When non-null the machine was
|
||||
/// operator-purged (Task 5): it is excluded from every list/get query and is
|
||||
/// never restored by the startup reconcile, but the row (and its audit
|
||||
/// history) is retained. NULL = live. Nullable, so it is read NULL-tolerantly
|
||||
/// in the manual `FromRow` below.
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
/// Relational site binding for a machine enrolled via `/api/enroll` (SPEC-016,
|
||||
/// migration 010). NULL for legacy / support-code / connect-path machines that
|
||||
/// never enrolled through the zero-touch flow. A change of this on re-enroll is
|
||||
/// the "site move" the enroll path audits.
|
||||
pub site_id: Option<Uuid>,
|
||||
/// Collision-gate state (SPEC-016, migration 010): `'active'` (live, auto-approve)
|
||||
/// or `'pending'` (a machine_uid collision was detected at enroll; awaiting
|
||||
/// operator confirmation before the endpoint may be controlled). Non-null with a
|
||||
/// default of `'active'`; read NULL-tolerantly below for defense in depth.
|
||||
pub enrollment_state: String,
|
||||
}
|
||||
|
||||
impl<'r> FromRow<'r, PgRow> for Machine {
|
||||
@@ -65,6 +89,17 @@ impl<'r> FromRow<'r, PgRow> for Machine {
|
||||
tenant_id: row.try_get("tenant_id")?,
|
||||
organization: row.try_get("organization")?,
|
||||
site: row.try_get("site")?,
|
||||
// Schema-nullable (migration 008); decode directly as Option.
|
||||
machine_uid: row.try_get("machine_uid")?,
|
||||
// Schema-nullable (migration 009); decode directly as Option.
|
||||
deleted_at: row.try_get("deleted_at")?,
|
||||
// Schema-nullable (migration 010); decode directly as Option.
|
||||
site_id: row.try_get("site_id")?,
|
||||
// Non-null with default 'active' (migration 010); read NULL-tolerantly
|
||||
// (older snapshots / partial rows) and fall back to 'active'.
|
||||
enrollment_state: row
|
||||
.try_get::<Option<String>, _>("enrollment_state")?
|
||||
.unwrap_or_else(|| "active".to_string()),
|
||||
// Nullable-with-default columns mapped to non-`Option` Rust types: read as
|
||||
// `Option<T>` and fall back to the type default so a NULL cell never errors.
|
||||
is_elevated: row
|
||||
@@ -97,27 +132,219 @@ impl<'r> FromRow<'r, PgRow> for Machine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create a machine by agent_id (upsert)
|
||||
/// Get or create a machine record (upsert), deduplicating on the most stable
|
||||
/// identity available (SPEC-004 / v2-stable-identity Task 2).
|
||||
///
|
||||
/// Two dedup paths, selected by whether the caller passes a `machine_uid`:
|
||||
///
|
||||
/// - **`machine_uid = Some(uid)` (the un-keyed dedup path):** key on the stable
|
||||
/// hardware identity — `ON CONFLICT (machine_uid)`. The SAME machine reconnecting
|
||||
/// with a DIFFERENT `agent_id` (e.g. after a config loss minted a fresh random id)
|
||||
/// updates its EXISTING row instead of inserting a duplicate. `agent_id` and
|
||||
/// `hostname` are refreshed to the latest reported values. This is what collapses
|
||||
/// the duplicate-registration fleet down to one row per real machine.
|
||||
///
|
||||
/// - **`machine_uid = None` (legacy / authoritative path):** preserve the original
|
||||
/// behavior exactly — `ON CONFLICT (agent_id)`. Used for agents that do not report
|
||||
/// a uid AND, critically, for `cak_`-keyed agents: the caller (`relay`) passes
|
||||
/// `None` for keyed agents so their AUTHORITATIVE key-bound `agent_id` is the dedup
|
||||
/// key and a client-claimed `machine_uid` can never repoint a keyed machine's row.
|
||||
///
|
||||
/// SECURITY: a client-asserted `machine_uid` is spoofable, so it is a *correctness*
|
||||
/// aid, not a trust boundary. Only the un-keyed path supplies it; the keyed path's
|
||||
/// authority lives in the key→machine binding upstream (see
|
||||
/// `relay::agent_ws_handler`), never here.
|
||||
///
|
||||
/// SOFT-DELETE REVIVE (Task 5): both `ON CONFLICT DO UPDATE` arms clear
|
||||
/// `deleted_at` (set it back to NULL). A machine that was operator-purged but then
|
||||
/// genuinely reconnects is a live machine again, so it must reappear in the console
|
||||
/// rather than stay hidden behind a stale soft-delete marker. A purge of a truly
|
||||
/// gone host is permanent precisely because such a host never upserts again.
|
||||
pub async fn upsert_machine(
|
||||
pool: &PgPool,
|
||||
agent_id: &str,
|
||||
hostname: &str,
|
||||
is_persistent: bool,
|
||||
machine_uid: Option<&str>,
|
||||
) -> Result<Machine, sqlx::Error> {
|
||||
match machine_uid {
|
||||
// Un-keyed dedup path: stable hardware identity is the conflict arbiter.
|
||||
// A new agent_id for the same physical machine updates the existing row.
|
||||
//
|
||||
// Edge case: if this INSERT's new random `agent_id` happens to collide with a
|
||||
// DIFFERENT legacy row's value under the `agent_id UNIQUE` constraint, Postgres
|
||||
// raises the unique violation on `agent_id` BEFORE the `ON CONFLICT
|
||||
// (machine_uid)` arbiter is consulted, so the upsert errors instead of merging
|
||||
// on uid. This is non-fatal: the caller logs the error and the live in-memory
|
||||
// session is unaffected, and the agent simply retries with a freshly minted
|
||||
// UUID. The window closes on its own as legacy rows age out (SPEC-004 Task 3).
|
||||
Some(uid) => {
|
||||
sqlx::query_as::<_, Machine>(
|
||||
r#"
|
||||
INSERT INTO connect_machines (agent_id, hostname, is_persistent, status, last_seen, machine_uid)
|
||||
VALUES ($1, $2, $3, 'online', NOW(), $4)
|
||||
ON CONFLICT (machine_uid) WHERE machine_uid IS NOT NULL DO UPDATE SET
|
||||
agent_id = EXCLUDED.agent_id,
|
||||
hostname = EXCLUDED.hostname,
|
||||
status = 'online',
|
||||
last_seen = NOW(),
|
||||
deleted_at = NULL
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(hostname)
|
||||
.bind(is_persistent)
|
||||
.bind(uid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
// Legacy / authoritative path: dedup on agent_id exactly as before. Leaves
|
||||
// machine_uid NULL (the partial unique index excludes NULLs, so any number
|
||||
// of these may coexist).
|
||||
None => {
|
||||
sqlx::query_as::<_, Machine>(
|
||||
r#"
|
||||
INSERT INTO connect_machines (agent_id, hostname, is_persistent, status, last_seen)
|
||||
VALUES ($1, $2, $3, 'online', NOW())
|
||||
ON CONFLICT (agent_id) DO UPDATE SET
|
||||
hostname = EXCLUDED.hostname,
|
||||
status = 'online',
|
||||
last_seen = NOW(),
|
||||
deleted_at = NULL
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(hostname)
|
||||
.bind(is_persistent)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a machine by the SPEC-016 per-tenant dedup key `(tenant_id, machine_uid)`.
|
||||
///
|
||||
/// This is the enroll-time dedup lookup: the same hardware re-enrolling (re-image /
|
||||
/// re-install) resolves to its existing row within the tenant, while the same
|
||||
/// hardware in a DIFFERENT tenant is a distinct row (resolved-decision #4). Tenant
|
||||
/// scoping uses the same default-tenant fold as the unique index so the lookup
|
||||
/// matches the uniqueness guarantee.
|
||||
///
|
||||
/// Unlike `get_machine_by_agent_id`, this deliberately does NOT filter
|
||||
/// `deleted_at IS NULL`: a previously operator-purged machine that legitimately
|
||||
/// re-enrolls must be found so the enroll path can revive it (clearing
|
||||
/// `deleted_at`), mirroring the connect-path revive in `upsert_machine`.
|
||||
pub async fn get_machine_by_tenant_uid(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
machine_uid: &str,
|
||||
) -> Result<Option<Machine>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Machine>(
|
||||
r#"
|
||||
SELECT * FROM connect_machines
|
||||
WHERE machine_uid = $1
|
||||
AND COALESCE(tenant_id, '00000000-0000-0000-0000-000000000001'::uuid) = $2
|
||||
"#,
|
||||
)
|
||||
.bind(machine_uid)
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Parameters for an enroll-time machine create/update (SPEC-016 `/api/enroll`).
|
||||
///
|
||||
/// `agent_id` is a freshly minted opaque id for a NEW enrollment (the agent's
|
||||
/// config UUID story is Phase B; the server only needs a unique non-null value for
|
||||
/// the `agent_id UNIQUE` column). On REUSE/MOVE the existing row's `agent_id` is
|
||||
/// preserved (the FK target of any already-minted `cak_`), so the update path does
|
||||
/// not touch it.
|
||||
pub struct EnrollMachineParams<'a> {
|
||||
pub agent_id: &'a str,
|
||||
pub hostname: &'a str,
|
||||
pub machine_uid: &'a str,
|
||||
pub tenant_id: Uuid,
|
||||
pub site_id: Uuid,
|
||||
/// Company label (-> connect_machines.organization).
|
||||
pub company: Option<&'a str>,
|
||||
/// Site label (-> connect_machines.site) — the free-text label, distinct from
|
||||
/// the relational site_id binding.
|
||||
pub site_label: Option<&'a str>,
|
||||
pub tags: &'a [String],
|
||||
/// 'active' (auto-approve) or 'pending' (collision-gated).
|
||||
pub enrollment_state: &'a str,
|
||||
}
|
||||
|
||||
/// Insert a NEW machine row for a first-time enrollment (SPEC-016).
|
||||
///
|
||||
/// Carries the labels, the relational `site_id`, the per-tenant `machine_uid`, and
|
||||
/// the collision-gate `enrollment_state`. Persistent + online. Returns the created
|
||||
/// row (its `id` is the FK target for the `cak_` the caller mints next).
|
||||
pub async fn insert_enrolled_machine(
|
||||
pool: &PgPool,
|
||||
p: &EnrollMachineParams<'_>,
|
||||
) -> Result<Machine, sqlx::Error> {
|
||||
sqlx::query_as::<_, Machine>(
|
||||
r#"
|
||||
INSERT INTO connect_machines (agent_id, hostname, is_persistent, status, last_seen)
|
||||
VALUES ($1, $2, $3, 'online', NOW())
|
||||
ON CONFLICT (agent_id) DO UPDATE SET
|
||||
hostname = EXCLUDED.hostname,
|
||||
status = 'online',
|
||||
last_seen = NOW()
|
||||
INSERT INTO connect_machines
|
||||
(agent_id, hostname, is_persistent, status, last_seen, machine_uid,
|
||||
tenant_id, site_id, organization, site, tags, enrollment_state)
|
||||
VALUES ($1, $2, true, 'online', NOW(), $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(hostname)
|
||||
.bind(is_persistent)
|
||||
.bind(p.agent_id)
|
||||
.bind(p.hostname)
|
||||
.bind(p.machine_uid)
|
||||
.bind(p.tenant_id)
|
||||
.bind(p.site_id)
|
||||
.bind(p.company)
|
||||
.bind(p.site_label)
|
||||
.bind(p.tags)
|
||||
.bind(p.enrollment_state)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update an EXISTING machine row on re-enroll / reuse / site-move (SPEC-016).
|
||||
///
|
||||
/// Refreshes hostname, site binding (`site_id`), labels, and `enrollment_state`,
|
||||
/// and revives a soft-deleted row (`deleted_at = NULL`) — a re-enroll of a purged
|
||||
/// host means it is live again, mirroring `upsert_machine`'s revive. Deliberately
|
||||
/// does NOT change `agent_id`: the existing id is the FK target of any prior `cak_`.
|
||||
/// Labels are COALESCE-merged so an enroll that omits a label does not wipe an
|
||||
/// existing value; `tags` is overwritten only when a non-empty set is supplied
|
||||
/// (matching `update_machine_metadata`'s convention).
|
||||
pub async fn update_enrolled_machine(
|
||||
pool: &PgPool,
|
||||
machine_id: Uuid,
|
||||
p: &EnrollMachineParams<'_>,
|
||||
) -> Result<Machine, sqlx::Error> {
|
||||
sqlx::query_as::<_, Machine>(
|
||||
r#"
|
||||
UPDATE connect_machines SET
|
||||
hostname = $2,
|
||||
site_id = $3,
|
||||
organization = COALESCE($4, organization),
|
||||
site = COALESCE($5, site),
|
||||
tags = CASE WHEN $6::text[] = '{}' THEN tags ELSE $6 END,
|
||||
enrollment_state = $7,
|
||||
status = 'online',
|
||||
last_seen = NOW(),
|
||||
deleted_at = NULL
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(machine_id)
|
||||
.bind(p.hostname)
|
||||
.bind(p.site_id)
|
||||
.bind(p.company)
|
||||
.bind(p.site_label)
|
||||
.bind(p.tags)
|
||||
.bind(p.enrollment_state)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
@@ -153,24 +380,31 @@ pub async fn update_machine_status(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all persistent machines (for restore on startup)
|
||||
/// Get all persistent machines (for the dashboard list AND the startup restore).
|
||||
///
|
||||
/// Excludes operator-purged rows (`deleted_at IS NOT NULL`, migration 009 / Task 5):
|
||||
/// a soft-deleted machine must not reappear in `/api/machines` and must not be
|
||||
/// re-restored into the in-memory session manager on startup. This is the filter
|
||||
/// that makes the ghost-row purge stick.
|
||||
pub async fn get_all_machines(pool: &PgPool) -> Result<Vec<Machine>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Machine>(
|
||||
"SELECT * FROM connect_machines WHERE is_persistent = true ORDER BY hostname",
|
||||
"SELECT * FROM connect_machines WHERE is_persistent = true AND deleted_at IS NULL ORDER BY hostname",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get machine by agent_id
|
||||
/// Get machine by agent_id (live rows only — excludes soft-deleted, Task 5).
|
||||
pub async fn get_machine_by_agent_id(
|
||||
pool: &PgPool,
|
||||
agent_id: &str,
|
||||
) -> Result<Option<Machine>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Machine>("SELECT * FROM connect_machines WHERE agent_id = $1")
|
||||
.bind(agent_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
sqlx::query_as::<_, Machine>(
|
||||
"SELECT * FROM connect_machines WHERE agent_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get machine by its primary-key UUID (`connect_machines.id`).
|
||||
@@ -179,6 +413,13 @@ pub async fn get_machine_by_agent_id(
|
||||
/// `verify_agent_key` back to its canonical `agent_id`, so persistent reattach
|
||||
/// binds to the authenticated identity rather than a client-supplied query
|
||||
/// param (Task 3 identity binding).
|
||||
///
|
||||
/// NOTE (Task 5): this deliberately does NOT filter `deleted_at IS NULL`. It is
|
||||
/// the authenticated-identity resolver for a `cak_`-keyed agent's reattach, not a
|
||||
/// dashboard read. If a previously operator-purged machine genuinely reconnects
|
||||
/// with a valid key, it must resolve so `upsert_machine` can revive it (the
|
||||
/// upsert clears `deleted_at`). The dashboard get-by-id path is
|
||||
/// `get_machine_by_agent_id`, which IS filtered.
|
||||
pub async fn get_machine_by_id(
|
||||
pool: &PgPool,
|
||||
machine_id: Uuid,
|
||||
@@ -200,7 +441,11 @@ pub async fn mark_machine_offline(pool: &PgPool, agent_id: &str) -> Result<(), s
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a machine record
|
||||
/// Hard-delete a machine record (legacy path, retained for backward compatibility).
|
||||
///
|
||||
/// Cascades to `connect_sessions` / `connect_session_events` via the FKs, so it
|
||||
/// also destroys audit history. The Task-5 operator-removal flow prefers
|
||||
/// [`soft_delete_machine`] instead, which keeps the row for the audit trail.
|
||||
pub async fn delete_machine(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("DELETE FROM connect_machines WHERE agent_id = $1")
|
||||
.bind(agent_id)
|
||||
@@ -209,6 +454,24 @@ pub async fn delete_machine(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::E
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Soft-delete (operator purge) a single machine by `agent_id` (Task 5).
|
||||
///
|
||||
/// Sets `deleted_at = NOW()` so the row is excluded from every list/get query and
|
||||
/// the startup reconcile, while retaining the row and its `connect_session_events`
|
||||
/// history for the audit trail. Only flips rows that are still live
|
||||
/// (`deleted_at IS NULL`), so a re-purge is a no-op rather than overwriting the
|
||||
/// original removal instant. Returns the number of rows affected (0 = unknown or
|
||||
/// already-purged `agent_id`), letting the caller distinguish a 404 from a success.
|
||||
pub async fn soft_delete_machine(pool: &PgPool, agent_id: &str) -> Result<u64, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE connect_machines SET deleted_at = NOW() WHERE agent_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Update machine organization, site, and tags
|
||||
pub async fn update_machine_metadata(
|
||||
pool: &PgPool,
|
||||
@@ -239,3 +502,290 @@ pub async fn update_machine_metadata(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
/// Connect to a throwaway test Postgres and apply migrations, or return `None`
|
||||
/// when `TEST_DATABASE_URL` is unset so the suite is a no-op on workstations
|
||||
/// without a database. CI sets `TEST_DATABASE_URL` against an ephemeral Postgres,
|
||||
/// where these run for real. (The server crate is Linux-targeted and validated
|
||||
/// in Gitea CI; these DB tests run there.)
|
||||
async fn test_pool() -> Option<PgPool> {
|
||||
let url = std::env::var("TEST_DATABASE_URL").ok()?;
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect to TEST_DATABASE_URL");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("apply migrations to the test database");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
/// Remove any rows this test created so reruns are clean and tests don't collide.
|
||||
async fn cleanup(pool: &PgPool, agent_ids: &[&str], machine_uids: &[&str]) {
|
||||
for id in agent_ids {
|
||||
let _ = sqlx::query("DELETE FROM connect_machines WHERE agent_id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
for uid in machine_uids {
|
||||
let _ = sqlx::query("DELETE FROM connect_machines WHERE machine_uid = $1")
|
||||
.bind(uid)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// (a) Same `machine_uid` with two DIFFERENT `agent_id`s collapses to ONE row.
|
||||
/// The second upsert updates the existing row (and repoints its agent_id) rather
|
||||
/// than inserting a duplicate — the core dedup guarantee for the un-keyed fleet.
|
||||
#[tokio::test]
|
||||
async fn same_machine_uid_two_agent_ids_one_row() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return; // no TEST_DATABASE_URL: skip (runs in CI)
|
||||
};
|
||||
let uid = "test-muid-dedup-001";
|
||||
cleanup(&pool, &["agent-A", "agent-B"], &[uid]).await;
|
||||
|
||||
let m1 = upsert_machine(&pool, "agent-A", "HOST-A", true, Some(uid))
|
||||
.await
|
||||
.expect("first upsert");
|
||||
let m2 = upsert_machine(&pool, "agent-B", "HOST-A2", true, Some(uid))
|
||||
.await
|
||||
.expect("second upsert with same uid, different agent_id");
|
||||
|
||||
// Same physical row, agent_id and hostname refreshed to the latest.
|
||||
assert_eq!(m1.id, m2.id, "same machine_uid must update the same row");
|
||||
assert_eq!(m2.agent_id, "agent-B");
|
||||
assert_eq!(m2.hostname, "HOST-A2");
|
||||
assert_eq!(m2.machine_uid.as_deref(), Some(uid));
|
||||
|
||||
// Exactly one row carries this uid.
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM connect_machines WHERE machine_uid = $1")
|
||||
.bind(uid)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("count rows for uid");
|
||||
assert_eq!(count, 1, "must be exactly one row for the machine_uid");
|
||||
|
||||
cleanup(&pool, &["agent-A", "agent-B"], &[uid]).await;
|
||||
}
|
||||
|
||||
/// (b) Legacy NULL-`machine_uid` path is unchanged: dedup keys on `agent_id`,
|
||||
/// the row's `machine_uid` stays NULL, and re-upserting the same agent_id with
|
||||
/// no uid updates the same row (no crash, no duplicate).
|
||||
#[tokio::test]
|
||||
async fn legacy_null_machine_uid_dedups_on_agent_id() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return; // no TEST_DATABASE_URL: skip (runs in CI)
|
||||
};
|
||||
let agent = "test-legacy-agent-001";
|
||||
cleanup(&pool, &[agent], &[]).await;
|
||||
|
||||
let m1 = upsert_machine(&pool, agent, "LEGACY-HOST", true, None)
|
||||
.await
|
||||
.expect("legacy upsert (no uid)");
|
||||
assert_eq!(
|
||||
m1.machine_uid, None,
|
||||
"legacy row must have NULL machine_uid"
|
||||
);
|
||||
|
||||
let m2 = upsert_machine(&pool, agent, "LEGACY-HOST-RENAMED", true, None)
|
||||
.await
|
||||
.expect("legacy re-upsert (no uid)");
|
||||
assert_eq!(m1.id, m2.id, "legacy agent_id must dedup to the same row");
|
||||
assert_eq!(m2.hostname, "LEGACY-HOST-RENAMED");
|
||||
assert_eq!(m2.machine_uid, None);
|
||||
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM connect_machines WHERE agent_id = $1")
|
||||
.bind(agent)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("count legacy rows");
|
||||
assert_eq!(count, 1, "legacy path must not duplicate the row");
|
||||
|
||||
cleanup(&pool, &[agent], &[]).await;
|
||||
}
|
||||
|
||||
/// Multiple legacy rows with NULL machine_uid coexist — the partial unique index
|
||||
/// excludes NULLs, so distinct un-keyed agents are independent rows.
|
||||
#[tokio::test]
|
||||
async fn multiple_null_machine_uid_rows_coexist() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return; // no TEST_DATABASE_URL: skip (runs in CI)
|
||||
};
|
||||
cleanup(&pool, &["null-1", "null-2"], &[]).await;
|
||||
|
||||
let a = upsert_machine(&pool, "null-1", "H1", true, None)
|
||||
.await
|
||||
.expect("first null-uid row");
|
||||
let b = upsert_machine(&pool, "null-2", "H2", true, None)
|
||||
.await
|
||||
.expect("second null-uid row");
|
||||
assert_ne!(a.id, b.id, "distinct legacy agents must be distinct rows");
|
||||
|
||||
cleanup(&pool, &["null-1", "null-2"], &[]).await;
|
||||
}
|
||||
|
||||
/// Helper: does `get_all_machines` (the dashboard list / startup restore query)
|
||||
/// currently return a row with this agent_id?
|
||||
async fn list_contains(pool: &PgPool, agent_id: &str) -> bool {
|
||||
get_all_machines(pool)
|
||||
.await
|
||||
.expect("list machines")
|
||||
.iter()
|
||||
.any(|m| m.agent_id == agent_id)
|
||||
}
|
||||
|
||||
/// Task 5: soft-deleting a machine sets `deleted_at` and excludes it from BOTH
|
||||
/// the list query and the by-agent_id get — the core operator-removal guarantee
|
||||
/// (a purged ghost row must not reappear in /api/machines).
|
||||
#[tokio::test]
|
||||
async fn soft_delete_machine_hides_from_list_and_get() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return; // no TEST_DATABASE_URL: skip (runs in CI)
|
||||
};
|
||||
let agent = "test-softdel-agent-001";
|
||||
cleanup(&pool, &[agent], &[]).await;
|
||||
|
||||
let m = upsert_machine(&pool, agent, "SOFTDEL-HOST", true, None)
|
||||
.await
|
||||
.expect("create machine");
|
||||
assert!(m.deleted_at.is_none(), "fresh row must be live");
|
||||
assert!(
|
||||
list_contains(&pool, agent).await,
|
||||
"live machine must be listed"
|
||||
);
|
||||
assert!(
|
||||
get_machine_by_agent_id(&pool, agent)
|
||||
.await
|
||||
.expect("get")
|
||||
.is_some(),
|
||||
"live machine must be gettable"
|
||||
);
|
||||
|
||||
// Soft-delete.
|
||||
let affected = soft_delete_machine(&pool, agent)
|
||||
.await
|
||||
.expect("soft delete");
|
||||
assert_eq!(affected, 1, "exactly one live row flips to deleted");
|
||||
|
||||
// Excluded from list and get.
|
||||
assert!(
|
||||
!list_contains(&pool, agent).await,
|
||||
"soft-deleted machine must NOT be listed"
|
||||
);
|
||||
assert!(
|
||||
get_machine_by_agent_id(&pool, agent)
|
||||
.await
|
||||
.expect("get after delete")
|
||||
.is_none(),
|
||||
"soft-deleted machine must NOT be gettable by agent_id"
|
||||
);
|
||||
|
||||
// The row still exists with a non-null deleted_at (history retained).
|
||||
let deleted_at: Option<DateTime<Utc>> =
|
||||
sqlx::query_scalar("SELECT deleted_at FROM connect_machines WHERE agent_id = $1")
|
||||
.bind(agent)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("row still present");
|
||||
assert!(
|
||||
deleted_at.is_some(),
|
||||
"row must be retained with deleted_at set"
|
||||
);
|
||||
|
||||
// Re-purge is a no-op (does not overwrite the original instant).
|
||||
let again = soft_delete_machine(&pool, agent)
|
||||
.await
|
||||
.expect("re-soft-delete");
|
||||
assert_eq!(
|
||||
again, 0,
|
||||
"re-purge of an already-deleted row affects 0 rows"
|
||||
);
|
||||
|
||||
cleanup(&pool, &[agent], &[]).await;
|
||||
}
|
||||
|
||||
/// Task 5: a genuine reconnect (upsert) of a previously soft-deleted machine
|
||||
/// REVIVES it — `deleted_at` is cleared so it reappears in the console. A purge
|
||||
/// only sticks for a host that never upserts again.
|
||||
#[tokio::test]
|
||||
async fn upsert_revives_soft_deleted_machine() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return; // no TEST_DATABASE_URL: skip (runs in CI)
|
||||
};
|
||||
let agent = "test-revive-agent-001";
|
||||
cleanup(&pool, &[agent], &[]).await;
|
||||
|
||||
upsert_machine(&pool, agent, "REVIVE-HOST", true, None)
|
||||
.await
|
||||
.expect("create");
|
||||
soft_delete_machine(&pool, agent)
|
||||
.await
|
||||
.expect("soft delete");
|
||||
assert!(!list_contains(&pool, agent).await, "purged: hidden");
|
||||
|
||||
// Reconnect.
|
||||
let revived = upsert_machine(&pool, agent, "REVIVE-HOST", true, None)
|
||||
.await
|
||||
.expect("reconnect upsert");
|
||||
assert!(
|
||||
revived.deleted_at.is_none(),
|
||||
"reconnect must clear deleted_at"
|
||||
);
|
||||
assert!(
|
||||
list_contains(&pool, agent).await,
|
||||
"revived machine must be listed again"
|
||||
);
|
||||
|
||||
cleanup(&pool, &[agent], &[]).await;
|
||||
}
|
||||
|
||||
/// Task 5: bulk soft-delete (as the bulk endpoint does, one id at a time)
|
||||
/// removes every listed id from the live list.
|
||||
#[tokio::test]
|
||||
async fn bulk_soft_delete_hides_all_listed() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return; // no TEST_DATABASE_URL: skip (runs in CI)
|
||||
};
|
||||
let agents = ["test-bulk-a", "test-bulk-b", "test-bulk-c"];
|
||||
cleanup(&pool, &agents, &[]).await;
|
||||
|
||||
for (i, a) in agents.iter().enumerate() {
|
||||
upsert_machine(&pool, a, &format!("BULK-HOST-{i}"), true, None)
|
||||
.await
|
||||
.expect("create bulk machine");
|
||||
}
|
||||
for a in &agents {
|
||||
assert!(list_contains(&pool, a).await, "{a} listed before bulk");
|
||||
}
|
||||
|
||||
// Purge all three (the bulk endpoint loops soft_delete_machine per id).
|
||||
let mut removed = 0u64;
|
||||
for a in &agents {
|
||||
removed += soft_delete_machine(&pool, a)
|
||||
.await
|
||||
.expect("bulk soft delete");
|
||||
}
|
||||
assert_eq!(removed, 3, "all three live rows flipped to deleted");
|
||||
|
||||
for a in &agents {
|
||||
assert!(
|
||||
!list_contains(&pool, a).await,
|
||||
"{a} must be hidden after bulk purge"
|
||||
);
|
||||
}
|
||||
|
||||
cleanup(&pool, &agents, &[]).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
//! Optional - server works without database if DATABASE_URL not set.
|
||||
|
||||
pub mod agent_keys;
|
||||
pub mod enrollment_keys;
|
||||
pub mod events;
|
||||
pub mod machines;
|
||||
pub mod releases;
|
||||
pub mod sessions;
|
||||
pub mod sites;
|
||||
pub mod support_codes;
|
||||
pub mod tenancy;
|
||||
pub mod users;
|
||||
|
||||
@@ -170,6 +170,7 @@ pub async fn get_machines_needing_update(
|
||||
SELECT agent_id FROM connect_machines
|
||||
WHERE status = 'online'
|
||||
AND is_persistent = true
|
||||
AND deleted_at IS NULL
|
||||
AND (agent_version IS NULL OR agent_version < $1)
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -25,6 +25,10 @@ pub struct DbSession {
|
||||
/// Attended-consent state: 'not_required' | 'pending' | 'granted' | 'denied'.
|
||||
/// Enforcement is Task 5; this column carries the state only.
|
||||
pub consent_state: String,
|
||||
/// Soft-delete marker (migration 009). When non-null the session was
|
||||
/// operator-purged (Task 5) and is excluded from every list/get query, while
|
||||
/// the row (and its audit history) is retained. NULL = live.
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Create a new session record
|
||||
@@ -120,10 +124,12 @@ pub async fn get_session(
|
||||
pool: &PgPool,
|
||||
session_id: Uuid,
|
||||
) -> Result<Option<DbSession>, sqlx::Error> {
|
||||
sqlx::query_as::<_, DbSession>("SELECT * FROM connect_sessions WHERE id = $1")
|
||||
.bind(session_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
sqlx::query_as::<_, DbSession>(
|
||||
"SELECT * FROM connect_sessions WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get active sessions for a machine
|
||||
@@ -133,7 +139,7 @@ pub async fn get_active_sessions_for_machine(
|
||||
machine_id: Uuid,
|
||||
) -> Result<Vec<DbSession>, sqlx::Error> {
|
||||
sqlx::query_as::<_, DbSession>(
|
||||
"SELECT * FROM connect_sessions WHERE machine_id = $1 AND status = 'active' ORDER BY started_at DESC"
|
||||
"SELECT * FROM connect_sessions WHERE machine_id = $1 AND status = 'active' AND deleted_at IS NULL ORDER BY started_at DESC"
|
||||
)
|
||||
.bind(machine_id)
|
||||
.fetch_all(pool)
|
||||
@@ -144,7 +150,7 @@ pub async fn get_active_sessions_for_machine(
|
||||
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
||||
pub async fn get_recent_sessions(pool: &PgPool, limit: i64) -> Result<Vec<DbSession>, sqlx::Error> {
|
||||
sqlx::query_as::<_, DbSession>(
|
||||
"SELECT * FROM connect_sessions ORDER BY started_at DESC LIMIT $1",
|
||||
"SELECT * FROM connect_sessions WHERE deleted_at IS NULL ORDER BY started_at DESC LIMIT $1",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
@@ -157,9 +163,140 @@ pub async fn get_sessions_for_machine(
|
||||
machine_id: Uuid,
|
||||
) -> Result<Vec<DbSession>, sqlx::Error> {
|
||||
sqlx::query_as::<_, DbSession>(
|
||||
"SELECT * FROM connect_sessions WHERE machine_id = $1 ORDER BY started_at DESC",
|
||||
"SELECT * FROM connect_sessions WHERE machine_id = $1 AND deleted_at IS NULL ORDER BY started_at DESC",
|
||||
)
|
||||
.bind(machine_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Soft-delete (operator purge) a single session by id (Task 5).
|
||||
///
|
||||
/// Sets `deleted_at = NOW()` so the row is excluded from every list/get query
|
||||
/// while the row and its `connect_session_events` history are retained for the
|
||||
/// audit trail. Only flips rows that are still live (`deleted_at IS NULL`) so a
|
||||
/// re-purge does not overwrite the original removal instant. Returns the number
|
||||
/// of rows affected (0 = unknown or already-purged id) so the caller can return a
|
||||
/// clean 404 vs. success.
|
||||
pub async fn soft_delete_session(pool: &PgPool, session_id: Uuid) -> Result<u64, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE connect_sessions SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(session_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::machines;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
/// Connect to a throwaway test Postgres and apply migrations, or return `None`
|
||||
/// when `TEST_DATABASE_URL` is unset (self-skip on workstations; runs in CI).
|
||||
async fn test_pool() -> Option<PgPool> {
|
||||
let url = std::env::var("TEST_DATABASE_URL").ok()?;
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect to TEST_DATABASE_URL");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("apply migrations to the test database");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
/// Create a machine to satisfy the `connect_sessions.machine_id` FK; returns its id.
|
||||
async fn seed_machine(pool: &PgPool, agent_id: &str) -> Uuid {
|
||||
let _ = sqlx::query("DELETE FROM connect_machines WHERE agent_id = $1")
|
||||
.bind(agent_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
machines::upsert_machine(pool, agent_id, "SESS-TEST-HOST", true, None)
|
||||
.await
|
||||
.expect("seed machine")
|
||||
.id
|
||||
}
|
||||
|
||||
/// Task 5: soft-deleting a session sets `deleted_at` and excludes it from get and
|
||||
/// the machine/recent list queries, while the row is retained.
|
||||
#[tokio::test]
|
||||
async fn soft_delete_session_hides_from_get_and_lists() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return; // no TEST_DATABASE_URL: skip (runs in CI)
|
||||
};
|
||||
let agent = "test-sess-softdel-agent";
|
||||
let machine_id = seed_machine(&pool, agent).await;
|
||||
let session_id = Uuid::new_v4();
|
||||
|
||||
let s = create_session(&pool, session_id, machine_id, false, None)
|
||||
.await
|
||||
.expect("create session");
|
||||
assert!(s.deleted_at.is_none(), "fresh session must be live");
|
||||
|
||||
// Visible before deletion.
|
||||
assert!(
|
||||
get_session(&pool, session_id).await.expect("get").is_some(),
|
||||
"live session must be gettable"
|
||||
);
|
||||
assert!(
|
||||
get_sessions_for_machine(&pool, machine_id)
|
||||
.await
|
||||
.expect("list for machine")
|
||||
.iter()
|
||||
.any(|x| x.id == session_id),
|
||||
"live session must be in the machine list"
|
||||
);
|
||||
|
||||
// Soft-delete.
|
||||
let affected = soft_delete_session(&pool, session_id)
|
||||
.await
|
||||
.expect("soft delete session");
|
||||
assert_eq!(affected, 1, "exactly one live session flips to deleted");
|
||||
|
||||
// Excluded from get and lists.
|
||||
assert!(
|
||||
get_session(&pool, session_id)
|
||||
.await
|
||||
.expect("get after delete")
|
||||
.is_none(),
|
||||
"soft-deleted session must NOT be gettable"
|
||||
);
|
||||
assert!(
|
||||
!get_sessions_for_machine(&pool, machine_id)
|
||||
.await
|
||||
.expect("list after delete")
|
||||
.iter()
|
||||
.any(|x| x.id == session_id),
|
||||
"soft-deleted session must NOT be in the machine list"
|
||||
);
|
||||
|
||||
// Row retained with deleted_at set.
|
||||
let deleted_at: Option<DateTime<Utc>> =
|
||||
sqlx::query_scalar("SELECT deleted_at FROM connect_sessions WHERE id = $1")
|
||||
.bind(session_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("session row still present");
|
||||
assert!(deleted_at.is_some(), "session row retained with deleted_at");
|
||||
|
||||
// Re-purge is a no-op.
|
||||
let again = soft_delete_session(&pool, session_id)
|
||||
.await
|
||||
.expect("re-soft-delete");
|
||||
assert_eq!(
|
||||
again, 0,
|
||||
"re-purge of an already-deleted session affects 0 rows"
|
||||
);
|
||||
|
||||
// Cleanup (cascade from the machine removes the session row too).
|
||||
let _ = sqlx::query("DELETE FROM connect_machines WHERE id = $1")
|
||||
.bind(machine_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
94
server/src/db/sites.rs
Normal file
94
server/src/db/sites.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
//! Site database operations (SPEC-016 zero-touch enrollment).
|
||||
//!
|
||||
//! Backs the `connect_sites` table (migration 010): the relational anchor a
|
||||
//! per-site enrollment key hangs off and the `/api/enroll` flow resolves by
|
||||
//! `site_code`. See the migration header for why this table exists (the prior
|
||||
//! schema modeled "site" only as a free-text column on `connect_machines`).
|
||||
//!
|
||||
//! All queries use runtime `sqlx::query()` / `sqlx::query_as()` per the codebase
|
||||
//! convention (no compile-time `query!` macros, no `.sqlx` offline cache).
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Site record from the database.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Site {
|
||||
pub id: Uuid,
|
||||
pub site_code: String,
|
||||
pub display_name: Option<String>,
|
||||
pub company: Option<String>,
|
||||
pub tenant_id: Option<Uuid>,
|
||||
/// RESERVED for future per-site enrollment POLICY work (SPEC-016 §out-of-scope).
|
||||
/// Not enforced in Phase A.
|
||||
pub enrollment_policy: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Resolve a site by its operator-facing `site_code`, scoped to the given tenant.
|
||||
///
|
||||
/// Tenant scoping uses the same default-tenant fold as the unique index so the
|
||||
/// lookup matches the uniqueness guarantee: `(COALESCE(tenant_id, default),
|
||||
/// site_code)`. Returns `None` if no site with that code exists in the tenant.
|
||||
pub async fn get_site_by_code(
|
||||
pool: &PgPool,
|
||||
site_code: &str,
|
||||
tenant_id: Uuid,
|
||||
) -> Result<Option<Site>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Site>(
|
||||
r#"
|
||||
SELECT id, site_code, display_name, company, tenant_id, enrollment_policy, created_at
|
||||
FROM connect_sites
|
||||
WHERE site_code = $1
|
||||
AND COALESCE(tenant_id, '00000000-0000-0000-0000-000000000001'::uuid) = $2
|
||||
"#,
|
||||
)
|
||||
.bind(site_code)
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch a site by its primary-key UUID.
|
||||
pub async fn get_site_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Site>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Site>(
|
||||
r#"
|
||||
SELECT id, site_code, display_name, company, tenant_id, enrollment_policy, created_at
|
||||
FROM connect_sites
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Insert a new site, returning the created row.
|
||||
///
|
||||
/// `tenant_id` is `None`-tolerant and resolved via `db::tenancy::current_tenant_id()`
|
||||
/// at the call site. Errors with a unique-violation if `(tenant, site_code)` already
|
||||
/// exists (the caller maps that to a 409).
|
||||
#[allow(dead_code)] // Wired by the site-admin API (dashboard site CRUD); Phase A exposes key rotation, not site CRUD.
|
||||
pub async fn insert_site(
|
||||
pool: &PgPool,
|
||||
site_code: &str,
|
||||
display_name: Option<&str>,
|
||||
company: Option<&str>,
|
||||
tenant_id: Option<Uuid>,
|
||||
) -> Result<Site, sqlx::Error> {
|
||||
sqlx::query_as::<_, Site>(
|
||||
r#"
|
||||
INSERT INTO connect_sites (site_code, display_name, company, tenant_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, site_code, display_name, company, tenant_id, enrollment_policy, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(site_code)
|
||||
.bind(display_name)
|
||||
.bind(company)
|
||||
.bind(tenant_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
@@ -21,7 +21,7 @@ pub mod proto {
|
||||
use anyhow::Result;
|
||||
use axum::http::{HeaderValue, Method};
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Json, Path, Query, Request, State},
|
||||
extract::{ConnectInfo, Json, Path, Request, State},
|
||||
http::StatusCode,
|
||||
middleware::{self as axum_middleware, Next},
|
||||
response::IntoResponse,
|
||||
@@ -57,6 +57,16 @@ const SPA_DIR: &str = "static/app";
|
||||
/// non-WS, non-asset GET so `BrowserRouter` deep links (`/machines`,
|
||||
/// `/sessions`, `/login`) survive a hard reload.
|
||||
const SPA_INDEX: &str = "static/app/index.html";
|
||||
|
||||
/// How long an OFFLINE persistent session may sit idle before the reaper removes
|
||||
/// it (v2-stable-identity Task 4). Measured on the session's monotonic
|
||||
/// `last_heartbeat_instant`. Ten minutes is well past the agent's 30s heartbeat /
|
||||
/// 90s timeout, so only genuinely-gone machines age out — a brief reconnect blip
|
||||
/// never reaps a real session.
|
||||
const PERSISTENT_SESSION_TTL: std::time::Duration = std::time::Duration::from_secs(10 * 60);
|
||||
|
||||
/// Cadence of the stale-session reaper sweep (v2-stable-identity Task 4).
|
||||
const PERSISTENT_SESSION_REAP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
use metrics::SharedMetrics;
|
||||
use prometheus_client::registry::Registry;
|
||||
use support_codes::{CodeValidation, CreateCodeRequest, SupportCode, SupportCodeManager};
|
||||
@@ -245,9 +255,35 @@ async fn main() -> Result<()> {
|
||||
"Reconciling {} managed session(s) from database",
|
||||
machines.len()
|
||||
);
|
||||
// Machines bound to an active `cak_` key. For these the key→machine
|
||||
// binding is authoritative (SPEC-004 Task 2), so we must NOT index a
|
||||
// restored keyed session by its stored `machine_uid`: doing so would
|
||||
// let an un-keyed agent spoofing that uid reattach the keyed machine's
|
||||
// offline session after a restart. The connect path never writes a uid
|
||||
// for keyed agents, so a non-NULL uid on a keyed row can only come from
|
||||
// a legacy pre-keying row — but close the gap regardless. On query
|
||||
// failure, fail closed (treat all machines as keyed: index none by uid)
|
||||
// rather than risk indexing a keyed machine.
|
||||
let keyed_ids = match db::agent_keys::keyed_machine_ids(db.pool()).await {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Could not load keyed-machine set; suppressing uid reattach index for all restored machines: {}",
|
||||
e
|
||||
);
|
||||
machines.iter().map(|m| m.id).collect()
|
||||
}
|
||||
};
|
||||
for machine in machines {
|
||||
// Keyed machines get None (uid reattach disabled); un-keyed
|
||||
// machines keep their stored uid for legitimate reattach.
|
||||
let restore_uid = if keyed_ids.contains(&machine.id) {
|
||||
None
|
||||
} else {
|
||||
machine.machine_uid.as_deref()
|
||||
};
|
||||
sessions
|
||||
.restore_offline_machine(&machine.agent_id, &machine.hostname)
|
||||
.restore_offline_machine(&machine.agent_id, &machine.hostname, restore_uid)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -257,6 +293,37 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn the stale-session reaper (v2-stable-identity Task 4). Periodically
|
||||
// removes OFFLINE persistent sessions that have aged out past
|
||||
// PERSISTENT_SESSION_TTL with no viewer attached, purging both the agent_id and
|
||||
// machine_uid indexes via SessionManager::remove_session. Spawned AFTER the
|
||||
// startup restore so restored-offline rows are present (they age out once they
|
||||
// pass the TTL, per plan). The task holds only a clone of the SessionManager
|
||||
// (Arc-backed internally), so it shares the live session map.
|
||||
{
|
||||
let reaper_sessions = sessions.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(PERSISTENT_SESSION_REAP_INTERVAL);
|
||||
// Skip the immediate first tick so we do not sweep before the server is
|
||||
// even serving; the first real sweep happens one interval in.
|
||||
interval.tick().await;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let reaped = reaper_sessions
|
||||
.reap_stale_persistent(PERSISTENT_SESSION_TTL)
|
||||
.await;
|
||||
if reaped > 0 {
|
||||
info!("Stale-session reaper removed {} offline session(s)", reaped);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
info!(
|
||||
"Stale-session reaper started (ttl {}s, sweep every {}s)",
|
||||
PERSISTENT_SESSION_TTL.as_secs(),
|
||||
PERSISTENT_SESSION_REAP_INTERVAL.as_secs()
|
||||
);
|
||||
|
||||
// Agent API key for persistent agents (optional)
|
||||
let agent_api_key = std::env::var("AGENT_API_KEY").ok();
|
||||
if let Some(ref key) = agent_api_key {
|
||||
@@ -381,13 +448,20 @@ async fn main() -> Result<()> {
|
||||
)),
|
||||
)
|
||||
.route("/api/codes/:code/cancel", post(cancel_code))
|
||||
// Zero-touch enrollment (SPEC-016). PUBLIC: no JWT — the per-site enrollment
|
||||
// key in the body is the gate, and the handler applies its own
|
||||
// per-(site_code, IP) rate limit / lockout (defense-in-depth). Mounted with
|
||||
// the other public API routes.
|
||||
.route("/api/enroll", post(api::enroll::enroll))
|
||||
// WebSocket endpoints
|
||||
.route("/ws/agent", get(relay::agent_ws_handler))
|
||||
.route("/ws/viewer", get(relay::viewer_ws_handler))
|
||||
// REST API - Sessions
|
||||
.route("/api/sessions", get(list_sessions))
|
||||
.route("/api/sessions/:id", get(get_session))
|
||||
.route("/api/sessions/:id", delete(disconnect_session))
|
||||
// DELETE: live-only disconnect by default; `?purge=true` soft-deletes +
|
||||
// removes in-memory + audits (admin-only). Task 5 (api::removal).
|
||||
.route("/api/sessions/:id", delete(api::removal::remove_session))
|
||||
// Session-scoped viewer-token minting (dashboard JWT; bound to one session)
|
||||
.route(
|
||||
"/api/sessions/:id/viewer-token",
|
||||
@@ -395,8 +469,20 @@ async fn main() -> Result<()> {
|
||||
)
|
||||
// REST API - Machines
|
||||
.route("/api/machines", get(list_machines))
|
||||
// Bulk operator removal (admin-only). Registered before the `:agent_id`
|
||||
// routes; matchit (axum 0.7) prefers the static `bulk-remove` segment over
|
||||
// the `:agent_id` capture, so it never shadows a real agent_id. Task 5.
|
||||
.route(
|
||||
"/api/machines/bulk-remove",
|
||||
post(api::removal::bulk_remove_machines),
|
||||
)
|
||||
.route("/api/machines/:agent_id", get(get_machine))
|
||||
.route("/api/machines/:agent_id", delete(delete_machine))
|
||||
// DELETE: legacy hard-delete by default; `?purge=true` soft-deletes +
|
||||
// removes in-memory + audits (admin-only). Task 5 (api::removal).
|
||||
.route(
|
||||
"/api/machines/:agent_id",
|
||||
delete(api::removal::remove_machine),
|
||||
)
|
||||
.route("/api/machines/:agent_id/history", get(get_machine_history))
|
||||
.route(
|
||||
"/api/machines/:agent_id/update",
|
||||
@@ -417,6 +503,18 @@ async fn main() -> Result<()> {
|
||||
"/api/machines/:agent_id/keys/:key_id",
|
||||
delete(api::machine_keys::revoke_key),
|
||||
)
|
||||
// Per-site enrollment key administration (SPEC-016, admin-only / JWT).
|
||||
// Rotate regenerates the cek_ secret + fingerprint (old installers can no
|
||||
// longer enroll new machines); GET returns the current non-secret
|
||||
// fingerprint/version. Both gated by the AdminUser extractor.
|
||||
.route(
|
||||
"/api/sites/:id/enrollment-key",
|
||||
get(api::sites::get_enrollment_key),
|
||||
)
|
||||
.route(
|
||||
"/api/sites/:id/enrollment-key/rotate",
|
||||
post(api::sites::rotate_enrollment_key),
|
||||
)
|
||||
// REST API - Releases and Version
|
||||
.route("/api/version", get(api::releases::get_version)) // No auth - for agent polling
|
||||
.route("/api/releases", get(api::releases::list_releases))
|
||||
@@ -673,28 +771,6 @@ async fn get_session(
|
||||
Ok(Json(api::SessionInfo::from(session)))
|
||||
}
|
||||
|
||||
async fn disconnect_session(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let session_id = match uuid::Uuid::parse_str(&id) {
|
||||
Ok(id) => id,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid session ID"),
|
||||
};
|
||||
|
||||
if state
|
||||
.sessions
|
||||
.disconnect_session(session_id, "Disconnected by administrator")
|
||||
.await
|
||||
{
|
||||
info!("Session {} disconnected by admin", session_id);
|
||||
(StatusCode::OK, "Session disconnected")
|
||||
} else {
|
||||
(StatusCode::NOT_FOUND, "Session not found")
|
||||
}
|
||||
}
|
||||
|
||||
// Machine API handlers
|
||||
|
||||
async fn list_machines(
|
||||
@@ -769,89 +845,6 @@ async fn get_machine_history(
|
||||
Ok(Json(history))
|
||||
}
|
||||
|
||||
async fn delete_machine(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Path(agent_id): Path<String>,
|
||||
Query(params): Query<api::DeleteMachineParams>,
|
||||
) -> Result<Json<api::DeleteMachineResponse>, (StatusCode, &'static str)> {
|
||||
let db = state
|
||||
.db
|
||||
.as_ref()
|
||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||
|
||||
// Get machine first
|
||||
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
|
||||
|
||||
// Export history if requested
|
||||
let history = if params.export {
|
||||
let sessions = db::sessions::get_sessions_for_machine(db.pool(), machine.id)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||
let events = db::events::get_events_for_machine(db.pool(), machine.id)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||
|
||||
Some(api::MachineHistory {
|
||||
machine: api::MachineInfo::from(machine.clone()),
|
||||
sessions: sessions.into_iter().map(api::SessionRecord::from).collect(),
|
||||
events: events.into_iter().map(api::EventRecord::from).collect(),
|
||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Send uninstall command if requested and agent is online
|
||||
let mut uninstall_sent = false;
|
||||
if params.uninstall {
|
||||
// Find session for this agent
|
||||
if let Some(session) = state.sessions.get_session_by_agent(&agent_id).await {
|
||||
if session.is_online {
|
||||
uninstall_sent = state
|
||||
.sessions
|
||||
.send_admin_command(
|
||||
session.id,
|
||||
proto::AdminCommandType::AdminUninstall,
|
||||
"Deleted by administrator",
|
||||
)
|
||||
.await;
|
||||
if uninstall_sent {
|
||||
info!("Sent uninstall command to agent {}", agent_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from session manager
|
||||
state.sessions.remove_agent(&agent_id).await;
|
||||
|
||||
// Delete from database (cascades to sessions and events)
|
||||
db::machines::delete_machine(db.pool(), &agent_id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to delete machine",
|
||||
)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"Deleted machine {} (uninstall_sent: {})",
|
||||
agent_id, uninstall_sent
|
||||
);
|
||||
|
||||
Ok(Json(api::DeleteMachineResponse {
|
||||
success: true,
|
||||
message: format!("Machine {} deleted", machine.hostname),
|
||||
uninstall_sent,
|
||||
history,
|
||||
}))
|
||||
}
|
||||
|
||||
// Update trigger request
|
||||
#[derive(Deserialize)]
|
||||
struct TriggerUpdateRequest {
|
||||
|
||||
@@ -77,6 +77,19 @@ pub const CODE_VALIDATE_MAX_FAILURES: u32 = 10;
|
||||
/// Support-code validate: how long an IP stays locked out once tripped.
|
||||
pub const CODE_VALIDATE_LOCKOUT: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
/// Enroll (`POST /api/enroll`, SPEC-016): window length.
|
||||
pub const ENROLL_WINDOW: Duration = Duration::from_secs(60);
|
||||
/// Enroll: max requests per window per `(site_code, IP)`. A zero-touch site push
|
||||
/// drives N machines through enroll near-simultaneously, so this is generous
|
||||
/// (mass-deploy friendly) while still capping a runaway loop. Defense-in-depth: the
|
||||
/// 256-bit enrollment key is the load-bearing gate, not this cap.
|
||||
pub const ENROLL_MAX_PER_WINDOW: u32 = 60;
|
||||
/// Enroll: consecutive FAILED enroll attempts (bad/inactive key, unknown site) from
|
||||
/// one `(site_code, IP)` that trip the lockout.
|
||||
pub const ENROLL_MAX_FAILURES: u32 = 20;
|
||||
/// Enroll: how long a `(site_code, IP)` stays locked out once tripped.
|
||||
pub const ENROLL_LOCKOUT: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
/// Hard cap on the number of distinct IPs tracked by any single limiter map.
|
||||
/// Prevents an IP-rotating attacker from growing memory without bound. When the
|
||||
/// cap is hit, the oldest-windowed entries are pruned. Generous for a real MSP
|
||||
@@ -260,6 +273,150 @@ impl FailureLockout {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Composite-key limiter for enrollment (keyed by (site_code, IP)) — SPEC-016
|
||||
// ============================================================================
|
||||
//
|
||||
// The login / change-password / code-validate limiters above key purely on IP.
|
||||
// SPEC-016 §3 wants the enroll defense keyed on `(site_code, source-IP)` so a noisy
|
||||
// site push from one office IP cannot lock out a different site enrolling from the
|
||||
// same egress IP. Rather than overload the IP-only maps, this is a small dedicated
|
||||
// composite-key limiter + lockout. It is invoked from the enroll HANDLER (not a
|
||||
// `from_fn` layer) because the `site_code` lives in the JSON body, which a
|
||||
// pre-handler middleware cannot read without consuming it. Documented as
|
||||
// defense-in-depth: the 256-bit enrollment key is the real gate.
|
||||
|
||||
/// Composite limiter key: the site_code and the real client IP.
|
||||
type EnrollKey = (String, IpAddr);
|
||||
|
||||
/// Per-`(site_code, IP)` fixed-window limiter + consecutive-failure lockout.
|
||||
///
|
||||
/// Combines both protections behind one lock-guarded map so the enroll handler
|
||||
/// makes a single allow/deny decision and reports success/failure into the same
|
||||
/// structure. Self-pruning and size-capped, like the IP-only limiters.
|
||||
#[derive(Clone)]
|
||||
pub struct EnrollLimiter {
|
||||
inner: std::sync::Arc<Mutex<HashMap<EnrollKey, EnrollEntry>>>,
|
||||
max_per_window: u32,
|
||||
window: Duration,
|
||||
max_failures: u32,
|
||||
cooldown: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct EnrollEntry {
|
||||
window_started: Instant,
|
||||
count: u32,
|
||||
failures: u32,
|
||||
locked_until: Option<Instant>,
|
||||
last_seen: Instant,
|
||||
}
|
||||
|
||||
impl EnrollLimiter {
|
||||
pub fn new(
|
||||
max_per_window: u32,
|
||||
window: Duration,
|
||||
max_failures: u32,
|
||||
cooldown: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||
max_per_window,
|
||||
window,
|
||||
max_failures,
|
||||
cooldown,
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_now() -> EnrollEntry {
|
||||
let now = Instant::now();
|
||||
EnrollEntry {
|
||||
window_started: now,
|
||||
count: 0,
|
||||
failures: 0,
|
||||
locked_until: None,
|
||||
last_seen: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit one enroll attempt for `(site_code, ip)`. Returns `true` if allowed
|
||||
/// (and counts it). Returns `false` if the key is currently locked out OR over
|
||||
/// the per-window request cap. Clock injected for tests.
|
||||
fn check_at(&self, site_code: &str, ip: IpAddr, now: Instant) -> bool {
|
||||
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
if map.len() >= MAX_TRACKED_IPS {
|
||||
let window = self.window;
|
||||
let cooldown = self.cooldown;
|
||||
map.retain(|_, e| {
|
||||
e.locked_until.map(|u| now < u).unwrap_or(false)
|
||||
|| now.duration_since(e.window_started) < window
|
||||
|| now.duration_since(e.last_seen) < cooldown
|
||||
});
|
||||
}
|
||||
|
||||
let key = (site_code.to_string(), ip);
|
||||
let e = map.entry(key).or_insert_with(Self::entry_now);
|
||||
e.last_seen = now;
|
||||
|
||||
// Lockout takes precedence.
|
||||
if let Some(until) = e.locked_until {
|
||||
if now < until {
|
||||
return false;
|
||||
}
|
||||
// Cooldown elapsed — clear it for a fresh start.
|
||||
e.locked_until = None;
|
||||
e.failures = 0;
|
||||
}
|
||||
|
||||
// Roll the fixed window forward if elapsed.
|
||||
if now.duration_since(e.window_started) >= self.window {
|
||||
e.window_started = now;
|
||||
e.count = 0;
|
||||
}
|
||||
|
||||
if e.count >= self.max_per_window {
|
||||
false
|
||||
} else {
|
||||
e.count += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit one enroll attempt (real clock).
|
||||
pub fn check(&self, site_code: &str, ip: IpAddr) -> bool {
|
||||
self.check_at(site_code, ip, Instant::now())
|
||||
}
|
||||
|
||||
fn record_failure_at(&self, site_code: &str, ip: IpAddr, now: Instant) {
|
||||
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = (site_code.to_string(), ip);
|
||||
let e = map.entry(key).or_insert_with(Self::entry_now);
|
||||
e.last_seen = now;
|
||||
e.failures = e.failures.saturating_add(1);
|
||||
if e.failures >= self.max_failures {
|
||||
e.locked_until = Some(now + self.cooldown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a FAILED enroll attempt (bad key / unknown site) for the key,
|
||||
/// tripping the lockout once the streak reaches `max_failures`.
|
||||
pub fn record_failure(&self, site_code: &str, ip: IpAddr) {
|
||||
self.record_failure_at(site_code, ip, Instant::now());
|
||||
}
|
||||
|
||||
/// Record a SUCCESSFUL enroll for the key, resetting its failure streak.
|
||||
pub fn record_success(&self, site_code: &str, ip: IpAddr) {
|
||||
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = (site_code.to_string(), ip);
|
||||
if let Some(e) = map.get_mut(&key) {
|
||||
e.failures = 0;
|
||||
e.locked_until = None;
|
||||
e.last_seen = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shared rate-limit state (lives in AppState)
|
||||
// ============================================================================
|
||||
@@ -275,6 +432,9 @@ pub struct RateLimitState {
|
||||
pub code_validate: RateLimiter,
|
||||
/// Per-IP lockout on repeated failed code validations (brute-force defense).
|
||||
pub code_validate_lockout: FailureLockout,
|
||||
/// `POST /api/enroll` (SPEC-016): per-`(site_code, IP)` request cap +
|
||||
/// consecutive-failure lockout. Invoked from the enroll handler.
|
||||
pub enroll: EnrollLimiter,
|
||||
}
|
||||
|
||||
impl RateLimitState {
|
||||
@@ -290,6 +450,12 @@ impl RateLimitState {
|
||||
CODE_VALIDATE_MAX_FAILURES,
|
||||
CODE_VALIDATE_LOCKOUT,
|
||||
),
|
||||
enroll: EnrollLimiter::new(
|
||||
ENROLL_MAX_PER_WINDOW,
|
||||
ENROLL_WINDOW,
|
||||
ENROLL_MAX_FAILURES,
|
||||
ENROLL_LOCKOUT,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -524,4 +690,51 @@ mod tests {
|
||||
assert!(lockout.is_locked_at(ip(8), t0));
|
||||
assert!(!lockout.is_locked_at(ip(9), t0)); // ip9 unaffected
|
||||
}
|
||||
|
||||
// -- EnrollLimiter (composite (site_code, IP) key) --------------------------
|
||||
|
||||
#[test]
|
||||
fn enroll_window_allows_up_to_cap_then_blocks() {
|
||||
let lim = EnrollLimiter::new(2, Duration::from_secs(60), 100, Duration::from_secs(600));
|
||||
let t0 = Instant::now();
|
||||
assert!(lim.check_at("SITE-A", ip(1), t0)); // 1
|
||||
assert!(lim.check_at("SITE-A", ip(1), t0)); // 2
|
||||
assert!(!lim.check_at("SITE-A", ip(1), t0)); // over cap
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enroll_is_keyed_by_site_and_ip() {
|
||||
let lim = EnrollLimiter::new(1, Duration::from_secs(60), 100, Duration::from_secs(600));
|
||||
let t0 = Instant::now();
|
||||
assert!(lim.check_at("SITE-A", ip(1), t0));
|
||||
assert!(!lim.check_at("SITE-A", ip(1), t0)); // same key over cap
|
||||
// Different site, same IP -> independent bucket.
|
||||
assert!(lim.check_at("SITE-B", ip(1), t0));
|
||||
// Same site, different IP -> independent bucket.
|
||||
assert!(lim.check_at("SITE-A", ip(2), t0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enroll_lockout_trips_after_failures_and_blocks_check() {
|
||||
let lim = EnrollLimiter::new(100, Duration::from_secs(60), 3, Duration::from_secs(600));
|
||||
let t0 = Instant::now();
|
||||
lim.record_failure_at("SITE-A", ip(1), t0);
|
||||
lim.record_failure_at("SITE-A", ip(1), t0);
|
||||
// Not yet tripped: a check still admits.
|
||||
assert!(lim.check_at("SITE-A", ip(1), t0));
|
||||
lim.record_failure_at("SITE-A", ip(1), t0); // 3rd -> trips
|
||||
// Now locked out: check denies even though under the request cap.
|
||||
assert!(!lim.check_at("SITE-A", ip(1), t0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enroll_success_resets_failure_streak() {
|
||||
let lim = EnrollLimiter::new(100, Duration::from_secs(60), 2, Duration::from_secs(600));
|
||||
let t0 = Instant::now();
|
||||
lim.record_failure_at("SITE-A", ip(1), t0);
|
||||
lim.record_success("SITE-A", ip(1)); // reset
|
||||
lim.record_failure_at("SITE-A", ip(1), t0);
|
||||
// Only one failure since reset -> not locked.
|
||||
assert!(lim.check_at("SITE-A", ip(1), t0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,13 @@ pub struct AgentParams {
|
||||
/// API key for persistent (managed) agents
|
||||
#[serde(default)]
|
||||
api_key: Option<String>,
|
||||
/// Deterministic, recomputable hardware identity reported by the agent
|
||||
/// (v2-stable-identity Task 1; `transport/websocket.rs`). Used to dedup
|
||||
/// registrations for UN-KEYED agents only — see the security note where it is
|
||||
/// consumed below. CLIENT-SUPPLIED and therefore spoofable: it is never used to
|
||||
/// override a `cak_` key's authoritative machine binding.
|
||||
#[serde(default)]
|
||||
machine_uid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -110,6 +117,12 @@ pub async fn agent_ws_handler(
|
||||
.unwrap_or_else(|| agent_id.clone());
|
||||
let support_code = params.support_code.clone();
|
||||
let api_key = params.api_key.clone();
|
||||
// CLIENT-SUPPLIED machine_uid (v2-stable-identity Task 1). Spoofable; see the
|
||||
// security gate below. It is used for dedup ONLY on the un-keyed path; for
|
||||
// `cak_`-keyed agents it is suppressed so the key's machine binding stays
|
||||
// authoritative. `is_keyed_agent` records which path authenticated us.
|
||||
let claimed_machine_uid = params.machine_uid.clone();
|
||||
let mut is_keyed_agent = false;
|
||||
// Real client IP via the trusted-proxy-aware extractor (shared with the rate
|
||||
// limiter and audit log). Behind NPM on loopback, `addr.ip()` is 127.0.0.1;
|
||||
// this resolves the actual remote agent IP from forwarding headers when the
|
||||
@@ -268,6 +281,11 @@ pub async fn agent_ws_handler(
|
||||
);
|
||||
}
|
||||
agent_id = trusted_agent_id;
|
||||
// KEYED agent: the key→machine binding is AUTHORITATIVE. Suppress
|
||||
// the client-claimed machine_uid for dedup (below) so a valid key
|
||||
// for machine X can never repoint machine Y's row by claiming Y's
|
||||
// uid. Dedup for this agent stays on its authenticated agent_id.
|
||||
is_keyed_agent = true;
|
||||
info!(
|
||||
"Agent {} from {} authenticated via per-agent key",
|
||||
agent_id, client_ip
|
||||
@@ -324,6 +342,20 @@ pub async fn agent_ws_handler(
|
||||
let support_codes = state.support_codes.clone();
|
||||
let db = state.db.clone();
|
||||
|
||||
// SECURITY GATE for machine_uid dedup (v2-stable-identity Task 2):
|
||||
// - KEYED (`cak_`) agents: dedup stays on the authenticated agent_id; the
|
||||
// claimed uid is DROPPED so it cannot override the key's machine binding.
|
||||
// - UN-KEYED agents (support-code, deprecated shared key, no auth-identity):
|
||||
// the claimed uid is a dedup-only correctness aid and is passed through.
|
||||
// A client-asserted machine_uid is spoofable, so it must never be the dedup key
|
||||
// for a keyed agent. This is the only place the distinction is enforced.
|
||||
let effective_machine_uid = if is_keyed_agent {
|
||||
None
|
||||
} else {
|
||||
// Treat an empty/whitespace-only claim as absent.
|
||||
claimed_machine_uid.filter(|u| !u.trim().is_empty())
|
||||
};
|
||||
|
||||
// Bounded relay: cap inbound frame/message size before the socket is upgraded
|
||||
// so oversized agent frames are rejected by the WS layer, never `to_vec()`'d
|
||||
// and broadcast. (WS-OOM HIGH.)
|
||||
@@ -340,6 +372,7 @@ pub async fn agent_ws_handler(
|
||||
agent_id,
|
||||
agent_name,
|
||||
support_code,
|
||||
effective_machine_uid,
|
||||
Some(client_ip),
|
||||
)
|
||||
}))
|
||||
@@ -548,6 +581,10 @@ async fn handle_agent_connection(
|
||||
agent_id: String,
|
||||
agent_name: String,
|
||||
support_code: Option<String>,
|
||||
// Dedup identity for the UN-KEYED path only (already security-gated to `None`
|
||||
// for `cak_`-keyed agents by `agent_ws_handler`). Drives both the in-memory
|
||||
// session reattach key and the DB `ON CONFLICT (machine_uid)` upsert.
|
||||
machine_uid: Option<String>,
|
||||
client_ip: Option<std::net::IpAddr>,
|
||||
) {
|
||||
info!(
|
||||
@@ -581,14 +618,27 @@ async fn handle_agent_connection(
|
||||
// Persistent agents (no support code) keep their session when disconnected
|
||||
let is_persistent = support_code.is_none();
|
||||
let (session_id, frame_tx, mut input_rx) = sessions
|
||||
.register_agent(agent_id.clone(), agent_name.clone(), is_persistent)
|
||||
.register_agent(
|
||||
agent_id.clone(),
|
||||
agent_name.clone(),
|
||||
is_persistent,
|
||||
machine_uid.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
info!("Session created: {} (agent in idle mode)", session_id);
|
||||
|
||||
// Database: upsert machine and create session record
|
||||
let _machine_id = if let Some(ref db) = db {
|
||||
match db::machines::upsert_machine(db.pool(), &agent_id, &agent_name, is_persistent).await {
|
||||
match db::machines::upsert_machine(
|
||||
db.pool(),
|
||||
&agent_id,
|
||||
&agent_name,
|
||||
is_persistent,
|
||||
machine_uid.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(machine) => {
|
||||
// Create session record
|
||||
let _ = db::sessions::create_session(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
63
session-logs/2026-06-03-session.md
Normal file
63
session-logs/2026-06-03-session.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Session Log — 2026-06-03 — GuruConnect SPEC-018 review validation + fixes
|
||||
|
||||
## User
|
||||
- **User:** Mike Swanson (mike)
|
||||
- **Machine:** GURU-5070
|
||||
- **Role:** admin
|
||||
|
||||
---
|
||||
|
||||
## Session Summary
|
||||
|
||||
Mike forwarded a thorough external code review of GuruConnect SPEC-018 Phase 1 (managed agent as LocalSystem service host; merge 11af9df) performed by a Grok reviewer persona and written to `D:\GrokTools\guru-connect-review-SPEC018.md`. Task: look over the project and validate.
|
||||
|
||||
Independently validated the two flagged bugs and Issue 6 by reading the actual code (not just relaying). Confirmed all three as real. Added a refinement the review missed: the non-functional managed fallback (Bug 1) *does* still work for a deprecated legacy-`api_key` managed binary, but is broken specifically for the modern SPEC-016 enrollment path — sharpening the fix.
|
||||
|
||||
Copied the review into the project at `reports/2026-06-03-spec018-review.md`, claimed a coord lock on `guruconnect`, created branch `fix/spec018-review-bugs`, and had the Coding Agent implement the three fixes. `cargo check -p guruconnect --target x86_64-pc-windows-msvc` passes clean (no errors/warnings). Filed Gitea issue #8 for the deferred lower-severity items. Changes remain uncommitted on the branch pending Mike's PR-vs-direct-to-main decision.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **Validated, did not rubber-stamp.** Read the code at each cited location to confirm Bug 1 (main.rs:496), Bug 2 (config.rs:392), Issue 6 (startup.rs transmute) before acting.
|
||||
- **Bug 1 fix = remove the fallback, surface an elevation error** (rather than build a degraded fallback). Matches `install_managed_service` docs; the managed model is elevated-install. The deprecated legacy-key edge case also errors now — acceptable and honest.
|
||||
- **Bug 2 fix = read persisted agent_id from the TOML first**, generate only if absent — stops agent_id churn on every restart while keeping machine_uid/cak_ as the stable keys.
|
||||
- **Issue 6 fix = typed `HKEY` from the windows crate** (no `HANDLE`+transmute). `install.rs` was already typed (no change).
|
||||
- **Deferred Issues 3/4/5/7/8** (hot-path unwraps, panic-guard scope, nits) to Gitea #8 — lower severity, follow-ups.
|
||||
- **No commit yet** — branch held for human review of diffs + PR-vs-main choice.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
**In submodule `projects/msp-tools/guru-connect` (branch `fix/spec018-review-bugs`, UNCOMMITTED):**
|
||||
- `agent/src/config.rs` — added `Config::persisted_agent_id()`; embedded branch now `agent_id: Self::persisted_agent_id().unwrap_or_else(generate_agent_id)`; corrected comment.
|
||||
- `agent/src/main.rs` — `run_permanent_agent_managed`: removed `run_agent_mode(None)` fallback, now `error!` + `Err(...)` requiring elevation; updated doc/inline comments.
|
||||
- `agent/src/startup.rs` — replaced `transmute::<HANDLE,HKEY>` with `HKEY::default()` + `&mut hkey`; added SAFETY comments.
|
||||
- Created `reports/2026-06-03-spec018-review.md` (copy of the external review).
|
||||
- Stray untracked `tmp-spec018.diff` left untouched (from the Grok session).
|
||||
|
||||
---
|
||||
|
||||
## Commands & Outputs
|
||||
|
||||
- Validation greps/reads: `run_permanent_agent_managed` at main.rs:482, fallback at :496; `Config::load` embedded branch config.rs:382-409 (`agent_id: generate_agent_id()` unconditional, save() never read back); `resolve_agent_credential` main.rs:515 (load_cak permission_denied guard / enroll C1 read-back).
|
||||
- `cargo check -p guruconnect --target x86_64-pc-windows-msvc` → Finished clean, no warnings from the changes.
|
||||
- Coord lock id `0cfd6269-4548-46d4-8436-c829e42f79d8` (guruconnect / agent/src, ttl 2h, GURU-5070/claude-main).
|
||||
|
||||
---
|
||||
|
||||
## Pending / Incomplete Tasks
|
||||
|
||||
- **Awaiting Mike's decision:** push branch + open PR (recommended, matches SPEC-018 PR #7 convention) vs. commit straight to `main`.
|
||||
- On decision: commit the 3 fixes + the review report, push, (PR/merge), then bump the parent-repo submodule pointer on next `/sync`, update the coord `guruconnect` component, and release lock `0cfd6269`.
|
||||
- Deferred hardening: Gitea **guru-connect#8** (Issues 3/4/5/7/8).
|
||||
|
||||
---
|
||||
|
||||
## Reference Information
|
||||
|
||||
- External review: `D:\GrokTools\guru-connect-review-SPEC018.md` → copied to `reports/2026-06-03-spec018-review.md`.
|
||||
- Branch: `fix/spec018-review-bugs` (off `main` @ 11af9df).
|
||||
- Gitea issue: https://git.azcomputerguru.com/azcomputerguru/guru-connect/issues/8
|
||||
- Files: `agent/src/{config.rs,main.rs,startup.rs}`.
|
||||
@@ -527,3 +527,60 @@ Reference: SPEC-002 §5; `agent/src/encoder/raw.rs` (salvaged), `proto/guruconne
|
||||
- **Rate limiting:** hammer `/api/auth/login` and the code-validate route → confirm throttling/lockout.
|
||||
- **Migrations:** fresh DB applies the v2 migrations cleanly; `_sqlx_migrations` consistent; `tenant_id`
|
||||
populated with the default tenant.
|
||||
|
||||
---
|
||||
|
||||
## Task 9 [PROPOSED 2026-06-01 — provisioning model = TOFU auto-enroll, chosen by Mike]: `cak_` auto-enroll provisioning + shared-key retirement
|
||||
|
||||
> Context: Task 2 built the SERVER `cak_` machinery (mint/SHA-256 hash/verify in `auth/agent_keys.rs`,
|
||||
> relay validation in `validate_agent_api_key`, admin issuance `POST /api/machines/:id/keys`). What's
|
||||
> missing is how an AGENT obtains and uses a `cak_` — today agents still carry the deprecated shared
|
||||
> `AGENT_API_KEY`, so `connect_agent_keys` is empty and the relay logs the DEPRECATED-shared-key warning
|
||||
> for every agent. This task closes that with **trust-on-first-use auto-enroll** so the shared key can be
|
||||
> retired (unblocks task list #5). NOTE: the agent already presents whatever is in its `api_key` slot and
|
||||
> the relay auto-detects `cak_` vs shared — so a `cak_`-keyed agent needs **no change to its auth call**,
|
||||
> only a way to *receive*, *persist*, and *prefer* a `cak_`.
|
||||
|
||||
**Flow (TOFU):**
|
||||
1. **Bootstrap (first connect):** a fresh agent authenticates on `/ws/agent` with a bootstrap secret —
|
||||
interim: the shared `AGENT_API_KEY` (embedded by the download endpoint); target: a single-use,
|
||||
short-lived **enroll token** (more secure TOFU — see Security).
|
||||
2. **Server issues on first connect:** when an agent authed via the bootstrap path (i.e. NOT already
|
||||
`cak_`-keyed) connects and its machine has **no active (non-revoked) `cak_`**, the relay: resolves/creates
|
||||
the machine row (existing `upsert_machine` on `machine_uid` — now functional after the 2026-06-01
|
||||
ON CONFLICT fix), mints a `cak_` (`generate_agent_key` + `db::agent_keys::insert_agent_key` for that
|
||||
`machine_id`), and sends the plaintext key to the agent **once** over a new server→agent message. Only
|
||||
the hash is stored. **Idempotent:** never re-issue if an active key already exists for the machine.
|
||||
3. **Agent receives + persists + prefers:** on `AgentKeyProvision`, the agent persists the `cak_` durably at
|
||||
`%ProgramData%\GuruConnect\agent_key` (restricted ACL, same pattern as `machine_uid`). On startup it loads
|
||||
the persisted `cak_` if present and uses it as its auth key, falling back to the embedded/bootstrap secret
|
||||
only when no `cak_` is stored yet. After provisioning, every reconnect authenticates via `cak_` (no more
|
||||
DEPRECATED-shared-key warning for that agent).
|
||||
4. **Shared-key retirement (phased):** Phase A — shared key stays as the bootstrap so existing+new agents
|
||||
self-enroll; monitor the relay WARN count → ~0. Phase B — once the fleet is `cak_`-keyed, restrict the
|
||||
shared `AGENT_API_KEY` to enrollment-only or remove the env entirely (only `cak_` / enroll-token accepted).
|
||||
This is the concrete completion of task-list #5.
|
||||
|
||||
**Protocol (4-artifact drift discipline):** add `AgentKeyProvision { string key = 1; }` (server→agent) to
|
||||
`proto/guruconnect.proto` with a new reserved message ID; regenerate prost on both agent + server; the
|
||||
hand-written `dashboard/src/lib/protobuf.ts` decoder does NOT need it (agent-plane only) but reserve the ID.
|
||||
|
||||
**Files:** `proto/guruconnect.proto` (new message); `server/src/relay/mod.rs` (issue+send on bootstrap connect
|
||||
with no active key); `server/src/db/agent_keys.rs` (add `has_active_key(machine_id)` check; reuse insert);
|
||||
`agent/src/transport/*` (handle inbound `AgentKeyProvision`); `agent/src/config.rs` + a small key-store module
|
||||
(load/persist `cak_`, prefer over bootstrap).
|
||||
|
||||
**Security (TOFU):** the first connect trusts the bootstrap secret — a leaked shared key during the enroll
|
||||
window could enroll a rogue agent; the secure target is a **single-use, short-lived enroll token** per
|
||||
deployment instead of the shared key (shared-key bootstrap is interim convenience). The `cak_` is sent
|
||||
plaintext once over the existing wss/TLS channel; only the hash is stored server-side; the agent stores it
|
||||
locally with restricted ACLs. Revocation via the existing `DELETE /api/machines/:id/keys/:key_id` fails the
|
||||
agent closed; on its next bootstrap connect it re-enrolls. The keyed-agent dedup (Task 3) keeps the
|
||||
authenticated identity authoritative.
|
||||
|
||||
**Verification:** drop a current-build (signed 0.3.0+) agent configured with the shared-key bootstrap →
|
||||
it connects, receives a `cak_`, persists it; restart → it authenticates via the `cak_` (relay shows NO
|
||||
DEPRECATED-shared-key warning) and `connect_agent_keys` holds exactly one active key for the machine; issue
|
||||
is idempotent across reconnects; revoke the key via the admin API → agent rejected, then re-enrolls on next
|
||||
bootstrap connect. Reference: `auth/agent_keys.rs`, `api/machine_keys.rs`, `relay/mod.rs:266-309`
|
||||
(`validate_agent_api_key`), `.claude/standards/security/credential-handling.md`.
|
||||
|
||||
84
specs/v2-stable-identity/plan.md
Normal file
84
specs/v2-stable-identity/plan.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# v2 Stable Machine Identity + Session Reaping + Operator Removal — Implementation Plan
|
||||
|
||||
> Status: planned 2026-05-30. Parent: [SPEC-004](../../docs/specs/SPEC-004-session-lifecycle-and-removal.md)
|
||||
> (v2 Phase 1/2). Builds on the v2-secure-session-core per-agent `cak_` keys.
|
||||
> Unblocks the fleet per-agent-key migration (task #7) → retire shared key (task #5).
|
||||
|
||||
## Why now
|
||||
|
||||
Live evidence of the problem: `connect_machines` holds **15 persistent rows for 5 real hosts**
|
||||
(`DESKTOP-I66IM5Q` ×9) — the duplicate-registration bug. Until the fleet is deduped, you
|
||||
cannot mint one `cak_` key per real machine, so the shared `AGENT_API_KEY` can't be retired.
|
||||
|
||||
## The core integration: `machine_uid` vs. per-agent `cak_` key
|
||||
|
||||
Worked out against the current code so this composes with the just-built auth, not against it:
|
||||
|
||||
- **Today:** `agent_id` = random UUID from `generate_agent_id()` (`agent/src/config.rs:90`), persisted
|
||||
in the config file. Lost/missing config → a fresh UUID → a new row, because
|
||||
`connect_machines.agent_id` is `UNIQUE` and `upsert_machine` dedups on `ON CONFLICT (agent_id)`
|
||||
(`server/src/db/machines.rs:101,111`). Unstable id → duplicates.
|
||||
- **Per-agent keys already prevent duplicates for KEYED agents:** a `cak_` key binds to a
|
||||
`connect_machines` row via `connect_agent_keys.machine_id`, and reattach uses the **key's** machine
|
||||
identity, ignoring a client-supplied `agent_id`. The duplicate problem is the **shared-key /
|
||||
support-code / config-loss** fleet, which has no stable identity.
|
||||
- **`machine_uid` = deterministic hardware identity** (Windows `MachineGuid` from
|
||||
`HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`, hashed; optionally folded with a board serial),
|
||||
**recomputable** so a lost config self-heals to the *same* id. It is the IDENTITY (who this box is);
|
||||
the `cak_` key is the CREDENTIAL (proof it's authorized as that box). They compose: **one `cak_` key
|
||||
per `machine_uid` = one key per real machine** — exactly what task #7 needs.
|
||||
- **Security stance (unchanged from SPEC-004):** a client-asserted `machine_uid` is spoofable, so it is
|
||||
**not** a trust boundary on its own. For keyed agents the `cak_` key stays authoritative (server uses
|
||||
the key's machine, not the claimed uid). For un-keyed agents `machine_uid` is **dedup-only**
|
||||
(correctness, not trust). Reimage/clone caveats per SPEC-004 (MachineGuid regenerates on sysprep,
|
||||
clones collide — caught by the key binding).
|
||||
|
||||
## Tasks (ordered; each Coding Agent + Code Review, gates green)
|
||||
|
||||
### Task 1 — Agent derives + reports `machine_uid`
|
||||
- New `agent/src/identity.rs`: `machine_uid()` = stable hash of `MachineGuid` (Windows; registry read),
|
||||
recomputable; non-Windows fallback = a persisted random UUID. Cache in config but **recompute if
|
||||
absent** (don't depend on the config file for correctness).
|
||||
- Send `machine_uid` in the connect handshake (`agent/src/transport/websocket.rs:40` query) and on
|
||||
`AgentStatus` (proto). Keep the legacy random `agent_id` as a migration fallback only.
|
||||
- Tests: deterministic (same machine inputs → same uid); a wiped config recomputes the same uid.
|
||||
|
||||
### Task 2 — Server schema + dedup on `machine_uid`
|
||||
- Migration `008_machine_uid.sql`: add `connect_machines.machine_uid TEXT` (nullable for legacy) +
|
||||
a unique index `WHERE machine_uid IS NOT NULL`. Idempotent; startup-applied.
|
||||
- `upsert_machine` keys on `machine_uid` when present (`ON CONFLICT (machine_uid)`), falling back to
|
||||
`agent_id` for legacy agents. Session reuse / reattach key on `machine_uid`. The `cak_` key's machine
|
||||
binding stays authoritative for keyed agents.
|
||||
- Tests: same `machine_uid` with varying `agent_id` → ONE row; legacy (no uid) path unchanged.
|
||||
|
||||
### Task 3 — Reconcile existing duplicate rows
|
||||
- Prefer **age-out + operator removal over a risky backfill** (per the #7 audit note): once Tasks 4/5
|
||||
land, the 14 duplicate ghost rows reap/purge naturally. If a deterministic collapse is wanted later,
|
||||
map duplicates by hostname→`machine_uid` and repoint sessions/keys — but only as a separate, reviewed
|
||||
migration. v1 of this plan does NOT auto-collapse.
|
||||
|
||||
### Task 4 — Session lifecycle reaping (`server/src/session/mod.rs`)
|
||||
- `reap_stale_persistent(ttl)` on `SessionManager`: periodic sweep (spawned at startup, ~60s) removing
|
||||
persistent sessions offline (`is_online == false`) past a TTL (default 10 min, via `last_heartbeat_instant`).
|
||||
- On reconnect, **supersede** prior same-machine (`machine_uid`) sessions so a fresh `agent_id` can't
|
||||
strand the old one.
|
||||
- Tests: offline-past-TTL reaped; online / within-TTL spared; same-machine reconnect supersedes; never
|
||||
reap an online or viewer-attached session.
|
||||
|
||||
### Task 5 — Operator removal API + dashboard
|
||||
- `deleted_at` on `connect_sessions` (+ machines as needed); `DELETE …?purge=true` (in-memory remove +
|
||||
DB soft-delete) distinct from the live-only disconnect; a bulk endpoint; per-row + multi-select removal
|
||||
on the dashboard machines/sessions views. Admin-gated, audited to `events`.
|
||||
- This is also the **immediate fix for the live ghost rows** — once it lands, purge the 14 duplicates.
|
||||
|
||||
## Exit criteria
|
||||
One machine = one record/session; a config-loss or portable run can't duplicate; admins can purge stale
|
||||
rows individually and in bulk; the fleet is deduped enough to mint one `cak_` key per real machine —
|
||||
unblocking task #7 (fleet key migration) → task #5 (retire shared `AGENT_API_KEY`).
|
||||
|
||||
## Open questions
|
||||
1. `machine_uid` recipe — `MachineGuid` alone vs. folded with a board/BIOS serial? (Proposed: MachineGuid
|
||||
primary, hashed.)
|
||||
2. Should `machine_uid` REPLACE `agent_id` as the primary key, or sit alongside it (legacy fallback)?
|
||||
(Proposed: alongside, dedup prefers `machine_uid`; agent_id retained for legacy + transition.)
|
||||
3. Reap TTL default (10 min proposed) and whether managed vs. support sessions differ.
|
||||
Reference in New Issue
Block a user