desktop: become the machine console
Qt/QML now carries 11 local views while the Agent owns observation and intent. Tauri, WebKit, and the second React desktop build leave together. Linux ships first; Windows waits for a native Qt runner.
This commit is contained in:
parent
b056ab5199
commit
f487de554a
61 changed files with 4475 additions and 10471 deletions
|
|
@ -133,23 +133,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
- name: Install Tauri system dependencies
|
||||
- name: Install Qt 6 build dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
- name: Check RedFlag desktop
|
||||
run: |
|
||||
cd web
|
||||
npm ci
|
||||
npm run build:desktop
|
||||
cd ../desktop
|
||||
cargo check
|
||||
sudo apt-get install -y -qq qt6-base-dev qt6-declarative-dev qt6-declarative-dev-tools libgl1-mesa-dev
|
||||
- name: Check native RedFlag Desktop
|
||||
run: cd desktop && cargo check --locked
|
||||
|
||||
installer-integrity:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -53,11 +53,8 @@ jobs:
|
|||
FAIL=1
|
||||
fi
|
||||
|
||||
# Desktop: optional component, but if present, its version must match.
|
||||
# desktop/Cargo.toml is the single source of the desktop version (3-part
|
||||
# semver); tauri.conf.json carries no version field and inherits it from
|
||||
# the crate (a 4-octet there is invalid semver and Tauri's build refuses
|
||||
# it), so there is nothing to cross-check on tauri.conf.json.
|
||||
# Desktop: optional component, but if present, its Rust/Qt crate version
|
||||
# must match the first three tag octets.
|
||||
if [ -f desktop/Cargo.toml ]; then
|
||||
DESKTOP_CARGO_VER=$(grep -m1 '^version' desktop/Cargo.toml | cut -d'"' -f2)
|
||||
echo "desktop/Cargo.toml: version=$DESKTOP_CARGO_VER"
|
||||
|
|
@ -345,23 +342,19 @@ jobs:
|
|||
if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi
|
||||
cp target/${{ matrix.rust_target }}/release/redflag-helper${EXT} ../dist/redflag-helper-${{ matrix.suffix }}${EXT}
|
||||
|
||||
- name: Build desktop (Tauri system tray)
|
||||
- name: Build native Qt/QML Desktop
|
||||
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
|
||||
# Tauri v2 system dependencies (webkit2gtk-4.1 for Ubuntu 24.04+).
|
||||
# CXX-Qt compiles the Rust bridge and embeds the QML module. The web
|
||||
# application is not an input to the native Desktop binary.
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev 2>/dev/null || true
|
||||
sudo apt-get install -y -qq qt6-base-dev qt6-declarative-dev qt6-declarative-dev-tools libgl1-mesa-dev
|
||||
|
||||
# Build the desktop frontend (Tauri's beforeBuildCommand, but we do it
|
||||
# explicitly so the web build is deterministic).
|
||||
cd web && npm ci --silent && npm run build:desktop && cd ..
|
||||
|
||||
# Build the desktop binary.
|
||||
cd desktop
|
||||
cargo build --release
|
||||
cargo build --release --locked
|
||||
cp target/release/redflag-desktop ../dist/redflag-desktop-${{ matrix.suffix }}
|
||||
echo "Desktop binary built: $(ls -lh ../dist/redflag-desktop-${{ matrix.suffix }})"
|
||||
|
||||
|
|
@ -370,40 +363,13 @@ jobs:
|
|||
echo "Desktop version: $DESKTOP_VER"
|
||||
echo "$DESKTOP_VER" | grep -q "v$VERSION" || { echo "::error::desktop binary reports $DESKTOP_VER, expected v$VERSION"; exit 1; }
|
||||
|
||||
- name: Build desktop (Tauri system tray - Windows cross-compile)
|
||||
if: matrix.goos == 'windows' && matrix.goarch == 'amd64'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
|
||||
# Tauri v2 host build scripts need webkit2gtk even when cross-compiling.
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev clang lld llvm 2>/dev/null || true
|
||||
|
||||
# Install cargo-xwin for MSVC cross-compilation.
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
cargo install --locked cargo-xwin
|
||||
|
||||
# Build the desktop frontend.
|
||||
cd web && npm ci --silent && npm run build:desktop && cd ..
|
||||
|
||||
# Cross-compile the desktop binary (bundling disabled — raw exe).
|
||||
cd desktop
|
||||
cargo xwin build --release --target x86_64-pc-windows-msvc
|
||||
cp target/x86_64-pc-windows-msvc/release/redflag-desktop.exe \
|
||||
../dist/redflag-desktop-${{ matrix.suffix }}.exe
|
||||
echo "Desktop binary built: $(ls -lh ../dist/redflag-desktop-${{ matrix.suffix }}.exe)"
|
||||
# Cross-compiled binary can't run here for version check, but the gate
|
||||
# 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.
|
||||
# MSI's ProductVersion only carries 3 significant fields for upgrade
|
||||
# detection; bump-version.sh applies the same 3-vs-4-part mapping.
|
||||
WIX_VERSION=$(echo "$VERSION" | cut -d. -f1-3)
|
||||
|
||||
# NOT the official WiX Toolset .NET CLI — `wix build` genuinely
|
||||
|
|
|
|||
4
Makefile
4
Makefile
|
|
@ -38,10 +38,10 @@ logs: ## Tail the server logs
|
|||
prune-cruft: ## Remove retired RedFlag images (redflag-web, desktop-stage-test)
|
||||
-docker image rm redflag-web:latest redflag-desktop-stage-test:latest 2>/dev/null; true
|
||||
|
||||
fetch-desktop-windows: ## Fetch+verify the signed Windows tray from the latest release into ./dist
|
||||
fetch-desktop-windows: ## Fetch+verify the signed Windows Desktop from the latest release into ./dist
|
||||
@mkdir -p dist
|
||||
sh scripts/fetch-desktop-windows.sh amd64 ./dist
|
||||
@ls -lh dist/redflag-desktop.exe 2>/dev/null || echo "no Windows tray in the latest release yet"
|
||||
@ls -lh dist/redflag-desktop.exe 2>/dev/null || echo "no Windows Desktop in the latest release yet"
|
||||
|
||||
db-up: ## Start PostgreSQL database
|
||||
docker-compose up -d postgres
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# RedFlag
|
||||
|
||||
**Self-hosted update management for operators who own their stack.**
|
||||
**Understand and govern the machines you own.**
|
||||
|
||||
`v0.2.9.3` — July 2026 · AGPL-3.0
|
||||
|
||||
|
|
@ -14,7 +14,9 @@
|
|||
|
||||
<!-- Showcase video goes here: terminal → install → multiple agents → updates. -->
|
||||
|
||||
One dashboard for updates across Linux, Windows, and the Docker containers running on those hosts. Agents check in, scan their package managers, and queue what they find. Nothing installs until a human approves it.
|
||||
RedFlag is a local-machine operations console and a self-hosted fleet authority. The native Qt/QML Desktop asks what this computer is doing now: health, resources, processes, connections, services, containers, installed software, updates, security evidence, and history. RedFlag Web asks the same questions across many machines.
|
||||
|
||||
The Agent owns observation and machine state. Desktop expresses operator intent; it does not shell out to package managers, service managers, or Docker. Privileged mutation crosses RedFlag's signed authority path or it does not happen.
|
||||
|
||||
What makes RedFlag different: the software that patches your fleet runs as root on every box, which makes it part of your attack surface — XZ Utils came through a build pipeline, SolarWinds came through an update. So every command here is Ed25519-signed and agents reject anything forged or replayed. On APT and DNF, direct package mutation must cross a privileged Rust helper: a short-lived capability binds the host, operation, and artifact entries whose hashes resolved, and the helper validates that authority before executing a fixed argv plan with a cleared environment. Docker, Winget, and Windows Update still use the default-strict signed-command path. The full trust model is in [SECURITY.md](SECURITY.md).
|
||||
|
||||
|
|
@ -41,6 +43,23 @@ ConnectWise charges $50/agent/month. RedFlag doesn't.
|
|||
|
||||
---
|
||||
|
||||
## RedFlag Desktop
|
||||
|
||||
Desktop is the native view of this machine, not a web dashboard wrapped in a window. It is built in Rust with Qt/QML and talks to the Agent over a local Unix socket or Windows named pipe.
|
||||
|
||||
The current Linux cut includes:
|
||||
|
||||
- live CPU, load, memory, swap, network, storage, and thermal history
|
||||
- process inventory and drill-down with sockets, namespaces, capabilities, service/container ownership, and owning package
|
||||
- systemd services, Docker containers and Compose stacks
|
||||
- installed pacman, dpkg/APT, and RPM/DNF software inventory with dependency and file detail
|
||||
- available updates, advisories, policy evidence, approval, and recorded override intent
|
||||
- security posture and durable local history
|
||||
|
||||
The Windows local transport exists, but a Windows Desktop artifact waits for a native Qt/MSVC release runner. The web application remains the fleet surface; it is not embedded into Desktop.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Server
|
||||
|
|
@ -126,6 +145,8 @@ The full trust model lives in [SECURITY.md](SECURITY.md), including how to repor
|
|||
- **Proxy support** — HTTP/HTTPS/SOCKS5 for restricted networks
|
||||
- **Native services** — systemd on Linux, Windows Services on Windows
|
||||
- **Full audit trail** — all operations logged with context, sanitized against log injection
|
||||
- **Native local console** — live machine health and operations beside the Agent, with no browser or cloud dependency
|
||||
- **Connected inspection** — follow a process into its service, container, socket, capability set, and owning package
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -155,6 +176,7 @@ The full trust model lives in [SECURITY.md](SECURITY.md), including how to repor
|
|||
- Setup accepts an operator-supplied signing keypair — bring-your-own-key deployments
|
||||
- Reversible token encryption with one-liner restore
|
||||
- Real-time heartbeat and rapid polling
|
||||
- Native Qt/QML Desktop source for health, performance, processes, network, storage, containers, services, software, updates, security, and history
|
||||
|
||||
**Not yet done:**
|
||||
- No AUR, Snap, Flatpak, or Homebrew support
|
||||
|
|
@ -165,6 +187,8 @@ The full trust model lives in [SECURITY.md](SECURITY.md), including how to repor
|
|||
- Helper-side rehashing of normal registry artifacts before mutation
|
||||
- Network isolation for the privileged helper invocation
|
||||
- Capability-helper execution for Docker, Winget, and Windows Update
|
||||
- Native Windows Desktop release artifact and installer proof
|
||||
- GPU telemetry, rich disk health, and vendor-specific power sensors
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ func NewLoopContext(cfg *config.Config, opts LoopContextOptions) (*LoopContext,
|
|||
return nil, fmt.Errorf("failed to initialize command handler: %w", err)
|
||||
}
|
||||
|
||||
// Initialize desktop manager (spawns Tauri system tray + local UI)
|
||||
// Initialize the native Desktop manager.
|
||||
var desktopMgr *desktop.Manager
|
||||
if opts.EnableDesktop {
|
||||
desktopMgr = desktop.NewManager(
|
||||
|
|
@ -371,12 +371,41 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
if ctx.DesktopManager != nil {
|
||||
onDesktopHealth = ctx.DesktopManager.RecordHealth
|
||||
}
|
||||
dockerProvider := func() (*localapi.DockerResponse, error) {
|
||||
scanner, err := orchestrator.NewDockerScanner()
|
||||
if err != nil {
|
||||
return &localapi.DockerResponse{Available: false, CollectedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
defer scanner.Close()
|
||||
if !scanner.IsAvailable() {
|
||||
return &localapi.DockerResponse{Available: false, CollectedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
containers, err := scanner.ScanContainers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &localapi.DockerResponse{
|
||||
Available: true, Version: scanner.GetEngineVersion(), Containers: containers,
|
||||
Stacks: scanner.ScanStacks(containers), Count: len(containers), CollectedAt: time.Now().UTC(),
|
||||
}
|
||||
for _, container := range containers {
|
||||
if container.State == "running" {
|
||||
response.Running++
|
||||
}
|
||||
if container.Health == "unhealthy" {
|
||||
response.Unhealthy++
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
localAPIServer, err := localapi.Start(localapi.Options{
|
||||
Config: ctx.Cfg,
|
||||
DesktopProvider: ctx.DesktopManager,
|
||||
TriggerScan: triggerScan,
|
||||
ApproveUpdate: approveUpdate,
|
||||
OnDesktopHealth: onDesktopHealth,
|
||||
DockerProvider: dockerProvider,
|
||||
EventsProvider: ctx.EventBuffer.ReadBufferedEvents,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.TeeLogger.Error("agent", "localapi", "localapi", fmt.Sprintf("start_failed error=%v", err), map[string]interface{}{"error": err.Error()})
|
||||
|
|
@ -385,7 +414,7 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
}
|
||||
|
||||
// Start desktop app (connects to the local API socket above).
|
||||
// On Linux the tray is launched by XDG autostart via the user's desktop
|
||||
// On Linux Desktop is launched by XDG autostart via the user's desktop
|
||||
// environment — the agent service must not spawn a second copy.
|
||||
if ctx.DesktopManager != nil && runtime.GOOS != "linux" {
|
||||
go ctx.DesktopManager.Start(ctx.Ctx)
|
||||
|
|
@ -1135,7 +1164,7 @@ func reportSystemInfo(apiClient *client.Client, cfg *config.Config, desktopMgr *
|
|||
}
|
||||
|
||||
// Desktop component state (UPDATE-002/INSTALL-004): installed/running/
|
||||
// version, sourced from the tray's own health reports since the Linux tray
|
||||
// version, sourced from Desktop's own health reports since the Linux app
|
||||
// is autostart-launched, not agent-spawned. Merges under
|
||||
// agent.metadata["desktop"] — the backend for a fleet "components
|
||||
// installed" indicator.
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ type Config struct {
|
|||
MigrationState *MigrationState `json:"migration_state,omitempty"` // Migration completion tracking
|
||||
}
|
||||
|
||||
// DesktopConfig controls the Tauri desktop app (system tray + local UI).
|
||||
// DesktopConfig controls the native Qt/QML local-machine operations console.
|
||||
// The agent service spawns the desktop binary as a child process when a
|
||||
// desktop session is detected. The binary connects back to the agent's
|
||||
// local API socket.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Package desktop manages the Tauri desktop app (system tray + local UI shell).
|
||||
// Package desktop manages the native Qt/QML local-machine operations console.
|
||||
// The agent service spawns the desktop binary as a child process when a desktop
|
||||
// session is available. The binary connects back to the agent's local API socket
|
||||
// and provides a system tray icon with a local dashboard.
|
||||
// and presents Agent-owned machine state and bounded operator intent.
|
||||
package desktop
|
||||
|
||||
import (
|
||||
|
|
@ -30,8 +30,8 @@ type Manager struct {
|
|||
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
|
||||
// healthReport is Desktop's most recent POST /v1/desktop self-report. On
|
||||
// Linux Desktop 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 {
|
||||
|
|
@ -40,8 +40,8 @@ type healthReport struct {
|
|||
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.
|
||||
// healthFreshness is how long a health report counts as proof of a live Desktop.
|
||||
// Desktop reports every 30s; three missed beats means it is gone.
|
||||
const healthFreshness = 90 * time.Second
|
||||
|
||||
// HealthSnapshot is the fleet-reportable desktop component state.
|
||||
|
|
@ -201,7 +201,7 @@ func (m *Manager) Status() (running bool, pid int) {
|
|||
return true, m.cmd.Process.Pid
|
||||
}
|
||||
|
||||
// RecordHealth stores the tray's self-report (POST /v1/desktop via localapi).
|
||||
// RecordHealth stores Desktop's self-report (POST /v1/desktop via localapi).
|
||||
func (m *Manager) RecordHealth(version string, windowOpen bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ var (
|
|||
ErrApprovalBlocked = errors.New("approval blocked by supply-chain gate")
|
||||
)
|
||||
|
||||
// LocalApproveRequest is the tray's approval submission.
|
||||
// LocalApproveRequest is RedFlag Desktop's approval submission.
|
||||
type LocalApproveRequest struct {
|
||||
PackageType string `json:"package_type"`
|
||||
PackageName string `json:"package_name"`
|
||||
|
|
@ -37,7 +37,7 @@ type LocalApproveRequest struct {
|
|||
}
|
||||
|
||||
// LocalApproveResult carries the gate verdicts plus the execution outcome so
|
||||
// the tray can render exactly what was checked and what happened.
|
||||
// Desktop can render exactly what was checked and what happened.
|
||||
type LocalApproveResult struct {
|
||||
RequestID string `json:"request_id"`
|
||||
OSVStatus string `json:"osv_status"`
|
||||
|
|
|
|||
|
|
@ -68,14 +68,14 @@ func RunPostUpgradeHealthcheck(cfg *config.Config) int {
|
|||
desktopPath += ".exe"
|
||||
}
|
||||
check(fileExists(desktopPath),
|
||||
"desktop binary not found at %s — system tray will not appear", desktopPath)
|
||||
"desktop binary not found at %s — local operations console will not appear", desktopPath)
|
||||
}
|
||||
|
||||
// --- Autostart entry (Linux only) ---
|
||||
if cfg.Desktop.Enabled && runtime.GOOS == "linux" {
|
||||
autostartPath := "/etc/xdg/autostart/redflag-desktop.desktop"
|
||||
check(fileExists(autostartPath),
|
||||
"desktop autostart entry not found at %s — tray will not start on next login", autostartPath)
|
||||
"desktop autostart entry not found at %s — RedFlag Desktop will not start on next login", autostartPath)
|
||||
}
|
||||
|
||||
// --- Socket directory permissions ---
|
||||
|
|
@ -88,9 +88,9 @@ func RunPostUpgradeHealthcheck(cfg *config.Config) int {
|
|||
if info, err := os.Stat(resolved); err == nil {
|
||||
perm := info.Mode().Perm()
|
||||
check(perm&0o010 != 0,
|
||||
"localapi socket dir %s has permissions %#o — the tray user cannot traverse to the socket (needs 0o10 group execute)", resolved, perm)
|
||||
"localapi socket dir %s has permissions %#o — the Desktop user cannot traverse to the socket (needs 0o10 group execute)", resolved, perm)
|
||||
} else {
|
||||
log.Printf("[WARN] [agent] [healthcheck] localapi socket dir %s not found — tray may not work: %v", resolved, err)
|
||||
log.Printf("[WARN] [agent] [healthcheck] localapi socket dir %s not found — Desktop may not work: %v", resolved, err)
|
||||
gaps++
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ type ClientOptions struct {
|
|||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Snapshot is the compact local state needed by status probes and tray shells.
|
||||
// Snapshot is the compact local state needed by status probes and Desktop.
|
||||
type Snapshot struct {
|
||||
Identity IdentityResponse `json:"identity"`
|
||||
Status StatusResponse `json:"status"`
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
)
|
||||
|
|
@ -52,6 +55,15 @@ type Options struct {
|
|||
DesktopProvider DesktopStatusProvider
|
||||
SystemProvider func() (*system.SystemInfo, error)
|
||||
ProcessProvider func(limit int) ([]system.TopProcess, error)
|
||||
MonitorProvider func() (*system.ResourceSnapshot, error)
|
||||
ProcessesProvider func() (*system.FullProcessSnapshot, error)
|
||||
ProcessDetailProvider func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
|
||||
SoftwareProvider func() (*system.SoftwareSnapshot, error)
|
||||
PackageDetailProvider func(packageType, identity string) (*system.PackageDetail, error)
|
||||
ConnectionsProvider func() (*system.ConnectionSnapshot, error)
|
||||
ServicesProvider func() (*system.ServiceSnapshot, error)
|
||||
DockerProvider func() (*DockerResponse, error)
|
||||
EventsProvider func() ([]*models.SystemEvent, error)
|
||||
// TriggerScan enqueues a package-update scan through the agent's existing
|
||||
// scanner primitives (FEAT-002 write path). Authorization is the OS-local
|
||||
// group boundary on the socket/pipe — anyone who can connect may trigger.
|
||||
|
|
@ -62,8 +74,8 @@ type Options struct {
|
|||
// Nil disables the endpoint (503). Wrap errors in ErrApprovalConflict /
|
||||
// ErrApprovalUnavailable to control the HTTP status.
|
||||
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-
|
||||
// OnDesktopHealth receives each Desktop self-report (POST /v1/desktop) so the
|
||||
// agent can track app liveness/version — on Linux Desktop is autostart-
|
||||
// launched and this is the only signal. Nil means reports are logged only.
|
||||
OnDesktopHealth func(version string, windowOpen bool)
|
||||
}
|
||||
|
|
@ -74,6 +86,7 @@ type Server struct {
|
|||
listener net.Listener
|
||||
address string
|
||||
logf func(format string, args ...interface{})
|
||||
monitor *system.ResourceMonitor
|
||||
}
|
||||
|
||||
// Start creates a platform-native local listener and serves the read-only local
|
||||
|
|
@ -89,6 +102,12 @@ func Start(opts Options) (*Server, error) {
|
|||
opts.RequestLog = log.Printf
|
||||
}
|
||||
|
||||
var monitor *system.ResourceMonitor
|
||||
if opts.MonitorProvider == nil {
|
||||
monitor = system.NewResourceMonitor(time.Second, 300)
|
||||
monitor.Start()
|
||||
opts.MonitorProvider = monitor.Snapshot
|
||||
}
|
||||
handler := newHandler(opts)
|
||||
httpServer := &http.Server{Handler: handler}
|
||||
|
||||
|
|
@ -98,6 +117,9 @@ func Start(opts Options) (*Server, error) {
|
|||
if listener == nil {
|
||||
listener, address, err = listen(opts)
|
||||
if err != nil {
|
||||
if monitor != nil {
|
||||
monitor.Stop()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
|
|
@ -109,6 +131,7 @@ func Start(opts Options) (*Server, error) {
|
|||
listener: listener,
|
||||
address: address,
|
||||
logf: opts.RequestLog,
|
||||
monitor: monitor,
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
|
@ -129,6 +152,9 @@ func (s *Server) Stop() {
|
|||
if err := s.httpServer.Close(); err != nil && s.logf != nil {
|
||||
s.logf("[WARNING] [agent] [localapi] stop_failed address=%s error=%v", s.address, err)
|
||||
}
|
||||
if s.monitor != nil {
|
||||
s.monitor.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
|
|
@ -140,6 +166,15 @@ type handler struct {
|
|||
onDesktopHealth func(version string, windowOpen bool)
|
||||
systemInfo func() (*system.SystemInfo, error)
|
||||
topProcesses func(limit int) ([]system.TopProcess, error)
|
||||
monitorSnapshot func() (*system.ResourceSnapshot, error)
|
||||
processes func() (*system.FullProcessSnapshot, error)
|
||||
processDetail func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
|
||||
software func() (*system.SoftwareSnapshot, error)
|
||||
packageDetail func(packageType, identity string) (*system.PackageDetail, error)
|
||||
connections func() (*system.ConnectionSnapshot, error)
|
||||
services func() (*system.ServiceSnapshot, error)
|
||||
docker func() (*DockerResponse, error)
|
||||
events func() ([]*models.SystemEvent, error)
|
||||
}
|
||||
|
||||
// DesktopStatusProvider allows the desktop manager to report its status.
|
||||
|
|
@ -205,6 +240,38 @@ type SystemResponse struct {
|
|||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type DockerResponse struct {
|
||||
Available bool `json:"available"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Containers []client.DockerReportContainer `json:"containers"`
|
||||
Stacks []client.DockerReportStack `json:"stacks"`
|
||||
Count int `json:"count"`
|
||||
Running int `json:"running"`
|
||||
Unhealthy int `json:"unhealthy"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type EventsResponse struct {
|
||||
Events []*models.SystemEvent `json:"events"`
|
||||
Count int `json:"count"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type SecurityResponse struct {
|
||||
CommandSigningEnabled bool `json:"command_signing_enabled"`
|
||||
CommandEnforcement string `json:"command_enforcement"`
|
||||
TLSVerification bool `json:"tls_verification"`
|
||||
SecurityLogging bool `json:"security_logging"`
|
||||
KernelEnforcement bool `json:"kernel_enforcement"`
|
||||
KernelFailClosed bool `json:"kernel_fail_closed"`
|
||||
DegradedMode bool `json:"degraded_mode"`
|
||||
Registered bool `json:"registered"`
|
||||
CriticalUpdates int `json:"critical_updates"`
|
||||
HighUpdates int `json:"high_updates"`
|
||||
Capabilities cache.CapabilityTokenState `json:"capabilities"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
func newHandler(opts Options) http.Handler {
|
||||
h := &handler{
|
||||
cfg: opts.Config,
|
||||
|
|
@ -215,6 +282,15 @@ func newHandler(opts Options) http.Handler {
|
|||
onDesktopHealth: opts.OnDesktopHealth,
|
||||
systemInfo: opts.SystemProvider,
|
||||
topProcesses: opts.ProcessProvider,
|
||||
monitorSnapshot: opts.MonitorProvider,
|
||||
processes: opts.ProcessesProvider,
|
||||
processDetail: opts.ProcessDetailProvider,
|
||||
software: opts.SoftwareProvider,
|
||||
packageDetail: opts.PackageDetailProvider,
|
||||
connections: opts.ConnectionsProvider,
|
||||
services: opts.ServicesProvider,
|
||||
docker: opts.DockerProvider,
|
||||
events: opts.EventsProvider,
|
||||
}
|
||||
if h.systemInfo == nil {
|
||||
h.systemInfo = func() (*system.SystemInfo, error) {
|
||||
|
|
@ -224,6 +300,24 @@ func newHandler(opts Options) http.Handler {
|
|||
if h.topProcesses == nil {
|
||||
h.topProcesses = system.GetTopProcesses
|
||||
}
|
||||
if h.processes == nil {
|
||||
h.processes = system.GetFullProcessSnapshot
|
||||
}
|
||||
if h.processDetail == nil {
|
||||
h.processDetail = system.GetProcessDetail
|
||||
}
|
||||
if h.software == nil {
|
||||
h.software = system.GetSoftwareSnapshot
|
||||
}
|
||||
if h.packageDetail == nil {
|
||||
h.packageDetail = system.GetPackageDetail
|
||||
}
|
||||
if h.connections == nil {
|
||||
h.connections = system.GetConnectionsSnapshot
|
||||
}
|
||||
if h.services == nil {
|
||||
h.services = system.GetServicesSnapshot
|
||||
}
|
||||
if h.loadCache == nil {
|
||||
h.loadCache = cache.Load
|
||||
}
|
||||
|
|
@ -233,6 +327,16 @@ func newHandler(opts Options) http.Handler {
|
|||
mux.HandleFunc("/v1/scans/latest", h.scansLatest)
|
||||
mux.HandleFunc("/v1/packages", h.scansLatest)
|
||||
mux.HandleFunc("/v1/system", h.system)
|
||||
mux.HandleFunc("/v1/monitor", h.monitor)
|
||||
mux.HandleFunc("/v1/processes", h.processList)
|
||||
mux.HandleFunc("/v1/processes/", h.processByPID)
|
||||
mux.HandleFunc("/v1/software", h.softwareList)
|
||||
mux.HandleFunc("/v1/software/detail", h.softwareDetail)
|
||||
mux.HandleFunc("/v1/connections", h.connectionList)
|
||||
mux.HandleFunc("/v1/services", h.serviceList)
|
||||
mux.HandleFunc("/v1/containers", h.containerList)
|
||||
mux.HandleFunc("/v1/events", h.eventList)
|
||||
mux.HandleFunc("/v1/security", h.security)
|
||||
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
|
||||
mux.HandleFunc("/v1/desktop", h.desktopHealth)
|
||||
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
|
||||
|
|
@ -240,6 +344,192 @@ func newHandler(opts Options) http.Handler {
|
|||
return mux
|
||||
}
|
||||
|
||||
func (h *handler) containerList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
if h.docker == nil {
|
||||
writeJSON(w, DockerResponse{Available: false, CollectedAt: time.Now().UTC()})
|
||||
return
|
||||
}
|
||||
snapshot, err := h.docker()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] container_inventory_failed error=%v", err)
|
||||
http.Error(w, "container inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) eventList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
if h.events == nil {
|
||||
writeJSON(w, EventsResponse{CollectedAt: time.Now().UTC()})
|
||||
return
|
||||
}
|
||||
events, err := h.events()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] event_history_failed error=%v", err)
|
||||
http.Error(w, "local event history unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
sort.Slice(events, func(i, j int) bool { return events[i].CreatedAt.After(events[j].CreatedAt) })
|
||||
writeJSON(w, EventsResponse{Events: events, Count: len(events), CollectedAt: time.Now().UTC()})
|
||||
}
|
||||
|
||||
func (h *handler) security(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
localCache, ok := h.load(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response := SecurityResponse{
|
||||
CommandSigningEnabled: h.cfg.CommandSigning.Enabled,
|
||||
CommandEnforcement: h.cfg.CommandSigning.EnforcementMode,
|
||||
TLSVerification: !h.cfg.TLS.InsecureSkipVerify,
|
||||
SecurityLogging: h.cfg.SecurityLogging.Enabled,
|
||||
KernelEnforcement: h.cfg.KernelEnforcement.Enabled,
|
||||
KernelFailClosed: h.cfg.KernelEnforcement.FailClosed,
|
||||
DegradedMode: h.cfg.DegradedMode,
|
||||
Registered: h.cfg.IsRegistered(),
|
||||
CriticalUpdates: localCache.Summary.BySeverity["critical"],
|
||||
HighUpdates: localCache.Summary.BySeverity["high"] + localCache.Summary.BySeverity["important"],
|
||||
Capabilities: localCache.Capabilities,
|
||||
CollectedAt: time.Now().UTC(),
|
||||
}
|
||||
writeJSON(w, response)
|
||||
}
|
||||
|
||||
func (h *handler) connectionList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
snapshot, err := h.connections()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] connection_inventory_failed error=%v", err)
|
||||
http.Error(w, "connection inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) serviceList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
snapshot, err := h.services()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] service_inventory_failed error=%v", err)
|
||||
http.Error(w, "service inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) monitor(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
if h.monitorSnapshot == nil {
|
||||
http.Error(w, "resource monitor unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
snapshot, err := h.monitorSnapshot()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] resource_monitor_failed error=%v", err)
|
||||
http.Error(w, "resource monitor unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) processList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
snapshot, err := h.processes()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] process_inventory_failed error=%v", err)
|
||||
http.Error(w, "process inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) processByPID(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
rawPID := strings.TrimPrefix(r.URL.Path, "/v1/processes/")
|
||||
pid, err := strconv.Atoi(rawPID)
|
||||
if err != nil || pid <= 0 || strings.Contains(rawPID, "/") {
|
||||
http.Error(w, "invalid process id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
caps := processCaps(h.cfg.ProcessExplorer)
|
||||
process, err := h.processDetail(pid, caps)
|
||||
if err != nil {
|
||||
log.Printf("[INFO] [agent] [localapi] process_detail_unavailable pid=%d error=%v", pid, err)
|
||||
http.Error(w, "process unavailable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, process)
|
||||
}
|
||||
|
||||
func (h *handler) softwareList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
snapshot, err := h.software()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] software_inventory_failed error=%v", err)
|
||||
http.Error(w, "software inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) softwareDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
packageType := strings.TrimSpace(r.URL.Query().Get("manager"))
|
||||
identity := strings.TrimSpace(r.URL.Query().Get("identity"))
|
||||
if packageType == "" || identity == "" {
|
||||
http.Error(w, "manager and identity are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
detail, err := h.packageDetail(packageType, identity)
|
||||
if err != nil {
|
||||
log.Printf("[INFO] [agent] [localapi] software_detail_unavailable manager=%s identity=%s error=%v", packageType, identity, err)
|
||||
http.Error(w, "software detail unavailable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, detail)
|
||||
}
|
||||
|
||||
func processCaps(cfg config.ProcessExplorerConfig) system.ProcessCaps {
|
||||
capOr := func(value, fallback int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
return system.ProcessCaps{
|
||||
MaxOpenFiles: capOr(cfg.MaxOpenFiles, 2000),
|
||||
MaxSockets: capOr(cfg.MaxSockets, 500),
|
||||
MaxPipes: capOr(cfg.MaxPipes, 500),
|
||||
MaxMemoryMap: capOr(cfg.MaxMemoryMap, 2000),
|
||||
MaxNamespaces: capOr(cfg.MaxNamespaces, 50),
|
||||
MaxEnvKeys: capOr(cfg.MaxEnvKeys, 200),
|
||||
MaxListeningPorts: capOr(cfg.MaxListeningPorts, 100),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) system(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -158,6 +158,113 @@ func TestSystemHealthUsesAgentCollectors(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMonitorAndProcessInventoryUseAgentCollectors(t *testing.T) {
|
||||
now := time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC)
|
||||
handler := newHandler(Options{
|
||||
Config: testConfig(t),
|
||||
LoadCache: func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
},
|
||||
MonitorProvider: func() (*system.ResourceSnapshot, error) {
|
||||
return &system.ResourceSnapshot{
|
||||
CollectedAt: now,
|
||||
CPU: system.CPUMetrics{UsagePercent: 27.5, Load1: 1.2},
|
||||
Memory: system.MemoryMetrics{UsedPercent: 42},
|
||||
History: []system.ResourcePoint{{Timestamp: now, CPUPercent: 27.5}},
|
||||
}, nil
|
||||
},
|
||||
ProcessesProvider: func() (*system.FullProcessSnapshot, error) {
|
||||
return &system.FullProcessSnapshot{
|
||||
Processes: []system.FullProcess{{PID: 42, Name: "redflag-agent", User: "root"}},
|
||||
ProcessCount: 1,
|
||||
ScannedAt: now,
|
||||
}, nil
|
||||
},
|
||||
ProcessDetailProvider: func(pid int, caps system.ProcessCaps) (*system.FullProcess, error) {
|
||||
if pid != 42 {
|
||||
t.Fatalf("pid = %d, want 42", pid)
|
||||
}
|
||||
if caps.MaxSockets != 500 || caps.MaxOpenFiles != 2000 {
|
||||
t.Fatalf("default process caps = %#v", caps)
|
||||
}
|
||||
return &system.FullProcess{PID: pid, Name: "redflag-agent", Path: "/usr/bin/redflag-agent"}, nil
|
||||
},
|
||||
SoftwareProvider: func() (*system.SoftwareSnapshot, error) {
|
||||
return &system.SoftwareSnapshot{
|
||||
Supported: true,
|
||||
Packages: []system.InstalledPackage{{PackageType: "pacman", Identity: "redflag-agent", Name: "redflag-agent", Version: "0.3.0", InstallReason: "explicit", Origin: "repository"}},
|
||||
Count: 1,
|
||||
}, nil
|
||||
},
|
||||
PackageDetailProvider: func(packageType, identity string) (*system.PackageDetail, error) {
|
||||
if packageType != "pacman" || identity != "redflag-agent" {
|
||||
t.Fatalf("package detail request = %s/%s", packageType, identity)
|
||||
}
|
||||
return &system.PackageDetail{InstalledPackage: system.InstalledPackage{PackageType: packageType, Identity: identity, Name: identity, Version: "0.3.0"}, DependsOn: []string{"glibc"}}, nil
|
||||
},
|
||||
ConnectionsProvider: func() (*system.ConnectionSnapshot, error) {
|
||||
return &system.ConnectionSnapshot{Connections: []system.Connection{{PID: 42, Process: "redflag-agent", Protocol: "TCP", LocalPort: 443, State: "LISTEN"}}, Count: 1, CollectedAt: now}, nil
|
||||
},
|
||||
ServicesProvider: func() (*system.ServiceSnapshot, error) {
|
||||
return &system.ServiceSnapshot{Manager: "systemd", Services: []system.Service{{Name: "redflag-agent", ActiveState: "active", SubState: "running"}}, Count: 1, Running: 1, CollectedAt: now}, nil
|
||||
},
|
||||
})
|
||||
|
||||
var monitor system.ResourceSnapshot
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/monitor", &monitor)
|
||||
if monitor.CPU.UsagePercent != 27.5 || len(monitor.History) != 1 {
|
||||
t.Fatalf("monitor = %#v", monitor)
|
||||
}
|
||||
|
||||
var processes system.FullProcessSnapshot
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/processes", &processes)
|
||||
if processes.ProcessCount != 1 || processes.Processes[0].Name != "redflag-agent" {
|
||||
t.Fatalf("processes = %#v", processes)
|
||||
}
|
||||
|
||||
var process system.FullProcess
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/processes/42", &process)
|
||||
if process.Path != "/usr/bin/redflag-agent" {
|
||||
t.Fatalf("process detail = %#v", process)
|
||||
}
|
||||
|
||||
var software system.SoftwareSnapshot
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/software", &software)
|
||||
if software.Count != 1 || software.Packages[0].Version != "0.3.0" {
|
||||
t.Fatalf("software = %#v", software)
|
||||
}
|
||||
|
||||
var packageDetail system.PackageDetail
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/software/detail?manager=pacman&identity=redflag-agent", &packageDetail)
|
||||
if packageDetail.Name != "redflag-agent" || len(packageDetail.DependsOn) != 1 {
|
||||
t.Fatalf("package detail = %#v", packageDetail)
|
||||
}
|
||||
|
||||
var connections system.ConnectionSnapshot
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/connections", &connections)
|
||||
if connections.Count != 1 || connections.Connections[0].State != "LISTEN" {
|
||||
t.Fatalf("connections = %#v", connections)
|
||||
}
|
||||
|
||||
var services system.ServiceSnapshot
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/services", &services)
|
||||
if services.Running != 1 || services.Services[0].Name != "redflag-agent" {
|
||||
t.Fatalf("services = %#v", services)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailRejectsInvalidPID(t *testing.T) {
|
||||
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/processes/not-a-pid", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
|
||||
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
|
||||
return nil, errors.New("cannot read cache")
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ func signalDesktopRestart(target string) error {
|
|||
return nil
|
||||
}
|
||||
// pkill exit 1 = no process matched; taskkill exit 128 = process not found.
|
||||
// Both are non-errors — the tray wasn't running.
|
||||
// Both are non-errors — Desktop wasn't running.
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
if (runtime.GOOS == "linux" && exitErr.ExitCode() == 1) ||
|
||||
(runtime.GOOS == "windows" && exitErr.ExitCode() == 128) {
|
||||
|
|
@ -301,4 +301,3 @@ func computeFileSHA256Hex(path string) (string, error) {
|
|||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
|
|
|
|||
26
agent/internal/system/connections.go
Normal file
26
agent/internal/system/connections.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package system
|
||||
|
||||
import "time"
|
||||
|
||||
type ConnectionSnapshot struct {
|
||||
Connections []Connection `json:"connections"`
|
||||
Count int `json:"count"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
PID int `json:"pid,omitempty"`
|
||||
Process string `json:"process,omitempty"`
|
||||
Protocol string `json:"protocol"`
|
||||
Family string `json:"family"`
|
||||
LocalAddr string `json:"local_addr"`
|
||||
LocalPort int `json:"local_port"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
RemotePort int `json:"remote_port,omitempty"`
|
||||
State string `json:"state"`
|
||||
Inode uint64 `json:"inode"`
|
||||
}
|
||||
|
||||
func GetConnectionsSnapshot() (*ConnectionSnapshot, error) {
|
||||
return getConnectionsSnapshot()
|
||||
}
|
||||
104
agent/internal/system/connections_linux.go
Normal file
104
agent/internal/system/connections_linux.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
//go:build linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func getConnectionsSnapshot() (*ConnectionSnapshot, error) {
|
||||
owners := socketOwners()
|
||||
var connections []Connection
|
||||
for _, source := range []struct {
|
||||
path, protocol, family string
|
||||
}{
|
||||
{"/proc/net/tcp", "TCP", "IPv4"},
|
||||
{"/proc/net/tcp6", "TCP", "IPv6"},
|
||||
{"/proc/net/udp", "UDP", "IPv4"},
|
||||
{"/proc/net/udp6", "UDP", "IPv6"},
|
||||
} {
|
||||
data, err := os.ReadFile(source.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for index, line := range strings.Split(string(data), "\n") {
|
||||
if index == 0 || strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
localAddr, localPort := parseHexAddr(fields[1])
|
||||
remoteAddr, remotePort := parseHexAddr(fields[2])
|
||||
inode := atouint64(fields[9])
|
||||
owner := owners[inode]
|
||||
state := fields[3]
|
||||
if source.protocol == "TCP" {
|
||||
state = tcpState(state)
|
||||
}
|
||||
connections = append(connections, Connection{
|
||||
PID: owner.pid, Process: owner.name, Protocol: source.protocol, Family: source.family,
|
||||
LocalAddr: localAddr, LocalPort: localPort, RemoteAddr: remoteAddr,
|
||||
RemotePort: remotePort, State: state, Inode: inode,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(connections) == 0 {
|
||||
if _, err := os.Stat("/proc/net/tcp"); err != nil {
|
||||
return nil, fmt.Errorf("read network connection table: %w", err)
|
||||
}
|
||||
}
|
||||
sort.Slice(connections, func(i, j int) bool {
|
||||
if connections[i].State != connections[j].State {
|
||||
return connections[i].State == "LISTEN"
|
||||
}
|
||||
if connections[i].Process != connections[j].Process {
|
||||
return connections[i].Process < connections[j].Process
|
||||
}
|
||||
return connections[i].LocalPort < connections[j].LocalPort
|
||||
})
|
||||
return &ConnectionSnapshot{Connections: connections, Count: len(connections), CollectedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
|
||||
type socketOwner struct {
|
||||
pid int
|
||||
name string
|
||||
}
|
||||
|
||||
func socketOwners() map[uint64]socketOwner {
|
||||
owners := make(map[uint64]socketOwner)
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return owners
|
||||
}
|
||||
for _, entry := range entries {
|
||||
pid, err := strconv.Atoi(entry.Name())
|
||||
if err != nil || !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
nameBytes, _ := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
|
||||
name := strings.TrimSpace(string(nameBytes))
|
||||
fds, err := os.ReadDir(fmt.Sprintf("/proc/%d/fd", pid))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, fd := range fds {
|
||||
target, err := os.Readlink(fmt.Sprintf("/proc/%d/fd/%s", pid, fd.Name()))
|
||||
if err != nil || !strings.HasPrefix(target, "socket:[") {
|
||||
continue
|
||||
}
|
||||
inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]")
|
||||
value, err := strconv.ParseUint(inode, 10, 64)
|
||||
if err == nil {
|
||||
owners[value] = socketOwner{pid: pid, name: name}
|
||||
}
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
9
agent/internal/system/connections_other.go
Normal file
9
agent/internal/system/connections_other.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build !linux
|
||||
|
||||
package system
|
||||
|
||||
import "fmt"
|
||||
|
||||
func getConnectionsSnapshot() (*ConnectionSnapshot, error) {
|
||||
return nil, fmt.Errorf("connection inventory not supported on this platform")
|
||||
}
|
||||
297
agent/internal/system/monitor.go
Normal file
297
agent/internal/system/monitor.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrMonitorUnavailable = errors.New("system resource monitor unavailable")
|
||||
|
||||
// ResourceMonitor owns the bounded, live machine history consumed by Desktop.
|
||||
// Collection stays in the Agent: UIs receive observations and express intent;
|
||||
// they never learn a second way to inspect or mutate the machine.
|
||||
type ResourceMonitor struct {
|
||||
mu sync.RWMutex
|
||||
interval time.Duration
|
||||
capacity int
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
started bool
|
||||
previous *rawMonitorSample
|
||||
latest *ResourceSnapshot
|
||||
lastErr error
|
||||
history []ResourcePoint
|
||||
}
|
||||
|
||||
type ResourceSnapshot struct {
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
IntervalMS int64 `json:"interval_ms"`
|
||||
CPU CPUMetrics `json:"cpu"`
|
||||
Memory MemoryMetrics `json:"memory"`
|
||||
Network NetworkMetrics `json:"network"`
|
||||
Storage StorageMetrics `json:"storage"`
|
||||
Thermals []ThermalReading `json:"thermals,omitempty"`
|
||||
History []ResourcePoint `json:"history"`
|
||||
}
|
||||
|
||||
type CPUMetrics struct {
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
UserPercent float64 `json:"user_percent"`
|
||||
SystemPercent float64 `json:"system_percent"`
|
||||
IOWaitPercent float64 `json:"iowait_percent"`
|
||||
Load1 float64 `json:"load_1"`
|
||||
Load5 float64 `json:"load_5"`
|
||||
Load15 float64 `json:"load_15"`
|
||||
PerCore []CoreUsage `json:"per_core"`
|
||||
}
|
||||
|
||||
type CoreUsage struct {
|
||||
Core int `json:"core"`
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
type MemoryMetrics struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
AvailableBytes uint64 `json:"available_bytes"`
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
SwapTotalBytes uint64 `json:"swap_total_bytes"`
|
||||
SwapUsedBytes uint64 `json:"swap_used_bytes"`
|
||||
SwapPercent float64 `json:"swap_percent"`
|
||||
}
|
||||
|
||||
type NetworkMetrics struct {
|
||||
ReceiveBytesPerSecond float64 `json:"receive_bytes_per_second"`
|
||||
TransmitBytesPerSecond float64 `json:"transmit_bytes_per_second"`
|
||||
ReceiveBytes uint64 `json:"receive_bytes"`
|
||||
TransmitBytes uint64 `json:"transmit_bytes"`
|
||||
Interfaces []NetworkInterface `json:"interfaces"`
|
||||
}
|
||||
|
||||
type NetworkInterface struct {
|
||||
Name string `json:"name"`
|
||||
ReceiveBytesPerSecond float64 `json:"receive_bytes_per_second"`
|
||||
TransmitBytesPerSecond float64 `json:"transmit_bytes_per_second"`
|
||||
ReceiveBytes uint64 `json:"receive_bytes"`
|
||||
TransmitBytes uint64 `json:"transmit_bytes"`
|
||||
}
|
||||
|
||||
type StorageMetrics struct {
|
||||
ReadBytesPerSecond float64 `json:"read_bytes_per_second"`
|
||||
WriteBytesPerSecond float64 `json:"write_bytes_per_second"`
|
||||
ReadBytes uint64 `json:"read_bytes"`
|
||||
WriteBytes uint64 `json:"write_bytes"`
|
||||
}
|
||||
|
||||
type ThermalReading struct {
|
||||
Name string `json:"name"`
|
||||
Celsius float64 `json:"celsius"`
|
||||
}
|
||||
|
||||
type ResourcePoint struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryPercent float64 `json:"memory_percent"`
|
||||
NetworkReceivePerSec float64 `json:"network_receive_per_second"`
|
||||
NetworkTransmitPerSec float64 `json:"network_transmit_per_second"`
|
||||
DiskReadPerSec float64 `json:"disk_read_per_second"`
|
||||
DiskWritePerSec float64 `json:"disk_write_per_second"`
|
||||
}
|
||||
|
||||
type cpuCounters struct {
|
||||
User, Nice, System, Idle, IOWait, IRQ, SoftIRQ, Steal uint64
|
||||
}
|
||||
|
||||
func (c cpuCounters) total() uint64 {
|
||||
return c.User + c.Nice + c.System + c.Idle + c.IOWait + c.IRQ + c.SoftIRQ + c.Steal
|
||||
}
|
||||
|
||||
func (c cpuCounters) idle() uint64 { return c.Idle + c.IOWait }
|
||||
|
||||
type byteCounters struct{ Receive, Transmit uint64 }
|
||||
|
||||
type rawMonitorSample struct {
|
||||
at time.Time
|
||||
cpu cpuCounters
|
||||
cores []cpuCounters
|
||||
load [3]float64
|
||||
memory MemoryMetrics
|
||||
network map[string]byteCounters
|
||||
diskRead uint64
|
||||
diskWrite uint64
|
||||
thermals []ThermalReading
|
||||
}
|
||||
|
||||
func NewResourceMonitor(interval time.Duration, capacity int) *ResourceMonitor {
|
||||
if interval <= 0 {
|
||||
interval = time.Second
|
||||
}
|
||||
if capacity <= 0 {
|
||||
capacity = 300
|
||||
}
|
||||
return &ResourceMonitor{
|
||||
interval: interval,
|
||||
capacity: capacity,
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ResourceMonitor) Start() {
|
||||
m.mu.Lock()
|
||||
if m.started {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.started = true
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer close(m.done)
|
||||
m.collect()
|
||||
ticker := time.NewTicker(m.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.collect()
|
||||
case <-m.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *ResourceMonitor) Stop() {
|
||||
m.mu.Lock()
|
||||
if !m.started {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.started = false
|
||||
close(m.stop)
|
||||
m.mu.Unlock()
|
||||
<-m.done
|
||||
}
|
||||
|
||||
func (m *ResourceMonitor) Snapshot() (*ResourceSnapshot, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.latest == nil {
|
||||
if m.lastErr != nil {
|
||||
return nil, m.lastErr
|
||||
}
|
||||
return nil, ErrMonitorUnavailable
|
||||
}
|
||||
copy := *m.latest
|
||||
copy.CPU.PerCore = append([]CoreUsage(nil), m.latest.CPU.PerCore...)
|
||||
copy.Network.Interfaces = append([]NetworkInterface(nil), m.latest.Network.Interfaces...)
|
||||
copy.Thermals = append([]ThermalReading(nil), m.latest.Thermals...)
|
||||
copy.History = append([]ResourcePoint(nil), m.history...)
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (m *ResourceMonitor) collect() {
|
||||
raw, err := readRawMonitorSample()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err != nil {
|
||||
m.lastErr = err
|
||||
return
|
||||
}
|
||||
|
||||
snapshot := snapshotFromRaw(raw, m.previous, m.interval)
|
||||
m.previous = raw
|
||||
m.lastErr = nil
|
||||
point := ResourcePoint{
|
||||
Timestamp: snapshot.CollectedAt,
|
||||
CPUPercent: snapshot.CPU.UsagePercent,
|
||||
MemoryPercent: snapshot.Memory.UsedPercent,
|
||||
NetworkReceivePerSec: snapshot.Network.ReceiveBytesPerSecond,
|
||||
NetworkTransmitPerSec: snapshot.Network.TransmitBytesPerSecond,
|
||||
DiskReadPerSec: snapshot.Storage.ReadBytesPerSecond,
|
||||
DiskWritePerSec: snapshot.Storage.WriteBytesPerSecond,
|
||||
}
|
||||
m.history = append(m.history, point)
|
||||
if len(m.history) > m.capacity {
|
||||
copy(m.history, m.history[len(m.history)-m.capacity:])
|
||||
m.history = m.history[:m.capacity]
|
||||
}
|
||||
snapshot.History = append([]ResourcePoint(nil), m.history...)
|
||||
m.latest = snapshot
|
||||
}
|
||||
|
||||
func snapshotFromRaw(now, previous *rawMonitorSample, fallback time.Duration) *ResourceSnapshot {
|
||||
elapsed := fallback.Seconds()
|
||||
if previous != nil {
|
||||
elapsed = now.at.Sub(previous.at).Seconds()
|
||||
}
|
||||
if elapsed <= 0 {
|
||||
elapsed = 1
|
||||
}
|
||||
|
||||
cpu := CPUMetrics{Load1: now.load[0], Load5: now.load[1], Load15: now.load[2]}
|
||||
if previous != nil {
|
||||
cpu.UsagePercent, cpu.UserPercent, cpu.SystemPercent, cpu.IOWaitPercent = cpuDelta(now.cpu, previous.cpu)
|
||||
for index, counters := range now.cores {
|
||||
usage := 0.0
|
||||
if index < len(previous.cores) {
|
||||
usage, _, _, _ = cpuDelta(counters, previous.cores[index])
|
||||
}
|
||||
cpu.PerCore = append(cpu.PerCore, CoreUsage{Core: index, UsagePercent: usage})
|
||||
}
|
||||
}
|
||||
|
||||
network := NetworkMetrics{}
|
||||
for name, counters := range now.network {
|
||||
iface := NetworkInterface{Name: name, ReceiveBytes: counters.Receive, TransmitBytes: counters.Transmit}
|
||||
if previous != nil {
|
||||
before := previous.network[name]
|
||||
iface.ReceiveBytesPerSecond = perSecond(counters.Receive, before.Receive, elapsed)
|
||||
iface.TransmitBytesPerSecond = perSecond(counters.Transmit, before.Transmit, elapsed)
|
||||
}
|
||||
network.Interfaces = append(network.Interfaces, iface)
|
||||
if name != "lo" {
|
||||
network.ReceiveBytes += counters.Receive
|
||||
network.TransmitBytes += counters.Transmit
|
||||
network.ReceiveBytesPerSecond += iface.ReceiveBytesPerSecond
|
||||
network.TransmitBytesPerSecond += iface.TransmitBytesPerSecond
|
||||
}
|
||||
}
|
||||
|
||||
storage := StorageMetrics{ReadBytes: now.diskRead, WriteBytes: now.diskWrite}
|
||||
if previous != nil {
|
||||
storage.ReadBytesPerSecond = perSecond(now.diskRead, previous.diskRead, elapsed)
|
||||
storage.WriteBytesPerSecond = perSecond(now.diskWrite, previous.diskWrite, elapsed)
|
||||
}
|
||||
|
||||
return &ResourceSnapshot{
|
||||
CollectedAt: now.at,
|
||||
IntervalMS: int64(elapsed * 1000),
|
||||
CPU: cpu,
|
||||
Memory: now.memory,
|
||||
Network: network,
|
||||
Storage: storage,
|
||||
Thermals: now.thermals,
|
||||
}
|
||||
}
|
||||
|
||||
func cpuDelta(now, before cpuCounters) (usage, user, system, iowait float64) {
|
||||
total := now.total() - before.total()
|
||||
if total == 0 {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
percent := func(delta uint64) float64 { return float64(delta) * 100 / float64(total) }
|
||||
return percent(total - (now.idle() - before.idle())),
|
||||
percent((now.User + now.Nice) - (before.User + before.Nice)),
|
||||
percent((now.System + now.IRQ + now.SoftIRQ) - (before.System + before.IRQ + before.SoftIRQ)),
|
||||
percent(now.IOWait - before.IOWait)
|
||||
}
|
||||
|
||||
func perSecond(now, before uint64, elapsed float64) float64 {
|
||||
if now < before || elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(now-before) / elapsed
|
||||
}
|
||||
205
agent/internal/system/monitor_linux.go
Normal file
205
agent/internal/system/monitor_linux.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
//go:build linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func readRawMonitorSample() (*rawMonitorSample, error) {
|
||||
cpu, cores, err := readCPUCounters("/proc/stat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memory, err := readMemoryMetrics("/proc/meminfo")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
load, _ := readLoadAverage("/proc/loadavg")
|
||||
network, _ := readNetworkCounters("/proc/net/dev")
|
||||
diskRead, diskWrite := readDiskCounters()
|
||||
return &rawMonitorSample{
|
||||
at: time.Now().UTC(),
|
||||
cpu: cpu,
|
||||
cores: cores,
|
||||
load: load,
|
||||
memory: memory,
|
||||
network: network,
|
||||
diskRead: diskRead,
|
||||
diskWrite: diskWrite,
|
||||
thermals: readThermals(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readCPUCounters(path string) (cpuCounters, []cpuCounters, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return cpuCounters{}, nil, fmt.Errorf("read cpu counters: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
var total cpuCounters
|
||||
var cores []cpuCounters
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 5 || !strings.HasPrefix(fields[0], "cpu") {
|
||||
if len(cores) > 0 {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
counters := parseCPUFields(fields[1:])
|
||||
if fields[0] == "cpu" {
|
||||
total = counters
|
||||
} else {
|
||||
cores = append(cores, counters)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return cpuCounters{}, nil, err
|
||||
}
|
||||
if total.total() == 0 {
|
||||
return cpuCounters{}, nil, fmt.Errorf("cpu counters are empty")
|
||||
}
|
||||
return total, cores, nil
|
||||
}
|
||||
|
||||
func parseCPUFields(fields []string) cpuCounters {
|
||||
values := make([]uint64, 8)
|
||||
for i := 0; i < len(values) && i < len(fields); i++ {
|
||||
values[i], _ = strconv.ParseUint(fields[i], 10, 64)
|
||||
}
|
||||
return cpuCounters{User: values[0], Nice: values[1], System: values[2], Idle: values[3], IOWait: values[4], IRQ: values[5], SoftIRQ: values[6], Steal: values[7]}
|
||||
}
|
||||
|
||||
func readMemoryMetrics(path string) (MemoryMetrics, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return MemoryMetrics{}, fmt.Errorf("read memory counters: %w", err)
|
||||
}
|
||||
values := map[string]uint64{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
value, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
values[key] = value * 1024
|
||||
}
|
||||
}
|
||||
total := values["MemTotal"]
|
||||
available := values["MemAvailable"]
|
||||
if total == 0 {
|
||||
return MemoryMetrics{}, fmt.Errorf("MemTotal is unavailable")
|
||||
}
|
||||
used := total - available
|
||||
swapTotal := values["SwapTotal"]
|
||||
swapUsed := swapTotal - values["SwapFree"]
|
||||
metrics := MemoryMetrics{TotalBytes: total, UsedBytes: used, AvailableBytes: available, UsedPercent: float64(used) * 100 / float64(total), SwapTotalBytes: swapTotal, SwapUsedBytes: swapUsed}
|
||||
if swapTotal > 0 {
|
||||
metrics.SwapPercent = float64(swapUsed) * 100 / float64(swapTotal)
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func readLoadAverage(path string) ([3]float64, error) {
|
||||
var load [3]float64
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return load, err
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
for i := 0; i < 3 && i < len(fields); i++ {
|
||||
load[i], _ = strconv.ParseFloat(fields[i], 64)
|
||||
}
|
||||
return load, nil
|
||||
}
|
||||
|
||||
func readNetworkCounters(path string) (map[string]byteCounters, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]byteCounters)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
name, values, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(values)
|
||||
if len(fields) < 9 {
|
||||
continue
|
||||
}
|
||||
receive, _ := strconv.ParseUint(fields[0], 10, 64)
|
||||
transmit, _ := strconv.ParseUint(fields[8], 10, 64)
|
||||
result[strings.TrimSpace(name)] = byteCounters{Receive: receive, Transmit: transmit}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readDiskCounters() (uint64, uint64) {
|
||||
devices, err := os.ReadDir("/sys/block")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
whole := make(map[string]struct{}, len(devices))
|
||||
for _, device := range devices {
|
||||
name := device.Name()
|
||||
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "dm-") {
|
||||
continue
|
||||
}
|
||||
whole[name] = struct{}{}
|
||||
}
|
||||
data, err := os.ReadFile("/proc/diskstats")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
var read, written uint64
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
if _, ok := whole[fields[2]]; !ok {
|
||||
continue
|
||||
}
|
||||
sectorsRead, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
sectorsWritten, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||
read += sectorsRead * 512
|
||||
written += sectorsWritten * 512
|
||||
}
|
||||
return read, written
|
||||
}
|
||||
|
||||
func readThermals() []ThermalReading {
|
||||
paths, _ := filepath.Glob("/sys/class/thermal/thermal_zone*/temp")
|
||||
var readings []ThermalReading
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
name := filepath.Base(filepath.Dir(path))
|
||||
if data, err := os.ReadFile(filepath.Join(filepath.Dir(path), "type")); err == nil {
|
||||
name = strings.TrimSpace(string(data))
|
||||
}
|
||||
if value > 1000 {
|
||||
value /= 1000
|
||||
}
|
||||
if value > -100 && value < 250 {
|
||||
readings = append(readings, ThermalReading{Name: name, Celsius: value})
|
||||
}
|
||||
}
|
||||
sort.Slice(readings, func(i, j int) bool { return readings[i].Name < readings[j].Name })
|
||||
return readings
|
||||
}
|
||||
9
agent/internal/system/monitor_other.go
Normal file
9
agent/internal/system/monitor_other.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build !linux
|
||||
|
||||
package system
|
||||
|
||||
import "fmt"
|
||||
|
||||
func readRawMonitorSample() (*rawMonitorSample, error) {
|
||||
return nil, fmt.Errorf("%w on this platform", ErrMonitorUnavailable)
|
||||
}
|
||||
68
agent/internal/system/monitor_test.go
Normal file
68
agent/internal/system/monitor_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSnapshotFromRawComputesRatesAndHistoryPointInputs(t *testing.T) {
|
||||
before := &rawMonitorSample{
|
||||
at: time.Unix(100, 0),
|
||||
cpu: cpuCounters{User: 100, System: 50, Idle: 850},
|
||||
cores: []cpuCounters{{User: 100, Idle: 900}},
|
||||
memory: MemoryMetrics{TotalBytes: 1000, UsedBytes: 400, AvailableBytes: 600, UsedPercent: 40},
|
||||
network: map[string]byteCounters{"eth0": {Receive: 1000, Transmit: 2000}},
|
||||
diskRead: 5000,
|
||||
diskWrite: 8000,
|
||||
}
|
||||
now := &rawMonitorSample{
|
||||
at: time.Unix(102, 0),
|
||||
cpu: cpuCounters{User: 180, System: 70, Idle: 950},
|
||||
cores: []cpuCounters{{User: 160, Idle: 1040}},
|
||||
load: [3]float64{1.2, 0.8, 0.5},
|
||||
memory: MemoryMetrics{TotalBytes: 1000, UsedBytes: 450, AvailableBytes: 550, UsedPercent: 45},
|
||||
network: map[string]byteCounters{"eth0": {Receive: 3000, Transmit: 5000}},
|
||||
diskRead: 9000,
|
||||
diskWrite: 14000,
|
||||
}
|
||||
|
||||
snapshot := snapshotFromRaw(now, before, time.Second)
|
||||
assertNear(t, snapshot.CPU.UsagePercent, 50)
|
||||
assertNear(t, snapshot.CPU.UserPercent, 40)
|
||||
assertNear(t, snapshot.CPU.SystemPercent, 10)
|
||||
assertNear(t, snapshot.Network.ReceiveBytesPerSecond, 1000)
|
||||
assertNear(t, snapshot.Network.TransmitBytesPerSecond, 1500)
|
||||
assertNear(t, snapshot.Storage.ReadBytesPerSecond, 2000)
|
||||
assertNear(t, snapshot.Storage.WriteBytesPerSecond, 3000)
|
||||
if len(snapshot.CPU.PerCore) != 1 {
|
||||
t.Fatalf("per-core samples = %d, want 1", len(snapshot.CPU.PerCore))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceMonitorHistoryIsBounded(t *testing.T) {
|
||||
monitor := NewResourceMonitor(time.Second, 2)
|
||||
monitor.history = []ResourcePoint{{CPUPercent: 1}, {CPUPercent: 2}}
|
||||
monitor.capacity = 2
|
||||
monitor.latest = &ResourceSnapshot{}
|
||||
monitor.previous = nil
|
||||
|
||||
monitor.mu.Lock()
|
||||
monitor.history = append(monitor.history, ResourcePoint{CPUPercent: 3})
|
||||
if len(monitor.history) > monitor.capacity {
|
||||
copy(monitor.history, monitor.history[len(monitor.history)-monitor.capacity:])
|
||||
monitor.history = monitor.history[:monitor.capacity]
|
||||
}
|
||||
monitor.mu.Unlock()
|
||||
|
||||
if len(monitor.history) != 2 || monitor.history[0].CPUPercent != 2 || monitor.history[1].CPUPercent != 3 {
|
||||
t.Fatalf("bounded history = %#v", monitor.history)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNear(t *testing.T, got, want float64) {
|
||||
t.Helper()
|
||||
if math.Abs(got-want) > 0.001 {
|
||||
t.Fatalf("got %.3f, want %.3f", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
package system
|
||||
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
|
@ -299,6 +298,10 @@ func getProcessDetail(pid int, caps ProcessCaps) (*FullProcess, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owner, err := FindSoftwareOwner(proc.Path); err == nil {
|
||||
proc.PackageManager = owner.PackageType
|
||||
proc.PackageName = owner.PackageName
|
||||
}
|
||||
|
||||
pidStr := strconv.Itoa(pid)
|
||||
|
||||
|
|
@ -474,7 +477,6 @@ func readProcSockets(pidStr string, maxSockets int) []ProcessOpenSocket {
|
|||
return sockets
|
||||
}
|
||||
|
||||
|
||||
// readEnvKeys reads /proc/[pid]/environ and returns only the key names.
|
||||
// Values are never transmitted (security: env vars may contain secrets).
|
||||
func readEnvKeys(pidStr string, maxKeys int) []string {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ type ProcessOwner struct {
|
|||
Unit string `json:"unit,omitempty"`
|
||||
ContainerID string `json:"container_id,omitempty"`
|
||||
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||
PackageManager string `json:"package_manager,omitempty"`
|
||||
PackageName string `json:"package_name,omitempty"`
|
||||
}
|
||||
|
||||
// parseCgroupOwner reads the contents of /proc/[pid]/cgroup.
|
||||
|
|
|
|||
22
agent/internal/system/services.go
Normal file
22
agent/internal/system/services.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package system
|
||||
|
||||
import "time"
|
||||
|
||||
type ServiceSnapshot struct {
|
||||
Manager string `json:"manager"`
|
||||
Services []Service `json:"services"`
|
||||
Count int `json:"count"`
|
||||
Running int `json:"running"`
|
||||
Failed int `json:"failed"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
LoadState string `json:"load_state,omitempty"`
|
||||
ActiveState string `json:"active_state"`
|
||||
SubState string `json:"sub_state,omitempty"`
|
||||
}
|
||||
|
||||
func GetServicesSnapshot() (*ServiceSnapshot, error) { return getServicesSnapshot() }
|
||||
51
agent/internal/system/services_linux.go
Normal file
51
agent/internal/system/services_linux.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
//go:build linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type systemdUnit struct {
|
||||
Unit string `json:"unit"`
|
||||
Load string `json:"load"`
|
||||
Active string `json:"active"`
|
||||
Sub string `json:"sub"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func getServicesSnapshot() (*ServiceSnapshot, error) {
|
||||
command := exec.Command("systemctl", "list-units", "--type=service", "--all", "--output=json", "--no-pager")
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list systemd services: %w", err)
|
||||
}
|
||||
var units []systemdUnit
|
||||
if err := json.Unmarshal(output, &units); err != nil {
|
||||
return nil, fmt.Errorf("decode systemd service list: %w", err)
|
||||
}
|
||||
snapshot := &ServiceSnapshot{Manager: "systemd", CollectedAt: time.Now().UTC()}
|
||||
for _, unit := range units {
|
||||
name := strings.TrimSuffix(unit.Unit, ".service")
|
||||
snapshot.Services = append(snapshot.Services, Service{Name: name, Description: unit.Description, LoadState: unit.Load, ActiveState: unit.Active, SubState: unit.Sub})
|
||||
if unit.Active == "active" {
|
||||
snapshot.Running++
|
||||
}
|
||||
if unit.Active == "failed" {
|
||||
snapshot.Failed++
|
||||
}
|
||||
}
|
||||
sort.Slice(snapshot.Services, func(i, j int) bool {
|
||||
if snapshot.Services[i].ActiveState != snapshot.Services[j].ActiveState {
|
||||
return snapshot.Services[i].ActiveState == "failed"
|
||||
}
|
||||
return snapshot.Services[i].Name < snapshot.Services[j].Name
|
||||
})
|
||||
snapshot.Count = len(snapshot.Services)
|
||||
return snapshot, nil
|
||||
}
|
||||
9
agent/internal/system/services_other.go
Normal file
9
agent/internal/system/services_other.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build !linux
|
||||
|
||||
package system
|
||||
|
||||
import "fmt"
|
||||
|
||||
func getServicesSnapshot() (*ServiceSnapshot, error) {
|
||||
return nil, fmt.Errorf("service inventory not supported on this platform")
|
||||
}
|
||||
80
agent/internal/system/software.go
Normal file
80
agent/internal/system/software.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package system
|
||||
|
||||
import "time"
|
||||
|
||||
const MaxPackageFiles = 5000
|
||||
|
||||
// SoftwareSnapshot is the Agent-owned view of installed software. Managers
|
||||
// are reported independently so one broken inventory source does not turn
|
||||
// another manager's known state into absence.
|
||||
type SoftwareSnapshot struct {
|
||||
Supported bool `json:"supported"`
|
||||
Managers []SoftwareManager `json:"managers"`
|
||||
Packages []InstalledPackage `json:"packages"`
|
||||
Count int `json:"count"`
|
||||
ExplicitCount int `json:"explicit_count"`
|
||||
DependencyCount int `json:"dependency_count"`
|
||||
ForeignCount int `json:"foreign_count"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
// SoftwareManager records the state of one inventory source. Available and
|
||||
// Error stay separate: installed-but-failing is not the same as absent.
|
||||
type SoftwareManager struct {
|
||||
Name string `json:"name"`
|
||||
Available bool `json:"available"`
|
||||
Count int `json:"count"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// InstalledPackage is the common local-machine package identity. Identity is
|
||||
// the exact manager-owned query key; Name is the human/cross-domain package
|
||||
// name used to join pending updates where that mapping is unambiguous.
|
||||
type InstalledPackage struct {
|
||||
PackageType string `json:"package_type"`
|
||||
Identity string `json:"identity"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Repository string `json:"repository,omitempty"`
|
||||
InstallReason string `json:"install_reason"` // explicit, dependency, unknown
|
||||
Origin string `json:"origin"` // repository, foreign, unknown
|
||||
InstalledSizeBytes uint64 `json:"installed_size_bytes,omitempty"`
|
||||
InstalledAt string `json:"installed_at,omitempty"`
|
||||
}
|
||||
|
||||
// PackageDetail adds provenance and relationship data only when an operator
|
||||
// drills into one package. Files are bounded; FileCount preserves the real
|
||||
// total when the returned list is truncated.
|
||||
type PackageDetail struct {
|
||||
InstalledPackage
|
||||
URL string `json:"url,omitempty"`
|
||||
Licenses []string `json:"licenses,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
DependsOn []string `json:"depends_on,omitempty"`
|
||||
OptionalDependencies []string `json:"optional_dependencies,omitempty"`
|
||||
RequiredBy []string `json:"required_by,omitempty"`
|
||||
Provides []string `json:"provides,omitempty"`
|
||||
ConflictsWith []string `json:"conflicts_with,omitempty"`
|
||||
Replaces []string `json:"replaces,omitempty"`
|
||||
Packager string `json:"packager,omitempty"`
|
||||
BuildDate string `json:"build_date,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
FilesTruncated bool `json:"files_truncated"`
|
||||
}
|
||||
|
||||
// SoftwareOwner is the package identity that owns an executable path.
|
||||
type SoftwareOwner struct {
|
||||
PackageType string `json:"package_type"`
|
||||
PackageName string `json:"package_name"`
|
||||
}
|
||||
|
||||
func GetSoftwareSnapshot() (*SoftwareSnapshot, error) { return getSoftwareSnapshot() }
|
||||
|
||||
func GetPackageDetail(packageType, identity string) (*PackageDetail, error) {
|
||||
return getPackageDetail(packageType, identity)
|
||||
}
|
||||
|
||||
func FindSoftwareOwner(path string) (*SoftwareOwner, error) { return findSoftwareOwner(path) }
|
||||
512
agent/internal/system/software_linux.go
Normal file
512
agent/internal/system/software_linux.go
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
//go:build linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var errSoftwareManagerUnavailable = errors.New("software manager unavailable")
|
||||
|
||||
type softwareCollector struct {
|
||||
name string
|
||||
binary string
|
||||
inventory func() ([]InstalledPackage, error)
|
||||
detail func(string) (*PackageDetail, error)
|
||||
owner func(string) (*SoftwareOwner, error)
|
||||
}
|
||||
|
||||
func linuxSoftwareCollectors() []softwareCollector {
|
||||
return []softwareCollector{
|
||||
{name: "pacman", binary: "pacman", inventory: pacmanInventory, detail: pacmanDetail, owner: pacmanOwner},
|
||||
{name: "apt", binary: "dpkg-query", inventory: dpkgInventory, detail: dpkgDetail, owner: dpkgOwner},
|
||||
{name: "dnf", binary: "rpm", inventory: rpmInventory, detail: rpmDetail, owner: rpmOwner},
|
||||
}
|
||||
}
|
||||
|
||||
func getSoftwareSnapshot() (*SoftwareSnapshot, error) {
|
||||
snapshot := &SoftwareSnapshot{Supported: true, CollectedAt: time.Now().UTC()}
|
||||
for _, collector := range linuxSoftwareCollectors() {
|
||||
state := SoftwareManager{Name: collector.name}
|
||||
if _, err := exec.LookPath(collector.binary); err != nil {
|
||||
snapshot.Managers = append(snapshot.Managers, state)
|
||||
continue
|
||||
}
|
||||
state.Available = true
|
||||
packages, err := collector.inventory()
|
||||
if err != nil {
|
||||
state.Error = conciseCommandError(err)
|
||||
snapshot.Managers = append(snapshot.Managers, state)
|
||||
continue
|
||||
}
|
||||
state.Count = len(packages)
|
||||
snapshot.Managers = append(snapshot.Managers, state)
|
||||
snapshot.Packages = append(snapshot.Packages, packages...)
|
||||
}
|
||||
|
||||
sort.Slice(snapshot.Packages, func(i, j int) bool {
|
||||
if snapshot.Packages[i].Name != snapshot.Packages[j].Name {
|
||||
return snapshot.Packages[i].Name < snapshot.Packages[j].Name
|
||||
}
|
||||
return snapshot.Packages[i].PackageType < snapshot.Packages[j].PackageType
|
||||
})
|
||||
snapshot.Count = len(snapshot.Packages)
|
||||
for _, pkg := range snapshot.Packages {
|
||||
switch pkg.InstallReason {
|
||||
case "explicit":
|
||||
snapshot.ExplicitCount++
|
||||
case "dependency":
|
||||
snapshot.DependencyCount++
|
||||
}
|
||||
if pkg.Origin == "foreign" {
|
||||
snapshot.ForeignCount++
|
||||
}
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func getPackageDetail(packageType, identity string) (*PackageDetail, error) {
|
||||
if !validPackageIdentity(identity) {
|
||||
return nil, fmt.Errorf("invalid package identity")
|
||||
}
|
||||
for _, collector := range linuxSoftwareCollectors() {
|
||||
if collector.name != packageType {
|
||||
continue
|
||||
}
|
||||
if _, err := exec.LookPath(collector.binary); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", packageType, errSoftwareManagerUnavailable)
|
||||
}
|
||||
return collector.detail(identity)
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported package manager %q", packageType)
|
||||
}
|
||||
|
||||
func findSoftwareOwner(path string) (*SoftwareOwner, error) {
|
||||
if path == "" || !strings.HasPrefix(path, "/") || strings.ContainsRune(path, '\x00') {
|
||||
return nil, fmt.Errorf("invalid executable path")
|
||||
}
|
||||
var lastErr error
|
||||
for _, collector := range linuxSoftwareCollectors() {
|
||||
if _, err := exec.LookPath(collector.binary); err != nil {
|
||||
continue
|
||||
}
|
||||
owner, err := collector.owner(path)
|
||||
if err == nil && owner != nil {
|
||||
return owner, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errSoftwareManagerUnavailable
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func packageCommand(binary string, args ...string) ([]byte, error) {
|
||||
path, err := exec.LookPath(binary)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", binary, errSoftwareManagerUnavailable)
|
||||
}
|
||||
command := exec.Command(path, args...)
|
||||
command.Env = []string{
|
||||
"LC_ALL=C",
|
||||
"LANG=C",
|
||||
"PATH=/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s query failed: %w", binary, err)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func conciseCommandError(err error) string {
|
||||
if errors.Is(err, errSoftwareManagerUnavailable) {
|
||||
return "unavailable"
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "query exited " + strconv.Itoa(exitErr.ExitCode())
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func validPackageIdentity(identity string) bool {
|
||||
if identity == "" || len(identity) > 255 || (!unicode.IsLetter(rune(identity[0])) && !unicode.IsDigit(rune(identity[0]))) {
|
||||
return false
|
||||
}
|
||||
for _, char := range identity {
|
||||
if unicode.IsLetter(char) || unicode.IsDigit(char) || strings.ContainsRune("@+_.:-", char) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func lines(output []byte) []string {
|
||||
var result []string
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func stringSet(values []string) map[string]bool {
|
||||
set := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
fields := strings.Fields(value)
|
||||
if len(fields) > 0 {
|
||||
set[fields[0]] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func splitPackageList(value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || value == "None" || value == "(none)" {
|
||||
return nil
|
||||
}
|
||||
fields := strings.Fields(value)
|
||||
result := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(strings.TrimSuffix(field, ","))
|
||||
if field != "" {
|
||||
result = append(result, field)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseColonRecord(output []byte) map[string]string {
|
||||
record := make(map[string]string)
|
||||
lastKey := ""
|
||||
for _, raw := range strings.Split(string(output), "\n") {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if (raw[0] == ' ' || raw[0] == '\t') && lastKey != "" {
|
||||
continuation := strings.TrimSpace(raw)
|
||||
if continuation != "" {
|
||||
record[lastKey] = strings.TrimSpace(record[lastKey] + " " + continuation)
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(raw, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
lastKey = ""
|
||||
continue
|
||||
}
|
||||
lastKey = strings.TrimSpace(parts[0])
|
||||
record[lastKey] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func parseHumanSize(value string) uint64 {
|
||||
fields := strings.Fields(strings.ReplaceAll(value, ",", ""))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
amount, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil || amount < 0 {
|
||||
return 0
|
||||
}
|
||||
multiplier := float64(1)
|
||||
if len(fields) > 1 {
|
||||
switch strings.ToLower(fields[1]) {
|
||||
case "kib", "kb":
|
||||
multiplier = 1024
|
||||
case "mib", "mb":
|
||||
multiplier = 1024 * 1024
|
||||
case "gib", "gb":
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
case "tib", "tb":
|
||||
multiplier = 1024 * 1024 * 1024 * 1024
|
||||
}
|
||||
}
|
||||
return uint64(amount * multiplier)
|
||||
}
|
||||
|
||||
func boundedFiles(values []string) ([]string, int, bool) {
|
||||
count := len(values)
|
||||
if count <= MaxPackageFiles {
|
||||
return values, count, false
|
||||
}
|
||||
return values[:MaxPackageFiles], count, true
|
||||
}
|
||||
|
||||
func pacmanInventory() ([]InstalledPackage, error) {
|
||||
installed, err := packageCommand("pacman", "-Q")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
explicitOutput, explicitErr := packageCommand("pacman", "-Qqe")
|
||||
dependencyOutput, dependencyErr := packageCommand("pacman", "-Qqd")
|
||||
foreignOutput, foreignErr := packageCommand("pacman", "-Qm")
|
||||
explicit := map[string]bool{}
|
||||
dependencies := map[string]bool{}
|
||||
foreign := map[string]bool{}
|
||||
if explicitErr == nil {
|
||||
explicit = stringSet(lines(explicitOutput))
|
||||
}
|
||||
if dependencyErr == nil {
|
||||
dependencies = stringSet(lines(dependencyOutput))
|
||||
}
|
||||
if foreignErr == nil {
|
||||
foreign = stringSet(lines(foreignOutput))
|
||||
}
|
||||
|
||||
var packages []InstalledPackage
|
||||
for _, line := range lines(installed) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) != 2 {
|
||||
continue
|
||||
}
|
||||
reason := "unknown"
|
||||
switch {
|
||||
case explicit[fields[0]]:
|
||||
reason = "explicit"
|
||||
case dependencies[fields[0]]:
|
||||
reason = "dependency"
|
||||
}
|
||||
origin := "repository"
|
||||
if foreign[fields[0]] {
|
||||
origin = "foreign"
|
||||
}
|
||||
packages = append(packages, InstalledPackage{
|
||||
PackageType: "pacman", Identity: fields[0], Name: fields[0], Version: fields[1],
|
||||
InstallReason: reason, Origin: origin,
|
||||
})
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func pacmanDetail(identity string) (*PackageDetail, error) {
|
||||
output, err := packageCommand("pacman", "-Qi", "--color=never", identity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := parseColonRecord(output)
|
||||
origin := "repository"
|
||||
if foreign, err := packageCommand("pacman", "-Qm", identity); err == nil && len(lines(foreign)) > 0 {
|
||||
origin = "foreign"
|
||||
}
|
||||
reason := "unknown"
|
||||
if strings.EqualFold(record["Install Reason"], "Explicitly installed") {
|
||||
reason = "explicit"
|
||||
} else if strings.Contains(strings.ToLower(record["Install Reason"]), "dependency") {
|
||||
reason = "dependency"
|
||||
}
|
||||
detail := &PackageDetail{InstalledPackage: InstalledPackage{
|
||||
PackageType: "pacman", Identity: identity, Name: record["Name"], Version: record["Version"],
|
||||
Architecture: record["Architecture"], Description: record["Description"], InstallReason: reason,
|
||||
Origin: origin, InstalledSizeBytes: parseHumanSize(record["Installed Size"]), InstalledAt: record["Install Date"],
|
||||
}, URL: record["URL"], Licenses: splitPackageList(record["Licenses"]), Groups: splitPackageList(record["Groups"]),
|
||||
DependsOn: splitPackageList(record["Depends On"]), OptionalDependencies: splitPackageList(record["Optional Deps"]),
|
||||
RequiredBy: splitPackageList(record["Required By"]), Provides: splitPackageList(record["Provides"]),
|
||||
ConflictsWith: splitPackageList(record["Conflicts With"]), Replaces: splitPackageList(record["Replaces"]),
|
||||
Packager: record["Packager"], BuildDate: record["Build Date"],
|
||||
}
|
||||
if detail.Name == "" {
|
||||
detail.Name = identity
|
||||
}
|
||||
if output, err := packageCommand("pacman", "-Ql", identity); err == nil {
|
||||
prefix := identity + " "
|
||||
var files []string
|
||||
for _, line := range lines(output) {
|
||||
files = append(files, strings.TrimPrefix(line, prefix))
|
||||
}
|
||||
detail.Files, detail.FileCount, detail.FilesTruncated = boundedFiles(files)
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func pacmanOwner(path string) (*SoftwareOwner, error) {
|
||||
output, err := packageCommand("pacman", "-Qoq", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := lines(output)
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("no pacman package owns path")
|
||||
}
|
||||
return &SoftwareOwner{PackageType: "pacman", PackageName: names[0]}, nil
|
||||
}
|
||||
|
||||
func dpkgInventory() ([]InstalledPackage, error) {
|
||||
output, err := packageCommand("dpkg-query", "-W", "-f=${binary:Package}\t${Version}\t${Architecture}\t${db:Status-Abbrev}\t${binary:Synopsis}\n")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manual := map[string]bool{}
|
||||
if manualOutput, err := packageCommand("apt-mark", "showmanual"); err == nil {
|
||||
manual = stringSet(lines(manualOutput))
|
||||
}
|
||||
var packages []InstalledPackage
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 5 || !strings.HasPrefix(fields[3], "ii") {
|
||||
continue
|
||||
}
|
||||
identity := fields[0]
|
||||
name := strings.TrimSuffix(identity, ":"+fields[2])
|
||||
reason := "unknown"
|
||||
if manual[name] || manual[identity] {
|
||||
reason = "explicit"
|
||||
} else if len(manual) > 0 {
|
||||
reason = "dependency"
|
||||
}
|
||||
packages = append(packages, InstalledPackage{
|
||||
PackageType: "apt", Identity: identity, Name: name, Version: fields[1], Architecture: fields[2],
|
||||
Description: fields[4], InstallReason: reason, Origin: "unknown",
|
||||
})
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func dpkgDetail(identity string) (*PackageDetail, error) {
|
||||
format := "${binary:Package}\t${Version}\t${Architecture}\t${binary:Synopsis}\t${Homepage}\t${Installed-Size}\t${Maintainer}\t${Depends}\t${Pre-Depends}\t${Recommends}\t${Suggests}\t${Provides}\t${Conflicts}\t${Replaces}\n"
|
||||
output, err := packageCommand("dpkg-query", "-W", "-f="+format, identity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := strings.Split(strings.TrimRight(string(output), "\n"), "\t")
|
||||
if len(fields) < 14 {
|
||||
return nil, fmt.Errorf("dpkg returned incomplete package detail")
|
||||
}
|
||||
name := strings.TrimSuffix(fields[0], ":"+fields[2])
|
||||
reason := "unknown"
|
||||
if manual, err := packageCommand("apt-mark", "showmanual"); err == nil {
|
||||
set := stringSet(lines(manual))
|
||||
if set[name] || set[identity] {
|
||||
reason = "explicit"
|
||||
} else {
|
||||
reason = "dependency"
|
||||
}
|
||||
}
|
||||
installedSize, _ := strconv.ParseUint(strings.TrimSpace(fields[5]), 10, 64)
|
||||
detail := &PackageDetail{InstalledPackage: InstalledPackage{
|
||||
PackageType: "apt", Identity: fields[0], Name: name, Version: fields[1], Architecture: fields[2],
|
||||
Description: fields[3], InstallReason: reason, Origin: "unknown", InstalledSizeBytes: installedSize * 1024,
|
||||
}, URL: fields[4], Packager: fields[6]}
|
||||
detail.DependsOn = splitCommaDependencies(strings.Join([]string{fields[7], fields[8]}, ","))
|
||||
detail.OptionalDependencies = splitCommaDependencies(strings.Join([]string{fields[9], fields[10]}, ","))
|
||||
detail.Provides = splitCommaDependencies(fields[11])
|
||||
detail.ConflictsWith = splitCommaDependencies(fields[12])
|
||||
detail.Replaces = splitCommaDependencies(fields[13])
|
||||
if output, err := packageCommand("dpkg-query", "-L", identity); err == nil {
|
||||
detail.Files, detail.FileCount, detail.FilesTruncated = boundedFiles(lines(output))
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func splitCommaDependencies(value string) []string {
|
||||
var result []string
|
||||
for _, dependency := range strings.Split(value, ",") {
|
||||
dependency = strings.TrimSpace(dependency)
|
||||
if dependency != "" && dependency != "<none>" {
|
||||
result = append(result, dependency)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func dpkgOwner(path string) (*SoftwareOwner, error) {
|
||||
output, err := packageCommand("dpkg-query", "-S", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line := strings.SplitN(string(output), "\n", 2)[0]
|
||||
parts := strings.SplitN(line, ": ", 2)
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return nil, fmt.Errorf("dpkg returned no package owner")
|
||||
}
|
||||
name := parts[0]
|
||||
if index := strings.LastIndex(name, ":"); index > 0 {
|
||||
name = name[:index]
|
||||
}
|
||||
return &SoftwareOwner{PackageType: "apt", PackageName: name}, nil
|
||||
}
|
||||
|
||||
func rpmInventory() ([]InstalledPackage, error) {
|
||||
format := "%{NAME}\t%{NEVRA}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SUMMARY}\t%{SIZE}\t%{INSTALLTIME}\n"
|
||||
output, err := packageCommand("rpm", "-qa", "--qf", format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var packages []InstalledPackage
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 7 {
|
||||
continue
|
||||
}
|
||||
size, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
installed := ""
|
||||
if epoch, err := strconv.ParseInt(fields[6], 10, 64); err == nil && epoch > 0 {
|
||||
installed = time.Unix(epoch, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
packages = append(packages, InstalledPackage{
|
||||
PackageType: "dnf", Identity: fields[1], Name: fields[0], Version: fields[2], Architecture: fields[3],
|
||||
Description: fields[4], InstallReason: "unknown", Origin: "unknown", InstalledSizeBytes: size, InstalledAt: installed,
|
||||
})
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func rpmDetail(identity string) (*PackageDetail, error) {
|
||||
output, err := packageCommand("rpm", "-qi", identity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := parseColonRecord(output)
|
||||
version := record["Version"]
|
||||
if release := record["Release"]; release != "" {
|
||||
version += "-" + release
|
||||
}
|
||||
detail := &PackageDetail{InstalledPackage: InstalledPackage{
|
||||
PackageType: "dnf", Identity: identity, Name: record["Name"], Version: version,
|
||||
Architecture: record["Architecture"], Description: record["Summary"], InstallReason: "unknown",
|
||||
Origin: "unknown", InstalledSizeBytes: parseHumanSize(record["Size"]), InstalledAt: record["Install Date"],
|
||||
}, URL: record["URL"], Licenses: splitPackageList(record["License"]), Packager: record["Packager"], BuildDate: record["Build Date"]}
|
||||
if output, err := packageCommand("rpm", "-qR", identity); err == nil {
|
||||
for _, requirement := range lines(output) {
|
||||
if !strings.HasPrefix(requirement, "rpmlib(") {
|
||||
detail.DependsOn = append(detail.DependsOn, requirement)
|
||||
}
|
||||
}
|
||||
}
|
||||
if output, err := packageCommand("rpm", "-q", "--provides", identity); err == nil {
|
||||
detail.Provides = lines(output)
|
||||
}
|
||||
if output, err := packageCommand("rpm", "-q", "--whatrequires", detail.Name); err == nil {
|
||||
detail.RequiredBy = lines(output)
|
||||
}
|
||||
if output, err := packageCommand("rpm", "-ql", identity); err == nil {
|
||||
detail.Files, detail.FileCount, detail.FilesTruncated = boundedFiles(lines(output))
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func rpmOwner(path string) (*SoftwareOwner, error) {
|
||||
output, err := packageCommand("rpm", "-qf", "--qf", "%{NAME}\n", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := lines(output)
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("rpm returned no package owner")
|
||||
}
|
||||
return &SoftwareOwner{PackageType: "dnf", PackageName: names[0]}, nil
|
||||
}
|
||||
20
agent/internal/system/software_other.go
Normal file
20
agent/internal/system/software_other.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//go:build !linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func getSoftwareSnapshot() (*SoftwareSnapshot, error) {
|
||||
return &SoftwareSnapshot{Supported: false, CollectedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
|
||||
func getPackageDetail(packageType, identity string) (*PackageDetail, error) {
|
||||
return nil, fmt.Errorf("software detail not supported on this platform")
|
||||
}
|
||||
|
||||
func findSoftwareOwner(path string) (*SoftwareOwner, error) {
|
||||
return nil, fmt.Errorf("software ownership not supported on this platform")
|
||||
}
|
||||
3
desktop/.qmlls.ini
Normal file
3
desktop/.qmlls.ini
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[General]
|
||||
buildDir="/home/casey/Projects/redflag-desktop-health-worktree/desktop/target/cxxqt/qml_modules"
|
||||
no-cmake-calls=true
|
||||
4298
desktop/Cargo.lock
generated
4298
desktop/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,14 +4,19 @@ version = "0.2.9"
|
|||
edition = "2021"
|
||||
license = "AGPL-3.0"
|
||||
publish = false
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
description = "RedFlag native local-machine operations console"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
cxx = "1"
|
||||
cxx-qt = "0.9"
|
||||
cxx-qt-lib = { version = "0.9", features = ["qt_full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
|
||||
[build-dependencies]
|
||||
cxx-qt-build = "0.9"
|
||||
qt-build-utils = "0.9"
|
||||
|
||||
[[bin]]
|
||||
name = "redflag-desktop"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,26 @@
|
|||
# RedFlag Desktop
|
||||
|
||||
Tauri shell for the same RedFlag app in local-agent context.
|
||||
Native Qt/QML local-machine operations console backed by the RedFlag Agent.
|
||||
|
||||
The desktop shell does not read protected config or state files. Its Rust backend talks to
|
||||
the existing agent local IPC surface:
|
||||
Desktop is the local scope of the RedFlag domain: machine health, live resources,
|
||||
processes, connections, storage, services, containers, software, updates, security,
|
||||
history, and bounded operator intent. RedFlag Web presents the same domain at fleet
|
||||
scope.
|
||||
|
||||
The QML process holds no machine authority and reads no protected Agent state. Its
|
||||
CXX-Qt bridge talks only to the platform-local Agent transport:
|
||||
|
||||
- Linux: `/var/lib/redflag/agent/localapi/redflag-agent.sock`
|
||||
- Windows: `\\.\pipe\RedFlagAgentLocal`
|
||||
|
||||
The frontend entry is `web/index.desktop.html` and `web/src/desktop/main.tsx`.
|
||||
Fleet-server context should be added to this same app later rather than creating a second
|
||||
local-only application.
|
||||
Observation belongs to the Agent. Privileged mutations travel through Agent policy,
|
||||
a signed mutation envelope, and the RedFlag helper. QML must never acquire a package,
|
||||
service, container, or reboot escape hatch of its own.
|
||||
|
||||
The QML structure inherits useful interaction and typed-bridge work from Souveraine
|
||||
Updater. That updater does not survive as a separate product or authority path.
|
||||
|
||||
The current release pipeline builds Desktop for Linux amd64 with Qt 6. The Windows
|
||||
transport exists, but no Windows Desktop artifact is published until a native Qt/MSVC
|
||||
runner owns that build. The web application remains the fleet surface; it is not
|
||||
embedded into Desktop.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,31 @@
|
|||
use cxx_qt_build::{CxxQtBuilder, QmlModule};
|
||||
use qt_build_utils::QmlFile;
|
||||
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
let singletons = ["qml/Theme.qml"];
|
||||
let views = ["qml/LineGraph.qml", "qml/MetricCard.qml", "qml/NavItem.qml"];
|
||||
let mut files = vec![QmlFile::from("qml/Main.qml")];
|
||||
files.extend(
|
||||
singletons
|
||||
.iter()
|
||||
.map(|path| QmlFile::from(*path).singleton(true)),
|
||||
);
|
||||
files.extend(views.iter().map(|path| QmlFile::from(*path)));
|
||||
for path in ["qml/Main.qml"]
|
||||
.iter()
|
||||
.chain(singletons.iter())
|
||||
.chain(views.iter())
|
||||
{
|
||||
println!("cargo:rerun-if-changed={path}");
|
||||
}
|
||||
println!("cargo:rerun-if-changed=src/bridge/machine.rs");
|
||||
|
||||
CxxQtBuilder::new_qml_module(QmlModule::new("com.redflag.desktop").qml_files(files))
|
||||
.qrc_resources(["icons/icon.png"])
|
||||
.file("src/bridge/machine.rs")
|
||||
.qt_module("Core")
|
||||
.qt_module("Gui")
|
||||
.qt_module("Qml")
|
||||
.qt_module("Quick")
|
||||
.build();
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
6
desktop/package-lock.json
generated
6
desktop/package-lock.json
generated
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "desktop",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
45
desktop/qml/LineGraph.qml
Normal file
45
desktop/qml/LineGraph.qml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import QtQuick
|
||||
|
||||
Canvas {
|
||||
id: root
|
||||
property var values: []
|
||||
property real ceiling: 100
|
||||
property color lineColor: Theme.red
|
||||
property color fillColor: Theme.tint(lineColor, 0.12)
|
||||
property bool grid: true
|
||||
|
||||
onValuesChanged: requestPaint()
|
||||
onWidthChanged: requestPaint()
|
||||
onHeightChanged: requestPaint()
|
||||
onCeilingChanged: requestPaint()
|
||||
|
||||
onPaint: {
|
||||
const ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
if (grid) {
|
||||
ctx.strokeStyle = Theme.divider
|
||||
ctx.lineWidth = 1
|
||||
for (let y = 0; y <= 4; ++y) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y * height / 4); ctx.lineTo(width, y * height / 4); ctx.stroke()
|
||||
}
|
||||
}
|
||||
if (!values || values.length < 2) return
|
||||
const top = Math.max(1, ceiling)
|
||||
const step = width / Math.max(1, values.length - 1)
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < values.length; ++i) {
|
||||
const x = i * step
|
||||
const y = height - Math.min(top, Math.max(0, Number(values[i]))) / top * height
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.lineTo(width, height); ctx.lineTo(0, height); ctx.closePath()
|
||||
ctx.fillStyle = fillColor; ctx.fill()
|
||||
ctx.beginPath()
|
||||
for (let j = 0; j < values.length; ++j) {
|
||||
const x2 = j * step
|
||||
const y2 = height - Math.min(top, Math.max(0, Number(values[j]))) / top * height
|
||||
if (j === 0) ctx.moveTo(x2, y2); else ctx.lineTo(x2, y2)
|
||||
}
|
||||
ctx.strokeStyle = lineColor; ctx.lineWidth = 2; ctx.stroke()
|
||||
}
|
||||
}
|
||||
1012
desktop/qml/Main.qml
Normal file
1012
desktop/qml/Main.qml
Normal file
File diff suppressed because it is too large
Load diff
20
desktop/qml/MetricCard.qml
Normal file
20
desktop/qml/MetricCard.qml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import QtQuick
|
||||
|
||||
Rectangle {
|
||||
required property string label
|
||||
required property string value
|
||||
property string detail: ""
|
||||
property color accent: Theme.text
|
||||
|
||||
implicitWidth: 180
|
||||
implicitHeight: 96
|
||||
radius: Theme.radius
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.divider
|
||||
|
||||
Rectangle { width: 3; height: 28; radius: 2; color: parent.accent; anchors.left: parent.left; anchors.leftMargin: 13; anchors.top: parent.top; anchors.topMargin: 16 }
|
||||
Text { text: parent.label.toUpperCase(); color: Theme.faint; font.pixelSize: 10; font.letterSpacing: 1.1; anchors.left: parent.left; anchors.leftMargin: 26; anchors.top: parent.top; anchors.topMargin: 14 }
|
||||
Text { text: parent.value; color: parent.accent; font.pixelSize: 22; font.weight: 650; anchors.left: parent.left; anchors.leftMargin: 26; anchors.top: parent.top; anchors.topMargin: 32 }
|
||||
Text { text: parent.detail; color: Theme.dim; font.pixelSize: Theme.fontMeta; anchors.left: parent.left; anchors.leftMargin: 26; anchors.right: parent.right; anchors.rightMargin: 10; anchors.bottom: parent.bottom; anchors.bottomMargin: 11; elide: Text.ElideRight }
|
||||
}
|
||||
52
desktop/qml/NavItem.qml
Normal file
52
desktop/qml/NavItem.qml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import QtQuick
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
required property string label
|
||||
property string badge: ""
|
||||
property bool selected: false
|
||||
signal clicked()
|
||||
|
||||
width: parent ? parent.width : 180
|
||||
height: 38
|
||||
radius: Theme.radiusSm
|
||||
color: selected ? Theme.redDim : mouse.containsMouse ? Theme.hover : "transparent"
|
||||
|
||||
Rectangle {
|
||||
width: 3
|
||||
height: 18
|
||||
radius: 2
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: root.selected ? Theme.red : "transparent"
|
||||
}
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.label
|
||||
color: root.selected ? Theme.text : Theme.dim
|
||||
font.pixelSize: Theme.fontBody
|
||||
font.weight: root.selected ? 600 : 450
|
||||
}
|
||||
Rectangle {
|
||||
visible: root.badge.length > 0
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.max(20, badgeText.implicitWidth + 10)
|
||||
height: 20
|
||||
radius: 10
|
||||
color: Theme.tint(root.selected ? Theme.red : Theme.faint, 0.22)
|
||||
Text {
|
||||
id: badgeText
|
||||
anchors.centerIn: parent
|
||||
text: root.badge
|
||||
color: root.selected ? Theme.red : Theme.dim
|
||||
font.pixelSize: 10
|
||||
font.weight: 700
|
||||
}
|
||||
}
|
||||
MouseArea { id: mouse; anchors.fill: parent; hoverEnabled: true; onClicked: root.clicked() }
|
||||
}
|
||||
45
desktop/qml/Theme.qml
Normal file
45
desktop/qml/Theme.qml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
pragma Singleton
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
property bool dark: true
|
||||
|
||||
readonly property color window: dark ? "#0b0e13" : "#f3f4f6"
|
||||
readonly property color rail: dark ? "#10141b" : "#ffffff"
|
||||
readonly property color surface: dark ? "#151a22" : "#ffffff"
|
||||
readonly property color raised: dark ? "#1b222d" : "#f8fafc"
|
||||
readonly property color hover: dark ? "#222b38" : "#eef2f6"
|
||||
readonly property color divider: dark ? "#252d39" : "#dce1e7"
|
||||
readonly property color text: dark ? "#eef1f5" : "#171a1f"
|
||||
readonly property color dim: dark ? "#99a2af" : "#5f6874"
|
||||
readonly property color faint: dark ? "#66707d" : "#8a939e"
|
||||
readonly property color red: "#e23b3b"
|
||||
readonly property color redDim: dark ? "#3a1a1e" : "#fee2e2"
|
||||
readonly property color good: "#32bd74"
|
||||
readonly property color warn: "#e5a936"
|
||||
readonly property color bad: "#ef5350"
|
||||
readonly property color blue: "#4d8dff"
|
||||
readonly property color purple: "#ad7aff"
|
||||
readonly property color cyan: "#30b8c9"
|
||||
|
||||
readonly property int railWidth: 216
|
||||
readonly property int headerHeight: 72
|
||||
readonly property int radius: 10
|
||||
readonly property int radiusSm: 6
|
||||
readonly property int spacing: 14
|
||||
readonly property int spacingSm: 8
|
||||
readonly property int fontTitle: 23
|
||||
readonly property int fontHeading: 17
|
||||
readonly property int fontBody: 13
|
||||
readonly property int fontMeta: 11
|
||||
readonly property string mono: {
|
||||
const wanted = ["JetBrainsMono NF", "Adwaita Mono", "DejaVu Sans Mono"]
|
||||
const installed = Qt.fontFamilies()
|
||||
for (let i = 0; i < wanted.length; ++i)
|
||||
if (installed.indexOf(wanted[i]) >= 0) return wanted[i]
|
||||
return "monospace"
|
||||
}
|
||||
|
||||
function tint(color, alpha) { return Qt.rgba(color.r, color.g, color.b, alpha) }
|
||||
function pressure(percent) { return percent >= 90 ? bad : percent >= 75 ? warn : good }
|
||||
}
|
||||
95
desktop/src/bridge/local_api.rs
Normal file
95
desktop/src/bridge/local_api.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
use serde_json::Value;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
trait ReadWrite: Read + Write {}
|
||||
impl<T: Read + Write> ReadWrite for T {}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn connect() -> Result<Box<dyn ReadWrite>, String> {
|
||||
let path = "/var/lib/redflag/agent/localapi/redflag-agent.sock";
|
||||
std::os::unix::net::UnixStream::connect(path)
|
||||
.map(|stream| Box::new(stream) as Box<dyn ReadWrite>)
|
||||
.map_err(|error| format!("connect RedFlag Agent at {path}: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn connect() -> Result<Box<dyn ReadWrite>, String> {
|
||||
let path = r"\\.\pipe\RedFlagAgentLocal";
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.map(|stream| Box::new(stream) as Box<dyn ReadWrite>)
|
||||
.map_err(|error| format!("connect RedFlag Agent at {path}: {error}"))
|
||||
}
|
||||
|
||||
pub fn get(path: &str) -> Result<Value, String> {
|
||||
request("GET", path, None)
|
||||
}
|
||||
|
||||
/// Ok(None) when the Agent has no such route. An older Agent is not an
|
||||
/// unreachable one, and rendering it as a connection failure is a lie about
|
||||
/// which half of the pair is behind.
|
||||
pub fn get_optional(path: &str) -> Result<Option<Value>, String> {
|
||||
match raw("GET", path, None) {
|
||||
Ok((404, _)) => Ok(None),
|
||||
Ok((status, body)) => decode(status, &body).map(Some),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post(path: &str, value: &Value) -> Result<Value, String> {
|
||||
let body =
|
||||
serde_json::to_vec(value).map_err(|error| format!("encode Agent request: {error}"))?;
|
||||
request("POST", path, Some(&body))
|
||||
}
|
||||
|
||||
fn request(method: &str, path: &str, body: Option<&[u8]>) -> Result<Value, String> {
|
||||
let (status, body) = raw(method, path, body)?;
|
||||
decode(status, &body)
|
||||
}
|
||||
|
||||
fn decode(status: u16, body: &str) -> Result<Value, String> {
|
||||
if !(200..300).contains(&status) {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(body) {
|
||||
if let Some(error) = value["error"].as_str() {
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
return Err(format!("Agent returned {status}: {}", body.trim()));
|
||||
}
|
||||
serde_json::from_str(body).map_err(|error| format!("decode Agent response: {error}"))
|
||||
}
|
||||
|
||||
fn raw(method: &str, path: &str, body: Option<&[u8]>) -> Result<(u16, String), String> {
|
||||
let body = body.unwrap_or_default();
|
||||
let mut stream = connect()?;
|
||||
let request = format!(
|
||||
"{method} {path} HTTP/1.1\r\nHost: redflag.local\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.map_err(|error| format!("write Agent request: {error}"))?;
|
||||
stream
|
||||
.write_all(body)
|
||||
.map_err(|error| format!("write Agent body: {error}"))?;
|
||||
stream
|
||||
.flush()
|
||||
.map_err(|error| format!("flush Agent request: {error}"))?;
|
||||
|
||||
let mut response = String::new();
|
||||
stream
|
||||
.read_to_string(&mut response)
|
||||
.map_err(|error| format!("read Agent response: {error}"))?;
|
||||
let (head, body) = response
|
||||
.split_once("\r\n\r\n")
|
||||
.ok_or_else(|| "Agent response has no HTTP body".to_string())?;
|
||||
let status = head
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.and_then(|code| code.parse::<u16>().ok())
|
||||
.ok_or_else(|| "Agent response has no status".to_string())?;
|
||||
Ok((status, body.to_string()))
|
||||
}
|
||||
723
desktop/src/bridge/machine.rs
Normal file
723
desktop/src/bridge/machine.rs
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
use super::local_api;
|
||||
use cxx_qt::Threading;
|
||||
use cxx_qt_lib::QString;
|
||||
use serde_json::Value;
|
||||
use std::pin::Pin;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Instant;
|
||||
|
||||
static DESKTOP_START: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||
|
||||
#[cxx_qt::bridge]
|
||||
pub mod ffi {
|
||||
unsafe extern "C++" {
|
||||
include!("cxx-qt-lib/qstring.h");
|
||||
type QString = cxx_qt_lib::QString;
|
||||
}
|
||||
|
||||
#[auto_cxx_name]
|
||||
unsafe extern "RustQt" {
|
||||
#[qobject]
|
||||
#[qml_element]
|
||||
#[qproperty(bool, connected)]
|
||||
#[qproperty(QString, connection_error)]
|
||||
#[qproperty(QString, agent_gap)]
|
||||
#[qproperty(QString, hostname)]
|
||||
#[qproperty(QString, machine_subtitle)]
|
||||
#[qproperty(QString, agent_version)]
|
||||
#[qproperty(QString, agent_status)]
|
||||
#[qproperty(QString, enrollment)]
|
||||
#[qproperty(QString, uptime)]
|
||||
#[qproperty(f64, cpu_usage)]
|
||||
#[qproperty(f64, load_1)]
|
||||
#[qproperty(f64, load_5)]
|
||||
#[qproperty(f64, load_15)]
|
||||
#[qproperty(f64, memory_percent)]
|
||||
#[qproperty(u64, memory_used)]
|
||||
#[qproperty(u64, memory_total)]
|
||||
#[qproperty(f64, swap_percent)]
|
||||
#[qproperty(f64, network_receive)]
|
||||
#[qproperty(f64, network_transmit)]
|
||||
#[qproperty(f64, disk_read)]
|
||||
#[qproperty(f64, disk_write)]
|
||||
#[qproperty(f64, temperature)]
|
||||
#[qproperty(i32, process_count)]
|
||||
#[qproperty(i32, update_count)]
|
||||
#[qproperty(i32, critical_count)]
|
||||
#[qproperty(i32, container_count)]
|
||||
#[qproperty(i32, container_running)]
|
||||
#[qproperty(i32, container_unhealthy)]
|
||||
#[qproperty(i32, service_count)]
|
||||
#[qproperty(i32, service_running)]
|
||||
#[qproperty(i32, service_failed)]
|
||||
#[qproperty(QString, health_state)]
|
||||
#[qproperty(QString, history_json)]
|
||||
#[qproperty(QString, system_json)]
|
||||
#[qproperty(QString, processes_json)]
|
||||
#[qproperty(QString, process_detail_json)]
|
||||
#[qproperty(QString, software_json)]
|
||||
#[qproperty(QString, software_detail_json)]
|
||||
#[qproperty(QString, connections_json)]
|
||||
#[qproperty(QString, containers_json)]
|
||||
#[qproperty(QString, services_json)]
|
||||
#[qproperty(QString, updates_json)]
|
||||
#[qproperty(QString, security_json)]
|
||||
#[qproperty(QString, events_json)]
|
||||
#[qproperty(QString, operation_message)]
|
||||
#[qproperty(QString, approval_json)]
|
||||
#[qproperty(i32, software_count)]
|
||||
#[qproperty(i32, software_explicit_count)]
|
||||
#[qproperty(i32, software_dependency_count)]
|
||||
#[qproperty(i32, software_foreign_count)]
|
||||
#[qproperty(bool, approval_running)]
|
||||
#[qproperty(bool, telemetry_loading)]
|
||||
#[qproperty(bool, overview_loading)]
|
||||
#[qproperty(bool, processes_loading)]
|
||||
#[qproperty(bool, software_loading)]
|
||||
type Machine = super::MachineRust;
|
||||
|
||||
#[qinvokable]
|
||||
fn refresh_telemetry(self: Pin<&mut Machine>);
|
||||
#[qinvokable]
|
||||
fn refresh_overview(self: Pin<&mut Machine>);
|
||||
#[qinvokable]
|
||||
fn refresh_processes(self: Pin<&mut Machine>);
|
||||
#[qinvokable]
|
||||
fn refresh_connections(self: Pin<&mut Machine>);
|
||||
#[qinvokable]
|
||||
fn load_process(self: Pin<&mut Machine>, pid: i32);
|
||||
#[qinvokable]
|
||||
fn refresh_software(self: Pin<&mut Machine>);
|
||||
#[qinvokable]
|
||||
fn load_software(self: Pin<&mut Machine>, package_type: &QString, identity: &QString);
|
||||
#[qinvokable]
|
||||
fn trigger_scan(self: Pin<&mut Machine>);
|
||||
#[qinvokable]
|
||||
fn approve_update(
|
||||
self: Pin<&mut Machine>,
|
||||
package_type: &QString,
|
||||
package_name: &QString,
|
||||
available_version: &QString,
|
||||
override_reason: &QString,
|
||||
);
|
||||
|
||||
#[qsignal]
|
||||
fn telemetry_updated(self: Pin<&mut Machine>);
|
||||
#[qsignal]
|
||||
fn overview_updated(self: Pin<&mut Machine>);
|
||||
#[qsignal]
|
||||
fn processes_updated(self: Pin<&mut Machine>);
|
||||
#[qsignal]
|
||||
fn connections_updated(self: Pin<&mut Machine>);
|
||||
#[qsignal]
|
||||
fn process_detail_updated(self: Pin<&mut Machine>);
|
||||
#[qsignal]
|
||||
fn software_updated(self: Pin<&mut Machine>);
|
||||
#[qsignal]
|
||||
fn software_detail_updated(self: Pin<&mut Machine>);
|
||||
#[qsignal]
|
||||
fn operation_finished(self: Pin<&mut Machine>, success: bool);
|
||||
#[qsignal]
|
||||
fn approval_finished(self: Pin<&mut Machine>, success: bool);
|
||||
}
|
||||
|
||||
impl cxx_qt::Threading for Machine {}
|
||||
}
|
||||
|
||||
pub struct MachineRust {
|
||||
connected: bool,
|
||||
connection_error: QString,
|
||||
agent_gap: QString,
|
||||
hostname: QString,
|
||||
machine_subtitle: QString,
|
||||
agent_version: QString,
|
||||
agent_status: QString,
|
||||
enrollment: QString,
|
||||
uptime: QString,
|
||||
cpu_usage: f64,
|
||||
load_1: f64,
|
||||
load_5: f64,
|
||||
load_15: f64,
|
||||
memory_percent: f64,
|
||||
memory_used: u64,
|
||||
memory_total: u64,
|
||||
swap_percent: f64,
|
||||
network_receive: f64,
|
||||
network_transmit: f64,
|
||||
disk_read: f64,
|
||||
disk_write: f64,
|
||||
temperature: f64,
|
||||
process_count: i32,
|
||||
update_count: i32,
|
||||
critical_count: i32,
|
||||
container_count: i32,
|
||||
container_running: i32,
|
||||
container_unhealthy: i32,
|
||||
service_count: i32,
|
||||
service_running: i32,
|
||||
service_failed: i32,
|
||||
health_state: QString,
|
||||
history_json: QString,
|
||||
system_json: QString,
|
||||
processes_json: QString,
|
||||
process_detail_json: QString,
|
||||
software_json: QString,
|
||||
software_detail_json: QString,
|
||||
connections_json: QString,
|
||||
containers_json: QString,
|
||||
services_json: QString,
|
||||
updates_json: QString,
|
||||
security_json: QString,
|
||||
events_json: QString,
|
||||
operation_message: QString,
|
||||
approval_json: QString,
|
||||
software_count: i32,
|
||||
software_explicit_count: i32,
|
||||
software_dependency_count: i32,
|
||||
software_foreign_count: i32,
|
||||
approval_running: bool,
|
||||
telemetry_loading: bool,
|
||||
overview_loading: bool,
|
||||
processes_loading: bool,
|
||||
software_loading: bool,
|
||||
}
|
||||
|
||||
impl Default for MachineRust {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connected: false,
|
||||
connection_error: QString::default(),
|
||||
agent_gap: QString::default(),
|
||||
hostname: QString::from("This machine"),
|
||||
machine_subtitle: QString::from("Waiting for RedFlag Agent"),
|
||||
agent_version: QString::default(),
|
||||
agent_status: QString::from("connecting"),
|
||||
enrollment: QString::from("unknown"),
|
||||
uptime: QString::default(),
|
||||
cpu_usage: 0.0,
|
||||
load_1: 0.0,
|
||||
load_5: 0.0,
|
||||
load_15: 0.0,
|
||||
memory_percent: 0.0,
|
||||
memory_used: 0,
|
||||
memory_total: 0,
|
||||
swap_percent: 0.0,
|
||||
network_receive: 0.0,
|
||||
network_transmit: 0.0,
|
||||
disk_read: 0.0,
|
||||
disk_write: 0.0,
|
||||
temperature: 0.0,
|
||||
process_count: 0,
|
||||
update_count: 0,
|
||||
critical_count: 0,
|
||||
container_count: 0,
|
||||
container_running: 0,
|
||||
container_unhealthy: 0,
|
||||
service_count: 0,
|
||||
service_running: 0,
|
||||
service_failed: 0,
|
||||
health_state: QString::from("observing"),
|
||||
history_json: QString::from("[]"),
|
||||
system_json: QString::from("{}"),
|
||||
processes_json: QString::from("[]"),
|
||||
process_detail_json: QString::from("{}"),
|
||||
software_json: QString::from("{}"),
|
||||
software_detail_json: QString::from("{}"),
|
||||
connections_json: QString::from("[]"),
|
||||
containers_json: QString::from("[]"),
|
||||
services_json: QString::from("[]"),
|
||||
updates_json: QString::from("[]"),
|
||||
security_json: QString::from("{}"),
|
||||
events_json: QString::from("[]"),
|
||||
operation_message: QString::default(),
|
||||
approval_json: QString::from("{}"),
|
||||
software_count: 0,
|
||||
software_explicit_count: 0,
|
||||
software_dependency_count: 0,
|
||||
software_foreign_count: 0,
|
||||
approval_running: false,
|
||||
telemetry_loading: false,
|
||||
overview_loading: false,
|
||||
processes_loading: false,
|
||||
software_loading: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ffi::Machine {
|
||||
pub fn refresh_telemetry(mut self: Pin<&mut Self>) {
|
||||
if self.telemetry_loading {
|
||||
return;
|
||||
}
|
||||
self.as_mut().set_telemetry_loading(true);
|
||||
let thread = self.qt_thread();
|
||||
std::thread::spawn(move || {
|
||||
let result = match local_api::get_optional("/v1/monitor") {
|
||||
Ok(Some(value)) => Ok(value),
|
||||
Ok(None) => Ok(Value::Object(serde_json::Map::new())),
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let _ = thread.queue(move |machine| machine.apply_telemetry(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn refresh_overview(mut self: Pin<&mut Self>) {
|
||||
if self.overview_loading {
|
||||
return;
|
||||
}
|
||||
self.as_mut().set_overview_loading(true);
|
||||
let thread = self.qt_thread();
|
||||
std::thread::spawn(move || {
|
||||
let result = load_overview();
|
||||
let _ = thread.queue(move |machine| machine.apply_overview(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn refresh_processes(mut self: Pin<&mut Self>) {
|
||||
if self.processes_loading {
|
||||
return;
|
||||
}
|
||||
self.as_mut().set_processes_loading(true);
|
||||
let thread = self.qt_thread();
|
||||
std::thread::spawn(move || {
|
||||
let result = local_api::get("/v1/processes");
|
||||
let _ = thread.queue(move |machine| machine.apply_processes(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn refresh_connections(self: Pin<&mut Self>) {
|
||||
let thread = self.qt_thread();
|
||||
std::thread::spawn(move || {
|
||||
let result = local_api::get("/v1/connections");
|
||||
let _ = thread.queue(move |machine| machine.apply_connections(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn load_process(self: Pin<&mut Self>, pid: i32) {
|
||||
if pid <= 0 {
|
||||
return;
|
||||
}
|
||||
let thread = self.qt_thread();
|
||||
std::thread::spawn(move || {
|
||||
let result = local_api::get(&format!("/v1/processes/{pid}"));
|
||||
let _ = thread.queue(move |machine| machine.apply_process_detail(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn refresh_software(mut self: Pin<&mut Self>) {
|
||||
if self.software_loading {
|
||||
return;
|
||||
}
|
||||
self.as_mut().set_software_loading(true);
|
||||
let thread = self.qt_thread();
|
||||
std::thread::spawn(move || {
|
||||
let result = local_api::get("/v1/software");
|
||||
let _ = thread.queue(move |machine| machine.apply_software(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn load_software(self: Pin<&mut Self>, package_type: &QString, identity: &QString) {
|
||||
let package_type = url_component(&package_type.to_string());
|
||||
let identity = url_component(&identity.to_string());
|
||||
let thread = self.qt_thread();
|
||||
std::thread::spawn(move || {
|
||||
let result = local_api::get(&format!(
|
||||
"/v1/software/detail?manager={package_type}&identity={identity}"
|
||||
));
|
||||
let _ = thread.queue(move |machine| machine.apply_software_detail(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn trigger_scan(mut self: Pin<&mut Self>) {
|
||||
self.as_mut()
|
||||
.set_operation_message(QString::from("Scan requested"));
|
||||
let thread = self.qt_thread();
|
||||
std::thread::spawn(move || {
|
||||
let result = local_api::post(
|
||||
"/v1/actions/trigger-scan",
|
||||
&Value::Object(Default::default()),
|
||||
);
|
||||
let _ = thread.queue(move |machine| machine.apply_operation(result));
|
||||
});
|
||||
}
|
||||
|
||||
// Standalone approval. The Agent runs dry-run, closure hash resolution, OSV,
|
||||
// mint, and helper execution; this only carries the request and renders the
|
||||
// verdict it gets back. No package-manager path exists in this process.
|
||||
pub fn approve_update(
|
||||
mut self: Pin<&mut Self>,
|
||||
package_type: &QString,
|
||||
package_name: &QString,
|
||||
available_version: &QString,
|
||||
override_reason: &QString,
|
||||
) {
|
||||
if self.approval_running {
|
||||
return;
|
||||
}
|
||||
let request = serde_json::json!({
|
||||
"package_type": package_type.to_string(),
|
||||
"package_name": package_name.to_string(),
|
||||
"available_version": available_version.to_string(),
|
||||
// Asserted from the session that opened the socket, not attested.
|
||||
// ARCH-002 leaves fresh StepUp and real attribution unfinished.
|
||||
"operator": std::env::var("USER")
|
||||
.or_else(|_| std::env::var("USERNAME"))
|
||||
.unwrap_or_else(|_| "local".into()),
|
||||
"override_reason": override_reason.to_string(),
|
||||
});
|
||||
self.as_mut().set_approval_running(true);
|
||||
self.as_mut().set_approval_json(QString::from("{}"));
|
||||
let thread = self.qt_thread();
|
||||
std::thread::spawn(move || {
|
||||
let result = local_api::post("/v1/actions/approve-update", &request);
|
||||
let _ = thread.queue(move |machine| machine.apply_approval(result));
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_approval(mut self: Pin<&mut Self>, result: Result<Value, String>) {
|
||||
self.as_mut().set_approval_running(false);
|
||||
let success = result.is_ok();
|
||||
let payload = match result {
|
||||
Ok(value) => value,
|
||||
Err(error) => serde_json::json!({ "error": error }),
|
||||
};
|
||||
self.as_mut().set_approval_json(json_string(&payload));
|
||||
self.approval_finished(success);
|
||||
}
|
||||
|
||||
fn apply_telemetry(mut self: Pin<&mut Self>, result: Result<Value, String>) {
|
||||
self.as_mut().set_telemetry_loading(false);
|
||||
match result {
|
||||
Ok(value) => {
|
||||
let cpu = &value["cpu"];
|
||||
let memory = &value["memory"];
|
||||
let network = &value["network"];
|
||||
let storage = &value["storage"];
|
||||
self.as_mut().set_cpu_usage(number(cpu, "usage_percent"));
|
||||
self.as_mut().set_load_1(number(cpu, "load_1"));
|
||||
self.as_mut().set_load_5(number(cpu, "load_5"));
|
||||
self.as_mut().set_load_15(number(cpu, "load_15"));
|
||||
self.as_mut()
|
||||
.set_memory_percent(number(memory, "used_percent"));
|
||||
self.as_mut().set_memory_used(integer(memory, "used_bytes"));
|
||||
self.as_mut()
|
||||
.set_memory_total(integer(memory, "total_bytes"));
|
||||
self.as_mut()
|
||||
.set_swap_percent(number(memory, "swap_percent"));
|
||||
self.as_mut()
|
||||
.set_network_receive(number(network, "receive_bytes_per_second"));
|
||||
self.as_mut()
|
||||
.set_network_transmit(number(network, "transmit_bytes_per_second"));
|
||||
self.as_mut()
|
||||
.set_disk_read(number(storage, "read_bytes_per_second"));
|
||||
self.as_mut()
|
||||
.set_disk_write(number(storage, "write_bytes_per_second"));
|
||||
let hottest = value["thermals"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|reading| reading["celsius"].as_f64())
|
||||
.fold(0.0_f64, f64::max);
|
||||
self.as_mut().set_temperature(hottest);
|
||||
self.as_mut()
|
||||
.set_history_json(json_string(&value["history"]));
|
||||
self.as_mut().set_connected(true);
|
||||
self.as_mut().set_connection_error(QString::default());
|
||||
self.telemetry_updated();
|
||||
}
|
||||
Err(error) => self.as_mut().report_connection_error(&error),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_overview(mut self: Pin<&mut Self>, result: Result<Overview, String>) {
|
||||
self.as_mut().set_overview_loading(false);
|
||||
match result {
|
||||
Ok(data) => self.as_mut().apply_overview_data(data),
|
||||
Err(error) => self.as_mut().report_connection_error(&error),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_overview_data(mut self: Pin<&mut Self>, data: Overview) {
|
||||
let gap = if data.missing.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"This Agent does not serve {}. Update the RedFlag Agent to fill these views — the machine is reachable, the Agent is behind.",
|
||||
data.missing.join(", ")
|
||||
)
|
||||
};
|
||||
self.as_mut().set_agent_gap(QString::from(gap.as_str()));
|
||||
let info = &data.system["system"];
|
||||
let name = text(&data.identity, "display_name")
|
||||
.or_else(|| text(info, "hostname"))
|
||||
.unwrap_or_else(|| "This machine".into());
|
||||
let os = text(info, "os_version")
|
||||
.or_else(|| text(&data.identity, "os_type"))
|
||||
.unwrap_or_else(|| "Unknown OS".into());
|
||||
let model = text(info, "device_model")
|
||||
.or_else(|| text(info, "device_type"))
|
||||
.unwrap_or_else(|| "Computer".into());
|
||||
let registered = data.identity["registered"].as_bool().unwrap_or(false);
|
||||
let updates = data.updates["updates"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let critical = data.security["critical_updates"].as_i64().unwrap_or(0) as i32;
|
||||
let memory = info["memory_info"]["used_percent"].as_f64().unwrap_or(0.0);
|
||||
let disk_pressure = info["disk_info"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|disk| disk["used_percent"].as_f64().unwrap_or(0.0) >= 90.0);
|
||||
let failed_services = data.services["failed"].as_i64().unwrap_or(0) as i32;
|
||||
let unhealthy = data.containers["unhealthy"].as_i64().unwrap_or(0) as i32;
|
||||
let degraded = data.security["degraded_mode"].as_bool().unwrap_or(false);
|
||||
let health = if degraded
|
||||
|| critical > 0
|
||||
|| memory >= 90.0
|
||||
|| disk_pressure
|
||||
|| failed_services > 0
|
||||
|| unhealthy > 0
|
||||
{
|
||||
"degraded"
|
||||
} else {
|
||||
"healthy"
|
||||
};
|
||||
|
||||
self.as_mut().set_hostname(QString::from(name.as_str()));
|
||||
self.as_mut()
|
||||
.set_machine_subtitle(QString::from(format!("{model} · {os}").as_str()));
|
||||
self.as_mut()
|
||||
.set_agent_version(qtext(&data.identity, "agent_version"));
|
||||
self.as_mut()
|
||||
.set_agent_status(qtext(&data.status, "agent_status"));
|
||||
self.as_mut().set_enrollment(QString::from(if registered {
|
||||
"fleet enrolled"
|
||||
} else {
|
||||
"standalone"
|
||||
}));
|
||||
self.as_mut().set_uptime(qtext(info, "uptime"));
|
||||
self.as_mut()
|
||||
.set_process_count(info["running_processes"].as_i64().unwrap_or(0) as i32);
|
||||
self.as_mut().set_update_count(updates.len() as i32);
|
||||
self.as_mut().set_critical_count(critical);
|
||||
self.as_mut()
|
||||
.set_container_count(data.containers["count"].as_i64().unwrap_or(0) as i32);
|
||||
self.as_mut()
|
||||
.set_container_running(data.containers["running"].as_i64().unwrap_or(0) as i32);
|
||||
self.as_mut().set_container_unhealthy(unhealthy);
|
||||
self.as_mut()
|
||||
.set_service_count(data.services["count"].as_i64().unwrap_or(0) as i32);
|
||||
self.as_mut()
|
||||
.set_service_running(data.services["running"].as_i64().unwrap_or(0) as i32);
|
||||
self.as_mut().set_service_failed(failed_services);
|
||||
self.as_mut().set_health_state(QString::from(health));
|
||||
self.as_mut().set_system_json(json_string(info));
|
||||
self.as_mut()
|
||||
.set_updates_json(json_string(&Value::Array(updates)));
|
||||
self.as_mut().set_security_json(json_string(&data.security));
|
||||
self.as_mut()
|
||||
.set_containers_json(json_string(&data.containers["containers"]));
|
||||
self.as_mut()
|
||||
.set_services_json(json_string(&data.services["services"]));
|
||||
self.as_mut()
|
||||
.set_events_json(json_string(&data.events["events"]));
|
||||
self.as_mut().set_connected(true);
|
||||
self.as_mut().set_connection_error(QString::default());
|
||||
self.overview_updated();
|
||||
}
|
||||
|
||||
fn apply_processes(mut self: Pin<&mut Self>, result: Result<Value, String>) {
|
||||
self.as_mut().set_processes_loading(false);
|
||||
match result {
|
||||
Ok(value) => {
|
||||
self.as_mut()
|
||||
.set_processes_json(json_string(&value["processes"]));
|
||||
self.as_mut()
|
||||
.set_process_count(value["process_count"].as_i64().unwrap_or(0) as i32);
|
||||
self.processes_updated();
|
||||
}
|
||||
Err(error) => self
|
||||
.as_mut()
|
||||
.set_operation_message(QString::from(error.as_str())),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_connections(mut self: Pin<&mut Self>, result: Result<Value, String>) {
|
||||
match result {
|
||||
Ok(value) => {
|
||||
self.as_mut()
|
||||
.set_connections_json(json_string(&value["connections"]));
|
||||
self.connections_updated();
|
||||
}
|
||||
Err(error) => self
|
||||
.as_mut()
|
||||
.set_operation_message(QString::from(error.as_str())),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_process_detail(mut self: Pin<&mut Self>, result: Result<Value, String>) {
|
||||
match result {
|
||||
Ok(value) => self.as_mut().set_process_detail_json(json_string(&value)),
|
||||
Err(error) => self
|
||||
.as_mut()
|
||||
.set_process_detail_json(json_string(&serde_json::json!({"error": error}))),
|
||||
}
|
||||
self.process_detail_updated();
|
||||
}
|
||||
|
||||
fn apply_software(mut self: Pin<&mut Self>, result: Result<Value, String>) {
|
||||
self.as_mut().set_software_loading(false);
|
||||
match result {
|
||||
Ok(value) => {
|
||||
self.as_mut()
|
||||
.set_software_count(value["count"].as_i64().unwrap_or(0) as i32);
|
||||
self.as_mut().set_software_explicit_count(
|
||||
value["explicit_count"].as_i64().unwrap_or(0) as i32,
|
||||
);
|
||||
self.as_mut().set_software_dependency_count(
|
||||
value["dependency_count"].as_i64().unwrap_or(0) as i32,
|
||||
);
|
||||
self.as_mut().set_software_foreign_count(
|
||||
value["foreign_count"].as_i64().unwrap_or(0) as i32,
|
||||
);
|
||||
self.as_mut().set_software_json(json_string(&value));
|
||||
self.software_updated();
|
||||
}
|
||||
Err(error) => self
|
||||
.as_mut()
|
||||
.set_operation_message(QString::from(error.as_str())),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_software_detail(mut self: Pin<&mut Self>, result: Result<Value, String>) {
|
||||
let payload = match result {
|
||||
Ok(value) => value,
|
||||
Err(error) => serde_json::json!({ "error": error }),
|
||||
};
|
||||
self.as_mut()
|
||||
.set_software_detail_json(json_string(&payload));
|
||||
self.software_detail_updated();
|
||||
}
|
||||
|
||||
fn apply_operation(mut self: Pin<&mut Self>, result: Result<Value, String>) {
|
||||
let (success, message) = match result {
|
||||
Ok(value) => (
|
||||
value["accepted"].as_bool().unwrap_or(true),
|
||||
"Agent scan started".to_string(),
|
||||
),
|
||||
Err(error) => (false, error),
|
||||
};
|
||||
self.as_mut()
|
||||
.set_operation_message(QString::from(message.as_str()));
|
||||
self.operation_finished(success);
|
||||
}
|
||||
|
||||
fn report_connection_error(mut self: Pin<&mut Self>, error: &str) {
|
||||
self.as_mut().set_connected(false);
|
||||
self.as_mut().set_agent_status(QString::from("unreachable"));
|
||||
self.as_mut().set_health_state(QString::from("unknown"));
|
||||
self.as_mut().set_connection_error(QString::from(error));
|
||||
}
|
||||
}
|
||||
|
||||
struct Overview {
|
||||
identity: Value,
|
||||
status: Value,
|
||||
system: Value,
|
||||
updates: Value,
|
||||
security: Value,
|
||||
containers: Value,
|
||||
services: Value,
|
||||
events: Value,
|
||||
missing: Vec<&'static str>,
|
||||
}
|
||||
|
||||
fn load_overview() -> Result<Overview, String> {
|
||||
// Presence is a report to the Agent, never a self-declared health verdict.
|
||||
// The Agent owns liveness and may surface or journal a missing desktop.
|
||||
let _ = local_api::post(
|
||||
"/v1/desktop",
|
||||
&serde_json::json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": DESKTOP_START.elapsed().as_secs(),
|
||||
"window_open": true
|
||||
}),
|
||||
);
|
||||
// identity and status are the contract every Agent has served. Everything
|
||||
// below them arrived later, so a 404 names an Agent that predates the
|
||||
// endpoint rather than a machine that cannot answer.
|
||||
let identity = local_api::get("/v1/identity")?;
|
||||
let status = local_api::get("/v1/status")?;
|
||||
let mut missing = Vec::new();
|
||||
let mut optional =
|
||||
|path: &str, name: &'static str, empty: Value| match local_api::get_optional(path) {
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => {
|
||||
missing.push(name);
|
||||
empty
|
||||
}
|
||||
Err(_) => empty,
|
||||
};
|
||||
Ok(Overview {
|
||||
system: optional("/v1/system", "machine health", empty_object()),
|
||||
updates: optional("/v1/packages", "packages", empty_list("updates")),
|
||||
security: optional("/v1/security", "security posture", empty_object()),
|
||||
containers: optional("/v1/containers", "containers", empty_list("containers")),
|
||||
services: optional("/v1/services", "services", empty_list("services")),
|
||||
events: optional("/v1/events", "history", empty_list("events")),
|
||||
missing,
|
||||
identity,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_object() -> Value {
|
||||
Value::Object(serde_json::Map::new())
|
||||
}
|
||||
|
||||
fn empty_list(key: &str) -> Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
object.insert(key.to_string(), Value::Array(Vec::new()));
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
fn number(value: &Value, key: &str) -> f64 {
|
||||
value[key].as_f64().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn integer(value: &Value, key: &str) -> u64 {
|
||||
value[key].as_u64().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn text(value: &Value, key: &str) -> Option<String> {
|
||||
value[key]
|
||||
.as_str()
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn qtext(value: &Value, key: &str) -> QString {
|
||||
QString::from(text(value, key).unwrap_or_default().as_str())
|
||||
}
|
||||
|
||||
fn json_string(value: &Value) -> QString {
|
||||
QString::from(
|
||||
serde_json::to_string(value)
|
||||
.unwrap_or_else(|_| "null".into())
|
||||
.as_str(),
|
||||
)
|
||||
}
|
||||
|
||||
fn url_component(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(&mut encoded, "%{byte:02X}");
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
2
desktop/src/bridge/mod.rs
Normal file
2
desktop/src/bridge/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod local_api;
|
||||
pub mod machine;
|
||||
|
|
@ -1,519 +1,38 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager,
|
||||
};
|
||||
use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QString, QUrl};
|
||||
|
||||
#[cfg(unix)]
|
||||
const LOCAL_SOCKET_PATH: &str = "/var/lib/redflag/agent/localapi/redflag-agent.sock";
|
||||
#[cfg(windows)]
|
||||
const LOCAL_PIPE_NAME: &str = r"\\.\pipe\RedFlagAgentLocal";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct LocalSnapshot {
|
||||
identity: IdentityResponse,
|
||||
status: StatusResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct IdentityResponse {
|
||||
agent_id: String,
|
||||
server_url: String,
|
||||
hostname: Option<String>,
|
||||
os_type: Option<String>,
|
||||
display_name: Option<String>,
|
||||
organization: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
agent_version: String,
|
||||
config_version: Option<String>,
|
||||
check_in_interval: i64,
|
||||
registered: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct StatusResponse {
|
||||
agent_status: String,
|
||||
last_check_in: Option<String>,
|
||||
last_updated: Option<String>,
|
||||
last_scan_time: Option<String>,
|
||||
update_count: i64,
|
||||
summary: UpdateSummary,
|
||||
scanners: Option<BTreeMap<String, ScannerState>>,
|
||||
registered: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct UpdateSummary {
|
||||
total: i64,
|
||||
by_ecosystem: Option<BTreeMap<String, i64>>,
|
||||
by_severity: Option<BTreeMap<String, i64>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ScannerState {
|
||||
name: String,
|
||||
status: String,
|
||||
last_scan_time: Option<String>,
|
||||
last_duration_ms: Option<i64>,
|
||||
last_error: Option<String>,
|
||||
update_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DesktopHealthRequest {
|
||||
version: String,
|
||||
uptime_seconds: u64,
|
||||
window_open: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DesktopHealthResponse {
|
||||
agent_version: Option<String>,
|
||||
desktop: Option<DesktopStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DesktopStatus {
|
||||
running: bool,
|
||||
pid: Option<u32>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
trait ReadWrite: Read + Write {}
|
||||
impl<T: Read + Write> ReadWrite for T {}
|
||||
|
||||
#[tauri::command]
|
||||
fn local_status() -> Result<LocalSnapshot, String> {
|
||||
let identity: IdentityResponse = local_get_json("/v1/identity")?;
|
||||
let status: StatusResponse = local_get_json("/v1/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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct SystemSnapshot {
|
||||
system: SystemInfo,
|
||||
#[serde(default)]
|
||||
top_processes: Vec<TopProcess>,
|
||||
collected_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct SystemInfo {
|
||||
hostname: String,
|
||||
os_type: String,
|
||||
os_version: String,
|
||||
os_architecture: String,
|
||||
ip_address: String,
|
||||
cpu_info: CpuInfo,
|
||||
memory_info: MemoryInfo,
|
||||
#[serde(default)]
|
||||
disk_info: Vec<DiskInfo>,
|
||||
running_processes: i64,
|
||||
uptime: String,
|
||||
reboot_required: bool,
|
||||
#[serde(default)]
|
||||
reboot_reason: String,
|
||||
#[serde(default)]
|
||||
device_type: String,
|
||||
#[serde(default)]
|
||||
device_model: String,
|
||||
#[serde(default)]
|
||||
os_distro: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CpuInfo {
|
||||
model_name: String,
|
||||
cores: i64,
|
||||
threads: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct MemoryInfo {
|
||||
total: u64,
|
||||
available: u64,
|
||||
used: u64,
|
||||
used_percent: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct DiskInfo {
|
||||
mountpoint: String,
|
||||
total: u64,
|
||||
available: u64,
|
||||
used: u64,
|
||||
used_percent: f64,
|
||||
filesystem: String,
|
||||
is_root: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TopProcess {
|
||||
name: String,
|
||||
pid: i64,
|
||||
cpu: f64,
|
||||
mem: f64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_updates() -> Result<ScanSnapshot, String> {
|
||||
local_get_json("/v1/packages")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn system_health() -> Result<SystemSnapshot, String> {
|
||||
local_get_json("/v1/system")
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TriggerScanResponse {
|
||||
accepted: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn trigger_scan() -> Result<TriggerScanResponse, String> {
|
||||
// The agent answers 202 Accepted or 409 Conflict, both with a JSON body.
|
||||
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)]
|
||||
struct ApproveUpdateRequest {
|
||||
package_type: String,
|
||||
package_name: String,
|
||||
#[serde(default)]
|
||||
available_version: String,
|
||||
#[serde(default)]
|
||||
operator: String,
|
||||
#[serde(default)]
|
||||
override_reason: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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 =
|
||||
serde_json::to_vec(&request).map_err(|err| format!("encode approve-update request: {err}"))?;
|
||||
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> {
|
||||
let body = local_get(path)?;
|
||||
serde_json::from_str(&body).map_err(|err| format!("decode local API response: {err}"))
|
||||
}
|
||||
|
||||
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 response = local_post(path, &body_bytes)?;
|
||||
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> {
|
||||
let mut stream = connect_local_api()?;
|
||||
let request = format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: redflag.local\r\nAccept: application/json\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.map_err(|err| format!("write local API request: {err}"))?;
|
||||
stream
|
||||
.flush()
|
||||
.map_err(|err| format!("flush local API request: {err}"))?;
|
||||
|
||||
let mut response = String::new();
|
||||
stream
|
||||
.read_to_string(&mut response)
|
||||
.map_err(|err| format!("read local API response: {err}"))?;
|
||||
|
||||
let (head, body) = response
|
||||
.split_once("\r\n\r\n")
|
||||
.ok_or_else(|| "local API response missing HTTP headers".to_string())?;
|
||||
let status = head
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or_else(|| "local API response missing status line".to_string())?;
|
||||
if !status.contains(" 200 ") {
|
||||
return Err(format!("local API returned {status}"));
|
||||
}
|
||||
Ok(body.to_string())
|
||||
}
|
||||
|
||||
fn local_post(path: &str, body: &[u8]) -> Result<LocalHttpResponse, String> {
|
||||
let mut stream = connect_local_api()?;
|
||||
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",
|
||||
body.len()
|
||||
);
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.map_err(|err| format!("write POST request: {err}"))?;
|
||||
stream
|
||||
.write_all(body)
|
||||
.map_err(|err| format!("write POST body: {err}"))?;
|
||||
stream
|
||||
.flush()
|
||||
.map_err(|err| format!("flush POST request: {err}"))?;
|
||||
|
||||
let mut response = String::new();
|
||||
stream
|
||||
.read_to_string(&mut response)
|
||||
.map_err(|err| format!("read POST response: {err}"))?;
|
||||
|
||||
let (head, body) = response
|
||||
.split_once("\r\n\r\n")
|
||||
.ok_or_else(|| "POST response missing HTTP headers".to_string())?;
|
||||
let status_line = head
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or_else(|| "POST response missing status line".to_string())?;
|
||||
let status: u16 = status_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.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)]
|
||||
fn connect_local_api() -> Result<Box<dyn ReadWrite>, String> {
|
||||
let stream = std::os::unix::net::UnixStream::connect(LOCAL_SOCKET_PATH).map_err(|err| {
|
||||
if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
diagnose_socket_permission()
|
||||
} else {
|
||||
format!("connect local API socket {LOCAL_SOCKET_PATH}: {err}")
|
||||
}
|
||||
})?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
// The local API socket is gated by the redflag-local group. Permission denied
|
||||
// has exactly two causes worth telling the user apart: the user was never
|
||||
// added to the group (installer predates the desktop app, or ran headless),
|
||||
// or the user IS in the group on disk but this login session was stamped
|
||||
// before the membership existed — groups only apply at login.
|
||||
#[cfg(unix)]
|
||||
fn diagnose_socket_permission() -> String {
|
||||
const GROUP: &str = "redflag-local";
|
||||
let user = std::env::var("USER")
|
||||
.or_else(|_| std::env::var("LOGNAME"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let on_disk = std::fs::read_to_string("/etc/group")
|
||||
.ok()
|
||||
.and_then(|groups| {
|
||||
groups.lines().find_map(|line| {
|
||||
let mut fields = line.split(':');
|
||||
if fields.next() != Some(GROUP) {
|
||||
return None;
|
||||
}
|
||||
let gid = fields.nth(1)?.to_string();
|
||||
let members = fields.next().unwrap_or_default();
|
||||
Some((gid, members.split(',').any(|m| m == user)))
|
||||
})
|
||||
});
|
||||
|
||||
let Some((gid, user_in_group)) = on_disk else {
|
||||
return format!(
|
||||
"Cannot reach the RedFlag agent: the {GROUP} group does not exist. \
|
||||
The agent installer creates it — is the agent installed on this machine?"
|
||||
);
|
||||
};
|
||||
|
||||
let session_has_group = std::fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|status| {
|
||||
status.lines().find_map(|line| {
|
||||
line.strip_prefix("Groups:")
|
||||
.map(|ids| ids.split_whitespace().any(|id| id == gid))
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if user_in_group && !session_has_group {
|
||||
format!(
|
||||
"Almost there: {user} is in the {GROUP} group, but this login session \
|
||||
started before the membership was added. Log out and back in, then \
|
||||
reopen RedFlag."
|
||||
)
|
||||
} else if !user_in_group {
|
||||
format!(
|
||||
"Cannot reach the RedFlag agent: {user} is not in the {GROUP} group. \
|
||||
Run: sudo usermod -aG {GROUP} {user} — then log out and back in."
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"connect local API socket {LOCAL_SOCKET_PATH}: permission denied \
|
||||
(group membership looks correct — check socket directory permissions)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn connect_local_api() -> Result<Box<dyn ReadWrite>, String> {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(LOCAL_PIPE_NAME)
|
||||
.map_err(|err| format!("connect local API pipe {LOCAL_PIPE_NAME}: {err}"))?;
|
||||
Ok(Box::new(file))
|
||||
}
|
||||
|
||||
fn show_main_window(app: &tauri::AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
mod bridge;
|
||||
|
||||
fn main() {
|
||||
let start_time = Instant::now();
|
||||
let window_open = Arc::new(AtomicBool::new(true));
|
||||
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
local_status,
|
||||
system_health,
|
||||
list_updates,
|
||||
trigger_scan,
|
||||
approve_update
|
||||
])
|
||||
.setup(move |app| {
|
||||
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 menu = Menu::with_items(app, &[&show_i, &quit_i])?;
|
||||
|
||||
let window_open_clone = window_open.clone();
|
||||
let window_open_tray = window_open.clone();
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(move |app, event| match event.id.as_ref() {
|
||||
"show" => {
|
||||
window_open_clone.store(true, Ordering::Relaxed);
|
||||
show_main_window(app);
|
||||
if std::env::args().any(|arg| arg == "--version" || arg == "-V") {
|
||||
println!("RedFlag v{}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
|
||||
cxx_qt::init_crate!(redflag_desktop);
|
||||
cxx_qt::init_qml_module!("com.redflag.desktop");
|
||||
|
||||
let mut app = QGuiApplication::new();
|
||||
if let Some(mut app) = app.as_mut() {
|
||||
app.as_mut()
|
||||
.set_application_name(&QString::from("com.redflag.Desktop"));
|
||||
app.as_mut()
|
||||
.set_application_display_name(&QString::from("RedFlag"));
|
||||
app.as_mut()
|
||||
.set_organization_name(&QString::from("RedFlag"));
|
||||
}
|
||||
|
||||
let mut engine = QQmlApplicationEngine::new();
|
||||
if let Some(mut engine) = engine.as_mut() {
|
||||
engine
|
||||
.as_mut()
|
||||
.on_object_creation_failed(|_, url| {
|
||||
eprintln!("redflag-desktop: QML root failed to construct: {url}");
|
||||
})
|
||||
.on_tray_icon_event(move |tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
window_open_tray.store(true, Ordering::Relaxed);
|
||||
show_main_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Hide to tray on close instead of exiting; track visibility.
|
||||
let window_open_close = window_open.clone();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let value = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
window_open_close.store(false, Ordering::Relaxed);
|
||||
let _ = value.hide();
|
||||
}
|
||||
});
|
||||
.release();
|
||||
engine.load(&QUrl::from("qrc:/qt/qml/com/redflag/desktop/qml/Main.qml"));
|
||||
}
|
||||
|
||||
// Spawn health reporting thread — POSTs to /v1/desktop every 30s.
|
||||
let window_open_health = window_open.clone();
|
||||
std::thread::spawn(move || {
|
||||
let version = env!("CARGO_PKG_VERSION").to_string();
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
let req = DesktopHealthRequest {
|
||||
version: version.clone(),
|
||||
uptime_seconds: start_time.elapsed().as_secs(),
|
||||
window_open: window_open_health.load(Ordering::Relaxed),
|
||||
};
|
||||
match local_post_json::<_, DesktopHealthResponse>("/v1/desktop", &req) {
|
||||
Ok(_resp) => {
|
||||
// Health reported successfully.
|
||||
if let Some(app) = app.as_mut() {
|
||||
app.exec();
|
||||
}
|
||||
Err(err) => {
|
||||
// Best-effort — don't crash if agent is temporarily unavailable.
|
||||
eprintln!("desktop health report failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error running RedFlag desktop");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "RedFlag",
|
||||
"identifier": "com.redflag.local",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm --prefix ../web run dev:desktop",
|
||||
"devUrl": "http://127.0.0.1:3001/index.desktop.html",
|
||||
"beforeBuildCommand": "npm --prefix ../web run build:desktop",
|
||||
"frontendDist": "../web/dist-desktop"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "RedFlag",
|
||||
"width": 980,
|
||||
"height": 760,
|
||||
"minWidth": 760,
|
||||
"minHeight": 620,
|
||||
"resizable": true,
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"icon": [
|
||||
"icons/icon.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -110,9 +110,8 @@ if [ -f "$FILE" ]; then
|
|||
echo " [5] desktop/Cargo.toml version -> $CARGO_VERSION (from $NEW_VERSION)"
|
||||
fi
|
||||
|
||||
# desktop/tauri.conf.json carries no version field — Tauri inherits it from
|
||||
# desktop/Cargo.toml (set above). A 4-octet there is not valid semver and Tauri's
|
||||
# build refuses it, so the desktop version has a single source: the crate.
|
||||
# Desktop is a Rust/Qt crate. Its Cargo version carries three semver fields;
|
||||
# release tags and the Go components retain RedFlag's fourth build field.
|
||||
|
||||
echo ""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
#!/bin/sh
|
||||
# fetch-desktop-windows.sh — obtain the prebuilt, signed Windows desktop tray
|
||||
# fetch-desktop-windows.sh — obtain the prebuilt, signed Windows Desktop
|
||||
# binary from the newest published release and verify it against that release's
|
||||
# manifest hash.
|
||||
#
|
||||
# Why this exists: the agent is Go and cross-compiles to Windows for free
|
||||
# (Dockerfile bakes redflag-agent.exe directly). The desktop tray is Tauri/Rust
|
||||
# and its Windows cross-compile drags in the whole MSVC sysroot (~9GB of build
|
||||
# toolchain). Forcing that into every from-source `docker-compose build` is
|
||||
# unacceptable — build-from-source is the primary distribution path. So the
|
||||
# expensive compile lives in CI (release.yml, cargo-xwin) exactly once, and
|
||||
# from-source servers fetch the resulting signed artifact here.
|
||||
# Why this exists: the Go agent cross-compiles to Windows without a Windows
|
||||
# SDK. Native Qt/QML Desktop does not: it needs a matching Qt and MSVC toolchain.
|
||||
# That build belongs on a native release runner, never inside every source-built
|
||||
# server image. Until that runner publishes an artifact, this script returns an
|
||||
# honest optional absence.
|
||||
#
|
||||
# Trust: TLS to the forge + the manifest sha256 gate this download, the same
|
||||
# trust any release download carries. The server re-signs the binary with its
|
||||
|
|
@ -18,14 +16,14 @@
|
|||
# serving path; it is not the fleet trust root.
|
||||
#
|
||||
# The desktop component is OPTIONAL (manifest required:false). Every failure that
|
||||
# is not active tampering degrades to "no Windows tray" rather than breaking the
|
||||
# is not active tampering degrades to "no Windows Desktop" rather than breaking the
|
||||
# build: offline, no release yet, no desktop asset in the release, no manifest
|
||||
# entry. A hash MISMATCH is the one hard stop — that is tampering, not absence.
|
||||
set -eu
|
||||
|
||||
ARCH="${1:-amd64}"
|
||||
OUT_DIR="${2:-/out}"
|
||||
REPO_API="${DESKTOP_RELEASE_REPO_API:-https://codeberg.org/api/v1/repos/Fimeg/RedFlag}"
|
||||
REPO_API="${DESKTOP_RELEASE_REPO_API:-https://forge.caseytunturi.com/api/v1/repos/Fimeg/RedFlag}"
|
||||
|
||||
log() { printf '%s\n' "[INFO] [build] [desktop-fetch] $*" >&2; }
|
||||
warn() { printf '%s\n' "[WARN] [build] [desktop-fetch] $*" >&2; }
|
||||
|
|
@ -40,25 +38,25 @@ trap 'rm -rf "$work"' EXIT
|
|||
# would always come back empty. The list endpoint sorts newest-first.
|
||||
log "querying newest release from $REPO_API"
|
||||
if ! curl -sfL "$REPO_API/releases?limit=1&draft=false" -o "$work/releases.json"; then
|
||||
warn "release API unreachable (offline or forge down) — skipping Windows tray"
|
||||
warn "release API unreachable (offline or forge down) — skipping Windows Desktop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$(jq 'length' "$work/releases.json" 2>/dev/null || echo 0)" = "0" ]; then
|
||||
log "no published releases at $REPO_API yet — Windows tray ships once a release exists"
|
||||
log "no published releases at $REPO_API yet — Windows Desktop ships once a native build exists"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
manifest_url="$(jq -r '.[0].assets[]? | select(.name == "manifest.json") | .browser_download_url' "$work/releases.json" 2>/dev/null | head -1)"
|
||||
tag="$(jq -r '.[0].tag_name // empty' "$work/releases.json" 2>/dev/null)"
|
||||
if [ -z "$manifest_url" ] || [ "$manifest_url" = "null" ]; then
|
||||
warn "newest release ($tag) has no manifest.json asset — skipping Windows tray"
|
||||
warn "newest release ($tag) has no manifest.json asset — skipping Windows Desktop"
|
||||
exit 0
|
||||
fi
|
||||
log "newest release: $tag"
|
||||
|
||||
if ! curl -sfL "$manifest_url" -o "$work/manifest.json"; then
|
||||
warn "manifest download failed — skipping Windows tray"
|
||||
warn "manifest download failed — skipping Windows Desktop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -71,7 +69,7 @@ exe_name="$(jq -r --arg a "$ARCH" \
|
|||
"$work/manifest.json" 2>/dev/null | head -1)"
|
||||
|
||||
if [ -z "$expected_sha" ] || [ "$expected_sha" = "null" ]; then
|
||||
warn "release $tag carries no desktop-windows/$ARCH binary — Windows tray unavailable from this release"
|
||||
warn "release $tag carries no desktop-windows/$ARCH binary — Windows Desktop unavailable from this release"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -83,24 +81,24 @@ zip_url="$(jq -r --arg z "$zip_name" \
|
|||
'.[0].assets[]? | select(.name == $z) | .browser_download_url' \
|
||||
"$work/releases.json" 2>/dev/null | head -1)"
|
||||
if [ -z "$zip_url" ] || [ "$zip_url" = "null" ]; then
|
||||
warn "release $tag has a manifest entry but no $zip_name asset — skipping Windows tray"
|
||||
warn "release $tag has a manifest entry but no $zip_name asset — skipping Windows Desktop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "downloading $zip_name"
|
||||
if ! curl -sfL "$zip_url" -o "$work/win.zip"; then
|
||||
warn "zip download failed — skipping Windows tray"
|
||||
warn "zip download failed — skipping Windows Desktop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! unzip -o -q "$work/win.zip" -d "$work/unz"; then
|
||||
warn "zip extraction failed — skipping Windows tray"
|
||||
warn "zip extraction failed — skipping Windows Desktop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
src="$work/unz/$exe_name"
|
||||
if [ ! -f "$src" ]; then
|
||||
warn "$exe_name not found inside $zip_name — skipping Windows tray"
|
||||
warn "$exe_name not found inside $zip_name — skipping Windows Desktop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -111,4 +109,4 @@ if [ "$actual_sha" != "$expected_sha" ]; then
|
|||
fi
|
||||
|
||||
cp "$src" "$OUT_DIR/redflag-desktop.exe"
|
||||
log "verified Windows tray $tag (desktop-windows/$ARCH) -> $OUT_DIR/redflag-desktop.exe"
|
||||
log "verified Windows Desktop $tag (desktop-windows/$ARCH) -> $OUT_DIR/redflag-desktop.exe"
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ RUN npm ci --ignore-scripts
|
|||
COPY web/ ./
|
||||
RUN npx vite build
|
||||
|
||||
# Desktop variant (Tauri frontend) — built here too because this stage has
|
||||
# Node 20; bookworm's packaged Node 18 is below Vite's floor.
|
||||
RUN npx tsc && npx vite build --config vite.desktop.config.ts
|
||||
|
||||
# Stage 0b: Supply-chain self-attestation. Runs the SAME scripts/dep-scan.sh the
|
||||
# CI/release pipeline runs, so an operator's own docker-compose build gates its
|
||||
# dependency supply chain and produces the attested posture embedded into the
|
||||
|
|
@ -154,47 +150,38 @@ RUN rustup target add 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 native Qt/QML Desktop.
|
||||
FROM rust:1-bookworm AS desktop-builder
|
||||
|
||||
ARG BUILD_VERSION=dev
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libjavascriptcoregtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libgtk-3-dev \
|
||||
libsoup-3.0-dev \
|
||||
libglib2.0-dev \
|
||||
qt6-base-dev \
|
||||
qt6-declarative-dev \
|
||||
qt6-declarative-dev-tools \
|
||||
libgl1-mesa-dev \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Tauri's build embeds frontendDist (../web/dist-desktop) at compile time —
|
||||
# take the bundle from the Node 20 stage instead of building it here.
|
||||
COPY --from=web-builder /web/dist-desktop web/dist-desktop
|
||||
|
||||
# Copy desktop Tauri source
|
||||
# The QML module, bridge, and visual assets are embedded by CXX-Qt.
|
||||
COPY desktop/ desktop/
|
||||
|
||||
# Build the Tauri app in release mode
|
||||
RUN cd desktop && cargo build --release && \
|
||||
RUN cd desktop && cargo build --release --locked && \
|
||||
mkdir -p /out && \
|
||||
cp target/release/redflag-desktop /out/redflag-desktop
|
||||
|
||||
# Stage 2d: Fetch the prebuilt, signed Windows desktop tray from the newest
|
||||
# Stage 2d: Fetch the prebuilt, signed Windows Desktop from the newest
|
||||
# release and verify it against the release manifest hash. The Windows desktop
|
||||
# is Tauri/Rust — cross-compiling it pulls the whole MSVC sysroot (~9GB of build
|
||||
# toolchain), which has no business in a from-source server build. CI
|
||||
# (release.yml, cargo-xwin) builds it once where the toolchain is ephemeral;
|
||||
# this stage fetches the signed result. Optional component: anything short of a
|
||||
# hash mismatch degrades to "no Windows tray". See scripts/fetch-desktop-windows.sh.
|
||||
# needs a matching native Qt and MSVC SDK, which has no business in a
|
||||
# from-source server build. A native release runner will build it once; this
|
||||
# stage fetches that signed result. Optional component: anything short of a
|
||||
# hash mismatch degrades to "no Windows Desktop". See scripts/fetch-desktop-windows.sh.
|
||||
FROM alpine:3.21 AS desktop-windows-fetcher
|
||||
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG DESKTOP_RELEASE_REPO_API=https://codeberg.org/api/v1/repos/Fimeg/RedFlag
|
||||
ARG DESKTOP_RELEASE_REPO_API=https://forge.caseytunturi.com/api/v1/repos/Fimeg/RedFlag
|
||||
ENV DESKTOP_RELEASE_REPO_API=${DESKTOP_RELEASE_REPO_API}
|
||||
|
||||
RUN apk --no-cache add curl jq unzip
|
||||
|
|
@ -202,8 +189,8 @@ RUN apk --no-cache add curl jq unzip
|
|||
COPY scripts/fetch-desktop-windows.sh /usr/local/bin/fetch-desktop-windows.sh
|
||||
|
||||
# BUILD_VERSION sits in this layer's cache key so bumping the version re-fetches
|
||||
# rather than reusing a stale tray from an earlier build.
|
||||
RUN echo "fetch Windows tray for build $BUILD_VERSION" && \
|
||||
# rather than reusing a stale Desktop from an earlier build.
|
||||
RUN echo "fetch Windows Desktop for build $BUILD_VERSION" && \
|
||||
sh /usr/local/bin/fetch-desktop-windows.sh amd64 /out
|
||||
|
||||
# Stage 3: Final image with server and all agent binaries
|
||||
|
|
@ -225,21 +212,21 @@ COPY --from=agent-builder /build/binaries ./binaries
|
|||
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 native RedFlag Desktop.
|
||||
COPY --from=desktop-builder /out/redflag-desktop ./binaries/linux-amd64/redflag-desktop
|
||||
|
||||
# Stage in the Windows desktop tray IF the newest release carried one (fetched +
|
||||
# Stage in Windows Desktop IF the newest release carried one (fetched +
|
||||
# hash-verified by the desktop-windows-fetcher stage). When absent — no release
|
||||
# yet, offline build, or a release with no desktop — the server simply serves no
|
||||
# Windows tray and the installer 404-skips it. The heavy Windows compile never
|
||||
# Windows Desktop and the installer 404-skips it. The native Windows compile never
|
||||
# happens here; only the verified artifact lands.
|
||||
COPY --from=desktop-windows-fetcher /out /opt/winfetch
|
||||
RUN if [ -f /opt/winfetch/redflag-desktop.exe ]; then \
|
||||
mkdir -p ./binaries/windows-amd64 && \
|
||||
mv /opt/winfetch/redflag-desktop.exe ./binaries/windows-amd64/redflag-desktop.exe && \
|
||||
echo "[INFO] [build] [desktop] staged Windows tray into image"; \
|
||||
echo "[INFO] [build] [desktop] staged Windows Desktop into image"; \
|
||||
else \
|
||||
echo "[INFO] [build] [desktop] no Windows tray staged (none in latest release)"; \
|
||||
echo "[INFO] [build] [desktop] no Windows Desktop staged (none in latest release)"; \
|
||||
fi; \
|
||||
rm -rf /opt/winfetch
|
||||
|
||||
|
|
|
|||
|
|
@ -309,7 +309,7 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
// Sign desktop binaries (Tauri system tray + local UI shell).
|
||||
// Sign native Desktop binaries.
|
||||
// Same pattern as helper: stored under "desktop-<os>", listed in
|
||||
// the release manifest. Missing binary is non-fatal — installer skips.
|
||||
desktopArches := []string{"amd64"}
|
||||
|
|
@ -620,7 +620,7 @@ func main() {
|
|||
// route, same as /manifest. Signed + manifest-verified at install time.
|
||||
api.GET("/helper/:arch", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadHelper)
|
||||
|
||||
// Desktop app (Tauri system tray + local UI shell). Optional — 404
|
||||
// Native Desktop app. Optional — 404
|
||||
// if not built for this arch, installer skips gracefully.
|
||||
api.GET("/desktop/:platform/:arch", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadDesktop)
|
||||
|
||||
|
|
|
|||
|
|
@ -350,7 +350,7 @@ func (h *DownloadHandler) DownloadHelper(c *gin.Context) {
|
|||
c.File(absPath)
|
||||
}
|
||||
|
||||
// DownloadDesktop serves the Tauri desktop app (system tray + local UI shell).
|
||||
// DownloadDesktop serves the native Qt/QML local-machine operations console.
|
||||
// The desktop binary is optional — if not built for a given platform/arch,
|
||||
// returns 404 gracefully so the installer can skip it.
|
||||
func (h *DownloadHandler) DownloadDesktop(c *gin.Context) {
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ if $GUIDED; then
|
|||
echo ""
|
||||
echo "This will install the RedFlag agent and its components."
|
||||
echo "Required components: agent, helper, web (embedded in server)."
|
||||
echo "Optional components: desktop (system tray + local dashboard)."
|
||||
echo "Optional components: RedFlag Desktop (native local-machine operations console)."
|
||||
echo ""
|
||||
echo "Press Enter to continue or Ctrl-C to abort."
|
||||
read -r
|
||||
|
|
@ -279,10 +279,10 @@ if [ ! -d "$AGENT_HOME" ]; then
|
|||
echo " - Logs: $AGENT_LOG_DIR"
|
||||
fi
|
||||
|
||||
# Local-API / tray access chain — enforced on EVERY run, not just first
|
||||
# install. Upgrades from pre-tray versions carry agent:agent 700 dirs that
|
||||
# Local-API / Desktop access chain — enforced on EVERY run, not just first
|
||||
# install. Upgrades from older versions carry agent:agent 700 dirs that
|
||||
# block the ${LOCAL_API_GROUP} group from traversing to the socket, which
|
||||
# leaves the tray installed but unable to connect. Idempotent by design.
|
||||
# leaves Desktop installed but unable to connect. Idempotent by design.
|
||||
# 710/750: group gets traverse only on the path, read on the socket dir;
|
||||
# the agent re-asserts socket dir/file ownership itself on startup.
|
||||
sudo mkdir -p "$AGENT_HOME/localapi"
|
||||
|
|
@ -802,24 +802,20 @@ while IFS='|' read -r comp_name comp_kind comp_req comp_vcmd comp_file comp_sha
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=RedFlag
|
||||
Comment=RedFlag system tray (local agent dashboard)
|
||||
Comment=RedFlag local-machine operations console
|
||||
Exec=${INSTALL_DIR}/redflag-desktop
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
EOF
|
||||
sudo chmod 644 "${XDG_AUTOSTART_DIR}/redflag-desktop.desktop"
|
||||
fi
|
||||
# GNOME AppIndicator note.
|
||||
if command -v gnome-shell >/dev/null && ! compgen -G "/usr/share/gnome-shell/extensions/*appindicator*" >/dev/null; then
|
||||
echo "[INFO] [installer] [desktop] GNOME detected without AppIndicator extension — install gnome-shell-extension-appindicator"
|
||||
fi
|
||||
# Add invoking user to redflag-local group (provisioning: desktop_user_membership).
|
||||
if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
|
||||
if id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$LOCAL_API_GROUP"; then
|
||||
echo "✓ $SUDO_USER already in $LOCAL_API_GROUP group"
|
||||
else
|
||||
sudo usermod -aG "$LOCAL_API_GROUP" "$SUDO_USER"
|
||||
echo "✓ Added $SUDO_USER to $LOCAL_API_GROUP (tray socket access — re-login to take effect)"
|
||||
echo "✓ Added $SUDO_USER to $LOCAL_API_GROUP (Desktop socket access — re-login to take effect)"
|
||||
fi
|
||||
fi
|
||||
checkoff "$comp_name" "ok" "installed at ${INSTALL_DIR}/redflag-desktop"
|
||||
|
|
|
|||
|
|
@ -637,7 +637,7 @@ if (-not $SkipServiceInstall) {
|
|||
Start-Service -Name $ServiceName
|
||||
}
|
||||
|
||||
# Step 7: Download and install desktop app (system tray + local dashboard).
|
||||
# Step 7: Download and install native RedFlag Desktop.
|
||||
# The desktop binary is optional — the installer proceeds without it if the
|
||||
# server has no build for this platform.
|
||||
$DesktopBinary = "redflag-desktop.exe"
|
||||
|
|
@ -645,7 +645,7 @@ $DesktopURL = "$ServerUrl/api/v1/desktop/windows/amd64?version=$Version"
|
|||
$DesktopBinaryPath = Join-Path $InstallDir $DesktopBinary
|
||||
|
||||
Write-Host
|
||||
Write-Host "Downloading desktop app (system tray + local dashboard)..." -ForegroundColor Yellow
|
||||
Write-Host "Downloading RedFlag Desktop..." -ForegroundColor Yellow
|
||||
try {
|
||||
$TmpDesktop = Join-Path $env:TEMP "redflag-desktop-download.exe"
|
||||
$DesktopResp = Invoke-WebRequest -Uri $DesktopURL -OutFile $TmpDesktop -UseBasicParsing -PassThru
|
||||
|
|
@ -677,7 +677,7 @@ try {
|
|||
}
|
||||
|
||||
# Step 8: Register desktop autostart for the interactive user.
|
||||
# The Run key launches redflag-desktop.exe on logon so the tray icon appears.
|
||||
# The Run key launches redflag-desktop.exe on interactive logon.
|
||||
# Must target HKCU (per-user); the installer runs elevated so we resolve the
|
||||
# original non-elevated user via the calling process chain.
|
||||
if (Test-Path $DesktopBinaryPath) {
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RedFlag Local Agent</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/desktop/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
229
web/package-lock.json
generated
229
web/package-lock.json
generated
|
|
@ -11,7 +11,6 @@
|
|||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"axios": "^1.19.0",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
|
|
@ -25,7 +24,6 @@
|
|||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
|
|
@ -1298,233 +1296,6 @@
|
|||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
||||
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
|
||||
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.2",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.2",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
|
||||
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
|
||||
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
|
||||
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
|
||||
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
|
||||
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
|
||||
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
|
||||
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
|
||||
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
|
||||
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
|
||||
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
|
||||
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
|
|
|
|||
|
|
@ -6,16 +6,13 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:desktop": "vite --config vite.desktop.config.ts --host 127.0.0.1",
|
||||
"build": "tsc && vite build",
|
||||
"build:desktop": "tsc && vite build --config vite.desktop.config.ts",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"axios": "^1.19.0",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
|
|
@ -29,7 +26,6 @@
|
|||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
|
|
|
|||
|
|
@ -1,664 +0,0 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
|
||||
// ---------- theme palette ----------
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
interface Palette {
|
||||
bg: string
|
||||
bgAlt: string
|
||||
bgHeader: string
|
||||
border: string
|
||||
borderInner: string
|
||||
text: string
|
||||
textMuted: string
|
||||
textDim: string
|
||||
accent: string
|
||||
good: string
|
||||
warn: string
|
||||
bad: string
|
||||
errorBg: string
|
||||
errorBorder: string
|
||||
errorText: string
|
||||
}
|
||||
|
||||
const DARK: Palette = {
|
||||
bg: '#0f1117',
|
||||
bgAlt: '#161b27',
|
||||
bgHeader: '#161b27',
|
||||
border: '#1e2535',
|
||||
borderInner: '#1a1f2e',
|
||||
text: '#d1d5db',
|
||||
textMuted: '#9ca3af',
|
||||
textDim: '#6b7280',
|
||||
accent: '#dc2626',
|
||||
good: '#22c55e',
|
||||
warn: '#f59e0b',
|
||||
bad: '#ef4444',
|
||||
errorBg: '#2d1b1b',
|
||||
errorBorder: '#7f1d1d',
|
||||
errorText: '#fca5a5',
|
||||
}
|
||||
|
||||
const LIGHT: Palette = {
|
||||
bg: '#f9fafb',
|
||||
bgAlt: '#ffffff',
|
||||
bgHeader: '#f3f4f6',
|
||||
border: '#e5e7eb',
|
||||
borderInner: '#f3f4f6',
|
||||
text: '#374151',
|
||||
textMuted: '#6b7280',
|
||||
textDim: '#9ca3af',
|
||||
accent: '#dc2626',
|
||||
good: '#16a34a',
|
||||
warn: '#d97706',
|
||||
bad: '#dc2626',
|
||||
errorBg: '#fef2f2',
|
||||
errorBorder: '#fecaca',
|
||||
errorText: '#991b1b',
|
||||
}
|
||||
|
||||
// ---------- API types ----------
|
||||
|
||||
interface LocalIdentity {
|
||||
agent_id: string
|
||||
server_url: string
|
||||
hostname?: string
|
||||
os_type?: string
|
||||
display_name?: string
|
||||
organization?: string
|
||||
tags?: string[]
|
||||
agent_version: string
|
||||
config_version?: string
|
||||
check_in_interval: number
|
||||
registered: boolean
|
||||
}
|
||||
|
||||
interface UpdateSummary {
|
||||
total: number
|
||||
by_ecosystem?: Record<string, number>
|
||||
by_severity?: Record<string, number>
|
||||
}
|
||||
|
||||
interface ScannerState {
|
||||
name: string
|
||||
status: string
|
||||
last_scan_time?: string
|
||||
last_duration_ms?: number
|
||||
last_error?: string
|
||||
update_count: number
|
||||
}
|
||||
|
||||
interface LocalStatus {
|
||||
agent_status: string
|
||||
last_check_in?: string
|
||||
last_updated?: string
|
||||
last_scan_time?: string
|
||||
update_count: number
|
||||
summary: UpdateSummary
|
||||
scanners?: Record<string, ScannerState>
|
||||
registered: boolean
|
||||
}
|
||||
|
||||
interface LocalSnapshot {
|
||||
identity: LocalIdentity
|
||||
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 SystemInfo {
|
||||
hostname: string
|
||||
os_type: string
|
||||
os_version: string
|
||||
os_architecture: string
|
||||
ip_address: string
|
||||
cpu_info: { model_name: string; cores: number; threads: number }
|
||||
memory_info: { total: number; available: number; used: number; used_percent: number }
|
||||
disk_info: Array<{
|
||||
mountpoint: string
|
||||
total: number
|
||||
available: number
|
||||
used: number
|
||||
used_percent: number
|
||||
filesystem: string
|
||||
is_root: boolean
|
||||
}>
|
||||
running_processes: number
|
||||
uptime: string
|
||||
reboot_required: boolean
|
||||
reboot_reason: string
|
||||
device_type: string
|
||||
device_model: string
|
||||
os_distro: string
|
||||
}
|
||||
|
||||
interface SystemSnapshot {
|
||||
system: SystemInfo
|
||||
top_processes: Array<{ name: string; pid: number; cpu: number; mem: number }>
|
||||
collected_at: string
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
// ---------- main component ----------
|
||||
|
||||
const LocalAgentApp: React.FC = () => {
|
||||
const [theme, setTheme] = useState<Theme>('dark')
|
||||
const [snapshot, setSnapshot] = useState<LocalSnapshot | null>(null)
|
||||
const [system, setSystem] = useState<SystemSnapshot | null>(null)
|
||||
const [updates, setUpdates] = useState<UpdateItem[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
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 load = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const [next, health, scan] = await Promise.all([
|
||||
invoke<LocalSnapshot>('local_status'),
|
||||
invoke<SystemSnapshot>('system_health'),
|
||||
invoke<ScanSnapshot>('list_updates'),
|
||||
])
|
||||
setSnapshot(next)
|
||||
setSystem(health)
|
||||
setLastRefresh(new Date())
|
||||
setUpdates(scan.updates ?? [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const id = window.setInterval(load, 15000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [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>(() => {
|
||||
if (error || !snapshot) return 'error'
|
||||
if (snapshot.identity.registered && !snapshot.status.registered) return 'warning'
|
||||
if (snapshot.status.agent_status && snapshot.status.agent_status !== 'online' && snapshot.identity.registered) return 'warning'
|
||||
if (system?.system.reboot_required) return 'warning'
|
||||
if ((system?.system.memory_info.used_percent ?? 0) >= 90) return 'warning'
|
||||
if (system?.system.disk_info.some(disk => disk.used_percent >= 90)) return 'warning'
|
||||
return (snapshot.status.summary.by_severity?.critical ?? 0) > 0 ? 'warning' : 'healthy'
|
||||
}, [error, snapshot, system])
|
||||
|
||||
const scanners = useMemo(() => {
|
||||
if (!snapshot?.status.scanners) return []
|
||||
return Object.values(snapshot.status.scanners).sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [snapshot])
|
||||
|
||||
const healthDot = health === 'healthy' ? p.good : health === 'warning' ? p.warn : p.bad
|
||||
const healthLabel = health === 'healthy' ? 'Online' : health === 'warning' ? 'Warning' : 'Error'
|
||||
const rootDisk = system?.system.disk_info.find(disk => disk.is_root)
|
||||
?? system?.system.disk_info[0]
|
||||
const memoryColor = (system?.system.memory_info.used_percent ?? 0) >= 90
|
||||
? p.bad
|
||||
: (system?.system.memory_info.used_percent ?? 0) >= 80 ? p.warn : p.good
|
||||
const diskColor = (rootDisk?.used_percent ?? 0) >= 90
|
||||
? p.bad
|
||||
: (rootDisk?.used_percent ?? 0) >= 80 ? p.warn : p.good
|
||||
|
||||
return (
|
||||
<div style={{ background: p.bg, color: p.text, fontFamily: "'Inter', system-ui, sans-serif", minHeight: '100vh', fontSize: '13px' }}>
|
||||
|
||||
{/* RedFlag is the product; this computer is the first surface. */}
|
||||
<div style={{ background: p.bgHeader, borderBottom: `1px solid ${p.border}`, padding: '14px 18px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ width: '4px', height: '28px', background: p.accent, borderRadius: '2px' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 750, fontSize: '19px', letterSpacing: '-0.02em', color: theme === 'dark' ? '#f9fafb' : '#111827' }}>RedFlag</div>
|
||||
<div style={{ color: p.textDim, fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.12em' }}>This Computer</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
|
||||
title={theme === 'dark' ? 'Switch to light' : 'Switch to dark'}
|
||||
style={{ background: 'none', border: 'none', color: p.textDim, cursor: 'pointer', padding: '2px 4px', fontSize: '11px', lineHeight: 1, borderRadius: '2px' }}
|
||||
>
|
||||
{theme === 'dark' ? '☀' : '☾'}
|
||||
</button>
|
||||
<button
|
||||
onClick={load}
|
||||
title="Refresh"
|
||||
style={{ background: 'none', border: 'none', color: p.textDim, cursor: 'pointer', padding: '2px', lineHeight: 1 }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', opacity: loading ? 0.4 : 1 }}>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '18px', borderBottom: `1px solid ${p.border}`, background: p.bgAlt }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '20px', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ width: '9px', height: '9px', borderRadius: '50%', background: healthDot }} />
|
||||
<span style={{ fontSize: '20px', fontWeight: 650, color: p.text }}>
|
||||
{snapshot?.identity.display_name || system?.system.hostname || snapshot?.identity.hostname || 'This computer'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px', color: p.textMuted, fontSize: '12px' }}>
|
||||
{system?.system.device_model || system?.system.device_type || 'Computer'} · {system?.system.os_version || snapshot?.identity.os_type || 'Unknown OS'} · {healthLabel}
|
||||
</div>
|
||||
</div>
|
||||
{system?.system.reboot_required && (
|
||||
<div style={{ border: `1px solid ${p.warn}`, color: p.warn, borderRadius: '4px', padding: '5px 9px', fontSize: '11px' }}>
|
||||
Reboot required{system.system.reboot_reason ? ` · ${system.system.reboot_reason}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ background: p.errorBg, border: `1px solid ${p.errorBorder}`, borderLeft: `3px solid ${p.bad}`, margin: '10px 12px', padding: '7px 10px', borderRadius: '3px', fontSize: '12px', color: p.errorText, fontFamily: 'monospace' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System health is the landing view. */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: '1px', background: p.border, borderBottom: `1px solid ${p.border}` }}>
|
||||
<StatCell p={p} label="Memory" value={system ? `${system.system.memory_info.used_percent.toFixed(0)}%` : '—'} accent={memoryColor} />
|
||||
<StatCell p={p} label="Root disk" value={rootDisk ? `${rootDisk.used_percent.toFixed(0)}%` : '—'} accent={diskColor} />
|
||||
<StatCell p={p} label="Processes" value={system ? String(system.system.running_processes) : '—'} accent={p.text} />
|
||||
<StatCell p={p} label="Uptime" value={system?.system.uptime || '—'} accent={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} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.15fr 0.85fr', borderBottom: `1px solid ${p.border}` }}>
|
||||
<Section p={p} label="Computer health">
|
||||
<Row p={p} label="Processor" value={system?.system.cpu_info.model_name || '—'} />
|
||||
<Row p={p} label="CPU" value={system ? `${system.system.cpu_info.cores} cores · ${system.system.cpu_info.threads} threads` : '—'} />
|
||||
<Row p={p} label="Memory" value={system ? `${formatBytes(system.system.memory_info.used)} of ${formatBytes(system.system.memory_info.total)}` : '—'} />
|
||||
<Row p={p} label="Root storage" value={rootDisk ? `${formatBytes(rootDisk.used)} of ${formatBytes(rootDisk.total)} · ${rootDisk.filesystem}` : '—'} />
|
||||
<Row p={p} label="Network" value={system?.system.ip_address || '—'} mono />
|
||||
</Section>
|
||||
<Section p={p} label="Top processes">
|
||||
{(system?.top_processes.length ?? 0) === 0 ? (
|
||||
<div style={{ padding: '10px 14px', color: p.textDim, fontSize: '12px' }}>Process data unavailable.</div>
|
||||
) : system?.top_processes.slice(0, 6).map(process => (
|
||||
<div key={process.pid} style={{ display: 'grid', gridTemplateColumns: '1fr 54px 54px', gap: '8px', padding: '4px 14px', borderBottom: `1px solid ${p.borderInner}`, fontSize: '11px' }}>
|
||||
<span style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{process.name}</span>
|
||||
<span style={{ color: p.textDim, textAlign: 'right' }}>{process.cpu.toFixed(1)}% CPU</span>
|
||||
<span style={{ color: p.textDim, textAlign: 'right' }}>{process.mem.toFixed(1)}% RAM</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
</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 */}
|
||||
<Section p={p} label="Identity">
|
||||
<Row p={p} label="Host" value={snapshot?.identity.hostname || '—'} mono />
|
||||
<Row p={p} label="Agent" value={snapshot ? snapshot.identity.agent_id.slice(0, 16) + '…' : '—'} mono />
|
||||
<Row p={p} label="Server" value={snapshot?.identity.server_url || '—'} />
|
||||
<Row p={p} label="Version" value={snapshot?.identity.agent_version || '—'} mono />
|
||||
<Row p={p} label="Platform" value={snapshot?.identity.os_type || '—'} />
|
||||
</Section>
|
||||
|
||||
{/* Activity */}
|
||||
<Section p={p} label="Activity">
|
||||
<Row p={p} label="Last check-in" value={maybeRelative(snapshot?.status.last_check_in)} />
|
||||
<Row p={p} label="Last scan" value={maybeRelative(snapshot?.status.last_scan_time)} />
|
||||
<Row p={p} label="Status" value={snapshot?.status.agent_status || 'unknown'} accent={snapshot?.status.agent_status === 'online' ? p.good : p.warn} />
|
||||
</Section>
|
||||
|
||||
{/* Scanners */}
|
||||
{scanners.length > 0 && (
|
||||
<Section p={p} label={`Scanners (${scanners.length})`}>
|
||||
{scanners.map(scanner => (
|
||||
<div key={scanner.name} style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', padding: '5px 14px', borderBottom: `1px solid ${p.borderInner}` }}>
|
||||
<div style={{ width: '6px', height: '6px', borderRadius: '50%', marginTop: '4px', flexShrink: 0, background: scanner.status === 'success' ? p.good : scanner.status === 'failed' ? p.bad : p.textDim }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '12px', color: p.text }}>{scanner.name}</span>
|
||||
<span style={{ fontSize: '11px', color: p.textDim }}>{scanner.update_count} pkgs</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: scanner.last_error ? p.bad : p.textDim, marginTop: '1px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{scanner.last_error || maybeRelative(scanner.last_scan_time)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ padding: '8px 14px', borderTop: `1px solid ${p.border}`, display: 'flex', justifyContent: 'space-between', fontSize: '11px', color: p.textDim }}>
|
||||
<span>{lastRefresh ? `Updated ${formatRelativeTime(lastRefresh.toISOString())}` : 'Awaiting data'}</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>v{snapshot?.identity.agent_version ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- sub-components ----------
|
||||
|
||||
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 }) => (
|
||||
<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 }}>{label}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const StatCell: React.FC<WithPalette & { label: string; value: string; accent: string }> = ({ p, label, value, accent }) => (
|
||||
<div style={{ background: p.bg, padding: '8px 14px' }}>
|
||||
<div style={{ fontSize: '10px', color: p.textDim, textTransform: 'uppercase' as const, letterSpacing: '0.06em', marginBottom: '2px' }}>{label}</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 600, color: accent }}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Row: React.FC<WithPalette & { label: string; value: string; mono?: boolean; accent?: string }> = ({ p, label, value, mono, accent }) => (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '100px 1fr', gap: '8px', padding: '4px 14px', borderBottom: `1px solid ${p.borderInner}`, alignItems: 'baseline' }}>
|
||||
<span style={{ fontSize: '11px', color: p.textDim, flexShrink: 0 }}>{label}</span>
|
||||
<span style={{ fontSize: '12px', color: accent || p.text, fontFamily: mono ? 'monospace' : 'inherit', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
function maybeRelative(value?: string): string {
|
||||
if (!value || value.startsWith('0001-')) return '—'
|
||||
return formatRelativeTime(value)
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 B'
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
const unit = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1)
|
||||
const amount = value / (1024 ** unit)
|
||||
return `${amount >= 10 || unit === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
export default LocalAgentApp
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import LocalAgentApp from './LocalAgentApp'
|
||||
import '../index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<LocalAgentApp />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// Tauri's embedded asset server resolves index.html from frontendDist; the
|
||||
// desktop entry is index.desktop.html, so rename it in the bundle output.
|
||||
const desktopEntryAsIndex = {
|
||||
name: 'desktop-entry-as-index',
|
||||
generateBundle(_options: unknown, bundle: Record<string, { fileName: string }>) {
|
||||
const entry = bundle['index.desktop.html']
|
||||
if (entry) {
|
||||
entry.fileName = 'index.html'
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), desktopEntryAsIndex],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist-desktop",
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: path.resolve(__dirname, "index.desktop.html"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue