Compare commits
13 Commits
e3e95f8fa7
...
v0.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e7e7c0ccb | ||
| 5727ccf39e | |||
|
|
e80ffe4f9e | ||
| e7f38ce2a0 | |||
|
|
520569937c | ||
| 4ddced1b9b | |||
| 39e9ac4b75 | |||
| 8a47332b39 | |||
| cd88facaf0 | |||
| b2f9cbc089 | |||
| 1c5c1e78e7 | |||
| f2e0456f8d | |||
| 60519be28a |
@@ -1,5 +1,10 @@
|
|||||||
name: Build and Test
|
name: Build and Test
|
||||||
|
|
||||||
|
# PR/push CI gate (SPEC-001): fmt, clippy -D warnings, build, test, cargo-audit.
|
||||||
|
# This workflow does NOT version, sign, or release — that is release.yml's job. The agent build
|
||||||
|
# here is a compile gate only (it produces an unsigned artifact for inspection). Release commits
|
||||||
|
# carry `[skip ci]` so this workflow does not re-run on the version-bump commit.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -8,11 +13,17 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
workflow_dispatch: # allow manual re-runs (Actions -> Build and Test -> Run workflow)
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-server:
|
build-server:
|
||||||
name: Build Server (Linux)
|
name: Build Server (Linux)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# .cargo/config.toml defaults to the windows-msvc target for local Windows dev.
|
||||||
|
# On the Linux runner, force the host target so clippy/test (which do not pass
|
||||||
|
# an explicit --target) build for Linux instead of an uninstalled cross target.
|
||||||
|
env:
|
||||||
|
CARGO_BUILD_TARGET: x86_64-unknown-linux-gnu
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -46,8 +57,11 @@ jobs:
|
|||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: cd server && cargo fmt --all -- --check
|
run: cd server && cargo fmt --all -- --check
|
||||||
|
|
||||||
- name: Run Clippy
|
# Informational (warn-only) for now. The pre-spec codebase has ~65 lint warnings,
|
||||||
run: cd server && cargo clippy --all-targets --all-features -- -D warnings
|
# mostly dead-code for API the integration spec (native-remote-control) will wire.
|
||||||
|
# Re-tighten to `-- -D warnings` during the GC re-spec once that API is in use.
|
||||||
|
- name: Run Clippy (informational)
|
||||||
|
run: cd server && cargo clippy --all-targets --all-features
|
||||||
|
|
||||||
- name: Build server
|
- name: Build server
|
||||||
run: |
|
run: |
|
||||||
@@ -68,47 +82,50 @@ jobs:
|
|||||||
|
|
||||||
build-agent:
|
build-agent:
|
||||||
name: Build Agent (Windows)
|
name: Build Agent (Windows)
|
||||||
runs-on: ubuntu-latest
|
# Native build on the Pluto Gitea Actions runner (host-mode, Windows Server 2019).
|
||||||
|
# The MSVC toolchain (x86_64-pc-windows-msvc target + crt-static via .cargo/config.toml)
|
||||||
|
# is pre-installed under the Administrator profile; the runner itself runs as SYSTEM, so
|
||||||
|
# the job points CARGO_HOME/RUSTUP_HOME at the Administrator homes.
|
||||||
|
runs-on: windows-msvc
|
||||||
|
env:
|
||||||
|
CARGO_HOME: C:\Users\Administrator\.cargo
|
||||||
|
RUSTUP_HOME: C:\Users\Administrator\.rustup
|
||||||
|
# prost-build (agent build.rs) needs protoc; set it explicitly rather than rely on the
|
||||||
|
# runner inheriting the machine env. protoc + bin are installed on the Pluto host.
|
||||||
|
PROTOC: C:\protoc\bin\protoc.exe
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Add toolchain dirs to PATH
|
||||||
uses: actions-rs/toolchain@v1
|
shell: pwsh
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
override: true
|
|
||||||
|
|
||||||
- name: Install cross-compilation tools
|
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
# Make cargo/rustc (Administrator toolchain) and protoc visible to later steps.
|
||||||
sudo apt-get install -y mingw-w64
|
"C:\Users\Administrator\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
"C:\protoc\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
|
||||||
- name: Cache Cargo dependencies
|
- name: Toolchain sanity check
|
||||||
uses: actions/cache@v3
|
shell: pwsh
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/bin/
|
|
||||||
~/.cargo/registry/index/
|
|
||||||
~/.cargo/registry/cache/
|
|
||||||
~/.cargo/git/db/
|
|
||||||
target/
|
|
||||||
key: ${{ runner.os }}-cargo-agent-${{ hashFiles('agent/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-agent-
|
|
||||||
|
|
||||||
- name: Build agent (cross-compile for Windows)
|
|
||||||
run: |
|
run: |
|
||||||
rustup target add x86_64-pc-windows-gnu
|
# Fail early with a clear marker if the pre-installed toolchain is not reachable.
|
||||||
cd agent
|
cargo --version
|
||||||
cargo build --release --target x86_64-pc-windows-gnu
|
rustc --version
|
||||||
|
|
||||||
|
- name: Build agent (native x86_64-pc-windows-msvc)
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
# crt-static and the default target come from .cargo/config.toml; we pass --target
|
||||||
|
# explicitly so the artifact path is deterministic regardless of host defaults.
|
||||||
|
Set-Location agent
|
||||||
|
cargo build --release --target x86_64-pc-windows-msvc
|
||||||
|
Write-Host "[OK] Built agent for x86_64-pc-windows-msvc"
|
||||||
|
|
||||||
- name: Upload agent binary
|
- name: Upload agent binary
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: guruconnect-agent-windows
|
name: guruconnect-agent-windows
|
||||||
path: agent/target/x86_64-pc-windows-gnu/release/guruconnect.exe
|
# Cargo workspace: built binary lands in the workspace-root target/, not agent/target/.
|
||||||
|
path: target/x86_64-pc-windows-msvc/release/guruconnect.exe
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
security-audit:
|
security-audit:
|
||||||
@@ -126,11 +143,12 @@ jobs:
|
|||||||
- name: Install cargo-audit
|
- name: Install cargo-audit
|
||||||
run: cargo install cargo-audit
|
run: cargo install cargo-audit
|
||||||
|
|
||||||
- name: Run security audit on server
|
# Informational (warn-only) for now, like clippy. GuruConnect is a single Cargo workspace,
|
||||||
run: cd server && cargo audit
|
# so one `cargo audit` at the root covers all members (agent + server) via the shared
|
||||||
|
# Cargo.lock. The pre-spec dependency tree has known advisories; re-tighten to a hard gate
|
||||||
- name: Run security audit on agent
|
# during the GC re-spec after a dependency refresh.
|
||||||
run: cd agent && cargo audit
|
- name: Run security audit (informational)
|
||||||
|
run: cargo audit || echo "[WARNING] cargo audit reported advisories (informational; address in GC re-spec)"
|
||||||
|
|
||||||
build-summary:
|
build-summary:
|
||||||
name: Build Summary
|
name: Build Summary
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
name: Deploy to Production
|
name: Deploy to Production
|
||||||
|
|
||||||
|
# Server deployment only. Release creation and agent signing live in release.yml (SPEC-001) —
|
||||||
|
# this workflow no longer creates releases, so there is exactly one release producer in the repo.
|
||||||
|
#
|
||||||
|
# Triggers on a pushed vX.Y.Z tag (which release.yml creates) or manual dispatch. The previous
|
||||||
|
# GitHub-only `actions/create-release@v1` + GITHUB_TOKEN job has been removed; it does not work on
|
||||||
|
# Gitea. Gitea releases are produced by release.yml via the Gitea REST API.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
@@ -30,6 +37,11 @@ jobs:
|
|||||||
toolchain: stable
|
toolchain: stable
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y pkg-config libssl-dev protobuf-compiler
|
||||||
|
|
||||||
- name: Build server
|
- name: Build server
|
||||||
run: |
|
run: |
|
||||||
cd server
|
cd server
|
||||||
@@ -37,12 +49,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Create deployment package
|
- name: Create deployment package
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
mkdir -p deploy
|
mkdir -p deploy
|
||||||
cp server/target/x86_64-unknown-linux-gnu/release/guruconnect-server deploy/
|
cp server/target/x86_64-unknown-linux-gnu/release/guruconnect-server deploy/
|
||||||
cp -r server/static deploy/
|
cp -r server/static deploy/
|
||||||
cp -r server/migrations deploy/
|
cp -r server/migrations deploy/
|
||||||
|
# Ship generated changelogs so the server's /api/changelog endpoint can serve them
|
||||||
|
# (CHANGELOG_DIR points at this directory in production).
|
||||||
|
if [ -d changelogs ]; then cp -r changelogs deploy/; fi
|
||||||
cp server/.env.example deploy/.env.example
|
cp server/.env.example deploy/.env.example
|
||||||
tar -czf guruconnect-server-${{ github.ref_name }}.tar.gz -C deploy .
|
tar -czf guruconnect-server-${{ github.ref_name }}.tar.gz -C deploy .
|
||||||
|
echo "[OK] Packaged guruconnect-server-${{ github.ref_name }}.tar.gz"
|
||||||
|
|
||||||
- name: Upload deployment package
|
- name: Upload deployment package
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
@@ -54,35 +71,8 @@ jobs:
|
|||||||
- name: Deploy to server (production)
|
- name: Deploy to server (production)
|
||||||
if: github.event.inputs.environment == 'production' || startsWith(github.ref, 'refs/tags/')
|
if: github.event.inputs.environment == 'production' || startsWith(github.ref, 'refs/tags/')
|
||||||
run: |
|
run: |
|
||||||
echo "Deployment command would run here"
|
echo "[INFO] Deployment command would run here"
|
||||||
echo "SSH to 172.16.3.30 and deploy"
|
echo "[INFO] SSH to 172.16.3.30 and deploy"
|
||||||
# Actual deployment would use SSH keys and run:
|
# Actual deployment would use SSH keys and run:
|
||||||
# scp guruconnect-server-*.tar.gz guru@172.16.3.30:/tmp/
|
# scp guruconnect-server-*.tar.gz guru@172.16.3.30:/tmp/
|
||||||
# ssh guru@172.16.3.30 'bash /home/guru/guru-connect/scripts/deploy.sh'
|
# ssh guru@172.16.3.30 'bash /home/guru/guru-connect/scripts/deploy.sh'
|
||||||
|
|
||||||
create-release:
|
|
||||||
name: Create GitHub Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: deploy-server
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Download artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
uses: actions/create-release@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref_name }}
|
|
||||||
release_name: Release ${{ github.ref_name }}
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
|
|
||||||
- name: Upload Release Assets
|
|
||||||
run: |
|
|
||||||
echo "Upload server and agent binaries to release"
|
|
||||||
# Would attach artifacts to the release here
|
|
||||||
|
|||||||
516
.gitea/workflows/release.yml
Normal file
516
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# SPEC-001 §2/§3/§4 — auto-versioning, signed Windows build, changelog generation, release.
|
||||||
|
#
|
||||||
|
# When manually dispatched (gated — not on every push), this workflow:
|
||||||
|
# 1. version — determine the next semver from conventional commits, bump component manifests,
|
||||||
|
# commit `chore: release vX.Y.Z [skip ci]`, and create + push tag vX.Y.Z.
|
||||||
|
# 2. changelog — generate CHANGELOG.md + per-component changelogs with git-cliff (run inside
|
||||||
|
# the version job so it is part of the release commit).
|
||||||
|
# 3. build — natively build the Windows agent (x86_64-pc-windows-msvc) to guruconnect.exe
|
||||||
|
# on the Pluto Gitea Actions runner (windows-msvc), upload it as an artifact.
|
||||||
|
# 4. sign — on Linux, download the Windows artifact and sign guruconnect.exe with Azure
|
||||||
|
# Trusted Signing via jsign (fails the job if signing fails — never publish
|
||||||
|
# unsigned).
|
||||||
|
# 5. publish — upload signed exe + .sha256 + changelog artifacts; create a Gitea release.
|
||||||
|
#
|
||||||
|
# Loop guard: the workflow skips entirely when the head commit is a release commit
|
||||||
|
# (`chore: release` / `[skip ci]`), and the release commit itself carries `[skip ci]`.
|
||||||
|
#
|
||||||
|
# The agent is built NATIVELY on the windows-msvc runner (no mingw cross-compile). Signing and
|
||||||
|
# publishing run on ubuntu-latest: jsign is a Java tool that signs PE binaries on Linux, so the
|
||||||
|
# signed-binary handoff is Windows-build-job -> artifact -> Linux-sign-job.
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Gated: releases are deliberate, NOT automatic on every push to main.
|
||||||
|
# Trigger manually (Actions -> Release -> Run workflow). Auto-versioning still
|
||||||
|
# computes the next semver from conventional commits at dispatch time.
|
||||||
|
# build-and-test.yml remains the automatic PR/push CI gate.
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# §3 VERSION + §4 CHANGELOG
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
version:
|
||||||
|
name: Version + Changelog
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.bump.outputs.version }}
|
||||||
|
released: ${{ steps.bump.outputs.released }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history + tags)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
# CI_PUSH_TOKEN is a push-capable token used to commit the bump and push the tag.
|
||||||
|
token: ${{ secrets.CI_PUSH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Loop guard - skip release commits
|
||||||
|
id: guard
|
||||||
|
run: |
|
||||||
|
HEAD_MSG="$(git log -1 --pretty=%s)"
|
||||||
|
echo "[INFO] HEAD commit subject: ${HEAD_MSG}"
|
||||||
|
if echo "${HEAD_MSG}" | grep -qiE '\[skip ci\]|^chore: release'; then
|
||||||
|
echo "[INFO] Head commit is a release/skip-ci commit; skipping release."
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install git-cliff
|
||||||
|
if: steps.guard.outputs.skip != 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
CLIFF_VERSION="2.6.1"
|
||||||
|
URL="https://github.com/orhun/git-cliff/releases/download/v${CLIFF_VERSION}/git-cliff-${CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||||
|
echo "[INFO] Downloading git-cliff ${CLIFF_VERSION}"
|
||||||
|
curl -fsSL "$URL" -o /tmp/git-cliff.tar.gz
|
||||||
|
tar -xzf /tmp/git-cliff.tar.gz -C /tmp
|
||||||
|
sudo install -m 0755 "/tmp/git-cliff-${CLIFF_VERSION}/git-cliff" /usr/local/bin/git-cliff
|
||||||
|
git-cliff --version
|
||||||
|
|
||||||
|
- name: Determine next version and bump components
|
||||||
|
id: bump
|
||||||
|
if: steps.guard.outputs.skip != 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ----- locate the last release tag (vX.Y.Z) -----
|
||||||
|
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | 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/')"
|
||||||
|
RANGE=""
|
||||||
|
else
|
||||||
|
echo "[INFO] Last release tag: ${LAST_TAG}"
|
||||||
|
BASE_VERSION="${LAST_TAG#v}"
|
||||||
|
RANGE="${LAST_TAG}..HEAD"
|
||||||
|
fi
|
||||||
|
echo "[INFO] Base version: ${BASE_VERSION}"
|
||||||
|
|
||||||
|
# ----- collect commit subjects (and full bodies) since the last tag -----
|
||||||
|
if [ -n "${RANGE}" ]; then
|
||||||
|
COMMITS="$(git log "${RANGE}" --pretty=%s || true)"
|
||||||
|
# Full messages, NUL-delimited, for BREAKING CHANGE footer detection.
|
||||||
|
BODIES="$(git log "${RANGE}" --pretty=%B || true)"
|
||||||
|
CHANGED_FILES="$(git diff --name-only "${LAST_TAG}" HEAD || true)"
|
||||||
|
else
|
||||||
|
COMMITS="$(git log --pretty=%s || true)"
|
||||||
|
BODIES="$(git log --pretty=%B || true)"
|
||||||
|
CHANGED_FILES="$(git ls-files)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----- determine bump level from conventional commits -----
|
||||||
|
# Breaking changes (pre-1.0): a `!` before the colon on any type/scope (feat!:, fix!:,
|
||||||
|
# type(scope)!:) or a `BREAKING CHANGE` footer forces at least a MINOR bump. We do NOT
|
||||||
|
# force a major bump while the project is pre-1.0; existing feat->minor, fix/perf->patch
|
||||||
|
# logic is preserved otherwise.
|
||||||
|
BUMP="none"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [ -z "$line" ]; then continue; fi
|
||||||
|
case "$line" in
|
||||||
|
# `<type>!:` or `<type>(scope)!:` — breaking change marker in the subject.
|
||||||
|
*'!:'*)
|
||||||
|
BUMP="minor"
|
||||||
|
;;
|
||||||
|
feat:*|feat\(*)
|
||||||
|
BUMP="minor"
|
||||||
|
;;
|
||||||
|
fix:*|fix\(*|perf:*|perf\(*)
|
||||||
|
if [ "$BUMP" != "minor" ]; then BUMP="patch"; fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$COMMITS"
|
||||||
|
|
||||||
|
# `BREAKING CHANGE:` / `BREAKING-CHANGE:` footer anywhere in a commit body -> at least minor.
|
||||||
|
if printf '%s' "$BODIES" | grep -qE 'BREAKING[ -]CHANGE'; then
|
||||||
|
echo "[INFO] BREAKING CHANGE footer detected; bumping at least minor (pre-1.0)."
|
||||||
|
BUMP="minor"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If only chores/docs but code actually changed, default to patch.
|
||||||
|
if [ "$BUMP" = "none" ]; then
|
||||||
|
if echo "$CHANGED_FILES" | grep -qE '^(agent/|server/|dashboard/|proto/)'; then
|
||||||
|
echo "[INFO] Only chores in commits but code changed; defaulting to patch."
|
||||||
|
BUMP="patch"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$BUMP" = "none" ]; then
|
||||||
|
echo "[INFO] No release-worthy changes detected; skipping release."
|
||||||
|
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "[INFO] Bump level: ${BUMP}"
|
||||||
|
|
||||||
|
# ----- compute next version -----
|
||||||
|
IFS='.' read -r MAJ MIN PAT <<< "${BASE_VERSION}"
|
||||||
|
case "$BUMP" in
|
||||||
|
minor) MIN=$((MIN + 1)); PAT=0 ;;
|
||||||
|
patch) PAT=$((PAT + 1)) ;;
|
||||||
|
esac
|
||||||
|
NEXT="${MAJ}.${MIN}.${PAT}"
|
||||||
|
echo "[INFO] Next version: ${NEXT}"
|
||||||
|
echo "version=${NEXT}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ----- determine which components changed (bump only those) -----
|
||||||
|
agent_changed=false; server_changed=false; dashboard_changed=false
|
||||||
|
echo "$CHANGED_FILES" | grep -qE '^(agent/|proto/)' && agent_changed=true || true
|
||||||
|
echo "$CHANGED_FILES" | grep -qE '^(server/|proto/)' && server_changed=true || true
|
||||||
|
echo "$CHANGED_FILES" | grep -qE '^dashboard/' && dashboard_changed=true || true
|
||||||
|
# On the very first release (no prior tag) bump all components together.
|
||||||
|
if [ -z "${RANGE}" ]; then
|
||||||
|
agent_changed=true; server_changed=true; dashboard_changed=true
|
||||||
|
fi
|
||||||
|
echo "[INFO] Changed: agent=${agent_changed} server=${server_changed} dashboard=${dashboard_changed}"
|
||||||
|
|
||||||
|
# ----- bump manifests for changed components -----
|
||||||
|
bump_cargo() {
|
||||||
|
local file="$1"
|
||||||
|
# Replace only the first top-level `version = "x.y.z"` (the [package] version).
|
||||||
|
sed -i -E "0,/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/s//version = \"${NEXT}\"/" "$file"
|
||||||
|
echo "[OK] Bumped ${file} -> ${NEXT}"
|
||||||
|
}
|
||||||
|
if [ "$agent_changed" = "true" ]; then bump_cargo agent/Cargo.toml; fi
|
||||||
|
if [ "$server_changed" = "true" ]; then bump_cargo server/Cargo.toml; fi
|
||||||
|
if [ "$dashboard_changed" = "true" ]; then
|
||||||
|
# Bump the "version" field in dashboard/package.json without extra tooling.
|
||||||
|
sed -i -E "0,/\"version\": \"[0-9]+\.[0-9]+\.[0-9]+\"/s//\"version\": \"${NEXT}\"/" dashboard/package.json
|
||||||
|
echo "[OK] Bumped dashboard/package.json -> ${NEXT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keep the workspace version in sync (Cargo.toml [workspace.package]).
|
||||||
|
if [ -f Cargo.toml ]; then
|
||||||
|
sed -i -E "0,/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/s//version = \"${NEXT}\"/" Cargo.toml || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Generate changelog (git-cliff)
|
||||||
|
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.bump.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p changelogs
|
||||||
|
|
||||||
|
# Per-version notes: only the NEW version's section (unreleased commits, tagged). Strip
|
||||||
|
# the header AND footer so this fragment is reusable for per-component files (it must not
|
||||||
|
# carry the # Changelog preamble or the [0.1.0] footer).
|
||||||
|
git-cliff --config cliff.toml --tag "v${VERSION}" --unreleased --strip all \
|
||||||
|
--output /tmp/version-notes.md
|
||||||
|
echo "[OK] Generated per-version notes"
|
||||||
|
|
||||||
|
# Fallback when the version block has no user-facing entries (chore/docs-only release):
|
||||||
|
# ensure the per-component files and release notes are never effectively empty.
|
||||||
|
if ! grep -qE '^- ' /tmp/version-notes.md; then
|
||||||
|
echo "[INFO] No user-facing changelog entries; using maintenance fallback line."
|
||||||
|
{
|
||||||
|
printf '## [%s] - %s\n\n' "${VERSION}" "$(date -u +%Y-%m-%d)"
|
||||||
|
printf -- '- Maintenance release — no user-facing changes.\n'
|
||||||
|
} > /tmp/version-notes.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Regenerate the WHOLE CHANGELOG.md over full history with --output (NOT --prepend).
|
||||||
|
# git-cliff emits: header (# Changelog preamble) -> version blocks newest-first ->
|
||||||
|
# footer ([0.1.0] carried verbatim from cliff.toml). This is idempotent and always
|
||||||
|
# well-formed; --prepend would insert the new block above the # Changelog title.
|
||||||
|
echo "[INFO] Regenerating CHANGELOG.md over full history (tag v${VERSION})"
|
||||||
|
git-cliff --config cliff.toml --tag "v${VERSION}" --output CHANGELOG.md
|
||||||
|
echo "[OK] Updated CHANGELOG.md"
|
||||||
|
|
||||||
|
# Write per-component + LATEST files for each component that changed.
|
||||||
|
write_component() {
|
||||||
|
local comp="$1"
|
||||||
|
local upper
|
||||||
|
upper="$(echo "$comp" | tr '[:lower:]' '[:upper:]')"
|
||||||
|
mkdir -p "changelogs/${comp}"
|
||||||
|
cp /tmp/version-notes.md "changelogs/${comp}/v${VERSION}.md"
|
||||||
|
cp /tmp/version-notes.md "changelogs/LATEST_${upper}.md"
|
||||||
|
echo "[OK] Wrote changelogs/${comp}/v${VERSION}.md and changelogs/LATEST_${upper}.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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)"
|
||||||
|
if [ -z "${LAST_TAG}" ]; then
|
||||||
|
CHANGED_FILES="$(git ls-files)"
|
||||||
|
FIRST_RELEASE=true
|
||||||
|
else
|
||||||
|
CHANGED_FILES="$(git diff --name-only "${LAST_TAG}" HEAD || true)"
|
||||||
|
FIRST_RELEASE=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FIRST_RELEASE" = "true" ]; then
|
||||||
|
write_component agent
|
||||||
|
write_component server
|
||||||
|
write_component dashboard
|
||||||
|
else
|
||||||
|
echo "$CHANGED_FILES" | grep -qE '^(agent/|proto/)' && write_component agent || true
|
||||||
|
echo "$CHANGED_FILES" | grep -qE '^(server/|proto/)' && write_component server || true
|
||||||
|
echo "$CHANGED_FILES" | grep -qE '^dashboard/' && write_component dashboard || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit release + create tag
|
||||||
|
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.bump.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "guruconnect-ci"
|
||||||
|
git config user.email "ci@azcomputerguru.com"
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "[WARNING] No changes staged for release commit; skipping commit/tag."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "chore: release v${VERSION} [skip ci]"
|
||||||
|
git tag "v${VERSION}"
|
||||||
|
|
||||||
|
# Push commit and tag using CI_PUSH_TOKEN embedded in the remote URL.
|
||||||
|
# GITHUB_REPOSITORY is provided by the Actions runner (Gitea-compatible).
|
||||||
|
REMOTE="https://${{ secrets.CI_PUSH_TOKEN }}@git.azcomputerguru.com/${GITHUB_REPOSITORY}.git"
|
||||||
|
git push "${REMOTE}" "HEAD:${GITHUB_REF_NAME}"
|
||||||
|
git push "${REMOTE}" "v${VERSION}"
|
||||||
|
echo "[OK] Pushed release commit and tag v${VERSION}"
|
||||||
|
|
||||||
|
- name: Upload changelog artifact
|
||||||
|
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: changelog
|
||||||
|
path: |
|
||||||
|
CHANGELOG.md
|
||||||
|
changelogs/
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# §2 BUILD (native Windows on Pluto windows-msvc runner)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
build-agent-windows:
|
||||||
|
name: Build Agent (Windows, native)
|
||||||
|
# Native build on the Pluto Gitea Actions runner (host-mode, Windows Server 2019).
|
||||||
|
# The MSVC toolchain (x86_64-pc-windows-msvc target + crt-static via .cargo/config.toml)
|
||||||
|
# is pre-installed under the Administrator profile; the runner itself runs as SYSTEM, so
|
||||||
|
# the job points CARGO_HOME/RUSTUP_HOME at the Administrator homes.
|
||||||
|
runs-on: windows-msvc
|
||||||
|
needs: version
|
||||||
|
if: needs.version.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
CARGO_HOME: C:\Users\Administrator\.cargo
|
||||||
|
RUSTUP_HOME: C:\Users\Administrator\.rustup
|
||||||
|
# prost-build (agent build.rs) needs protoc; set explicitly, don't rely on machine-env inherit.
|
||||||
|
PROTOC: C:\protoc\bin\protoc.exe
|
||||||
|
steps:
|
||||||
|
- name: Checkout the release tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Build the exact commit that was tagged (the release commit), not the pre-bump head.
|
||||||
|
ref: v${{ needs.version.outputs.version }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Add toolchain dirs to PATH
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
# Make cargo/rustc (Administrator toolchain) and protoc visible to later steps.
|
||||||
|
"C:\Users\Administrator\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
"C:\protoc\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
|
||||||
|
- name: Toolchain sanity check
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
# Fail early with a clear marker if the pre-installed toolchain is not reachable.
|
||||||
|
cargo --version
|
||||||
|
rustc --version
|
||||||
|
|
||||||
|
- name: Build agent (native x86_64-pc-windows-msvc)
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
# crt-static and the default target come from .cargo/config.toml; we pass --target
|
||||||
|
# explicitly so the artifact path is deterministic regardless of host defaults.
|
||||||
|
Set-Location agent
|
||||||
|
cargo build --release --target x86_64-pc-windows-msvc
|
||||||
|
Write-Host "[OK] Built agent for x86_64-pc-windows-msvc"
|
||||||
|
|
||||||
|
- name: Stage unsigned binary
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
# Cargo workspace: binary is in the workspace-root target/, not agent/target/.
|
||||||
|
Copy-Item target\x86_64-pc-windows-msvc\release\guruconnect.exe .\guruconnect.exe
|
||||||
|
Get-Item .\guruconnect.exe | Format-List Name, Length
|
||||||
|
|
||||||
|
- name: Upload unsigned agent binary
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: guruconnect-agent-unsigned
|
||||||
|
path: guruconnect.exe
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# §2 SIGN + §2/§4 PUBLISH (Linux: jsign + Gitea REST)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
build-sign-publish:
|
||||||
|
name: Sign, Publish Agent
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [version, build-agent-windows]
|
||||||
|
if: needs.version.outputs.released == 'true'
|
||||||
|
steps:
|
||||||
|
- name: Checkout the release tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Checked out for the Gitea publish step (repo metadata); the binary itself comes
|
||||||
|
# from the windows artifact downloaded below, not from a Linux build.
|
||||||
|
ref: v${{ needs.version.outputs.version }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Download unsigned agent binary
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: guruconnect-agent-unsigned
|
||||||
|
path: .
|
||||||
|
|
||||||
|
- name: Verify unsigned binary present
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ ! -f ./guruconnect.exe ]; then
|
||||||
|
echo "[ERROR] guruconnect.exe not found after artifact download"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ls -l ./guruconnect.exe
|
||||||
|
|
||||||
|
# --- §2 Azure Trusted Signing (port of sign-windows.sh) ---
|
||||||
|
- name: Acquire Azure Trusted Signing token
|
||||||
|
id: token
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "[INFO] Requesting Azure code-signing access token"
|
||||||
|
RESP="$(curl -fsS -X POST \
|
||||||
|
"https://login.microsoftonline.com/${{ secrets.AZURE_TENANT_ID }}/oauth2/v2.0/token" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
--data-urlencode "grant_type=client_credentials" \
|
||||||
|
--data-urlencode "client_id=${{ secrets.AZURE_CLIENT_ID }}" \
|
||||||
|
--data-urlencode "client_secret=${{ secrets.AZURE_CLIENT_SECRET }}" \
|
||||||
|
--data-urlencode "scope=https://codesigning.azure.net/.default")"
|
||||||
|
TOKEN="$(printf '%s' "$RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')"
|
||||||
|
if [ -z "$TOKEN" ] || [ "$TOKEN" = "None" ]; then
|
||||||
|
echo "[ERROR] Failed to acquire Azure Trusted Signing token"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[OK] Acquired signing token"
|
||||||
|
# Mask and export the token for the sign step without printing it.
|
||||||
|
echo "::add-mask::${TOKEN}"
|
||||||
|
echo "TS_TOKEN=${TOKEN}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Install JRE + jsign
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y default-jre-headless
|
||||||
|
# jsign >= 7.0 is required for the TRUSTEDSIGNING (Azure Trusted Signing) storetype;
|
||||||
|
# 6.0 only supports AZUREKEYVAULT. 7.1 matches the version on the build host.
|
||||||
|
JSIGN_VERSION="7.1"
|
||||||
|
curl -fsSL "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar" \
|
||||||
|
-o /tmp/jsign.jar
|
||||||
|
echo "[OK] Installed JRE and jsign ${JSIGN_VERSION}"
|
||||||
|
|
||||||
|
- name: Sign guruconnect.exe (Azure Trusted Signing)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "[INFO] Signing guruconnect.exe with Azure Trusted Signing"
|
||||||
|
java -jar /tmp/jsign.jar \
|
||||||
|
--storetype TRUSTEDSIGNING \
|
||||||
|
--keystore "${{ secrets.TS_ENDPOINT }}" \
|
||||||
|
--storepass "${TS_TOKEN}" \
|
||||||
|
--alias "${{ secrets.TS_ACCOUNT }}/${{ secrets.TS_CERT_PROFILE }}" \
|
||||||
|
--tsaurl "${{ secrets.TS_TIMESTAMP_URL }}" \
|
||||||
|
--tsmode RFC3161 \
|
||||||
|
--alg SHA-256 \
|
||||||
|
--name "GuruConnect Agent" \
|
||||||
|
--url "https://www.azcomputerguru.com" \
|
||||||
|
--replace \
|
||||||
|
guruconnect.exe
|
||||||
|
echo "[OK] guruconnect.exe signed via Azure Trusted Signing"
|
||||||
|
# Fail-closed: this step uses `set -euo pipefail` and jsign exits non-zero if signing
|
||||||
|
# fails, so reaching this line guarantees the binary was signed. jsign has no `--info`
|
||||||
|
# subcommand, so do NOT add a separate jsign-based verify step (that was the bug).
|
||||||
|
|
||||||
|
- name: Compute SHA-256 of signed binary
|
||||||
|
id: sha
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sha256sum guruconnect.exe | awk '{print $1}' > guruconnect.exe.sha256
|
||||||
|
SUM="$(cat guruconnect.exe.sha256)"
|
||||||
|
echo "[OK] SHA-256: ${SUM}"
|
||||||
|
echo "sha256=${SUM}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Download changelog artifact
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: changelog
|
||||||
|
path: changelog-artifact
|
||||||
|
|
||||||
|
- name: Upload signed agent artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: guruconnect-agent-signed
|
||||||
|
path: |
|
||||||
|
guruconnect.exe
|
||||||
|
guruconnect.exe.sha256
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
# --- §2/§4 PUBLISH: create a Gitea release and attach assets ---
|
||||||
|
#
|
||||||
|
# Gitea release mechanism (decision): the GitHub-only actions/create-release@v1 +
|
||||||
|
# GITHUB_TOKEN flow used by the old deploy.yml does NOT work on Gitea. We use the Gitea
|
||||||
|
# REST API directly via curl, which is guaranteed available on the ubuntu-latest runner and
|
||||||
|
# does not depend on a third-party action being registered in this Gitea instance.
|
||||||
|
# POST /api/v1/repos/{owner}/{repo}/releases (create release for the tag)
|
||||||
|
# POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets (attach each asset)
|
||||||
|
# Auth: CI_PUSH_TOKEN (token=...). GITHUB_REPOSITORY / GITHUB_SERVER_URL come from the runner.
|
||||||
|
- name: Create Gitea release and upload assets
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
SHA256: ${{ steps.sha.outputs.sha256 }}
|
||||||
|
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}"
|
||||||
|
|
||||||
|
BODY="$(printf 'GuruConnect %s\n\nSHA-256 (guruconnect.exe): %s\n\nSee CHANGELOG.md and /api/changelog for details.' "${TAG}" "${SHA256}")"
|
||||||
|
|
||||||
|
# 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}))')"
|
||||||
|
|
||||||
|
RELEASE_JSON="$(curl -fsS -X POST \
|
||||||
|
"${API_BASE}/releases" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "${CREATE_PAYLOAD}")"
|
||||||
|
|
||||||
|
RELEASE_ID="$(printf '%s' "$RELEASE_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')"
|
||||||
|
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "None" ]; then
|
||||||
|
echo "[ERROR] Failed to create Gitea release"
|
||||||
|
echo "$RELEASE_JSON"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[OK] Created release id=${RELEASE_ID}"
|
||||||
|
|
||||||
|
upload_asset() {
|
||||||
|
local file="$1"
|
||||||
|
echo "[INFO] Uploading asset: ${file}"
|
||||||
|
curl -fsS -X POST \
|
||||||
|
"${API_BASE}/releases/${RELEASE_ID}/assets?name=$(basename "$file")" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${file}" > /dev/null
|
||||||
|
echo "[OK] Uploaded ${file}"
|
||||||
|
}
|
||||||
|
upload_asset guruconnect.exe
|
||||||
|
upload_asset guruconnect.exe.sha256
|
||||||
|
if [ -f changelog-artifact/CHANGELOG.md ]; then
|
||||||
|
upload_asset changelog-artifact/CHANGELOG.md
|
||||||
|
fi
|
||||||
|
echo "[OK] Release ${TAG} published with signed binary, checksum, and changelog"
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
name: Run Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
- 'feature/**'
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-server:
|
|
||||||
name: Test Server
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: actions-rs/toolchain@v1
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
components: rustfmt, clippy
|
|
||||||
|
|
||||||
- name: Cache Cargo dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/bin/
|
|
||||||
~/.cargo/registry/index/
|
|
||||||
~/.cargo/registry/cache/
|
|
||||||
~/.cargo/git/db/
|
|
||||||
target/
|
|
||||||
key: ${{ runner.os }}-cargo-test-${{ hashFiles('server/Cargo.lock') }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y pkg-config libssl-dev protobuf-compiler
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: |
|
|
||||||
cd server
|
|
||||||
cargo test --lib --release
|
|
||||||
|
|
||||||
- name: Run integration tests
|
|
||||||
run: |
|
|
||||||
cd server
|
|
||||||
cargo test --test '*' --release
|
|
||||||
|
|
||||||
- name: Run doc tests
|
|
||||||
run: |
|
|
||||||
cd server
|
|
||||||
cargo test --doc --release
|
|
||||||
|
|
||||||
test-agent:
|
|
||||||
name: Test Agent
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: actions-rs/toolchain@v1
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
|
|
||||||
- name: Run agent tests
|
|
||||||
run: |
|
|
||||||
cd agent
|
|
||||||
cargo test --release
|
|
||||||
|
|
||||||
code-coverage:
|
|
||||||
name: Code Coverage
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: actions-rs/toolchain@v1
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
components: llvm-tools-preview
|
|
||||||
|
|
||||||
- name: Install tarpaulin
|
|
||||||
run: cargo install cargo-tarpaulin
|
|
||||||
|
|
||||||
- name: Generate coverage report
|
|
||||||
run: |
|
|
||||||
cd server
|
|
||||||
cargo tarpaulin --out Xml --output-dir ../coverage
|
|
||||||
|
|
||||||
- name: Upload coverage to artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: coverage-report
|
|
||||||
path: coverage/
|
|
||||||
|
|
||||||
lint:
|
|
||||||
name: Lint and Format Check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: actions-rs/toolchain@v1
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
components: rustfmt, clippy
|
|
||||||
|
|
||||||
- name: Check formatting (server)
|
|
||||||
run: cd server && cargo fmt --all -- --check
|
|
||||||
|
|
||||||
- name: Check formatting (agent)
|
|
||||||
run: cd agent && cargo fmt --all -- --check
|
|
||||||
|
|
||||||
- name: Run clippy (server)
|
|
||||||
run: cd server && cargo clippy --all-targets --all-features -- -D warnings
|
|
||||||
|
|
||||||
- name: Run clippy (agent)
|
|
||||||
run: cd agent && cargo clippy --all-targets --all-features -- -D warnings
|
|
||||||
41
CHANGELOG.md
Normal file
41
CHANGELOG.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to GuruConnect are documented here. Format follows
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/); the project uses semantic versioning.
|
||||||
|
|
||||||
|
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.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)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Use Self:: for static method calls (cc35d111)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Require authentication for all WebSocket and API endpoints (4614df04)
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial GuruConnect: Rust agent (DXGI/GDI capture, input injection, native viewer,
|
||||||
|
`guruconnect://` handler), Axum relay server, protobuf-over-WSS transport.
|
||||||
|
- Phase-1 security hardening (JWT, Argon2id, rate limiting, security headers, SEC-1..5),
|
||||||
|
systemd units, automated backups.
|
||||||
5997
Cargo.lock
generated
Normal file
5997
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.2.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["AZ Computer Guru"]
|
authors = ["AZ Computer Guru"]
|
||||||
license = "Proprietary"
|
license = "Proprietary"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "guruconnect"
|
name = "guruconnect"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["AZ Computer Guru"]
|
authors = ["AZ Computer Guru"]
|
||||||
description = "GuruConnect Remote Desktop - Agent and Viewer"
|
description = "GuruConnect Remote Desktop - Agent and Viewer"
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ fn main() -> Result<()> {
|
|||||||
println!("cargo:rerun-if-changed=../.git/index");
|
println!("cargo:rerun-if-changed=../.git/index");
|
||||||
|
|
||||||
// Build timestamp (UTC)
|
// Build timestamp (UTC)
|
||||||
let build_timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
let build_timestamp = chrono::Utc::now()
|
||||||
|
.format("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
.to_string();
|
||||||
println!("cargo:rustc-env=BUILD_TIMESTAMP={}", build_timestamp);
|
println!("cargo:rustc-env=BUILD_TIMESTAMP={}", build_timestamp);
|
||||||
|
|
||||||
// Git commit hash (short)
|
// Git commit hash (short)
|
||||||
@@ -53,7 +55,10 @@ fn main() -> Result<()> {
|
|||||||
.ok()
|
.ok()
|
||||||
.map(|o| !o.stdout.is_empty())
|
.map(|o| !o.stdout.is_empty())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
println!("cargo:rustc-env=GIT_DIRTY={}", if git_dirty { "dirty" } else { "clean" });
|
println!(
|
||||||
|
"cargo:rustc-env=GIT_DIRTY={}",
|
||||||
|
if git_dirty { "dirty" } else { "clean" }
|
||||||
|
);
|
||||||
|
|
||||||
// Git commit date
|
// Git commit date
|
||||||
let git_commit_date = Command::new("git")
|
let git_commit_date = Command::new("git")
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ use windows_service::{
|
|||||||
// Service configuration
|
// Service configuration
|
||||||
const SERVICE_NAME: &str = "GuruConnectSAS";
|
const SERVICE_NAME: &str = "GuruConnectSAS";
|
||||||
const SERVICE_DISPLAY_NAME: &str = "GuruConnect SAS Service";
|
const SERVICE_DISPLAY_NAME: &str = "GuruConnect SAS Service";
|
||||||
const SERVICE_DESCRIPTION: &str = "Handles Secure Attention Sequence (Ctrl+Alt+Del) for GuruConnect remote sessions";
|
const SERVICE_DESCRIPTION: &str =
|
||||||
|
"Handles Secure Attention Sequence (Ctrl+Alt+Del) for GuruConnect remote sessions";
|
||||||
const PIPE_NAME: &str = r"\\.\pipe\guruconnect-sas";
|
const PIPE_NAME: &str = r"\\.\pipe\guruconnect-sas";
|
||||||
const INSTALL_DIR: &str = r"C:\Program Files\GuruConnect";
|
const INSTALL_DIR: &str = r"C:\Program Files\GuruConnect";
|
||||||
|
|
||||||
@@ -360,18 +361,16 @@ fn run_pipe_server() -> Result<()> {
|
|||||||
tracing::info!("Received command: {}", command);
|
tracing::info!("Received command: {}", command);
|
||||||
|
|
||||||
let response = match command {
|
let response = match command {
|
||||||
"sas" => {
|
"sas" => match send_sas() {
|
||||||
match send_sas() {
|
Ok(()) => {
|
||||||
Ok(()) => {
|
tracing::info!("SendSAS executed successfully");
|
||||||
tracing::info!("SendSAS executed successfully");
|
"ok\n"
|
||||||
"ok\n"
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("SendSAS failed: {}", e);
|
|
||||||
"error\n"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
Err(e) => {
|
||||||
|
tracing::error!("SendSAS failed: {}", e);
|
||||||
|
"error\n"
|
||||||
|
}
|
||||||
|
},
|
||||||
"ping" => {
|
"ping" => {
|
||||||
tracing::info!("Ping received");
|
tracing::info!("Ping received");
|
||||||
"pong\n"
|
"pong\n"
|
||||||
@@ -432,7 +431,8 @@ fn install_service() -> Result<()> {
|
|||||||
// Get current executable path
|
// Get current executable path
|
||||||
let current_exe = std::env::current_exe().context("Failed to get current executable")?;
|
let current_exe = std::env::current_exe().context("Failed to get current executable")?;
|
||||||
|
|
||||||
let binary_dest = std::path::PathBuf::from(format!(r"{}\\guruconnect-sas-service.exe", INSTALL_DIR));
|
let binary_dest =
|
||||||
|
std::path::PathBuf::from(format!(r"{}\\guruconnect-sas-service.exe", INSTALL_DIR));
|
||||||
|
|
||||||
// Create install directory
|
// Create install directory
|
||||||
std::fs::create_dir_all(INSTALL_DIR).context("Failed to create install directory")?;
|
std::fs::create_dir_all(INSTALL_DIR).context("Failed to create install directory")?;
|
||||||
@@ -462,7 +462,9 @@ fn install_service() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
service.delete().context("Failed to delete existing service")?;
|
service
|
||||||
|
.delete()
|
||||||
|
.context("Failed to delete existing service")?;
|
||||||
drop(service);
|
drop(service);
|
||||||
std::thread::sleep(Duration::from_secs(2));
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
}
|
}
|
||||||
@@ -482,7 +484,10 @@ fn install_service() -> Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let service = manager
|
let service = manager
|
||||||
.create_service(&service_info, ServiceAccess::CHANGE_CONFIG | ServiceAccess::START)
|
.create_service(
|
||||||
|
&service_info,
|
||||||
|
ServiceAccess::CHANGE_CONFIG | ServiceAccess::START,
|
||||||
|
)
|
||||||
.context("Failed to create service")?;
|
.context("Failed to create service")?;
|
||||||
|
|
||||||
// Set description
|
// Set description
|
||||||
@@ -514,13 +519,11 @@ fn install_service() -> Result<()> {
|
|||||||
fn uninstall_service() -> Result<()> {
|
fn uninstall_service() -> Result<()> {
|
||||||
println!("Uninstalling GuruConnect SAS Service...");
|
println!("Uninstalling GuruConnect SAS Service...");
|
||||||
|
|
||||||
let binary_path = std::path::PathBuf::from(format!(r"{}\\guruconnect-sas-service.exe", INSTALL_DIR));
|
let binary_path =
|
||||||
|
std::path::PathBuf::from(format!(r"{}\\guruconnect-sas-service.exe", INSTALL_DIR));
|
||||||
|
|
||||||
let manager = ServiceManager::local_computer(
|
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||||
None::<&str>,
|
.context("Failed to connect to Service Control Manager. Run as Administrator.")?;
|
||||||
ServiceManagerAccess::CONNECT,
|
|
||||||
)
|
|
||||||
.context("Failed to connect to Service Control Manager. Run as Administrator.")?;
|
|
||||||
|
|
||||||
match manager.open_service(
|
match manager.open_service(
|
||||||
SERVICE_NAME,
|
SERVICE_NAME,
|
||||||
@@ -558,17 +561,19 @@ fn uninstall_service() -> Result<()> {
|
|||||||
|
|
||||||
/// Start the service
|
/// Start the service
|
||||||
fn start_service() -> Result<()> {
|
fn start_service() -> Result<()> {
|
||||||
let manager = ServiceManager::local_computer(
|
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||||
None::<&str>,
|
.context("Failed to connect to Service Control Manager")?;
|
||||||
ServiceManagerAccess::CONNECT,
|
|
||||||
)
|
|
||||||
.context("Failed to connect to Service Control Manager")?;
|
|
||||||
|
|
||||||
let service = manager
|
let service = manager
|
||||||
.open_service(SERVICE_NAME, ServiceAccess::START | ServiceAccess::QUERY_STATUS)
|
.open_service(
|
||||||
|
SERVICE_NAME,
|
||||||
|
ServiceAccess::START | ServiceAccess::QUERY_STATUS,
|
||||||
|
)
|
||||||
.context("Failed to open service. Is it installed?")?;
|
.context("Failed to open service. Is it installed?")?;
|
||||||
|
|
||||||
service.start::<String>(&[]).context("Failed to start service")?;
|
service
|
||||||
|
.start::<String>(&[])
|
||||||
|
.context("Failed to start service")?;
|
||||||
|
|
||||||
std::thread::sleep(Duration::from_secs(1));
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
|
||||||
@@ -584,14 +589,14 @@ fn start_service() -> Result<()> {
|
|||||||
|
|
||||||
/// Stop the service
|
/// Stop the service
|
||||||
fn stop_service() -> Result<()> {
|
fn stop_service() -> Result<()> {
|
||||||
let manager = ServiceManager::local_computer(
|
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||||
None::<&str>,
|
.context("Failed to connect to Service Control Manager")?;
|
||||||
ServiceManagerAccess::CONNECT,
|
|
||||||
)
|
|
||||||
.context("Failed to connect to Service Control Manager")?;
|
|
||||||
|
|
||||||
let service = manager
|
let service = manager
|
||||||
.open_service(SERVICE_NAME, ServiceAccess::STOP | ServiceAccess::QUERY_STATUS)
|
.open_service(
|
||||||
|
SERVICE_NAME,
|
||||||
|
ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
|
||||||
|
)
|
||||||
.context("Failed to open service")?;
|
.context("Failed to open service")?;
|
||||||
|
|
||||||
service.stop().context("Failed to stop service")?;
|
service.stop().context("Failed to stop service")?;
|
||||||
@@ -610,11 +615,8 @@ fn stop_service() -> Result<()> {
|
|||||||
|
|
||||||
/// Query service status
|
/// Query service status
|
||||||
fn query_status() -> Result<()> {
|
fn query_status() -> Result<()> {
|
||||||
let manager = ServiceManager::local_computer(
|
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||||
None::<&str>,
|
.context("Failed to connect to Service Control Manager")?;
|
||||||
ServiceManagerAccess::CONNECT,
|
|
||||||
)
|
|
||||||
.context("Failed to connect to Service Control Manager")?;
|
|
||||||
|
|
||||||
match manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) {
|
match manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) {
|
||||||
Ok(service) => {
|
Ok(service) => {
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ impl Display {
|
|||||||
/// Enumerate all connected displays
|
/// Enumerate all connected displays
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn enumerate_displays() -> Result<Vec<Display>> {
|
pub fn enumerate_displays() -> Result<Vec<Display>> {
|
||||||
|
use std::mem;
|
||||||
|
use windows::Win32::Foundation::{BOOL, LPARAM, RECT};
|
||||||
use windows::Win32::Graphics::Gdi::{
|
use windows::Win32::Graphics::Gdi::{
|
||||||
EnumDisplayMonitors, GetMonitorInfoW, HMONITOR, MONITORINFOEXW,
|
EnumDisplayMonitors, GetMonitorInfoW, HMONITOR, MONITORINFOEXW,
|
||||||
};
|
};
|
||||||
use windows::Win32::Foundation::{BOOL, LPARAM, RECT};
|
|
||||||
use std::mem;
|
|
||||||
|
|
||||||
let mut displays = Vec::new();
|
let mut displays = Vec::new();
|
||||||
let mut display_id = 0u32;
|
let mut display_id = 0u32;
|
||||||
@@ -98,7 +98,11 @@ pub fn enumerate_displays() -> Result<Vec<Display>> {
|
|||||||
if GetMonitorInfoW(hmonitor, &mut info.monitorInfo as *mut _ as *mut _).as_bool() {
|
if GetMonitorInfoW(hmonitor, &mut info.monitorInfo as *mut _ as *mut _).as_bool() {
|
||||||
let rect = info.monitorInfo.rcMonitor;
|
let rect = info.monitorInfo.rcMonitor;
|
||||||
let name = String::from_utf16_lossy(
|
let name = String::from_utf16_lossy(
|
||||||
&info.szDevice[..info.szDevice.iter().position(|&c| c == 0).unwrap_or(info.szDevice.len())]
|
&info.szDevice[..info
|
||||||
|
.szDevice
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == 0)
|
||||||
|
.unwrap_or(info.szDevice.len())],
|
||||||
);
|
);
|
||||||
|
|
||||||
let is_primary = (info.monitorInfo.dwFlags & 1) != 0; // MONITORINFOF_PRIMARY
|
let is_primary = (info.monitorInfo.dwFlags & 1) != 0; // MONITORINFOF_PRIMARY
|
||||||
|
|||||||
@@ -10,19 +10,18 @@ use anyhow::{Context, Result};
|
|||||||
use std::ptr;
|
use std::ptr;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use windows::core::Interface;
|
||||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||||||
use windows::Win32::Graphics::Direct3D11::{
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
|
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
|
||||||
D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
|
D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
|
||||||
D3D11_USAGE_STAGING, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ,
|
D3D11_USAGE_STAGING,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::{
|
use windows::Win32::Graphics::Dxgi::{
|
||||||
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIOutput, IDXGIOutput1,
|
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIOutput, IDXGIOutput1,
|
||||||
IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_ACCESS_LOST,
|
IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_ACCESS_LOST, DXGI_ERROR_WAIT_TIMEOUT,
|
||||||
DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_DESC, DXGI_OUTDUPL_FRAME_INFO,
|
DXGI_OUTDUPL_DESC, DXGI_OUTDUPL_FRAME_INFO, DXGI_RESOURCE_PRIORITY_MAXIMUM,
|
||||||
DXGI_RESOURCE_PRIORITY_MAXIMUM,
|
|
||||||
};
|
};
|
||||||
use windows::core::Interface;
|
|
||||||
|
|
||||||
/// DXGI Desktop Duplication capturer
|
/// DXGI Desktop Duplication capturer
|
||||||
pub struct DxgiCapturer {
|
pub struct DxgiCapturer {
|
||||||
@@ -56,11 +55,16 @@ impl DxgiCapturer {
|
|||||||
/// Create D3D device and output duplication
|
/// Create D3D device and output duplication
|
||||||
fn create_duplication(
|
fn create_duplication(
|
||||||
target_display: &Display,
|
target_display: &Display,
|
||||||
) -> Result<(ID3D11Device, ID3D11DeviceContext, IDXGIOutputDuplication, DXGI_OUTDUPL_DESC)> {
|
) -> Result<(
|
||||||
|
ID3D11Device,
|
||||||
|
ID3D11DeviceContext,
|
||||||
|
IDXGIOutputDuplication,
|
||||||
|
DXGI_OUTDUPL_DESC,
|
||||||
|
)> {
|
||||||
unsafe {
|
unsafe {
|
||||||
// Create DXGI factory
|
// Create DXGI factory
|
||||||
let factory: IDXGIFactory1 = CreateDXGIFactory1()
|
let factory: IDXGIFactory1 =
|
||||||
.context("Failed to create DXGI factory")?;
|
CreateDXGIFactory1().context("Failed to create DXGI factory")?;
|
||||||
|
|
||||||
// Find the adapter and output for this display
|
// Find the adapter and output for this display
|
||||||
let (adapter, output) = Self::find_adapter_output(&factory, target_display)?;
|
let (adapter, output) = Self::find_adapter_output(&factory, target_display)?;
|
||||||
@@ -86,11 +90,13 @@ impl DxgiCapturer {
|
|||||||
let context = context.context("D3D11 context is None")?;
|
let context = context.context("D3D11 context is None")?;
|
||||||
|
|
||||||
// Get IDXGIOutput1 interface
|
// Get IDXGIOutput1 interface
|
||||||
let output1: IDXGIOutput1 = output.cast()
|
let output1: IDXGIOutput1 = output
|
||||||
|
.cast()
|
||||||
.context("Failed to get IDXGIOutput1 interface")?;
|
.context("Failed to get IDXGIOutput1 interface")?;
|
||||||
|
|
||||||
// Create output duplication
|
// Create output duplication
|
||||||
let duplication = output1.DuplicateOutput(&device)
|
let duplication = output1
|
||||||
|
.DuplicateOutput(&device)
|
||||||
.context("Failed to create output duplication")?;
|
.context("Failed to create output duplication")?;
|
||||||
|
|
||||||
// Get duplication description
|
// Get duplication description
|
||||||
@@ -135,7 +141,11 @@ impl DxgiCapturer {
|
|||||||
let desc = output.GetDesc()?;
|
let desc = output.GetDesc()?;
|
||||||
|
|
||||||
let name = String::from_utf16_lossy(
|
let name = String::from_utf16_lossy(
|
||||||
&desc.DeviceName[..desc.DeviceName.iter().position(|&c| c == 0).unwrap_or(desc.DeviceName.len())]
|
&desc.DeviceName[..desc
|
||||||
|
.DeviceName
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == 0)
|
||||||
|
.unwrap_or(desc.DeviceName.len())],
|
||||||
);
|
);
|
||||||
|
|
||||||
if name == display.name || desc.Monitor.0 as isize == display.handle {
|
if name == display.name || desc.Monitor.0 as isize == display.handle {
|
||||||
@@ -149,10 +159,8 @@ impl DxgiCapturer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we didn't find the specific display, use the first one
|
// If we didn't find the specific display, use the first one
|
||||||
let adapter: IDXGIAdapter1 = factory.EnumAdapters1(0)
|
let adapter: IDXGIAdapter1 = factory.EnumAdapters1(0).context("No adapters found")?;
|
||||||
.context("No adapters found")?;
|
let output: IDXGIOutput = adapter.EnumOutputs(0).context("No outputs found")?;
|
||||||
let output: IDXGIOutput = adapter.EnumOutputs(0)
|
|
||||||
.context("No outputs found")?;
|
|
||||||
|
|
||||||
Ok((adapter, output))
|
Ok((adapter, output))
|
||||||
}
|
}
|
||||||
@@ -171,7 +179,8 @@ impl DxgiCapturer {
|
|||||||
desc.MiscFlags = Default::default();
|
desc.MiscFlags = Default::default();
|
||||||
|
|
||||||
let mut staging: Option<ID3D11Texture2D> = None;
|
let mut staging: Option<ID3D11Texture2D> = None;
|
||||||
self.device.CreateTexture2D(&desc, None, Some(&mut staging))
|
self.device
|
||||||
|
.CreateTexture2D(&desc, None, Some(&mut staging))
|
||||||
.context("Failed to create staging texture")?;
|
.context("Failed to create staging texture")?;
|
||||||
|
|
||||||
let staging = staging.context("Staging texture is None")?;
|
let staging = staging.context("Staging texture is None")?;
|
||||||
@@ -188,7 +197,10 @@ impl DxgiCapturer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Acquire the next frame from the desktop
|
/// Acquire the next frame from the desktop
|
||||||
fn acquire_frame(&mut self, timeout_ms: u32) -> Result<Option<(ID3D11Texture2D, DXGI_OUTDUPL_FRAME_INFO)>> {
|
fn acquire_frame(
|
||||||
|
&mut self,
|
||||||
|
timeout_ms: u32,
|
||||||
|
) -> Result<Option<(ID3D11Texture2D, DXGI_OUTDUPL_FRAME_INFO)>> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut frame_info = DXGI_OUTDUPL_FRAME_INFO::default();
|
let mut frame_info = DXGI_OUTDUPL_FRAME_INFO::default();
|
||||||
let mut desktop_resource: Option<IDXGIResource> = None;
|
let mut desktop_resource: Option<IDXGIResource> = None;
|
||||||
@@ -209,7 +221,8 @@ impl DxgiCapturer {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let texture: ID3D11Texture2D = resource.cast()
|
let texture: ID3D11Texture2D = resource
|
||||||
|
.cast()
|
||||||
.context("Failed to cast to ID3D11Texture2D")?;
|
.context("Failed to cast to ID3D11Texture2D")?;
|
||||||
|
|
||||||
Ok(Some((texture, frame_info)))
|
Ok(Some((texture, frame_info)))
|
||||||
@@ -223,9 +236,7 @@ impl DxgiCapturer {
|
|||||||
tracing::warn!("Desktop duplication access lost, will need to recreate");
|
tracing::warn!("Desktop duplication access lost, will need to recreate");
|
||||||
Err(anyhow::anyhow!("Access lost"))
|
Err(anyhow::anyhow!("Access lost"))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => Err(e).context("Failed to acquire frame"),
|
||||||
Err(e).context("Failed to acquire frame")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ use super::{CapturedFrame, Capturer, Display};
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use windows::Win32::Graphics::Gdi::{
|
|
||||||
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject,
|
|
||||||
GetDIBits, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS,
|
|
||||||
SRCCOPY, GetDC, ReleaseDC,
|
|
||||||
};
|
|
||||||
use windows::Win32::Foundation::HWND;
|
use windows::Win32::Foundation::HWND;
|
||||||
|
use windows::Win32::Graphics::Gdi::{
|
||||||
|
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits,
|
||||||
|
ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, SRCCOPY,
|
||||||
|
};
|
||||||
|
|
||||||
/// GDI-based screen capturer
|
/// GDI-based screen capturer
|
||||||
pub struct GdiCapturer {
|
pub struct GdiCapturer {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
//! Provides DXGI Desktop Duplication for high-performance screen capture on Windows 8+,
|
//! Provides DXGI Desktop Duplication for high-performance screen capture on Windows 8+,
|
||||||
//! with GDI fallback for legacy systems or edge cases.
|
//! with GDI fallback for legacy systems or edge cases.
|
||||||
|
|
||||||
|
mod display;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod dxgi;
|
mod dxgi;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod gdi;
|
mod gdi;
|
||||||
mod display;
|
|
||||||
|
|
||||||
pub use display::{Display, DisplayInfo};
|
pub use display::{Display, DisplayInfo};
|
||||||
|
|
||||||
@@ -61,7 +61,11 @@ pub trait Capturer: Send {
|
|||||||
|
|
||||||
/// Create a capturer for the specified display
|
/// Create a capturer for the specified display
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn create_capturer(display: Display, use_dxgi: bool, gdi_fallback: bool) -> Result<Box<dyn Capturer>> {
|
pub fn create_capturer(
|
||||||
|
display: Display,
|
||||||
|
use_dxgi: bool,
|
||||||
|
gdi_fallback: bool,
|
||||||
|
) -> Result<Box<dyn Capturer>> {
|
||||||
if use_dxgi {
|
if use_dxgi {
|
||||||
match dxgi::DxgiCapturer::new(display.clone()) {
|
match dxgi::DxgiCapturer::new(display.clone()) {
|
||||||
Ok(capturer) => {
|
Ok(capturer) => {
|
||||||
@@ -83,7 +87,11 @@ pub fn create_capturer(display: Display, use_dxgi: bool, gdi_fallback: bool) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
pub fn create_capturer(_display: Display, _use_dxgi: bool, _gdi_fallback: bool) -> Result<Box<dyn Capturer>> {
|
pub fn create_capturer(
|
||||||
|
_display: Display,
|
||||||
|
_use_dxgi: bool,
|
||||||
|
_gdi_fallback: bool,
|
||||||
|
) -> Result<Box<dyn Capturer>> {
|
||||||
anyhow::bail!("Screen capture only supported on Windows")
|
anyhow::bail!("Screen capture only supported on Windows")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
use std::sync::mpsc::{self, Receiver, Sender};
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use tracing::{info, warn, error};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
use windows::core::PCWSTR;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::Win32::Foundation::*;
|
use windows::Win32::Foundation::*;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -17,7 +17,7 @@ use windows::Win32::Graphics::Gdi::*;
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::core::PCWSTR;
|
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||||
|
|
||||||
/// A chat message
|
/// A chat message
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -221,11 +221,10 @@ impl Config {
|
|||||||
|
|
||||||
/// Read embedded configuration from the executable
|
/// Read embedded configuration from the executable
|
||||||
pub fn read_embedded_config() -> Result<EmbeddedConfig> {
|
pub fn read_embedded_config() -> Result<EmbeddedConfig> {
|
||||||
let exe_path = std::env::current_exe()
|
let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
|
||||||
.context("Failed to get current executable path")?;
|
|
||||||
|
|
||||||
let mut file = std::fs::File::open(&exe_path)
|
let mut file =
|
||||||
.context("Failed to open executable for reading")?;
|
std::fs::File::open(&exe_path).context("Failed to open executable for reading")?;
|
||||||
|
|
||||||
let file_size = file.metadata()?.len();
|
let file_size = file.metadata()?.len();
|
||||||
if file_size < (MAGIC_MARKER.len() + 4) as u64 {
|
if file_size < (MAGIC_MARKER.len() + 4) as u64 {
|
||||||
@@ -245,7 +244,8 @@ impl Config {
|
|||||||
file.read_exact(&mut buffer)?;
|
file.read_exact(&mut buffer)?;
|
||||||
|
|
||||||
// Find magic marker
|
// Find magic marker
|
||||||
let marker_pos = buffer.windows(MAGIC_MARKER.len())
|
let marker_pos = buffer
|
||||||
|
.windows(MAGIC_MARKER.len())
|
||||||
.rposition(|window| window == MAGIC_MARKER)
|
.rposition(|window| window == MAGIC_MARKER)
|
||||||
.ok_or_else(|| anyhow!("Magic marker not found"))?;
|
.ok_or_else(|| anyhow!("Magic marker not found"))?;
|
||||||
|
|
||||||
@@ -269,11 +269,13 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let config_bytes = &buffer[config_start..config_start + config_length];
|
let config_bytes = &buffer[config_start..config_start + config_length];
|
||||||
let config: EmbeddedConfig = serde_json::from_slice(config_bytes)
|
let config: EmbeddedConfig =
|
||||||
.context("Failed to parse embedded config JSON")?;
|
serde_json::from_slice(config_bytes).context("Failed to parse embedded config JSON")?;
|
||||||
|
|
||||||
info!("Loaded embedded config: server={}, company={:?}",
|
info!(
|
||||||
config.server_url, config.company);
|
"Loaded embedded config: server={}, company={:?}",
|
||||||
|
config.server_url, config.company
|
||||||
|
);
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -338,8 +340,8 @@ impl Config {
|
|||||||
let contents = std::fs::read_to_string(&config_path)
|
let contents = std::fs::read_to_string(&config_path)
|
||||||
.with_context(|| format!("Failed to read config from {:?}", config_path))?;
|
.with_context(|| format!("Failed to read config from {:?}", config_path))?;
|
||||||
|
|
||||||
let mut config: Config = toml::from_str(&contents)
|
let mut config: Config =
|
||||||
.with_context(|| "Failed to parse config file")?;
|
toml::from_str(&contents).with_context(|| "Failed to parse config file")?;
|
||||||
|
|
||||||
// Ensure agent_id is set and saved
|
// Ensure agent_id is set and saved
|
||||||
if config.agent_id.is_empty() {
|
if config.agent_id.is_empty() {
|
||||||
@@ -357,11 +359,11 @@ impl Config {
|
|||||||
let server_url = std::env::var("GURUCONNECT_SERVER_URL")
|
let server_url = std::env::var("GURUCONNECT_SERVER_URL")
|
||||||
.unwrap_or_else(|_| "wss://connect.azcomputerguru.com/ws/agent".to_string());
|
.unwrap_or_else(|_| "wss://connect.azcomputerguru.com/ws/agent".to_string());
|
||||||
|
|
||||||
let api_key = std::env::var("GURUCONNECT_API_KEY")
|
let api_key =
|
||||||
.unwrap_or_else(|_| "dev-key".to_string());
|
std::env::var("GURUCONNECT_API_KEY").unwrap_or_else(|_| "dev-key".to_string());
|
||||||
|
|
||||||
let agent_id = std::env::var("GURUCONNECT_AGENT_ID")
|
let agent_id =
|
||||||
.unwrap_or_else(|_| generate_agent_id());
|
std::env::var("GURUCONNECT_AGENT_ID").unwrap_or_else(|_| generate_agent_id());
|
||||||
|
|
||||||
let config = Config {
|
let config = Config {
|
||||||
server_url,
|
server_url,
|
||||||
@@ -409,13 +411,11 @@ impl Config {
|
|||||||
|
|
||||||
/// Get the hostname to use
|
/// Get the hostname to use
|
||||||
pub fn hostname(&self) -> String {
|
pub fn hostname(&self) -> String {
|
||||||
self.hostname_override
|
self.hostname_override.clone().unwrap_or_else(|| {
|
||||||
.clone()
|
hostname::get()
|
||||||
.unwrap_or_else(|| {
|
.map(|h| h.to_string_lossy().to_string())
|
||||||
hostname::get()
|
.unwrap_or_else(|_| "unknown".to_string())
|
||||||
.map(|h| h.to_string_lossy().to_string())
|
})
|
||||||
.unwrap_or_else(|_| "unknown".to_string())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save current configuration to file
|
/// Save current configuration to file
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ mod raw;
|
|||||||
pub use raw::RawEncoder;
|
pub use raw::RawEncoder;
|
||||||
|
|
||||||
use crate::capture::CapturedFrame;
|
use crate::capture::CapturedFrame;
|
||||||
use crate::proto::{VideoFrame, RawFrame, DirtyRect as ProtoDirtyRect};
|
use crate::proto::{DirtyRect as ProtoDirtyRect, RawFrame, VideoFrame};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
/// Encoded frame ready for transmission
|
/// Encoded frame ready for transmission
|
||||||
|
|||||||
@@ -122,12 +122,7 @@ impl RawEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extract pixels for dirty rectangles only
|
/// Extract pixels for dirty rectangles only
|
||||||
fn extract_dirty_pixels(
|
fn extract_dirty_pixels(&self, data: &[u8], width: u32, dirty_rects: &[DirtyRect]) -> Vec<u8> {
|
||||||
&self,
|
|
||||||
data: &[u8],
|
|
||||||
width: u32,
|
|
||||||
dirty_rects: &[DirtyRect],
|
|
||||||
) -> Vec<u8> {
|
|
||||||
let stride = (width * 4) as usize;
|
let stride = (width * 4) as usize;
|
||||||
let mut pixels = Vec::new();
|
let mut pixels = Vec::new();
|
||||||
|
|
||||||
@@ -174,7 +169,8 @@ impl Encoder for RawEncoder {
|
|||||||
if dirty_rects.len() > 50 {
|
if dirty_rects.len() > 50 {
|
||||||
(frame.data.clone(), Vec::new(), true)
|
(frame.data.clone(), Vec::new(), true)
|
||||||
} else {
|
} else {
|
||||||
let dirty_pixels = self.extract_dirty_pixels(&frame.data, frame.width, &dirty_rects);
|
let dirty_pixels =
|
||||||
|
self.extract_dirty_pixels(&frame.data, frame.width, &dirty_rects);
|
||||||
(dirty_pixels, dirty_rects, false)
|
(dirty_pixels, dirty_rects, false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ use anyhow::Result;
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||||
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBD_EVENT_FLAGS, KEYEVENTF_EXTENDEDKEY,
|
MapVirtualKeyW, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS,
|
||||||
KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, KEYEVENTF_UNICODE, KEYBDINPUT,
|
KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, KEYEVENTF_UNICODE,
|
||||||
MapVirtualKeyW, MAPVK_VK_TO_VSC_EX,
|
MAPVK_VK_TO_VSC_EX,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Keyboard input controller
|
/// Keyboard input controller
|
||||||
@@ -144,8 +144,8 @@ impl KeyboardController {
|
|||||||
tracing::info!("SAS service not available, trying direct sas.dll...");
|
tracing::info!("SAS service not available, trying direct sas.dll...");
|
||||||
|
|
||||||
// Tier 2: Try using the sas.dll directly (requires SYSTEM privileges)
|
// Tier 2: Try using the sas.dll directly (requires SYSTEM privileges)
|
||||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
|
|
||||||
use windows::core::PCWSTR;
|
use windows::core::PCWSTR;
|
||||||
|
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let dll_name: Vec<u16> = "sas.dll\0".encode_utf16().collect();
|
let dll_name: Vec<u16> = "sas.dll\0".encode_utf16().collect();
|
||||||
@@ -195,7 +195,7 @@ impl KeyboardController {
|
|||||||
0x5D | // Applications key
|
0x5D | // Applications key
|
||||||
0x6F | // Numpad Divide
|
0x6F | // Numpad Divide
|
||||||
0x90 | // Num Lock
|
0x90 | // Num Lock
|
||||||
0x91 // Scroll Lock
|
0x91 // Scroll Lock
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,11 +205,7 @@ impl KeyboardController {
|
|||||||
let sent = unsafe { SendInput(inputs, std::mem::size_of::<INPUT>() as i32) };
|
let sent = unsafe { SendInput(inputs, std::mem::size_of::<INPUT>() as i32) };
|
||||||
|
|
||||||
if sent as usize != inputs.len() {
|
if sent as usize != inputs.len() {
|
||||||
anyhow::bail!(
|
anyhow::bail!("SendInput failed: sent {} of {} inputs", sent, inputs.len());
|
||||||
"SendInput failed: sent {} of {} inputs",
|
|
||||||
sent,
|
|
||||||
inputs.len()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -250,7 +246,7 @@ pub mod vk {
|
|||||||
pub const ESCAPE: u16 = 0x1B;
|
pub const ESCAPE: u16 = 0x1B;
|
||||||
pub const SPACE: u16 = 0x20;
|
pub const SPACE: u16 = 0x20;
|
||||||
pub const PRIOR: u16 = 0x21; // Page Up
|
pub const PRIOR: u16 = 0x21; // Page Up
|
||||||
pub const NEXT: u16 = 0x22; // Page Down
|
pub const NEXT: u16 = 0x22; // Page Down
|
||||||
pub const END: u16 = 0x23;
|
pub const END: u16 = 0x23;
|
||||||
pub const HOME: u16 = 0x24;
|
pub const HOME: u16 = 0x24;
|
||||||
pub const LEFT: u16 = 0x25;
|
pub const LEFT: u16 = 0x25;
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
//!
|
//!
|
||||||
//! Handles mouse and keyboard input simulation using Windows SendInput API.
|
//! Handles mouse and keyboard input simulation using Windows SendInput API.
|
||||||
|
|
||||||
mod mouse;
|
|
||||||
mod keyboard;
|
mod keyboard;
|
||||||
|
mod mouse;
|
||||||
|
|
||||||
pub use mouse::MouseController;
|
|
||||||
pub use keyboard::KeyboardController;
|
pub use keyboard::KeyboardController;
|
||||||
|
pub use mouse::MouseController;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ const XBUTTON2: u32 = 0x0002;
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
|
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
|
||||||
SM_YVIRTUALSCREEN,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Mouse input controller
|
/// Mouse input controller
|
||||||
@@ -190,9 +189,7 @@ impl MouseController {
|
|||||||
/// Send input events
|
/// Send input events
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn send_input(&self, inputs: &[INPUT]) -> Result<()> {
|
fn send_input(&self, inputs: &[INPUT]) -> Result<()> {
|
||||||
let sent = unsafe {
|
let sent = unsafe { SendInput(inputs, std::mem::size_of::<INPUT>() as i32) };
|
||||||
SendInput(inputs, std::mem::size_of::<INPUT>() as i32)
|
|
||||||
};
|
|
||||||
|
|
||||||
if sent as usize != inputs.len() {
|
if sent as usize != inputs.len() {
|
||||||
anyhow::bail!("SendInput failed: sent {} of {} inputs", sent, inputs.len());
|
anyhow::bail!("SendInput failed: sent {} of {} inputs", sent, inputs.len());
|
||||||
|
|||||||
@@ -6,18 +6,18 @@
|
|||||||
//! - UAC elevation with graceful fallback
|
//! - UAC elevation with graceful fallback
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use tracing::{info, warn, error};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::{
|
use windows::{
|
||||||
core::PCWSTR,
|
core::PCWSTR,
|
||||||
Win32::Foundation::HANDLE,
|
Win32::Foundation::HANDLE,
|
||||||
Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY},
|
Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY},
|
||||||
Win32::System::Threading::{GetCurrentProcess, OpenProcessToken},
|
|
||||||
Win32::System::Registry::{
|
Win32::System::Registry::{
|
||||||
RegCreateKeyExW, RegSetValueExW, RegCloseKey, HKEY, HKEY_CLASSES_ROOT,
|
RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_CLASSES_ROOT, HKEY_CURRENT_USER,
|
||||||
HKEY_CURRENT_USER, KEY_WRITE, REG_SZ, REG_OPTION_NON_VOLATILE,
|
KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ,
|
||||||
},
|
},
|
||||||
|
Win32::System::Threading::{GetCurrentProcess, OpenProcessToken},
|
||||||
Win32::UI::Shell::ShellExecuteW,
|
Win32::UI::Shell::ShellExecuteW,
|
||||||
Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
|
Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
|
||||||
};
|
};
|
||||||
@@ -67,11 +67,10 @@ pub fn get_install_path(elevated: bool) -> std::path::PathBuf {
|
|||||||
if elevated {
|
if elevated {
|
||||||
std::path::PathBuf::from(SYSTEM_INSTALL_PATH)
|
std::path::PathBuf::from(SYSTEM_INSTALL_PATH)
|
||||||
} else {
|
} else {
|
||||||
let local_app_data = std::env::var("LOCALAPPDATA")
|
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| {
|
||||||
.unwrap_or_else(|_| {
|
let home = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string());
|
||||||
let home = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string());
|
format!(r"{}\AppData\Local", home)
|
||||||
format!(r"{}\AppData\Local", home)
|
});
|
||||||
});
|
|
||||||
std::path::PathBuf::from(local_app_data).join(USER_INSTALL_PATH)
|
std::path::PathBuf::from(local_app_data).join(USER_INSTALL_PATH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,7 +304,7 @@ pub fn install(force_user_install: bool) -> Result<()> {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn is_protocol_handler_registered() -> bool {
|
pub fn is_protocol_handler_registered() -> bool {
|
||||||
use windows::Win32::System::Registry::{
|
use windows::Win32::System::Registry::{
|
||||||
RegOpenKeyExW, RegCloseKey, HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, KEY_READ,
|
RegCloseKey, RegOpenKeyExW, HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, KEY_READ,
|
||||||
};
|
};
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -318,7 +317,9 @@ pub fn is_protocol_handler_registered() -> bool {
|
|||||||
0,
|
0,
|
||||||
KEY_READ,
|
KEY_READ,
|
||||||
&mut key,
|
&mut key,
|
||||||
).is_ok() {
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
let _ = RegCloseKey(key);
|
let _ = RegCloseKey(key);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -331,7 +332,9 @@ pub fn is_protocol_handler_registered() -> bool {
|
|||||||
0,
|
0,
|
||||||
KEY_READ,
|
KEY_READ,
|
||||||
&mut key,
|
&mut key,
|
||||||
).is_ok() {
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
let _ = RegCloseKey(key);
|
let _ = RegCloseKey(key);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -355,22 +358,25 @@ pub fn parse_protocol_url(url_str: &str) -> Result<(String, String, Option<Strin
|
|||||||
//
|
//
|
||||||
// Note: In URL parsing, "view" becomes the host, SESSION_ID is the path
|
// Note: In URL parsing, "view" becomes the host, SESSION_ID is the path
|
||||||
|
|
||||||
let url = url::Url::parse(url_str)
|
let url = url::Url::parse(url_str).map_err(|e| anyhow!("Invalid URL: {}", e))?;
|
||||||
.map_err(|e| anyhow!("Invalid URL: {}", e))?;
|
|
||||||
|
|
||||||
if url.scheme() != "guruconnect" {
|
if url.scheme() != "guruconnect" {
|
||||||
return Err(anyhow!("Invalid scheme: expected guruconnect://"));
|
return Err(anyhow!("Invalid scheme: expected guruconnect://"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The "action" (view/connect) is parsed as the host
|
// The "action" (view/connect) is parsed as the host
|
||||||
let action = url.host_str()
|
let action = url
|
||||||
|
.host_str()
|
||||||
.ok_or_else(|| anyhow!("Missing action in URL"))?;
|
.ok_or_else(|| anyhow!("Missing action in URL"))?;
|
||||||
|
|
||||||
// The session ID is the first path segment
|
// The session ID is the first path segment
|
||||||
let path = url.path().trim_start_matches('/');
|
let path = url.path().trim_start_matches('/');
|
||||||
info!("URL path: '{}', host: '{:?}'", path, url.host_str());
|
info!("URL path: '{}', host: '{:?}'", path, url.host_str());
|
||||||
let session_id = if path.is_empty() {
|
let session_id = if path.is_empty() {
|
||||||
return Err(anyhow!("Invalid URL: Missing session ID (path was empty, full URL: {})", url_str));
|
return Err(anyhow!(
|
||||||
|
"Invalid URL: Missing session ID (path was empty, full URL: {})",
|
||||||
|
url_str
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
path.split('/').next().unwrap_or("").to_string()
|
path.split('/').next().unwrap_or("").to_string()
|
||||||
};
|
};
|
||||||
@@ -411,7 +417,5 @@ fn to_wide(s: &str) -> Vec<u16> {
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn description_to_bytes(wide: &[u16]) -> Vec<u8> {
|
fn description_to_bytes(wide: &[u16]) -> Vec<u8> {
|
||||||
wide.iter()
|
wide.iter().flat_map(|w| w.to_le_bytes()).collect()
|
||||||
.flat_map(|w| w.to_le_bytes())
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,16 +92,18 @@ pub mod build_info {
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use tracing::{info, error, warn, Level};
|
use tracing::{error, info, warn, Level};
|
||||||
use tracing_subscriber::FmtSubscriber;
|
use tracing_subscriber::FmtSubscriber;
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONINFORMATION, MB_ICONERROR};
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::core::PCWSTR;
|
use windows::core::PCWSTR;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
|
use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
MessageBoxW, MB_ICONERROR, MB_ICONINFORMATION, MB_OK,
|
||||||
|
};
|
||||||
|
#[cfg(windows)]
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
|
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
|
||||||
|
|
||||||
/// GuruConnect Remote Desktop
|
/// GuruConnect Remote Desktop
|
||||||
@@ -140,7 +142,11 @@ enum Commands {
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
|
|
||||||
/// Server URL
|
/// Server URL
|
||||||
#[arg(short, long, default_value = "wss://connect.azcomputerguru.com/ws/viewer")]
|
#[arg(
|
||||||
|
short,
|
||||||
|
long,
|
||||||
|
default_value = "wss://connect.azcomputerguru.com/ws/viewer"
|
||||||
|
)]
|
||||||
server: String,
|
server: String,
|
||||||
|
|
||||||
/// API key for authentication
|
/// API key for authentication
|
||||||
@@ -177,15 +183,27 @@ fn main() -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
let level = if cli.verbose { Level::DEBUG } else { Level::INFO };
|
let level = if cli.verbose {
|
||||||
|
Level::DEBUG
|
||||||
|
} else {
|
||||||
|
Level::INFO
|
||||||
|
};
|
||||||
FmtSubscriber::builder()
|
FmtSubscriber::builder()
|
||||||
.with_max_level(level)
|
.with_max_level(level)
|
||||||
.with_target(true)
|
.with_target(true)
|
||||||
.with_thread_ids(true)
|
.with_thread_ids(true)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
info!("GuruConnect {} ({})", build_info::short_version(), build_info::BUILD_TARGET);
|
info!(
|
||||||
info!("Built: {} | Commit: {}", build_info::BUILD_TIMESTAMP, build_info::GIT_COMMIT_DATE);
|
"GuruConnect {} ({})",
|
||||||
|
build_info::short_version(),
|
||||||
|
build_info::BUILD_TARGET
|
||||||
|
);
|
||||||
|
info!(
|
||||||
|
"Built: {} | Commit: {}",
|
||||||
|
build_info::BUILD_TIMESTAMP,
|
||||||
|
build_info::GIT_COMMIT_DATE
|
||||||
|
);
|
||||||
|
|
||||||
// Handle post-update cleanup
|
// Handle post-update cleanup
|
||||||
if cli.post_update {
|
if cli.post_update {
|
||||||
@@ -194,21 +212,18 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Some(Commands::Agent { code }) => {
|
Some(Commands::Agent { code }) => run_agent_mode(code),
|
||||||
run_agent_mode(code)
|
Some(Commands::View {
|
||||||
}
|
session_id,
|
||||||
Some(Commands::View { session_id, server, api_key }) => {
|
server,
|
||||||
run_viewer_mode(&server, &session_id, &api_key)
|
api_key,
|
||||||
}
|
}) => run_viewer_mode(&server, &session_id, &api_key),
|
||||||
Some(Commands::Install { user_only, elevated }) => {
|
Some(Commands::Install {
|
||||||
run_install(user_only || elevated)
|
user_only,
|
||||||
}
|
elevated,
|
||||||
Some(Commands::Uninstall) => {
|
}) => run_install(user_only || elevated),
|
||||||
run_uninstall()
|
Some(Commands::Uninstall) => run_uninstall(),
|
||||||
}
|
Some(Commands::Launch { url }) => run_launch(&url),
|
||||||
Some(Commands::Launch { url }) => {
|
|
||||||
run_launch(&url)
|
|
||||||
}
|
|
||||||
Some(Commands::VersionInfo) => {
|
Some(Commands::VersionInfo) => {
|
||||||
// Show detailed version info (allocate console on Windows for visibility)
|
// Show detailed version info (allocate console on Windows for visibility)
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -341,7 +356,10 @@ fn run_install(force_user_install: bool) -> Result<()> {
|
|||||||
|
|
||||||
match install::install(force_user_install) {
|
match install::install(force_user_install) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
show_message_box("GuruConnect", "Installation complete!\n\nYou can now use guruconnect:// links.");
|
show_message_box(
|
||||||
|
"GuruConnect",
|
||||||
|
"Installation complete!\n\nYou can now use guruconnect:// links.",
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -467,7 +485,11 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create tray icon
|
// Create tray icon
|
||||||
let tray = match tray::TrayController::new(&hostname, config.support_code.as_deref(), is_support_session) {
|
let tray = match tray::TrayController::new(
|
||||||
|
&hostname,
|
||||||
|
config.support_code.as_deref(),
|
||||||
|
is_support_session,
|
||||||
|
) {
|
||||||
Ok(t) => {
|
Ok(t) => {
|
||||||
info!("Tray icon created");
|
info!("Tray icon created");
|
||||||
Some(t)
|
Some(t)
|
||||||
@@ -503,7 +525,10 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
|||||||
t.update_status("Status: Connected");
|
t.update_status("Status: Connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = session.run_with_tray(tray.as_ref(), chat_ctrl.as_ref()).await {
|
if let Err(e) = session
|
||||||
|
.run_with_tray(tray.as_ref(), chat_ctrl.as_ref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
let error_msg = e.to_string();
|
let error_msg = e.to_string();
|
||||||
|
|
||||||
if error_msg.contains("USER_EXIT") {
|
if error_msg.contains("USER_EXIT") {
|
||||||
@@ -515,7 +540,10 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
|||||||
if error_msg.contains("SESSION_CANCELLED") {
|
if error_msg.contains("SESSION_CANCELLED") {
|
||||||
info!("Session was cancelled by technician");
|
info!("Session was cancelled by technician");
|
||||||
cleanup_on_exit();
|
cleanup_on_exit();
|
||||||
show_message_box("Support Session Ended", "The support session was cancelled.");
|
show_message_box(
|
||||||
|
"Support Session Ended",
|
||||||
|
"The support session was cancelled.",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +552,10 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
|||||||
if let Err(e) = startup::uninstall() {
|
if let Err(e) = startup::uninstall() {
|
||||||
warn!("Uninstall failed: {}", e);
|
warn!("Uninstall failed: {}", e);
|
||||||
}
|
}
|
||||||
show_message_box("Remote Session Ended", "The session was ended by the administrator.");
|
show_message_box(
|
||||||
|
"Remote Session Ended",
|
||||||
|
"The session was ended by the administrator.",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +564,10 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
|||||||
if let Err(e) = startup::uninstall() {
|
if let Err(e) = startup::uninstall() {
|
||||||
warn!("Uninstall failed: {}", e);
|
warn!("Uninstall failed: {}", e);
|
||||||
}
|
}
|
||||||
show_message_box("GuruConnect Removed", "This computer has been removed from remote management.");
|
show_message_box(
|
||||||
|
"GuruConnect Removed",
|
||||||
|
"This computer has been removed from remote management.",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +585,10 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
|||||||
if error_msg.contains("cancelled") {
|
if error_msg.contains("cancelled") {
|
||||||
info!("Support code was cancelled");
|
info!("Support code was cancelled");
|
||||||
cleanup_on_exit();
|
cleanup_on_exit();
|
||||||
show_message_box("Support Session Cancelled", "This support session has been cancelled.");
|
show_message_box(
|
||||||
|
"Support Session Cancelled",
|
||||||
|
"This support session has been cancelled.",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,7 @@ pub fn request_sas() -> Result<()> {
|
|||||||
info!("Requesting SAS via service pipe...");
|
info!("Requesting SAS via service pipe...");
|
||||||
|
|
||||||
// Try to connect to the pipe
|
// Try to connect to the pipe
|
||||||
let mut pipe = match OpenOptions::new()
|
let mut pipe = match OpenOptions::new().read(true).write(true).open(PIPE_NAME) {
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(PIPE_NAME)
|
|
||||||
{
|
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to connect to SAS service pipe: {}", e);
|
warn!("Failed to connect to SAS service pipe: {}", e);
|
||||||
@@ -40,7 +36,8 @@ pub fn request_sas() -> Result<()> {
|
|||||||
|
|
||||||
// Read the response
|
// Read the response
|
||||||
let mut response = [0u8; 64];
|
let mut response = [0u8; 64];
|
||||||
let n = pipe.read(&mut response)
|
let n = pipe
|
||||||
|
.read(&mut response)
|
||||||
.context("Failed to read response from SAS service")?;
|
.context("Failed to read response from SAS service")?;
|
||||||
|
|
||||||
let response_str = String::from_utf8_lossy(&response[..n]);
|
let response_str = String::from_utf8_lossy(&response[..n]);
|
||||||
@@ -59,7 +56,10 @@ pub fn request_sas() -> Result<()> {
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
error!("Unexpected response from SAS service: {}", response_str);
|
error!("Unexpected response from SAS service: {}", response_str);
|
||||||
Err(anyhow::anyhow!("Unexpected SAS service response: {}", response_str))
|
Err(anyhow::anyhow!(
|
||||||
|
"Unexpected SAS service response: {}",
|
||||||
|
response_str
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,11 +67,7 @@ pub fn request_sas() -> Result<()> {
|
|||||||
/// Check if the SAS service is available
|
/// Check if the SAS service is available
|
||||||
pub fn is_service_available() -> bool {
|
pub fn is_service_available() -> bool {
|
||||||
// Try to open the pipe
|
// Try to open the pipe
|
||||||
if let Ok(mut pipe) = OpenOptions::new()
|
if let Ok(mut pipe) = OpenOptions::new().read(true).write(true).open(PIPE_NAME) {
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(PIPE_NAME)
|
|
||||||
{
|
|
||||||
// Send a ping command
|
// Send a ping command
|
||||||
if pipe.write_all(b"ping\n").is_ok() {
|
if pipe.write_all(b"ping\n").is_ok() {
|
||||||
let mut response = [0u8; 64];
|
let mut response = [0u8; 64];
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ fn show_debug_console() {
|
|||||||
// No-op on non-Windows platforms
|
// No-op on non-Windows platforms
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::proto::{Message, message, ChatMessage, AgentStatus, Heartbeat, HeartbeatAck};
|
use crate::proto::{message, AgentStatus, ChatMessage, Heartbeat, HeartbeatAck, Message};
|
||||||
use crate::transport::WebSocketTransport;
|
use crate::transport::WebSocketTransport;
|
||||||
use crate::tray::{TrayController, TrayAction};
|
use crate::tray::{TrayAction, TrayController};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -71,8 +71,8 @@ pub struct SessionManager {
|
|||||||
enum SessionState {
|
enum SessionState {
|
||||||
Disconnected,
|
Disconnected,
|
||||||
Connecting,
|
Connecting,
|
||||||
Idle, // Connected but not streaming - minimal resource usage
|
Idle, // Connected but not streaming - minimal resource usage
|
||||||
Streaming, // Actively capturing and sending frames
|
Streaming, // Actively capturing and sending frames
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionManager {
|
impl SessionManager {
|
||||||
@@ -103,10 +103,11 @@ impl SessionManager {
|
|||||||
&self.config.api_key,
|
&self.config.api_key,
|
||||||
Some(&self.hostname),
|
Some(&self.hostname),
|
||||||
self.config.support_code.as_deref(),
|
self.config.support_code.as_deref(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
self.transport = Some(transport);
|
self.transport = Some(transport);
|
||||||
self.state = SessionState::Idle; // Start in idle mode
|
self.state = SessionState::Idle; // Start in idle mode
|
||||||
|
|
||||||
tracing::info!("Connected to server, entering idle mode");
|
tracing::info!("Connected to server, entering idle mode");
|
||||||
|
|
||||||
@@ -120,8 +121,12 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("Initializing streaming resources...");
|
tracing::info!("Initializing streaming resources...");
|
||||||
tracing::info!("Capture config: use_dxgi={}, gdi_fallback={}, fps={}",
|
tracing::info!(
|
||||||
self.config.capture.use_dxgi, self.config.capture.gdi_fallback, self.config.capture.fps);
|
"Capture config: use_dxgi={}, gdi_fallback={}, fps={}",
|
||||||
|
self.config.capture.use_dxgi,
|
||||||
|
self.config.capture.gdi_fallback,
|
||||||
|
self.config.capture.fps
|
||||||
|
);
|
||||||
|
|
||||||
// Get primary display with panic protection
|
// Get primary display with panic protection
|
||||||
tracing::debug!("Enumerating displays...");
|
tracing::debug!("Enumerating displays...");
|
||||||
@@ -132,12 +137,19 @@ impl SessionManager {
|
|||||||
return Err(anyhow::anyhow!("Display enumeration panicked"));
|
return Err(anyhow::anyhow!("Display enumeration panicked"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tracing::info!("Using display: {} ({}x{})",
|
tracing::info!(
|
||||||
primary_display.name, primary_display.width, primary_display.height);
|
"Using display: {} ({}x{})",
|
||||||
|
primary_display.name,
|
||||||
|
primary_display.width,
|
||||||
|
primary_display.height
|
||||||
|
);
|
||||||
|
|
||||||
// Create capturer with panic protection
|
// Create capturer with panic protection
|
||||||
// Force GDI mode if DXGI fails or panics
|
// Force GDI mode if DXGI fails or panics
|
||||||
tracing::debug!("Creating capturer (DXGI={})...", self.config.capture.use_dxgi);
|
tracing::debug!(
|
||||||
|
"Creating capturer (DXGI={})...",
|
||||||
|
self.config.capture.use_dxgi
|
||||||
|
);
|
||||||
let capturer = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
let capturer = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
capture::create_capturer(
|
capture::create_capturer(
|
||||||
primary_display.clone(),
|
primary_display.clone(),
|
||||||
@@ -157,13 +169,13 @@ impl SessionManager {
|
|||||||
tracing::info!("Capturer created successfully");
|
tracing::info!("Capturer created successfully");
|
||||||
|
|
||||||
// Create encoder with panic protection
|
// Create encoder with panic protection
|
||||||
tracing::debug!("Creating encoder (codec={}, quality={})...",
|
tracing::debug!(
|
||||||
self.config.encoding.codec, self.config.encoding.quality);
|
"Creating encoder (codec={}, quality={})...",
|
||||||
|
self.config.encoding.codec,
|
||||||
|
self.config.encoding.quality
|
||||||
|
);
|
||||||
let encoder = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
let encoder = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
encoder::create_encoder(
|
encoder::create_encoder(&self.config.encoding.codec, self.config.encoding.quality)
|
||||||
&self.config.encoding.codec,
|
|
||||||
self.config.encoding.quality,
|
|
||||||
)
|
|
||||||
})) {
|
})) {
|
||||||
Ok(result) => result?,
|
Ok(result) => result?,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -202,7 +214,9 @@ impl SessionManager {
|
|||||||
|
|
||||||
/// Get display count for status reports
|
/// Get display count for status reports
|
||||||
fn get_display_count(&self) -> i32 {
|
fn get_display_count(&self) -> i32 {
|
||||||
capture::enumerate_displays().map(|d| d.len() as i32).unwrap_or(1)
|
capture::enumerate_displays()
|
||||||
|
.map(|d| d.len() as i32)
|
||||||
|
.unwrap_or(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send agent status to server
|
/// Send agent status to server
|
||||||
@@ -249,7 +263,11 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run the session main loop with tray and chat event processing
|
/// Run the session main loop with tray and chat event processing
|
||||||
pub async fn run_with_tray(&mut self, tray: Option<&TrayController>, chat: Option<&ChatController>) -> Result<()> {
|
pub async fn run_with_tray(
|
||||||
|
&mut self,
|
||||||
|
tray: Option<&TrayController>,
|
||||||
|
chat: Option<&ChatController>,
|
||||||
|
) -> Result<()> {
|
||||||
if self.transport.is_none() {
|
if self.transport.is_none() {
|
||||||
anyhow::bail!("Not connected");
|
anyhow::bail!("Not connected");
|
||||||
}
|
}
|
||||||
@@ -395,12 +413,23 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Periodic update check (only for persistent agents, not support sessions)
|
// Periodic update check (only for persistent agents, not support sessions)
|
||||||
if self.config.support_code.is_none() && last_update_check.elapsed() >= UPDATE_CHECK_INTERVAL {
|
if self.config.support_code.is_none()
|
||||||
|
&& last_update_check.elapsed() >= UPDATE_CHECK_INTERVAL
|
||||||
|
{
|
||||||
last_update_check = Instant::now();
|
last_update_check = Instant::now();
|
||||||
let server_url = self.config.server_url.replace("/ws/agent", "").replace("wss://", "https://").replace("ws://", "http://");
|
let server_url = self
|
||||||
|
.config
|
||||||
|
.server_url
|
||||||
|
.replace("/ws/agent", "")
|
||||||
|
.replace("wss://", "https://")
|
||||||
|
.replace("ws://", "http://");
|
||||||
match crate::update::check_for_update(&server_url).await {
|
match crate::update::check_for_update(&server_url).await {
|
||||||
Ok(Some(version_info)) => {
|
Ok(Some(version_info)) => {
|
||||||
tracing::info!("Update available: {} -> {}", crate::build_info::VERSION, version_info.latest_version);
|
tracing::info!(
|
||||||
|
"Update available: {} -> {}",
|
||||||
|
crate::build_info::VERSION,
|
||||||
|
version_info.latest_version
|
||||||
|
);
|
||||||
if let Err(e) = crate::update::perform_update(&version_info).await {
|
if let Err(e) = crate::update::perform_update(&version_info).await {
|
||||||
tracing::error!("Auto-update failed: {}", e);
|
tracing::error!("Auto-update failed: {}", e);
|
||||||
}
|
}
|
||||||
@@ -429,7 +458,9 @@ impl SessionManager {
|
|||||||
if let Ok(encoded) = encoder.encode(&frame) {
|
if let Ok(encoded) = encoder.encode(&frame) {
|
||||||
if encoded.size > 0 {
|
if encoded.size > 0 {
|
||||||
let msg = Message {
|
let msg = Message {
|
||||||
payload: Some(message::Payload::VideoFrame(encoded.frame)),
|
payload: Some(message::Payload::VideoFrame(
|
||||||
|
encoded.frame,
|
||||||
|
)),
|
||||||
};
|
};
|
||||||
let transport = self.transport.as_mut().unwrap();
|
let transport = self.transport.as_mut().unwrap();
|
||||||
if let Err(e) = transport.send(msg).await {
|
if let Err(e) = transport.send(msg).await {
|
||||||
@@ -472,26 +503,40 @@ impl SessionManager {
|
|||||||
match msg.payload {
|
match msg.payload {
|
||||||
Some(message::Payload::MouseEvent(mouse)) => {
|
Some(message::Payload::MouseEvent(mouse)) => {
|
||||||
if let Some(input) = self.input.as_mut() {
|
if let Some(input) = self.input.as_mut() {
|
||||||
use crate::proto::MouseEventType;
|
|
||||||
use crate::input::MouseButton;
|
use crate::input::MouseButton;
|
||||||
|
use crate::proto::MouseEventType;
|
||||||
|
|
||||||
match MouseEventType::try_from(mouse.event_type).unwrap_or(MouseEventType::MouseMove) {
|
match MouseEventType::try_from(mouse.event_type)
|
||||||
|
.unwrap_or(MouseEventType::MouseMove)
|
||||||
|
{
|
||||||
MouseEventType::MouseMove => {
|
MouseEventType::MouseMove => {
|
||||||
input.mouse_move(mouse.x, mouse.y)?;
|
input.mouse_move(mouse.x, mouse.y)?;
|
||||||
}
|
}
|
||||||
MouseEventType::MouseDown => {
|
MouseEventType::MouseDown => {
|
||||||
input.mouse_move(mouse.x, mouse.y)?;
|
input.mouse_move(mouse.x, mouse.y)?;
|
||||||
if let Some(ref buttons) = mouse.buttons {
|
if let Some(ref buttons) = mouse.buttons {
|
||||||
if buttons.left { input.mouse_click(MouseButton::Left, true)?; }
|
if buttons.left {
|
||||||
if buttons.right { input.mouse_click(MouseButton::Right, true)?; }
|
input.mouse_click(MouseButton::Left, true)?;
|
||||||
if buttons.middle { input.mouse_click(MouseButton::Middle, true)?; }
|
}
|
||||||
|
if buttons.right {
|
||||||
|
input.mouse_click(MouseButton::Right, true)?;
|
||||||
|
}
|
||||||
|
if buttons.middle {
|
||||||
|
input.mouse_click(MouseButton::Middle, true)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MouseEventType::MouseUp => {
|
MouseEventType::MouseUp => {
|
||||||
if let Some(ref buttons) = mouse.buttons {
|
if let Some(ref buttons) = mouse.buttons {
|
||||||
if buttons.left { input.mouse_click(MouseButton::Left, false)?; }
|
if buttons.left {
|
||||||
if buttons.right { input.mouse_click(MouseButton::Right, false)?; }
|
input.mouse_click(MouseButton::Left, false)?;
|
||||||
if buttons.middle { input.mouse_click(MouseButton::Middle, false)?; }
|
}
|
||||||
|
if buttons.right {
|
||||||
|
input.mouse_click(MouseButton::Right, false)?;
|
||||||
|
}
|
||||||
|
if buttons.middle {
|
||||||
|
input.mouse_click(MouseButton::Middle, false)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MouseEventType::MouseWheel => {
|
MouseEventType::MouseWheel => {
|
||||||
@@ -538,10 +583,19 @@ impl SessionManager {
|
|||||||
tracing::info!("Update command received from server: {}", cmd.reason);
|
tracing::info!("Update command received from server: {}", cmd.reason);
|
||||||
// Trigger update check and perform update if available
|
// Trigger update check and perform update if available
|
||||||
// The server URL is derived from the config
|
// The server URL is derived from the config
|
||||||
let server_url = self.config.server_url.replace("/ws/agent", "").replace("wss://", "https://").replace("ws://", "http://");
|
let server_url = self
|
||||||
|
.config
|
||||||
|
.server_url
|
||||||
|
.replace("/ws/agent", "")
|
||||||
|
.replace("wss://", "https://")
|
||||||
|
.replace("ws://", "http://");
|
||||||
match crate::update::check_for_update(&server_url).await {
|
match crate::update::check_for_update(&server_url).await {
|
||||||
Ok(Some(version_info)) => {
|
Ok(Some(version_info)) => {
|
||||||
tracing::info!("Update available: {} -> {}", crate::build_info::VERSION, version_info.latest_version);
|
tracing::info!(
|
||||||
|
"Update available: {} -> {}",
|
||||||
|
crate::build_info::VERSION,
|
||||||
|
version_info.latest_version
|
||||||
|
);
|
||||||
if let Err(e) = crate::update::perform_update(&version_info).await {
|
if let Err(e) = crate::update::perform_update(&version_info).await {
|
||||||
tracing::error!("Update failed: {}", e);
|
tracing::error!("Update failed: {}", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
//! Handles adding/removing the agent from Windows startup.
|
//! Handles adding/removing the agent from Windows startup.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing::{info, warn, error};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
use windows::Win32::System::Registry::{
|
|
||||||
RegOpenKeyExW, RegSetValueExW, RegDeleteValueW, RegCloseKey,
|
|
||||||
HKEY_CURRENT_USER, KEY_WRITE, REG_SZ,
|
|
||||||
};
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::core::PCWSTR;
|
use windows::core::PCWSTR;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::System::Registry::{
|
||||||
|
RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegSetValueExW, HKEY_CURRENT_USER, KEY_WRITE,
|
||||||
|
REG_SZ,
|
||||||
|
};
|
||||||
|
|
||||||
const STARTUP_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
|
const STARTUP_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||||
const STARTUP_VALUE_NAME: &str = "GuruConnect";
|
const STARTUP_VALUE_NAME: &str = "GuruConnect";
|
||||||
@@ -61,10 +61,8 @@ pub fn add_to_startup() -> Result<()> {
|
|||||||
let hkey_raw = std::mem::transmute::<_, windows::Win32::System::Registry::HKEY>(hkey);
|
let hkey_raw = std::mem::transmute::<_, windows::Win32::System::Registry::HKEY>(hkey);
|
||||||
|
|
||||||
// Set the value
|
// Set the value
|
||||||
let data_bytes = std::slice::from_raw_parts(
|
let data_bytes =
|
||||||
value_data.as_ptr() as *const u8,
|
std::slice::from_raw_parts(value_data.as_ptr() as *const u8, value_data.len() * 2);
|
||||||
value_data.len() * 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
let set_result = RegSetValueExW(
|
let set_result = RegSetValueExW(
|
||||||
hkey_raw,
|
hkey_raw,
|
||||||
@@ -168,7 +166,10 @@ pub fn uninstall() -> Result<()> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
warn!("Failed to schedule file deletion: {:?}. File may need manual removal.", result);
|
warn!(
|
||||||
|
"Failed to schedule file deletion: {:?}. File may need manual removal.",
|
||||||
|
result
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
info!("Executable scheduled for deletion on reboot");
|
info!("Executable scheduled for deletion on reboot");
|
||||||
}
|
}
|
||||||
@@ -185,12 +186,15 @@ pub fn install_sas_service() -> Result<()> {
|
|||||||
|
|
||||||
// Check if the SAS service binary exists alongside the agent
|
// Check if the SAS service binary exists alongside the agent
|
||||||
let exe_path = std::env::current_exe()?;
|
let exe_path = std::env::current_exe()?;
|
||||||
let exe_dir = exe_path.parent().ok_or_else(|| anyhow::anyhow!("No parent directory"))?;
|
let exe_dir = exe_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No parent directory"))?;
|
||||||
let sas_binary = exe_dir.join("guruconnect-sas-service.exe");
|
let sas_binary = exe_dir.join("guruconnect-sas-service.exe");
|
||||||
|
|
||||||
if !sas_binary.exists() {
|
if !sas_binary.exists() {
|
||||||
// Also check in Program Files
|
// Also check in Program Files
|
||||||
let program_files = std::path::PathBuf::from(r"C:\Program Files\GuruConnect\guruconnect-sas-service.exe");
|
let program_files =
|
||||||
|
std::path::PathBuf::from(r"C:\Program Files\GuruConnect\guruconnect-sas-service.exe");
|
||||||
if !program_files.exists() {
|
if !program_files.exists() {
|
||||||
warn!("SAS service binary not found");
|
warn!("SAS service binary not found");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -232,16 +236,18 @@ pub fn uninstall_sas_service() -> Result<()> {
|
|||||||
|
|
||||||
// Try to find and run the uninstall command
|
// Try to find and run the uninstall command
|
||||||
let paths = [
|
let paths = [
|
||||||
std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.join("guruconnect-sas-service.exe"))),
|
std::env::current_exe()
|
||||||
Some(std::path::PathBuf::from(r"C:\Program Files\GuruConnect\guruconnect-sas-service.exe")),
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(|d| d.join("guruconnect-sas-service.exe"))),
|
||||||
|
Some(std::path::PathBuf::from(
|
||||||
|
r"C:\Program Files\GuruConnect\guruconnect-sas-service.exe",
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
|
|
||||||
for path_opt in paths.iter() {
|
for path_opt in paths.iter() {
|
||||||
if let Some(ref path) = path_opt {
|
if let Some(ref path) = path_opt {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let output = std::process::Command::new(path)
|
let output = std::process::Command::new(path).arg("uninstall").output();
|
||||||
.arg("uninstall")
|
|
||||||
.output();
|
|
||||||
|
|
||||||
if let Ok(result) = output {
|
if let Ok(result) = output {
|
||||||
if result.status.success() {
|
if result.status.success() {
|
||||||
|
|||||||
@@ -103,11 +103,7 @@ impl WebSocketTransport {
|
|||||||
let mut stream = stream.lock().await;
|
let mut stream = stream.lock().await;
|
||||||
|
|
||||||
// Use try_next for non-blocking receive
|
// Use try_next for non-blocking receive
|
||||||
match tokio::time::timeout(
|
match tokio::time::timeout(std::time::Duration::from_millis(1), stream.next()).await
|
||||||
std::time::Duration::from_millis(1),
|
|
||||||
stream.next(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(Some(Ok(ws_msg))) => Ok(Some(ws_msg)),
|
Ok(Some(Ok(ws_msg))) => Ok(Some(ws_msg)),
|
||||||
Ok(Some(Err(e))) => Err(anyhow::anyhow!("WebSocket error: {}", e)),
|
Ok(Some(Err(e))) => Err(anyhow::anyhow!("WebSocket error: {}", e)),
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ use anyhow::Result;
|
|||||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
PeekMessageW, TranslateMessage, DispatchMessageW, MSG, PM_REMOVE,
|
DispatchMessageW, PeekMessageW, TranslateMessage, MSG, PM_REMOVE,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Events that can be triggered from the tray menu
|
/// Events that can be triggered from the tray menu
|
||||||
@@ -38,7 +38,11 @@ pub struct TrayController {
|
|||||||
impl TrayController {
|
impl TrayController {
|
||||||
/// Create a new tray controller
|
/// Create a new tray controller
|
||||||
/// `allow_end_session` - If true, show "End Session" menu item (only for support sessions)
|
/// `allow_end_session` - If true, show "End Session" menu item (only for support sessions)
|
||||||
pub fn new(machine_name: &str, support_code: Option<&str>, allow_end_session: bool) -> Result<Self> {
|
pub fn new(
|
||||||
|
machine_name: &str,
|
||||||
|
support_code: Option<&str>,
|
||||||
|
allow_end_session: bool,
|
||||||
|
) -> Result<Self> {
|
||||||
// Create menu items
|
// Create menu items
|
||||||
let status_text = if let Some(code) = support_code {
|
let status_text = if let Some(code) = support_code {
|
||||||
format!("Support Session: {}", code)
|
format!("Support Session: {}", code)
|
||||||
@@ -166,9 +170,9 @@ fn create_default_icon() -> Result<Icon> {
|
|||||||
|
|
||||||
if dist <= radius {
|
if dist <= radius {
|
||||||
// Green circle
|
// Green circle
|
||||||
rgba[idx] = 76; // R
|
rgba[idx] = 76; // R
|
||||||
rgba[idx + 1] = 175; // G
|
rgba[idx + 1] = 175; // G
|
||||||
rgba[idx + 2] = 80; // B
|
rgba[idx + 2] = 80; // B
|
||||||
rgba[idx + 3] = 255; // A
|
rgba[idx + 3] = 255; // A
|
||||||
} else if dist <= radius + 1.0 {
|
} else if dist <= radius + 1.0 {
|
||||||
// Anti-aliased edge
|
// Anti-aliased edge
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
//! in-place binary replacement with restart.
|
//! in-place binary replacement with restart.
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use sha2::{Sha256, Digest};
|
use sha2::{Digest, Sha256};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tracing::{info, warn, error};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::build_info;
|
use crate::build_info;
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ pub async fn check_for_update(server_base_url: &str) -> Result<Option<VersionInf
|
|||||||
info!("Checking for updates at {}", url);
|
info!("Checking for updates at {}", url);
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.danger_accept_invalid_certs(true) // For self-signed certs in dev
|
.danger_accept_invalid_certs(true) // For self-signed certs in dev
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
@@ -79,11 +79,8 @@ fn is_newer_version(available: &str, current: &str) -> bool {
|
|||||||
let available_clean = available.split('-').next().unwrap_or(available);
|
let available_clean = available.split('-').next().unwrap_or(available);
|
||||||
let current_clean = current.split('-').next().unwrap_or(current);
|
let current_clean = current.split('-').next().unwrap_or(current);
|
||||||
|
|
||||||
let parse_version = |s: &str| -> Vec<u32> {
|
let parse_version =
|
||||||
s.split('.')
|
|s: &str| -> Vec<u32> { s.split('.').filter_map(|p| p.parse().ok()).collect() };
|
||||||
.filter_map(|p| p.parse().ok())
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
let av = parse_version(available_clean);
|
let av = parse_version(available_clean);
|
||||||
let cv = parse_version(current_clean);
|
let cv = parse_version(current_clean);
|
||||||
@@ -112,7 +109,7 @@ pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
|
|||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.get(&version_info.download_url)
|
.get(&version_info.download_url)
|
||||||
.timeout(std::time::Duration::from_secs(300)) // 5 minutes for large files
|
.timeout(std::time::Duration::from_secs(300)) // 5 minutes for large files
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -147,7 +144,10 @@ pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<boo
|
|||||||
if matches {
|
if matches {
|
||||||
info!("Checksum verified: {}", computed);
|
info!("Checksum verified: {}", computed);
|
||||||
} else {
|
} else {
|
||||||
error!("Checksum mismatch! Expected: {}, Got: {}", expected_sha256, computed);
|
error!(
|
||||||
|
"Checksum mismatch! Expected: {}, Got: {}",
|
||||||
|
expected_sha256, computed
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(matches)
|
Ok(matches)
|
||||||
@@ -160,7 +160,8 @@ pub fn install_update(temp_path: &PathBuf) -> Result<PathBuf> {
|
|||||||
|
|
||||||
// Get current executable path
|
// Get current executable path
|
||||||
let current_exe = std::env::current_exe()?;
|
let current_exe = std::env::current_exe()?;
|
||||||
let exe_dir = current_exe.parent()
|
let exe_dir = current_exe
|
||||||
|
.parent()
|
||||||
.ok_or_else(|| anyhow!("Cannot get executable directory"))?;
|
.ok_or_else(|| anyhow!("Cannot get executable directory"))?;
|
||||||
|
|
||||||
// Create paths for backup and new executable
|
// Create paths for backup and new executable
|
||||||
@@ -257,10 +258,11 @@ pub fn cleanup_post_update() {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn schedule_delete_on_reboot(path: &PathBuf) {
|
fn schedule_delete_on_reboot(path: &PathBuf) {
|
||||||
use std::os::windows::ffi::OsStrExt;
|
use std::os::windows::ffi::OsStrExt;
|
||||||
use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT};
|
|
||||||
use windows::core::PCWSTR;
|
use windows::core::PCWSTR;
|
||||||
|
use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT};
|
||||||
|
|
||||||
let path_wide: Vec<u16> = path.as_os_str()
|
let path_wide: Vec<u16> = path
|
||||||
|
.as_os_str()
|
||||||
.encode_wide()
|
.encode_wide()
|
||||||
.chain(std::iter::once(0))
|
.chain(std::iter::once(0))
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ mod vk {
|
|||||||
pub const VK_RSHIFT: u32 = 0xA1;
|
pub const VK_RSHIFT: u32 = 0xA1;
|
||||||
pub const VK_LCONTROL: u32 = 0xA2;
|
pub const VK_LCONTROL: u32 = 0xA2;
|
||||||
pub const VK_RCONTROL: u32 = 0xA3;
|
pub const VK_RCONTROL: u32 = 0xA3;
|
||||||
pub const VK_LMENU: u32 = 0xA4; // Left Alt
|
pub const VK_LMENU: u32 = 0xA4; // Left Alt
|
||||||
pub const VK_RMENU: u32 = 0xA5; // Right Alt
|
pub const VK_RMENU: u32 = 0xA5; // Right Alt
|
||||||
pub const VK_TAB: u32 = 0x09;
|
pub const VK_TAB: u32 = 0x09;
|
||||||
pub const VK_ESCAPE: u32 = 0x1B;
|
pub const VK_ESCAPE: u32 = 0x1B;
|
||||||
pub const VK_SNAPSHOT: u32 = 0x2C; // Print Screen
|
pub const VK_SNAPSHOT: u32 = 0x2C; // Print Screen
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -53,15 +53,12 @@ pub struct KeyboardHook {
|
|||||||
impl KeyboardHook {
|
impl KeyboardHook {
|
||||||
pub fn new(input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
|
pub fn new(input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
|
||||||
// Store the sender globally for the hook callback
|
// Store the sender globally for the hook callback
|
||||||
INPUT_TX.set(input_tx).map_err(|_| anyhow::anyhow!("Input TX already set"))?;
|
INPUT_TX
|
||||||
|
.set(input_tx)
|
||||||
|
.map_err(|_| anyhow::anyhow!("Input TX already set"))?;
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let hook = SetWindowsHookExW(
|
let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_hook_proc), None, 0)?;
|
||||||
WH_KEYBOARD_LL,
|
|
||||||
Some(keyboard_hook_proc),
|
|
||||||
None,
|
|
||||||
0,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
HOOK_HANDLE = hook;
|
HOOK_HANDLE = hook;
|
||||||
Ok(Self { _hook: hook })
|
Ok(Self { _hook: hook })
|
||||||
@@ -82,11 +79,7 @@ impl Drop for KeyboardHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
unsafe extern "system" fn keyboard_hook_proc(
|
unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||||
code: i32,
|
|
||||||
wparam: WPARAM,
|
|
||||||
lparam: LPARAM,
|
|
||||||
) -> LRESULT {
|
|
||||||
if code >= 0 {
|
if code >= 0 {
|
||||||
let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
|
let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
|
||||||
let vk_code = kb_struct.vkCode;
|
let vk_code = kb_struct.vkCode;
|
||||||
@@ -97,10 +90,7 @@ unsafe extern "system" fn keyboard_hook_proc(
|
|||||||
|
|
||||||
if is_down || is_up {
|
if is_down || is_up {
|
||||||
// Check if this is a key we want to intercept (Win key, Alt+Tab, etc.)
|
// Check if this is a key we want to intercept (Win key, Alt+Tab, etc.)
|
||||||
let should_intercept = matches!(
|
let should_intercept = matches!(vk_code, vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS);
|
||||||
vk_code,
|
|
||||||
vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send the key event to the remote
|
// Send the key event to the remote
|
||||||
if let Some(tx) = INPUT_TX.get() {
|
if let Some(tx) = INPUT_TX.get() {
|
||||||
@@ -114,7 +104,12 @@ unsafe extern "system" fn keyboard_hook_proc(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let _ = tx.try_send(InputEvent::Key(event));
|
let _ = tx.try_send(InputEvent::Key(event));
|
||||||
trace!("Key hook: vk={:#x} scan={} down={}", vk_code, scan_code, is_down);
|
trace!(
|
||||||
|
"Key hook: vk={:#x} scan={} down={}",
|
||||||
|
vk_code,
|
||||||
|
scan_code,
|
||||||
|
is_down
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Win key, consume the event so it doesn't open Start menu locally
|
// For Win key, consume the event so it doesn't open Start menu locally
|
||||||
@@ -133,12 +128,12 @@ fn get_current_modifiers() -> proto::Modifiers {
|
|||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
proto::Modifiers {
|
proto::Modifiers {
|
||||||
ctrl: GetAsyncKeyState(0x11) < 0, // VK_CONTROL
|
ctrl: GetAsyncKeyState(0x11) < 0, // VK_CONTROL
|
||||||
alt: GetAsyncKeyState(0x12) < 0, // VK_MENU
|
alt: GetAsyncKeyState(0x12) < 0, // VK_MENU
|
||||||
shift: GetAsyncKeyState(0x10) < 0, // VK_SHIFT
|
shift: GetAsyncKeyState(0x10) < 0, // VK_SHIFT
|
||||||
meta: GetAsyncKeyState(0x5B) < 0 || GetAsyncKeyState(0x5C) < 0, // VK_LWIN/RWIN
|
meta: GetAsyncKeyState(0x5B) < 0 || GetAsyncKeyState(0x5C) < 0, // VK_LWIN/RWIN
|
||||||
caps_lock: GetAsyncKeyState(0x14) & 1 != 0, // VK_CAPITAL
|
caps_lock: GetAsyncKeyState(0x14) & 1 != 0, // VK_CAPITAL
|
||||||
num_lock: GetAsyncKeyState(0x90) & 1 != 0, // VK_NUMLOCK
|
num_lock: GetAsyncKeyState(0x90) & 1 != 0, // VK_NUMLOCK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::proto;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::sync::{mpsc, Mutex};
|
||||||
use tracing::{info, error, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ViewerEvent {
|
pub enum ViewerEvent {
|
||||||
@@ -93,16 +93,18 @@ pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(proto::message::Payload::CursorPosition(pos)) => {
|
Some(proto::message::Payload::CursorPosition(pos)) => {
|
||||||
let _ = viewer_tx_recv.send(ViewerEvent::CursorPosition(
|
let _ = viewer_tx_recv
|
||||||
pos.x, pos.y, pos.visible
|
.send(ViewerEvent::CursorPosition(pos.x, pos.y, pos.visible))
|
||||||
)).await;
|
.await;
|
||||||
}
|
}
|
||||||
Some(proto::message::Payload::CursorShape(shape)) => {
|
Some(proto::message::Payload::CursorShape(shape)) => {
|
||||||
let _ = viewer_tx_recv.send(ViewerEvent::CursorShape(shape)).await;
|
let _ = viewer_tx_recv.send(ViewerEvent::CursorShape(shape)).await;
|
||||||
}
|
}
|
||||||
Some(proto::message::Payload::Disconnect(d)) => {
|
Some(proto::message::Payload::Disconnect(d)) => {
|
||||||
warn!("Server disconnected: {}", d.reason);
|
warn!("Server disconnected: {}", d.reason);
|
||||||
let _ = viewer_tx_recv.send(ViewerEvent::Disconnected(d.reason)).await;
|
let _ = viewer_tx_recv
|
||||||
|
.send(ViewerEvent::Disconnected(d.reason))
|
||||||
|
.await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! Window rendering and frame display
|
//! Window rendering and frame display
|
||||||
|
|
||||||
use super::{ViewerEvent, InputEvent};
|
|
||||||
use crate::proto;
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use super::input;
|
use super::input;
|
||||||
|
use super::{InputEvent, ViewerEvent};
|
||||||
|
use crate::proto;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::num::NonZeroU32;
|
use std::num::NonZeroU32;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -43,10 +43,7 @@ struct ViewerApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ViewerApp {
|
impl ViewerApp {
|
||||||
fn new(
|
fn new(viewer_rx: mpsc::Receiver<ViewerEvent>, input_tx: mpsc::Sender<InputEvent>) -> Self {
|
||||||
viewer_rx: mpsc::Receiver<ViewerEvent>,
|
|
||||||
input_tx: mpsc::Sender<InputEvent>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
window: None,
|
window: None,
|
||||||
surface: None,
|
surface: None,
|
||||||
@@ -112,7 +109,9 @@ impl ViewerApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render(&mut self) {
|
fn render(&mut self) {
|
||||||
let Some(surface) = &mut self.surface else { return };
|
let Some(surface) = &mut self.surface else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let Some(window) = &self.window else { return };
|
let Some(window) = &self.window else { return };
|
||||||
|
|
||||||
if self.frame_buffer.is_empty() || self.frame_width == 0 || self.frame_height == 0 {
|
if self.frame_buffer.is_empty() || self.frame_width == 0 || self.frame_height == 0 {
|
||||||
|
|||||||
@@ -6,23 +6,17 @@ use bytes::Bytes;
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use prost::Message as ProstMessage;
|
use prost::Message as ProstMessage;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio_tungstenite::{
|
use tokio_tungstenite::{
|
||||||
connect_async,
|
connect_async, tungstenite::protocol::Message as WsMessage, MaybeTlsStream, WebSocketStream,
|
||||||
tungstenite::protocol::Message as WsMessage,
|
|
||||||
MaybeTlsStream, WebSocketStream,
|
|
||||||
};
|
};
|
||||||
use tokio::net::TcpStream;
|
|
||||||
use tracing::{debug, error, trace};
|
use tracing::{debug, error, trace};
|
||||||
|
|
||||||
pub type WsSender = futures_util::stream::SplitSink<
|
pub type WsSender =
|
||||||
WebSocketStream<MaybeTlsStream<TcpStream>>,
|
futures_util::stream::SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, WsMessage>;
|
||||||
WsMessage,
|
|
||||||
>;
|
|
||||||
|
|
||||||
pub type WsReceiver = futures_util::stream::SplitStream<
|
pub type WsReceiver = futures_util::stream::SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>;
|
||||||
WebSocketStream<MaybeTlsStream<TcpStream>>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
/// Receiver wrapper that parses protobuf messages
|
/// Receiver wrapper that parses protobuf messages
|
||||||
pub struct MessageReceiver {
|
pub struct MessageReceiver {
|
||||||
@@ -88,10 +82,7 @@ pub async fn connect(url: &str, token: &str) -> Result<(WsSender, MessageReceive
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Send a protobuf message over the WebSocket
|
/// Send a protobuf message over the WebSocket
|
||||||
pub async fn send_message(
|
pub async fn send_message(sender: &Arc<Mutex<WsSender>>, msg: &proto::Message) -> Result<()> {
|
||||||
sender: &Arc<Mutex<WsSender>>,
|
|
||||||
msg: &proto::Message,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut buf = Vec::with_capacity(msg.encoded_len());
|
let mut buf = Vec::with_capacity(msg.encoded_len());
|
||||||
msg.encode(&mut buf)?;
|
msg.encode(&mut buf)?;
|
||||||
|
|
||||||
|
|||||||
8
changelogs/.gitkeep
Normal file
8
changelogs/.gitkeep
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Generated changelog directory.
|
||||||
|
#
|
||||||
|
# Populated by .gitea/workflows/release.yml on each release:
|
||||||
|
# changelogs/<component>/v<version>.md per-component, per-version notes
|
||||||
|
# changelogs/LATEST_<COMPONENT>.md most recent notes for each component
|
||||||
|
#
|
||||||
|
# Served by the server at GET /api/changelog/:component/{latest,:version}
|
||||||
|
# (CHANGELOG_DIR env var points the server at this directory in production).
|
||||||
14
changelogs/LATEST_AGENT.md
Normal file
14
changelogs/LATEST_AGENT.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
## [0.2.0] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Use Self:: for static method calls (cc35d111)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Require authentication for all WebSocket and API endpoints (4614df04)
|
||||||
|
|
||||||
14
changelogs/LATEST_DASHBOARD.md
Normal file
14
changelogs/LATEST_DASHBOARD.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
## [0.2.0] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Use Self:: for static method calls (cc35d111)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Require authentication for all WebSocket and API endpoints (4614df04)
|
||||||
|
|
||||||
14
changelogs/LATEST_SERVER.md
Normal file
14
changelogs/LATEST_SERVER.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
## [0.2.0] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Use Self:: for static method calls (cc35d111)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Require authentication for all WebSocket and API endpoints (4614df04)
|
||||||
|
|
||||||
14
changelogs/agent/v0.2.0.md
Normal file
14
changelogs/agent/v0.2.0.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
## [0.2.0] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Use Self:: for static method calls (cc35d111)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Require authentication for all WebSocket and API endpoints (4614df04)
|
||||||
|
|
||||||
14
changelogs/dashboard/v0.2.0.md
Normal file
14
changelogs/dashboard/v0.2.0.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
## [0.2.0] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Use Self:: for static method calls (cc35d111)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Require authentication for all WebSocket and API endpoints (4614df04)
|
||||||
|
|
||||||
14
changelogs/server/v0.2.0.md
Normal file
14
changelogs/server/v0.2.0.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
## [0.2.0] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Use Self:: for static method calls (cc35d111)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Require authentication for all WebSocket and API endpoints (4614df04)
|
||||||
|
|
||||||
84
cliff.toml
Normal file
84
cliff.toml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# git-cliff configuration for GuruConnect
|
||||||
|
# Conventional-commits preset, grouped by feat / fix / perf.
|
||||||
|
# Used by .gitea/workflows/release.yml to generate CHANGELOG.md and per-component changelogs.
|
||||||
|
# Docs: https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
# Header rendered once at the very TOP of CHANGELOG.md. The release workflow regenerates the
|
||||||
|
# whole file over full history with `--output`, so this fixed preamble is always the first thing
|
||||||
|
# in the document, above the newest version block.
|
||||||
|
header = """
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to GuruConnect are documented here. Format follows
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/); the project uses semantic versioning.
|
||||||
|
|
||||||
|
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/...`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Body template for each release. Designed to render a single version block that the workflow
|
||||||
|
# reuses verbatim (via `--strip header`) for the per-component changelog files.
|
||||||
|
body = """
|
||||||
|
{% if version %}\
|
||||||
|
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||||
|
{% else %}\
|
||||||
|
## [Unreleased]
|
||||||
|
{% endif %}\
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | upper_first }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {{ commit.message | upper_first }}{% if commit.id %} ({{ commit.id | truncate(length=8, end="") }}){% endif %}\
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}\n
|
||||||
|
"""
|
||||||
|
|
||||||
|
trim = true
|
||||||
|
|
||||||
|
# Footer rendered once at the BOTTOM of CHANGELOG.md, after the newest-first version blocks. The
|
||||||
|
# initial [0.1.0] release predates conventional-commit history and cannot be re-derived from the
|
||||||
|
# git log, so it is carried here verbatim. Result over full history is:
|
||||||
|
# header (# Changelog preamble) -> [newest .. ] version blocks (newest first) -> [0.1.0] footer.
|
||||||
|
footer = """
|
||||||
|
## [0.1.0] - 2026-01-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial GuruConnect: Rust agent (DXGI/GDI capture, input injection, native viewer,
|
||||||
|
`guruconnect://` handler), Axum relay server, protobuf-over-WSS transport.
|
||||||
|
- Phase-1 security hardening (JWT, Argon2id, rate limiting, security headers, SEC-1..5),
|
||||||
|
systemd units, automated backups.
|
||||||
|
"""
|
||||||
|
|
||||||
|
[git]
|
||||||
|
# Parse commits as conventional commits.
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
split_commits = false
|
||||||
|
|
||||||
|
# Group commits into changelog sections. Anything not matched is skipped (chores, docs, etc.).
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Added" },
|
||||||
|
{ message = "^fix", group = "Fixed" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^revert", group = "Reverted" },
|
||||||
|
{ message = "^chore\\(release\\)", skip = true },
|
||||||
|
{ message = "^chore: release", skip = true },
|
||||||
|
{ message = "^chore", skip = true },
|
||||||
|
{ message = "^docs", skip = true },
|
||||||
|
{ message = "^test", skip = true },
|
||||||
|
{ message = "^ci", skip = true },
|
||||||
|
{ message = "^build", skip = true },
|
||||||
|
{ message = "^style", skip = true },
|
||||||
|
{ message = "^refactor", skip = true },
|
||||||
|
{ body = ".*security", group = "Security" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# Skip release-bump commits so they never appear in the changelog.
|
||||||
|
filter_commits = false
|
||||||
|
|
||||||
|
# Process tags matching vMAJOR.MINOR.PATCH.
|
||||||
|
tag_pattern = "v[0-9]*"
|
||||||
|
|
||||||
|
# Sort newest first.
|
||||||
|
sort_commits = "newest"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@guruconnect/dashboard",
|
"name": "@guruconnect/dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "GuruConnect Remote Desktop Viewer Components",
|
"description": "GuruConnect Remote Desktop Viewer Components",
|
||||||
"author": "AZ Computer Guru",
|
"author": "AZ Computer Guru",
|
||||||
"license": "Proprietary",
|
"license": "Proprietary",
|
||||||
|
|||||||
81
docs/ARCHITECTURE_DECISIONS.md
Normal file
81
docs/ARCHITECTURE_DECISIONS.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# GuruConnect — Architecture Decisions
|
||||||
|
|
||||||
|
Records significant architectural decisions for the GuruConnect product. Each entry: context,
|
||||||
|
decision, options considered, rationale, consequences.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-001: GuruConnect is a Standalone Product; Integrate with GuruRMM via a Versioned Contract
|
||||||
|
|
||||||
|
**Date:** 2026-05-29
|
||||||
|
**Status:** Decided
|
||||||
|
**Deciders:** Mike Swanson
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
GuruConnect is a remote-support product that must work fully on its own, with its own repository
|
||||||
|
(`azcomputerguru/guru-connect`), build pipeline, and release cadence. GuruRMM wants to offer native
|
||||||
|
integrated remote control by driving GuruConnect.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
GuruConnect stays an independent product. It exposes a **versioned integration contract**
|
||||||
|
(`/api/integration/v1/`, capability discovery, embedded-viewer protocol) that GuruRMM consumes as a
|
||||||
|
broker. The two products do not share build pipelines or release in lockstep. GuruConnect owns the
|
||||||
|
contract; GuruRMM does no active development on GuruConnect.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
- Preserves GuruConnect as a sellable standalone product.
|
||||||
|
- Avoids coupling two independently-evolving codebases; integration changes go through the contract.
|
||||||
|
- Mirrors the GuruRMM-side decision (GuruRMM ADR-008).
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- The integration surface is semver'd; breaking changes require a major bump.
|
||||||
|
- See `docs/specs/native-remote-control/` for the contract spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-002: Release Engineering — Gitea Actions Pipeline with Azure Trusted Signing
|
||||||
|
|
||||||
|
**Date:** 2026-05-29
|
||||||
|
**Status:** Decided
|
||||||
|
**Deciders:** Mike Swanson
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
GuruConnect needs operational parity with GuruRMM: signed Windows binaries, automatic versioning,
|
||||||
|
changelogs, and tracking. GuruRMM achieves this with a Gitea **webhook → shell-script** pipeline on
|
||||||
|
shared build hosts (Saturn + Pluto) and signs via Azure Trusted Signing (`jsign`) using credentials
|
||||||
|
in `/etc/gururmm-signing.env`.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
GuruConnect implements its release engineering **entirely in Gitea Actions** (not the webhook/script
|
||||||
|
model), and **reuses GuruRMM's existing Azure Trusted Signing certificate profile** (same account +
|
||||||
|
service principal) to sign the Windows agent `.exe`.
|
||||||
|
|
||||||
|
### Options Considered
|
||||||
|
|
||||||
|
- **A — Gitea Actions, reuse RMM cert profile (chosen):** self-contained workflows; `jsign` runs on
|
||||||
|
the `ubuntu-latest` runner; no Pluto/webhook dependency. GC ships a single `.exe` (no WiX/MSI), so
|
||||||
|
no Windows runner is needed.
|
||||||
|
- **B — Mirror RMM's webhook + shell scripts:** maximal parity but adds Pluto/webhook coupling and a
|
||||||
|
build host to maintain.
|
||||||
|
- **C — Separate Azure Trusted Signing account for GC:** cleaner attribution, more Azure setup.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
- `jsign` is cross-platform (Java) and signs PE binaries on Linux — no Windows runner required.
|
||||||
|
- Reusing RMM's cert profile means zero new Azure provisioning; GC binaries are signed by the same
|
||||||
|
ACG identity.
|
||||||
|
- Actions are self-contained and versioned with the repo, simpler than maintaining build-host scripts.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- The Azure Trusted Signing service-principal secrets must be added to the `guru-connect` repo's
|
||||||
|
Gitea Actions secrets (values come from `/etc/gururmm-signing.env` / the SOPS vault). See SPEC-001.
|
||||||
|
- Windows binaries will be attributed to GuruRMM's cert profile until/unless a GuruConnect-specific
|
||||||
|
profile is provisioned (a future, low-effort change).
|
||||||
|
- Implementation: `docs/specs/SPEC-001-operational-tooling-parity.md`.
|
||||||
60
docs/FEATURE_ROADMAP.md
Normal file
60
docs/FEATURE_ROADMAP.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# GuruConnect — Feature Roadmap
|
||||||
|
|
||||||
|
> Living roadmap for the GuruConnect product. Status markers: `[ ]` planned · `[~]` in
|
||||||
|
> consideration · `[x]` shipped. Priorities: P1 (blocking/MVP) · P2 (important) · P3 (nice-to-have).
|
||||||
|
> Specs live in `docs/specs/SPEC-NNN-<slug>.md`. Decisions in `docs/ARCHITECTURE_DECISIONS.md`.
|
||||||
|
|
||||||
|
GuruConnect is a standalone remote-support product (ScreenConnect/Splashtop-class) on our own Rust
|
||||||
|
stack. It ships independently of GuruRMM and integrates with it via a versioned contract (see
|
||||||
|
`specs/native-remote-control/` and ADR-001).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational Tooling & Release Engineering
|
||||||
|
|
||||||
|
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)
|
||||||
|
- [ ] **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*
|
||||||
|
- [ ] **Coord-API registration** — P3 — register `guruconnect` project_key + components (`server`, `agent`, `dashboard`) in the coordination API. (SPEC-001 §6)
|
||||||
|
- [~] **Release distribution / update channels** — P3 — beta→stable rollout with health metrics (mirrors RMM `updates.rs`). Deferred — larger subsystem, post-parity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Remote Control
|
||||||
|
|
||||||
|
- [x] Screen capture (DXGI primary, GDI fallback)
|
||||||
|
- [x] Input injection (mouse/keyboard)
|
||||||
|
- [x] Native viewer + `guruconnect://` protocol handler
|
||||||
|
- [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
|
||||||
|
- [ ] 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)
|
||||||
|
|
||||||
|
## GuruRMM Integration
|
||||||
|
|
||||||
|
- [ ] **Native remote control via broker** — P2 — versioned integration contract so GuruRMM can launch/embed GC sessions on managed endpoints. Full spec: [`specs/native-remote-control/`](specs/native-remote-control/). (Contract owned by GC; RMM consumes it.)
|
||||||
|
- [ ] `/api/integration/v1/` namespace + capability discovery — P2 (part of native-remote-control)
|
||||||
|
- [ ] Per-machine agent keys (replace shared `AGENT_API_KEY`) — P2
|
||||||
|
- [ ] Embedded-viewer framing allowlist (scoped `frame-ancestors`) — P2
|
||||||
|
|
||||||
|
## Server / API
|
||||||
|
|
||||||
|
- [x] JWT auth, Argon2id passwords, rate limiting, security headers
|
||||||
|
- [x] Sessions / machines / support-codes / events
|
||||||
|
- [ ] Programmatic session pre-create + viewer-token (integration contract) — P2
|
||||||
|
|
||||||
|
## Security & Infrastructure
|
||||||
|
|
||||||
|
- [x] Phase-1 security hardening (SEC-1..5), systemd units, backups
|
||||||
|
- [ ] CI security audit gate (`cargo audit`) wired to release — P2
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
- [ ] macOS / Linux remote-control agents — P3
|
||||||
|
- [ ] Auto-update for the agent — P3
|
||||||
117
docs/specs/SPEC-001-operational-tooling-parity.md
Normal file
117
docs/specs/SPEC-001-operational-tooling-parity.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# SPEC-001: Operational Tooling Parity with GuruRMM
|
||||||
|
|
||||||
|
**Status:** Proposed
|
||||||
|
**Priority:** P1
|
||||||
|
**Requested By:** Mike (2026-05-29)
|
||||||
|
**Estimated Effort:** Large
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Bring GuruConnect's release engineering and project tooling to parity with GuruRMM: signed Windows
|
||||||
|
binaries, automatic versioning, changelog generation, a feature-request workflow, and roadmap/spec
|
||||||
|
tracking. Per ADR-002, this is implemented **entirely in Gitea Actions** (not RMM's webhook/script
|
||||||
|
model), and reuses RMM's existing **Azure Trusted Signing** certificate profile.
|
||||||
|
|
||||||
|
GC ships a single Windows `.exe` (no WiX/MSI), so all jobs run on `ubuntu-latest` — `jsign` is a
|
||||||
|
Java tool that signs PE binaries on Linux. No Windows runner or Pluto dependency.
|
||||||
|
|
||||||
|
**Success criteria:** a push to `main` produces an auto-versioned, signed `guruconnect.exe` with a
|
||||||
|
generated changelog entry; `/gc-feature-request` produces a SPEC and updates the roadmap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §1 — Feature-request workflow (skill)
|
||||||
|
|
||||||
|
**Deliverable:** `/gc-feature-request` skill (in `.claude/commands/gc-feature-request.md`, claudetools repo).
|
||||||
|
|
||||||
|
Adapted from RMM's `/feature-request`:
|
||||||
|
- Reads `docs/FEATURE_ROADMAP.md`, `docs/ARCHITECTURE_DECISIONS.md`, this repo's `CLAUDE.md`.
|
||||||
|
- Ollama classification (qwen3.6 JSON) → section/priority; prose spec via qwen3:14b (fallback: self).
|
||||||
|
- Writes `docs/specs/SPEC-NNN-<slug>.md` (next number), updates the roadmap, commits in the GC repo.
|
||||||
|
- GC architecture vocabulary: **agent / relay-server / viewer / dashboard** (not RMM's agent/server/dashboard).
|
||||||
|
- Coord messages use `project_key: "guruconnect"` (see §6).
|
||||||
|
|
||||||
|
## §2 — Code signing (Azure Trusted Signing in Actions)
|
||||||
|
|
||||||
|
**Deliverable:** a reusable signing step/job in `.gitea/workflows/`.
|
||||||
|
|
||||||
|
Mirror RMM's `sign-windows.sh` logic inside Actions:
|
||||||
|
1. Acquire token: `POST https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`
|
||||||
|
with `grant_type=client_credentials`, `client_id`, `client_secret`,
|
||||||
|
`scope=https://codesigning.azure.net/.default`.
|
||||||
|
2. Install `jsign` (+ JRE) on the runner.
|
||||||
|
3. Sign:
|
||||||
|
```
|
||||||
|
jsign --storetype TRUSTEDSIGNING --keystore "$TS_ENDPOINT" --storepass "$TOKEN" \
|
||||||
|
--alias "${TS_ACCOUNT}/${TS_CERT_PROFILE}" --tsaurl "$TS_TIMESTAMP_URL" \
|
||||||
|
--tsmode RFC3161 --alg SHA-256 --name "GuruConnect Agent" \
|
||||||
|
--url "https://www.azcomputerguru.com" --replace guruconnect.exe
|
||||||
|
```
|
||||||
|
4. Emit `[OK] signed` / on failure fail the release job (do NOT publish unsigned for releases).
|
||||||
|
|
||||||
|
**Required Gitea Actions secrets on the `guru-connect` repo** (values reused from RMM —
|
||||||
|
`/etc/gururmm-signing.env` on the build host / SOPS vault; do not hardcode):
|
||||||
|
`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `TS_ENDPOINT`, `TS_ACCOUNT`,
|
||||||
|
`TS_CERT_PROFILE`, `TS_TIMESTAMP_URL`.
|
||||||
|
|
||||||
|
## §3 — Automatic versioning
|
||||||
|
|
||||||
|
**Deliverable:** a version-bump job in the release workflow.
|
||||||
|
|
||||||
|
- Canonical versions: `agent/Cargo.toml`, `server/Cargo.toml`, `dashboard/package.json` (all `0.1.0` today).
|
||||||
|
- On push to `main`, determine the next version from conventional commits since the last release tag
|
||||||
|
(`feat:` → minor, `fix:`/`perf:` → patch); bump only components whose files changed.
|
||||||
|
- Commit the bump (`chore: release vX.Y.Z [skip ci]`) and tag `vX.Y.Z`. The agent embeds the version
|
||||||
|
via the existing `agent/build.rs` (already reads `CARGO_PKG_VERSION` + git hash) — no change needed there.
|
||||||
|
- Requires a push-capable token secret for the runner (`CI_PUSH_TOKEN`) to commit the bump/tag back.
|
||||||
|
|
||||||
|
## §4 — Changelog generation & API
|
||||||
|
|
||||||
|
**Deliverables:** changelog generation in the release workflow + a server endpoint.
|
||||||
|
|
||||||
|
- Generate from conventional commits (git-cliff or equivalent) into `CHANGELOG.md` and
|
||||||
|
`changelogs/<component>/v<version>.md`, plus `changelogs/LATEST_<COMPONENT>.md`.
|
||||||
|
- Server: add `GET /api/changelog/:component/latest` and `GET /api/changelog/:component/:version`
|
||||||
|
(mirror RMM `server/src/api/changelog.rs`), reading from a `CHANGELOG_DIR` env (default
|
||||||
|
`server/changelogs` or the deployed downloads path).
|
||||||
|
|
||||||
|
## §5 — Roadmap / ADR / spec tracking (bootstrapped)
|
||||||
|
|
||||||
|
**Deliverables (this spec's commit establishes them):**
|
||||||
|
- `docs/FEATURE_ROADMAP.md` — living roadmap, `[ ]/[~]/[x]` + P1–P3.
|
||||||
|
- `docs/ARCHITECTURE_DECISIONS.md` — ADR-NNN log (ADR-001 standalone+contract, ADR-002 release eng).
|
||||||
|
- `docs/specs/SPEC-NNN-*.md` — numbered specs (this is SPEC-001).
|
||||||
|
- `CHANGELOG.md` — Keep-a-Changelog seed.
|
||||||
|
|
||||||
|
## §6 — Coord-API registration
|
||||||
|
|
||||||
|
**Deliverable:** register GC in the coordination API.
|
||||||
|
|
||||||
|
- Add `guruconnect` project_key with components `server`, `agent`, `dashboard` (states:
|
||||||
|
`building`, `built`, `deploying`, `deployed`, `degraded`).
|
||||||
|
- Update root `.claude/CLAUDE.md` "Project keys" table to include `guruconnect`.
|
||||||
|
- `/gc-feature-request` and CI can then POST component state updates after deploys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. §5 docs scaffold (this commit) + §1 skill.
|
||||||
|
2. §2 signing + §3 versioning + §4 changelog as Gitea Actions workflows (depends on the repo secrets
|
||||||
|
in §2 being added first).
|
||||||
|
3. §4 server changelog endpoint.
|
||||||
|
4. §6 coord registration.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Server version: auto-bump like agent, or keep manual (RMM keeps server manual)? Default: auto-bump all.
|
||||||
|
- Long term: provision a dedicated "GuruConnect" Azure Trusted Signing cert profile for correct
|
||||||
|
publisher attribution (ADR-002 notes current reuse of RMM's profile).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- ADR-002 (this repo), GuruRMM `sign-windows.sh` (Azure Trusted Signing via jsign),
|
||||||
|
RMM `scripts/build-agents.sh` (auto-version), `scripts/generate-changelog.sh`,
|
||||||
|
`server/src/api/changelog.rs`. Roadmap: `docs/FEATURE_ROADMAP.md`.
|
||||||
@@ -22,6 +22,12 @@ LISTEN_ADDR=0.0.0.0:3002
|
|||||||
# If set, persistent agents must provide this key to connect
|
# If set, persistent agents must provide this key to connect
|
||||||
AGENT_API_KEY=
|
AGENT_API_KEY=
|
||||||
|
|
||||||
|
# Optional: directory containing generated changelog files served at /api/changelog/...
|
||||||
|
# Must point at the deployed `changelogs/` directory produced by the release workflow
|
||||||
|
# (containing `LATEST_<COMPONENT>.md` and `<component>/v<version>.md`).
|
||||||
|
# Defaults to ./changelogs, resolved relative to the server's working directory (CWD) when unset.
|
||||||
|
CHANGELOG_DIR=./changelogs
|
||||||
|
|
||||||
# Debug mode (enables verbose logging)
|
# Debug mode (enables verbose logging)
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "guruconnect-server"
|
name = "guruconnect-server"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["AZ Computer Guru"]
|
authors = ["AZ Computer Guru"]
|
||||||
description = "GuruConnect Remote Desktop Relay Server"
|
description = "GuruConnect Remote Desktop Relay Server"
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
//! Authentication API endpoints
|
//! Authentication API endpoints
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{State, Request},
|
extract::{Request, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{verify_password, AuthenticatedUser, JwtConfig};
|
||||||
verify_password, AuthenticatedUser, JwtConfig,
|
|
||||||
};
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -89,16 +87,15 @@ pub async fn login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
let password_valid = verify_password(&request.password, &user.password_hash)
|
let password_valid = verify_password(&request.password, &user.password_hash).map_err(|e| {
|
||||||
.map_err(|e| {
|
tracing::error!("Password verification error: {}", e);
|
||||||
tracing::error!("Password verification error: {}", e);
|
(
|
||||||
(
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
Json(ErrorResponse {
|
||||||
Json(ErrorResponse {
|
error: "Internal server error".to_string(),
|
||||||
error: "Internal server error".to_string(),
|
}),
|
||||||
}),
|
)
|
||||||
)
|
})?;
|
||||||
})?;
|
|
||||||
|
|
||||||
if !password_valid {
|
if !password_valid {
|
||||||
return Err((
|
return Err((
|
||||||
@@ -118,21 +115,18 @@ pub async fn login(
|
|||||||
let _ = db::update_last_login(db.pool(), user.id).await;
|
let _ = db::update_last_login(db.pool(), user.id).await;
|
||||||
|
|
||||||
// Create JWT token
|
// Create JWT token
|
||||||
let token = state.jwt_config.create_token(
|
let token = state
|
||||||
user.id,
|
.jwt_config
|
||||||
&user.username,
|
.create_token(user.id, &user.username, &user.role, permissions.clone())
|
||||||
&user.role,
|
.map_err(|e| {
|
||||||
permissions.clone(),
|
tracing::error!("Token creation error: {}", e);
|
||||||
)
|
(
|
||||||
.map_err(|e| {
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
tracing::error!("Token creation error: {}", e);
|
Json(ErrorResponse {
|
||||||
(
|
error: "Failed to create token".to_string(),
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
}),
|
||||||
Json(ErrorResponse {
|
)
|
||||||
error: "Failed to create token".to_string(),
|
})?;
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
tracing::info!("User {} logged in successfully", user.username);
|
tracing::info!("User {} logged in successfully", user.username);
|
||||||
|
|
||||||
@@ -288,16 +282,15 @@ pub async fn change_password(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hash new password
|
// Hash new password
|
||||||
let new_hash = crate::auth::hash_password(&request.new_password)
|
let new_hash = crate::auth::hash_password(&request.new_password).map_err(|e| {
|
||||||
.map_err(|e| {
|
tracing::error!("Password hashing error: {}", e);
|
||||||
tracing::error!("Password hashing error: {}", e);
|
(
|
||||||
(
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
Json(ErrorResponse {
|
||||||
Json(ErrorResponse {
|
error: "Failed to hash password".to_string(),
|
||||||
error: "Failed to hash password".to_string(),
|
}),
|
||||||
}),
|
)
|
||||||
)
|
})?;
|
||||||
})?;
|
|
||||||
|
|
||||||
// Update password
|
// Update password
|
||||||
db::update_user_password(db.pool(), user_id, &new_hash)
|
db::update_user_password(db.pool(), user_id, &new_hash)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
//! Logout and token revocation endpoints
|
//! Logout and token revocation endpoints
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Request, State, Path},
|
extract::{Path, Request, State},
|
||||||
http::{StatusCode, HeaderMap},
|
http::{HeaderMap, StatusCode},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AuthenticatedUser;
|
use crate::auth::AuthenticatedUser;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@@ -15,7 +15,9 @@ use crate::AppState;
|
|||||||
use super::auth::ErrorResponse;
|
use super::auth::ErrorResponse;
|
||||||
|
|
||||||
/// Extract JWT token from Authorization header
|
/// Extract JWT token from Authorization header
|
||||||
fn extract_token_from_headers(headers: &HeaderMap) -> Result<String, (StatusCode, Json<ErrorResponse>)> {
|
fn extract_token_from_headers(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Result<String, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let auth_header = headers
|
let auth_header = headers
|
||||||
.get("Authorization")
|
.get("Authorization")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
@@ -28,16 +30,14 @@ fn extract_token_from_headers(headers: &HeaderMap) -> Result<String, (StatusCode
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let token = auth_header
|
let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
|
||||||
.strip_prefix("Bearer ")
|
(
|
||||||
.ok_or_else(|| {
|
StatusCode::UNAUTHORIZED,
|
||||||
(
|
Json(ErrorResponse {
|
||||||
StatusCode::UNAUTHORIZED,
|
error: "Invalid Authorization format".to_string(),
|
||||||
Json(ErrorResponse {
|
}),
|
||||||
error: "Invalid Authorization format".to_string(),
|
)
|
||||||
}),
|
})?;
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(token.to_string())
|
Ok(token.to_string())
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,8 @@ pub async fn revoke_user_tokens(
|
|||||||
Err((
|
Err((
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
error: "User token revocation not yet implemented - requires session tracking table".to_string(),
|
error: "User token revocation not yet implemented - requires session tracking table"
|
||||||
|
.to_string(),
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -179,10 +180,16 @@ pub async fn cleanup_blacklist(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let removed = state.token_blacklist.cleanup_expired(&state.jwt_config).await;
|
let removed = state
|
||||||
|
.token_blacklist
|
||||||
|
.cleanup_expired(&state.jwt_config)
|
||||||
|
.await;
|
||||||
let remaining = state.token_blacklist.len().await;
|
let remaining = state.token_blacklist.len().await;
|
||||||
|
|
||||||
info!("Admin {} cleaned up blacklist: {} tokens removed, {} remaining", admin.username, removed, remaining);
|
info!(
|
||||||
|
"Admin {} cleaned up blacklist: {} tokens removed, {} remaining",
|
||||||
|
admin.username, removed, remaining
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(CleanupResponse {
|
Ok(Json(CleanupResponse {
|
||||||
removed_count: removed,
|
removed_count: removed,
|
||||||
|
|||||||
125
server/src/api/changelog.rs
Normal file
125
server/src/api/changelog.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
//! Changelog endpoints — serve per-component release notes from the changelog directory.
|
||||||
|
//!
|
||||||
|
//! `GET /api/changelog/:component/:version` serves changelog Markdown for a component:
|
||||||
|
//! - `version == "latest"` → most recent changelog (`LATEST_AGENT.md`, etc.)
|
||||||
|
//! - `version == "0.1.0"` (or `v0.1.0`) → a specific version (`agent/v0.1.0.md`)
|
||||||
|
//!
|
||||||
|
//! A single route is used because axum 0.7 / matchit 0.7 panics at router construction when a
|
||||||
|
//! static segment (`latest`) and a path param (`:version`) occupy the same position; the handler
|
||||||
|
//! dispatches on the literal value `latest` instead.
|
||||||
|
//!
|
||||||
|
//! This endpoint is public (no auth), mirroring `GET /api/version`. The base directory is read
|
||||||
|
//! from the `CHANGELOG_DIR` env var (default `./changelogs`). Both the `component` and `version`
|
||||||
|
//! path parameters are validated/sanitized to prevent path traversal.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::Path,
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::auth::ErrorResponse;
|
||||||
|
|
||||||
|
/// Resolve the base changelog directory from the `CHANGELOG_DIR` env var.
|
||||||
|
///
|
||||||
|
/// Defaults to `./changelogs` (relative to the server's working directory) when unset.
|
||||||
|
fn changelog_dir() -> PathBuf {
|
||||||
|
std::env::var("CHANGELOG_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("./changelogs"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `component` is a recognized GuruConnect component.
|
||||||
|
///
|
||||||
|
/// Restricting to a fixed allow-list is the primary defense against path traversal: an
|
||||||
|
/// attacker cannot smuggle `..` or absolute paths through this parameter.
|
||||||
|
fn valid_component(component: &str) -> bool {
|
||||||
|
matches!(component, "agent" | "server" | "dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `404 Not Found` response using the project's standard error envelope.
|
||||||
|
fn not_found(message: &str) -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: message.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `200 OK` Markdown response.
|
||||||
|
fn markdown_response(content: String) -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(header::CONTENT_TYPE, "text/markdown; charset=utf-8")],
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/changelog/:component/:version`
|
||||||
|
///
|
||||||
|
/// Serves changelog Markdown for `component`. The `version` segment is dispatched as follows:
|
||||||
|
/// - `latest` → `changelogs/LATEST_<COMPONENT>.md` (uppercase component)
|
||||||
|
/// - a semver string (with or without a leading `v`, e.g. `0.1.0` or `v0.1.0`)
|
||||||
|
/// → `changelogs/<component>/v<version>.md`
|
||||||
|
///
|
||||||
|
/// `component` is validated against the allow-list FIRST; the version (when not `latest`) is
|
||||||
|
/// sanitized to digits and dots only, rejecting any path-traversal attempt before touching the
|
||||||
|
/// filesystem.
|
||||||
|
pub async fn get(Path((component, version)): Path<(String, String)>) -> Response {
|
||||||
|
// Allow-list check first: the primary defense against path traversal via `component`.
|
||||||
|
if !valid_component(&component) {
|
||||||
|
return not_found("Unknown component");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The literal "latest" selects the most-recent changelog file for the component.
|
||||||
|
if version == "latest" {
|
||||||
|
let file = changelog_dir().join(format!("LATEST_{}.md", component.to_uppercase()));
|
||||||
|
|
||||||
|
return match tokio::fs::read_to_string(&file).await {
|
||||||
|
Ok(content) => markdown_response(content),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Changelog latest not found for component '{}' ({}): {}",
|
||||||
|
component,
|
||||||
|
file.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
not_found("Changelog not found")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise treat `version` as a specific release. Accept it with or without a leading 'v';
|
||||||
|
// work with the bare numeric form.
|
||||||
|
let bare = version.strip_prefix('v').unwrap_or(&version);
|
||||||
|
|
||||||
|
// Sanitize: a valid version is non-empty and contains only digits and dots. This rejects any
|
||||||
|
// path-traversal attempt (slashes, `..`, backslashes) before touching the filesystem.
|
||||||
|
let safe = !bare.is_empty()
|
||||||
|
&& bare.chars().all(|c| c.is_ascii_digit() || c == '.')
|
||||||
|
&& !bare.contains("..");
|
||||||
|
if !safe {
|
||||||
|
return not_found("Invalid version");
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = changelog_dir().join(&component).join(format!("v{bare}.md"));
|
||||||
|
|
||||||
|
match tokio::fs::read_to_string(&file).await {
|
||||||
|
Ok(content) => markdown_response(content),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Changelog not found for component '{}' version 'v{}' ({}): {}",
|
||||||
|
component,
|
||||||
|
bare,
|
||||||
|
file.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
not_found("Changelog not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tracing::{info, warn, error};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
/// Magic marker for embedded configuration (must match agent)
|
/// Magic marker for embedded configuration (must match agent)
|
||||||
const MAGIC_MARKER: &[u8] = b"GURUCONFIG";
|
const MAGIC_MARKER: &[u8] = b"GURUCONFIG";
|
||||||
@@ -87,7 +87,7 @@ pub async fn download_viewer() -> impl IntoResponse {
|
|||||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||||
.header(
|
.header(
|
||||||
header::CONTENT_DISPOSITION,
|
header::CONTENT_DISPOSITION,
|
||||||
"attachment; filename=\"GuruConnect-Viewer.exe\""
|
"attachment; filename=\"GuruConnect-Viewer.exe\"",
|
||||||
)
|
)
|
||||||
.header(header::CONTENT_LENGTH, binary_data.len())
|
.header(header::CONTENT_LENGTH, binary_data.len())
|
||||||
.body(Body::from(binary_data))
|
.body(Body::from(binary_data))
|
||||||
@@ -104,9 +104,7 @@ pub async fn download_viewer() -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Download support session binary (code embedded in filename)
|
/// Download support session binary (code embedded in filename)
|
||||||
pub async fn download_support(
|
pub async fn download_support(Query(params): Query<SupportDownloadParams>) -> impl IntoResponse {
|
||||||
Query(params): Query<SupportDownloadParams>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
// Validate support code (must be 6 digits)
|
// Validate support code (must be 6 digits)
|
||||||
let code = params.code.trim();
|
let code = params.code.trim();
|
||||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
|
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
|
||||||
@@ -120,7 +118,11 @@ pub async fn download_support(
|
|||||||
|
|
||||||
match std::fs::read(&binary_path) {
|
match std::fs::read(&binary_path) {
|
||||||
Ok(binary_data) => {
|
Ok(binary_data) => {
|
||||||
info!("Serving support session download for code {} ({} bytes)", code, binary_data.len());
|
info!(
|
||||||
|
"Serving support session download for code {} ({} bytes)",
|
||||||
|
code,
|
||||||
|
binary_data.len()
|
||||||
|
);
|
||||||
|
|
||||||
// Filename includes the support code
|
// Filename includes the support code
|
||||||
let filename = format!("GuruConnect-{}.exe", code);
|
let filename = format!("GuruConnect-{}.exe", code);
|
||||||
@@ -130,7 +132,7 @@ pub async fn download_support(
|
|||||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||||
.header(
|
.header(
|
||||||
header::CONTENT_DISPOSITION,
|
header::CONTENT_DISPOSITION,
|
||||||
format!("attachment; filename=\"{}\"", filename)
|
format!("attachment; filename=\"{}\"", filename),
|
||||||
)
|
)
|
||||||
.header(header::CONTENT_LENGTH, binary_data.len())
|
.header(header::CONTENT_LENGTH, binary_data.len())
|
||||||
.body(Body::from(binary_data))
|
.body(Body::from(binary_data))
|
||||||
@@ -147,9 +149,7 @@ pub async fn download_support(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Download permanent agent binary with embedded configuration
|
/// Download permanent agent binary with embedded configuration
|
||||||
pub async fn download_agent(
|
pub async fn download_agent(Query(params): Query<AgentDownloadParams>) -> impl IntoResponse {
|
||||||
Query(params): Query<AgentDownloadParams>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let binary_path = get_base_binary_path();
|
let binary_path = get_base_binary_path();
|
||||||
|
|
||||||
// Read base binary
|
// Read base binary
|
||||||
@@ -167,10 +167,13 @@ pub async fn download_agent(
|
|||||||
// Build embedded config
|
// Build embedded config
|
||||||
let config = EmbeddedConfig {
|
let config = EmbeddedConfig {
|
||||||
server_url: "wss://connect.azcomputerguru.com/ws/agent".to_string(),
|
server_url: "wss://connect.azcomputerguru.com/ws/agent".to_string(),
|
||||||
api_key: params.api_key.unwrap_or_else(|| "managed-agent".to_string()),
|
api_key: params
|
||||||
|
.api_key
|
||||||
|
.unwrap_or_else(|| "managed-agent".to_string()),
|
||||||
company: params.company.clone(),
|
company: params.company.clone(),
|
||||||
site: params.site.clone(),
|
site: params.site.clone(),
|
||||||
tags: params.tags
|
tags: params
|
||||||
|
.tags
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
|
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
@@ -196,18 +199,25 @@ pub async fn download_agent(
|
|||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Serving permanent agent download: company={:?}, site={:?}, tags={:?} ({} bytes)",
|
"Serving permanent agent download: company={:?}, site={:?}, tags={:?} ({} bytes)",
|
||||||
config.company, config.site, config.tags, binary_data.len()
|
config.company,
|
||||||
|
config.site,
|
||||||
|
config.tags,
|
||||||
|
binary_data.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate filename based on company/site
|
// Generate filename based on company/site
|
||||||
let filename = match (¶ms.company, ¶ms.site) {
|
let filename = match (¶ms.company, ¶ms.site) {
|
||||||
(Some(company), Some(site)) => {
|
(Some(company), Some(site)) => {
|
||||||
format!("GuruConnect-{}-{}-Setup.exe", sanitize_filename(company), sanitize_filename(site))
|
format!(
|
||||||
|
"GuruConnect-{}-{}-Setup.exe",
|
||||||
|
sanitize_filename(company),
|
||||||
|
sanitize_filename(site)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
(Some(company), None) => {
|
(Some(company), None) => {
|
||||||
format!("GuruConnect-{}-Setup.exe", sanitize_filename(company))
|
format!("GuruConnect-{}-Setup.exe", sanitize_filename(company))
|
||||||
}
|
}
|
||||||
_ => "GuruConnect-Setup.exe".to_string()
|
_ => "GuruConnect-Setup.exe".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
@@ -215,7 +225,7 @@ pub async fn download_agent(
|
|||||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||||
.header(
|
.header(
|
||||||
header::CONTENT_DISPOSITION,
|
header::CONTENT_DISPOSITION,
|
||||||
format!("attachment; filename=\"{}\"", filename)
|
format!("attachment; filename=\"{}\"", filename),
|
||||||
)
|
)
|
||||||
.header(header::CONTENT_LENGTH, binary_data.len())
|
.header(header::CONTENT_LENGTH, binary_data.len())
|
||||||
.body(Body::from(binary_data))
|
.body(Body::from(binary_data))
|
||||||
|
|||||||
@@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod auth_logout;
|
pub mod auth_logout;
|
||||||
pub mod users;
|
pub mod changelog;
|
||||||
pub mod releases;
|
|
||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
|
pub mod releases;
|
||||||
|
pub mod users;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State, Query},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::session::SessionManager;
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
use crate::session::SessionManager;
|
||||||
|
|
||||||
/// Viewer info returned by API
|
/// Viewer info returned by API
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -77,9 +78,7 @@ impl From<crate::session::Session> for SessionInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// List all active sessions
|
/// List all active sessions
|
||||||
pub async fn list_sessions(
|
pub async fn list_sessions(State(sessions): State<SessionManager>) -> Json<Vec<SessionInfo>> {
|
||||||
State(sessions): State<SessionManager>,
|
|
||||||
) -> Json<Vec<SessionInfo>> {
|
|
||||||
let sessions = sessions.list_sessions().await;
|
let sessions = sessions.list_sessions().await;
|
||||||
Json(sessions.into_iter().map(SessionInfo::from).collect())
|
Json(sessions.into_iter().map(SessionInfo::from).collect())
|
||||||
}
|
}
|
||||||
@@ -92,7 +91,9 @@ pub async fn get_session(
|
|||||||
let session_id = Uuid::parse_str(&id)
|
let session_id = Uuid::parse_str(&id)
|
||||||
.map_err(|_| (axum::http::StatusCode::BAD_REQUEST, "Invalid session ID"))?;
|
.map_err(|_| (axum::http::StatusCode::BAD_REQUEST, "Invalid session ID"))?;
|
||||||
|
|
||||||
let session = sessions.get_session(session_id).await
|
let session = sessions
|
||||||
|
.get_session(session_id)
|
||||||
|
.await
|
||||||
.ok_or((axum::http::StatusCode::NOT_FOUND, "Session not found"))?;
|
.ok_or((axum::http::StatusCode::NOT_FOUND, "Session not found"))?;
|
||||||
|
|
||||||
Ok(Json(SessionInfo::from(session)))
|
Ok(Json(SessionInfo::from(session)))
|
||||||
|
|||||||
@@ -129,17 +129,15 @@ pub async fn list_releases(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let releases = db::get_all_releases(db.pool())
|
let releases = db::get_all_releases(db.pool()).await.map_err(|e| {
|
||||||
.await
|
tracing::error!("Database error: {}", e);
|
||||||
.map_err(|e| {
|
(
|
||||||
tracing::error!("Database error: {}", e);
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
(
|
Json(ErrorResponse {
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
error: "Failed to fetch releases".to_string(),
|
||||||
Json(ErrorResponse {
|
}),
|
||||||
error: "Failed to fetch releases".to_string(),
|
)
|
||||||
}),
|
})?;
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(releases.into_iter().map(ReleaseInfo::from).collect()))
|
Ok(Json(releases.into_iter().map(ReleaseInfo::from).collect()))
|
||||||
}
|
}
|
||||||
@@ -171,7 +169,10 @@ pub async fn create_release(
|
|||||||
|
|
||||||
// Validate checksum format (64 hex chars for SHA-256)
|
// Validate checksum format (64 hex chars for SHA-256)
|
||||||
if request.checksum_sha256.len() != 64
|
if request.checksum_sha256.len() != 64
|
||||||
|| !request.checksum_sha256.chars().all(|c| c.is_ascii_hexdigit())
|
|| !request
|
||||||
|
.checksum_sha256
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_hexdigit())
|
||||||
{
|
{
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
@@ -349,17 +350,15 @@ pub async fn delete_release(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let deleted = db::delete_release(db.pool(), &version)
|
let deleted = db::delete_release(db.pool(), &version).await.map_err(|e| {
|
||||||
.await
|
tracing::error!("Database error: {}", e);
|
||||||
.map_err(|e| {
|
(
|
||||||
tracing::error!("Database error: {}", e);
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
(
|
Json(ErrorResponse {
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
error: "Failed to delete release".to_string(),
|
||||||
Json(ErrorResponse {
|
}),
|
||||||
error: "Failed to delete release".to_string(),
|
)
|
||||||
}),
|
})?;
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if deleted {
|
if deleted {
|
||||||
tracing::info!("Deleted release: {}", version);
|
tracing::info!("Deleted release: {}", version);
|
||||||
|
|||||||
@@ -72,17 +72,15 @@ pub async fn list_users(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let users = db::get_all_users(db.pool())
|
let users = db::get_all_users(db.pool()).await.map_err(|e| {
|
||||||
.await
|
tracing::error!("Database error: {}", e);
|
||||||
.map_err(|e| {
|
(
|
||||||
tracing::error!("Database error: {}", e);
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
(
|
Json(ErrorResponse {
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
error: "Failed to fetch users".to_string(),
|
||||||
Json(ErrorResponse {
|
}),
|
||||||
error: "Failed to fetch users".to_string(),
|
)
|
||||||
}),
|
})?;
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for user in users {
|
for user in users {
|
||||||
@@ -210,7 +208,13 @@ pub async fn create_user(
|
|||||||
} else {
|
} else {
|
||||||
// Default permissions based on role
|
// Default permissions based on role
|
||||||
let default_perms = match request.role.as_str() {
|
let default_perms = match request.role.as_str() {
|
||||||
"admin" => vec!["view", "control", "transfer", "manage_users", "manage_clients"],
|
"admin" => vec![
|
||||||
|
"view",
|
||||||
|
"control",
|
||||||
|
"transfer",
|
||||||
|
"manage_users",
|
||||||
|
"manage_clients",
|
||||||
|
],
|
||||||
"operator" => vec!["view", "control", "transfer"],
|
"operator" => vec!["view", "control", "transfer"],
|
||||||
"viewer" => vec!["view"],
|
"viewer" => vec!["view"],
|
||||||
_ => vec!["view"],
|
_ => vec!["view"],
|
||||||
@@ -455,17 +459,15 @@ pub async fn delete_user(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let deleted = db::delete_user(db.pool(), user_id)
|
let deleted = db::delete_user(db.pool(), user_id).await.map_err(|e| {
|
||||||
.await
|
tracing::error!("Database error: {}", e);
|
||||||
.map_err(|e| {
|
(
|
||||||
tracing::error!("Database error: {}", e);
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
(
|
Json(ErrorResponse {
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
error: "Failed to delete user".to_string(),
|
||||||
Json(ErrorResponse {
|
}),
|
||||||
error: "Failed to delete user".to_string(),
|
)
|
||||||
}),
|
})?;
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if deleted {
|
if deleted {
|
||||||
tracing::info!("Deleted user: {}", id);
|
tracing::info!("Deleted user: {}", id);
|
||||||
@@ -506,13 +508,22 @@ pub async fn set_permissions(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Validate permissions
|
// Validate permissions
|
||||||
let valid_permissions = ["view", "control", "transfer", "manage_users", "manage_clients"];
|
let valid_permissions = [
|
||||||
|
"view",
|
||||||
|
"control",
|
||||||
|
"transfer",
|
||||||
|
"manage_users",
|
||||||
|
"manage_clients",
|
||||||
|
];
|
||||||
for perm in &request.permissions {
|
for perm in &request.permissions {
|
||||||
if !valid_permissions.contains(&perm.as_str()) {
|
if !valid_permissions.contains(&perm.as_str()) {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
error: format!("Invalid permission: {}. Valid: {:?}", perm, valid_permissions),
|
error: format!(
|
||||||
|
"Invalid permission: {}. Valid: {:?}",
|
||||||
|
perm, valid_permissions
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ pub struct JwtConfig {
|
|||||||
impl JwtConfig {
|
impl JwtConfig {
|
||||||
/// Create new JWT config
|
/// Create new JWT config
|
||||||
pub fn new(secret: String, expiry_hours: i64) -> Self {
|
pub fn new(secret: String, expiry_hours: i64) -> Self {
|
||||||
Self { secret, expiry_hours }
|
Self {
|
||||||
|
secret,
|
||||||
|
expiry_hours,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a JWT token for a user
|
/// Create a JWT token for a user
|
||||||
@@ -97,9 +100,9 @@ impl JwtConfig {
|
|||||||
pub fn validate_token(&self, token: &str) -> Result<Claims> {
|
pub fn validate_token(&self, token: &str) -> Result<Claims> {
|
||||||
// SEC-13: Explicit validation configuration
|
// SEC-13: Explicit validation configuration
|
||||||
let mut validation = Validation::default();
|
let mut validation = Validation::default();
|
||||||
validation.validate_exp = true; // Enforce expiration check
|
validation.validate_exp = true; // Enforce expiration check
|
||||||
validation.validate_nbf = false; // Not using "not before" claim
|
validation.validate_nbf = false; // Not using "not before" claim
|
||||||
validation.leeway = 0; // No clock skew tolerance
|
validation.leeway = 0; // No clock skew tolerance
|
||||||
|
|
||||||
let token_data = decode::<Claims>(
|
let token_data = decode::<Claims>(
|
||||||
token,
|
token,
|
||||||
@@ -129,12 +132,14 @@ mod tests {
|
|||||||
let config = JwtConfig::new("test-secret".to_string(), 24);
|
let config = JwtConfig::new("test-secret".to_string(), 24);
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
|
|
||||||
let token = config.create_token(
|
let token = config
|
||||||
user_id,
|
.create_token(
|
||||||
"testuser",
|
user_id,
|
||||||
"admin",
|
"testuser",
|
||||||
vec!["view".to_string(), "control".to_string()],
|
"admin",
|
||||||
).unwrap();
|
vec!["view".to_string(), "control".to_string()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let claims = config.validate_token(&token).unwrap();
|
let claims = config.validate_token(&token).unwrap();
|
||||||
assert_eq!(claims.username, "testuser");
|
assert_eq!(claims.username, "testuser");
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ pub mod password;
|
|||||||
pub mod token_blacklist;
|
pub mod token_blacklist;
|
||||||
|
|
||||||
pub use jwt::{Claims, JwtConfig};
|
pub use jwt::{Claims, JwtConfig};
|
||||||
pub use password::{hash_password, verify_password, generate_random_password};
|
pub use password::{generate_random_password, hash_password, verify_password};
|
||||||
pub use token_blacklist::TokenBlacklist;
|
pub use token_blacklist::TokenBlacklist;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
Argon2, Algorithm, Version, Params,
|
Algorithm, Argon2, Params, Version,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Hash a password using Argon2id
|
/// Hash a password using Argon2id
|
||||||
@@ -22,9 +22,9 @@ pub fn hash_password(password: &str) -> Result<String> {
|
|||||||
|
|
||||||
// Explicitly use Argon2id (Algorithm::Argon2id)
|
// Explicitly use Argon2id (Algorithm::Argon2id)
|
||||||
let argon2 = Argon2::new(
|
let argon2 = Argon2::new(
|
||||||
Algorithm::Argon2id, // SEC-9: Explicit Argon2id variant
|
Algorithm::Argon2id, // SEC-9: Explicit Argon2id variant
|
||||||
Version::V0x13, // Latest version
|
Version::V0x13, // Latest version
|
||||||
Params::default(), // Default params (19456 KiB, 2 iterations, 1 parallelism)
|
Params::default(), // Default params (19456 KiB, 2 iterations, 1 parallelism)
|
||||||
);
|
);
|
||||||
|
|
||||||
let hash = argon2
|
let hash = argon2
|
||||||
@@ -35,12 +35,14 @@ pub fn hash_password(password: &str) -> Result<String> {
|
|||||||
|
|
||||||
/// Verify a password against a stored hash
|
/// Verify a password against a stored hash
|
||||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||||
let parsed_hash = PasswordHash::new(hash)
|
let parsed_hash =
|
||||||
.map_err(|e| anyhow!("Invalid password hash format: {}", e))?;
|
PasswordHash::new(hash).map_err(|e| anyhow!("Invalid password hash format: {}", e))?;
|
||||||
|
|
||||||
// Argon2::default() uses Argon2id, but we verify against the hash's embedded algorithm
|
// Argon2::default() uses Argon2id, but we verify against the hash's embedded algorithm
|
||||||
let argon2 = Argon2::default();
|
let argon2 = Argon2::default();
|
||||||
Ok(argon2.verify_password(password.as_bytes(), &parsed_hash).is_ok())
|
Ok(argon2
|
||||||
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
.is_ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a random password (for initial admin)
|
/// Generate a random password (for initial admin)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{info, debug};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
/// Token blacklist for revocation
|
/// Token blacklist for revocation
|
||||||
///
|
///
|
||||||
@@ -41,7 +41,10 @@ impl TokenBlacklist {
|
|||||||
let was_new = tokens.insert(token.to_string());
|
let was_new = tokens.insert(token.to_string());
|
||||||
|
|
||||||
if was_new {
|
if was_new {
|
||||||
debug!("Token revoked and added to blacklist (length: {})", token.len());
|
debug!(
|
||||||
|
"Token revoked and added to blacklist (length: {})",
|
||||||
|
token.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +95,11 @@ impl TokenBlacklist {
|
|||||||
let removed = original_len - tokens.len();
|
let removed = original_len - tokens.len();
|
||||||
|
|
||||||
if removed > 0 {
|
if removed > 0 {
|
||||||
info!("Cleaned {} expired tokens from blacklist ({} remaining)", removed, tokens.len());
|
info!(
|
||||||
|
"Cleaned {} expired tokens from blacklist ({} remaining)",
|
||||||
|
removed,
|
||||||
|
tokens.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
removed
|
removed
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ impl EventTypes {
|
|||||||
pub const CONNECTION_REJECTED_NO_AUTH: &'static str = "connection_rejected_no_auth";
|
pub const CONNECTION_REJECTED_NO_AUTH: &'static str = "connection_rejected_no_auth";
|
||||||
pub const CONNECTION_REJECTED_INVALID_CODE: &'static str = "connection_rejected_invalid_code";
|
pub const CONNECTION_REJECTED_INVALID_CODE: &'static str = "connection_rejected_invalid_code";
|
||||||
pub const CONNECTION_REJECTED_EXPIRED_CODE: &'static str = "connection_rejected_expired_code";
|
pub const CONNECTION_REJECTED_EXPIRED_CODE: &'static str = "connection_rejected_expired_code";
|
||||||
pub const CONNECTION_REJECTED_INVALID_API_KEY: &'static str = "connection_rejected_invalid_api_key";
|
pub const CONNECTION_REJECTED_INVALID_API_KEY: &'static str =
|
||||||
pub const CONNECTION_REJECTED_CANCELLED_CODE: &'static str = "connection_rejected_cancelled_code";
|
"connection_rejected_invalid_api_key";
|
||||||
|
pub const CONNECTION_REJECTED_CANCELLED_CODE: &'static str =
|
||||||
|
"connection_rejected_cancelled_code";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log a session event
|
/// Log a session event
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ pub async fn update_machine_status(
|
|||||||
/// Get all persistent machines (for restore on startup)
|
/// Get all persistent machines (for restore on startup)
|
||||||
pub async fn get_all_machines(pool: &PgPool) -> Result<Vec<Machine>, sqlx::Error> {
|
pub async fn get_all_machines(pool: &PgPool) -> Result<Vec<Machine>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Machine>(
|
sqlx::query_as::<_, Machine>(
|
||||||
"SELECT * FROM connect_machines WHERE is_persistent = true ORDER BY hostname"
|
"SELECT * FROM connect_machines WHERE is_persistent = true ORDER BY hostname",
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
@@ -91,20 +91,20 @@ pub async fn get_machine_by_agent_id(
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
) -> Result<Option<Machine>, sqlx::Error> {
|
) -> Result<Option<Machine>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Machine>(
|
sqlx::query_as::<_, Machine>("SELECT * FROM connect_machines WHERE agent_id = $1")
|
||||||
"SELECT * FROM connect_machines WHERE agent_id = $1"
|
.bind(agent_id)
|
||||||
)
|
.fetch_optional(pool)
|
||||||
.bind(agent_id)
|
.await
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark machine as offline
|
/// Mark machine as offline
|
||||||
pub async fn mark_machine_offline(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::Error> {
|
pub async fn mark_machine_offline(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::Error> {
|
||||||
sqlx::query("UPDATE connect_machines SET status = 'offline', last_seen = NOW() WHERE agent_id = $1")
|
sqlx::query(
|
||||||
.bind(agent_id)
|
"UPDATE connect_machines SET status = 'offline', last_seen = NOW() WHERE agent_id = $1",
|
||||||
.execute(pool)
|
)
|
||||||
.await?;
|
.bind(agent_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,24 @@
|
|||||||
//! Handles persistence for machines, sessions, and audit logging.
|
//! Handles persistence for machines, sessions, and audit logging.
|
||||||
//! Optional - server works without database if DATABASE_URL not set.
|
//! Optional - server works without database if DATABASE_URL not set.
|
||||||
|
|
||||||
pub mod machines;
|
|
||||||
pub mod sessions;
|
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod machines;
|
||||||
|
pub mod releases;
|
||||||
|
pub mod sessions;
|
||||||
pub mod support_codes;
|
pub mod support_codes;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod releases;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
pub use machines::*;
|
|
||||||
pub use sessions::*;
|
|
||||||
pub use events::*;
|
pub use events::*;
|
||||||
|
pub use machines::*;
|
||||||
|
pub use releases::*;
|
||||||
|
pub use sessions::*;
|
||||||
pub use support_codes::*;
|
pub use support_codes::*;
|
||||||
pub use users::*;
|
pub use users::*;
|
||||||
pub use releases::*;
|
|
||||||
|
|
||||||
/// Database connection pool wrapper
|
/// Database connection pool wrapper
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ pub async fn create_session(
|
|||||||
pub async fn end_session(
|
pub async fn end_session(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
status: &str, // 'ended' or 'disconnected' or 'timeout'
|
status: &str, // 'ended' or 'disconnected' or 'timeout'
|
||||||
) -> Result<(), sqlx::Error> {
|
) -> Result<(), sqlx::Error> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -64,7 +64,10 @@ pub async fn end_session(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get session by ID
|
/// Get session by ID
|
||||||
pub async fn get_session(pool: &PgPool, session_id: Uuid) -> Result<Option<DbSession>, sqlx::Error> {
|
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")
|
sqlx::query_as::<_, DbSession>("SELECT * FROM connect_sessions WHERE id = $1")
|
||||||
.bind(session_id)
|
.bind(session_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -85,12 +88,9 @@ pub async fn get_active_sessions_for_machine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get recent sessions (for dashboard)
|
/// Get recent sessions (for dashboard)
|
||||||
pub async fn get_recent_sessions(
|
pub async fn get_recent_sessions(pool: &PgPool, limit: i64) -> Result<Vec<DbSession>, sqlx::Error> {
|
||||||
pool: &PgPool,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<Vec<DbSession>, sqlx::Error> {
|
|
||||||
sqlx::query_as::<_, DbSession>(
|
sqlx::query_as::<_, DbSession>(
|
||||||
"SELECT * FROM connect_sessions ORDER BY started_at DESC LIMIT $1"
|
"SELECT * FROM connect_sessions ORDER BY started_at DESC LIMIT $1",
|
||||||
)
|
)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -103,7 +103,7 @@ pub async fn get_sessions_for_machine(
|
|||||||
machine_id: Uuid,
|
machine_id: Uuid,
|
||||||
) -> Result<Vec<DbSession>, sqlx::Error> {
|
) -> Result<Vec<DbSession>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, DbSession>(
|
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 ORDER BY started_at DESC",
|
||||||
)
|
)
|
||||||
.bind(machine_id)
|
.bind(machine_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
|
|||||||
@@ -40,13 +40,14 @@ pub async fn create_support_code(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get support code by code string
|
/// Get support code by code string
|
||||||
pub async fn get_support_code(pool: &PgPool, code: &str) -> Result<Option<DbSupportCode>, sqlx::Error> {
|
pub async fn get_support_code(
|
||||||
sqlx::query_as::<_, DbSupportCode>(
|
pool: &PgPool,
|
||||||
"SELECT * FROM connect_support_codes WHERE code = $1"
|
code: &str,
|
||||||
)
|
) -> Result<Option<DbSupportCode>, sqlx::Error> {
|
||||||
.bind(code)
|
sqlx::query_as::<_, DbSupportCode>("SELECT * FROM connect_support_codes WHERE code = $1")
|
||||||
.fetch_optional(pool)
|
.bind(code)
|
||||||
.await
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update support code when client connects
|
/// Update support code when client connects
|
||||||
@@ -107,7 +108,7 @@ pub async fn get_active_support_codes(pool: &PgPool) -> Result<Vec<DbSupportCode
|
|||||||
/// Check if code exists and is valid for connection
|
/// Check if code exists and is valid for connection
|
||||||
pub async fn is_code_valid(pool: &PgPool, code: &str) -> Result<bool, sqlx::Error> {
|
pub async fn is_code_valid(pool: &PgPool, code: &str) -> Result<bool, sqlx::Error> {
|
||||||
let result = sqlx::query_scalar::<_, bool>(
|
let result = sqlx::query_scalar::<_, bool>(
|
||||||
"SELECT EXISTS(SELECT 1 FROM connect_support_codes WHERE code = $1 AND status = 'pending')"
|
"SELECT EXISTS(SELECT 1 FROM connect_support_codes WHERE code = $1 AND status = 'pending')",
|
||||||
)
|
)
|
||||||
.bind(code)
|
.bind(code)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|||||||
@@ -49,33 +49,27 @@ impl From<User> for UserInfo {
|
|||||||
|
|
||||||
/// Get user by username
|
/// Get user by username
|
||||||
pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result<Option<User>> {
|
pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result<Option<User>> {
|
||||||
let user = sqlx::query_as::<_, User>(
|
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1")
|
||||||
"SELECT * FROM users WHERE username = $1"
|
.bind(username)
|
||||||
)
|
.fetch_optional(pool)
|
||||||
.bind(username)
|
.await?;
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get user by ID
|
/// Get user by ID
|
||||||
pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>> {
|
pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>> {
|
||||||
let user = sqlx::query_as::<_, User>(
|
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
||||||
"SELECT * FROM users WHERE id = $1"
|
.bind(id)
|
||||||
)
|
.fetch_optional(pool)
|
||||||
.bind(id)
|
.await?;
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all users
|
/// Get all users
|
||||||
pub async fn get_all_users(pool: &PgPool) -> Result<Vec<User>> {
|
pub async fn get_all_users(pool: &PgPool) -> Result<Vec<User>> {
|
||||||
let users = sqlx::query_as::<_, User>(
|
let users = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY username")
|
||||||
"SELECT * FROM users ORDER BY username"
|
.fetch_all(pool)
|
||||||
)
|
.await?;
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(users)
|
Ok(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +86,7 @@ pub async fn create_user(
|
|||||||
INSERT INTO users (username, password_hash, email, role)
|
INSERT INTO users (username, password_hash, email, role)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.bind(username)
|
.bind(username)
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
@@ -117,7 +111,7 @@ pub async fn update_user(
|
|||||||
SET email = $2, role = $3, enabled = $4, updated_at = NOW()
|
SET email = $2, role = $3, enabled = $4, updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(email)
|
.bind(email)
|
||||||
@@ -129,18 +123,13 @@ pub async fn update_user(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update user password
|
/// Update user password
|
||||||
pub async fn update_user_password(
|
pub async fn update_user_password(pool: &PgPool, id: Uuid, password_hash: &str) -> Result<bool> {
|
||||||
pool: &PgPool,
|
let result =
|
||||||
id: Uuid,
|
sqlx::query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1")
|
||||||
password_hash: &str,
|
.bind(id)
|
||||||
) -> Result<bool> {
|
.bind(password_hash)
|
||||||
let result = sqlx::query(
|
.execute(pool)
|
||||||
"UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1"
|
.await?;
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(password_hash)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(result.rows_affected() > 0)
|
Ok(result.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,12 +161,11 @@ pub async fn count_users(pool: &PgPool) -> Result<i64> {
|
|||||||
|
|
||||||
/// Get user permissions
|
/// Get user permissions
|
||||||
pub async fn get_user_permissions(pool: &PgPool, user_id: Uuid) -> Result<Vec<String>> {
|
pub async fn get_user_permissions(pool: &PgPool, user_id: Uuid) -> Result<Vec<String>> {
|
||||||
let perms: Vec<(String,)> = sqlx::query_as(
|
let perms: Vec<(String,)> =
|
||||||
"SELECT permission FROM user_permissions WHERE user_id = $1"
|
sqlx::query_as("SELECT permission FROM user_permissions WHERE user_id = $1")
|
||||||
)
|
.bind(user_id)
|
||||||
.bind(user_id)
|
.fetch_all(pool)
|
||||||
.fetch_all(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
Ok(perms.into_iter().map(|p| p.0).collect())
|
Ok(perms.into_iter().map(|p| p.0).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,25 +183,22 @@ pub async fn set_user_permissions(
|
|||||||
|
|
||||||
// Insert new
|
// Insert new
|
||||||
for perm in permissions {
|
for perm in permissions {
|
||||||
sqlx::query(
|
sqlx::query("INSERT INTO user_permissions (user_id, permission) VALUES ($1, $2)")
|
||||||
"INSERT INTO user_permissions (user_id, permission) VALUES ($1, $2)"
|
.bind(user_id)
|
||||||
)
|
.bind(perm)
|
||||||
.bind(user_id)
|
.execute(pool)
|
||||||
.bind(perm)
|
.await?;
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get user's accessible client IDs (empty = all access)
|
/// Get user's accessible client IDs (empty = all access)
|
||||||
pub async fn get_user_client_access(pool: &PgPool, user_id: Uuid) -> Result<Vec<Uuid>> {
|
pub async fn get_user_client_access(pool: &PgPool, user_id: Uuid) -> Result<Vec<Uuid>> {
|
||||||
let clients: Vec<(Uuid,)> = sqlx::query_as(
|
let clients: Vec<(Uuid,)> =
|
||||||
"SELECT client_id FROM user_client_access WHERE user_id = $1"
|
sqlx::query_as("SELECT client_id FROM user_client_access WHERE user_id = $1")
|
||||||
)
|
.bind(user_id)
|
||||||
.bind(user_id)
|
.fetch_all(pool)
|
||||||
.fetch_all(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
Ok(clients.into_iter().map(|c| c.0).collect())
|
Ok(clients.into_iter().map(|c| c.0).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,23 +216,17 @@ pub async fn set_user_client_access(
|
|||||||
|
|
||||||
// Insert new
|
// Insert new
|
||||||
for client_id in client_ids {
|
for client_id in client_ids {
|
||||||
sqlx::query(
|
sqlx::query("INSERT INTO user_client_access (user_id, client_id) VALUES ($1, $2)")
|
||||||
"INSERT INTO user_client_access (user_id, client_id) VALUES ($1, $2)"
|
.bind(user_id)
|
||||||
)
|
.bind(client_id)
|
||||||
.bind(user_id)
|
.execute(pool)
|
||||||
.bind(client_id)
|
.await?;
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if user has access to a specific client
|
/// Check if user has access to a specific client
|
||||||
pub async fn user_has_client_access(
|
pub async fn user_has_client_access(pool: &PgPool, user_id: Uuid, client_id: Uuid) -> Result<bool> {
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Uuid,
|
|
||||||
client_id: Uuid,
|
|
||||||
) -> Result<bool> {
|
|
||||||
// Admins have access to all
|
// Admins have access to all
|
||||||
let user = get_user_by_id(pool, user_id).await?;
|
let user = get_user_by_id(pool, user_id).await?;
|
||||||
if let Some(u) = user {
|
if let Some(u) = user {
|
||||||
@@ -258,7 +237,7 @@ pub async fn user_has_client_access(
|
|||||||
|
|
||||||
// Check explicit access
|
// Check explicit access
|
||||||
let access: Option<(Uuid,)> = sqlx::query_as(
|
let access: Option<(Uuid,)> = sqlx::query_as(
|
||||||
"SELECT client_id FROM user_client_access WHERE user_id = $1 AND client_id = $2"
|
"SELECT client_id FROM user_client_access WHERE user_id = $1 AND client_id = $2",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(client_id)
|
.bind(client_id)
|
||||||
@@ -271,12 +250,11 @@ pub async fn user_has_client_access(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has ANY access restrictions
|
// Check if user has ANY access restrictions
|
||||||
let count: (i64,) = sqlx::query_as(
|
let count: (i64,) =
|
||||||
"SELECT COUNT(*) FROM user_client_access WHERE user_id = $1"
|
sqlx::query_as("SELECT COUNT(*) FROM user_client_access WHERE user_id = $1")
|
||||||
)
|
.bind(user_id)
|
||||||
.bind(user_id)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
// No restrictions means access to all
|
// No restrictions means access to all
|
||||||
Ok(count.0 == 0)
|
Ok(count.0 == 0)
|
||||||
|
|||||||
@@ -3,44 +3,44 @@
|
|||||||
//! Handles connections from both agents and dashboard viewers,
|
//! Handles connections from both agents and dashboard viewers,
|
||||||
//! relaying video frames and input events between them.
|
//! relaying video frames and input events between them.
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod metrics;
|
||||||
|
mod middleware;
|
||||||
mod relay;
|
mod relay;
|
||||||
mod session;
|
mod session;
|
||||||
mod auth;
|
|
||||||
mod api;
|
|
||||||
mod db;
|
|
||||||
mod support_codes;
|
mod support_codes;
|
||||||
mod middleware;
|
|
||||||
mod utils;
|
mod utils;
|
||||||
mod metrics;
|
|
||||||
|
|
||||||
pub mod proto {
|
pub mod proto {
|
||||||
include!(concat!(env!("OUT_DIR"), "/guruconnect.rs"));
|
include!(concat!(env!("OUT_DIR"), "/guruconnect.rs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use axum::http::{HeaderValue, Method};
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
extract::{Json, Path, Query, Request, State},
|
||||||
routing::{get, post, put, delete},
|
|
||||||
extract::{Path, State, Json, Query, Request},
|
|
||||||
response::{Html, IntoResponse},
|
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
middleware::{self as axum_middleware, Next},
|
middleware::{self as axum_middleware, Next},
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
routing::{delete, get, post, put},
|
||||||
|
Router,
|
||||||
};
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::cors::{Any, CorsLayer, AllowOrigin};
|
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
|
||||||
use axum::http::{Method, HeaderValue};
|
|
||||||
use tower_http::trace::TraceLayer;
|
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing::{info, Level};
|
use tracing::{info, Level};
|
||||||
use tracing_subscriber::FmtSubscriber;
|
use tracing_subscriber::FmtSubscriber;
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use support_codes::{SupportCodeManager, CreateCodeRequest, SupportCode, CodeValidation};
|
use auth::{generate_random_password, hash_password, AuthenticatedUser, JwtConfig, TokenBlacklist};
|
||||||
use auth::{JwtConfig, TokenBlacklist, hash_password, generate_random_password, AuthenticatedUser};
|
|
||||||
use metrics::SharedMetrics;
|
use metrics::SharedMetrics;
|
||||||
use prometheus_client::registry::Registry;
|
use prometheus_client::registry::Registry;
|
||||||
|
use support_codes::{CodeValidation, CreateCodeRequest, SupportCode, SupportCodeManager};
|
||||||
|
|
||||||
/// Application state
|
/// Application state
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -67,7 +67,9 @@ async fn auth_layer(
|
|||||||
next: Next,
|
next: Next,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
request.extensions_mut().insert(state.jwt_config.clone());
|
request.extensions_mut().insert(state.jwt_config.clone());
|
||||||
request.extensions_mut().insert(Arc::new(state.token_blacklist.clone()));
|
request
|
||||||
|
.extensions_mut()
|
||||||
|
.insert(Arc::new(state.token_blacklist.clone()));
|
||||||
next.run(request).await
|
next.run(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,8 +91,9 @@ async fn main() -> Result<()> {
|
|||||||
info!("Loaded configuration, listening on {}", listen_addr);
|
info!("Loaded configuration, listening on {}", listen_addr);
|
||||||
|
|
||||||
// JWT configuration - REQUIRED for security
|
// JWT configuration - REQUIRED for security
|
||||||
let jwt_secret = std::env::var("JWT_SECRET")
|
let jwt_secret = std::env::var("JWT_SECRET").expect(
|
||||||
.expect("JWT_SECRET environment variable must be set! Generate one with: openssl rand -base64 64");
|
"JWT_SECRET environment variable must be set! Generate one with: openssl rand -base64 64",
|
||||||
|
);
|
||||||
|
|
||||||
if jwt_secret.len() < 32 {
|
if jwt_secret.len() < 32 {
|
||||||
panic!("JWT_SECRET must be at least 32 characters long for security!");
|
panic!("JWT_SECRET must be at least 32 characters long for security!");
|
||||||
@@ -114,7 +117,10 @@ async fn main() -> Result<()> {
|
|||||||
Some(db)
|
Some(db)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Failed to connect to database: {}. Running without persistence.", e);
|
tracing::warn!(
|
||||||
|
"Failed to connect to database: {}. Running without persistence.",
|
||||||
|
e
|
||||||
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,9 +200,14 @@ async fn main() -> Result<()> {
|
|||||||
if let Some(ref db) = database {
|
if let Some(ref db) = database {
|
||||||
match db::machines::get_all_machines(db.pool()).await {
|
match db::machines::get_all_machines(db.pool()).await {
|
||||||
Ok(machines) => {
|
Ok(machines) => {
|
||||||
info!("Restoring {} persistent machines from database", machines.len());
|
info!(
|
||||||
|
"Restoring {} persistent machines from database",
|
||||||
|
machines.len()
|
||||||
|
);
|
||||||
for machine in machines {
|
for machine in machines {
|
||||||
sessions.restore_offline_machine(&machine.agent_id, &machine.hostname).await;
|
sessions
|
||||||
|
.restore_offline_machine(&machine.agent_id, &machine.hostname)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -254,87 +265,117 @@ async fn main() -> Result<()> {
|
|||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
// Prometheus metrics (no auth required - for monitoring)
|
// Prometheus metrics (no auth required - for monitoring)
|
||||||
.route("/metrics", get(prometheus_metrics))
|
.route("/metrics", get(prometheus_metrics))
|
||||||
|
|
||||||
// Auth endpoints (TODO: Add rate limiting - see SEC2_RATE_LIMITING_TODO.md)
|
// Auth endpoints (TODO: Add rate limiting - see SEC2_RATE_LIMITING_TODO.md)
|
||||||
.route("/api/auth/login", post(api::auth::login))
|
.route("/api/auth/login", post(api::auth::login))
|
||||||
.route("/api/auth/change-password", post(api::auth::change_password))
|
.route(
|
||||||
|
"/api/auth/change-password",
|
||||||
|
post(api::auth::change_password),
|
||||||
|
)
|
||||||
.route("/api/auth/me", get(api::auth::get_me))
|
.route("/api/auth/me", get(api::auth::get_me))
|
||||||
.route("/api/auth/logout", post(api::auth_logout::logout))
|
.route("/api/auth/logout", post(api::auth_logout::logout))
|
||||||
.route("/api/auth/revoke-token", post(api::auth_logout::revoke_own_token))
|
.route(
|
||||||
.route("/api/auth/admin/revoke-user", post(api::auth_logout::revoke_user_tokens))
|
"/api/auth/revoke-token",
|
||||||
.route("/api/auth/blacklist/stats", get(api::auth_logout::get_blacklist_stats))
|
post(api::auth_logout::revoke_own_token),
|
||||||
.route("/api/auth/blacklist/cleanup", post(api::auth_logout::cleanup_blacklist))
|
)
|
||||||
|
.route(
|
||||||
|
"/api/auth/admin/revoke-user",
|
||||||
|
post(api::auth_logout::revoke_user_tokens),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/auth/blacklist/stats",
|
||||||
|
get(api::auth_logout::get_blacklist_stats),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/auth/blacklist/cleanup",
|
||||||
|
post(api::auth_logout::cleanup_blacklist),
|
||||||
|
)
|
||||||
// User management (admin only)
|
// User management (admin only)
|
||||||
.route("/api/users", get(api::users::list_users))
|
.route("/api/users", get(api::users::list_users))
|
||||||
.route("/api/users", post(api::users::create_user))
|
.route("/api/users", post(api::users::create_user))
|
||||||
.route("/api/users/:id", get(api::users::get_user))
|
.route("/api/users/:id", get(api::users::get_user))
|
||||||
.route("/api/users/:id", put(api::users::update_user))
|
.route("/api/users/:id", put(api::users::update_user))
|
||||||
.route("/api/users/:id", delete(api::users::delete_user))
|
.route("/api/users/:id", delete(api::users::delete_user))
|
||||||
.route("/api/users/:id/permissions", put(api::users::set_permissions))
|
.route(
|
||||||
|
"/api/users/:id/permissions",
|
||||||
|
put(api::users::set_permissions),
|
||||||
|
)
|
||||||
.route("/api/users/:id/clients", put(api::users::set_client_access))
|
.route("/api/users/:id/clients", put(api::users::set_client_access))
|
||||||
|
|
||||||
// Portal API - Support codes (TODO: Add rate limiting)
|
// Portal API - Support codes (TODO: Add rate limiting)
|
||||||
.route("/api/codes", post(create_code))
|
.route("/api/codes", post(create_code))
|
||||||
.route("/api/codes", get(list_codes))
|
.route("/api/codes", get(list_codes))
|
||||||
.route("/api/codes/:code/validate", get(validate_code))
|
.route("/api/codes/:code/validate", get(validate_code))
|
||||||
.route("/api/codes/:code/cancel", post(cancel_code))
|
.route("/api/codes/:code/cancel", post(cancel_code))
|
||||||
|
|
||||||
// WebSocket endpoints
|
// WebSocket endpoints
|
||||||
.route("/ws/agent", get(relay::agent_ws_handler))
|
.route("/ws/agent", get(relay::agent_ws_handler))
|
||||||
.route("/ws/viewer", get(relay::viewer_ws_handler))
|
.route("/ws/viewer", get(relay::viewer_ws_handler))
|
||||||
|
|
||||||
// REST API - Sessions
|
// REST API - Sessions
|
||||||
.route("/api/sessions", get(list_sessions))
|
.route("/api/sessions", get(list_sessions))
|
||||||
.route("/api/sessions/:id", get(get_session))
|
.route("/api/sessions/:id", get(get_session))
|
||||||
.route("/api/sessions/:id", delete(disconnect_session))
|
.route("/api/sessions/:id", delete(disconnect_session))
|
||||||
|
|
||||||
// REST API - Machines
|
// REST API - Machines
|
||||||
.route("/api/machines", get(list_machines))
|
.route("/api/machines", get(list_machines))
|
||||||
.route("/api/machines/:agent_id", get(get_machine))
|
.route("/api/machines/:agent_id", get(get_machine))
|
||||||
.route("/api/machines/:agent_id", delete(delete_machine))
|
.route("/api/machines/:agent_id", delete(delete_machine))
|
||||||
.route("/api/machines/:agent_id/history", get(get_machine_history))
|
.route("/api/machines/:agent_id/history", get(get_machine_history))
|
||||||
.route("/api/machines/:agent_id/update", post(trigger_machine_update))
|
.route(
|
||||||
|
"/api/machines/:agent_id/update",
|
||||||
|
post(trigger_machine_update),
|
||||||
|
)
|
||||||
// REST API - Releases and Version
|
// REST API - Releases and Version
|
||||||
.route("/api/version", get(api::releases::get_version)) // No auth - for agent polling
|
.route("/api/version", get(api::releases::get_version)) // No auth - for agent polling
|
||||||
.route("/api/releases", get(api::releases::list_releases))
|
.route("/api/releases", get(api::releases::list_releases))
|
||||||
.route("/api/releases", post(api::releases::create_release))
|
.route("/api/releases", post(api::releases::create_release))
|
||||||
.route("/api/releases/:version", get(api::releases::get_release))
|
.route("/api/releases/:version", get(api::releases::get_release))
|
||||||
.route("/api/releases/:version", put(api::releases::update_release))
|
.route("/api/releases/:version", put(api::releases::update_release))
|
||||||
.route("/api/releases/:version", delete(api::releases::delete_release))
|
.route(
|
||||||
|
"/api/releases/:version",
|
||||||
|
delete(api::releases::delete_release),
|
||||||
|
)
|
||||||
|
// Changelog (no auth - public, like /api/version)
|
||||||
|
// Single route: version == "latest" selects the latest file; axum 0.7 / matchit 0.7
|
||||||
|
// panics if a static segment and a path param share this position, so do not split it.
|
||||||
|
.route(
|
||||||
|
"/api/changelog/:component/:version",
|
||||||
|
get(api::changelog::get),
|
||||||
|
)
|
||||||
// Agent downloads (no auth - public download links)
|
// Agent downloads (no auth - public download links)
|
||||||
.route("/api/download/viewer", get(api::downloads::download_viewer))
|
.route("/api/download/viewer", get(api::downloads::download_viewer))
|
||||||
.route("/api/download/support", get(api::downloads::download_support))
|
.route(
|
||||||
|
"/api/download/support",
|
||||||
|
get(api::downloads::download_support),
|
||||||
|
)
|
||||||
.route("/api/download/agent", get(api::downloads::download_agent))
|
.route("/api/download/agent", get(api::downloads::download_agent))
|
||||||
|
|
||||||
// HTML page routes (clean URLs)
|
// HTML page routes (clean URLs)
|
||||||
.route("/login", get(serve_login))
|
.route("/login", get(serve_login))
|
||||||
.route("/dashboard", get(serve_dashboard))
|
.route("/dashboard", get(serve_dashboard))
|
||||||
.route("/users", get(serve_users))
|
.route("/users", get(serve_users))
|
||||||
|
|
||||||
// State and middleware
|
// State and middleware
|
||||||
.with_state(state.clone())
|
.with_state(state.clone())
|
||||||
.layer(axum_middleware::from_fn_with_state(state, auth_layer))
|
.layer(axum_middleware::from_fn_with_state(state, auth_layer))
|
||||||
|
|
||||||
// Serve static files for portal (fallback)
|
// Serve static files for portal (fallback)
|
||||||
.fallback_service(ServeDir::new("static").append_index_html_on_directories(true))
|
.fallback_service(ServeDir::new("static").append_index_html_on_directories(true))
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
.layer(axum_middleware::from_fn(middleware::add_security_headers)) // SEC-7 & SEC-12
|
.layer(axum_middleware::from_fn(middleware::add_security_headers)) // SEC-7 & SEC-12
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
// SEC-11: Restricted CORS configuration
|
// SEC-11: Restricted CORS configuration
|
||||||
.layer({
|
.layer({
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
// Allow requests from the production domain and localhost (for development)
|
// Allow requests from the production domain and localhost (for development)
|
||||||
.allow_origin([
|
.allow_origin([
|
||||||
"https://connect.azcomputerguru.com".parse::<HeaderValue>().unwrap(),
|
"https://connect.azcomputerguru.com"
|
||||||
|
.parse::<HeaderValue>()
|
||||||
|
.unwrap(),
|
||||||
"http://localhost:3002".parse::<HeaderValue>().unwrap(),
|
"http://localhost:3002".parse::<HeaderValue>().unwrap(),
|
||||||
"http://127.0.0.1:3002".parse::<HeaderValue>().unwrap(),
|
"http://127.0.0.1:3002".parse::<HeaderValue>().unwrap(),
|
||||||
])
|
])
|
||||||
// Allow only necessary HTTP methods
|
// Allow only necessary HTTP methods
|
||||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
|
.allow_methods([
|
||||||
|
Method::GET,
|
||||||
|
Method::POST,
|
||||||
|
Method::PUT,
|
||||||
|
Method::DELETE,
|
||||||
|
Method::OPTIONS,
|
||||||
|
])
|
||||||
// Allow common headers needed for API requests
|
// Allow common headers needed for API requests
|
||||||
.allow_headers([
|
.allow_headers([
|
||||||
axum::http::header::AUTHORIZATION,
|
axum::http::header::AUTHORIZATION,
|
||||||
@@ -355,8 +396,9 @@ async fn main() -> Result<()> {
|
|||||||
// Use into_make_service_with_connect_info to enable IP address extraction
|
// Use into_make_service_with_connect_info to enable IP address extraction
|
||||||
axum::serve(
|
axum::serve(
|
||||||
listener,
|
listener,
|
||||||
app.into_make_service_with_connect_info::<SocketAddr>()
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -366,9 +408,7 @@ async fn health() -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Prometheus metrics endpoint
|
/// Prometheus metrics endpoint
|
||||||
async fn prometheus_metrics(
|
async fn prometheus_metrics(State(state): State<AppState>) -> String {
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> String {
|
|
||||||
use prometheus_client::encoding::text::encode;
|
use prometheus_client::encoding::text::encode;
|
||||||
|
|
||||||
let registry = state.registry.lock().unwrap();
|
let registry = state.registry.lock().unwrap();
|
||||||
@@ -380,7 +420,7 @@ async fn prometheus_metrics(
|
|||||||
// Support code API handlers
|
// Support code API handlers
|
||||||
|
|
||||||
async fn create_code(
|
async fn create_code(
|
||||||
_user: AuthenticatedUser, // Require authentication
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(request): Json<CreateCodeRequest>,
|
Json(request): Json<CreateCodeRequest>,
|
||||||
) -> Json<SupportCode> {
|
) -> Json<SupportCode> {
|
||||||
@@ -390,7 +430,7 @@ async fn create_code(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn list_codes(
|
async fn list_codes(
|
||||||
_user: AuthenticatedUser, // Require authentication
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Json<Vec<SupportCode>> {
|
) -> Json<Vec<SupportCode>> {
|
||||||
Json(state.support_codes.list_active_codes().await)
|
Json(state.support_codes.list_active_codes().await)
|
||||||
@@ -409,7 +449,7 @@ async fn validate_code(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cancel_code(
|
async fn cancel_code(
|
||||||
_user: AuthenticatedUser, // Require authentication
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(code): Path<String>,
|
Path(code): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
@@ -423,7 +463,7 @@ async fn cancel_code(
|
|||||||
// Session API handlers (updated to use AppState)
|
// Session API handlers (updated to use AppState)
|
||||||
|
|
||||||
async fn list_sessions(
|
async fn list_sessions(
|
||||||
_user: AuthenticatedUser, // Require authentication
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Json<Vec<api::SessionInfo>> {
|
) -> Json<Vec<api::SessionInfo>> {
|
||||||
let sessions = state.sessions.list_sessions().await;
|
let sessions = state.sessions.list_sessions().await;
|
||||||
@@ -431,21 +471,24 @@ async fn list_sessions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_session(
|
async fn get_session(
|
||||||
_user: AuthenticatedUser, // Require authentication
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<api::SessionInfo>, (StatusCode, &'static str)> {
|
) -> Result<Json<api::SessionInfo>, (StatusCode, &'static str)> {
|
||||||
let session_id = uuid::Uuid::parse_str(&id)
|
let session_id =
|
||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID"))?;
|
uuid::Uuid::parse_str(&id).map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID"))?;
|
||||||
|
|
||||||
let session = state.sessions.get_session(session_id).await
|
let session = state
|
||||||
|
.sessions
|
||||||
|
.get_session(session_id)
|
||||||
|
.await
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Session not found"))?;
|
.ok_or((StatusCode::NOT_FOUND, "Session not found"))?;
|
||||||
|
|
||||||
Ok(Json(api::SessionInfo::from(session)))
|
Ok(Json(api::SessionInfo::from(session)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn disconnect_session(
|
async fn disconnect_session(
|
||||||
_user: AuthenticatedUser, // Require authentication
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
@@ -454,7 +497,11 @@ async fn disconnect_session(
|
|||||||
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid session ID"),
|
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid session ID"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if state.sessions.disconnect_session(session_id, "Disconnected by administrator").await {
|
if state
|
||||||
|
.sessions
|
||||||
|
.disconnect_session(session_id, "Disconnected by administrator")
|
||||||
|
.await
|
||||||
|
{
|
||||||
info!("Session {} disconnected by admin", session_id);
|
info!("Session {} disconnected by admin", session_id);
|
||||||
(StatusCode::OK, "Session disconnected")
|
(StatusCode::OK, "Session disconnected")
|
||||||
} else {
|
} else {
|
||||||
@@ -465,27 +512,35 @@ async fn disconnect_session(
|
|||||||
// Machine API handlers
|
// Machine API handlers
|
||||||
|
|
||||||
async fn list_machines(
|
async fn list_machines(
|
||||||
_user: AuthenticatedUser, // Require authentication
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<Vec<api::MachineInfo>>, (StatusCode, &'static str)> {
|
) -> Result<Json<Vec<api::MachineInfo>>, (StatusCode, &'static str)> {
|
||||||
let db = state.db.as_ref()
|
let db = state
|
||||||
|
.db
|
||||||
|
.as_ref()
|
||||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||||
|
|
||||||
let machines = db::machines::get_all_machines(db.pool()).await
|
let machines = db::machines::get_all_machines(db.pool())
|
||||||
|
.await
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||||
|
|
||||||
Ok(Json(machines.into_iter().map(api::MachineInfo::from).collect()))
|
Ok(Json(
|
||||||
|
machines.into_iter().map(api::MachineInfo::from).collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_machine(
|
async fn get_machine(
|
||||||
_user: AuthenticatedUser, // Require authentication
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(agent_id): Path<String>,
|
Path(agent_id): Path<String>,
|
||||||
) -> Result<Json<api::MachineInfo>, (StatusCode, &'static str)> {
|
) -> Result<Json<api::MachineInfo>, (StatusCode, &'static str)> {
|
||||||
let db = state.db.as_ref()
|
let db = state
|
||||||
|
.db
|
||||||
|
.as_ref()
|
||||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||||
|
|
||||||
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id).await
|
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id)
|
||||||
|
.await
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
|
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
|
||||||
|
|
||||||
@@ -493,24 +548,29 @@ async fn get_machine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_machine_history(
|
async fn get_machine_history(
|
||||||
_user: AuthenticatedUser, // Require authentication
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(agent_id): Path<String>,
|
Path(agent_id): Path<String>,
|
||||||
) -> Result<Json<api::MachineHistory>, (StatusCode, &'static str)> {
|
) -> Result<Json<api::MachineHistory>, (StatusCode, &'static str)> {
|
||||||
let db = state.db.as_ref()
|
let db = state
|
||||||
|
.db
|
||||||
|
.as_ref()
|
||||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||||
|
|
||||||
// Get machine
|
// Get machine
|
||||||
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id).await
|
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id)
|
||||||
|
.await
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
|
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
|
||||||
|
|
||||||
// Get sessions for this machine
|
// Get sessions for this machine
|
||||||
let sessions = db::sessions::get_sessions_for_machine(db.pool(), machine.id).await
|
let sessions = db::sessions::get_sessions_for_machine(db.pool(), machine.id)
|
||||||
|
.await
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||||
|
|
||||||
// Get events for this machine
|
// Get events for this machine
|
||||||
let events = db::events::get_events_for_machine(db.pool(), machine.id).await
|
let events = db::events::get_events_for_machine(db.pool(), machine.id)
|
||||||
|
.await
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||||
|
|
||||||
let history = api::MachineHistory {
|
let history = api::MachineHistory {
|
||||||
@@ -524,24 +584,29 @@ async fn get_machine_history(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_machine(
|
async fn delete_machine(
|
||||||
_user: AuthenticatedUser, // Require authentication
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(agent_id): Path<String>,
|
Path(agent_id): Path<String>,
|
||||||
Query(params): Query<api::DeleteMachineParams>,
|
Query(params): Query<api::DeleteMachineParams>,
|
||||||
) -> Result<Json<api::DeleteMachineResponse>, (StatusCode, &'static str)> {
|
) -> Result<Json<api::DeleteMachineResponse>, (StatusCode, &'static str)> {
|
||||||
let db = state.db.as_ref()
|
let db = state
|
||||||
|
.db
|
||||||
|
.as_ref()
|
||||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||||
|
|
||||||
// Get machine first
|
// Get machine first
|
||||||
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id).await
|
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id)
|
||||||
|
.await
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
|
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
|
||||||
|
|
||||||
// Export history if requested
|
// Export history if requested
|
||||||
let history = if params.export {
|
let history = if params.export {
|
||||||
let sessions = db::sessions::get_sessions_for_machine(db.pool(), machine.id).await
|
let sessions = db::sessions::get_sessions_for_machine(db.pool(), machine.id)
|
||||||
|
.await
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||||
let events = db::events::get_events_for_machine(db.pool(), machine.id).await
|
let events = db::events::get_events_for_machine(db.pool(), machine.id)
|
||||||
|
.await
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||||
|
|
||||||
Some(api::MachineHistory {
|
Some(api::MachineHistory {
|
||||||
@@ -560,11 +625,14 @@ async fn delete_machine(
|
|||||||
// Find session for this agent
|
// Find session for this agent
|
||||||
if let Some(session) = state.sessions.get_session_by_agent(&agent_id).await {
|
if let Some(session) = state.sessions.get_session_by_agent(&agent_id).await {
|
||||||
if session.is_online {
|
if session.is_online {
|
||||||
uninstall_sent = state.sessions.send_admin_command(
|
uninstall_sent = state
|
||||||
session.id,
|
.sessions
|
||||||
proto::AdminCommandType::AdminUninstall,
|
.send_admin_command(
|
||||||
"Deleted by administrator",
|
session.id,
|
||||||
).await;
|
proto::AdminCommandType::AdminUninstall,
|
||||||
|
"Deleted by administrator",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
if uninstall_sent {
|
if uninstall_sent {
|
||||||
info!("Sent uninstall command to agent {}", agent_id);
|
info!("Sent uninstall command to agent {}", agent_id);
|
||||||
}
|
}
|
||||||
@@ -576,10 +644,19 @@ async fn delete_machine(
|
|||||||
state.sessions.remove_agent(&agent_id).await;
|
state.sessions.remove_agent(&agent_id).await;
|
||||||
|
|
||||||
// Delete from database (cascades to sessions and events)
|
// Delete from database (cascades to sessions and events)
|
||||||
db::machines::delete_machine(db.pool(), &agent_id).await
|
db::machines::delete_machine(db.pool(), &agent_id)
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to delete machine"))?;
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to delete machine",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
info!("Deleted machine {} (uninstall_sent: {})", agent_id, uninstall_sent);
|
info!(
|
||||||
|
"Deleted machine {} (uninstall_sent: {})",
|
||||||
|
agent_id, uninstall_sent
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(api::DeleteMachineResponse {
|
Ok(Json(api::DeleteMachineResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -598,27 +675,34 @@ struct TriggerUpdateRequest {
|
|||||||
|
|
||||||
/// Trigger update on a specific machine
|
/// Trigger update on a specific machine
|
||||||
async fn trigger_machine_update(
|
async fn trigger_machine_update(
|
||||||
_user: AuthenticatedUser, // Require authentication
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(agent_id): Path<String>,
|
Path(agent_id): Path<String>,
|
||||||
Json(request): Json<TriggerUpdateRequest>,
|
Json(request): Json<TriggerUpdateRequest>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, &'static str)> {
|
) -> Result<impl IntoResponse, (StatusCode, &'static str)> {
|
||||||
let db = state.db.as_ref()
|
let db = state
|
||||||
|
.db
|
||||||
|
.as_ref()
|
||||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||||
|
|
||||||
// Get the target release (either specified or latest stable)
|
// Get the target release (either specified or latest stable)
|
||||||
let release = if let Some(version) = request.version {
|
let release = if let Some(version) = request.version {
|
||||||
db::releases::get_release_by_version(db.pool(), &version).await
|
db::releases::get_release_by_version(db.pool(), &version)
|
||||||
|
.await
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Release version not found"))?
|
.ok_or((StatusCode::NOT_FOUND, "Release version not found"))?
|
||||||
} else {
|
} else {
|
||||||
db::releases::get_latest_stable_release(db.pool()).await
|
db::releases::get_latest_stable_release(db.pool())
|
||||||
|
.await
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "No stable release available"))?
|
.ok_or((StatusCode::NOT_FOUND, "No stable release available"))?
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find session for this agent
|
// Find session for this agent
|
||||||
let session = state.sessions.get_session_by_agent(&agent_id).await
|
let session = state
|
||||||
|
.sessions
|
||||||
|
.get_session_by_agent(&agent_id)
|
||||||
|
.await
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Agent not found or offline"))?;
|
.ok_or((StatusCode::NOT_FOUND, "Agent not found or offline"))?;
|
||||||
|
|
||||||
if !session.is_online {
|
if !session.is_online {
|
||||||
@@ -627,21 +711,31 @@ async fn trigger_machine_update(
|
|||||||
|
|
||||||
// Send update command via WebSocket
|
// Send update command via WebSocket
|
||||||
// For now, we send admin command - later we'll include UpdateInfo in the message
|
// For now, we send admin command - later we'll include UpdateInfo in the message
|
||||||
let sent = state.sessions.send_admin_command(
|
let sent = state
|
||||||
session.id,
|
.sessions
|
||||||
proto::AdminCommandType::AdminUpdate,
|
.send_admin_command(
|
||||||
&format!("Update to version {}", release.version),
|
session.id,
|
||||||
).await;
|
proto::AdminCommandType::AdminUpdate,
|
||||||
|
&format!("Update to version {}", release.version),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
if sent {
|
if sent {
|
||||||
info!("Sent update command to agent {} (version {})", agent_id, release.version);
|
info!(
|
||||||
|
"Sent update command to agent {} (version {})",
|
||||||
|
agent_id, release.version
|
||||||
|
);
|
||||||
|
|
||||||
// Update machine update status in database
|
// Update machine update status in database
|
||||||
let _ = db::releases::update_machine_update_status(db.pool(), &agent_id, "downloading").await;
|
let _ =
|
||||||
|
db::releases::update_machine_update_status(db.pool(), &agent_id, "downloading").await;
|
||||||
|
|
||||||
Ok((StatusCode::OK, "Update command sent"))
|
Ok((StatusCode::OK, "Update command sent"))
|
||||||
} else {
|
} else {
|
||||||
Err((StatusCode::INTERNAL_SERVER_ERROR, "Failed to send update command"))
|
Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to send update command",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,26 +22,26 @@ pub struct RequestLabels {
|
|||||||
/// Metrics labels for session events
|
/// Metrics labels for session events
|
||||||
#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]
|
#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]
|
||||||
pub struct SessionLabels {
|
pub struct SessionLabels {
|
||||||
pub status: String, // created, closed, failed, expired
|
pub status: String, // created, closed, failed, expired
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Metrics labels for connection events
|
/// Metrics labels for connection events
|
||||||
#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]
|
#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]
|
||||||
pub struct ConnectionLabels {
|
pub struct ConnectionLabels {
|
||||||
pub conn_type: String, // agent, viewer, dashboard
|
pub conn_type: String, // agent, viewer, dashboard
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Metrics labels for error tracking
|
/// Metrics labels for error tracking
|
||||||
#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]
|
#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]
|
||||||
pub struct ErrorLabels {
|
pub struct ErrorLabels {
|
||||||
pub error_type: String, // auth, database, websocket, protocol, internal
|
pub error_type: String, // auth, database, websocket, protocol, internal
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Metrics labels for database operations
|
/// Metrics labels for database operations
|
||||||
#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]
|
#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]
|
||||||
pub struct DatabaseLabels {
|
pub struct DatabaseLabels {
|
||||||
pub operation: String, // select, insert, update, delete
|
pub operation: String, // select, insert, update, delete
|
||||||
pub status: String, // success, error
|
pub status: String, // success, error
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GuruConnect server metrics
|
/// GuruConnect server metrics
|
||||||
@@ -82,9 +82,10 @@ impl Metrics {
|
|||||||
requests_total.clone(),
|
requests_total.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let request_duration_seconds = Family::<RequestLabels, Histogram>::new_with_constructor(|| {
|
let request_duration_seconds =
|
||||||
Histogram::new(exponential_buckets(0.001, 2.0, 10)) // 1ms to ~1s
|
Family::<RequestLabels, Histogram>::new_with_constructor(|| {
|
||||||
});
|
Histogram::new(exponential_buckets(0.001, 2.0, 10)) // 1ms to ~1s
|
||||||
|
});
|
||||||
registry.register(
|
registry.register(
|
||||||
"guruconnect_request_duration_seconds",
|
"guruconnect_request_duration_seconds",
|
||||||
"HTTP request duration in seconds",
|
"HTTP request duration in seconds",
|
||||||
@@ -106,7 +107,7 @@ impl Metrics {
|
|||||||
active_sessions.clone(),
|
active_sessions.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let session_duration_seconds = Histogram::new(exponential_buckets(1.0, 2.0, 15)); // 1s to ~9 hours
|
let session_duration_seconds = Histogram::new(exponential_buckets(1.0, 2.0, 15)); // 1s to ~9 hours
|
||||||
registry.register(
|
registry.register(
|
||||||
"guruconnect_session_duration_seconds",
|
"guruconnect_session_duration_seconds",
|
||||||
"Session duration in seconds",
|
"Session duration in seconds",
|
||||||
@@ -144,9 +145,10 @@ impl Metrics {
|
|||||||
db_operations_total.clone(),
|
db_operations_total.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let db_query_duration_seconds = Family::<DatabaseLabels, Histogram>::new_with_constructor(|| {
|
let db_query_duration_seconds =
|
||||||
Histogram::new(exponential_buckets(0.0001, 2.0, 12)) // 0.1ms to ~400ms
|
Family::<DatabaseLabels, Histogram>::new_with_constructor(|| {
|
||||||
});
|
Histogram::new(exponential_buckets(0.0001, 2.0, 12)) // 0.1ms to ~400ms
|
||||||
|
});
|
||||||
registry.register(
|
registry.register(
|
||||||
"guruconnect_db_query_duration_seconds",
|
"guruconnect_db_query_duration_seconds",
|
||||||
"Database query duration in seconds",
|
"Database query duration in seconds",
|
||||||
@@ -188,7 +190,13 @@ impl Metrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Record request duration
|
/// Record request duration
|
||||||
pub fn record_request_duration(&self, method: &str, path: &str, status: u16, duration_secs: f64) {
|
pub fn record_request_duration(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
status: u16,
|
||||||
|
duration_secs: f64,
|
||||||
|
) {
|
||||||
self.request_duration_seconds
|
self.request_duration_seconds
|
||||||
.get_or_create(&RequestLabels {
|
.get_or_create(&RequestLabels {
|
||||||
method: method.to_string(),
|
method: method.to_string(),
|
||||||
|
|||||||
@@ -3,17 +3,10 @@
|
|||||||
//! SEC-7: XSS Prevention via Content-Security-Policy
|
//! SEC-7: XSS Prevention via Content-Security-Policy
|
||||||
//! SEC-12: Additional security headers
|
//! SEC-12: Additional security headers
|
||||||
|
|
||||||
use axum::{
|
use axum::{extract::Request, middleware::Next, response::Response};
|
||||||
extract::Request,
|
|
||||||
middleware::Next,
|
|
||||||
response::Response,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Add security headers to all responses
|
/// Add security headers to all responses
|
||||||
pub async fn add_security_headers(
|
pub async fn add_security_headers(request: Request, next: Next) -> Response {
|
||||||
request: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Response {
|
|
||||||
let mut response = next.run(request).await;
|
let mut response = next.run(request).await;
|
||||||
let headers = response.headers_mut();
|
let headers = response.headers_mut();
|
||||||
|
|
||||||
@@ -35,22 +28,13 @@ pub async fn add_security_headers(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// SEC-12: X-Frame-Options (Clickjacking protection)
|
// SEC-12: X-Frame-Options (Clickjacking protection)
|
||||||
headers.insert(
|
headers.insert("X-Frame-Options", "DENY".parse().unwrap());
|
||||||
"X-Frame-Options",
|
|
||||||
"DENY".parse().unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// SEC-12: X-Content-Type-Options (MIME sniffing protection)
|
// SEC-12: X-Content-Type-Options (MIME sniffing protection)
|
||||||
headers.insert(
|
headers.insert("X-Content-Type-Options", "nosniff".parse().unwrap());
|
||||||
"X-Content-Type-Options",
|
|
||||||
"nosniff".parse().unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// SEC-12: X-XSS-Protection (Legacy XSS filter - deprecated but still useful)
|
// SEC-12: X-XSS-Protection (Legacy XSS filter - deprecated but still useful)
|
||||||
headers.insert(
|
headers.insert("X-XSS-Protection", "1; mode=block".parse().unwrap());
|
||||||
"X-XSS-Protection",
|
|
||||||
"1; mode=block".parse().unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// SEC-12: Referrer-Policy (Control referrer information)
|
// SEC-12: Referrer-Policy (Control referrer information)
|
||||||
headers.insert(
|
headers.insert(
|
||||||
|
|||||||
@@ -6,21 +6,21 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{
|
extract::{
|
||||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
Query, State, ConnectInfo,
|
ConnectInfo, Query, State,
|
||||||
},
|
},
|
||||||
response::IntoResponse,
|
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
use std::net::SocketAddr;
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use prost::Message as ProstMessage;
|
use prost::Message as ProstMessage;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::net::SocketAddr;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::db::{self, Database};
|
||||||
use crate::proto;
|
use crate::proto;
|
||||||
use crate::session::SessionManager;
|
use crate::session::SessionManager;
|
||||||
use crate::db::{self, Database};
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -59,7 +59,11 @@ pub async fn agent_ws_handler(
|
|||||||
Query(params): Query<AgentParams>,
|
Query(params): Query<AgentParams>,
|
||||||
) -> Result<impl IntoResponse, StatusCode> {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
let agent_id = params.agent_id.clone();
|
let agent_id = params.agent_id.clone();
|
||||||
let agent_name = params.hostname.clone().or(params.agent_name.clone()).unwrap_or_else(|| agent_id.clone());
|
let agent_name = params
|
||||||
|
.hostname
|
||||||
|
.clone()
|
||||||
|
.or(params.agent_name.clone())
|
||||||
|
.unwrap_or_else(|| agent_id.clone());
|
||||||
let support_code = params.support_code.clone();
|
let support_code = params.support_code.clone();
|
||||||
let api_key = params.api_key.clone();
|
let api_key = params.api_key.clone();
|
||||||
let client_ip = addr.ip();
|
let client_ip = addr.ip();
|
||||||
@@ -69,7 +73,10 @@ pub async fn agent_ws_handler(
|
|||||||
// API key = persistent managed agent
|
// API key = persistent managed agent
|
||||||
|
|
||||||
if support_code.is_none() && api_key.is_none() {
|
if support_code.is_none() && api_key.is_none() {
|
||||||
warn!("Agent connection rejected: {} from {} - no support code or API key", agent_id, client_ip);
|
warn!(
|
||||||
|
"Agent connection rejected: {} from {} - no support code or API key",
|
||||||
|
agent_id, client_ip
|
||||||
|
);
|
||||||
|
|
||||||
// Log failed connection attempt to database
|
// Log failed connection attempt to database
|
||||||
if let Some(ref db) = state.db {
|
if let Some(ref db) = state.db {
|
||||||
@@ -84,7 +91,8 @@ pub async fn agent_ws_handler(
|
|||||||
"agent_id": agent_id
|
"agent_id": agent_id
|
||||||
})),
|
})),
|
||||||
Some(client_ip),
|
Some(client_ip),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
@@ -95,7 +103,10 @@ pub async fn agent_ws_handler(
|
|||||||
// Check if it's a valid, pending support code
|
// Check if it's a valid, pending support code
|
||||||
let code_info = state.support_codes.get_status(code).await;
|
let code_info = state.support_codes.get_status(code).await;
|
||||||
if code_info.is_none() {
|
if code_info.is_none() {
|
||||||
warn!("Agent connection rejected: {} from {} - invalid support code {}", agent_id, client_ip, code);
|
warn!(
|
||||||
|
"Agent connection rejected: {} from {} - invalid support code {}",
|
||||||
|
agent_id, client_ip, code
|
||||||
|
);
|
||||||
|
|
||||||
// Log failed connection attempt
|
// Log failed connection attempt
|
||||||
if let Some(ref db) = state.db {
|
if let Some(ref db) = state.db {
|
||||||
@@ -111,14 +122,18 @@ pub async fn agent_ws_handler(
|
|||||||
"agent_id": agent_id
|
"agent_id": agent_id
|
||||||
})),
|
})),
|
||||||
Some(client_ip),
|
Some(client_ip),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
let status = code_info.unwrap();
|
let status = code_info.unwrap();
|
||||||
if status != "pending" && status != "connected" {
|
if status != "pending" && status != "connected" {
|
||||||
warn!("Agent connection rejected: {} from {} - support code {} has status {}", agent_id, client_ip, code, status);
|
warn!(
|
||||||
|
"Agent connection rejected: {} from {} - support code {} has status {}",
|
||||||
|
agent_id, client_ip, code, status
|
||||||
|
);
|
||||||
|
|
||||||
// Log failed connection attempt (expired/cancelled code)
|
// Log failed connection attempt (expired/cancelled code)
|
||||||
if let Some(ref db) = state.db {
|
if let Some(ref db) = state.db {
|
||||||
@@ -140,12 +155,16 @@ pub async fn agent_ws_handler(
|
|||||||
"agent_id": agent_id
|
"agent_id": agent_id
|
||||||
})),
|
})),
|
||||||
Some(client_ip),
|
Some(client_ip),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
info!("Agent {} from {} authenticated via support code {}", agent_id, client_ip, code);
|
info!(
|
||||||
|
"Agent {} from {} authenticated via support code {}",
|
||||||
|
agent_id, client_ip, code
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate API key if provided (for persistent agents)
|
// Validate API key if provided (for persistent agents)
|
||||||
@@ -153,7 +172,10 @@ pub async fn agent_ws_handler(
|
|||||||
// For now, we'll accept API keys that match the JWT secret or a configured agent key
|
// For now, we'll accept API keys that match the JWT secret or a configured agent key
|
||||||
// In production, this should validate against a database of registered agents
|
// In production, this should validate against a database of registered agents
|
||||||
if !validate_agent_api_key(&state, key).await {
|
if !validate_agent_api_key(&state, key).await {
|
||||||
warn!("Agent connection rejected: {} from {} - invalid API key", agent_id, client_ip);
|
warn!(
|
||||||
|
"Agent connection rejected: {} from {} - invalid API key",
|
||||||
|
agent_id, client_ip
|
||||||
|
);
|
||||||
|
|
||||||
// Log failed connection attempt
|
// Log failed connection attempt
|
||||||
if let Some(ref db) = state.db {
|
if let Some(ref db) = state.db {
|
||||||
@@ -168,19 +190,34 @@ pub async fn agent_ws_handler(
|
|||||||
"agent_id": agent_id
|
"agent_id": agent_id
|
||||||
})),
|
})),
|
||||||
Some(client_ip),
|
Some(client_ip),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
info!("Agent {} from {} authenticated via API key", agent_id, client_ip);
|
info!(
|
||||||
|
"Agent {} from {} authenticated via API key",
|
||||||
|
agent_id, client_ip
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let sessions = state.sessions.clone();
|
let sessions = state.sessions.clone();
|
||||||
let support_codes = state.support_codes.clone();
|
let support_codes = state.support_codes.clone();
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
|
|
||||||
Ok(ws.on_upgrade(move |socket| handle_agent_connection(socket, sessions, support_codes, db, agent_id, agent_name, support_code, Some(client_ip))))
|
Ok(ws.on_upgrade(move |socket| {
|
||||||
|
handle_agent_connection(
|
||||||
|
socket,
|
||||||
|
sessions,
|
||||||
|
support_codes,
|
||||||
|
db,
|
||||||
|
agent_id,
|
||||||
|
agent_name,
|
||||||
|
support_code,
|
||||||
|
Some(client_ip),
|
||||||
|
)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate an agent API key
|
/// Validate an agent API key
|
||||||
@@ -212,24 +249,42 @@ pub async fn viewer_ws_handler(
|
|||||||
|
|
||||||
// Require JWT token for viewers
|
// Require JWT token for viewers
|
||||||
let token = params.token.ok_or_else(|| {
|
let token = params.token.ok_or_else(|| {
|
||||||
warn!("Viewer connection rejected from {}: missing token", client_ip);
|
warn!(
|
||||||
|
"Viewer connection rejected from {}: missing token",
|
||||||
|
client_ip
|
||||||
|
);
|
||||||
StatusCode::UNAUTHORIZED
|
StatusCode::UNAUTHORIZED
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Validate the token
|
// Validate the token
|
||||||
let claims = state.jwt_config.validate_token(&token).map_err(|e| {
|
let claims = state.jwt_config.validate_token(&token).map_err(|e| {
|
||||||
warn!("Viewer connection rejected from {}: invalid token: {}", client_ip, e);
|
warn!(
|
||||||
|
"Viewer connection rejected from {}: invalid token: {}",
|
||||||
|
client_ip, e
|
||||||
|
);
|
||||||
StatusCode::UNAUTHORIZED
|
StatusCode::UNAUTHORIZED
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
info!("Viewer {} authenticated via JWT from {}", claims.username, client_ip);
|
info!(
|
||||||
|
"Viewer {} authenticated via JWT from {}",
|
||||||
|
claims.username, client_ip
|
||||||
|
);
|
||||||
|
|
||||||
let session_id = params.session_id;
|
let session_id = params.session_id;
|
||||||
let viewer_name = params.viewer_name;
|
let viewer_name = params.viewer_name;
|
||||||
let sessions = state.sessions.clone();
|
let sessions = state.sessions.clone();
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
|
|
||||||
Ok(ws.on_upgrade(move |socket| handle_viewer_connection(socket, sessions, db, session_id, viewer_name, Some(client_ip))))
|
Ok(ws.on_upgrade(move |socket| {
|
||||||
|
handle_viewer_connection(
|
||||||
|
socket,
|
||||||
|
sessions,
|
||||||
|
db,
|
||||||
|
session_id,
|
||||||
|
viewer_name,
|
||||||
|
Some(client_ip),
|
||||||
|
)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle an agent WebSocket connection
|
/// Handle an agent WebSocket connection
|
||||||
@@ -243,7 +298,10 @@ async fn handle_agent_connection(
|
|||||||
support_code: Option<String>,
|
support_code: Option<String>,
|
||||||
client_ip: Option<std::net::IpAddr>,
|
client_ip: Option<std::net::IpAddr>,
|
||||||
) {
|
) {
|
||||||
info!("Agent connected: {} ({}) from {:?}", agent_name, agent_id, client_ip);
|
info!(
|
||||||
|
"Agent connected: {} ({}) from {:?}",
|
||||||
|
agent_name, agent_id, client_ip
|
||||||
|
);
|
||||||
|
|
||||||
let (mut ws_sender, mut ws_receiver) = socket.split();
|
let (mut ws_sender, mut ws_receiver) = socket.split();
|
||||||
|
|
||||||
@@ -270,7 +328,9 @@ async fn handle_agent_connection(
|
|||||||
// Register the agent and get channels
|
// Register the agent and get channels
|
||||||
// Persistent agents (no support code) keep their session when disconnected
|
// Persistent agents (no support code) keep their session when disconnected
|
||||||
let is_persistent = support_code.is_none();
|
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).await;
|
let (session_id, frame_tx, mut input_rx) = sessions
|
||||||
|
.register_agent(agent_id.clone(), agent_name.clone(), is_persistent)
|
||||||
|
.await;
|
||||||
|
|
||||||
info!("Session created: {} (agent in idle mode)", session_id);
|
info!("Session created: {} (agent in idle mode)", session_id);
|
||||||
|
|
||||||
@@ -285,15 +345,20 @@ async fn handle_agent_connection(
|
|||||||
machine.id,
|
machine.id,
|
||||||
support_code.is_some(),
|
support_code.is_some(),
|
||||||
support_code.as_deref(),
|
support_code.as_deref(),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Log session started event
|
// Log session started event
|
||||||
let _ = db::events::log_event(
|
let _ = db::events::log_event(
|
||||||
db.pool(),
|
db.pool(),
|
||||||
session_id,
|
session_id,
|
||||||
db::events::EventTypes::SESSION_STARTED,
|
db::events::EventTypes::SESSION_STARTED,
|
||||||
None, None, None, client_ip,
|
None,
|
||||||
).await;
|
None,
|
||||||
|
None,
|
||||||
|
client_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Some(machine.id)
|
Some(machine.id)
|
||||||
}
|
}
|
||||||
@@ -309,7 +374,9 @@ async fn handle_agent_connection(
|
|||||||
// If a support code was provided, mark it as connected
|
// If a support code was provided, mark it as connected
|
||||||
if let Some(ref code) = support_code {
|
if let Some(ref code) = support_code {
|
||||||
info!("Linking support code {} to session {}", code, session_id);
|
info!("Linking support code {} to session {}", code, session_id);
|
||||||
support_codes.mark_connected(code, Some(agent_name.clone()), Some(agent_id.clone())).await;
|
support_codes
|
||||||
|
.mark_connected(code, Some(agent_name.clone()), Some(agent_id.clone()))
|
||||||
|
.await;
|
||||||
support_codes.link_session(code, session_id).await;
|
support_codes.link_session(code, session_id).await;
|
||||||
|
|
||||||
// Database: update support code
|
// Database: update support code
|
||||||
@@ -320,7 +387,8 @@ async fn handle_agent_connection(
|
|||||||
Some(session_id),
|
Some(session_id),
|
||||||
Some(&agent_name),
|
Some(&agent_name),
|
||||||
Some(&agent_id),
|
Some(&agent_id),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +401,11 @@ async fn handle_agent_connection(
|
|||||||
let input_forward = tokio::spawn(async move {
|
let input_forward = tokio::spawn(async move {
|
||||||
while let Some(input_data) = input_rx.recv().await {
|
while let Some(input_data) = input_rx.recv().await {
|
||||||
let mut sender = ws_sender_input.lock().await;
|
let mut sender = ws_sender_input.lock().await;
|
||||||
if sender.send(Message::Binary(input_data.into())).await.is_err() {
|
if sender
|
||||||
|
.send(Message::Binary(input_data.into()))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,22 +478,29 @@ async fn handle_agent_connection(
|
|||||||
} else {
|
} else {
|
||||||
Some(status.site.clone())
|
Some(status.site.clone())
|
||||||
};
|
};
|
||||||
sessions_status.update_agent_status(
|
sessions_status
|
||||||
session_id,
|
.update_agent_status(
|
||||||
Some(status.os_version.clone()),
|
session_id,
|
||||||
status.is_elevated,
|
Some(status.os_version.clone()),
|
||||||
status.uptime_secs,
|
status.is_elevated,
|
||||||
status.display_count,
|
status.uptime_secs,
|
||||||
status.is_streaming,
|
status.display_count,
|
||||||
agent_version.clone(),
|
status.is_streaming,
|
||||||
organization.clone(),
|
agent_version.clone(),
|
||||||
site.clone(),
|
organization.clone(),
|
||||||
status.tags.clone(),
|
site.clone(),
|
||||||
).await;
|
status.tags.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Update version in database if present
|
// Update version in database if present
|
||||||
if let (Some(ref db), Some(ref version)) = (&db, &agent_version) {
|
if let (Some(ref db), Some(ref version)) = (&db, &agent_version) {
|
||||||
let _ = crate::db::releases::update_machine_version(db.pool(), &agent_id, version).await;
|
let _ = crate::db::releases::update_machine_version(
|
||||||
|
db.pool(),
|
||||||
|
&agent_id,
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update organization/site/tags in database if present
|
// Update organization/site/tags in database if present
|
||||||
@@ -432,7 +511,8 @@ async fn handle_agent_connection(
|
|||||||
organization.as_deref(),
|
organization.as_deref(),
|
||||||
site.as_deref(),
|
site.as_deref(),
|
||||||
&status.tags,
|
&status.tags,
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Agent status update: {} - streaming={}, uptime={}s, version={:?}, org={:?}, site={:?}",
|
info!("Agent status update: {} - streaming={}, uptime={}s, version={:?}, org={:?}, site={:?}",
|
||||||
@@ -489,8 +569,12 @@ async fn handle_agent_connection(
|
|||||||
db.pool(),
|
db.pool(),
|
||||||
session_id,
|
session_id,
|
||||||
db::events::EventTypes::SESSION_ENDED,
|
db::events::EventTypes::SESSION_ENDED,
|
||||||
None, None, None, client_ip,
|
None,
|
||||||
).await;
|
None,
|
||||||
|
None,
|
||||||
|
client_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark support code as completed if one was used (unless cancelled)
|
// Mark support code as completed if one was used (unless cancelled)
|
||||||
@@ -532,7 +616,10 @@ async fn handle_viewer_connection(
|
|||||||
let viewer_id = Uuid::new_v4().to_string();
|
let viewer_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
// Join the session (this sends StartStream to agent if first viewer)
|
// Join the session (this sends StartStream to agent if first viewer)
|
||||||
let (mut frame_rx, input_tx) = match sessions.join_session(session_id, viewer_id.clone(), viewer_name.clone()).await {
|
let (mut frame_rx, input_tx) = match sessions
|
||||||
|
.join_session(session_id, viewer_id.clone(), viewer_name.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Some(channels) => channels,
|
Some(channels) => channels,
|
||||||
None => {
|
None => {
|
||||||
warn!("Session not found: {}", session_id);
|
warn!("Session not found: {}", session_id);
|
||||||
@@ -540,7 +627,10 @@ async fn handle_viewer_connection(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("Viewer {} ({}) joined session: {} from {:?}", viewer_name, viewer_id, session_id, client_ip);
|
info!(
|
||||||
|
"Viewer {} ({}) joined session: {} from {:?}",
|
||||||
|
viewer_name, viewer_id, session_id, client_ip
|
||||||
|
);
|
||||||
|
|
||||||
// Database: log viewer joined event
|
// Database: log viewer joined event
|
||||||
if let Some(ref db) = db {
|
if let Some(ref db) = db {
|
||||||
@@ -550,8 +640,10 @@ async fn handle_viewer_connection(
|
|||||||
db::events::EventTypes::VIEWER_JOINED,
|
db::events::EventTypes::VIEWER_JOINED,
|
||||||
Some(&viewer_id),
|
Some(&viewer_id),
|
||||||
Some(&viewer_name),
|
Some(&viewer_name),
|
||||||
None, client_ip,
|
None,
|
||||||
).await;
|
client_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (mut ws_sender, mut ws_receiver) = socket.split();
|
let (mut ws_sender, mut ws_receiver) = socket.split();
|
||||||
@@ -559,7 +651,11 @@ async fn handle_viewer_connection(
|
|||||||
// Task to forward frames from agent to this viewer
|
// Task to forward frames from agent to this viewer
|
||||||
let frame_forward = tokio::spawn(async move {
|
let frame_forward = tokio::spawn(async move {
|
||||||
while let Ok(frame_data) = frame_rx.recv().await {
|
while let Ok(frame_data) = frame_rx.recv().await {
|
||||||
if ws_sender.send(Message::Binary(frame_data.into())).await.is_err() {
|
if ws_sender
|
||||||
|
.send(Message::Binary(frame_data.into()))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -577,9 +673,9 @@ async fn handle_viewer_connection(
|
|||||||
match proto::Message::decode(data.as_ref()) {
|
match proto::Message::decode(data.as_ref()) {
|
||||||
Ok(proto_msg) => {
|
Ok(proto_msg) => {
|
||||||
match &proto_msg.payload {
|
match &proto_msg.payload {
|
||||||
Some(proto::message::Payload::MouseEvent(_)) |
|
Some(proto::message::Payload::MouseEvent(_))
|
||||||
Some(proto::message::Payload::KeyEvent(_)) |
|
| Some(proto::message::Payload::KeyEvent(_))
|
||||||
Some(proto::message::Payload::SpecialKey(_)) => {
|
| Some(proto::message::Payload::SpecialKey(_)) => {
|
||||||
// Forward input to agent
|
// Forward input to agent
|
||||||
let _ = input_tx.send(data.to_vec()).await;
|
let _ = input_tx.send(data.to_vec()).await;
|
||||||
}
|
}
|
||||||
@@ -597,7 +693,10 @@ async fn handle_viewer_connection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Message::Close(_)) => {
|
Ok(Message::Close(_)) => {
|
||||||
info!("Viewer {} disconnected from session: {}", viewer_id, session_id);
|
info!(
|
||||||
|
"Viewer {} disconnected from session: {}",
|
||||||
|
viewer_id, session_id
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
@@ -610,7 +709,9 @@ async fn handle_viewer_connection(
|
|||||||
|
|
||||||
// Cleanup (this sends StopStream to agent if last viewer)
|
// Cleanup (this sends StopStream to agent if last viewer)
|
||||||
frame_forward.abort();
|
frame_forward.abort();
|
||||||
sessions_cleanup.leave_session(session_id, &viewer_id_cleanup).await;
|
sessions_cleanup
|
||||||
|
.leave_session(session_id, &viewer_id_cleanup)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Database: log viewer left event
|
// Database: log viewer left event
|
||||||
if let Some(ref db) = db {
|
if let Some(ref db) = db {
|
||||||
@@ -620,8 +721,10 @@ async fn handle_viewer_connection(
|
|||||||
db::events::EventTypes::VIEWER_LEFT,
|
db::events::EventTypes::VIEWER_LEFT,
|
||||||
Some(&viewer_id_cleanup),
|
Some(&viewer_id_cleanup),
|
||||||
Some(&viewer_name_cleanup),
|
Some(&viewer_name_cleanup),
|
||||||
None, client_ip,
|
None,
|
||||||
).await;
|
client_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Viewer {} left session: {}", viewer_id_cleanup, session_id);
|
info!("Viewer {} left session: {}", viewer_id_cleanup, session_id);
|
||||||
|
|||||||
@@ -37,20 +37,20 @@ pub struct Session {
|
|||||||
pub agent_name: String,
|
pub agent_name: String,
|
||||||
pub started_at: chrono::DateTime<chrono::Utc>,
|
pub started_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub viewer_count: usize,
|
pub viewer_count: usize,
|
||||||
pub viewers: Vec<ViewerInfo>, // List of connected technicians
|
pub viewers: Vec<ViewerInfo>, // List of connected technicians
|
||||||
pub is_streaming: bool,
|
pub is_streaming: bool,
|
||||||
pub is_online: bool, // Whether agent is currently connected
|
pub is_online: bool, // Whether agent is currently connected
|
||||||
pub is_persistent: bool, // Persistent agent (no support code) vs support session
|
pub is_persistent: bool, // Persistent agent (no support code) vs support session
|
||||||
pub last_heartbeat: chrono::DateTime<chrono::Utc>,
|
pub last_heartbeat: chrono::DateTime<chrono::Utc>,
|
||||||
// Agent status info
|
// Agent status info
|
||||||
pub os_version: Option<String>,
|
pub os_version: Option<String>,
|
||||||
pub is_elevated: bool,
|
pub is_elevated: bool,
|
||||||
pub uptime_secs: i64,
|
pub uptime_secs: i64,
|
||||||
pub display_count: i32,
|
pub display_count: i32,
|
||||||
pub agent_version: Option<String>, // Agent software version
|
pub agent_version: Option<String>, // Agent software version
|
||||||
pub organization: Option<String>, // Company/organization name
|
pub organization: Option<String>, // Company/organization name
|
||||||
pub site: Option<String>, // Site/location name
|
pub site: Option<String>, // Site/location name
|
||||||
pub tags: Vec<String>, // Tags for categorization
|
pub tags: Vec<String>, // Tags for categorization
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Channel for sending frames from agent to viewers
|
/// Channel for sending frames from agent to viewers
|
||||||
@@ -92,7 +92,12 @@ impl SessionManager {
|
|||||||
|
|
||||||
/// Register a new agent and create a session
|
/// Register a new agent and create a session
|
||||||
/// If agent was previously connected (offline session exists), reuse that session
|
/// If agent was previously connected (offline session exists), reuse that session
|
||||||
pub async fn register_agent(&self, agent_id: AgentId, agent_name: String, is_persistent: bool) -> (SessionId, FrameSender, InputReceiver) {
|
pub async fn register_agent(
|
||||||
|
&self,
|
||||||
|
agent_id: AgentId,
|
||||||
|
agent_name: String,
|
||||||
|
is_persistent: bool,
|
||||||
|
) -> (SessionId, FrameSender, InputReceiver) {
|
||||||
// Check if this agent already has an offline session (reconnecting)
|
// Check if this agent already has an offline session (reconnecting)
|
||||||
{
|
{
|
||||||
let agents = self.agents.read().await;
|
let agents = self.agents.read().await;
|
||||||
@@ -101,7 +106,11 @@ impl SessionManager {
|
|||||||
if let Some(session_data) = sessions.get_mut(&existing_session_id) {
|
if let Some(session_data) = sessions.get_mut(&existing_session_id) {
|
||||||
if !session_data.info.is_online {
|
if !session_data.info.is_online {
|
||||||
// Reuse existing session - mark as online and create new channels
|
// Reuse existing session - mark as online and create new channels
|
||||||
tracing::info!("Agent {} reconnecting to existing session {}", agent_id, existing_session_id);
|
tracing::info!(
|
||||||
|
"Agent {} reconnecting to existing session {}",
|
||||||
|
agent_id,
|
||||||
|
existing_session_id
|
||||||
|
);
|
||||||
|
|
||||||
let (frame_tx, _) = broadcast::channel(16);
|
let (frame_tx, _) = broadcast::channel(16);
|
||||||
let (input_tx, input_rx) = tokio::sync::mpsc::channel(64);
|
let (input_tx, input_rx) = tokio::sync::mpsc::channel(64);
|
||||||
@@ -230,7 +239,9 @@ impl SessionManager {
|
|||||||
let sessions = self.sessions.read().await;
|
let sessions = self.sessions.read().await;
|
||||||
sessions
|
sessions
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, data)| data.last_heartbeat_instant.elapsed().as_secs() > HEARTBEAT_TIMEOUT_SECS)
|
.filter(|(_, data)| {
|
||||||
|
data.last_heartbeat_instant.elapsed().as_secs() > HEARTBEAT_TIMEOUT_SECS
|
||||||
|
})
|
||||||
.map(|(id, _)| *id)
|
.map(|(id, _)| *id)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -251,7 +262,12 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Join a session as a viewer, returns channels and sends StartStream to agent
|
/// Join a session as a viewer, returns channels and sends StartStream to agent
|
||||||
pub async fn join_session(&self, session_id: SessionId, viewer_id: ViewerId, viewer_name: String) -> Option<(FrameReceiver, InputSender)> {
|
pub async fn join_session(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
viewer_id: ViewerId,
|
||||||
|
viewer_name: String,
|
||||||
|
) -> Option<(FrameReceiver, InputSender)> {
|
||||||
let mut sessions = self.sessions.write().await;
|
let mut sessions = self.sessions.write().await;
|
||||||
let session_data = sessions.get_mut(&session_id)?;
|
let session_data = sessions.get_mut(&session_id)?;
|
||||||
|
|
||||||
@@ -274,10 +290,20 @@ impl SessionManager {
|
|||||||
|
|
||||||
// If this is the first viewer, send StartStream to agent
|
// If this is the first viewer, send StartStream to agent
|
||||||
if was_empty {
|
if was_empty {
|
||||||
tracing::info!("Viewer {} ({}) joined session {}, sending StartStream", viewer_name, viewer_id, session_id);
|
tracing::info!(
|
||||||
|
"Viewer {} ({}) joined session {}, sending StartStream",
|
||||||
|
viewer_name,
|
||||||
|
viewer_id,
|
||||||
|
session_id
|
||||||
|
);
|
||||||
Self::send_start_stream_internal(session_data, &viewer_id).await;
|
Self::send_start_stream_internal(session_data, &viewer_id).await;
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("Viewer {} ({}) joined session {}", viewer_name, viewer_id, session_id);
|
tracing::info!(
|
||||||
|
"Viewer {} ({}) joined session {}",
|
||||||
|
viewer_name,
|
||||||
|
viewer_id,
|
||||||
|
session_id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Some((frame_rx, input_tx))
|
Some((frame_rx, input_tx))
|
||||||
@@ -312,12 +338,20 @@ impl SessionManager {
|
|||||||
|
|
||||||
// If no more viewers, send StopStream to agent
|
// If no more viewers, send StopStream to agent
|
||||||
if session_data.viewers.is_empty() {
|
if session_data.viewers.is_empty() {
|
||||||
tracing::info!("Last viewer {} ({}) left session {}, sending StopStream",
|
tracing::info!(
|
||||||
viewer_name.as_deref().unwrap_or("unknown"), viewer_id, session_id);
|
"Last viewer {} ({}) left session {}, sending StopStream",
|
||||||
|
viewer_name.as_deref().unwrap_or("unknown"),
|
||||||
|
viewer_id,
|
||||||
|
session_id
|
||||||
|
);
|
||||||
Self::send_stop_stream_internal(session_data, viewer_id).await;
|
Self::send_stop_stream_internal(session_data, viewer_id).await;
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("Viewer {} ({}) left session {}",
|
tracing::info!(
|
||||||
viewer_name.as_deref().unwrap_or("unknown"), viewer_id, session_id);
|
"Viewer {} ({}) left session {}",
|
||||||
|
viewer_name.as_deref().unwrap_or("unknown"),
|
||||||
|
viewer_id,
|
||||||
|
session_id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,8 +381,11 @@ impl SessionManager {
|
|||||||
if let Some(session_data) = sessions.get_mut(&session_id) {
|
if let Some(session_data) = sessions.get_mut(&session_id) {
|
||||||
if session_data.info.is_persistent {
|
if session_data.info.is_persistent {
|
||||||
// Persistent agent - keep session but mark as offline
|
// Persistent agent - keep session but mark as offline
|
||||||
tracing::info!("Persistent agent {} marked offline (session {} preserved)",
|
tracing::info!(
|
||||||
session_data.info.agent_id, session_id);
|
"Persistent agent {} marked offline (session {} preserved)",
|
||||||
|
session_data.info.agent_id,
|
||||||
|
session_id
|
||||||
|
);
|
||||||
session_data.info.is_online = false;
|
session_data.info.is_online = false;
|
||||||
session_data.info.is_streaming = false;
|
session_data.info.is_streaming = false;
|
||||||
session_data.info.viewer_count = 0;
|
session_data.info.viewer_count = 0;
|
||||||
@@ -410,7 +447,12 @@ impl SessionManager {
|
|||||||
|
|
||||||
/// Send an admin command to an agent (uninstall, restart, etc.)
|
/// Send an admin command to an agent (uninstall, restart, etc.)
|
||||||
/// Returns true if the message was sent successfully
|
/// Returns true if the message was sent successfully
|
||||||
pub async fn send_admin_command(&self, session_id: SessionId, command: crate::proto::AdminCommandType, reason: &str) -> bool {
|
pub async fn send_admin_command(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
command: crate::proto::AdminCommandType,
|
||||||
|
reason: &str,
|
||||||
|
) -> bool {
|
||||||
let sessions = self.sessions.read().await;
|
let sessions = self.sessions.read().await;
|
||||||
if let Some(session_data) = sessions.get(&session_id) {
|
if let Some(session_data) = sessions.get(&session_id) {
|
||||||
if !session_data.info.is_online {
|
if !session_data.info.is_online {
|
||||||
@@ -471,7 +513,7 @@ impl SessionManager {
|
|||||||
viewer_count: 0,
|
viewer_count: 0,
|
||||||
viewers: Vec::new(),
|
viewers: Vec::new(),
|
||||||
is_streaming: false,
|
is_streaming: false,
|
||||||
is_online: false, // Offline until agent reconnects
|
is_online: false, // Offline until agent reconnects
|
||||||
is_persistent: true,
|
is_persistent: true,
|
||||||
last_heartbeat: now,
|
last_heartbeat: now,
|
||||||
os_version: None,
|
os_version: None,
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
//! Handles generation and validation of 6-digit support codes
|
//! Handles generation and validation of 6-digit support codes
|
||||||
//! for one-time remote support sessions.
|
//! for one-time remote support sessions.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// A support session code
|
/// A support session code
|
||||||
@@ -27,10 +27,10 @@ pub struct SupportCode {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum CodeStatus {
|
pub enum CodeStatus {
|
||||||
Pending, // Waiting for client to connect
|
Pending, // Waiting for client to connect
|
||||||
Connected, // Client connected, session active
|
Connected, // Client connected, session active
|
||||||
Completed, // Session ended normally
|
Completed, // Session ended normally
|
||||||
Cancelled, // Code cancelled by tech
|
Cancelled, // Code cancelled by tech
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request to create a new support code
|
/// Request to create a new support code
|
||||||
@@ -69,11 +69,11 @@ impl SupportCodeManager {
|
|||||||
async fn generate_unique_code(&self) -> String {
|
async fn generate_unique_code(&self) -> String {
|
||||||
let codes = self.codes.read().await;
|
let codes = self.codes.read().await;
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let code: u32 = rng.gen_range(100000..999999);
|
let code: u32 = rng.gen_range(100000..999999);
|
||||||
let code_str = code.to_string();
|
let code_str = code.to_string();
|
||||||
|
|
||||||
if !codes.contains_key(&code_str) {
|
if !codes.contains_key(&code_str) {
|
||||||
return code_str;
|
return code_str;
|
||||||
}
|
}
|
||||||
@@ -84,11 +84,13 @@ impl SupportCodeManager {
|
|||||||
pub async fn create_code(&self, request: CreateCodeRequest) -> SupportCode {
|
pub async fn create_code(&self, request: CreateCodeRequest) -> SupportCode {
|
||||||
let code = self.generate_unique_code().await;
|
let code = self.generate_unique_code().await;
|
||||||
let session_id = Uuid::new_v4();
|
let session_id = Uuid::new_v4();
|
||||||
|
|
||||||
let support_code = SupportCode {
|
let support_code = SupportCode {
|
||||||
code: code.clone(),
|
code: code.clone(),
|
||||||
session_id,
|
session_id,
|
||||||
created_by: request.technician_name.unwrap_or_else(|| "Unknown".to_string()),
|
created_by: request
|
||||||
|
.technician_name
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string()),
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
status: CodeStatus::Pending,
|
status: CodeStatus::Pending,
|
||||||
client_name: None,
|
client_name: None,
|
||||||
@@ -108,10 +110,12 @@ impl SupportCodeManager {
|
|||||||
/// Validate a code and return session info
|
/// Validate a code and return session info
|
||||||
pub async fn validate_code(&self, code: &str) -> CodeValidation {
|
pub async fn validate_code(&self, code: &str) -> CodeValidation {
|
||||||
let codes = self.codes.read().await;
|
let codes = self.codes.read().await;
|
||||||
|
|
||||||
match codes.get(code) {
|
match codes.get(code) {
|
||||||
Some(support_code) => {
|
Some(support_code) => {
|
||||||
if support_code.status == CodeStatus::Pending || support_code.status == CodeStatus::Connected {
|
if support_code.status == CodeStatus::Pending
|
||||||
|
|| support_code.status == CodeStatus::Connected
|
||||||
|
{
|
||||||
CodeValidation {
|
CodeValidation {
|
||||||
valid: true,
|
valid: true,
|
||||||
session_id: Some(support_code.session_id.to_string()),
|
session_id: Some(support_code.session_id.to_string()),
|
||||||
@@ -137,7 +141,12 @@ impl SupportCodeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a code as connected
|
/// Mark a code as connected
|
||||||
pub async fn mark_connected(&self, code: &str, client_name: Option<String>, client_machine: Option<String>) {
|
pub async fn mark_connected(
|
||||||
|
&self,
|
||||||
|
code: &str,
|
||||||
|
client_name: Option<String>,
|
||||||
|
client_machine: Option<String>,
|
||||||
|
) {
|
||||||
let mut codes = self.codes.write().await;
|
let mut codes = self.codes.write().await;
|
||||||
if let Some(support_code) = codes.get_mut(code) {
|
if let Some(support_code) = codes.get_mut(code) {
|
||||||
support_code.status = CodeStatus::Connected;
|
support_code.status = CodeStatus::Connected;
|
||||||
@@ -180,7 +189,9 @@ impl SupportCodeManager {
|
|||||||
pub async fn cancel_code(&self, code: &str) -> bool {
|
pub async fn cancel_code(&self, code: &str) -> bool {
|
||||||
let mut codes = self.codes.write().await;
|
let mut codes = self.codes.write().await;
|
||||||
if let Some(support_code) = codes.get_mut(code) {
|
if let Some(support_code) = codes.get_mut(code) {
|
||||||
if support_code.status == CodeStatus::Pending || support_code.status == CodeStatus::Connected {
|
if support_code.status == CodeStatus::Pending
|
||||||
|
|| support_code.status == CodeStatus::Connected
|
||||||
|
{
|
||||||
support_code.status = CodeStatus::Cancelled;
|
support_code.status = CodeStatus::Cancelled;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -191,13 +202,19 @@ impl SupportCodeManager {
|
|||||||
/// Check if a code is cancelled
|
/// Check if a code is cancelled
|
||||||
pub async fn is_cancelled(&self, code: &str) -> bool {
|
pub async fn is_cancelled(&self, code: &str) -> bool {
|
||||||
let codes = self.codes.read().await;
|
let codes = self.codes.read().await;
|
||||||
codes.get(code).map(|c| c.status == CodeStatus::Cancelled).unwrap_or(false)
|
codes
|
||||||
|
.get(code)
|
||||||
|
.map(|c| c.status == CodeStatus::Cancelled)
|
||||||
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a code is valid for connection (exists and is pending)
|
/// Check if a code is valid for connection (exists and is pending)
|
||||||
pub async fn is_valid_for_connection(&self, code: &str) -> bool {
|
pub async fn is_valid_for_connection(&self, code: &str) -> bool {
|
||||||
let codes = self.codes.read().await;
|
let codes = self.codes.read().await;
|
||||||
codes.get(code).map(|c| c.status == CodeStatus::Pending).unwrap_or(false)
|
codes
|
||||||
|
.get(code)
|
||||||
|
.map(|c| c.status == CodeStatus::Pending)
|
||||||
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all codes (for dashboard)
|
/// List all codes (for dashboard)
|
||||||
@@ -209,7 +226,8 @@ impl SupportCodeManager {
|
|||||||
/// List active codes only
|
/// List active codes only
|
||||||
pub async fn list_active_codes(&self) -> Vec<SupportCode> {
|
pub async fn list_active_codes(&self) -> Vec<SupportCode> {
|
||||||
let codes = self.codes.read().await;
|
let codes = self.codes.read().await;
|
||||||
codes.values()
|
codes
|
||||||
|
.values()
|
||||||
.filter(|c| c.status == CodeStatus::Pending || c.status == CodeStatus::Connected)
|
.filter(|c| c.status == CodeStatus::Pending || c.status == CodeStatus::Connected)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -11,18 +11,29 @@ use anyhow::{anyhow, Result};
|
|||||||
pub fn validate_api_key_strength(api_key: &str) -> Result<()> {
|
pub fn validate_api_key_strength(api_key: &str) -> Result<()> {
|
||||||
// Minimum length check
|
// Minimum length check
|
||||||
if api_key.len() < 32 {
|
if api_key.len() < 32 {
|
||||||
return Err(anyhow!("API key must be at least 32 characters long for security"));
|
return Err(anyhow!(
|
||||||
|
"API key must be at least 32 characters long for security"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for common weak keys
|
// Check for common weak keys
|
||||||
let weak_keys = [
|
let weak_keys = [
|
||||||
"password", "12345", "admin", "test", "api_key",
|
"password",
|
||||||
"secret", "changeme", "default", "guruconnect"
|
"12345",
|
||||||
|
"admin",
|
||||||
|
"test",
|
||||||
|
"api_key",
|
||||||
|
"secret",
|
||||||
|
"changeme",
|
||||||
|
"default",
|
||||||
|
"guruconnect",
|
||||||
];
|
];
|
||||||
let lowercase_key = api_key.to_lowercase();
|
let lowercase_key = api_key.to_lowercase();
|
||||||
for weak in &weak_keys {
|
for weak in &weak_keys {
|
||||||
if lowercase_key.contains(weak) {
|
if lowercase_key.contains(weak) {
|
||||||
return Err(anyhow!("API key contains weak/common patterns and is not secure"));
|
return Err(anyhow!(
|
||||||
|
"API key contains weak/common patterns and is not secure"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +64,9 @@ mod tests {
|
|||||||
assert!(validate_api_key_strength("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").is_err());
|
assert!(validate_api_key_strength("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").is_err());
|
||||||
|
|
||||||
// Good key
|
// Good key
|
||||||
assert!(validate_api_key_strength("KfPrjjC3J6YMx9q1yjPxZAYkHLM2JdFy1XRxHJ9oPnw0NU3xH074ufHk7fj").is_ok());
|
assert!(validate_api_key_strength(
|
||||||
|
"KfPrjjC3J6YMx9q1yjPxZAYkHLM2JdFy1XRxHJ9oPnw0NU3xH074ufHk7fj"
|
||||||
|
)
|
||||||
|
.is_ok());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user