From 60519be28aa7d83ddcb16894b8f0869862ae385c Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Fri, 29 May 2026 07:19:29 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20operational=20tooling=20=E2=80=94=20sig?= =?UTF-8?q?ning,=20versioning,=20changelog,=20roadmap=20(SPEC-001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish GuruConnect's release engineering and project tracking (SPEC-001): - docs/ scaffold: FEATURE_ROADMAP, ARCHITECTURE_DECISIONS (ADR-001 standalone+contract, ADR-002 Gitea Actions + Azure Trusted Signing), docs/specs/SPEC-001, CHANGELOG. - .gitea/workflows/release.yml: conventional-commit auto-versioning, git-cliff changelog, Windows agent build, Azure Trusted Signing via jsign (reusing the shared ACG cert profile), Gitea release via REST API. build-and-test.yml is the PR/push gate; deploy.yml de-duplicated. - server: GET /api/changelog/:component/:version (latest + by-version), path-traversal hardened. - cliff.toml; server/.env.example documents CHANGELOG_DIR. Reviewed (Code Review Agent): axum route-conflict blocker fixed; CHANGELOG ordering, toolchain target, breaking-change parsing, empty-changelog fallback addressed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/build-and-test.yml | 10 +- .gitea/workflows/deploy.yml | 48 +- .gitea/workflows/release.yml | 484 ++++++++++++++++++ CHANGELOG.md | 16 + changelogs/.gitkeep | 8 + cliff.toml | 84 +++ docs/ARCHITECTURE_DECISIONS.md | 81 +++ docs/FEATURE_ROADMAP.md | 60 +++ .../SPEC-001-operational-tooling-parity.md | 117 +++++ server/.env.example | 6 + server/src/api/changelog.rs | 125 +++++ server/src/api/mod.rs | 1 + server/src/main.rs | 5 + 13 files changed, 1014 insertions(+), 31 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 changelogs/.gitkeep create mode 100644 cliff.toml create mode 100644 docs/ARCHITECTURE_DECISIONS.md create mode 100644 docs/FEATURE_ROADMAP.md create mode 100644 docs/specs/SPEC-001-operational-tooling-parity.md create mode 100644 server/src/api/changelog.rs diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml index b9e7b75..21123bc 100644 --- a/.gitea/workflows/build-and-test.yml +++ b/.gitea/workflows/build-and-test.yml @@ -1,5 +1,10 @@ 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: push: branches: @@ -77,7 +82,8 @@ jobs: uses: actions-rs/toolchain@v1 with: toolchain: stable - target: x86_64-pc-windows-msvc + # Single source of truth for the Windows target used by the build below. + target: x86_64-pc-windows-gnu override: true - name: Install cross-compilation tools @@ -100,7 +106,7 @@ jobs: - name: Build agent (cross-compile for Windows) run: | - rustup target add x86_64-pc-windows-gnu + # Target is installed by the toolchain step above (single source of truth). cd agent cargo build --release --target x86_64-pc-windows-gnu diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e9b5133..21deedf 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,5 +1,12 @@ 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: push: tags: @@ -30,6 +37,11 @@ jobs: toolchain: stable 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 run: | cd server @@ -37,12 +49,17 @@ jobs: - name: Create deployment package run: | + set -euo pipefail mkdir -p deploy cp server/target/x86_64-unknown-linux-gnu/release/guruconnect-server deploy/ cp -r server/static 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 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 uses: actions/upload-artifact@v3 @@ -54,35 +71,8 @@ jobs: - name: Deploy to server (production) if: github.event.inputs.environment == 'production' || startsWith(github.ref, 'refs/tags/') run: | - echo "Deployment command would run here" - echo "SSH to 172.16.3.30 and deploy" + echo "[INFO] Deployment command would run here" + echo "[INFO] SSH to 172.16.3.30 and deploy" # Actual deployment would use SSH keys and run: # 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' - - 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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..0a29125 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,484 @@ +name: Release + +# SPEC-001 §2/§3/§4 — auto-versioning, signed Windows build, changelog generation, release. +# +# On every push to main 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 — cross-compile the Windows agent (x86_64-pc-windows-gnu) to guruconnect.exe. +# 4. sign — 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]`. +# +# All jobs run on ubuntu-latest. GuruConnect ships a single .exe (no WiX/MSI); jsign is a Java +# tool that signs PE binaries on Linux, so no Windows runner is required. + +on: + push: + branches: + - main + 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 + # `!:` or `(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 + SIGN + PUBLISH + # --------------------------------------------------------------------------- + build-sign-publish: + name: Build, Sign, Publish Agent + runs-on: ubuntu-latest + needs: version + if: needs.version.outputs.released == 'true' + 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: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + # Single source of truth for the Windows target used by the build below. + target: x86_64-pc-windows-gnu + override: true + + - name: Install cross-compilation tools + run: | + sudo apt-get update + sudo apt-get install -y mingw-w64 + + - 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-agent-release-${{ hashFiles('agent/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-agent-release- + ${{ runner.os }}-cargo-agent- + + - name: Build agent (cross-compile for Windows) + run: | + set -euo pipefail + # Target is installed by the toolchain step above (single source of truth). + cd agent + cargo build --release --target x86_64-pc-windows-gnu + echo "[OK] Built agent for x86_64-pc-windows-gnu" + + - name: Stage unsigned binary + run: | + set -euo pipefail + cp agent/target/x86_64-pc-windows-gnu/release/guruconnect.exe ./guruconnect.exe + 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_VERSION="6.0" + 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] Signing command completed" + + - name: Verify signature present (fail release if unsigned) + run: | + set -euo pipefail + echo "[INFO] Verifying Authenticode signature is present" + # jsign's --info on a signed PE lists the signature(s); fail if none reported. + OUT="$(java -jar /tmp/jsign.jar --info guruconnect.exe 2>&1 || true)" + echo "$OUT" + if echo "$OUT" | grep -qiE 'signature|signer|signed'; then + echo "[OK] Signature present" + else + echo "[ERROR] No signature detected on guruconnect.exe - refusing to publish unsigned binary" + exit 1 + fi + + - 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" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2c5c772 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# 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//v.md` and served at `/api/changelog/...`. + +## [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. diff --git a/changelogs/.gitkeep b/changelogs/.gitkeep new file mode 100644 index 0000000..1382c37 --- /dev/null +++ b/changelogs/.gitkeep @@ -0,0 +1,8 @@ +# Generated changelog directory. +# +# Populated by .gitea/workflows/release.yml on each release: +# changelogs//v.md per-component, per-version notes +# changelogs/LATEST_.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). diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..09adf5a --- /dev/null +++ b/cliff.toml @@ -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//v.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" diff --git a/docs/ARCHITECTURE_DECISIONS.md b/docs/ARCHITECTURE_DECISIONS.md new file mode 100644 index 0000000..819c9ac --- /dev/null +++ b/docs/ARCHITECTURE_DECISIONS.md @@ -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`. diff --git a/docs/FEATURE_ROADMAP.md b/docs/FEATURE_ROADMAP.md new file mode 100644 index 0000000..19cfbc8 --- /dev/null +++ b/docs/FEATURE_ROADMAP.md @@ -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-.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 diff --git a/docs/specs/SPEC-001-operational-tooling-parity.md b/docs/specs/SPEC-001-operational-tooling-parity.md new file mode 100644 index 0000000..fec5363 --- /dev/null +++ b/docs/specs/SPEC-001-operational-tooling-parity.md @@ -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-.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//v.md`, plus `changelogs/LATEST_.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`. diff --git a/server/.env.example b/server/.env.example index 2bb1661..11394ae 100644 --- a/server/.env.example +++ b/server/.env.example @@ -22,6 +22,12 @@ LISTEN_ADDR=0.0.0.0:3002 # If set, persistent agents must provide this key to connect 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_.md` and `/v.md`). +# Defaults to ./changelogs, resolved relative to the server's working directory (CWD) when unset. +CHANGELOG_DIR=./changelogs + # Debug mode (enables verbose logging) DEBUG=false diff --git a/server/src/api/changelog.rs b/server/src/api/changelog.rs new file mode 100644 index 0000000..5a4a87f --- /dev/null +++ b/server/src/api/changelog.rs @@ -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_.md` (uppercase component) +/// - a semver string (with or without a leading `v`, e.g. `0.1.0` or `v0.1.0`) +/// → `changelogs//v.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") + } + } +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 16fa9e1..2deb5e3 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod auth_logout; +pub mod changelog; pub mod users; pub mod releases; pub mod downloads; diff --git a/server/src/main.rs b/server/src/main.rs index 2aedc3a..7316cbf 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -304,6 +304,11 @@ async fn main() -> Result<()> { .route("/api/releases/:version", put(api::releases::update_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) .route("/api/download/viewer", get(api::downloads::download_viewer)) .route("/api/download/support", get(api::downloads::download_support))