v0.2.9.3: device classification + ARM support — Pixel 3 lands
DEVICE-002: ARM machine-ID fallback — device-tree model + /etc/machine-id combo, then /proc/cpuinfo Serial (all-zero rejected), before the weak hostname fallback. Hardware-bound IDs on DMI-less devices. DEVICE-001: agent detects device_type (server/desktop/phone/tablet) from /sys signals — system battery (scope=Device peripherals excluded, UPS excluded), DRM connector state, framebuffer min-dimension for phone/tablet split. Reports device_type/device_model/os_distro in registration and system-info paths. SERVER-001: migration 061 — device_type, device_type_manual (operator override, never agent-written), device_model, os_distro on agents. effective_device_type computed into every serialized agent. SERVER-002: PUT /admin/agents/:id/device-type — set/clear override, enum-validated, journaled. WEB-001: device-type icons + fleet filter, device model in list, detail header badge with reclassify dropdown, os_distro surfaced. INSTALL-003: arm64 install path unblocked — helper (required manifest component) now cross-built aarch64-unknown-linux-musl via rust-lld in the server image, signed at boot (helperArches += arm64), listed in the release manifest. Install template already handled uname -m and pacman. Plus in-flight: desktop tray wiring, enrollment page polish, CI workflow updates, RAF session-broker/pacman-scanner docs, native installer scaffold.
This commit is contained in:
parent
b3d946b84f
commit
ff2f30f47a
58 changed files with 2989 additions and 429 deletions
|
|
@ -36,7 +36,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
|
||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
- name: cargo test
|
- name: cargo test
|
||||||
|
|
@ -78,7 +78,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: agent/go.mod
|
go-version-file: agent/go.mod
|
||||||
cache: true
|
cache: true
|
||||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
|
||||||
with:
|
with:
|
||||||
targets: ${{ matrix.rust_target }}
|
targets: ${{ matrix.rust_target }}
|
||||||
|
|
||||||
|
|
@ -157,7 +157,7 @@ jobs:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
|
||||||
|
|
||||||
# Provenance first: record the substrate even if a later scan fails the job.
|
# Provenance first: record the substrate even if a later scan fails the job.
|
||||||
# "Are we using hacked programs to build it?" — this is how we SEE the answer.
|
# "Are we using hacked programs to build it?" — this is how we SEE the answer.
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ jobs:
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
|
||||||
if: steps.pre.outputs.go == 'true'
|
if: steps.pre.outputs.go == 'true'
|
||||||
|
|
||||||
- name: Build web UI and stage embed
|
- name: Build web UI and stage embed
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ jobs:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
|
||||||
- name: Install scanners
|
- name: Install scanners
|
||||||
run: |
|
run: |
|
||||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
|
@ -265,7 +265,7 @@ jobs:
|
||||||
go-version-file: agent/go.mod
|
go-version-file: agent/go.mod
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
|
||||||
with:
|
with:
|
||||||
targets: ${{ matrix.rust_target }}
|
targets: ${{ matrix.rust_target }}
|
||||||
|
|
||||||
|
|
@ -303,7 +303,7 @@ jobs:
|
||||||
cd server && go build -ldflags "-s -w \
|
cd server && go build -ldflags "-s -w \
|
||||||
-X github.com/Fimeg/RedFlag/server/internal/version/versions.AgentVersion=$VERSION \
|
-X github.com/Fimeg/RedFlag/server/internal/version/versions.AgentVersion=$VERSION \
|
||||||
-X github.com/Fimeg/RedFlag/server/internal/version/versions.ConfigVersion=$VERSION" \
|
-X github.com/Fimeg/RedFlag/server/internal/version/versions.ConfigVersion=$VERSION" \
|
||||||
-o ../dist/redflag-server-${{ matrix.suffix }}${EXT} cmd/server/main.go
|
-o ../dist/redflag-server-${{ matrix.suffix }}${EXT} ./cmd/server/
|
||||||
|
|
||||||
- name: Build agent
|
- name: Build agent
|
||||||
env:
|
env:
|
||||||
|
|
@ -384,6 +384,39 @@ jobs:
|
||||||
# Cross-compiled binary can't run here for version check, but the gate
|
# Cross-compiled binary can't run here for version check, but the gate
|
||||||
# job validates the manifest hash before publish.
|
# job validates the manifest hash before publish.
|
||||||
|
|
||||||
|
- name: Build Windows installer (RedFlagSetup.msi)
|
||||||
|
if: matrix.goos == 'windows' && matrix.goarch == 'amd64'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/v}
|
||||||
|
# MSI's ProductVersion only carries 3 significant fields for
|
||||||
|
# upgrade detection — same 3-vs-4-part reconciliation bump-version.sh
|
||||||
|
# already does for desktop/Cargo.toml vs tauri.conf.json.
|
||||||
|
WIX_VERSION=$(echo "$VERSION" | cut -d. -f1-3)
|
||||||
|
|
||||||
|
# NOT the official WiX Toolset .NET CLI — `wix build` genuinely
|
||||||
|
# does not work when the compiler runs on Linux (reproduced: even
|
||||||
|
# a single-char Directory/@Name fails WIX0389 "not a relative
|
||||||
|
# path" on every WiX version 4.0.5 through 6.0.1; the tool's own
|
||||||
|
# output says "only supports Windows... undefined behavior"
|
||||||
|
# beyond that point). msitools' `wixl` is a from-scratch
|
||||||
|
# Linux-native reimplementation of the same MSI-building grammar,
|
||||||
|
# built for exactly this case — verified locally 2026-07-01
|
||||||
|
# (msiinfo confirms Directory/Component/ServiceInstall/Upgrade
|
||||||
|
# tables all populated correctly against a real cross-compiled
|
||||||
|
# server binary).
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq msitools
|
||||||
|
|
||||||
|
mkdir -p installer/windows/dist
|
||||||
|
cp dist/redflag-server-windows-amd64.exe installer/windows/dist/redflag-server-windows-amd64.exe
|
||||||
|
|
||||||
|
cd installer/windows
|
||||||
|
wixl Product.wxs \
|
||||||
|
-D RedFlagVersion="$WIX_VERSION" \
|
||||||
|
-o ../../dist/RedFlagSetup-${{ matrix.suffix }}.msi
|
||||||
|
echo "Installer built: $(ls -lh ../../dist/RedFlagSetup-${{ matrix.suffix }}.msi)"
|
||||||
|
|
||||||
# Did it make it in: linux-amd64 binaries self-report the tag version.
|
# Did it make it in: linux-amd64 binaries self-report the tag version.
|
||||||
# Cross-compiled binaries can't run here (wrong arch/OS), but the native
|
# Cross-compiled binaries can't run here (wrong arch/OS), but the native
|
||||||
# ones must match.
|
# ones must match.
|
||||||
|
|
@ -405,11 +438,16 @@ jobs:
|
||||||
cd dist
|
cd dist
|
||||||
ls -la
|
ls -la
|
||||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
# Windows: zip (no helper — it's Unix-only). Desktop binary is optional.
|
# Windows: zip (no helper — it's Unix-only). Desktop binary and
|
||||||
|
# the RedFlagSetup installer are both optional (only exist for
|
||||||
|
# windows-amd64 today, not e.g. windows-arm64).
|
||||||
ZIP_FILES="redflag-server-${{ matrix.suffix }}.exe redflag-agent-${{ matrix.suffix }}.exe"
|
ZIP_FILES="redflag-server-${{ matrix.suffix }}.exe redflag-agent-${{ matrix.suffix }}.exe"
|
||||||
if [ -f "redflag-desktop-${{ matrix.suffix }}.exe" ]; then
|
if [ -f "redflag-desktop-${{ matrix.suffix }}.exe" ]; then
|
||||||
ZIP_FILES="$ZIP_FILES redflag-desktop-${{ matrix.suffix }}.exe"
|
ZIP_FILES="$ZIP_FILES redflag-desktop-${{ matrix.suffix }}.exe"
|
||||||
fi
|
fi
|
||||||
|
if [ -f "RedFlagSetup-${{ matrix.suffix }}.msi" ]; then
|
||||||
|
ZIP_FILES="$ZIP_FILES RedFlagSetup-${{ matrix.suffix }}.msi"
|
||||||
|
fi
|
||||||
zip redflag-$VERSION-${{ matrix.suffix }}.zip $ZIP_FILES
|
zip redflag-$VERSION-${{ matrix.suffix }}.zip $ZIP_FILES
|
||||||
sha256sum redflag-$VERSION-${{ matrix.suffix }}.zip > checksums-$VERSION-${{ matrix.suffix }}.txt
|
sha256sum redflag-$VERSION-${{ matrix.suffix }}.zip > checksums-$VERSION-${{ matrix.suffix }}.txt
|
||||||
else
|
else
|
||||||
|
|
@ -446,6 +484,20 @@ jobs:
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# RedFlagSetup.msi doesn't fit the redflag-<bin>-<suffix> naming
|
||||||
|
# convention above (no redflag- prefix, .msi not .exe) — handled
|
||||||
|
# separately. Windows-amd64 only for now.
|
||||||
|
if [ -f "RedFlagSetup-${{ matrix.suffix }}.msi" ]; then
|
||||||
|
SHA=$(sha256sum "RedFlagSetup-${{ matrix.suffix }}.msi" | awk '{print $1}')
|
||||||
|
SIZE=$(stat -c%s "RedFlagSetup-${{ matrix.suffix }}.msi")
|
||||||
|
jq --arg plat "installer-${{ matrix.goos }}" --arg arch "${{ matrix.goarch }}" \
|
||||||
|
--arg file "RedFlagSetup-${{ matrix.suffix }}.msi" --arg sha "$SHA" \
|
||||||
|
--argjson size "$SIZE" \
|
||||||
|
'. + [{"platform":$plat,"architecture":$arch,"filename":$file,"sha256":$sha,"size":$size}]' \
|
||||||
|
"release-${{ matrix.suffix }}.artifacts.json" > tmp.json \
|
||||||
|
&& mv tmp.json "release-${{ matrix.suffix }}.artifacts.json"
|
||||||
|
fi
|
||||||
echo "Artifact snippet:"
|
echo "Artifact snippet:"
|
||||||
cat "release-${{ matrix.suffix }}.artifacts.json"
|
cat "release-${{ matrix.suffix }}.artifacts.json"
|
||||||
|
|
||||||
|
|
@ -483,12 +535,13 @@ jobs:
|
||||||
"generated_at": $NOW,
|
"generated_at": $NOW,
|
||||||
"key_id": "",
|
"key_id": "",
|
||||||
"components": [
|
"components": [
|
||||||
{"name": "server", "kind": "docker", "required": true, "version_cmd": "--version"},
|
{"name": "server", "kind": "binary", "required": true, "version_cmd": "--version"},
|
||||||
{"name": "agent", "kind": "binary", "required": true, "version_cmd": "--version"},
|
{"name": "agent", "kind": "binary", "required": true, "version_cmd": "--version"},
|
||||||
{"name": "helper", "kind": "binary", "required": true, "version_cmd": "--version"},
|
{"name": "helper", "kind": "binary", "required": true, "version_cmd": "--version"},
|
||||||
{"name": "desktop", "kind": "binary", "required": false, "version_cmd": "--version",
|
{"name": "desktop", "kind": "binary", "required": false, "version_cmd": "--version",
|
||||||
"provisioning": ["autostart_entry", "redflag-local_group", "desktop_user_membership"]},
|
"provisioning": ["autostart_entry", "redflag-local_group", "desktop_user_membership"]},
|
||||||
{"name": "web", "kind": "embedded","required": true}
|
{"name": "web", "kind": "embedded","required": true},
|
||||||
|
{"name": "installer","kind": "binary", "required": false}
|
||||||
],
|
],
|
||||||
"artifacts": []
|
"artifacts": []
|
||||||
}
|
}
|
||||||
|
|
@ -505,10 +558,12 @@ jobs:
|
||||||
done
|
done
|
||||||
|
|
||||||
# Verify every required component has at least one artifact.
|
# Verify every required component has at least one artifact.
|
||||||
# server = docker, built from source on the server host (docker-compose
|
# web = embedded (verified by web job producing a non-empty dist/).
|
||||||
# build) — not a release artifact. web = embedded (verified by web job
|
# server/agent/helper/desktop = binary artifacts, cross-compiled per
|
||||||
# producing a non-empty dist/). agent/helper/desktop = binary artifacts.
|
# platform by the release matrix. docker-compose-from-source remains
|
||||||
for comp in agent helper; do
|
# a valid, separate install path for the server — it just isn't a
|
||||||
|
# signed release artifact, so it isn't in this manifest.
|
||||||
|
for comp in server agent helper; do
|
||||||
if ! jq -e --arg c "$comp" '.artifacts[] | select(.platform | test($c))' manifest.json > /dev/null; then
|
if ! jq -e --arg c "$comp" '.artifacts[] | select(.platform | test($c))' manifest.json > /dev/null; then
|
||||||
echo "::error::required component '$comp' has no artifacts in manifest"
|
echo "::error::required component '$comp' has no artifacts in manifest"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
||||||
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -10,6 +10,42 @@ Format: version, date, then grouped by category (Added, Changed, Removed, Fixed,
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.2.9.3 (July 2026)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Native Windows installer (`RedFlagSetup.msi`) — installs the server binary as a
|
||||||
|
Windows service with a forward-only upgrade guard. Built with `wixl` (msitools),
|
||||||
|
not the official WiX Toolset .NET CLI, which does not work when the compiler runs
|
||||||
|
on Linux. CI (`release.yml`) builds it via `apt-get install msitools && wixl`.
|
||||||
|
- Native (non-docker) server config loading — the server previously only read OS
|
||||||
|
environment variables, which only ever worked because docker-compose's `env_file:`
|
||||||
|
injected them. A native install now reads a flat config file
|
||||||
|
(`REDFLAG_CONFIG_FILE` or a per-OS default path), additive and inert for existing
|
||||||
|
docker deployments.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Registration-token expiry ceiling raised from 7 days to 90 days (matching the
|
||||||
|
existing refresh-token precedent); the "Agents & Enrollment" settings page is
|
||||||
|
unified into one enroll flow instead of toggled panels, and the server signing-key
|
||||||
|
section moved out of the per-token detail pane (it wasn't per-token).
|
||||||
|
- Release manifest correctly catalogues the server binary as `kind: binary` instead
|
||||||
|
of `kind: docker` — every release was already building and packaging it, it just
|
||||||
|
wasn't gate-checked or documented as a real install artifact.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The release build's server compile step (and three `Makefile` targets) passed
|
||||||
|
`cmd/server/main.go` as a single-file argument, silently excluding `wire.go`
|
||||||
|
(added 2026-06-11) from the build. Broken since `v0.2.9.1`. Now builds the package
|
||||||
|
directory (`./cmd/server/`), matching how CI's own build-verification step already
|
||||||
|
did it correctly.
|
||||||
|
|
||||||
|
### Security / Design
|
||||||
|
- Windows privileged-mutation helper (SEC-030) elevation model decided: a
|
||||||
|
Scheduled Task run once as SYSTEM, ACL-delegated to the agent's service account,
|
||||||
|
provisioned at agent-install time — mirrors the Linux `systemd-run` transient-unit
|
||||||
|
pattern. Design of record in `RAF/components/04-helper.md`. Implementation not
|
||||||
|
started.
|
||||||
|
|
||||||
## v0.2.9.1 (June 2026)
|
## v0.2.9.1 (June 2026)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
6
Makefile
6
Makefile
|
|
@ -52,13 +52,13 @@ db-down: ## Stop PostgreSQL database
|
||||||
docker-compose down
|
docker-compose down
|
||||||
|
|
||||||
server: ## Build and run the server
|
server: ## Build and run the server
|
||||||
cd server && go mod tidy && go run cmd/server/main.go
|
cd server && go mod tidy && go run ./cmd/server/
|
||||||
|
|
||||||
agent: ## Build and run the agent
|
agent: ## Build and run the agent
|
||||||
cd agent && go mod tidy && go run cmd/agent/main.go
|
cd agent && go mod tidy && go run cmd/agent/main.go
|
||||||
|
|
||||||
build-server: ## Build server binary
|
build-server: ## Build server binary
|
||||||
cd server && go mod tidy && go build -ldflags "$(SERVER_LDFLAGS)" -o bin/server cmd/server/main.go
|
cd server && go mod tidy && go build -ldflags "$(SERVER_LDFLAGS)" -o bin/server ./cmd/server/
|
||||||
|
|
||||||
build-agent: ## Build agent binary with version injection
|
build-agent: ## Build agent binary with version injection
|
||||||
cd agent && go mod tidy && go build -ldflags "$(AGENT_LDFLAGS)" -o bin/agent ./cmd/agent/
|
cd agent && go mod tidy && go build -ldflags "$(AGENT_LDFLAGS)" -o bin/agent ./cmd/agent/
|
||||||
|
|
@ -68,7 +68,7 @@ clean: ## Clean build artifacts
|
||||||
|
|
||||||
build-all: ## Build all components with version from git tag
|
build-all: ## Build all components with version from git tag
|
||||||
@echo "Building all components at version $(VERSION)..."
|
@echo "Building all components at version $(VERSION)..."
|
||||||
cd server && go mod tidy && go build -ldflags "$(SERVER_LDFLAGS)" -o redflag-server cmd/server/main.go
|
cd server && go mod tidy && go build -ldflags "$(SERVER_LDFLAGS)" -o redflag-server ./cmd/server/
|
||||||
cd agent && go mod tidy && go build -ldflags "$(AGENT_LDFLAGS)" -o redflag-agent ./cmd/agent/
|
cd agent && go mod tidy && go build -ldflags "$(AGENT_LDFLAGS)" -o redflag-agent ./cmd/agent/
|
||||||
@echo "Build complete!"
|
@echo "Build complete!"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Start Here: RedFlag Architecture Overview
|
# Start Here: RedFlag Architecture Overview
|
||||||
|
|
||||||
**Version:** v0.2.8.0 (June 2026)
|
**Version:** v0.2.9.3 (July 2026)
|
||||||
|
|
||||||
This is the entry point into the RedFlag Architecture Framework. Read this first to
|
This is the entry point into the RedFlag Architecture Framework. Read this first to
|
||||||
understand the shape of the system, then follow the links into the detailed docs.
|
understand the shape of the system, then follow the links into the detailed docs.
|
||||||
|
|
@ -56,6 +56,28 @@ paths cannot drift on what counts as a clean closure.
|
||||||
load-bearing constraints, enforcement layers, trust chain, hash registry
|
load-bearing constraints, enforcement layers, trust chain, hash registry
|
||||||
- `docs/tasks/GATE-000-supply-chain-gate-plan.md` — build status & implementation tracking (not design)
|
- `docs/tasks/GATE-000-supply-chain-gate-plan.md` — build status & implementation tracking (not design)
|
||||||
|
|
||||||
|
### Tier 3: Break-Glass Sessions
|
||||||
|
|
||||||
|
When Tiers 1–3 (read, catalog actions, signed runbooks) can't cover the case — live
|
||||||
|
shell, desktop control, or an urgent pre-signed runbook triggered by detection — the
|
||||||
|
session broker provides a break-glass path. It is a **separate privileged Rust binary**
|
||||||
|
(`redflag-broker`), spawned on demand via the same `sudo systemd-run` pattern as the
|
||||||
|
helper, disposable, time-boxed, and audit-logged.
|
||||||
|
|
||||||
|
The broker cannot start without a minted, Ed25519-signed session grant specifying
|
||||||
|
exactly what it may do. The agent verifies the grant and spawns the broker; after that
|
||||||
|
the agent is out of the loop. The broker opens its own connection to the server, streams
|
||||||
|
live I/O, hash-chains every command in a tamper-evident audit log, and exits when the
|
||||||
|
grant expires.
|
||||||
|
|
||||||
|
**Prerequisite:** Tier 4 requires RBAC (operator-level role gating for grant minting).
|
||||||
|
The design is complete but gated behind the RBAC substrate — a break-glass path without
|
||||||
|
role-gated minting is just "anyone can get a root shell."
|
||||||
|
|
||||||
|
**Architecture docs:**
|
||||||
|
- [components/06-session-broker](components/06-session-broker.md) — design of record: grant format,
|
||||||
|
trust chain, audit trail, sequence diagram, scope variants
|
||||||
|
|
||||||
### Process Explorer
|
### Process Explorer
|
||||||
|
|
||||||
On-demand `/proc` filesystem scanning for process inventory and drill-down detail.
|
On-demand `/proc` filesystem scanning for process inventory and drill-down detail.
|
||||||
|
|
@ -116,7 +138,7 @@ From `security/05-supply-chain-gate.md` — do not regress these:
|
||||||
| Section | What It Describes |
|
| Section | What It Describes |
|
||||||
|---------|-------------------|
|
|---------|-------------------|
|
||||||
| [core](core/) | ETHOS principles, architectural decisions |
|
| [core](core/) | ETHOS principles, architectural decisions |
|
||||||
| [components](components/) | Server, agent, web, helper — package structure and responsibilities |
|
| [components](components/) | Server, agent, web, helper, session broker — package structure and responsibilities |
|
||||||
| [security](security/) | Trust boundaries, auth stack, machine binding, supply chain gate |
|
| [security](security/) | Trust boundaries, auth stack, machine binding, supply chain gate |
|
||||||
| [verification](verification/) | Ed25519 signing pipeline, agent verification, key rotation, replay protection |
|
| [verification](verification/) | Ed25519 signing pipeline, agent verification, key rotation, replay protection |
|
||||||
| [scanners](scanners/) | Per-ecosystem scanner behavior and integration points (incl. process scanner) |
|
| [scanners](scanners/) | Per-ecosystem scanner behavior and integration points (incl. process scanner) |
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ agent/
|
||||||
│ │ ├── processes.go # On-demand process explorer scans
|
│ │ ├── processes.go # On-demand process explorer scans
|
||||||
│ │ ├── local_approve.go # Desktop-self local approval flow
|
│ │ ├── local_approve.go # Desktop-self local approval flow
|
||||||
│ │ └── reboot.go, screenshot.go, upgrade_attestation.go
|
│ │ └── reboot.go, screenshot.go, upgrade_attestation.go
|
||||||
│ ├── scanner/ # apt, dnf, winget, windows (WUA), detect
|
│ ├── scanner/ # apt, dnf, pacman, winget, windows (WUA), detect
|
||||||
│ ├── installer/ # DiscoveryRunner + per-ecosystem installers
|
│ ├── installer/ # DiscoveryRunner + per-ecosystem installers
|
||||||
│ │ ├── discovery.go # Single chokepoint for read-only package ops
|
│ │ ├── discovery.go # Single chokepoint for read-only package ops
|
||||||
│ │ ├── apt.go, dnf.go # Gated: 4-method interface, no mutation
|
│ │ ├── apt.go, dnf.go # Gated: 4-method interface, no mutation
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,18 @@ Deny-by-default is the architecture, not a configuration: every failure path ret
|
||||||
|
|
||||||
The agent invokes it via `sudo systemd-run --pipe --property=ProtectSystem=no` — a transient unit with full filesystem access, separate from the agent's own locked-down unit. The token arrives on stdin (or a file path for self-update flows). The agent holds zero install sudo; the sudoers file grants discovery commands plus this one invocation line. See [components/02-agent](02-agent.md).
|
The agent invokes it via `sudo systemd-run --pipe --property=ProtectSystem=no` — a transient unit with full filesystem access, separate from the agent's own locked-down unit. The token arrives on stdin (or a file path for self-update flows). The agent holds zero install sudo; the sudoers file grants discovery commands plus this one invocation line. See [components/02-agent](02-agent.md).
|
||||||
|
|
||||||
|
### Windows Invocation (SEC-030, decided 2026-07-01)
|
||||||
|
|
||||||
|
Windows has no `systemd-run` equivalent for spinning up an ad-hoc transient privileged unit, so the helper is invoked through a **Scheduled Task, configured to run once as SYSTEM**. The agent's own service account has no standing right to mutate anything — it only holds a delegated "AllowedToRun" ACE on this one task definition (`schtasks /run /tn RedFlagHelper`), the direct Windows analogue of the Linux sudoers line that grants exactly one `systemd-run` invocation and nothing else.
|
||||||
|
|
||||||
|
Two elevation-model alternatives were considered and rejected:
|
||||||
|
- **A persistent, always-running elevated service watching a staging path.** Rejected outright — a standing elevated process is strictly more attack surface than what it replaces, failing "no standing daemon with broad rights" on its face.
|
||||||
|
- **A Windows Service the agent starts/stops per-operation.** Viable in principle (mirrors `systemd-run`'s transient-unit shape more closely) but means authoring and auditing a custom SCM service dispatcher/control handler in Rust — real new lifecycle code in a security-critical component. Scheduled Task reuses a well-understood, heavily-audited OS primitive instead of building one.
|
||||||
|
|
||||||
|
The task definition itself is provisioned **at agent-install time** by the (already-elevated) install script, the same moment Linux drops its sudoers entry — not self-provisioned by the agent on first gated operation, which would just relocate the "who grants the first elevation" problem rather than solve it.
|
||||||
|
|
||||||
|
The ACL lockdown described here is the v1 cut, not the final word — Casey's call ("that'll do for now"). Revisit if a real gap in the ACE-delegation model surfaces (see `docs/tasks/SEC-030-windows-privileged-mutation-helper.md` Open Questions for what's still unresolved: WUA's COM-driven install path, rollback ownership parity with Linux's `.bak` handling).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The Verification Pipeline
|
## The Verification Pipeline
|
||||||
|
|
|
||||||
537
RAF/components/06-session-broker.md
Normal file
537
RAF/components/06-session-broker.md
Normal file
|
|
@ -0,0 +1,537 @@
|
||||||
|
# Session Broker Component
|
||||||
|
|
||||||
|
**A privileged, per-session, network-capable Rust binary that executes Tier 4 (break-glass / interactive) operations under a minted, scoped, time-boxed grant — spawned on demand, exits when the session ends.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Language: Rust.** Same language as the helper — shared Ed25519 keyring, signature verification, grant parsing, and audit trail primitives. Five binaries, two languages: agent (Go), server (Go), helper (Rust), broker (Rust), streamer (Rust). Reference implementations cloned to `Projects/` for design study: Sunshine (C++, Moonlight server), RustDesk (Rust, full remote desktop), MeshCentral (Node.js, full RMM), Tactical RMM (Python+Go, RMM), moonlight-web-stream (Rust+TS, Sunshine-to-browser bridge — the direct ancestor of the streamer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The five-binary architecture
|
||||||
|
|
||||||
|
The existing architecture has three artifacts with clean boundaries. Tier 4 adds two: the broker (security surface) and the streamer (protocol surface).
|
||||||
|
|
||||||
|
| Component | Network | Lifetime | Privilege | Purpose |
|
||||||
|
|-----------|---------|----------|-----------|---------|
|
||||||
|
| Agent (Go) | None (pull-only) | Persistent daemon | Unprivileged | Poll, scan, process tokens |
|
||||||
|
| Helper (Rust) | None | One-shot | Privileged (systemd-run) | Verify + execute one mutation |
|
||||||
|
| Desktop (Rust/Tauri) | None (local socket only) | Persistent | Unprivileged | Local tray status |
|
||||||
|
| **Broker (Rust)** | WebSocket to server | Per-session | Privileged (systemd-run) | Grant verification, session lifecycle, audit trail |
|
||||||
|
| **Streamer (Rust)** | WebRTC/WS to browser, ENet to Sunshine | Per-session | Privileged (systemd-run) | Moonlight protocol, video decode, input injection |
|
||||||
|
|
||||||
|
The broker and streamer are **separate binaries** by design:
|
||||||
|
|
||||||
|
- **Broker is the security surface.** Grant verification, Ed25519 checks, audit logging, systemd-run spawning. ~1,500 lines, minimal dependencies, auditable in one sitting. Always shipped.
|
||||||
|
- **Streamer is the protocol surface.** WebRTC, moonlight-common-rust, Moonlight handshake, video decode, input injection. Heavy dependencies (`webrtc` crate), complex protocol code. **Opt-in** — installed only on hosts where the operator wants remote desktop capability. Not shipped by default.
|
||||||
|
- **Attack surface isolation.** A CVE in the `webrtc` crate hits the streamer, not the broker. The broker's grant model remains intact.
|
||||||
|
- **Update independence.** The streamer can be updated when Moonlight protocol changes without touching the broker.
|
||||||
|
|
||||||
|
Tier 4 needs a live interactive channel — streaming I/O, return data, a WebSocket or gRPC connection to the server. None of the existing components can carry this without breaking their design constraints:
|
||||||
|
|
||||||
|
- **Agent:** pull-only doctrine forbids server-initiated connections; grafting a push channel onto the agent would destroy the posture that makes it safe.
|
||||||
|
- **Helper:** network-less by design; one-shot by design. A persistent session is the opposite of both.
|
||||||
|
- **Desktop:** local-only by design; no server connectivity.
|
||||||
|
|
||||||
|
The session broker is the fifth artifact. It reuses the agent's spawn pattern (`sudo systemd-run --pipe`) but has its own trust boundary, its own network connection, and its own audit trail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Doctrine
|
||||||
|
|
||||||
|
1. **The grant is the gate.** The broker cannot start without a valid, unexpired, Ed25519-signed grant that specifies exactly what it may do. No grant, no session.
|
||||||
|
2. **The agent never holds session state.** The agent verifies the grant and spawns the broker. After that, the agent is out of the loop. The broker owns the session lifecycle.
|
||||||
|
3. **The broker is disposable.** Spawned on demand, lives for the session, exits when the grant expires or the operator disconnects. No daemon, no persistent state, no background listener.
|
||||||
|
4. **Server-side recording is the primary audit record.** The server independently records everything it relays through the session channel. The on-host audit log is corroboration, not the source of truth. A compromised broker can still act outside the channel — this is the honest claim, not "every executed command is captured."
|
||||||
|
5. **The broker is the most-privileged transient on the host.** It runs as root with `ProtectSystem=no` and a network connection. This is justified only because the grant gates it tightly: short-lived, scoped, reason-documented, RBAC-controlled. Every design choice flows from making this surface as narrow as possible.
|
||||||
|
6. **The helper stays untouched.** The broker is a separate binary with a separate trust boundary. It does not extend, wrap, or share code with the helper beyond the shared signing infrastructure (keyring, Ed25519 verification).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grant format
|
||||||
|
|
||||||
|
The session grant extends the existing Ed25519 infrastructure. It is a new payload type signed by the same authority key, verified against the same pinned keyring.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"grant_type": "session",
|
||||||
|
"grant_id": "<uuid>", // unique; replay guard + audit key
|
||||||
|
"agent_id": "<uuid>", // bound to exactly one host
|
||||||
|
"operator_id": "<uuid>", // the operator who minted this
|
||||||
|
"scope": "shell|desktop|script",
|
||||||
|
"runbook_id": "<uuid, optional>",// for scope=script: which pre-signed runbook
|
||||||
|
"max_duration_seconds": 1800, // hard ceiling on session runtime
|
||||||
|
"issued_at": <unix>,
|
||||||
|
"not_before": <unix>,
|
||||||
|
"expires_at": <unix>, // gates STARTING the session (recommended: 15 min)
|
||||||
|
"authority_key_id": "<hex, 32>", // which authority key signed this
|
||||||
|
"signature": "<hex ed25519>" // over the canonical message below
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Canonical signed message** (length-prefixed, no delimiter ambiguity):
|
||||||
|
|
||||||
|
```
|
||||||
|
len(grant_id) || grant_id ||
|
||||||
|
len(agent_id) || agent_id ||
|
||||||
|
len(operator_id) || operator_id ||
|
||||||
|
len(scope) || scope ||
|
||||||
|
len(runbook_id) || runbook_id ||
|
||||||
|
len(expires_at) || expires_at ||
|
||||||
|
len(max_duration_seconds) || max_duration_seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
Each field is prefixed with its byte length (u32 big-endian) followed by the field bytes. This eliminates delimiter-injection: no field can contain a separator that would shift parsing boundaries. The signed message is deterministic and language-agnostic — Go and Rust reconstruct identical bytes.
|
||||||
|
|
||||||
|
> **Why not colon-delimited?** GATE-005 taught this lesson: fields containing the delimiter create signing/verification asymmetry. Length-prefixing is unambiguous regardless of field content. UUIDs and enums today, but the contract holds for any charset.
|
||||||
|
|
||||||
|
### Duration semantics
|
||||||
|
|
||||||
|
`expires_at` and `max_duration_seconds` serve different purposes and must not conflict:
|
||||||
|
|
||||||
|
- **`expires_at` gates starting.** A session cannot begin after this timestamp. The broker rejects `session_start > expires_at`.
|
||||||
|
- **`max_duration_seconds` gates running.** The session hard-stops at `min(expires_at, session_start + max_duration_seconds)`, whichever comes first.
|
||||||
|
- A grant minted with `expires_at` 15 min out and `max_duration` 1800s: a session started at minute 14 gets 1 minute, not 30 more.
|
||||||
|
- The broker enforces the ceiling internally; the server enforces it at the relay layer (closes WebSocket at the hard-stop).
|
||||||
|
|
||||||
|
### Scope variants
|
||||||
|
|
||||||
|
| Scope | What it authorizes | Safety mechanism |
|
||||||
|
|-------|-------------------|-----------------|
|
||||||
|
| `shell` | Arbitrary commands in a live shell session | Server-side recording, time-boxed, operator identity bound |
|
||||||
|
| `desktop` | Moonlight/Sunshine remote desktop via broker relay | Server-side stream recording, time-boxed, operator identity bound |
|
||||||
|
| `script` | Execute one pre-signed runbook by ID | Hash-pinned artifact (Tier 3 primitive inside Tier 4) |
|
||||||
|
|
||||||
|
### Desktop scope: one protocol, two modes
|
||||||
|
|
||||||
|
Moonlight/Sunshine is the desktop scope. Not because it's perfect for both support and workstation use, but because it's the best single protocol that covers both without protocol sprawl:
|
||||||
|
|
||||||
|
- **USB passthrough** — forward USB devices to the remote session (keyboard, mass storage, smart cards)
|
||||||
|
- **Clipboard sharing** — bidirectional copy/paste
|
||||||
|
- **Audio forwarding** — capture and stream host audio
|
||||||
|
- **GPU-encoded** — low latency on hardware that has a GPU
|
||||||
|
- **Headless-capable** — Sunshine captures any display output; works on servers without a physical monitor (virtual display or dummy plug)
|
||||||
|
- **Cross-platform** — Windows, Linux, macOS clients and hosts
|
||||||
|
|
||||||
|
The support-grade requirements (NAT traversal, session recording, survives-reboot, end-user consent) are handled by the **broker relay layer**, not by the protocol itself:
|
||||||
|
|
||||||
|
- **NAT traversal** — the broker opens the WebSocket to the server; the operator connects through the server relay. No port-forwarding needed on the agent host. The server relays the Moonlight stream, adding a hop but solving the NAT problem.
|
||||||
|
- **Session recording** — the server records the relayed stream for audit. Not as clean as command logging (it's video, not text), but it captures what the operator saw and did.
|
||||||
|
- **Survives-reboot** — Sunshine runs as a system service; the broker can reconnect after transient failures within the grant window.
|
||||||
|
- **End-user consent** — if a user is present, the broker can prompt before allowing the session. If unattended (headless server), the grant itself is the consent.
|
||||||
|
|
||||||
|
SPICE and VNC remain available as fallbacks for environments that can't run Sunshine (no GPU, VM passthrough via QEMU/KEMU). But Moonlight/Sunshine is the first-class citizen — the grant format and broker architecture are designed around it, and the fallback protocols get a best-effort integration path, not a first-class grant type.
|
||||||
|
|
||||||
|
**Honest tradeoff:** Moonlight requires Sunshine on the host (the capture/encode server). If Sunshine isn't installed or the GPU can't encode, the desktop scope degrades gracefully to "the operator sees a static screenshot and can type commands" (shell scope with visual context). That's a valid fallback, not a failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Invocation
|
||||||
|
|
||||||
|
The agent spawns the broker via the same mechanism it uses for the helper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemd-run --pipe \
|
||||||
|
--property=ProtectSystem=no \
|
||||||
|
--property=ReadWritePaths="/var/lib/redflag/sessions" \
|
||||||
|
redflag-broker --grant /tmp/redflag-grant-<uuid>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The grant file is:
|
||||||
|
1. Written by the agent to a root-owned tmpfile (world-inaccessible)
|
||||||
|
2. The broker reads it once at startup, verifies the signature, deletes the file
|
||||||
|
3. The broker opens its own connection to the server using the grant as auth
|
||||||
|
4. When the session ends (grant expires, operator disconnects, or explicit close), the broker exits and the transient systemd unit is cleaned up
|
||||||
|
|
||||||
|
The agent's sudoers entry adds a second invocation line:
|
||||||
|
|
||||||
|
```
|
||||||
|
redflag-broker --grant <path>
|
||||||
|
```
|
||||||
|
|
||||||
|
Same pattern as the helper, same narrowed sudoers philosophy: the agent can spawn the broker, but cannot influence what the broker does after it starts. The broker reads the grant, verifies it independently, and operates within its scope.
|
||||||
|
|
||||||
|
### `ProtectSystem=no` blast radius
|
||||||
|
|
||||||
|
The broker unit is the most-privileged transient thing on the host: root, full filesystem access, network connection. This is a wider blast radius than the helper (which is also root + `ProtectSystem=no` but has no network). The tighter gating compensates:
|
||||||
|
|
||||||
|
- Grant is short-lived (15 min max recommended TTL)
|
||||||
|
- Grant is scoped to one operator, one agent, one scope
|
||||||
|
- Grant requires documented reason + RBAC authority
|
||||||
|
- Server-side recording captures everything relayed through the channel
|
||||||
|
- Session is logged to a tamper-evident audit trail
|
||||||
|
- The host is treated as suspect after break-glass use (see §Post-session posture)
|
||||||
|
|
||||||
|
If this blast radius is unacceptable for a deployment, that's a valid deployment choice — don't ship Tier 4. The architecture doesn't require it; Tiers 1–3 cover most ops.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Three verifications (defense in depth)
|
||||||
|
|
||||||
|
The grant is verified at three points, by three different components:
|
||||||
|
|
||||||
|
| Verifier | When | What it checks |
|
||||||
|
|----------|------|---------------|
|
||||||
|
| **Agent** | Before spawn | Signature against keyring, agent_id matches this host, not expired |
|
||||||
|
| **Broker** | At startup | Signature against keyring (independently), expiry, scope, grant_id not consumed |
|
||||||
|
| **Server** | At session-open | Grant signature, grant_id not already opened, RBAC still valid, not expired |
|
||||||
|
|
||||||
|
Three verifications by three independent components. A forged grant would need to pass all three. A replayed grant would fail the server's second check (grant_id already consumed). A stolen grant would fail the agent's machine-binding check (agent_id mismatch).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trust chain
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Human Operator │
|
||||||
|
│ - Requests break-glass session (reason required) │
|
||||||
|
│ - Higher authority than package approval │
|
||||||
|
└─────────────────────┬───────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────▼───────────────────────────────────────┐
|
||||||
|
│ RedFlag Server (authority) │
|
||||||
|
│ - Validates operator has Tier 4 permission (RBAC) │
|
||||||
|
│ - Validates operator can access this specific agent │
|
||||||
|
│ (RBAC with agent-scoping, not just a boolean) │
|
||||||
|
│ - Requires documented reason for the session │
|
||||||
|
│ - Mints Ed25519-signed session grant │
|
||||||
|
│ - Grant is scoped: one agent, one operator, one scope │
|
||||||
|
│ - Records grant_id, refuses second session-open │
|
||||||
|
│ - Streams relay through server (primary audit record) │
|
||||||
|
└─────────────────────┬───────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────▼───────────────────────────────────────┐
|
||||||
|
│ RedFlag Agent (gatekeeper, unprivileged) │
|
||||||
|
│ - Receives grant, confirms agent_id is this host │
|
||||||
|
│ - Verifies signature (same keyring as package tokens) │
|
||||||
|
│ - Writes grant to root-owned tmpfile │
|
||||||
|
│ - Spawns broker via sudo systemd-run │
|
||||||
|
│ - Agent is out of the loop after spawn │
|
||||||
|
└─────────────────────┬───────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────▼───────────────────────────────────────┐
|
||||||
|
│ Session Broker (privileged, network-capable, Rust) │
|
||||||
|
│ - Reads grant file, verifies signature independently │
|
||||||
|
│ - Records grant_id in consumed set (replay guard) │
|
||||||
|
│ - Opens WebSocket to server; grant is the client cred │
|
||||||
|
│ - Server validates grant again at session-open (3rd check) │
|
||||||
|
│ - Enforces hard-stop: min(expires_at, start + max_duration)│
|
||||||
|
│ - Emits receipt to server on session end │
|
||||||
|
│ - Disposable: spawned per-session, no persistent state │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit trail
|
||||||
|
|
||||||
|
The audit model has two layers, and the distinction matters:
|
||||||
|
|
||||||
|
### Primary: server-side recording
|
||||||
|
|
||||||
|
The server independently records everything relayed through the session channel:
|
||||||
|
- Every command the operator typed (captured at the relay layer, before reaching the broker)
|
||||||
|
- Every output frame the broker sent back (captured at the relay layer)
|
||||||
|
- Session metadata: operator_id, agent_id, grant_id, start/end timestamps, reason
|
||||||
|
|
||||||
|
This is the authoritative audit record. It exists on a machine the broker cannot reach. A compromised broker can still act outside the channel (see §Honest limitations), but the server's copy captures what was *relayed* — and the broker's ability to act undetected is bounded by the grant's short lifetime.
|
||||||
|
|
||||||
|
### Secondary: on-host corroboration
|
||||||
|
|
||||||
|
The broker writes a hash-chained log locally:
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/lib/redflag/sessions/<grant_id>/
|
||||||
|
├── audit.log # hash-chained: each line includes prev_hash
|
||||||
|
├── receipt.json # structured result (agent_id, operator, duration, exit_code)
|
||||||
|
└── receipt.sig # Ed25519 signature over receipt (broker's ephemeral key)
|
||||||
|
```
|
||||||
|
|
||||||
|
The hash chain works as follows:
|
||||||
|
- Each log entry: `{timestamp}: {prev_hash}: {event}`
|
||||||
|
- The chain starts from a genesis hash derived from the grant_id
|
||||||
|
- At session end, the broker generates a **per-session ephemeral Ed25519 keypair**, signs the final receipt with it, and includes the ephemeral public key in the receipt
|
||||||
|
- The server records the ephemeral public key alongside the receipt
|
||||||
|
|
||||||
|
**Why an ephemeral key, not the authority key?** The authority key is the system's highest-value secret. The broker is the most-attackable process on the host (root, network-capable, running untrusted operator input). Giving it the authority key means a compromised broker can mint tokens for any operation on any host. The ephemeral key signs *only this receipt* and is discarded with the session. The server can verify the receipt signature using the embedded ephemeral public key, confirming the receipt was produced by the broker that held this grant — without the broker ever holding the authority key.
|
||||||
|
|
||||||
|
The on-host log is corroboration: useful for local debugging, forensics on a live host, and proving to the host operator what happened during a session they may not have witnessed. But it is not the primary audit record, because the author of the log (the broker, running as root) could theoretically have written anything. The server's relay recording is the one that matters for accountability.
|
||||||
|
|
||||||
|
### Hash chain structure (on-host corroboration)
|
||||||
|
|
||||||
|
```
|
||||||
|
genesis = sha256(grant_id)
|
||||||
|
entry_0 = { "ts": <unix>, "prev": genesis, "event": "session_open", "operator": <id> }
|
||||||
|
entry_1 = { "ts": <unix>, "prev": sha256(entry_0), "event": "input", "data": "<command>" }
|
||||||
|
entry_2 = { "ts": <unix>, "prev": sha256(entry_1), "event": "output", "data": "<result_frame>" }
|
||||||
|
...
|
||||||
|
entry_n = { "ts": <unix>, "prev": sha256(entry_n-1), "event": "session_close", "reason": "<expir|disconnect|close>" }
|
||||||
|
receipt = { "grant_id", "agent_id", "operator_id", "start_ts", "end_ts", "duration_s",
|
||||||
|
"commands_relayed": <count>, "exit_code": <int>, "ephemeral_pubkey": "<hex>" }
|
||||||
|
receipt.sig = ed25519_sign(ephemeral_priv, sha256(receipt))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Honest limitations
|
||||||
|
|
||||||
|
The following are real gaps, not TODOs. They are accepted design tradeoffs, not bugs to fix:
|
||||||
|
|
||||||
|
**The broker is the author of the on-host audit trail.** A compromised broker can omit commands from its local log (omission doesn't break a hash chain the way mutation does). This is why the server-side relay recording is the primary record and the on-host log is corroboration. The broker cannot reach the server's copy.
|
||||||
|
|
||||||
|
**What the operator typed and what the broker ran may differ.** The server sees operator input at the relay layer; the broker sees the shell's actual execution. A compromised broker could execute something different from what was relayed. The honest claim is: "every command relayed through the session is recorded server-side; a compromised broker can still act outside the channel, which is why the grant is short-lived and the host is treated as suspect after break-glass use." Root can do anything, including lie about what it did.
|
||||||
|
|
||||||
|
**`ProtectSystem=no` + network = wide blast radius.** The broker is the most-privileged transient on the host. The tighter gating (short-lived, scoped, reason-documented, RBAC-controlled, server-recorded) compensates. If a deployment finds this unacceptable, don't ship Tier 4 — Tiers 1–3 cover most ops.
|
||||||
|
|
||||||
|
**Desktop scope is not production-ready.** Moonlight/Sunshine integration (broker relay, server-side stream recording, Sunshine service management) is a large implementation. The grant format and broker architecture accommodate it, but the actual streaming, rendering, and connection management should not be assumed close to shipping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grant replay protection
|
||||||
|
|
||||||
|
Grant theft between mint and spawn is a real vector on a compromised host. The replay guard works like the helper's token-ID recording:
|
||||||
|
|
||||||
|
1. **Broker records consumed grant_ids.** At startup, after verifying the grant, the broker writes `grant_id` to a consumed set (root-owned, same location as the audit log). If the grant_id is already in the set, the broker refuses to start.
|
||||||
|
2. **Server refuses second session-open.** When the broker opens a WebSocket and presents the grant, the server checks `grant_id` against its DB. If a session was already opened for this grant_id, the server rejects the connection.
|
||||||
|
3. **Grant_id is a UUID v4.** Unpredictable, so an attacker cannot guess valid grant_ids to probe.
|
||||||
|
|
||||||
|
The enforcement points are: broker (local replay guard) and server (global replay guard). The agent does not enforce replay — it's out of the loop after spawn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequence: break-glass session
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Operator clicks "Request Shell Session" in dashboard
|
||||||
|
- Required: reason text, target agent, session scope
|
||||||
|
- Server validates RBAC (operator has Tier 4 permission for this agent)
|
||||||
|
|
||||||
|
2. Server mints session grant
|
||||||
|
- Signs grant with authority key
|
||||||
|
- Stores grant in DB with status: pending
|
||||||
|
- Records grant_id (replay guard)
|
||||||
|
|
||||||
|
3. Agent polls, receives grant
|
||||||
|
- Verifies signature against keyring
|
||||||
|
- Confirms agent_id matches this host
|
||||||
|
- Writes grant to root-owned tmpfile
|
||||||
|
|
||||||
|
4. Agent spawns broker
|
||||||
|
- sudo systemd-run --pipe redflag-broker --grant <tmpfile>
|
||||||
|
|
||||||
|
5. Broker starts, verifies grant independently
|
||||||
|
- Reads tmpfile, deletes it
|
||||||
|
- Verifies signature (same Ed25519 check)
|
||||||
|
- Records grant_id in consumed set
|
||||||
|
- Checks expiry and scope
|
||||||
|
- Opens WebSocket to server; presents grant as client credential
|
||||||
|
|
||||||
|
6. Server validates grant again (third verification)
|
||||||
|
- Checks signature (defense in depth)
|
||||||
|
- Checks grant_id not already opened
|
||||||
|
- Checks RBAC still valid (not revoked since mint)
|
||||||
|
- Checks not expired
|
||||||
|
- Opens relay channel
|
||||||
|
|
||||||
|
7. Live session
|
||||||
|
- Operator types commands in browser
|
||||||
|
- Server relays to broker via WebSocket (server records input)
|
||||||
|
- Broker executes, streams output back (server records output)
|
||||||
|
- Broker hash-chains events in on-host corroboration log
|
||||||
|
|
||||||
|
8. Session ends (grant expires, operator closes, or explicit disconnect)
|
||||||
|
- Server closes relay at hard-stop: min(expires_at, start + max_duration)
|
||||||
|
- Broker generates ephemeral keypair, signs receipt
|
||||||
|
- Broker emits receipt to server
|
||||||
|
- Server records receipt, closes grant
|
||||||
|
- On-host: audit.log + receipt.json + receipt.sig written
|
||||||
|
|
||||||
|
9. Agent is unaffected — it moved on after step 4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The streamer binary (opt-in)
|
||||||
|
|
||||||
|
The streamer is a **separate Rust binary** that handles the Moonlight protocol and WebRTC streaming to the browser. It is not shipped by default — installed only on hosts where the operator wants remote desktop capability.
|
||||||
|
|
||||||
|
### Lineage
|
||||||
|
|
||||||
|
Forked from `moonlight-web-stream` (590 stars, Rust+TypeScript, GPL-3.0). That project bridges Sunshine/Moonlight to a browser via WebRTC. RedFlag's streamer forks the Rust components and strips the independent auth:
|
||||||
|
|
||||||
|
| moonlight-web-stream | RedFlag streamer |
|
||||||
|
|---------------------|-----------------|
|
||||||
|
| Own user/password auth | Removed — RedFlag grants replace it |
|
||||||
|
| SQLite user DB | Removed — RedFlag server owns identity |
|
||||||
|
| Standalone web server | Removed — RedFlag server relays |
|
||||||
|
| `moonlight-common-rust` (Moonlight client) | **Kept** — the protocol engine |
|
||||||
|
| `webrtc` crate (browser streaming) | **Kept** — the transport to browser |
|
||||||
|
| Streamer subprocess model | **Kept** — one subprocess per session |
|
||||||
|
| WebRTC + WebSocket fallback | **Kept** — works in restrictive networks |
|
||||||
|
| TURN server support | **Kept** — for NAT traversal |
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
1. Receives connection parameters from the broker (Sunshine host/port, session ID, encoding prefs)
|
||||||
|
2. Connects to Sunshine via Moonlight protocol (ENet, UDP)
|
||||||
|
3. Negotiates screen capture, GPU encoding, input injection with Sunshine
|
||||||
|
4. Streams the video/audio to the browser via WebRTC (or WebSocket fallback)
|
||||||
|
5. Forwards keyboard/mouse/gamepad input from browser back to Sunshine
|
||||||
|
6. Reports session metadata back to the broker for audit
|
||||||
|
|
||||||
|
### What it does NOT do
|
||||||
|
|
||||||
|
- **No grant verification** — that's the broker's job. The streamer trusts the broker to have verified the grant.
|
||||||
|
- **No audit logging** — the broker handles the audit trail. The streamer is a protocol engine, not a security component.
|
||||||
|
- **No persistent state** — spawned per-session, exits when the session ends.
|
||||||
|
- **No Sunshine management** — the broker starts/stops Sunshine. The streamer just connects to it.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
| Crate | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `moonlight-common-rust` | Moonlight client protocol (git dependency) |
|
||||||
|
| `webrtc` | WebRTC peer connection to browser |
|
||||||
|
| `tokio` | Async runtime |
|
||||||
|
| `actix-web` (optional) | Minimal HTTP for WebRTC signaling |
|
||||||
|
|
||||||
|
### Security posture
|
||||||
|
|
||||||
|
The streamer runs as root (via `systemd-run`) because it needs to inject input and capture audio. This is the same privilege level as the broker. The attack surface is:
|
||||||
|
|
||||||
|
- **Moonlight protocol parsing** — complex, but well-audited in moonlight-common-rust
|
||||||
|
- **WebRTC stack** — large dependency, most likely place for a vuln
|
||||||
|
- **Input injection** — root-level keyboard/mouse control
|
||||||
|
|
||||||
|
A vulnerability in the streamer can compromise the host during an active session. This is accepted because:
|
||||||
|
- The streamer only runs during an active, grant-scoped session
|
||||||
|
- The session is time-boxed (max_duration)
|
||||||
|
- The session is recorded (server-side relay recording)
|
||||||
|
- The host is treated as suspect after break-glass use
|
||||||
|
|
||||||
|
The broker remains unaffected by streamer vulnerabilities — it's a separate binary with separate dependencies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The RedFlag streaming page (web UI)
|
||||||
|
|
||||||
|
The RedFlag dashboard needs a new page: **Streaming** (or **Remote Desktop**). This is the operator-facing viewer that connects to the streamer via WebRTC.
|
||||||
|
|
||||||
|
### Page requirements
|
||||||
|
|
||||||
|
| Feature | Notes |
|
||||||
|
|---------|-------|
|
||||||
|
| WebRTC video/audio decode | Browser-native, H.264/AV1 via WebDecoder API |
|
||||||
|
| Keyboard input | Keyboard Lock API (requires HTTPS secure context) |
|
||||||
|
| Mouse input | Pointer Lock API, relative mouse movement |
|
||||||
|
| Gamepad input | Gamepad API — works with USB and Bluetooth controllers paired to the **operator's machine** (not the agent host) |
|
||||||
|
| Clipboard sync | Bidirectional copy/paste via WebRTC data channel |
|
||||||
|
| Display selection | Multi-monitor support (pick which display to view) |
|
||||||
|
| Quality controls | Resolution, framerate, bandwidth negotiation |
|
||||||
|
| Session info | Grant ID, operator, time remaining, agent name |
|
||||||
|
| Disconnect button | Clean session teardown |
|
||||||
|
|
||||||
|
### Controller support
|
||||||
|
|
||||||
|
The browser's Gamepad API supports USB and Bluetooth controllers connected to the **operator's machine** — not the agent host. The operator's controller input is captured in the browser, sent via WebRTC data channel to the streamer, which injects it into Sunshine as gamepad input.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- The operator can use a Bluetooth Xbox/PlayStation/Switch controller at their desk
|
||||||
|
- The input is forwarded to the agent host as if the controller were plugged in there
|
||||||
|
- No Bluetooth stack on the agent host is required — Sunshine sees emulated gamepad input
|
||||||
|
|
||||||
|
For an RMM break-glass context, keyboard and mouse are the primary input methods. Gamepad support is a bonus for workstation-use scenarios (CAD, media production) but not a core requirement.
|
||||||
|
|
||||||
|
### Page architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ RedFlag Dashboard (React SPA) │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ │ Streaming Page │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌──────────────────────────────────┐ │ │
|
||||||
|
│ │ │ WebRTC Video Canvas │ │ │
|
||||||
|
│ │ │ (H.264/AV1 decode via browser) │ │ │
|
||||||
|
│ │ └──────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │
|
||||||
|
│ │ │ Quality │ │ Displays │ │ Session│ │ │
|
||||||
|
│ │ │ Controls │ │ Selector │ │ Info │ │ │
|
||||||
|
│ │ └──────────┘ └──────────┘ └────────┘ │ │
|
||||||
|
│ └─────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────┬──────────────────────────┘
|
||||||
|
│ WebRTC (video + data channel)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ RedFlag Server (relay) │
|
||||||
|
│ - WebRTC signaling (offer/answer/ICE) │
|
||||||
|
│ - Relay video/audio/input data channels │
|
||||||
|
│ - Session recording at relay layer │
|
||||||
|
└──────────────────────┬──────────────────────────┘
|
||||||
|
│ WebSocket
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ RedFlag Streamer (on agent host) │
|
||||||
|
│ - Moonlight protocol to Sunshine │
|
||||||
|
│ - WebRTC peer to browser (via server relay) │
|
||||||
|
│ - Input injection (keyboard, mouse, gamepad) │
|
||||||
|
└──────────────────────┬──────────────────────────┘
|
||||||
|
│ ENet (UDP)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Sunshine (on agent host, managed by broker) │
|
||||||
|
│ - Screen capture (GPU) │
|
||||||
|
│ - Video encoding (NVENC/VAAPI/AMF) │
|
||||||
|
│ - Audio capture │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-session posture
|
||||||
|
|
||||||
|
After a break-glass session, the host is treated as suspect. This is not optional — it's a consequence of granting root shell access to a remote operator. The server should:
|
||||||
|
|
||||||
|
1. **Flag the agent** in the dashboard (visual indicator: "break-glass session used")
|
||||||
|
2. **Recommend a closure reconciliation** — the operator should verify the host's package closure against the signed manifest and reconcile any drift
|
||||||
|
3. **Optionally trigger an automatic scan** — re-scan the host's subsystems to capture post-session state
|
||||||
|
|
||||||
|
This is not an enforcement mechanism — a determined attacker who had root during the session can defeat post-session scanning. It's a signal to the operator that the host's trust posture should be re-verified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisite: RBAC with agent-scoping
|
||||||
|
|
||||||
|
Tier 4 grants require more than a boolean "has Tier 4 permission." For MSP deployments, an operator with Tier 4 should not get a root shell on every tenant's hosts. The RBAC substrate must support:
|
||||||
|
|
||||||
|
- **Per-operator agent scoping** — which agents can this operator break-glass into?
|
||||||
|
- **Per-operator scope restrictions** — can this operator request `shell`, or only `desktop_support`?
|
||||||
|
- **Reason auditing** — every Tier 4 grant carries a documented reason, queryable by auditors
|
||||||
|
|
||||||
|
The current system is single-admin with an inert `/admin` latch. The session broker design is complete and ready, but **cannot ship credibly until RBAC with agent-scoping lands** — a break-glass path without role-gated minting is just "anyone can get a root shell on any host."
|
||||||
|
|
||||||
|
The RBAC work is tracked separately. The session broker should be built to the grant format above but remain gated behind the RBAC substrate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
- **Capability token model** → [security/05-supply-chain-gate](../security/05-supply-chain-gate.md) (the primitive this extends)
|
||||||
|
- **Helper architecture** → [components/04-helper](04-helper.md) (the sibling binary this parallels)
|
||||||
|
- **Trust boundaries** → [security/01-trust-boundaries](../security/01-trust-boundaries.md) (the trust matrix this adds to)
|
||||||
|
- **Pull-only doctrine** → [security/01-trust-boundaries](../security/01-trust-boundaries.md) §Doctrine (why the broker opens its own connection, not the agent)
|
||||||
|
- **Standalone mode** → [security/06-standalone-authority](../security/06-standalone-authority.md) (standalone mint pattern, same spawn mechanism)
|
||||||
|
- **GATE-005** → `docs/tasks/GATE-005-helper-trusted-input-hardening.md` (delimiter-injection lesson that informed the canonical message format)
|
||||||
|
- **moonlight-web-stream** → `Projects/moonlight-web-stream/` (forked from — Rust+TS, Sunshine-to-browser bridge, 590 stars)
|
||||||
|
- **Sunshine capture** → `Projects/Sunshine/` (C++ — screen capture, GPU encode, service model, vendored for capture engine)
|
||||||
|
- **MeshCentral relay** → `Projects/MeshCentral/meshrelay.js` (relay pair-up pattern, session recording format, rights model)
|
||||||
|
- **RustDesk relay** → `Projects/RustDesk/` (Rust — rendezvous pattern, tokio async model)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last reviewed: 2026-06-30*
|
||||||
92
RAF/scanners/06-pacman-scanner.md
Normal file
92
RAF/scanners/06-pacman-scanner.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Pacman Scanner
|
||||||
|
|
||||||
|
**Arch Linux package manager scanning via pacman-contrib's checkupdates.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Details
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Method | `checkupdates --color=never` |
|
||||||
|
| Platform | Linux (Arch, Manjaro, EndeavourOS) |
|
||||||
|
| Execution time | ~5-15 seconds per scan |
|
||||||
|
| Output format | `pkgname oldver -> newver` (one per line) |
|
||||||
|
| Failure modes | pacman-contrib not installed, network timeout, database lock |
|
||||||
|
| Root required | No (checkupdates uses a private db copy) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
**File:** `agent/internal/scanner/pacman.go`
|
||||||
|
|
||||||
|
### Detection
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PacmanScanner) IsAvailable() bool {
|
||||||
|
// Requires both pacman and checkupdates (from pacman-contrib)
|
||||||
|
if _, err := exec.LookPath("checkupdates"); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := exec.LookPath("pacman")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scanning
|
||||||
|
|
||||||
|
Uses `DiscoveryRunner` (same as apt/dnf) for sandboxed execution:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PacmanScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||||
|
runner, err := installer.NewDiscoveryRunner("pacman")
|
||||||
|
// checkupdates --color=never
|
||||||
|
result, err := runner.Run(context.Background(), "--color=never")
|
||||||
|
// Parse "pkgname oldver -> newver" lines
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Parsing
|
||||||
|
|
||||||
|
`checkupdates` output format:
|
||||||
|
```
|
||||||
|
fakeroot 1:1.37.2-1 -> 1:1.37.2-2
|
||||||
|
linux 6.14.6.arch1-1 -> 6.15.1.arch1-1
|
||||||
|
```
|
||||||
|
|
||||||
|
The current (installed) version is embedded in the output — no secondary
|
||||||
|
query needed (unlike dnf which calls `rpm -q`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sudoers
|
||||||
|
|
||||||
|
The agent user needs `checkupdates` in sudoers. This is an unprivileged
|
||||||
|
discovery command (no mutation). Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
redflag-agent ALL=(ALL) NOPASSWD: /usr/bin/checkupdates
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- **Requires pacman-contrib** — `pacman` alone is not sufficient. The
|
||||||
|
`checkupdates` binary is in the `pacman-contrib` package, which may not
|
||||||
|
be installed by default.
|
||||||
|
- **AUR packages not scanned** — `checkupdates` only checks official repos.
|
||||||
|
AUR helpers (yay, paru) have their own update checkers but are not
|
||||||
|
integrated.
|
||||||
|
- **No dry-run for mutations** — pacman's `--print` flag exists but is not
|
||||||
|
used for hash resolution. Artifact hashing goes through the capability
|
||||||
|
gate path when it lands for pacman.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
- [core/02-architecture-decisions](../core/02-architecture-decisions.md) — DiscoveryRunner pattern
|
||||||
|
- [security/05-supply-chain-gate](../security/05-supply-chain-gate.md) — capability token path (future for pacman)
|
||||||
|
- `agent/internal/scanner/detect.go` — scanner registration
|
||||||
|
|
@ -129,6 +129,33 @@ Ties to SEC-012 (renewal atomicity): the server side is now fully transactional;
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Session Broker Trust Boundary
|
||||||
|
|
||||||
|
A **separate privileged Rust binary** spawned on demand for Tier 4 (break-glass /
|
||||||
|
interactive) sessions. The broker has its own trust boundary, its own network connection
|
||||||
|
to the server, and its own audit trail. It is not a sub-component of the agent or helper.
|
||||||
|
|
||||||
|
**Spawn mechanism:** agent writes a root-owned tmpfile with the minted grant, then
|
||||||
|
spawns the broker via `sudo systemd-run --pipe --property=ProtectSystem=no
|
||||||
|
redflag-broker --grant <tmpfile>`. The broker reads the grant once, verifies the
|
||||||
|
Ed25519 signature independently against the pinned keyring, deletes the tmpfile, and
|
||||||
|
opens its own WebSocket/gRPC connection to the server.
|
||||||
|
|
||||||
|
**Security Notes:**
|
||||||
|
- Grant is scoped: one agent, one operator, one scope (shell/desktop/script)
|
||||||
|
- Grant is time-boxed: hard ceiling enforced by the broker, not advisory
|
||||||
|
- Every command input is hash-chained in an append-only audit log
|
||||||
|
- Session end: broker signs the audit chain, emits a receipt, exits
|
||||||
|
- Agent is out of the loop after spawn — cannot influence broker execution
|
||||||
|
- No package operations — that's the helper's job; no persistent connections
|
||||||
|
- **Requires RBAC** for grant minting — design complete, gated behind RBAC substrate
|
||||||
|
|
||||||
|
**Cross-references:**
|
||||||
|
- [components/06-session-broker](../components/06-session-broker.md) (design of record)
|
||||||
|
- [components/04-helper](../components/04-helper.md) (sibling binary, same spawn pattern)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Local Trust Boundary (agent localapi)
|
### Local Trust Boundary (agent localapi)
|
||||||
|
|
||||||
Not a server HTTP boundary — this one lives on the **agent host**. The agent exposes a
|
Not a server HTTP boundary — this one lives on the **agent host**. The agent exposes a
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**Self-hosted update management for operators who own their stack.**
|
**Self-hosted update management for operators who own their stack.**
|
||||||
|
|
||||||
`v0.2.8.0` — June 2026 · AGPL-3.0
|
`v0.2.9.3` — July 2026 · AGPL-3.0
|
||||||
|
|
||||||
> **You're early — over 1,000 of you cloned this before it was announced.**
|
> **You're early — over 1,000 of you cloned this before it was announced.**
|
||||||
> A stable release is coming soon, bringing Windows support back fully gated.
|
> A stable release is coming soon, bringing Windows support back fully gated.
|
||||||
|
|
|
||||||
|
|
@ -367,11 +367,16 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var onDesktopHealth func(version string, windowOpen bool)
|
||||||
|
if ctx.DesktopManager != nil {
|
||||||
|
onDesktopHealth = ctx.DesktopManager.RecordHealth
|
||||||
|
}
|
||||||
localAPIServer, err := localapi.Start(localapi.Options{
|
localAPIServer, err := localapi.Start(localapi.Options{
|
||||||
Config: ctx.Cfg,
|
Config: ctx.Cfg,
|
||||||
DesktopProvider: ctx.DesktopManager,
|
DesktopProvider: ctx.DesktopManager,
|
||||||
TriggerScan: triggerScan,
|
TriggerScan: triggerScan,
|
||||||
ApproveUpdate: approveUpdate,
|
ApproveUpdate: approveUpdate,
|
||||||
|
OnDesktopHealth: onDesktopHealth,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.TeeLogger.Error("agent", "localapi", "localapi", fmt.Sprintf("start_failed error=%v", err), map[string]interface{}{"error": err.Error()})
|
ctx.TeeLogger.Error("agent", "localapi", "localapi", fmt.Sprintf("start_failed error=%v", err), map[string]interface{}{"error": err.Error()})
|
||||||
|
|
@ -435,7 +440,7 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
||||||
|
|
||||||
// Check for system info update
|
// Check for system info update
|
||||||
if time.Since(lastSystemInfoUpdate) >= 1*time.Hour {
|
if time.Since(lastSystemInfoUpdate) >= 1*time.Hour {
|
||||||
if err := reportSystemInfo(ctx.APIClient, ctx.Cfg); err != nil {
|
if err := reportSystemInfo(ctx.APIClient, ctx.Cfg, ctx.DesktopManager); err != nil {
|
||||||
ctx.TeeLogger.Warning("agent", "system", "system_info", fmt.Sprintf("report_system_info_failed error=%v", err), map[string]interface{}{"error": err.Error()})
|
ctx.TeeLogger.Warning("agent", "system", "system_info", fmt.Sprintf("report_system_info_failed error=%v", err), map[string]interface{}{"error": err.Error()})
|
||||||
} else {
|
} else {
|
||||||
lastSystemInfoUpdate = time.Now()
|
lastSystemInfoUpdate = time.Now()
|
||||||
|
|
@ -1081,7 +1086,7 @@ func calculateBackoff(attempt int, base, maxDelay time.Duration) time.Duration {
|
||||||
}
|
}
|
||||||
|
|
||||||
// reportSystemInfo reports detailed system information to server
|
// reportSystemInfo reports detailed system information to server
|
||||||
func reportSystemInfo(apiClient *client.Client, cfg *config.Config) error {
|
func reportSystemInfo(apiClient *client.Client, cfg *config.Config, desktopMgr *desktop.Manager) error {
|
||||||
sysInfo, err := system.GetSystemInfo(version.Version)
|
sysInfo, err := system.GetSystemInfo(version.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -1096,6 +1101,9 @@ func reportSystemInfo(apiClient *client.Client, cfg *config.Config) error {
|
||||||
IPAddress: sysInfo.IPAddress,
|
IPAddress: sysInfo.IPAddress,
|
||||||
Processes: sysInfo.RunningProcesses,
|
Processes: sysInfo.RunningProcesses,
|
||||||
Uptime: sysInfo.Uptime,
|
Uptime: sysInfo.Uptime,
|
||||||
|
DeviceType: sysInfo.DeviceType,
|
||||||
|
DeviceModel: sysInfo.DeviceModel,
|
||||||
|
OSDistro: sysInfo.OSDistro,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sysInfo.DiskInfo) > 0 {
|
if len(sysInfo.DiskInfo) > 0 {
|
||||||
|
|
@ -1126,5 +1134,17 @@ func reportSystemInfo(apiClient *client.Client, cfg *config.Config) error {
|
||||||
report.Metadata["top_processes"] = procs
|
report.Metadata["top_processes"] = procs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Desktop component state (UPDATE-002/INSTALL-004): installed/running/
|
||||||
|
// version, sourced from the tray's own health reports since the Linux tray
|
||||||
|
// is autostart-launched, not agent-spawned. Merges under
|
||||||
|
// agent.metadata["desktop"] — the backend for a fleet "components
|
||||||
|
// installed" indicator.
|
||||||
|
if desktopMgr != nil {
|
||||||
|
if report.Metadata == nil {
|
||||||
|
report.Metadata = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
report.Metadata["desktop"] = desktopMgr.Health()
|
||||||
|
}
|
||||||
|
|
||||||
return apiClient.ReportSystemInfo(cfg.AgentID, report)
|
return apiClient.ReportSystemInfo(cfg.AgentID, report)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,9 @@ type RegisterRequest struct {
|
||||||
PublicKeyFingerprint string `json:"public_key_fingerprint"`
|
PublicKeyFingerprint string `json:"public_key_fingerprint"`
|
||||||
Metadata map[string]string `json:"metadata"`
|
Metadata map[string]string `json:"metadata"`
|
||||||
AvailableScanners []string `json:"available_scanners"` // Platform-specific package managers (apt, dnf, winget, windows)
|
AvailableScanners []string `json:"available_scanners"` // Platform-specific package managers (apt, dnf, winget, windows)
|
||||||
|
DeviceType string `json:"device_type"` // Auto-detected form factor (DEVICE-001)
|
||||||
|
DeviceModel string `json:"device_model"` // Hardware model string
|
||||||
|
OSDistro string `json:"os_distro"` // Distro ID from /etc/os-release
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterResponse is returned after successful registration
|
// RegisterResponse is returned after successful registration
|
||||||
|
|
@ -1069,6 +1072,9 @@ type SystemInfoReport struct {
|
||||||
IPAddress string `json:"ip_address,omitempty"`
|
IPAddress string `json:"ip_address,omitempty"`
|
||||||
Processes int `json:"processes,omitempty"`
|
Processes int `json:"processes,omitempty"`
|
||||||
Uptime string `json:"uptime,omitempty"`
|
Uptime string `json:"uptime,omitempty"`
|
||||||
|
DeviceType string `json:"device_type,omitempty"` // Re-reported so reinstalls reclassify (DEVICE-001)
|
||||||
|
DeviceModel string `json:"device_model,omitempty"`
|
||||||
|
OSDistro string `json:"os_distro,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,34 @@ type Manager struct {
|
||||||
maxRestarts int
|
maxRestarts int
|
||||||
restartDelay time.Duration
|
restartDelay time.Duration
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
stopped bool
|
stopped bool
|
||||||
|
lastHealth healthReport
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthReport is the tray's most recent POST /v1/desktop self-report. On
|
||||||
|
// Linux the tray is launched by XDG autostart, not by this manager, so the
|
||||||
|
// spawned-process state is always empty there — the health report is the only
|
||||||
|
// liveness and version signal the agent has.
|
||||||
|
type healthReport struct {
|
||||||
|
version string
|
||||||
|
windowOpen bool
|
||||||
|
reportedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthFreshness is how long a health report counts as proof of a live tray.
|
||||||
|
// The tray reports every 30s; three missed beats means it is gone.
|
||||||
|
const healthFreshness = 90 * time.Second
|
||||||
|
|
||||||
|
// HealthSnapshot is the fleet-reportable desktop component state.
|
||||||
|
type HealthSnapshot struct {
|
||||||
|
Installed bool `json:"installed"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
WindowOpen bool `json:"window_open,omitempty"`
|
||||||
|
LastReport string `json:"last_report,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a desktop manager. binPath is the path to the redflag-desktop
|
// NewManager creates a desktop manager. binPath is the path to the redflag-desktop
|
||||||
|
|
@ -176,3 +200,41 @@ func (m *Manager) Status() (running bool, pid int) {
|
||||||
|
|
||||||
return true, m.cmd.Process.Pid
|
return true, m.cmd.Process.Pid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordHealth stores the tray's self-report (POST /v1/desktop via localapi).
|
||||||
|
func (m *Manager) RecordHealth(version string, windowOpen bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.lastHealth = healthReport{
|
||||||
|
version: version,
|
||||||
|
windowOpen: windowOpen,
|
||||||
|
reportedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health returns the desktop component state for fleet reporting. Running is
|
||||||
|
// true when this manager spawned a live process (Windows) or when a health
|
||||||
|
// report landed within the freshness window (Linux autostart path).
|
||||||
|
func (m *Manager) Health() HealthSnapshot {
|
||||||
|
running, _ := m.Status()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
last := m.lastHealth
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
snap := HealthSnapshot{Running: running}
|
||||||
|
if m.binPath != "" {
|
||||||
|
if info, err := os.Stat(m.binPath); err == nil && !info.IsDir() {
|
||||||
|
snap.Installed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !last.reportedAt.IsZero() {
|
||||||
|
snap.Version = last.version
|
||||||
|
snap.WindowOpen = last.windowOpen
|
||||||
|
snap.LastReport = last.reportedAt.Format(time.RFC3339)
|
||||||
|
if time.Since(last.reportedAt) <= healthFreshness {
|
||||||
|
snap.Running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,10 @@ type Options struct {
|
||||||
// Nil disables the endpoint (503). Wrap errors in ErrApprovalConflict /
|
// Nil disables the endpoint (503). Wrap errors in ErrApprovalConflict /
|
||||||
// ErrApprovalUnavailable to control the HTTP status.
|
// ErrApprovalUnavailable to control the HTTP status.
|
||||||
ApproveUpdate func(body []byte) (interface{}, error)
|
ApproveUpdate func(body []byte) (interface{}, error)
|
||||||
|
// OnDesktopHealth receives each tray self-report (POST /v1/desktop) so the
|
||||||
|
// agent can track tray liveness/version — on Linux the tray is autostart-
|
||||||
|
// launched and this is the only signal. Nil means reports are logged only.
|
||||||
|
OnDesktopHealth func(version string, windowOpen bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server owns the local API listener and HTTP server.
|
// Server owns the local API listener and HTTP server.
|
||||||
|
|
@ -125,11 +129,12 @@ func (s *Server) Stop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
type handler struct {
|
type handler struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
loadCache func() (*cache.LocalCache, error)
|
loadCache func() (*cache.LocalCache, error)
|
||||||
desktop DesktopStatusProvider
|
desktop DesktopStatusProvider
|
||||||
triggerScan func(source string) error
|
triggerScan func(source string) error
|
||||||
approveUpdate func(body []byte) (interface{}, error)
|
approveUpdate func(body []byte) (interface{}, error)
|
||||||
|
onDesktopHealth func(version string, windowOpen bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DesktopStatusProvider allows the desktop manager to report its status.
|
// DesktopStatusProvider allows the desktop manager to report its status.
|
||||||
|
|
@ -191,11 +196,12 @@ type TokenResponse struct {
|
||||||
|
|
||||||
func newHandler(opts Options) http.Handler {
|
func newHandler(opts Options) http.Handler {
|
||||||
h := &handler{
|
h := &handler{
|
||||||
cfg: opts.Config,
|
cfg: opts.Config,
|
||||||
loadCache: opts.LoadCache,
|
loadCache: opts.LoadCache,
|
||||||
desktop: opts.DesktopProvider,
|
desktop: opts.DesktopProvider,
|
||||||
triggerScan: opts.TriggerScan,
|
triggerScan: opts.TriggerScan,
|
||||||
approveUpdate: opts.ApproveUpdate,
|
approveUpdate: opts.ApproveUpdate,
|
||||||
|
onDesktopHealth: opts.OnDesktopHealth,
|
||||||
}
|
}
|
||||||
if h.loadCache == nil {
|
if h.loadCache == nil {
|
||||||
h.loadCache = cache.Load
|
h.loadCache = cache.Load
|
||||||
|
|
@ -317,6 +323,10 @@ func (h *handler) desktopHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
log.Printf("[INFO] [agent] [localapi] desktop_health version=%s uptime=%ds window_open=%v",
|
log.Printf("[INFO] [agent] [localapi] desktop_health version=%s uptime=%ds window_open=%v",
|
||||||
req.Version, req.Uptime, req.WindowOpen)
|
req.Version, req.Uptime, req.WindowOpen)
|
||||||
|
|
||||||
|
if h.onDesktopHealth != nil {
|
||||||
|
h.onDesktopHealth(req.Version, req.WindowOpen)
|
||||||
|
}
|
||||||
|
|
||||||
// Respond with agent status so the desktop app can display it.
|
// Respond with agent status so the desktop app can display it.
|
||||||
desktopStatus := DesktopStatus{Enabled: h.cfg.Desktop.Enabled}
|
desktopStatus := DesktopStatus{Enabled: h.cfg.Desktop.Enabled}
|
||||||
if h.desktop != nil {
|
if h.desktop != nil {
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ func RegisterAgent(cfg *config.Config, serverURL string) error {
|
||||||
PublicKeyFingerprint: publicKeyFingerprint,
|
PublicKeyFingerprint: publicKeyFingerprint,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
AvailableScanners: availableScanners,
|
AvailableScanners: availableScanners,
|
||||||
|
DeviceType: sysInfo.DeviceType,
|
||||||
|
DeviceModel: sysInfo.DeviceModel,
|
||||||
|
OSDistro: sysInfo.OSDistro,
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := apiClient.Register(req)
|
resp, err := apiClient.Register(req)
|
||||||
|
|
|
||||||
166
agent/internal/system/device.go
Normal file
166
agent/internal/system/device.go
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
package system
|
||||||
|
|
||||||
|
// device.go — DEVICE-001: device form-factor detection.
|
||||||
|
//
|
||||||
|
// Classifies the host as server / desktop / phone / tablet from hardware
|
||||||
|
// signals. The agent runs as a systemd service, so session environment
|
||||||
|
// (DISPLAY, WAYLAND_DISPLAY) is useless here — every signal is read from
|
||||||
|
// /sys and /proc. The server stores this as device_type; the operator can
|
||||||
|
// override it (device_type_manual), so misclassification is recoverable.
|
||||||
|
//
|
||||||
|
// Classification matrix (DEVICE-001):
|
||||||
|
//
|
||||||
|
// battery=no display=no → server
|
||||||
|
// battery=no display=yes → desktop
|
||||||
|
// battery=yes display=no → phone
|
||||||
|
// battery=yes display=yes → phone or tablet by screen size
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// phoneMaxMinDimension is the phone/tablet split on the framebuffer's smaller
|
||||||
|
// dimension in pixels. Phones run 720–1440 on the short edge; tablets start
|
||||||
|
// around 1600. The task's raw width<1024 test misfires on any modern phone
|
||||||
|
// (Pixel 3 is 1080 wide in portrait), so we compare the minimum dimension.
|
||||||
|
const phoneMaxMinDimension = 1440
|
||||||
|
|
||||||
|
// DetectDeviceType classifies this host's form factor. Only Linux exposes the
|
||||||
|
// signals we read; other platforms return "" and the server applies its
|
||||||
|
// conservative 'server' default.
|
||||||
|
func DetectDeviceType() string {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return detectDeviceTypeFrom("/sys/class/power_supply", "/sys/class/drm", "/sys/class/graphics/fb0/virtual_size")
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectDeviceTypeFrom(powerSupplyDir, drmDir, fbSizePath string) string {
|
||||||
|
battery := hasSystemBattery(powerSupplyDir)
|
||||||
|
display := hasDisplay(drmDir, fbSizePath)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !battery && !display:
|
||||||
|
return "server"
|
||||||
|
case !battery && display:
|
||||||
|
return "desktop"
|
||||||
|
case battery && !display:
|
||||||
|
return "phone"
|
||||||
|
}
|
||||||
|
|
||||||
|
// battery + display: split phone/tablet on screen size
|
||||||
|
if w, h := framebufferSize(fbSizePath); w > 0 && h > 0 {
|
||||||
|
if min(w, h) < phoneMaxMinDimension {
|
||||||
|
return "phone"
|
||||||
|
}
|
||||||
|
return "tablet"
|
||||||
|
}
|
||||||
|
return "phone"
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasSystemBattery scans /sys/class/power_supply for a system battery.
|
||||||
|
// Peripheral batteries (bluetooth mice, keyboards) advertise scope=Device
|
||||||
|
// and must not classify a desktop as mobile; UPS units report type=UPS.
|
||||||
|
func hasSystemBattery(dir string) bool {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
typ, err := os.ReadFile(filepath.Join(dir, e.Name(), "type"))
|
||||||
|
if err != nil || strings.TrimSpace(string(typ)) != "Battery" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if scope, err := os.ReadFile(filepath.Join(dir, e.Name(), "scope")); err == nil {
|
||||||
|
if strings.TrimSpace(string(scope)) == "Device" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasDisplay reports whether a display is attached: any DRM connector in
|
||||||
|
// state "connected", falling back to a present framebuffer.
|
||||||
|
func hasDisplay(drmDir, fbSizePath string) bool {
|
||||||
|
if entries, err := os.ReadDir(drmDir); err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
status, err := os.ReadFile(filepath.Join(drmDir, e.Name(), "status"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(status)) == "connected" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(fbSizePath); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// framebufferSize parses /sys/class/graphics/fb0/virtual_size ("1080,2160").
|
||||||
|
func framebufferSize(path string) (int, int) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(string(data)), ",", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
w, errW := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
|
h, errH := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||||
|
if errW != nil || errH != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
return w, h
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadDeviceModel returns the hardware model: device-tree on ARM, DMI on x86.
|
||||||
|
func ReadDeviceModel() string {
|
||||||
|
return readDeviceModelFrom("/proc/device-tree/model", "/sys/class/dmi/id/product_name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDeviceModelFrom(dtPath, dmiPath string) string {
|
||||||
|
if model, err := os.ReadFile(dtPath); err == nil {
|
||||||
|
// device-tree strings are NUL-terminated
|
||||||
|
if s := strings.TrimSpace(strings.Trim(string(model), "\x00")); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model, err := os.ReadFile(dmiPath); err == nil {
|
||||||
|
if s := strings.TrimSpace(string(model)); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectOSDistro returns the distro ID from /etc/os-release ("arch",
|
||||||
|
// "fedora", "debian"). Empty on non-Linux or when unreadable.
|
||||||
|
func DetectOSDistro() string {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return osDistroFrom("/etc/os-release")
|
||||||
|
}
|
||||||
|
|
||||||
|
func osDistroFrom(path string) string {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.HasPrefix(line, "ID=") {
|
||||||
|
return strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
174
agent/internal/system/device_test.go
Normal file
174
agent/internal/system/device_test.go
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
package system
|
||||||
|
|
||||||
|
// device_test.go — DEVICE-001: form-factor detection against mocked /sys trees.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeSysTree builds a mock power_supply dir, drm dir, and fb virtual_size file.
|
||||||
|
type fakeSysTree struct {
|
||||||
|
powerDir string
|
||||||
|
drmDir string
|
||||||
|
fbSize string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeSysTree(t *testing.T) *fakeSysTree {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
tree := &fakeSysTree{
|
||||||
|
powerDir: filepath.Join(root, "power_supply"),
|
||||||
|
drmDir: filepath.Join(root, "drm"),
|
||||||
|
fbSize: filepath.Join(root, "fb0_virtual_size"),
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(tree.powerDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(tree.drmDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSysTree) addPowerSupply(t *testing.T, name, typ, scope string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(f.powerDir, name)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "type"), []byte(typ+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if scope != "" {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "scope"), []byte(scope+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSysTree) addConnector(t *testing.T, name, status string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(f.drmDir, name)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "status"), []byte(status+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSysTree) setFramebuffer(t *testing.T, size string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(f.fbSize, []byte(size+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSysTree) detect() string {
|
||||||
|
return detectDeviceTypeFrom(f.powerDir, f.drmDir, f.fbSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectServer(t *testing.T) {
|
||||||
|
tree := newFakeSysTree(t)
|
||||||
|
tree.addPowerSupply(t, "AC", "Mains", "")
|
||||||
|
tree.addConnector(t, "card0-VGA-1", "disconnected")
|
||||||
|
|
||||||
|
if got := tree.detect(); got != "server" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] no battery, no display: got %q, want server", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectDesktop(t *testing.T) {
|
||||||
|
tree := newFakeSysTree(t)
|
||||||
|
tree.addConnector(t, "card0-HDMI-A-1", "connected")
|
||||||
|
|
||||||
|
if got := tree.detect(); got != "desktop" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] display, no battery: got %q, want desktop", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPhonePixel3(t *testing.T) {
|
||||||
|
// Pixel 3 blueline: system battery, panel connected, 1080x2160 portrait fb
|
||||||
|
tree := newFakeSysTree(t)
|
||||||
|
tree.addPowerSupply(t, "battery", "Battery", "System")
|
||||||
|
tree.addConnector(t, "card0-DSI-1", "connected")
|
||||||
|
tree.setFramebuffer(t, "1080,2160")
|
||||||
|
|
||||||
|
if got := tree.detect(); got != "phone" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] Pixel 3 signals: got %q, want phone", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectTablet(t *testing.T) {
|
||||||
|
tree := newFakeSysTree(t)
|
||||||
|
tree.addPowerSupply(t, "battery", "Battery", "")
|
||||||
|
tree.addConnector(t, "card0-DSI-1", "connected")
|
||||||
|
tree.setFramebuffer(t, "2560,1600")
|
||||||
|
|
||||||
|
if got := tree.detect(); got != "tablet" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] large battery-backed display: got %q, want tablet", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectBatteryNoDisplayIsPhone(t *testing.T) {
|
||||||
|
tree := newFakeSysTree(t)
|
||||||
|
tree.addPowerSupply(t, "bms", "Battery", "")
|
||||||
|
|
||||||
|
if got := tree.detect(); got != "phone" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] battery, no display: got %q, want phone", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeripheralBatteryDoesNotMakeDesktopMobile(t *testing.T) {
|
||||||
|
// Bluetooth mouse battery has scope=Device; desktop must stay desktop
|
||||||
|
tree := newFakeSysTree(t)
|
||||||
|
tree.addPowerSupply(t, "hid-aa:bb-battery", "Battery", "Device")
|
||||||
|
tree.addConnector(t, "card0-DP-1", "connected")
|
||||||
|
|
||||||
|
if got := tree.detect(); got != "desktop" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] peripheral battery misclassified: got %q, want desktop", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUPSDoesNotMakeServerMobile(t *testing.T) {
|
||||||
|
tree := newFakeSysTree(t)
|
||||||
|
tree.addPowerSupply(t, "ups", "UPS", "")
|
||||||
|
|
||||||
|
if got := tree.detect(); got != "server" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] UPS misclassified: got %q, want server", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadDeviceModelPrefersDeviceTree(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dt := filepath.Join(dir, "model")
|
||||||
|
dmi := filepath.Join(dir, "product_name")
|
||||||
|
if err := os.WriteFile(dt, []byte("Google Pixel 3\x00"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dmi, []byte("Precision 5820\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := readDeviceModelFrom(dt, dmi); got != "Google Pixel 3" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] got %q, want device-tree model", got)
|
||||||
|
}
|
||||||
|
if got := readDeviceModelFrom(filepath.Join(dir, "missing"), dmi); got != "Precision 5820" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] got %q, want DMI fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOSDistroParsesID(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
osRelease := filepath.Join(dir, "os-release")
|
||||||
|
content := "NAME=\"Arch Linux ARM\"\nPRETTY_NAME=\"Arch Linux ARM\"\nID=archarm\nID_LIKE=arch\n"
|
||||||
|
if err := os.WriteFile(osRelease, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := osDistroFrom(osRelease); got != "archarm" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] got %q, want archarm", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,9 @@ type SystemInfo struct {
|
||||||
Uptime string `json:"uptime"`
|
Uptime string `json:"uptime"`
|
||||||
RebootRequired bool `json:"reboot_required"`
|
RebootRequired bool `json:"reboot_required"`
|
||||||
RebootReason string `json:"reboot_reason"`
|
RebootReason string `json:"reboot_reason"`
|
||||||
|
DeviceType string `json:"device_type"` // "server", "desktop", "phone", "tablet" (DEVICE-001)
|
||||||
|
DeviceModel string `json:"device_model"` // "Google Pixel 3", "Dell PowerEdge R740"
|
||||||
|
OSDistro string `json:"os_distro"` // /etc/os-release ID: "arch", "fedora", "debian"
|
||||||
Metadata map[string]string `json:"metadata"`
|
Metadata map[string]string `json:"metadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,6 +123,11 @@ func GetSystemInfo(agentVersion string) (*SystemInfo, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device classification (DEVICE-001)
|
||||||
|
info.DeviceType = DetectDeviceType()
|
||||||
|
info.DeviceModel = ReadDeviceModel()
|
||||||
|
info.OSDistro = DetectOSDistro()
|
||||||
|
|
||||||
// Check if system requires reboot
|
// Check if system requires reboot
|
||||||
rebootRequired, rebootReason := checkRebootRequired()
|
rebootRequired, rebootReason := checkRebootRequired()
|
||||||
info.RebootRequired = rebootRequired
|
info.RebootRequired = rebootRequired
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,18 @@ func getLinuxMachineID() (string, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ARM: device-tree model, combined with /etc/machine-id when present.
|
||||||
|
// ARM hardware has no DMI; device-tree model binds to the hardware and
|
||||||
|
// /etc/machine-id distinguishes OS installs (dual-boot gets distinct IDs).
|
||||||
|
if id := getDeviceTreeMachineID(); id != "" {
|
||||||
|
return hashMachineID(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ARM: SoC serial from /proc/cpuinfo (not all SoCs expose one)
|
||||||
|
if serial := getARMSerial(); serial != "" {
|
||||||
|
return hashMachineID(serial), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Try /etc/hostname as last resort
|
// Try /etc/hostname as last resort
|
||||||
if hostname, err := os.ReadFile("/etc/hostname"); err == nil {
|
if hostname, err := os.ReadFile("/etc/hostname"); err == nil {
|
||||||
hostnameStr := strings.TrimSpace(string(hostname))
|
hostnameStr := strings.TrimSpace(string(hostname))
|
||||||
|
|
@ -76,6 +88,58 @@ func getLinuxMachineID() (string, error) {
|
||||||
return generateGenericMachineID()
|
return generateGenericMachineID()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getDeviceTreeMachineID builds an ID source from /proc/device-tree/model
|
||||||
|
// plus /etc/machine-id. Returns "" when device-tree is absent (x86, containers).
|
||||||
|
func getDeviceTreeMachineID() string {
|
||||||
|
return deviceTreeMachineIDFrom("/proc/device-tree/model", "/etc/machine-id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceTreeMachineIDFrom(modelPath, machineIDPath string) string {
|
||||||
|
model, err := os.ReadFile(modelPath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// device-tree strings are NUL-terminated
|
||||||
|
modelStr := strings.TrimSpace(strings.Trim(string(model), "\x00"))
|
||||||
|
if modelStr == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if id, err := os.ReadFile(machineIDPath); err == nil {
|
||||||
|
if idStr := strings.TrimSpace(string(id)); idStr != "" {
|
||||||
|
return modelStr + ":" + idStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hardware-bound but not install-unique; still better than hostname
|
||||||
|
return modelStr + ":arm-device"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getARMSerial reads a SoC serial number from /proc/cpuinfo when exposed.
|
||||||
|
func getARMSerial() string {
|
||||||
|
return armSerialFrom("/proc/cpuinfo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func armSerialFrom(cpuinfoPath string) string {
|
||||||
|
data, err := os.ReadFile(cpuinfoPath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if !strings.HasPrefix(line, "Serial") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serial := strings.TrimSpace(parts[1])
|
||||||
|
// All-zero serials are placeholders, not identifiers
|
||||||
|
if serial != "" && strings.Trim(serial, "0") != "" {
|
||||||
|
return "arm-serial:" + serial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// getWindowsMachineID gets Windows machine ID.
|
// getWindowsMachineID gets Windows machine ID.
|
||||||
// Note: The primary machineid.ID() in GetMachineID() already tried the
|
// Note: The primary machineid.ID() in GetMachineID() already tried the
|
||||||
// registry key HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid.
|
// registry key HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid.
|
||||||
|
|
|
||||||
83
agent/internal/system/machine_id_arm_test.go
Normal file
83
agent/internal/system/machine_id_arm_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package system
|
||||||
|
|
||||||
|
// machine_id_arm_test.go — DEVICE-002: ARM machine ID fallback.
|
||||||
|
//
|
||||||
|
// ARM devices (phones, SBCs) have no /sys/class/dmi/id/product_uuid. The
|
||||||
|
// fallback chain gains two hardware-bound sources before the weak hostname
|
||||||
|
// fallback: device-tree model + /etc/machine-id, then /proc/cpuinfo Serial.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeTempFile(t *testing.T, dir, name, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceTreeMachineIDCombinesModelAndMachineID(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// device-tree model strings are NUL-terminated
|
||||||
|
model := writeTempFile(t, dir, "model", "Google Pixel 3\x00")
|
||||||
|
machineID := writeTempFile(t, dir, "machine-id", "abcdef0123456789abcdef0123456789\n")
|
||||||
|
|
||||||
|
got := deviceTreeMachineIDFrom(model, machineID)
|
||||||
|
want := "Google Pixel 3:abcdef0123456789abcdef0123456789"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceTreeMachineIDWithoutMachineIDIsHardwareBound(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
model := writeTempFile(t, dir, "model", "Raspberry Pi 4 Model B\x00")
|
||||||
|
|
||||||
|
got := deviceTreeMachineIDFrom(model, filepath.Join(dir, "missing"))
|
||||||
|
want := "Raspberry Pi 4 Model B:arm-device"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceTreeMachineIDAbsentOnX86(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if got := deviceTreeMachineIDFrom(filepath.Join(dir, "missing"), filepath.Join(dir, "machine-id")); got != "" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] expected empty on missing device-tree, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARMSerialParsed(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cpuinfo := writeTempFile(t, dir, "cpuinfo",
|
||||||
|
"processor\t: 0\nHardware\t: Qualcomm Technologies, Inc SDM845\nSerial\t\t: 0000000012345abc\n")
|
||||||
|
|
||||||
|
got := armSerialFrom(cpuinfo)
|
||||||
|
want := "arm-serial:0000000012345abc"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARMSerialRejectsAllZero(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cpuinfo := writeTempFile(t, dir, "cpuinfo", "Serial\t\t: 0000000000000000\n")
|
||||||
|
|
||||||
|
if got := armSerialFrom(cpuinfo); got != "" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] all-zero serial must be rejected, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARMSerialAbsent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cpuinfo := writeTempFile(t, dir, "cpuinfo", "processor\t: 0\nmodel name\t: Intel Xeon\n")
|
||||||
|
|
||||||
|
if got := armSerialFrom(cpuinfo); got != "" {
|
||||||
|
t.Errorf("[ERROR] [agent] [system] expected empty without Serial line, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
desktop/Cargo.lock
generated
2
desktop/Cargo.lock
generated
|
|
@ -2335,7 +2335,7 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redflag-desktop"
|
name = "redflag-desktop"
|
||||||
version = "0.2.8"
|
version = "0.2.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,38 @@ fn local_status() -> Result<LocalSnapshot, String> {
|
||||||
Ok(LocalSnapshot { identity, status })
|
Ok(LocalSnapshot { identity, status })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct UpdateItem {
|
||||||
|
package_type: String,
|
||||||
|
package_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
current_version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
available_version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
severity: String,
|
||||||
|
#[serde(default)]
|
||||||
|
cve_list: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
size_bytes: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
repository_source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct ScanSnapshot {
|
||||||
|
#[serde(default)]
|
||||||
|
last_scan_time: Option<String>,
|
||||||
|
update_count: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
updates: Vec<UpdateItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_updates() -> Result<ScanSnapshot, String> {
|
||||||
|
local_get_json("/v1/packages")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct TriggerScanResponse {
|
struct TriggerScanResponse {
|
||||||
accepted: bool,
|
accepted: bool,
|
||||||
|
|
@ -104,20 +136,49 @@ struct TriggerScanResponse {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn trigger_scan() -> Result<TriggerScanResponse, String> {
|
fn trigger_scan() -> Result<TriggerScanResponse, String> {
|
||||||
let body = local_post("/v1/actions/trigger-scan", b"")?;
|
// The agent answers 202 Accepted or 409 Conflict, both with a JSON body.
|
||||||
serde_json::from_str(&body).map_err(|err| format!("decode trigger-scan response: {err}"))
|
let response = local_post("/v1/actions/trigger-scan", b"")?;
|
||||||
|
match response.status {
|
||||||
|
202 | 409 => serde_json::from_str(&response.body)
|
||||||
|
.map_err(|err| format!("decode trigger-scan response: {err}")),
|
||||||
|
status => Err(format!("trigger-scan returned {status}: {}", response.body)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors the agent's handlers.LocalApproveRequest — the standalone approval
|
||||||
|
// submission. Operator defaults to the desktop session's user.
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct ApproveUpdateRequest {
|
struct ApproveUpdateRequest {
|
||||||
update_id: String,
|
package_type: String,
|
||||||
|
package_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
available_version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
operator: String,
|
||||||
|
#[serde(default)]
|
||||||
|
override_reason: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn approve_update(request: ApproveUpdateRequest) -> Result<String, String> {
|
fn approve_update(mut request: ApproveUpdateRequest) -> Result<serde_json::Value, String> {
|
||||||
|
if request.operator.is_empty() {
|
||||||
|
request.operator = std::env::var("USER")
|
||||||
|
.or_else(|_| std::env::var("USERNAME"))
|
||||||
|
.unwrap_or_else(|_| "desktop".to_string());
|
||||||
|
}
|
||||||
let body_bytes =
|
let body_bytes =
|
||||||
serde_json::to_vec(&request).map_err(|err| format!("encode approve-update request: {err}"))?;
|
serde_json::to_vec(&request).map_err(|err| format!("encode approve-update request: {err}"))?;
|
||||||
local_post("/v1/actions/approve-update", &body_bytes)
|
let response = local_post("/v1/actions/approve-update", &body_bytes)?;
|
||||||
|
if response.status == 200 {
|
||||||
|
return serde_json::from_str(&response.body)
|
||||||
|
.map_err(|err| format!("decode approve-update response: {err}"));
|
||||||
|
}
|
||||||
|
// 409/503/500 carry {"error": "..."} — surface the agent's own message.
|
||||||
|
let message = serde_json::from_str::<serde_json::Value>(&response.body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
|
||||||
|
.unwrap_or(response.body);
|
||||||
|
Err(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn local_get_json<T: for<'de> Deserialize<'de>>(path: &str) -> Result<T, String> {
|
fn local_get_json<T: for<'de> Deserialize<'de>>(path: &str) -> Result<T, String> {
|
||||||
|
|
@ -128,7 +189,15 @@ fn local_get_json<T: for<'de> Deserialize<'de>>(path: &str) -> Result<T, String>
|
||||||
fn local_post_json<T: Serialize, R: for<'de> Deserialize<'de>>(path: &str, body: &T) -> Result<R, String> {
|
fn local_post_json<T: Serialize, R: for<'de> Deserialize<'de>>(path: &str, body: &T) -> Result<R, String> {
|
||||||
let body_bytes = serde_json::to_vec(body).map_err(|err| format!("encode request: {err}"))?;
|
let body_bytes = serde_json::to_vec(body).map_err(|err| format!("encode request: {err}"))?;
|
||||||
let response = local_post(path, &body_bytes)?;
|
let response = local_post(path, &body_bytes)?;
|
||||||
serde_json::from_str(&response).map_err(|err| format!("decode response: {err}"))
|
if response.status != 200 {
|
||||||
|
return Err(format!("POST {path} returned {}: {}", response.status, response.body));
|
||||||
|
}
|
||||||
|
serde_json::from_str(&response.body).map_err(|err| format!("decode response: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocalHttpResponse {
|
||||||
|
status: u16,
|
||||||
|
body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn local_get(path: &str) -> Result<String, String> {
|
fn local_get(path: &str) -> Result<String, String> {
|
||||||
|
|
@ -161,7 +230,7 @@ fn local_get(path: &str) -> Result<String, String> {
|
||||||
Ok(body.to_string())
|
Ok(body.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn local_post(path: &str, body: &[u8]) -> Result<String, String> {
|
fn local_post(path: &str, body: &[u8]) -> Result<LocalHttpResponse, String> {
|
||||||
let mut stream = connect_local_api()?;
|
let mut stream = connect_local_api()?;
|
||||||
let request = format!(
|
let request = format!(
|
||||||
"POST {path} HTTP/1.1\r\nHost: redflag.local\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccept: application/json\r\nConnection: close\r\n\r\n",
|
"POST {path} HTTP/1.1\r\nHost: redflag.local\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccept: application/json\r\nConnection: close\r\n\r\n",
|
||||||
|
|
@ -185,14 +254,19 @@ fn local_post(path: &str, body: &[u8]) -> Result<String, String> {
|
||||||
let (head, body) = response
|
let (head, body) = response
|
||||||
.split_once("\r\n\r\n")
|
.split_once("\r\n\r\n")
|
||||||
.ok_or_else(|| "POST response missing HTTP headers".to_string())?;
|
.ok_or_else(|| "POST response missing HTTP headers".to_string())?;
|
||||||
let status = head
|
let status_line = head
|
||||||
.lines()
|
.lines()
|
||||||
.next()
|
.next()
|
||||||
.ok_or_else(|| "POST response missing status line".to_string())?;
|
.ok_or_else(|| "POST response missing status line".to_string())?;
|
||||||
if !status.contains(" 200 ") {
|
let status: u16 = status_line
|
||||||
return Err(format!("POST {path} returned {status}"));
|
.split_whitespace()
|
||||||
}
|
.nth(1)
|
||||||
Ok(body.to_string())
|
.and_then(|code| code.parse().ok())
|
||||||
|
.ok_or_else(|| format!("POST response malformed status line: {status_line}"))?;
|
||||||
|
Ok(LocalHttpResponse {
|
||||||
|
status,
|
||||||
|
body: body.to_string(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|
@ -292,7 +366,12 @@ fn main() {
|
||||||
let window_open = Arc::new(AtomicBool::new(true));
|
let window_open = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![local_status, trigger_scan, approve_update])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
local_status,
|
||||||
|
list_updates,
|
||||||
|
trigger_scan,
|
||||||
|
approve_update
|
||||||
|
])
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let show_i = MenuItem::with_id(app, "show", "Show RedFlag", true, None::<&str>)?;
|
let show_i = MenuItem::with_id(app, "show", "Show RedFlag", true, None::<&str>)?;
|
||||||
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@
|
||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "RedFlag Local Agent",
|
"title": "RedFlag Local Agent",
|
||||||
"width": 720,
|
"width": 460,
|
||||||
"height": 760,
|
"height": 640,
|
||||||
"minWidth": 420,
|
"minWidth": 400,
|
||||||
"minHeight": 560,
|
"minHeight": 520,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"visible": true
|
"visible": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ services:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./server/Dockerfile
|
dockerfile: ./server/Dockerfile
|
||||||
args:
|
args:
|
||||||
BUILD_VERSION: ${BUILD_VERSION:-0.2.9.1}
|
BUILD_VERSION: ${BUILD_VERSION:-0.2.9.3}
|
||||||
container_name: redflag-server
|
container_name: redflag-server
|
||||||
volumes:
|
volumes:
|
||||||
- server-config:/app/config
|
- server-config:/app/config
|
||||||
|
|
|
||||||
|
|
@ -887,9 +887,60 @@ fn install_desktop_binary(staged: &str) -> Result<(), Denial> {
|
||||||
atomic_replace_binary(staged, &install, "desktop_self_install")?;
|
atomic_replace_binary(staged, &install, "desktop_self_install")?;
|
||||||
let _ = fs::remove_file(staged);
|
let _ = fs::remove_file(staged);
|
||||||
log_security(&format!("desktop_self_updated path={}", install));
|
log_security(&format!("desktop_self_updated path={}", install));
|
||||||
|
|
||||||
|
// A desktop-self token onto a pre-tray host delivers a binary that nothing
|
||||||
|
// launches — heal the session-start provisioning the token install can't
|
||||||
|
// carry (UPDATE-002 gap 2), same reconciliation posture as the agent unit
|
||||||
|
// drop-in.
|
||||||
|
reconcile_desktop_autostart(&install);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconcile_desktop_autostart creates the XDG autostart entry when it is
|
||||||
|
// missing, so hosts that received the tray via desktop-self token (rather than
|
||||||
|
// the installer) launch it at session login. Create-if-absent only: an existing
|
||||||
|
// file is never rewritten, whether installer-owned or operator-edited — and
|
||||||
|
// deliberate removal is how an operator opts a session out today, which the
|
||||||
|
// INSTALL-004 opt-out will formalize by gating the desktop-self mint
|
||||||
|
// server-side. Group membership is not healed here: the helper has no session
|
||||||
|
// user to bind, and the tray's own socket-permission diagnostic tells the user
|
||||||
|
// the exact usermod to run. Non-fatal on failure — the binary install already
|
||||||
|
// succeeded and is journaled.
|
||||||
|
fn reconcile_desktop_autostart(binary_path: &str) {
|
||||||
|
const AUTOSTART_DIR: &str = "/etc/xdg/autostart";
|
||||||
|
const AUTOSTART_PATH: &str = "/etc/xdg/autostart/redflag-desktop.desktop";
|
||||||
|
|
||||||
|
if Path::new(AUTOSTART_PATH).exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = format!(
|
||||||
|
"[Desktop Entry]\n\
|
||||||
|
Type=Application\n\
|
||||||
|
Name=RedFlag\n\
|
||||||
|
Comment=RedFlag system tray (local agent dashboard)\n\
|
||||||
|
Exec={}\n\
|
||||||
|
Terminal=false\n\
|
||||||
|
X-GNOME-Autostart-enabled=true\n",
|
||||||
|
binary_path
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = fs::create_dir_all(AUTOSTART_DIR) {
|
||||||
|
log_security(&format!("desktop_autostart_dir_failed path={} err={}", AUTOSTART_DIR, e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = fs::write(AUTOSTART_PATH, content) {
|
||||||
|
log_security(&format!("desktop_autostart_write_failed path={} err={}", AUTOSTART_PATH, e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// World-readable like every other /etc/xdg/autostart entry.
|
||||||
|
if let Err(e) = fs::set_permissions(AUTOSTART_PATH, fs::Permissions::from_mode(0o644)) {
|
||||||
|
log_security(&format!("desktop_autostart_chmod_failed path={} err={}", AUTOSTART_PATH, e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log_security(&format!("desktop_autostart_reconciled path={}", AUTOSTART_PATH));
|
||||||
|
}
|
||||||
|
|
||||||
fn run(token_file: Option<&str>, helper_file: Option<&str>) -> Result<PolicyResult, Box<(Option<CapabilityToken>, Denial)>> {
|
fn run(token_file: Option<&str>, helper_file: Option<&str>) -> Result<PolicyResult, Box<(Option<CapabilityToken>, Denial)>> {
|
||||||
let token = match token_file {
|
let token = match token_file {
|
||||||
Some(path) => read_token_from_file(path).map_err(|d| Box::new((None, d))),
|
Some(path) => read_token_from_file(path).map_err(|d| Box::new((None, d))),
|
||||||
|
|
|
||||||
152
installer/windows/Product.wxs
Normal file
152
installer/windows/Product.wxs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
RedFlagSetup.msi — native Windows installer for the RedFlag server (INSTALL-002).
|
||||||
|
|
||||||
|
Built with `wixl` (msitools), NOT the official WiX Toolset .NET CLI. The
|
||||||
|
official `wix build` genuinely does not work when the compiler itself runs
|
||||||
|
on Linux — reproduced empirically: even a single-character Directory/@Name
|
||||||
|
fails WIX0389 "not a relative path" on every WiX version tried (4.0.5,
|
||||||
|
4.0.6, 5.0.0, 5.0.2, 6.0.0, 6.0.1). The tool's own output says why:
|
||||||
|
"warning WIX0000: The WiX Toolset only supports Windows... All behavior
|
||||||
|
after this point is undefined." That's not a disclaimer to route around
|
||||||
|
with a workaround — the validator itself is broken cross-platform. wixl is
|
||||||
|
a from-scratch, Linux-native reimplementation of the (older, WiX v3-era)
|
||||||
|
MSI-building grammar, built for exactly this "generate a real Windows MSI
|
||||||
|
from Linux CI" case, and produces a structurally valid MSI (verified with
|
||||||
|
msiinfo tables / msiinfo suminfo) with no cross-platform path bugs. This
|
||||||
|
file therefore uses the WiX v3 schema/dialect (Product+Package as separate
|
||||||
|
elements, explicit Directory nesting, no StandardDirectory shorthand) —
|
||||||
|
wixl's grammar, not v4's.
|
||||||
|
|
||||||
|
Scope (v1): install the already-built, already-signed server binary as a
|
||||||
|
Windows service, with a forward-only upgrade guard. It does NOT bundle
|
||||||
|
PostgreSQL — docker-compose doesn't either (postgres:16-alpine is a
|
||||||
|
separate container); a native install is expected to point at a reachable
|
||||||
|
Postgres the same way the docker path does via config/.env. The installer's
|
||||||
|
job ends where the docker quick-start's does today: "service is running,
|
||||||
|
go to http://localhost:<port>/setup and finish configuration in the
|
||||||
|
browser" (README.md's existing docker flow, same UX, different transport).
|
||||||
|
|
||||||
|
Config: the Go binary only ever reads OS environment variables
|
||||||
|
(server/internal/config/config.go). Docker gets those from compose's
|
||||||
|
env_file:; a native install has no compose layer, so this installer drops
|
||||||
|
a template env file under ProgramData and the service reads it via
|
||||||
|
config.loadNativeConfigFile() — same manual "generate keys, paste into the
|
||||||
|
env file, restart" flow the docker README already documents, not a new UX.
|
||||||
|
|
||||||
|
NOT YET BUILT into this file: install-time manifest signature verification
|
||||||
|
(fetch the release manifest, verify Ed25519 signature, check the server
|
||||||
|
binary's SHA256 before executing) — the cold-start TOFU problem flagged in
|
||||||
|
docs/tasks/INSTALL-002-native-os-installers.md. This installer currently
|
||||||
|
trusts whatever binary CI hands it, same trust level as unzipping a release
|
||||||
|
tarball by hand. That verification layer is the next slice, not this one.
|
||||||
|
|
||||||
|
VERIFIED: built successfully with wixl 0.106 against a real cross-compiled
|
||||||
|
redflag-server-windows-amd64.exe on 2026-07-01 — msiinfo confirms a valid
|
||||||
|
MSI (Directory/Component/Feature/ServiceInstall/ServiceControl tables all
|
||||||
|
populated correctly). NOT yet verified: actually running the installer on
|
||||||
|
a real Windows host (install, service starts, uninstall, upgrade-over
|
||||||
|
itself). That's the next check, ideally on real hardware or a VM.
|
||||||
|
-->
|
||||||
|
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
UpgradeCode must never change for the life of this product — it's the
|
||||||
|
identity that makes the MajorUpgrade forward-only guard below mean
|
||||||
|
anything across releases. Generated once, fixed forever after.
|
||||||
|
-->
|
||||||
|
<Product Id="*"
|
||||||
|
Name="RedFlag Server"
|
||||||
|
Language="1033"
|
||||||
|
Version="$(var.RedFlagVersion)"
|
||||||
|
Manufacturer="Fimeg"
|
||||||
|
UpgradeCode="8f3c9e6a-4b1d-4a7f-9c2e-1d6a8b2f5e91">
|
||||||
|
|
||||||
|
<Package InstallerVersion="500" Compressed="yes" InstallScope="perMachine"
|
||||||
|
Description="RedFlag self-hosted update management — server install" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Forward-only, no downgrade — ETHOS doctrine elsewhere in this codebase
|
||||||
|
(refresh-token rotation, capability tokens) applied to the installer
|
||||||
|
itself. Windows Installer only compares the first three version
|
||||||
|
fields for upgrade detection — pass $(var.RedFlagVersion) as the
|
||||||
|
3-part form from CI, same 3-vs-4-part reconciliation bump-version.sh
|
||||||
|
already does for desktop/Cargo.toml vs tauri.conf.json.
|
||||||
|
-->
|
||||||
|
<MajorUpgrade DowngradeErrorMessage="A newer version of RedFlag Server is already installed. Forward-only upgrades only." />
|
||||||
|
|
||||||
|
<MediaTemplate />
|
||||||
|
|
||||||
|
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||||
|
<Directory Id="ProgramFiles64Folder">
|
||||||
|
<Directory Id="INSTALLFOLDER" Name="RedFlag">
|
||||||
|
<Component Id="ServerBinary" Guid="7a2d5e9c-3f81-4c6b-9e0a-2b7d4f8c1a63">
|
||||||
|
<File Id="RedFlagServerExe"
|
||||||
|
Source="dist/redflag-server-windows-amd64.exe"
|
||||||
|
Name="redflag-server.exe"
|
||||||
|
KeyPath="yes" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
LocalSystem for v1 — matches "get it working" over
|
||||||
|
least-privilege for now. The Linux side went through the same
|
||||||
|
arc (helper started as a plain child before the systemd-run
|
||||||
|
privilege split). Revisit alongside SEC-030 once a Windows
|
||||||
|
privileged-executor model exists to hand mutation off to
|
||||||
|
instead of running the whole server as SYSTEM.
|
||||||
|
-->
|
||||||
|
<ServiceInstall Id="RedFlagServerService"
|
||||||
|
Name="RedFlagServer"
|
||||||
|
DisplayName="RedFlag Server"
|
||||||
|
Description="RedFlag self-hosted update management server"
|
||||||
|
Type="ownProcess"
|
||||||
|
Start="auto"
|
||||||
|
ErrorControl="normal"
|
||||||
|
Account="LocalSystem" />
|
||||||
|
|
||||||
|
<ServiceControl Id="RedFlagServerServiceControl"
|
||||||
|
Name="RedFlagServer"
|
||||||
|
Start="install"
|
||||||
|
Stop="both"
|
||||||
|
Remove="uninstall"
|
||||||
|
Wait="yes" />
|
||||||
|
</Component>
|
||||||
|
</Directory>
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ProgramData, not Program Files: this holds the mutable,
|
||||||
|
operator-edited config (DB connection, admin creds, signing key
|
||||||
|
once generated) — it must survive upgrades/reinstalls, which
|
||||||
|
Program Files contents don't reliably. Only the .example template
|
||||||
|
ships here; the real redflag.env is created by the operator (or a
|
||||||
|
future first-run helper), never overwritten by this installer once
|
||||||
|
it exists.
|
||||||
|
-->
|
||||||
|
<Directory Id="CommonAppDataFolder">
|
||||||
|
<Directory Id="REDFLAGDATA" Name="RedFlag">
|
||||||
|
<Component Id="ConfigTemplate" Guid="1c6e8a2d-9b47-4f3e-a1c8-6d3e9b4a2f70">
|
||||||
|
<File Id="RedFlagEnvExample"
|
||||||
|
Source="config/redflag.env.example"
|
||||||
|
Name="redflag.env.example"
|
||||||
|
KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</Directory>
|
||||||
|
</Directory>
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<Feature Id="ServerFeature" Level="1">
|
||||||
|
<ComponentRef Id="ServerBinary" />
|
||||||
|
<ComponentRef Id="ConfigTemplate" />
|
||||||
|
</Feature>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Desktop tray feature slots in here later (Casey, 2026-06-30: "checkbox
|
||||||
|
for adding the desktop alongside it" plus future screen-capture work
|
||||||
|
from the session-broker RAF). Deliberately not stubbed as a disabled
|
||||||
|
checkbox — a checkbox that does nothing when checked is worse than no
|
||||||
|
checkbox. Wire it once the desktop/Tauri artifact has a real merge
|
||||||
|
path into this installer, per Casey's "keep it separate for now."
|
||||||
|
-->
|
||||||
|
|
||||||
|
</Product>
|
||||||
|
</Wix>
|
||||||
43
installer/windows/config/redflag.env.example
Normal file
43
installer/windows/config/redflag.env.example
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# ============================================================
|
||||||
|
# RedFlag Server — native Windows configuration
|
||||||
|
# Copy this file to: redflag.env (same folder), edit it, then
|
||||||
|
# restart the "RedFlag Server" service (services.msc or:
|
||||||
|
# net stop RedFlagServer && net start RedFlagServer
|
||||||
|
# ============================================================
|
||||||
|
# This is the native-install equivalent of config/.env.example
|
||||||
|
# in the main repo — same variables, same semantics. Docker gets
|
||||||
|
# these from docker-compose's env_file:; this binary reads this
|
||||||
|
# file directly (server/internal/config/config.go loadNativeConfigFile).
|
||||||
|
|
||||||
|
# --- PostgreSQL ---
|
||||||
|
# Point this at a Postgres instance you provide — same as the docker
|
||||||
|
# stack's postgres:16-alpine container, just not bundled by this installer.
|
||||||
|
REDFLAG_DB_HOST=localhost
|
||||||
|
REDFLAG_DB_PORT=5432
|
||||||
|
REDFLAG_DB_NAME=redflag
|
||||||
|
REDFLAG_DB_USER=redflag
|
||||||
|
REDFLAG_DB_PASSWORD=CHANGE_ME_pick_a_strong_db_password
|
||||||
|
|
||||||
|
# --- Server ---
|
||||||
|
REDFLAG_SERVER_HOST=0.0.0.0
|
||||||
|
REDFLAG_SERVER_PORT=8080
|
||||||
|
|
||||||
|
# --- Admin Account ---
|
||||||
|
# After first-run setup these are persisted in the database;
|
||||||
|
# these values re-apply on service restart.
|
||||||
|
REDFLAG_ADMIN_USER=admin
|
||||||
|
REDFLAG_ADMIN_EMAIL=admin@example.com
|
||||||
|
REDFLAG_ADMIN_PASSWORD=CHANGE_ME_pick_a_strong_admin_password
|
||||||
|
REDFLAG_JWT_SECRET=CHANGE_ME_random_string_at_least_32_characters_long
|
||||||
|
|
||||||
|
# --- Agent Registration ---
|
||||||
|
REDFLAG_TOKEN_EXPIRY=24h
|
||||||
|
REDFLAG_MAX_TOKENS=100
|
||||||
|
REDFLAG_MAX_SEATS=50
|
||||||
|
|
||||||
|
# --- Ed25519 Signing Key ---
|
||||||
|
# Leave blank on first run. Go to http://localhost:8080/setup and click
|
||||||
|
# "Generate Keys". Copy the private key here, then restart the service.
|
||||||
|
# Without this, agents cannot receive signed commands or upgrade themselves.
|
||||||
|
# BACKUP THE PRIVATE KEY. Losing it means re-enrolling every agent.
|
||||||
|
REDFLAG_SIGNING_PRIVATE_KEY=
|
||||||
|
|
@ -144,6 +144,16 @@ RUN cargo build --release && \
|
||||||
mkdir -p /out/helper-linux-amd64 && \
|
mkdir -p /out/helper-linux-amd64 && \
|
||||||
cp target/release/redflag-helper /out/helper-linux-amd64/redflag-helper
|
cp target/release/redflag-helper /out/helper-linux-amd64/redflag-helper
|
||||||
|
|
||||||
|
# Cross-build for arm64 (INSTALL-003: Pixel 3 / SBC fleet). The crate is pure
|
||||||
|
# Rust and the musl target ships self-contained crt objects, so rust-lld links
|
||||||
|
# it without an aarch64 C cross-toolchain. Static musl runs on glibc distros
|
||||||
|
# (Arch Linux ARM) the same as the amd64 helper does everywhere else.
|
||||||
|
RUN rustup target add aarch64-unknown-linux-musl && \
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=rust-lld \
|
||||||
|
cargo build --release --target aarch64-unknown-linux-musl && \
|
||||||
|
mkdir -p /out/helper-linux-arm64 && \
|
||||||
|
cp target/aarch64-unknown-linux-musl/release/redflag-helper /out/helper-linux-arm64/redflag-helper
|
||||||
|
|
||||||
# Stage 2c: Build the Tauri desktop app (system tray + local UI)
|
# Stage 2c: Build the Tauri desktop app (system tray + local UI)
|
||||||
FROM rust:1-bookworm AS desktop-builder
|
FROM rust:1-bookworm AS desktop-builder
|
||||||
|
|
||||||
|
|
@ -211,8 +221,9 @@ COPY --from=agent-builder /build/binaries ./binaries
|
||||||
|
|
||||||
# Copy the signed-at-startup capability-gate executor alongside the agent
|
# Copy the signed-at-startup capability-gate executor alongside the agent
|
||||||
# binaries so BuildOrchestrator.SignExistingBinary can find it at
|
# binaries so BuildOrchestrator.SignExistingBinary can find it at
|
||||||
# binaries/helper-linux-amd64/redflag-helper.
|
# binaries/helper-linux-<arch>/redflag-helper.
|
||||||
COPY --from=helper-builder /out/helper-linux-amd64 ./binaries/helper-linux-amd64
|
COPY --from=helper-builder /out/helper-linux-amd64 ./binaries/helper-linux-amd64
|
||||||
|
COPY --from=helper-builder /out/helper-linux-arm64 ./binaries/helper-linux-arm64
|
||||||
|
|
||||||
# Copy the Tauri desktop app (system tray + local UI shell)
|
# Copy the Tauri desktop app (system tray + local UI shell)
|
||||||
COPY --from=desktop-builder /out/redflag-desktop ./binaries/linux-amd64/redflag-desktop
|
COPY --from=desktop-builder /out/redflag-desktop ./binaries/linux-amd64/redflag-desktop
|
||||||
|
|
|
||||||
|
|
@ -299,7 +299,7 @@ func main() {
|
||||||
// signature, exactly like the agent. Stored under platform
|
// signature, exactly like the agent. Stored under platform
|
||||||
// "helper-linux". Missing binary (e.g. arm64 not cross-built) is logged
|
// "helper-linux". Missing binary (e.g. arm64 not cross-built) is logged
|
||||||
// and omitted — the manifest never lies about what it can attest.
|
// and omitted — the manifest never lies about what it can attest.
|
||||||
helperArches := []string{"amd64"}
|
helperArches := []string{"amd64", "arm64"}
|
||||||
for _, arch := range helperArches {
|
for _, arch := range helperArches {
|
||||||
helperPath := filepath.Join("/app", "binaries", "helper-linux-"+arch, "redflag-helper")
|
helperPath := filepath.Join("/app", "binaries", "helper-linux-"+arch, "redflag-helper")
|
||||||
if _, err := buildOrchestrator.SignExistingBinary(helperPath, version.AgentVersion, "helper-linux", arch); err != nil {
|
if _, err := buildOrchestrator.SignExistingBinary(helperPath, version.AgentVersion, "helper-linux", arch); err != nil {
|
||||||
|
|
@ -942,6 +942,9 @@ func main() {
|
||||||
// Machine ID Rebind (F-D1-2: recovery from machine ID mismatch)
|
// Machine ID Rebind (F-D1-2: recovery from machine ID mismatch)
|
||||||
admin.POST("/agents/:id/rebind-machine-id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentHandler.RebindMachineID)
|
admin.POST("/agents/:id/rebind-machine-id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentHandler.RebindMachineID)
|
||||||
|
|
||||||
|
// Device type override (SERVER-002: operator reclassification)
|
||||||
|
admin.PUT("/agents/:id/device-type", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentHandler.ReclassifyDeviceType)
|
||||||
|
|
||||||
// Signing key management — Ed25519 key rotation surface for the dashboard.
|
// Signing key management — Ed25519 key rotation surface for the dashboard.
|
||||||
// Operators can review every key the server has ever held and deprecate
|
// Operators can review every key the server has ever held and deprecate
|
||||||
// retired keys (the queries layer refuses to deprecate the current primary).
|
// retired keys (the queries layer refuses to deprecate the current primary).
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,21 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
||||||
pubKeyFP = &req.PublicKeyFingerprint
|
pubKeyFP = &req.PublicKeyFingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device classification (DEVICE-001). Unrecognized values degrade to the
|
||||||
|
// conservative default rather than failing registration on the CHECK constraint.
|
||||||
|
deviceType := req.DeviceType
|
||||||
|
if !models.ValidDeviceType(deviceType) {
|
||||||
|
deviceType = "server"
|
||||||
|
}
|
||||||
|
var deviceModel *string
|
||||||
|
if req.DeviceModel != "" {
|
||||||
|
deviceModel = &req.DeviceModel
|
||||||
|
}
|
||||||
|
var osDistro *string
|
||||||
|
if req.OSDistro != "" {
|
||||||
|
osDistro = &req.OSDistro
|
||||||
|
}
|
||||||
|
|
||||||
agent := &models.Agent{
|
agent := &models.Agent{
|
||||||
ID: uuid.Must(uuid.NewV4()),
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
Hostname: req.Hostname,
|
Hostname: req.Hostname,
|
||||||
|
|
@ -346,6 +361,9 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
||||||
CurrentVersion: req.AgentVersion,
|
CurrentVersion: req.AgentVersion,
|
||||||
MachineID: machineID,
|
MachineID: machineID,
|
||||||
PublicKeyFingerprint: pubKeyFP,
|
PublicKeyFingerprint: pubKeyFP,
|
||||||
|
DeviceType: deviceType,
|
||||||
|
DeviceModel: deviceModel,
|
||||||
|
OSDistro: osDistro,
|
||||||
LastSeen: time.Now().UTC(),
|
LastSeen: time.Now().UTC(),
|
||||||
Status: "online",
|
Status: "online",
|
||||||
Metadata: models.JSONB{},
|
Metadata: models.JSONB{},
|
||||||
|
|
@ -373,10 +391,12 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
||||||
INSERT INTO agents (
|
INSERT INTO agents (
|
||||||
id, hostname, os_type, os_version, os_architecture,
|
id, hostname, os_type, os_version, os_architecture,
|
||||||
agent_version, current_version, machine_id, public_key_fingerprint,
|
agent_version, current_version, machine_id, public_key_fingerprint,
|
||||||
|
device_type, device_model, os_distro,
|
||||||
last_seen, status, metadata
|
last_seen, status, metadata
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:id, :hostname, :os_type, :os_version, :os_architecture,
|
:id, :hostname, :os_type, :os_version, :os_architecture,
|
||||||
:agent_version, :current_version, :machine_id, :public_key_fingerprint,
|
:agent_version, :current_version, :machine_id, :public_key_fingerprint,
|
||||||
|
:device_type, :device_model, :os_distro,
|
||||||
:last_seen, :status, :metadata
|
:last_seen, :status, :metadata
|
||||||
)`
|
)`
|
||||||
if _, err := tx.NamedExec(createQuery, agent); err != nil {
|
if _, err := tx.NamedExec(createQuery, agent); err != nil {
|
||||||
|
|
@ -1513,6 +1533,60 @@ func (h *AgentHandler) RebindMachineID(c *gin.Context) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReclassifyDeviceType sets or clears the operator override for an agent's
|
||||||
|
// device type (SERVER-002). Auto-detection is heuristic; the operator has
|
||||||
|
// final say. null/empty device_type clears the override.
|
||||||
|
func (h *AgentHandler) ReclassifyDeviceType(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
agentID, err := uuid.FromString(idStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
DeviceType *string `json:"device_type"` // null clears the override
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var override *string
|
||||||
|
if req.DeviceType != nil && *req.DeviceType != "" {
|
||||||
|
if !models.ValidDeviceType(*req.DeviceType) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "device_type must be one of: server, desktop, phone, tablet (or null to clear)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
override = req.DeviceType
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, err := h.agentQueries.GetAgentByID(agentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldEffective := agent.EffectiveDeviceType()
|
||||||
|
|
||||||
|
if err := h.agentQueries.UpdateDeviceTypeManual(agentID, override); err != nil {
|
||||||
|
log.Printf("[ERROR] [server] [admin] device_reclassify_failed agent_id=%s error=%q", agentID, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update device type"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.DeviceTypeManual = override
|
||||||
|
adminUserID := c.GetString("user_id")
|
||||||
|
log.Printf("[INFO] [server] [admin] device_reclassified agent_id=%s from=%s to=%s admin_user=%s",
|
||||||
|
agentID, oldEffective, agent.EffectiveDeviceType(), adminUserID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": agent.ID,
|
||||||
|
"device_type": agent.DeviceType,
|
||||||
|
"device_type_manual": agent.DeviceTypeManual,
|
||||||
|
"effective_device_type": agent.EffectiveDeviceType(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// UnregisterAgent removes an agent from the system
|
// UnregisterAgent removes an agent from the system
|
||||||
func (h *AgentHandler) UnregisterAgent(c *gin.Context) {
|
func (h *AgentHandler) UnregisterAgent(c *gin.Context) {
|
||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
|
|
@ -1557,6 +1631,9 @@ func (h *AgentHandler) ReportSystemInfo(c *gin.Context) {
|
||||||
IPAddress string `json:"ip_address,omitempty"`
|
IPAddress string `json:"ip_address,omitempty"`
|
||||||
Processes int `json:"processes,omitempty"`
|
Processes int `json:"processes,omitempty"`
|
||||||
Uptime string `json:"uptime,omitempty"`
|
Uptime string `json:"uptime,omitempty"`
|
||||||
|
DeviceType string `json:"device_type,omitempty"`
|
||||||
|
DeviceModel string `json:"device_model,omitempty"`
|
||||||
|
OSDistro string `json:"os_distro,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1606,6 +1683,18 @@ func (h *AgentHandler) ReportSystemInfo(c *gin.Context) {
|
||||||
agent.Metadata["uptime"] = req.Uptime
|
agent.Metadata["uptime"] = req.Uptime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device classification (DEVICE-001): dedicated columns, not metadata.
|
||||||
|
// device_type_manual is operator-owned and never touched by agent reports.
|
||||||
|
if models.ValidDeviceType(req.DeviceType) {
|
||||||
|
agent.DeviceType = req.DeviceType
|
||||||
|
}
|
||||||
|
if req.DeviceModel != "" {
|
||||||
|
agent.DeviceModel = &req.DeviceModel
|
||||||
|
}
|
||||||
|
if req.OSDistro != "" {
|
||||||
|
agent.OSDistro = &req.OSDistro
|
||||||
|
}
|
||||||
|
|
||||||
// Store the timestamp when system info was last updated
|
// Store the timestamp when system info was last updated
|
||||||
agent.Metadata["system_info_updated_at"] = time.Now().UTC().Format(time.RFC3339)
|
agent.Metadata["system_info_updated_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,7 @@ func (h *DownloadHandler) buildReleaseManifest(version string) services.ReleaseM
|
||||||
{"windows", "amd64"},
|
{"windows", "amd64"},
|
||||||
{"windows", "arm64"},
|
{"windows", "arm64"},
|
||||||
{"helper-linux", "amd64"},
|
{"helper-linux", "amd64"},
|
||||||
|
{"helper-linux", "arm64"},
|
||||||
{"desktop-linux", "amd64"},
|
{"desktop-linux", "amd64"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,19 @@ import (
|
||||||
"github.com/gofrs/uuid/v5"
|
"github.com/gofrs/uuid/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxRegistrationTokenDuration is the hard ceiling on how long a registration
|
||||||
|
// token (a bearer credential for enrolling new agents) may live. Raised from
|
||||||
|
// the previous 168h (7d) cap 2026-06-30 — see
|
||||||
|
// docs/tasks/UI-REGISTRATION-ENROLLMENT-UNIFY.md. 90 days matches the
|
||||||
|
// existing precedent for a long-lived bound credential elsewhere in this
|
||||||
|
// system (refresh tokens — RAF/security/03-refresh-tokens.md) — same
|
||||||
|
// yardstick already in use, not a new risk category. True "never expires"
|
||||||
|
// was deliberately not added here: expires_at is NOT NULL and load-bearing
|
||||||
|
// in the active-token query (`expires_at > NOW()`), so making it optional is
|
||||||
|
// a schema change, not a UX pass. Blast radius still bounded by max_seats
|
||||||
|
// and by revocation being one click away in the UI.
|
||||||
|
const maxRegistrationTokenDuration = 2160 * time.Hour // 90 days
|
||||||
|
|
||||||
type RegistrationTokenHandler struct {
|
type RegistrationTokenHandler struct {
|
||||||
tokenQueries *queries.RegistrationTokenQueries
|
tokenQueries *queries.RegistrationTokenQueries
|
||||||
agentQueries *queries.AgentQueries
|
agentQueries *queries.AgentQueries
|
||||||
|
|
@ -78,8 +91,8 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
expiresAt := time.Now().Add(duration)
|
expiresAt := time.Now().Add(duration)
|
||||||
if duration > 168*time.Hour { // Max 7 days
|
if duration > maxRegistrationTokenDuration {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Token expiration cannot exceed 7 days"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Token expiration cannot exceed 90 days"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -377,7 +390,7 @@ func (h *RegistrationTokenHandler) GetTokenStats(c *gin.Context) {
|
||||||
},
|
},
|
||||||
"security_limits": gin.H{
|
"security_limits": gin.H{
|
||||||
"max_tokens_per_request": h.config.AgentRegistration.MaxTokens,
|
"max_tokens_per_request": h.config.AgentRegistration.MaxTokens,
|
||||||
"max_token_duration": "7 days",
|
"max_token_duration": "90 days",
|
||||||
"token_expiry_default": h.config.AgentRegistration.TokenExpiry,
|
"token_expiry_default": h.config.AgentRegistration.TokenExpiry,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -191,8 +192,66 @@ func readSecretFile(secretName string) (string, error) {
|
||||||
return strings.TrimSpace(string(data)), nil
|
return strings.TrimSpace(string(data)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadEnvFile applies KEY=VALUE lines from a flat config file into the process
|
||||||
|
// environment, without overriding anything already set — the same precedence
|
||||||
|
// docker-compose's own `env_file:` directive gives the container. Native
|
||||||
|
// (non-docker) installs have no compose layer to do this injection, so a
|
||||||
|
// service-adjacent config file is the only way to hand the binary its
|
||||||
|
// settings; this keeps that path additive and inert everywhere else.
|
||||||
|
func loadEnvFile(path string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, value, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
value = strings.Trim(strings.TrimSpace(value), `"'`)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, alreadySet := os.LookupEnv(key); !alreadySet {
|
||||||
|
os.Setenv(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadNativeConfigFile looks for a config file for non-docker deployments.
|
||||||
|
// REDFLAG_CONFIG_FILE takes an explicit path; otherwise it checks the
|
||||||
|
// per-OS default install-config location and no-ops if nothing is there —
|
||||||
|
// existing docker deployments (env vars already set by env_file:) never hit
|
||||||
|
// either path, so this cannot change their behavior.
|
||||||
|
func loadNativeConfigFile() {
|
||||||
|
path := os.Getenv("REDFLAG_CONFIG_FILE")
|
||||||
|
if path == "" {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
path = filepath.Join(os.Getenv("ProgramData"), "RedFlag", "redflag.env")
|
||||||
|
} else {
|
||||||
|
path = "/etc/redflag/server.env"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := loadEnvFile(path); err != nil {
|
||||||
|
fmt.Printf("[CONFIG] [WARN] found %s but failed to read it: %v\n", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("[CONFIG] Loaded native config file: %s\n", path)
|
||||||
|
}
|
||||||
|
|
||||||
// Load reads configuration from Docker secrets or environment variables
|
// Load reads configuration from Docker secrets or environment variables
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
|
loadNativeConfigFile()
|
||||||
|
|
||||||
// Check if we're in Docker secrets mode
|
// Check if we're in Docker secrets mode
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
if IsDockerSecretsMode() {
|
if IsDockerSecretsMode() {
|
||||||
|
|
|
||||||
64
server/internal/config/config_test.go
Normal file
64
server/internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadEnvFile_DoesNotOverrideExistingEnv(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "redflag.env")
|
||||||
|
content := "REDFLAG_TEST_A=from_file\nREDFLAG_TEST_B=from_file\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv("REDFLAG_TEST_A")
|
||||||
|
t.Setenv("REDFLAG_TEST_B", "already_set")
|
||||||
|
|
||||||
|
if err := loadEnvFile(path); err != nil {
|
||||||
|
t.Fatalf("loadEnvFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := os.Getenv("REDFLAG_TEST_A"); got != "from_file" {
|
||||||
|
t.Errorf("REDFLAG_TEST_A = %q, want %q (should be set from file)", got, "from_file")
|
||||||
|
}
|
||||||
|
if got := os.Getenv("REDFLAG_TEST_B"); got != "already_set" {
|
||||||
|
t.Errorf("REDFLAG_TEST_B = %q, want %q (existing env var must win)", got, "already_set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEnvFile_SkipsCommentsAndBlankLines(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "redflag.env")
|
||||||
|
content := "# a comment\n\nREDFLAG_TEST_C=\"quoted\"\n \nREDFLAG_TEST_D='single'\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("write test file: %v", err)
|
||||||
|
}
|
||||||
|
os.Unsetenv("REDFLAG_TEST_C")
|
||||||
|
os.Unsetenv("REDFLAG_TEST_D")
|
||||||
|
|
||||||
|
if err := loadEnvFile(path); err != nil {
|
||||||
|
t.Fatalf("loadEnvFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := os.Getenv("REDFLAG_TEST_C"); got != "quoted" {
|
||||||
|
t.Errorf("REDFLAG_TEST_C = %q, want %q", got, "quoted")
|
||||||
|
}
|
||||||
|
if got := os.Getenv("REDFLAG_TEST_D"); got != "single" {
|
||||||
|
t.Errorf("REDFLAG_TEST_D = %q, want %q", got, "single")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadNativeConfigFile_NoopWhenAbsent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("REDFLAG_CONFIG_FILE", filepath.Join(dir, "does-not-exist.env"))
|
||||||
|
os.Unsetenv("REDFLAG_TEST_SHOULD_NOT_APPEAR")
|
||||||
|
|
||||||
|
loadNativeConfigFile() // must not panic or error when the file is missing
|
||||||
|
|
||||||
|
if _, ok := os.LookupEnv("REDFLAG_TEST_SHOULD_NOT_APPEAR"); ok {
|
||||||
|
t.Error("expected no env var to be set when config file is absent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Migration 061 down: remove device classification columns.
|
||||||
|
|
||||||
|
ALTER TABLE agents DROP COLUMN IF EXISTS os_distro;
|
||||||
|
ALTER TABLE agents DROP COLUMN IF EXISTS device_model;
|
||||||
|
ALTER TABLE agents DROP COLUMN IF EXISTS device_type_manual;
|
||||||
|
ALTER TABLE agents DROP COLUMN IF EXISTS device_type;
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Migration 061: Device classification on agents (SERVER-001).
|
||||||
|
-- device_type is the agent's auto-detected form factor (DEVICE-001);
|
||||||
|
-- device_type_manual is the operator override (SERVER-002) — never written by
|
||||||
|
-- agent reports. Effective type = COALESCE(device_type_manual, device_type).
|
||||||
|
-- Existing agents default to 'server' (matches current behavior: every agent
|
||||||
|
-- so far is a server/desktop-class box treated identically).
|
||||||
|
|
||||||
|
ALTER TABLE agents ADD COLUMN IF NOT EXISTS device_type VARCHAR(20) NOT NULL DEFAULT 'server'
|
||||||
|
CHECK (device_type IN ('server', 'desktop', 'phone', 'tablet'));
|
||||||
|
ALTER TABLE agents ADD COLUMN IF NOT EXISTS device_type_manual VARCHAR(20)
|
||||||
|
CHECK (device_type_manual IN ('server', 'desktop', 'phone', 'tablet'));
|
||||||
|
ALTER TABLE agents ADD COLUMN IF NOT EXISTS device_model VARCHAR(255);
|
||||||
|
ALTER TABLE agents ADD COLUMN IF NOT EXISTS os_distro VARCHAR(50);
|
||||||
|
|
@ -31,10 +31,12 @@ func (q *AgentQueries) CreateAgent(agent *models.Agent) error {
|
||||||
INSERT INTO agents (
|
INSERT INTO agents (
|
||||||
id, hostname, os_type, os_version, os_architecture,
|
id, hostname, os_type, os_version, os_architecture,
|
||||||
agent_version, current_version, machine_id, public_key_fingerprint,
|
agent_version, current_version, machine_id, public_key_fingerprint,
|
||||||
|
device_type, device_model, os_distro,
|
||||||
last_seen, status, metadata
|
last_seen, status, metadata
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:id, :hostname, :os_type, :os_version, :os_architecture,
|
:id, :hostname, :os_type, :os_version, :os_architecture,
|
||||||
:agent_version, :current_version, :machine_id, :public_key_fingerprint,
|
:agent_version, :current_version, :machine_id, :public_key_fingerprint,
|
||||||
|
:device_type, :device_model, :os_distro,
|
||||||
:last_seen, :status, :metadata
|
:last_seen, :status, :metadata
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
@ -72,6 +74,9 @@ func (q *AgentQueries) UpdateAgent(agent *models.Agent) error {
|
||||||
os_version = :os_version,
|
os_version = :os_version,
|
||||||
os_architecture = :os_architecture,
|
os_architecture = :os_architecture,
|
||||||
agent_version = :agent_version,
|
agent_version = :agent_version,
|
||||||
|
device_type = :device_type,
|
||||||
|
device_model = :device_model,
|
||||||
|
os_distro = :os_distro,
|
||||||
last_seen = :last_seen,
|
last_seen = :last_seen,
|
||||||
status = :status,
|
status = :status,
|
||||||
metadata = :metadata
|
metadata = :metadata
|
||||||
|
|
@ -122,6 +127,14 @@ func (q *AgentQueries) UpdateMachineID(agentID uuid.UUID, newMachineID string) e
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateDeviceTypeManual sets or clears the operator's device-type override
|
||||||
|
// (SERVER-002). nil clears the override, reverting to the agent-detected type.
|
||||||
|
func (q *AgentQueries) UpdateDeviceTypeManual(agentID uuid.UUID, deviceType *string) error {
|
||||||
|
query := `UPDATE agents SET device_type_manual = $1 WHERE id = $2`
|
||||||
|
_, err := q.db.Exec(query, deviceType, agentID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// MarkOfflineAgents marks agents as offline if they haven't checked in recently
|
// MarkOfflineAgents marks agents as offline if they haven't checked in recently
|
||||||
func (q *AgentQueries) MarkOfflineAgents(threshold time.Duration) error {
|
func (q *AgentQueries) MarkOfflineAgents(threshold time.Duration) error {
|
||||||
query := `
|
query := `
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,34 @@ type Agent struct {
|
||||||
LastRebootAt *time.Time `json:"last_reboot_at,omitempty" db:"last_reboot_at"`
|
LastRebootAt *time.Time `json:"last_reboot_at,omitempty" db:"last_reboot_at"`
|
||||||
RebootReason *string `json:"reboot_reason,omitempty" db:"reboot_reason"`
|
RebootReason *string `json:"reboot_reason,omitempty" db:"reboot_reason"`
|
||||||
DockerVersion string `json:"docker_version" db:"docker_version"`
|
DockerVersion string `json:"docker_version" db:"docker_version"`
|
||||||
|
DeviceType string `json:"device_type" db:"device_type"` // Agent-detected form factor (DEVICE-001)
|
||||||
|
DeviceTypeManual *string `json:"device_type_manual,omitempty" db:"device_type_manual"` // Operator override, nil = use auto (SERVER-002)
|
||||||
|
DeviceModel *string `json:"device_model,omitempty" db:"device_model"` // Hardware model string
|
||||||
|
OSDistro *string `json:"os_distro,omitempty" db:"os_distro"` // Distro ID from /etc/os-release
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidDeviceType reports whether s is a recognized device form factor.
|
||||||
|
// Mirrors the CHECK constraint from migration 061 — validate before writing
|
||||||
|
// so a bad agent report degrades to the default instead of failing the row.
|
||||||
|
func ValidDeviceType(s string) bool {
|
||||||
|
switch s {
|
||||||
|
case "server", "desktop", "phone", "tablet":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveDeviceType returns the operator override when set, else the
|
||||||
|
// agent-detected type. All display and policy decisions use this.
|
||||||
|
func (a *Agent) EffectiveDeviceType() string {
|
||||||
|
if a.DeviceTypeManual != nil && *a.DeviceTypeManual != "" {
|
||||||
|
return *a.DeviceTypeManual
|
||||||
|
}
|
||||||
|
return a.DeviceType
|
||||||
|
}
|
||||||
|
|
||||||
// AgentWithLastScan extends Agent with last scan information
|
// AgentWithLastScan extends Agent with last scan information
|
||||||
type AgentWithLastScan struct {
|
type AgentWithLastScan struct {
|
||||||
ID uuid.UUID `json:"id" db:"id"`
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
|
@ -58,11 +82,42 @@ type AgentWithLastScan struct {
|
||||||
LastRebootAt *time.Time `json:"last_reboot_at,omitempty" db:"last_reboot_at"`
|
LastRebootAt *time.Time `json:"last_reboot_at,omitempty" db:"last_reboot_at"`
|
||||||
RebootReason *string `json:"reboot_reason,omitempty" db:"reboot_reason"`
|
RebootReason *string `json:"reboot_reason,omitempty" db:"reboot_reason"`
|
||||||
DockerVersion string `json:"docker_version" db:"docker_version"`
|
DockerVersion string `json:"docker_version" db:"docker_version"`
|
||||||
|
DeviceType string `json:"device_type" db:"device_type"`
|
||||||
|
DeviceTypeManual *string `json:"device_type_manual,omitempty" db:"device_type_manual"`
|
||||||
|
DeviceModel *string `json:"device_model,omitempty" db:"device_model"`
|
||||||
|
OSDistro *string `json:"os_distro,omitempty" db:"os_distro"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
LastScan *time.Time `json:"last_scan" db:"last_scan"`
|
LastScan *time.Time `json:"last_scan" db:"last_scan"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EffectiveDeviceType mirrors Agent.EffectiveDeviceType.
|
||||||
|
func (a *AgentWithLastScan) EffectiveDeviceType() string {
|
||||||
|
if a.DeviceTypeManual != nil && *a.DeviceTypeManual != "" {
|
||||||
|
return *a.DeviceTypeManual
|
||||||
|
}
|
||||||
|
return a.DeviceType
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON adds the computed effective_device_type to every serialized
|
||||||
|
// agent, so list and detail responses carry it without per-handler wiring.
|
||||||
|
func (a Agent) MarshalJSON() ([]byte, error) {
|
||||||
|
type alias Agent
|
||||||
|
return json.Marshal(struct {
|
||||||
|
alias
|
||||||
|
EffectiveDeviceType string `json:"effective_device_type"`
|
||||||
|
}{alias(a), a.EffectiveDeviceType()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON mirrors Agent.MarshalJSON.
|
||||||
|
func (a AgentWithLastScan) MarshalJSON() ([]byte, error) {
|
||||||
|
type alias AgentWithLastScan
|
||||||
|
return json.Marshal(struct {
|
||||||
|
alias
|
||||||
|
EffectiveDeviceType string `json:"effective_device_type"`
|
||||||
|
}{alias(a), a.EffectiveDeviceType()})
|
||||||
|
}
|
||||||
|
|
||||||
// AgentSpecs represents system specifications for an agent
|
// AgentSpecs represents system specifications for an agent
|
||||||
type AgentSpecs struct {
|
type AgentSpecs struct {
|
||||||
ID uuid.UUID `json:"id" db:"id"`
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
|
@ -91,6 +146,9 @@ type AgentRegistrationRequest struct {
|
||||||
PublicKeyFingerprint string `json:"public_key_fingerprint"` // Embedded public key fingerprint
|
PublicKeyFingerprint string `json:"public_key_fingerprint"` // Embedded public key fingerprint
|
||||||
Metadata map[string]string `json:"metadata"`
|
Metadata map[string]string `json:"metadata"`
|
||||||
AvailableScanners []string `json:"available_scanners"` // Platform-specific scanners (apt, dnf, winget, windows, docker)
|
AvailableScanners []string `json:"available_scanners"` // Platform-specific scanners (apt, dnf, winget, windows, docker)
|
||||||
|
DeviceType string `json:"device_type"` // Auto-detected form factor (DEVICE-001)
|
||||||
|
DeviceModel string `json:"device_model"` // Hardware model string
|
||||||
|
OSDistro string `json:"os_distro"` // Distro ID from /etc/os-release
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentRegistrationResponse is returned after successful registration
|
// AgentRegistrationResponse is returned after successful registration
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,18 @@ type ReleaseManifest struct {
|
||||||
// here has a built, version-self-reporting artifact.
|
// here has a built, version-self-reporting artifact.
|
||||||
func ComponentCatalog() []ManifestComponent {
|
func ComponentCatalog() []ManifestComponent {
|
||||||
return []ManifestComponent{
|
return []ManifestComponent{
|
||||||
{Name: "server", Kind: "docker", Required: true, VersionCmd: "--version"},
|
{Name: "server", Kind: "binary", Required: true, VersionCmd: "--version"},
|
||||||
{Name: "agent", Kind: "binary", Required: true, VersionCmd: "--version"},
|
{Name: "agent", Kind: "binary", Required: true, VersionCmd: "--version"},
|
||||||
{Name: "helper", Kind: "binary", Required: true, VersionCmd: "--version"},
|
{Name: "helper", Kind: "binary", Required: true, VersionCmd: "--version"},
|
||||||
{Name: "desktop", Kind: "binary", Required: false, VersionCmd: "--version",
|
{Name: "desktop", Kind: "binary", Required: false, VersionCmd: "--version",
|
||||||
Provisioning: []string{"autostart_entry", "redflag-local_group", "desktop_user_membership"}},
|
Provisioning: []string{"autostart_entry", "redflag-local_group", "desktop_user_membership"}},
|
||||||
{Name: "web", Kind: "embedded", Required: true},
|
{Name: "web", Kind: "embedded", Required: true},
|
||||||
|
// installer (INSTALL-002): release-gate presence check only, not a
|
||||||
|
// post-install healthcheck target — it's a delivery mechanism, not a
|
||||||
|
// running artifact (the thing it installs, "server", is already its
|
||||||
|
// own entry above). No VersionCmd: an MSI can't self-report a
|
||||||
|
// version by being executed. Windows-only today (RedFlagSetup.msi),
|
||||||
|
// so Required: false until macOS/Linux installers exist too.
|
||||||
|
{Name: "installer", Kind: "binary", Required: false},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ import (
|
||||||
// tag — the release gate enforces this. ldflags may override at build time;
|
// tag — the release gate enforces this. ldflags may override at build time;
|
||||||
// the release pipeline injects the tag so binaries and source agree.
|
// the release pipeline injects the tag so binaries and source agree.
|
||||||
var (
|
var (
|
||||||
AgentVersion = "0.2.9.1"
|
AgentVersion = "0.2.9.3"
|
||||||
ConfigVersion = "0.2.9.1"
|
ConfigVersion = "0.2.9.3"
|
||||||
MinAgentVersion = "0.1.22"
|
MinAgentVersion = "0.1.22"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Upload, CheckCircle, XCircle, RotateCw, Download } from 'lucide-react';
|
import { Upload, CheckCircle, XCircle, RotateCw, Download, AlertTriangle } from 'lucide-react';
|
||||||
import { useAgentUpdate } from '@/hooks/useAgentUpdate';
|
import { useAgentUpdate } from '@/hooks/useAgentUpdate';
|
||||||
import { Agent } from '@/types';
|
import { Agent } from '@/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -179,8 +179,9 @@ export function AgentUpdate({ agent, onUpdateComplete, className }: AgentUpdateP
|
||||||
{currentVersion === availableVersion ? (
|
{currentVersion === availableVersion ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded">
|
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded">
|
||||||
<p className="text-amber-800 font-medium mb-2">
|
<p className="text-amber-800 font-medium mb-2 inline-flex items-center gap-1.5">
|
||||||
⚠️ Version appears identical
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
Version appears identical
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-amber-700 mb-2">
|
<p className="text-sm text-amber-700 mb-2">
|
||||||
Current: <strong>{currentVersion}</strong> → Target: <strong>{availableVersion}</strong>
|
Current: <strong>{currentVersion}</strong> → Target: <strong>{availableVersion}</strong>
|
||||||
|
|
|
||||||
53
web/src/components/DeviceTypeIcon.tsx
Normal file
53
web/src/components/DeviceTypeIcon.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Server, Monitor, Smartphone, Tablet, Computer } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// DeviceTypeIcon — consistent form-factor rendering (WEB-001).
|
||||||
|
// effective_device_type drives everything; unknown/absent falls back to the
|
||||||
|
// generic Computer icon so pre-migration agents render unchanged.
|
||||||
|
|
||||||
|
const DEVICE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
server: Server,
|
||||||
|
desktop: Monitor,
|
||||||
|
phone: Smartphone,
|
||||||
|
tablet: Tablet,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEVICE_BADGE_CLASSES: Record<string, string> = {
|
||||||
|
server: 'bg-gray-100 text-gray-700',
|
||||||
|
desktop: 'bg-slate-100 text-slate-700',
|
||||||
|
phone: 'bg-emerald-100 text-emerald-700',
|
||||||
|
tablet: 'bg-violet-100 text-violet-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deviceTypeLabel = (type?: string): string => {
|
||||||
|
if (!type) return 'Unknown';
|
||||||
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeviceTypeIcon: React.FC<{ type?: string; className?: string }> = ({ type, className }) => {
|
||||||
|
const Icon = (type && DEVICE_ICONS[type]) || Computer;
|
||||||
|
return <Icon className={className || 'h-4 w-4'} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeviceTypeBadge: React.FC<{ type?: string; overridden?: boolean; className?: string }> = ({
|
||||||
|
type,
|
||||||
|
overridden,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
if (!type || !DEVICE_ICONS[type]) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center text-xs px-1.5 py-0.5 rounded-full w-fit',
|
||||||
|
DEVICE_BADGE_CLASSES[type],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
title={overridden ? 'Device type set by operator' : 'Device type auto-detected'}
|
||||||
|
>
|
||||||
|
<DeviceTypeIcon type={type} className="h-3 w-3 mr-1" />
|
||||||
|
{deviceTypeLabel(type)}
|
||||||
|
{overridden && <span className="ml-1 opacity-60">*</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { clientErrorLogger } from '@/lib/client-error-logger';
|
import { clientErrorLogger } from '@/lib/client-error-logger';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -43,7 +44,7 @@ class ErrorBoundary extends Component<Props, State> {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md text-center">
|
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md text-center">
|
||||||
<div className="text-4xl mb-4">⚠</div>
|
<AlertTriangle className="h-10 w-10 text-amber-500 mx-auto mb-4" />
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
Something went wrong
|
Something went wrong
|
||||||
</h2>
|
</h2>
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
)}
|
)}
|
||||||
<div className="p-4 space-y-1">
|
<div className="p-4 space-y-1">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/Fimeg/RedFlag"
|
href="https://codeberg.org/Fimeg/RedFlag"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
||||||
|
|
@ -289,7 +289,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
onClick={() => setIsNotificationDropdownOpen(false)}
|
onClick={() => setIsNotificationDropdownOpen(false)}
|
||||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
>
|
>
|
||||||
✕
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,49 @@ interface LocalSnapshot {
|
||||||
status: LocalStatus
|
status: LocalStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UpdateItem {
|
||||||
|
package_type: string
|
||||||
|
package_name: string
|
||||||
|
current_version: string
|
||||||
|
available_version: string
|
||||||
|
severity: string
|
||||||
|
cve_list: string[]
|
||||||
|
size_bytes: number
|
||||||
|
repository_source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanSnapshot {
|
||||||
|
last_scan_time?: string
|
||||||
|
update_count: number
|
||||||
|
updates: UpdateItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerScanResponse {
|
||||||
|
accepted: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApprovePolicy {
|
||||||
|
decision: string
|
||||||
|
reason: string
|
||||||
|
executed: boolean
|
||||||
|
verified_artifacts: number
|
||||||
|
exit_code: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApproveResult {
|
||||||
|
request_id: string
|
||||||
|
osv_status: string
|
||||||
|
osv_vuln_count: number
|
||||||
|
closure_size: number
|
||||||
|
policy: ApprovePolicy | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local approval is gated to the ecosystems whose closures the agent can
|
||||||
|
// resolve and pin — mirrors gatedLocalApproval in the agent.
|
||||||
|
const APPROVABLE = new Set(['dnf', 'apt'])
|
||||||
|
|
||||||
type HealthState = 'healthy' | 'warning' | 'error'
|
type HealthState = 'healthy' | 'warning' | 'error'
|
||||||
|
|
||||||
// ---------- main component ----------
|
// ---------- main component ----------
|
||||||
|
|
@ -114,9 +157,12 @@ type HealthState = 'healthy' | 'warning' | 'error'
|
||||||
const LocalAgentApp: React.FC = () => {
|
const LocalAgentApp: React.FC = () => {
|
||||||
const [theme, setTheme] = useState<Theme>('dark')
|
const [theme, setTheme] = useState<Theme>('dark')
|
||||||
const [snapshot, setSnapshot] = useState<LocalSnapshot | null>(null)
|
const [snapshot, setSnapshot] = useState<LocalSnapshot | null>(null)
|
||||||
|
const [updates, setUpdates] = useState<UpdateItem[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null)
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null)
|
||||||
|
const [scanState, setScanState] = useState<'idle' | 'requesting' | 'running'>('idle')
|
||||||
|
const [scanNote, setScanNote] = useState<string | null>(null)
|
||||||
const p = theme === 'dark' ? DARK : LIGHT
|
const p = theme === 'dark' ? DARK : LIGHT
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
|
@ -125,6 +171,8 @@ const LocalAgentApp: React.FC = () => {
|
||||||
const next = await invoke<LocalSnapshot>('local_status')
|
const next = await invoke<LocalSnapshot>('local_status')
|
||||||
setSnapshot(next)
|
setSnapshot(next)
|
||||||
setLastRefresh(new Date())
|
setLastRefresh(new Date())
|
||||||
|
const scan = await invoke<ScanSnapshot>('list_updates')
|
||||||
|
setUpdates(scan.updates ?? [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -138,10 +186,33 @@ const LocalAgentApp: React.FC = () => {
|
||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
|
const triggerScan = useCallback(async () => {
|
||||||
|
setScanState('requesting')
|
||||||
|
setScanNote(null)
|
||||||
|
try {
|
||||||
|
const resp = await invoke<TriggerScanResponse>('trigger_scan')
|
||||||
|
if (resp.accepted) {
|
||||||
|
setScanState('running')
|
||||||
|
// The scan runs agent-side; the 5s poll picks up results. Clear the
|
||||||
|
// running indicator after a grace window rather than tracking scan
|
||||||
|
// completion state the local API doesn't expose per-request.
|
||||||
|
window.setTimeout(() => setScanState('idle'), 20000)
|
||||||
|
} else {
|
||||||
|
setScanState('idle')
|
||||||
|
setScanNote(resp.error || 'scan not accepted')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setScanState('idle')
|
||||||
|
setScanNote(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Standalone (unregistered) is a first-class posture, not a degraded state —
|
||||||
|
// only fleet-joined agents that lose their status report warn on registration.
|
||||||
const health = useMemo<HealthState>(() => {
|
const health = useMemo<HealthState>(() => {
|
||||||
if (error || !snapshot) return 'error'
|
if (error || !snapshot) return 'error'
|
||||||
if (!snapshot.identity.registered || !snapshot.status.registered) return 'warning'
|
if (snapshot.identity.registered && !snapshot.status.registered) return 'warning'
|
||||||
if (snapshot.status.agent_status && snapshot.status.agent_status !== 'online') return 'warning'
|
if (snapshot.status.agent_status && snapshot.status.agent_status !== 'online' && snapshot.identity.registered) return 'warning'
|
||||||
return (snapshot.status.summary.by_severity?.critical ?? 0) > 0 ? 'warning' : 'healthy'
|
return (snapshot.status.summary.by_severity?.critical ?? 0) > 0 ? 'warning' : 'healthy'
|
||||||
}, [error, snapshot])
|
}, [error, snapshot])
|
||||||
|
|
||||||
|
|
@ -152,8 +223,6 @@ const LocalAgentApp: React.FC = () => {
|
||||||
|
|
||||||
const healthDot = health === 'healthy' ? p.good : health === 'warning' ? p.warn : p.bad
|
const healthDot = health === 'healthy' ? p.good : health === 'warning' ? p.warn : p.bad
|
||||||
const healthLabel = health === 'healthy' ? 'Online' : health === 'warning' ? 'Warning' : 'Error'
|
const healthLabel = health === 'healthy' ? 'Online' : health === 'warning' ? 'Warning' : 'Error'
|
||||||
const criticalCount = snapshot?.status.summary.by_severity?.critical ?? 0
|
|
||||||
const highCount = snapshot?.status.summary.by_severity?.high ?? 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: p.bg, color: p.text, fontFamily: "'Inter', system-ui, sans-serif", minHeight: '100vh', fontSize: '13px' }}>
|
<div style={{ background: p.bg, color: p.text, fontFamily: "'Inter', system-ui, sans-serif", minHeight: '100vh', fontSize: '13px' }}>
|
||||||
|
|
@ -198,10 +267,21 @@ const LocalAgentApp: React.FC = () => {
|
||||||
|
|
||||||
{/* Status strip */}
|
{/* Status strip */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px', background: p.border, borderBottom: `1px solid ${p.border}` }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px', background: p.border, borderBottom: `1px solid ${p.border}` }}>
|
||||||
<StatCell p={p} label="Fleet" value={snapshot?.identity.registered ? 'Bound' : 'Unbound'} accent={snapshot?.identity.registered ? p.good : p.warn} />
|
<StatCell p={p} label="Mode" value={snapshot?.identity.registered ? 'Fleet-joined' : 'Standalone'} accent={snapshot?.identity.registered ? p.good : p.text} />
|
||||||
<StatCell p={p} label="Updates" value={String(snapshot?.status.update_count ?? 0)} accent={(snapshot?.status.update_count ?? 0) > 0 ? p.warn : p.good} />
|
<StatCell p={p} label="Updates" value={String(snapshot?.status.update_count ?? 0)} accent={(snapshot?.status.update_count ?? 0) > 0 ? p.warn : p.good} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pending updates — the standalone admin surface */}
|
||||||
|
<UpdatesSection
|
||||||
|
p={p}
|
||||||
|
updates={updates}
|
||||||
|
standalone={snapshot ? !snapshot.identity.registered : false}
|
||||||
|
scanState={scanState}
|
||||||
|
scanNote={scanNote}
|
||||||
|
onScan={triggerScan}
|
||||||
|
onChanged={load}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Identity */}
|
{/* Identity */}
|
||||||
<Section p={p} label="Identity">
|
<Section p={p} label="Identity">
|
||||||
<Row p={p} label="Host" value={snapshot?.identity.hostname || '—'} mono />
|
<Row p={p} label="Host" value={snapshot?.identity.hostname || '—'} mono />
|
||||||
|
|
@ -218,17 +298,6 @@ const LocalAgentApp: React.FC = () => {
|
||||||
<Row p={p} label="Status" value={snapshot?.status.agent_status || 'unknown'} accent={snapshot?.status.agent_status === 'online' ? p.good : p.warn} />
|
<Row p={p} label="Status" value={snapshot?.status.agent_status || 'unknown'} accent={snapshot?.status.agent_status === 'online' ? p.good : p.warn} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Severity — only shown when there are updates */}
|
|
||||||
{(snapshot?.status.update_count ?? 0) > 0 && (
|
|
||||||
<Section p={p} label="Severity">
|
|
||||||
{criticalCount > 0 && <Row p={p} label="Critical" value={String(criticalCount)} accent={p.bad} />}
|
|
||||||
{highCount > 0 && <Row p={p} label="High" value={String(highCount)} accent={p.warn} />}
|
|
||||||
{snapshot?.status.summary.by_ecosystem && Object.entries(snapshot.status.summary.by_ecosystem).map(([eco, count]) => (
|
|
||||||
<Row key={eco} p={p} label={eco} value={String(count)} />
|
|
||||||
))}
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scanners */}
|
{/* Scanners */}
|
||||||
{scanners.length > 0 && (
|
{scanners.length > 0 && (
|
||||||
<Section p={p} label={`Scanners (${scanners.length})`}>
|
<Section p={p} label={`Scanners (${scanners.length})`}>
|
||||||
|
|
@ -262,6 +331,204 @@ const LocalAgentApp: React.FC = () => {
|
||||||
|
|
||||||
interface WithPalette { p: Palette }
|
interface WithPalette { p: Palette }
|
||||||
|
|
||||||
|
function severityColor(p: Palette, severity: string): string {
|
||||||
|
const s = severity.toLowerCase()
|
||||||
|
if (s === 'critical') return p.bad
|
||||||
|
if (s === 'high' || s === 'important') return p.warn
|
||||||
|
if (s === 'medium' || s === 'moderate') return p.accent
|
||||||
|
return p.textDim
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_ORDER: Record<string, number> = {
|
||||||
|
critical: 0, high: 1, important: 1, medium: 2, moderate: 2, low: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdatesSectionProps extends WithPalette {
|
||||||
|
updates: UpdateItem[]
|
||||||
|
standalone: boolean
|
||||||
|
scanState: 'idle' | 'requesting' | 'running'
|
||||||
|
scanNote: string | null
|
||||||
|
onScan: () => void
|
||||||
|
onChanged: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdatesSection: React.FC<UpdatesSectionProps> = ({ p, updates, standalone, scanState, scanNote, onScan, onChanged }) => {
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...updates].sort((a, b) => {
|
||||||
|
const sa = SEVERITY_ORDER[a.severity.toLowerCase()] ?? 4
|
||||||
|
const sb = SEVERITY_ORDER[b.severity.toLowerCase()] ?? 4
|
||||||
|
return sa !== sb ? sa - sb : a.package_name.localeCompare(b.package_name)
|
||||||
|
}),
|
||||||
|
[updates],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ borderBottom: `1px solid ${p.border}` }}>
|
||||||
|
<div style={{ padding: '5px 14px 4px', background: p.bgAlt, borderBottom: `1px solid ${p.borderInner}`, display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<div style={{ width: '2px', height: '10px', background: p.accent, borderRadius: '1px', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: '10px', fontWeight: 600, textTransform: 'uppercase' as const, letterSpacing: '0.08em', color: p.textMuted }}>
|
||||||
|
Updates ({updates.length})
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<button
|
||||||
|
onClick={onScan}
|
||||||
|
disabled={scanState !== 'idle'}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px',
|
||||||
|
color: scanState === 'idle' ? p.text : p.textDim, cursor: scanState === 'idle' ? 'pointer' : 'default',
|
||||||
|
padding: '1px 8px', fontSize: '10px', textTransform: 'uppercase' as const, letterSpacing: '0.06em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scanState === 'idle' ? 'Scan now' : scanState === 'requesting' ? 'Requesting…' : 'Scanning…'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scanNote && (
|
||||||
|
<div style={{ padding: '4px 14px', fontSize: '11px', color: p.warn, borderBottom: `1px solid ${p.borderInner}` }}>{scanNote}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!standalone && updates.length > 0 && (
|
||||||
|
<div style={{ padding: '4px 14px', fontSize: '11px', color: p.textDim, borderBottom: `1px solid ${p.borderInner}` }}>
|
||||||
|
Fleet-managed — approvals happen on the server.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<div style={{ padding: '8px 14px', fontSize: '12px', color: p.textDim }}>No pending updates.</div>
|
||||||
|
) : (
|
||||||
|
sorted.map(update => (
|
||||||
|
<UpdateRow key={`${update.package_type}:${update.package_name}`} p={p} update={update} standalone={standalone} onChanged={onChanged} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApprovePhase = 'idle' | 'confirm' | 'busy' | 'blocked' | 'done' | 'failed'
|
||||||
|
|
||||||
|
const UpdateRow: React.FC<WithPalette & { update: UpdateItem; standalone: boolean; onChanged: () => void }> = ({ p, update, standalone, onChanged }) => {
|
||||||
|
const [phase, setPhase] = useState<ApprovePhase>('idle')
|
||||||
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
|
const [overrideReason, setOverrideReason] = useState('')
|
||||||
|
const [result, setResult] = useState<ApproveResult | null>(null)
|
||||||
|
|
||||||
|
const approvable = standalone && APPROVABLE.has(update.package_type)
|
||||||
|
|
||||||
|
const approve = useCallback(async (reason: string) => {
|
||||||
|
setPhase('busy')
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
const res = await invoke<ApproveResult>('approve_update', {
|
||||||
|
request: {
|
||||||
|
package_type: update.package_type,
|
||||||
|
package_name: update.package_name,
|
||||||
|
available_version: update.available_version,
|
||||||
|
operator: '',
|
||||||
|
override_reason: reason,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setResult(res)
|
||||||
|
setPhase('done')
|
||||||
|
onChanged()
|
||||||
|
} catch (err) {
|
||||||
|
const text = err instanceof Error ? err.message : String(err)
|
||||||
|
setMessage(text)
|
||||||
|
// The gate refuses vulnerable/unreachable OSV verdicts without an
|
||||||
|
// explicit operator reason — offer the journaled override path.
|
||||||
|
setPhase(text.includes('override requires an explicit reason') ? 'blocked' : 'failed')
|
||||||
|
}
|
||||||
|
}, [update, onChanged])
|
||||||
|
|
||||||
|
const sevColor = severityColor(p, update.severity)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ borderBottom: `1px solid ${p.borderInner}` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '5px 14px' }}>
|
||||||
|
<div style={{ width: '6px', height: '6px', borderRadius: '50%', flexShrink: 0, background: sevColor }} title={update.severity || 'unknown'} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '8px' }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '12px', color: p.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{update.package_name}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '10px', color: p.textDim, flexShrink: 0 }}>{update.package_type}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: p.textDim, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{update.current_version || '?'} → {update.available_version || '?'}
|
||||||
|
{update.cve_list.length > 0 && <span style={{ color: sevColor }}> · {update.cve_list.length} CVE</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{approvable && phase === 'idle' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPhase('confirm')}
|
||||||
|
style={{ background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px', color: p.text, cursor: 'pointer', padding: '2px 8px', fontSize: '11px', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{phase === 'busy' && <span style={{ fontSize: '11px', color: p.warn, flexShrink: 0 }}>Verifying & installing…</span>}
|
||||||
|
{phase === 'done' && <span style={{ fontSize: '11px', color: p.good, flexShrink: 0 }}>Installed</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase === 'confirm' && (
|
||||||
|
<div style={{ padding: '6px 14px 8px 28px', fontSize: '11px', color: p.textMuted }}>
|
||||||
|
<div style={{ marginBottom: '6px' }}>
|
||||||
|
Local gates run first (closure resolve, OSV vulnerability check); the install executes through the signed helper and is journaled.
|
||||||
|
</div>
|
||||||
|
<button onClick={() => approve(overrideReason)} style={{ background: p.accent, border: 'none', borderRadius: '2px', color: '#fff', cursor: 'pointer', padding: '3px 10px', fontSize: '11px', marginRight: '6px' }}>
|
||||||
|
Approve & install
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setPhase('idle')} style={{ background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px', color: p.textMuted, cursor: 'pointer', padding: '3px 10px', fontSize: '11px' }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(phase === 'blocked' || phase === 'failed') && message && (
|
||||||
|
<div style={{ margin: '2px 14px 8px 28px', padding: '6px 8px', background: p.errorBg, border: `1px solid ${p.errorBorder}`, borderRadius: '3px', fontSize: '11px', color: p.errorText, fontFamily: 'monospace', overflowWrap: 'anywhere' }}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'blocked' && (
|
||||||
|
<div style={{ padding: '0 14px 8px 28px', fontSize: '11px' }}>
|
||||||
|
<input
|
||||||
|
value={overrideReason}
|
||||||
|
onChange={e => setOverrideReason(e.target.value)}
|
||||||
|
placeholder="Override reason (journaled)"
|
||||||
|
style={{ width: '100%', boxSizing: 'border-box', background: p.bgAlt, border: `1px solid ${p.border}`, borderRadius: '2px', color: p.text, padding: '4px 6px', fontSize: '11px', marginBottom: '6px' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => approve(overrideReason)}
|
||||||
|
disabled={overrideReason.trim() === ''}
|
||||||
|
style={{ background: overrideReason.trim() ? p.accent : p.border, border: 'none', borderRadius: '2px', color: '#fff', cursor: overrideReason.trim() ? 'pointer' : 'default', padding: '3px 10px', fontSize: '11px', marginRight: '6px' }}
|
||||||
|
>
|
||||||
|
Override & install
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setPhase('idle'); setMessage(null) }} style={{ background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px', color: p.textMuted, cursor: 'pointer', padding: '3px 10px', fontSize: '11px' }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'failed' && (
|
||||||
|
<div style={{ padding: '0 14px 8px 28px' }}>
|
||||||
|
<button onClick={() => { setPhase('idle'); setMessage(null) }} style={{ background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px', color: p.textMuted, cursor: 'pointer', padding: '3px 10px', fontSize: '11px' }}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'done' && result && (
|
||||||
|
<div style={{ padding: '0 14px 8px 28px', fontSize: '11px', color: p.textDim }}>
|
||||||
|
OSV {result.osv_status} · closure {result.closure_size} pkg{result.closure_size === 1 ? '' : 's'} · {result.policy?.decision ?? 'no verdict'}
|
||||||
|
{' — clears from this list on the next scan'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const Section: React.FC<WithPalette & { label: string; children: React.ReactNode }> = ({ p, label, children }) => (
|
const Section: React.FC<WithPalette & { label: string; children: React.ReactNode }> = ({ p, label, children }) => (
|
||||||
<div style={{ borderBottom: `1px solid ${p.border}` }}>
|
<div style={{ borderBottom: `1px solid ${p.border}` }}>
|
||||||
<div style={{ padding: '5px 14px 4px', background: p.bgAlt, borderBottom: `1px solid ${p.borderInner}`, display: 'flex', alignItems: 'center', gap: '6px' }}>
|
<div style={{ padding: '5px 14px 4px', background: p.bgAlt, borderBottom: `1px solid ${p.borderInner}`, display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,16 @@ export const agentApi = {
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Set or clear the operator device-type override (SERVER-002).
|
||||||
|
// deviceType null clears the override, reverting to auto-detected.
|
||||||
|
reclassifyDeviceType: async (
|
||||||
|
agentId: string,
|
||||||
|
deviceType: string | null
|
||||||
|
): Promise<{ id: string; device_type: string; device_type_manual: string | null; effective_device_type: string }> => {
|
||||||
|
const response = await api.put(`/admin/agents/${agentId}/device-type`, { device_type: deviceType });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get a single command by ID (used to poll screenshot result)
|
// Get a single command by ID (used to poll screenshot result)
|
||||||
getCommand: async (commandId: string): Promise<any> => {
|
getCommand: async (commandId: string): Promise<any> => {
|
||||||
const response = await api.get(`/commands/${commandId}`);
|
const response = await api.get(`/commands/${commandId}`);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { clsx, type ClassValue } from 'clsx';
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import { Package, Container, Monitor, AppWindow, ClipboardList, type LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
// Utility function for combining class names
|
// Utility function for combining class names
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
|
@ -142,21 +143,24 @@ import { packageStatusColor, packageSeverityColor } from '@/components/primitive
|
||||||
export const getStatusColor = packageStatusColor;
|
export const getStatusColor = packageStatusColor;
|
||||||
export const getSeverityColor = packageSeverityColor;
|
export const getSeverityColor = packageSeverityColor;
|
||||||
|
|
||||||
export const getPackageTypeIcon = (type: string): string => {
|
// Package-type icons — lucide components, consistent with the iconography
|
||||||
|
// used everywhere else in the UI (UI-DASHBOARD-AUDIT #4: the old emoji
|
||||||
|
// glyphs clashed with the aesthetic).
|
||||||
|
export const getPackageTypeIcon = (type: string): LucideIcon => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'apt':
|
case 'apt':
|
||||||
return '📦';
|
|
||||||
case 'docker':
|
|
||||||
return '🐳';
|
|
||||||
case 'yum':
|
case 'yum':
|
||||||
case 'dnf':
|
case 'dnf':
|
||||||
return '🐧';
|
return Package;
|
||||||
|
case 'docker':
|
||||||
|
return Container;
|
||||||
case 'windows':
|
case 'windows':
|
||||||
return '🪟';
|
case 'windows_update':
|
||||||
|
return Monitor;
|
||||||
case 'winget':
|
case 'winget':
|
||||||
return '📱';
|
return AppWindow;
|
||||||
default:
|
default:
|
||||||
return '📋';
|
return ClipboardList;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Computer,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ChevronRight as ChevronRightIcon,
|
ChevronRight as ChevronRightIcon,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -48,6 +47,8 @@ import { formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { AgentStorage } from '@/components/AgentStorage';
|
import { AgentStorage } from '@/components/AgentStorage';
|
||||||
|
import { DeviceTypeIcon, DeviceTypeBadge, deviceTypeLabel } from '@/components/DeviceTypeIcon';
|
||||||
|
import { DEVICE_TYPES } from '@/types';
|
||||||
import { AgentUpdatesEnhanced } from '@/components/AgentUpdatesEnhanced';
|
import { AgentUpdatesEnhanced } from '@/components/AgentUpdatesEnhanced';
|
||||||
import { AgentHealth } from '@/components/AgentHealth';
|
import { AgentHealth } from '@/components/AgentHealth';
|
||||||
import { AgentUpdatesModal } from '@/components/AgentUpdatesModal';
|
import { AgentUpdatesModal } from '@/components/AgentUpdatesModal';
|
||||||
|
|
@ -76,6 +77,7 @@ const Agents: React.FC = () => {
|
||||||
const filterConfig = {
|
const filterConfig = {
|
||||||
status: { urlParam: 'status', label: 'Status' },
|
status: { urlParam: 'status', label: 'Status' },
|
||||||
os: { urlParam: 'os', label: 'OS' },
|
os: { urlParam: 'os', label: 'OS' },
|
||||||
|
device: { urlParam: 'device', label: 'Device' },
|
||||||
};
|
};
|
||||||
const filter = useFilterUrl(filterConfig);
|
const filter = useFilterUrl(filterConfig);
|
||||||
const { sortBy, sortOrder, handleSort, applySort } = useColumnSort({
|
const { sortBy, sortOrder, handleSort, applySort } = useColumnSort({
|
||||||
|
|
@ -91,6 +93,9 @@ const Agents: React.FC = () => {
|
||||||
const [singleAgentUpdate, setSingleAgentUpdate] = useState<string | null>(null); // Single agent update modal
|
const [singleAgentUpdate, setSingleAgentUpdate] = useState<string | null>(null); // Single agent update modal
|
||||||
const [screenshotCommandId, setScreenshotCommandId] = useState<string | null>(null);
|
const [screenshotCommandId, setScreenshotCommandId] = useState<string | null>(null);
|
||||||
const [showRestartDropdown, setShowRestartDropdown] = useState(false);
|
const [showRestartDropdown, setShowRestartDropdown] = useState(false);
|
||||||
|
const [showReclassifyDropdown, setShowReclassifyDropdown] = useState(false);
|
||||||
|
const [reclassifyPending, setReclassifyPending] = useState(false);
|
||||||
|
const reclassifyDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const restartDropdownRef = useRef<HTMLDivElement>(null);
|
const restartDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -103,6 +108,9 @@ const Agents: React.FC = () => {
|
||||||
if (restartDropdownRef.current && !restartDropdownRef.current.contains(event.target as Node)) {
|
if (restartDropdownRef.current && !restartDropdownRef.current.contains(event.target as Node)) {
|
||||||
setShowRestartDropdown(false);
|
setShowRestartDropdown(false);
|
||||||
}
|
}
|
||||||
|
if (reclassifyDropdownRef.current && !reclassifyDropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setShowReclassifyDropdown(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
|
@ -270,10 +278,15 @@ const Agents: React.FC = () => {
|
||||||
// to agent metadata on every poll response and toggle.
|
// to agent metadata on every poll response and toggle.
|
||||||
const heartbeatStatus = useHeartbeatStatus(selectedAgent?.metadata);
|
const heartbeatStatus = useHeartbeatStatus(selectedAgent?.metadata);
|
||||||
|
|
||||||
// Filter agents based on OS
|
// Filter agents based on OS and device type
|
||||||
const filteredAgents = agents.filter(agent => {
|
const filteredAgents = agents.filter(agent => {
|
||||||
if (!filter.values.os) return true;
|
if (filter.values.os && !agent.os_type.toLowerCase().includes(filter.values.os.toLowerCase())) {
|
||||||
return agent.os_type.toLowerCase().includes(filter.values.os.toLowerCase());
|
return false;
|
||||||
|
}
|
||||||
|
if (filter.values.device && (agent.effective_device_type || 'server') !== filter.values.device) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort agents client-side (fleet size doesn't warrant server-side pagination yet)
|
// Sort agents client-side (fleet size doesn't warrant server-side pagination yet)
|
||||||
|
|
@ -322,6 +335,26 @@ const Agents: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle agent reboot
|
// Handle agent reboot
|
||||||
|
// Operator device-type override (WEB-001/SERVER-002). null clears back to auto.
|
||||||
|
const handleReclassify = async (agentId: string, deviceType: string | null) => {
|
||||||
|
setReclassifyPending(true);
|
||||||
|
try {
|
||||||
|
const result = await agentApi.reclassifyDeviceType(agentId, deviceType);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
|
||||||
|
toast.success(
|
||||||
|
deviceType
|
||||||
|
? `Reclassified as ${deviceTypeLabel(result.effective_device_type)}`
|
||||||
|
: `Override cleared — auto-detected as ${deviceTypeLabel(result.effective_device_type)}`
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to reclassify device');
|
||||||
|
} finally {
|
||||||
|
setReclassifyPending(false);
|
||||||
|
setShowReclassifyDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRebootAgent = async (agentId: string, hostname: string) => {
|
const handleRebootAgent = async (agentId: string, hostname: string) => {
|
||||||
if (!(await confirm({
|
if (!(await confirm({
|
||||||
title: 'Schedule System Restart',
|
title: 'Schedule System Restart',
|
||||||
|
|
@ -451,8 +484,11 @@ const Agents: React.FC = () => {
|
||||||
key: 'hostname', label: 'Agent', sortKey: 'hostname',
|
key: 'hostname', label: 'Agent', sortKey: 'hostname',
|
||||||
render: (agent) => (
|
render: (agent) => (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
|
<div
|
||||||
<Computer className="h-4 w-4 text-gray-600" />
|
className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center"
|
||||||
|
title={deviceTypeLabel(agent.effective_device_type)}
|
||||||
|
>
|
||||||
|
<DeviceTypeIcon type={agent.effective_device_type} className="h-4 w-4 text-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
|
@ -461,14 +497,14 @@ const Agents: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
{agent.metadata && (() => {
|
{agent.device_model || (agent.metadata && (() => {
|
||||||
const meta = getSystemMetadata(agent);
|
const meta = getSystemMetadata(agent);
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (meta.cpuCores !== 'Unknown') parts.push(`${meta.cpuCores} cores`);
|
if (meta.cpuCores !== 'Unknown') parts.push(`${meta.cpuCores} cores`);
|
||||||
if (meta.memoryTotal > 0) parts.push(formatBytes(meta.memoryTotal));
|
if (meta.memoryTotal > 0) parts.push(formatBytes(meta.memoryTotal));
|
||||||
if (parts.length > 0) return parts.join(' • ');
|
if (parts.length > 0) return parts.join(' • ');
|
||||||
return 'System info available';
|
return 'System info available';
|
||||||
})()}
|
})())}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -519,7 +555,8 @@ const Agents: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-gray-900">{osInfo.distribution || agent.os_type}</div>
|
<div className="text-sm text-gray-900">{osInfo.distribution || agent.os_type}</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
{osInfo.version ? `${osInfo.version} • ${agent.os_architecture || agent.architecture}` : (agent.os_architecture || agent.architecture)}
|
{[agent.os_distro, osInfo.version, agent.os_architecture || agent.architecture]
|
||||||
|
.filter(Boolean).join(' • ')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -630,8 +667,61 @@ const Agents: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-line with registration info only */}
|
{/* Sub-line: device classification + registration info */}
|
||||||
<div className="text-sm text-gray-600">
|
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-600">
|
||||||
|
<div className="relative" ref={reclassifyDropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowReclassifyDropdown(!showReclassifyDropdown)}
|
||||||
|
disabled={reclassifyPending}
|
||||||
|
className="hover:opacity-75 transition-opacity"
|
||||||
|
title="Reclassify device type"
|
||||||
|
>
|
||||||
|
<DeviceTypeBadge
|
||||||
|
type={selectedAgent.effective_device_type || 'server'}
|
||||||
|
overridden={!!selectedAgent.device_type_manual}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{showReclassifyDropdown && (
|
||||||
|
<div className="absolute left-0 mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg z-20">
|
||||||
|
{DEVICE_TYPES.map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => handleReclassify(selectedAgent.id, t)}
|
||||||
|
disabled={reclassifyPending}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center space-x-2',
|
||||||
|
selectedAgent.effective_device_type === t && 'bg-gray-50 font-medium'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DeviceTypeIcon type={t} className="h-4 w-4 text-gray-500" />
|
||||||
|
<span>{deviceTypeLabel(t)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{selectedAgent.device_type_manual && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleReclassify(selectedAgent.id, null)}
|
||||||
|
disabled={reclassifyPending}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-gray-500 hover:bg-gray-50 border-t border-gray-100"
|
||||||
|
>
|
||||||
|
Clear override (use auto-detect)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedAgent.device_model && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-400">•</span>
|
||||||
|
<span>{selectedAgent.device_model}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedAgent.os_distro && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-400">•</span>
|
||||||
|
<span>{selectedAgent.os_distro}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-400">•</span>
|
||||||
<span>Registered {formatRelativeTime(selectedAgent.created_at)}</span>
|
<span>Registered {formatRelativeTime(selectedAgent.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1159,6 +1249,7 @@ const Agents: React.FC = () => {
|
||||||
{ value: 'offline', label: 'Offline' },
|
{ value: 'offline', label: 'Offline' },
|
||||||
], placeholder: 'All Status' },
|
], placeholder: 'All Status' },
|
||||||
{ label: 'OS', value: filter.values.os, onChange: (v) => filter.setFilter('os', v), options: osTypes.map(os => ({ value: os, label: os })), placeholder: 'All OS' },
|
{ label: 'OS', value: filter.values.os, onChange: (v) => filter.setFilter('os', v), options: osTypes.map(os => ({ value: os, label: os })), placeholder: 'All OS' },
|
||||||
|
{ label: 'Device', value: filter.values.device, onChange: (v) => filter.setFilter('device', v), options: DEVICE_TYPES.map(t => ({ value: t, label: deviceTypeLabel(t) })), placeholder: 'All Devices' },
|
||||||
]}
|
]}
|
||||||
pills={buildFilterPills(filter, filterConfig)}
|
pills={buildFilterPills(filter, filterConfig)}
|
||||||
onClearAll={() => filter.clearAll()}
|
onClearAll={() => filter.clearAll()}
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ const Dashboard: React.FC = () => {
|
||||||
<AlertTriangle className="h-5 w-5 mr-3" />
|
<AlertTriangle className="h-5 w-5 mr-3" />
|
||||||
<div>
|
<div>
|
||||||
<strong className="font-bold">Security Upgrade Required:</strong>
|
<strong className="font-bold">Security Upgrade Required:</strong>
|
||||||
<span className="block sm:inline"> Your server is missing a private key for secure agent updates. Please go to <Link to="/settings/agents" className="font-bold underline hover:text-yellow-900">Agent Management</Link> to generate one.</span>
|
<span className="block sm:inline"> Your server is missing a private key for secure agent updates. Please go to <Link to="/settings/agents" className="font-bold underline hover:text-yellow-900">Agents & Enrollment</Link> to generate one.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -185,7 +185,7 @@ const Dashboard: React.FC = () => {
|
||||||
{updateTypeBreakdown.map((type) => (
|
{updateTypeBreakdown.map((type) => (
|
||||||
<div key={type.type} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
<div key={type.type} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<span className="text-2xl">{type.icon}</span>
|
<type.icon className="h-5 w-5 text-gray-500" />
|
||||||
<span className="text-sm font-medium text-gray-700">
|
<span className="text-sm font-medium text-gray-700">
|
||||||
{type.type}
|
{type.type}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ const LiveOperations: React.FC = () => {
|
||||||
// Optional: Show a secondary toast with the cheeky warning
|
// Optional: Show a secondary toast with the cheeky warning
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast(result.cheeky_warning ?? '', {
|
toast(result.cheeky_warning ?? '', {
|
||||||
icon: '⚠️',
|
icon: <AlertTriangle className="h-4 w-4 text-amber-600" />,
|
||||||
style: {
|
style: {
|
||||||
background: '#fef3c7',
|
background: '#fef3c7',
|
||||||
color: '#92400e',
|
color: '#92400e',
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ const PackageDetail: React.FC = () => {
|
||||||
const failedAgents = agents.filter((a) => a.can_retry);
|
const failedAgents = agents.filter((a) => a.can_retry);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4 max-w-4xl">
|
<div className="space-y-4 px-4 sm:px-6 lg:px-8 max-w-5xl">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
<button onClick={() => navigate('/updates')} className="hover:text-gray-700 inline-flex items-center gap-1">
|
<button onClick={() => navigate('/updates')} className="hover:text-gray-700 inline-flex items-center gap-1">
|
||||||
|
|
@ -408,18 +408,18 @@ const PackageDetail: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<li key={v.id} className="py-2.5 flex items-center gap-3 flex-wrap">
|
<li key={v.id} className="py-2.5 flex items-center gap-3 flex-wrap">
|
||||||
<span className="text-sm font-mono text-gray-900 flex-shrink-0">{v.version}</span>
|
<span className="text-sm font-mono text-gray-900 flex-shrink-0">{v.version}</span>
|
||||||
{isLatest && (
|
{isInstalled && (
|
||||||
<span className="text-[10px] font-medium text-blue-700 bg-blue-50 rounded px-1.5 py-0.5">
|
|
||||||
available
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isInstalled && isLatest && (
|
|
||||||
<span className="text-[10px] font-medium text-gray-600 bg-gray-100 rounded px-1.5 py-0.5">
|
<span className="text-[10px] font-medium text-gray-600 bg-gray-100 rounded px-1.5 py-0.5">
|
||||||
installed
|
installed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isLatest && !isInstalled && (
|
||||||
|
<span className="text-[10px] font-medium text-blue-700 bg-blue-50 rounded px-1.5 py-0.5">
|
||||||
|
available
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{osv && (
|
{osv && (
|
||||||
<span className={cn('text-[10px] border rounded px-1.5 py-0.5', osvCls)}>{osv}</span>
|
<span className={cn('text-[10px] font-medium border rounded px-1.5 py-0.5', osvCls)}>{osv}</span>
|
||||||
)}
|
)}
|
||||||
{v.sha256 && (
|
{v.sha256 && (
|
||||||
<span
|
<span
|
||||||
|
|
@ -430,10 +430,10 @@ const PackageDetail: React.FC = () => {
|
||||||
{v.sha256.slice(0, 8)}…
|
{v.sha256.slice(0, 8)}…
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-gray-400 ml-auto flex-shrink-0">
|
<span className="text-xs text-gray-500 ml-auto flex-shrink-0">
|
||||||
{v.published_at
|
{v.published_at
|
||||||
? formatRelativeTime(v.published_at)
|
? `published ${formatRelativeTime(v.published_at)}`
|
||||||
: formatRelativeTime(v.first_scanned_at)}
|
: `seen ${formatRelativeTime(v.first_scanned_at)}`}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
@ -442,7 +442,7 @@ const PackageDetail: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<VulnerabilityList vulnerabilities={vulns} title="Supply Chain" />
|
<VulnerabilityList vulnerabilities={vulns} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -583,7 +583,7 @@ const AgentDetailPane: React.FC<AgentDetailPaneProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<Clock className="h-3.5 w-3.5 text-gray-400 flex-shrink-0" />
|
<Clock className="h-3.5 w-3.5 text-gray-400 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="font-mono text-gray-700 flex-shrink-0">{cmd.action}</span>
|
<span className="font-mono text-gray-700 flex-shrink-0">{cmd.command_type}</span>
|
||||||
<span
|
<span
|
||||||
className={cn('text-[10px] border rounded px-1 py-0.5 flex-shrink-0', commandStatusInlineColor(cmd.status))}
|
className={cn('text-[10px] border rounded px-1 py-0.5 flex-shrink-0', commandStatusInlineColor(cmd.status))}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ const Settings: React.FC = () => {
|
||||||
{/* Token Overview */}
|
{/* Token Overview */}
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Token Overview</h2>
|
<h2 className="text-lg font-semibold text-gray-900">Registration Keys</h2>
|
||||||
<Link
|
<Link
|
||||||
to="/settings/agents"
|
to="/settings/agents"
|
||||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||||
|
|
@ -143,7 +143,7 @@ const Settings: React.FC = () => {
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold text-gray-900">{tokenStats.total_tokens}</p>
|
<p className="text-2xl font-bold text-gray-900">{tokenStats.total_tokens}</p>
|
||||||
<p className="text-sm text-gray-600">Total Tokens</p>
|
<p className="text-sm text-gray-600">Total Keys</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold text-green-600">{tokenStats.active_tokens}</p>
|
<p className="text-2xl font-bold text-green-600">{tokenStats.active_tokens}</p>
|
||||||
|
|
@ -161,7 +161,7 @@ const Settings: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
<p className="text-sm text-gray-500 mt-2">Loading token statistics...</p>
|
<p className="text-sm text-gray-500 mt-2">Loading key statistics...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
HardDrive,
|
HardDrive,
|
||||||
FileText,
|
FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { FilterBar, StatCard, StatCardGroup, PageState, Pagination, Modal, StatusBadge, SeverityBadge } from '@/components/primitives';
|
import { FilterBar, StatCard, StatCardGroup, PageState, Pagination, Modal, StatusBadge, SeverityBadge, CommandStatusBadge } from '@/components/primitives';
|
||||||
import { useFilterUrl, buildFilterPills } from '@/hooks/useFilterUrl';
|
import { useFilterUrl, buildFilterPills } from '@/hooks/useFilterUrl';
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
@ -296,9 +296,11 @@ const Updates: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get unique values for filters
|
// Filter dropdown options — the canonical status/severity sets from the
|
||||||
const statuses = [...new Set(updates.map((u: UpdatePackage) => u.status))];
|
// package state machine, not values scraped off the current page (which
|
||||||
const severities = [...new Set(updates.map((u: UpdatePackage) => u.severity))];
|
// collapse to a single option as soon as a filter is applied).
|
||||||
|
const statuses = ['pending', 'approved', 'checking_dependencies', 'pending_dependencies', 'installing', 'installed', 'failed', 'ignored'];
|
||||||
|
const severities = ['critical', 'high', 'medium', 'low'];
|
||||||
|
|
||||||
// Quick filter functions
|
// Quick filter functions
|
||||||
const handleQuickFilter = (quick: string) => {
|
const handleQuickFilter = (quick: string) => {
|
||||||
|
|
@ -417,6 +419,12 @@ const Updates: React.FC = () => {
|
||||||
const fleetAgents = packageFleetData?.agents || [];
|
const fleetAgents = packageFleetData?.agents || [];
|
||||||
const versionTimeline = packageVersionsData?.versions || [];
|
const versionTimeline = packageVersionsData?.versions || [];
|
||||||
|
|
||||||
|
// Hostname for this update's agent — the fleet data already fetched on
|
||||||
|
// this page carries it, so don't show a bare truncated UUID when we can
|
||||||
|
// name the machine.
|
||||||
|
const agentHostname = fleetAgents.find((fa) => fa.agent_id === selectedUpdate.agent_id)?.hostname;
|
||||||
|
const agentLabel = agentHostname || `${selectedUpdate.agent_id.slice(0, 8)}…`;
|
||||||
|
|
||||||
// Recent commands narrowed to this update — agent + package_name + package_type
|
// Recent commands narrowed to this update — agent + package_name + package_type
|
||||||
// is unique per current_package_state row, so this matches without server changes.
|
// is unique per current_package_state row, so this matches without server changes.
|
||||||
const allRecent = recentCommandsData?.commands || [];
|
const allRecent = recentCommandsData?.commands || [];
|
||||||
|
|
@ -450,6 +458,7 @@ const Updates: React.FC = () => {
|
||||||
return 0;
|
return 0;
|
||||||
})();
|
})();
|
||||||
const isFailed = selectedUpdate.status === 'failed';
|
const isFailed = selectedUpdate.status === 'failed';
|
||||||
|
const TypeIcon = getPackageTypeIcon(selectedUpdate.package_type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 sm:px-6 lg:px-8">
|
<div className="px-4 sm:px-6 lg:px-8">
|
||||||
|
|
@ -465,7 +474,7 @@ const Updates: React.FC = () => {
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center flex-wrap gap-2 mb-1">
|
<div className="flex items-center flex-wrap gap-2 mb-1">
|
||||||
<span className="text-2xl">{getPackageTypeIcon(selectedUpdate.package_type)}</span>
|
<TypeIcon className="h-6 w-6 text-gray-500 flex-shrink-0" />
|
||||||
<h1 className="text-2xl font-bold text-gray-900 truncate">
|
<h1 className="text-2xl font-bold text-gray-900 truncate">
|
||||||
{selectedUpdate.package_name}
|
{selectedUpdate.package_name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
@ -840,7 +849,7 @@ const Updates: React.FC = () => {
|
||||||
<GitBranch className="h-4 w-4 text-gray-500" />
|
<GitBranch className="h-4 w-4 text-gray-500" />
|
||||||
Lifecycle History
|
Lifecycle History
|
||||||
<span className="text-xs text-gray-500 font-normal">
|
<span className="text-xs text-gray-500 font-normal">
|
||||||
({lifecycleData.count} on {selectedUpdate.agent_id.slice(0, 8)}…)
|
({lifecycleData.count} on {agentLabel})
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="divide-y divide-gray-100">
|
<ul className="divide-y divide-gray-100">
|
||||||
|
|
@ -1000,7 +1009,10 @@ const Updates: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{['installing', 'completed', 'failed'].includes(selectedUpdate.status) && (
|
{/* 'installed', not 'completed' — the package state machine has no
|
||||||
|
'completed' status, so logs were unreachable after a successful
|
||||||
|
install (UI-SCOPE sweep 2026-07-02). */}
|
||||||
|
{['installing', 'installed', 'failed'].includes(selectedUpdate.status) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleViewLogs(selectedUpdate.id)}
|
onClick={() => handleViewLogs(selectedUpdate.id)}
|
||||||
disabled={logsLoading}
|
disabled={logsLoading}
|
||||||
|
|
@ -1049,7 +1061,7 @@ const Updates: React.FC = () => {
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Computer className="h-4 w-4 text-gray-500" />
|
<Computer className="h-4 w-4 text-gray-500" />
|
||||||
<span className="text-sm font-medium text-gray-900 truncate">
|
<span className="text-sm font-medium text-gray-900 truncate">
|
||||||
{selectedUpdate.agent_id.slice(0, 8)}…
|
{agentLabel}
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight className="h-4 w-4 text-gray-400 ml-auto group-hover:text-gray-700 transition-colors" />
|
<ChevronRight className="h-4 w-4 text-gray-400 ml-auto group-hover:text-gray-700 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1277,26 +1289,35 @@ const Updates: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<div className="text-sm text-gray-900">
|
{command.package_name && command.package_type ? (
|
||||||
{command.package_name}
|
<Link
|
||||||
</div>
|
to={`/updates/package/${command.package_type}/${command.package_name}`}
|
||||||
|
className="text-sm text-gray-900 hover:text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
{command.package_name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{command.package_name || '—'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<div className="text-sm text-gray-900">
|
{command.agent_id ? (
|
||||||
{command.agent_hostname}
|
<Link
|
||||||
</div>
|
to={`/agents/${command.agent_id}`}
|
||||||
|
className="text-sm text-gray-900 hover:text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
{command.agent_hostname}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{command.agent_hostname}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<span className={cn(
|
<CommandStatusBadge status={command.status} />
|
||||||
'badge',
|
|
||||||
command.status === 'completed' ? 'bg-green-100 text-green-800' :
|
|
||||||
command.status === 'failed' ? 'bg-red-100 text-red-800' :
|
|
||||||
command.status === 'cancelled' ? 'bg-gray-100 text-gray-800' :
|
|
||||||
command.status === 'pending' || command.status === 'sent' ? 'bg-blue-100 text-blue-800' :
|
|
||||||
'bg-gray-100 text-gray-800'
|
|
||||||
)}>
|
|
||||||
{command.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<div className="text-sm text-gray-900">
|
<div className="text-sm text-gray-900">
|
||||||
|
|
@ -1498,7 +1519,7 @@ const Updates: React.FC = () => {
|
||||||
<FilterBar
|
<FilterBar
|
||||||
search={{ value: searchQuery, onChange: setSearchQuery, placeholder: 'Search updates by package name...' }}
|
search={{ value: searchQuery, onChange: setSearchQuery, placeholder: 'Search updates by package name...' }}
|
||||||
filters={[
|
filters={[
|
||||||
{ label: 'Status', value: filter.values.status, onChange: (v) => filter.setFilter('status', v), options: statuses.map((s: string) => ({ value: s, label: s })), placeholder: 'All Status' },
|
{ label: 'Status', value: filter.values.status, onChange: (v) => filter.setFilter('status', v), options: statuses.map((s: string) => ({ value: s, label: s.replace(/_/g, ' ') })), placeholder: 'All Status' },
|
||||||
{ label: 'Severity', value: filter.values.severity, onChange: (v) => filter.setFilter('severity', v), options: severities.map((s: string) => ({ value: s, label: s })), placeholder: 'All Severities' },
|
{ label: 'Severity', value: filter.values.severity, onChange: (v) => filter.setFilter('severity', v), options: severities.map((s: string) => ({ value: s, label: s })), placeholder: 'All Severities' },
|
||||||
{ label: 'Type', value: filter.values.type, onChange: (v) => filter.setFilter('type', v), options: packageTypes.map((t: string) => ({ value: t, label: t.toUpperCase() })), placeholder: 'All Types' },
|
{ label: 'Type', value: filter.values.type, onChange: (v) => filter.setFilter('type', v), options: packageTypes.map((t: string) => ({ value: t, label: t.toUpperCase() })), placeholder: 'All Types' },
|
||||||
]}
|
]}
|
||||||
|
|
@ -1597,11 +1618,12 @@ const Updates: React.FC = () => {
|
||||||
pkg.approved_count > 0 ? { label: `${pkg.approved_count} approved`, cls: 'bg-green-50 text-green-700 border-green-200' } :
|
pkg.approved_count > 0 ? { label: `${pkg.approved_count} approved`, cls: 'bg-green-50 text-green-700 border-green-200' } :
|
||||||
pkg.installed_count > 0 ? { label: 'up to date', cls: 'bg-emerald-50 text-emerald-700 border-emerald-200' } :
|
pkg.installed_count > 0 ? { label: 'up to date', cls: 'bg-emerald-50 text-emerald-700 border-emerald-200' } :
|
||||||
{ label: '—', cls: 'bg-gray-50 text-gray-500 border-gray-200' };
|
{ label: '—', cls: 'bg-gray-50 text-gray-500 border-gray-200' };
|
||||||
|
const RowTypeIcon = getPackageTypeIcon(pkg.package_type);
|
||||||
return (
|
return (
|
||||||
<tr key={`${pkg.package_type}/${pkg.package_name}`} className="hover:bg-gray-50">
|
<tr key={`${pkg.package_type}/${pkg.package_name}`} className="hover:bg-gray-50">
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-lg flex-shrink-0">{getPackageTypeIcon(pkg.package_type)}</span>
|
<RowTypeIcon className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/updates/package/${pkg.package_type}/${pkg.package_name}`)}
|
onClick={() => navigate(`/updates/package/${pkg.package_type}/${pkg.package_name}`)}
|
||||||
className="text-sm font-medium text-gray-900 hover:text-primary-600 truncate block max-w-[16rem]"
|
className="text-sm font-medium text-gray-900 hover:text-primary-600 truncate block max-w-[16rem]"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
Users,
|
Users,
|
||||||
Key,
|
Key,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
KeyRound,
|
||||||
Server,
|
Server,
|
||||||
Monitor,
|
Monitor,
|
||||||
Laptop,
|
Laptop,
|
||||||
|
|
@ -63,6 +64,22 @@ const PLATFORMS = [
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// Expiry choices offered in the create-key form. Ceiling raised from 168h
|
||||||
|
// (7d) to 2160h (90d) 2026-06-30 — matches server/internal/api/handlers/
|
||||||
|
// registration_tokens.go's maxRegistrationTokenDuration. See
|
||||||
|
// docs/tasks/UI-REGISTRATION-ENROLLMENT-UNIFY.md for the reasoning: 90 days
|
||||||
|
// mirrors the refresh-token TTL already trusted elsewhere in this system,
|
||||||
|
// and there's still no "never expires" option — expires_at is a required
|
||||||
|
// column, and an unbounded bearer credential is a bigger step than a longer
|
||||||
|
// bound one.
|
||||||
|
const EXPIRY_OPTIONS = [
|
||||||
|
{ value: '24h', label: '24 hours' },
|
||||||
|
{ value: '72h', label: '3 days' },
|
||||||
|
{ value: '168h', label: '7 days (1 week)' },
|
||||||
|
{ value: '720h', label: '30 days' },
|
||||||
|
{ value: '2160h', label: '90 days (maximum)' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
function getServerUrl(): string {
|
function getServerUrl(): string {
|
||||||
// The host:port the browser is on is always reachable by the agent machine.
|
// The host:port the browser is on is always reachable by the agent machine.
|
||||||
const { protocol, hostname, port } = window.location;
|
const { protocol, hostname, port } = window.location;
|
||||||
|
|
@ -96,6 +113,64 @@ const getStatusText = (token: RegistrationToken): string => {
|
||||||
|
|
||||||
const tokenLabel = (t: RegistrationToken): string => t.label || `token ${t.id.slice(0, 8)}`;
|
const tokenLabel = (t: RegistrationToken): string => t.label || `token ${t.id.slice(0, 8)}`;
|
||||||
|
|
||||||
|
// PlatformPicker + InstallCommandBox are shared between the "use an existing
|
||||||
|
// key" and "key just created" branches of the enrollment flow below, so the
|
||||||
|
// platform choice and the resulting one-liner look and behave identically
|
||||||
|
// regardless of how the operator got there.
|
||||||
|
const PlatformPicker: React.FC<{ value: string; onChange: (id: string) => void }> = ({ value, onChange }) => (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
{PLATFORMS.map((p) => {
|
||||||
|
const Icon = p.icon;
|
||||||
|
const selected = value === p.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(p.id)}
|
||||||
|
disabled={!p.available}
|
||||||
|
className={cn(
|
||||||
|
'p-4 border-2 rounded-lg text-left transition-all disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
selected ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<Icon className={cn('w-6 h-6', p.id === 'linux' ? 'text-orange-600' : p.id === 'windows' ? 'text-primary-600' : 'text-gray-600')} />
|
||||||
|
{selected && <CheckCircle className="w-4 h-4 text-primary-600" />}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-gray-900">{p.name}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">{p.description}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InstallCommandBox: React.FC<{
|
||||||
|
command: string;
|
||||||
|
platform: string;
|
||||||
|
copied: boolean;
|
||||||
|
onCopy: () => void;
|
||||||
|
}> = ({ command, platform, copied, onCopy }) => (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Installation command
|
||||||
|
{platform === 'windows' && <span className="text-primary-600"> (Run in PowerShell as Administrator)</span>}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
|
||||||
|
<code>{command}</code>
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={onCopy}
|
||||||
|
className="absolute top-2 right-2 p-2 bg-gray-700 text-white rounded hover:bg-gray-600"
|
||||||
|
title="Copy command"
|
||||||
|
>
|
||||||
|
{copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const AgentsEnrollment: React.FC = () => {
|
const AgentsEnrollment: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -117,18 +192,19 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
const deleteToken = useDeleteRegistrationToken();
|
const deleteToken = useDeleteRegistrationToken();
|
||||||
const cleanupTokens = useCleanupRegistrationTokens();
|
const cleanupTokens = useCleanupRegistrationTokens();
|
||||||
|
|
||||||
// Selection + panels
|
// Key-list selection (right-hand detail pane, independent of enrollment)
|
||||||
const [selectedTokenId, setSelectedTokenId] = useState<string>('');
|
const [selectedTokenId, setSelectedTokenId] = useState<string>('');
|
||||||
const [showInstall, setShowInstall] = useState(false);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [revealToken, setRevealToken] = useState(false);
|
const [revealToken, setRevealToken] = useState(false);
|
||||||
const [createdToken, setCreatedToken] = useState<{ token: string; install_command: string } | null>(null);
|
|
||||||
|
|
||||||
// Install panel state (separate from detail selection — install picks a live
|
// Enrollment flow state — one card, two ways in ("use existing key" vs.
|
||||||
// token to enroll a NEW agent with; detail inspects any token)
|
// "create new key"), converging on the same platform picker + one-liner.
|
||||||
|
// Replaces the old showInstall/showCreate toggle-panel pair.
|
||||||
|
const [enrollTab, setEnrollTab] = useState<'use' | 'create'>('use');
|
||||||
|
const enrollTabTouched = React.useRef(false);
|
||||||
const [installPlatform, setInstallPlatform] = useState<string>('linux');
|
const [installPlatform, setInstallPlatform] = useState<string>('linux');
|
||||||
const [installTokenId, setInstallTokenId] = useState<string>('');
|
const [installTokenId, setInstallTokenId] = useState<string>('');
|
||||||
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
|
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
|
||||||
|
const [createdToken, setCreatedToken] = useState<{ token: string; label: string } | null>(null);
|
||||||
|
|
||||||
// Create-key form
|
// Create-key form
|
||||||
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
|
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
|
||||||
|
|
@ -137,7 +213,9 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
max_seats: 1,
|
max_seats: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Signing keys (preserved from the old Agent Management page)
|
// Signing keys (preserved from the old Agent Management page — now its own
|
||||||
|
// section instead of being repeated under whichever key happens to be
|
||||||
|
// selected, since it isn't actually per-key).
|
||||||
const { data: serverKeySecurity, isLoading: isLoadingServerKeySecurity, refetch: refetchServerKeySecurity } =
|
const { data: serverKeySecurity, isLoading: isLoadingServerKeySecurity, refetch: refetchServerKeySecurity } =
|
||||||
useServerKeySecurity();
|
useServerKeySecurity();
|
||||||
const [generatingKeys, setGeneratingKeys] = useState(false);
|
const [generatingKeys, setGeneratingKeys] = useState(false);
|
||||||
|
|
@ -167,7 +245,7 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
|
|
||||||
const selectedToken = allTokens.find((t) => t.id === selectedTokenId) || null;
|
const selectedToken = allTokens.find((t) => t.id === selectedTokenId) || null;
|
||||||
|
|
||||||
// Active tokens with available seats — what the install panel offers.
|
// Active tokens with available seats — what "use existing key" offers.
|
||||||
const availableTokens = React.useMemo(
|
const availableTokens = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
allTokens.filter(
|
allTokens.filter(
|
||||||
|
|
@ -176,6 +254,15 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
[allTokens],
|
[allTokens],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If the operator hasn't touched the tab and there's nothing to enroll
|
||||||
|
// with yet, default straight to "create new key" instead of showing an
|
||||||
|
// empty dropdown.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!enrollTabTouched.current && !isLoading && availableTokens.length === 0) {
|
||||||
|
setEnrollTab('create');
|
||||||
|
}
|
||||||
|
}, [availableTokens, isLoading]);
|
||||||
|
|
||||||
const installToken = availableTokens.find((t) => t.id === installTokenId) ?? null;
|
const installToken = availableTokens.find((t) => t.id === installTokenId) ?? null;
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (installTokenId && !availableTokens.some((t) => t.id === installTokenId)) {
|
if (installTokenId && !availableTokens.some((t) => t.id === installTokenId)) {
|
||||||
|
|
@ -183,8 +270,8 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [availableTokens, installTokenId]);
|
}, [availableTokens, installTokenId]);
|
||||||
|
|
||||||
// Deep-link from elsewhere (e.g. the Agents page): `?install=1` opens the
|
// Deep-link from elsewhere (e.g. the Agents page): `?install=1` lands on the
|
||||||
// install panel; `?install=<tokenId>` opens it pre-seeded with that key so
|
// "use existing key" tab; `?install=<tokenId>` pre-seeds it with that key so
|
||||||
// the operator lands on the exact key they came to enroll with. The guard
|
// the operator lands on the exact key they came to enroll with. The guard
|
||||||
// effect above drops the seed if the key turns out unavailable. Strip the
|
// effect above drops the seed if the key turns out unavailable. Strip the
|
||||||
// param afterward so a refresh or back-nav doesn't re-trigger it.
|
// param afterward so a refresh or back-nav doesn't re-trigger it.
|
||||||
|
|
@ -194,7 +281,8 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
const inst = searchParams.get('install');
|
const inst = searchParams.get('install');
|
||||||
if (!inst) return;
|
if (!inst) return;
|
||||||
deepLinkHandled.current = true;
|
deepLinkHandled.current = true;
|
||||||
setShowInstall(true);
|
enrollTabTouched.current = true;
|
||||||
|
setEnrollTab('use');
|
||||||
if (inst !== '1') setInstallTokenId(inst);
|
if (inst !== '1') setInstallTokenId(inst);
|
||||||
searchParams.delete('install');
|
searchParams.delete('install');
|
||||||
setSearchParams(searchParams, { replace: true });
|
setSearchParams(searchParams, { replace: true });
|
||||||
|
|
@ -207,9 +295,9 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
createToken.mutate(formData, {
|
createToken.mutate(formData, {
|
||||||
onSuccess: (data: any) => {
|
onSuccess: (data: any) => {
|
||||||
|
const label = formData.label || data.label;
|
||||||
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
|
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
|
||||||
setShowCreate(false);
|
setCreatedToken({ token: data.token, label });
|
||||||
setCreatedToken({ token: data.token, install_command: data.install_command });
|
|
||||||
refetch();
|
refetch();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -269,11 +357,13 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
|
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Jump straight from a selected key into the install flow, pre-seeded with
|
// Jump straight from a selected key into the enrollment flow, pre-seeded
|
||||||
// that key — no re-picking it in the install dropdown.
|
// with that key — no re-picking it in the dropdown.
|
||||||
const installWithKey = (tokenId: string) => {
|
const installWithKey = (tokenId: string) => {
|
||||||
|
enrollTabTouched.current = true;
|
||||||
|
setCreatedToken(null);
|
||||||
|
setEnrollTab('use');
|
||||||
setInstallTokenId(tokenId);
|
setInstallTokenId(tokenId);
|
||||||
setShowInstall(true);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -309,7 +399,10 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const installCommand = generateInstallCommand(installPlatform, installToken?.token);
|
// The token driving the platform picker + one-liner: whichever key was
|
||||||
|
// just created, or (in "use existing key" mode) whichever key is selected.
|
||||||
|
const enrollTokenValue = createdToken?.token ?? installToken?.token;
|
||||||
|
const installCommand = generateInstallCommand(installPlatform, enrollTokenValue);
|
||||||
const boundAgents = boundAgentsData?.agents || [];
|
const boundAgents = boundAgentsData?.agents || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -319,233 +412,193 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6 flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
|
<div className="mb-6">
|
||||||
<div>
|
<h1 className="text-3xl font-bold text-gray-900">Agents & Enrollment</h1>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Agents & Enrollment</h1>
|
<p className="mt-2 text-gray-600">
|
||||||
<p className="mt-2 text-gray-600">
|
Enroll new agents and manage the registration keys that let them join.
|
||||||
Enroll agents and manage registration keys. Select a key to see who enrolled with it.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowInstall((v) => !v)}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<Terminal className="w-4 h-4" />
|
|
||||||
{showInstall ? 'Hide install' : 'Install agent'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreate((v) => !v)}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
New key
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Install panel */}
|
{/* Enroll a new agent — single flow: pick or create a key, pick a
|
||||||
{showInstall && (
|
platform, copy the one-liner. Replaces the old install/create
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
|
toggle panels. */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="card mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Install a new agent</h2>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<button onClick={() => setShowInstall(false)} className="text-sm text-gray-500 hover:text-gray-700">
|
<Terminal className="w-5 h-5 text-primary-600" />
|
||||||
✕ close
|
<h2 className="text-lg font-semibold text-gray-900">Enroll a new agent</h2>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{availableTokens.length === 0 ? (
|
|
||||||
<div className="text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg p-4">
|
|
||||||
No registration keys with available seats.{' '}
|
|
||||||
<button onClick={() => setShowCreate(true)} className="text-primary-600 hover:text-primary-800 underline">
|
|
||||||
Create a key
|
|
||||||
</button>{' '}
|
|
||||||
first — existing agents are unaffected.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<span className="text-sm text-gray-600">Enroll with key:</span>
|
|
||||||
<select
|
|
||||||
value={installTokenId}
|
|
||||||
onChange={(e) => setInstallTokenId(e.target.value)}
|
|
||||||
className="px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-[320px]"
|
|
||||||
>
|
|
||||||
<option value="">— Select a key ({availableTokens.length} available) —</option>
|
|
||||||
{availableTokens.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{(t.token ?? t.id).slice(0, 12)}…{t.label ? ` · ${t.label}` : ''} · {t.seats_used}/{t.max_seats} seats
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
||||||
{PLATFORMS.map((p) => {
|
|
||||||
const Icon = p.icon;
|
|
||||||
const selected = installPlatform === p.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
onClick={() => setInstallPlatform(p.id)}
|
|
||||||
disabled={!p.available}
|
|
||||||
className={cn(
|
|
||||||
'p-4 border-2 rounded-lg text-left transition-all disabled:opacity-50 disabled:cursor-not-allowed',
|
|
||||||
selected ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<Icon className={cn('w-6 h-6', p.id === 'linux' ? 'text-orange-600' : p.id === 'windows' ? 'text-primary-600' : 'text-gray-600')} />
|
|
||||||
{selected && <CheckCircle className="w-4 h-4 text-primary-600" />}
|
|
||||||
</div>
|
|
||||||
<div className="font-medium text-gray-900">{p.name}</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-1">{p.description}</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{installToken ? (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Installation command
|
|
||||||
{installPlatform === 'windows' && <span className="text-primary-600"> (Run in PowerShell as Administrator)</span>}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
|
|
||||||
<code>{installCommand}</code>
|
|
||||||
</pre>
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(installCommand, 'install')}
|
|
||||||
className="absolute top-2 right-2 p-2 bg-gray-700 text-white rounded hover:bg-gray-600"
|
|
||||||
title="Copy command"
|
|
||||||
>
|
|
||||||
{copiedCommand === 'install' ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-gray-500 bg-gray-50 border border-gray-200 rounded-lg p-4">
|
|
||||||
Select a key above to generate the one-liner.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Pick a registration key (or make one), choose a platform, and copy the one-liner.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Create-key form */}
|
{createdToken ? (
|
||||||
{showCreate && (
|
<div className="space-y-4">
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
|
<div className="alert alert-success flex items-start justify-between gap-3">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Create new registration key</h2>
|
<p className="text-sm text-success-800">
|
||||||
<form onSubmit={handleCreateToken} className="space-y-4">
|
<span className="font-medium">Key "{createdToken.label}" created.</span> Copy the
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
token now — it cannot be retrieved again, only a hash is stored.
|
||||||
<div>
|
</p>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Label *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={formData.label}
|
|
||||||
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
|
|
||||||
placeholder="e.g., Production Servers"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Expires In</label>
|
|
||||||
<select
|
|
||||||
value={formData.expires_in}
|
|
||||||
onChange={(e) => setFormData({ ...formData, expires_in: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
>
|
|
||||||
<option value="24h">24 hours</option>
|
|
||||||
<option value="72h">3 days</option>
|
|
||||||
<option value="168h">7 days (1 week)</option>
|
|
||||||
</select>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">Maximum 7 days per server security policy</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats (Agents)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value={formData.max_seats || 1}
|
|
||||||
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">Number of agents that can enroll with this key</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
onClick={() => setCreatedToken(null)}
|
||||||
disabled={createToken.isPending}
|
className="text-xs text-success-700 hover:text-success-900 shrink-0 whitespace-nowrap"
|
||||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{createToken.isPending ? 'Creating...' : 'Create key'}
|
Enroll another
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCreate(false)}
|
|
||||||
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Created-key reveal — shown once, dismissed by the operator */}
|
|
||||||
{createdToken && (
|
|
||||||
<div className="bg-green-50 border border-green-300 rounded-lg p-6 mb-6">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
|
||||||
<h3 className="text-lg font-semibold text-green-900">Key created</h3>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setCreatedToken(null)} className="text-green-600 hover:text-green-800 text-sm">
|
|
||||||
Dismiss
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-green-800 mb-3">
|
|
||||||
Copy this key now. It cannot be retrieved again — only a hash is stored.
|
|
||||||
</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-green-700 mb-1">Token</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Token</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="flex-1 font-mono text-sm bg-white border border-green-200 px-3 py-2 rounded select-all">
|
<code className="flex-1 font-mono text-sm bg-gray-50 border border-gray-200 px-3 py-2 rounded select-all">
|
||||||
{createdToken.token}
|
{createdToken.token}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
onClick={() => copyToClipboard(createdToken.token, 'created-token')}
|
onClick={() => copyToClipboard(createdToken.token, 'created-token')}
|
||||||
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
|
className="p-2 text-gray-500 hover:text-gray-700 border border-gray-200 rounded"
|
||||||
title="Copy token"
|
title="Copy token"
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4" />
|
{copiedCommand === 'created-token' ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-green-700 mb-1">Install command</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="flex-1 font-mono text-xs bg-white border border-green-200 px-3 py-2 rounded select-all overflow-x-auto">
|
|
||||||
{createdToken.install_command}
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(createdToken.install_command, 'created-cmd')}
|
|
||||||
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
|
|
||||||
title="Copy install command"
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<PlatformPicker value={installPlatform} onChange={setInstallPlatform} />
|
||||||
|
<InstallCommandBox
|
||||||
|
command={installCommand}
|
||||||
|
platform={installPlatform}
|
||||||
|
copied={copiedCommand === 'install'}
|
||||||
|
onCopy={() => copyToClipboard(installCommand, 'install')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="space-y-4">
|
||||||
|
<div className="inline-flex rounded-lg border border-gray-200 p-0.5 bg-gray-50">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
enrollTabTouched.current = true;
|
||||||
|
setEnrollTab('use');
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||||
|
enrollTab === 'use' ? 'bg-white shadow-sm text-primary-700 font-medium' : 'text-gray-500 hover:text-gray-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Use existing key
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
enrollTabTouched.current = true;
|
||||||
|
setEnrollTab('create');
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md transition-colors inline-flex items-center gap-1',
|
||||||
|
enrollTab === 'create' ? 'bg-white shadow-sm text-primary-700 font-medium' : 'text-gray-500 hover:text-gray-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Create new key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enrollTab === 'use' ? (
|
||||||
|
availableTokens.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
No registration keys with available seats.{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
enrollTabTouched.current = true;
|
||||||
|
setEnrollTab('create');
|
||||||
|
}}
|
||||||
|
className="text-primary-600 hover:text-primary-800 underline"
|
||||||
|
>
|
||||||
|
Create one
|
||||||
|
</button>{' '}
|
||||||
|
— existing agents are unaffected.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-600">Key:</span>
|
||||||
|
<select
|
||||||
|
value={installTokenId}
|
||||||
|
onChange={(e) => setInstallTokenId(e.target.value)}
|
||||||
|
className="form-input bg-white min-w-[320px] w-auto"
|
||||||
|
>
|
||||||
|
<option value="">— Select a key ({availableTokens.length} available) —</option>
|
||||||
|
{availableTokens.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{(t.token ?? t.id).slice(0, 12)}…{t.label ? ` · ${t.label}` : ''} · {t.seats_used}/{t.max_seats} seats
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlatformPicker value={installPlatform} onChange={setInstallPlatform} />
|
||||||
|
|
||||||
|
{installToken ? (
|
||||||
|
<InstallCommandBox
|
||||||
|
command={installCommand}
|
||||||
|
platform={installPlatform}
|
||||||
|
copied={copiedCommand === 'install'}
|
||||||
|
onCopy={() => copyToClipboard(installCommand, 'install')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500 bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
Select a key above to generate the one-liner.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleCreateToken} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Label *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.label}
|
||||||
|
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
|
||||||
|
placeholder="e.g., Production Servers"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Expires In</label>
|
||||||
|
<select
|
||||||
|
value={formData.expires_in}
|
||||||
|
onChange={(e) => setFormData({ ...formData, expires_in: e.target.value })}
|
||||||
|
className="form-input bg-white"
|
||||||
|
>
|
||||||
|
{EXPIRY_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Maximum 90 days per key — revoke anytime before then.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats (Agents)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={formData.max_seats || 1}
|
||||||
|
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Number of agents that can enroll with this key</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={createToken.isPending} className="btn-primary">
|
||||||
|
{createToken.isPending ? 'Creating...' : 'Create key'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
{stats && (
|
{stats && (
|
||||||
|
|
@ -563,8 +616,12 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Master-detail */}
|
{/* Registration keys — master-detail roster + audit view */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6">
|
<div className="mb-3">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Registration keys</h2>
|
||||||
|
<p className="text-sm text-gray-500">All keys, active and historical. Select one to see who enrolled with it.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6 mb-6">
|
||||||
{/* LEFT: key list */}
|
{/* LEFT: key list */}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 flex flex-col max-h-[70vh]">
|
<div className="bg-white rounded-lg border border-gray-200 flex flex-col max-h-[70vh]">
|
||||||
<div className="p-4 border-b border-gray-200 space-y-3">
|
<div className="p-4 border-b border-gray-200 space-y-3">
|
||||||
|
|
@ -678,7 +735,7 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
onClick={() => installWithKey(selectedToken.id)}
|
onClick={() => installWithKey(selectedToken.id)}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-primary-700 bg-primary-50 border border-primary-200 rounded-md hover:bg-primary-100"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-primary-700 bg-primary-50 border border-primary-200 rounded-md hover:bg-primary-100"
|
||||||
title="Open the install panel pre-seeded with this key"
|
title="Jump to the enrollment flow pre-seeded with this key"
|
||||||
>
|
>
|
||||||
<Terminal className="w-4 h-4" />
|
<Terminal className="w-4 h-4" />
|
||||||
Install with this key
|
Install with this key
|
||||||
|
|
@ -845,60 +902,64 @@ const AgentsEnrollment: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Signing keys — preserved capability from the old Agent Management page */}
|
|
||||||
<div className="border-t border-gray-200 pt-5">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-3 inline-flex items-center gap-1.5">
|
|
||||||
<Key className="w-4 h-4 text-gray-500" />
|
|
||||||
Server signing key
|
|
||||||
</h3>
|
|
||||||
{isLoadingServerKeySecurity ? (
|
|
||||||
<div className="py-3 text-center">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-primary-600"></div>
|
|
||||||
</div>
|
|
||||||
) : serverKeySecurity?.has_private_key ? (
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
|
||||||
<div className="alert alert-success rounded-md p-2.5 flex-1">
|
|
||||||
<p className="text-sm text-green-800 inline-flex items-center gap-2">
|
|
||||||
<CheckCircle className="w-4 h-4" />
|
|
||||||
Server has a private key for signing agent updates.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<code className="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded font-mono">
|
|
||||||
{serverKeySecurity.public_key_fingerprint}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
|
||||||
<div className="alert alert-warning rounded-md p-2.5 flex-1">
|
|
||||||
<p className="text-sm text-amber-800">
|
|
||||||
Server is missing a private key — generate one to enable secure agent updates.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={generateKeys}
|
|
||||||
disabled={generatingKeys}
|
|
||||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{generatingKeys ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
||||||
Generating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Key className="w-4 h-4" />
|
|
||||||
Generate signing keys
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Server signing key — not per-key, so it lives here rather than
|
||||||
|
repeated inside every token's detail pane. */}
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-1 inline-flex items-center gap-2">
|
||||||
|
<KeyRound className="w-5 h-5 text-gray-500" />
|
||||||
|
Server signing key
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Signs agent update packages so agents can verify what they're installing.
|
||||||
|
</p>
|
||||||
|
{isLoadingServerKeySecurity ? (
|
||||||
|
<div className="py-3 text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
) : serverKeySecurity?.has_private_key ? (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
|
<div className="alert alert-success rounded-md p-2.5 flex-1">
|
||||||
|
<p className="text-sm text-green-800 inline-flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Server has a private key for signing agent updates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<code className="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded font-mono">
|
||||||
|
{serverKeySecurity.public_key_fingerprint}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
|
<div className="alert alert-warning rounded-md p-2.5 flex-1">
|
||||||
|
<p className="text-sm text-amber-800">
|
||||||
|
Server is missing a private key — generate one to enable secure agent updates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={generateKeys}
|
||||||
|
disabled={generatingKeys}
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{generatingKeys ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
Generate signing keys
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -442,8 +442,9 @@ const UpstreamTracking: React.FC = () => {
|
||||||
<span className="text-gray-400">never</span>
|
<span className="text-gray-400">never</span>
|
||||||
)}
|
)}
|
||||||
{s.last_error && (
|
{s.last_error && (
|
||||||
<div className="text-red-600 truncate max-w-xs" title={s.last_error}>
|
<div className="text-red-600 truncate max-w-xs inline-flex items-center gap-1" title={s.last_error}>
|
||||||
⚠ {s.last_error}
|
<AlertOctagon className="h-3 w-3 flex-shrink-0" />
|
||||||
|
{s.last_error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,19 @@ export interface Agent {
|
||||||
is_updating?: boolean;
|
is_updating?: boolean;
|
||||||
updating_to_version?: string;
|
updating_to_version?: string;
|
||||||
update_available?: boolean;
|
update_available?: boolean;
|
||||||
|
// Device classification (DEVICE-001/SERVER-001)
|
||||||
|
device_type?: string; // agent-detected: server | desktop | phone | tablet
|
||||||
|
device_type_manual?: string; // operator override, absent = use auto
|
||||||
|
device_model?: string; // "Google Pixel 3", "Dell PowerEdge R740"
|
||||||
|
os_distro?: string; // /etc/os-release ID: "arch", "fedora"
|
||||||
|
effective_device_type?: string; // server-computed COALESCE(manual, auto)
|
||||||
// Note: ip_address not available from API yet
|
// Note: ip_address not available from API yet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeviceType = 'server' | 'desktop' | 'phone' | 'tablet';
|
||||||
|
|
||||||
|
export const DEVICE_TYPES: DeviceType[] = ['server', 'desktop', 'phone', 'tablet'];
|
||||||
|
|
||||||
export interface AgentSpec {
|
export interface AgentSpec {
|
||||||
id: string;
|
id: string;
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue