Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-09-01 08:29:58 -04:00
commit f487de554a
61 changed files with 4475 additions and 10471 deletions

View file

@ -133,23 +133,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - 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 - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
- name: Install Tauri system dependencies - name: Install Qt 6 build dependencies
run: | run: |
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y -qq libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev sudo apt-get install -y -qq qt6-base-dev qt6-declarative-dev qt6-declarative-dev-tools libgl1-mesa-dev
- name: Check RedFlag desktop - name: Check native RedFlag Desktop
run: | run: cd desktop && cargo check --locked
cd web
npm ci
npm run build:desktop
cd ../desktop
cargo check
installer-integrity: installer-integrity:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -53,11 +53,8 @@ jobs:
FAIL=1 FAIL=1
fi fi
# Desktop: optional component, but if present, its version must match. # Desktop: optional component, but if present, its Rust/Qt crate version
# desktop/Cargo.toml is the single source of the desktop version (3-part # must match the first three tag octets.
# 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.
if [ -f desktop/Cargo.toml ]; then if [ -f desktop/Cargo.toml ]; then
DESKTOP_CARGO_VER=$(grep -m1 '^version' desktop/Cargo.toml | cut -d'"' -f2) DESKTOP_CARGO_VER=$(grep -m1 '^version' desktop/Cargo.toml | cut -d'"' -f2)
echo "desktop/Cargo.toml: version=$DESKTOP_CARGO_VER" echo "desktop/Cargo.toml: version=$DESKTOP_CARGO_VER"
@ -345,23 +342,19 @@ jobs:
if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi
cp target/${{ matrix.rust_target }}/release/redflag-helper${EXT} ../dist/redflag-helper-${{ matrix.suffix }}${EXT} 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' if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: | run: |
set -euo pipefail set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v} 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 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 cd desktop
cargo build --release cargo build --release --locked
cp target/release/redflag-desktop ../dist/redflag-desktop-${{ matrix.suffix }} cp target/release/redflag-desktop ../dist/redflag-desktop-${{ matrix.suffix }}
echo "Desktop binary built: $(ls -lh ../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 version: $DESKTOP_VER"
echo "$DESKTOP_VER" | grep -q "v$VERSION" || { echo "::error::desktop binary reports $DESKTOP_VER, expected v$VERSION"; exit 1; } 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) - name: Build Windows installer (RedFlagSetup.msi)
if: matrix.goos == 'windows' && matrix.goarch == 'amd64' if: matrix.goos == 'windows' && matrix.goarch == 'amd64'
run: | run: |
set -euo pipefail set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v} VERSION=${GITHUB_REF#refs/tags/v}
# MSI's ProductVersion only carries 3 significant fields for # MSI's ProductVersion only carries 3 significant fields for upgrade
# upgrade detection — same 3-vs-4-part reconciliation bump-version.sh # detection; bump-version.sh applies the same 3-vs-4-part mapping.
# already does for desktop/Cargo.toml vs tauri.conf.json.
WIX_VERSION=$(echo "$VERSION" | cut -d. -f1-3) WIX_VERSION=$(echo "$VERSION" | cut -d. -f1-3)
# NOT the official WiX Toolset .NET CLI — `wix build` genuinely # NOT the official WiX Toolset .NET CLI — `wix build` genuinely

View file

@ -38,10 +38,10 @@ logs: ## Tail the server logs
prune-cruft: ## Remove retired RedFlag images (redflag-web, desktop-stage-test) 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 -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 @mkdir -p dist
sh scripts/fetch-desktop-windows.sh amd64 ./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 db-up: ## Start PostgreSQL database
docker-compose up -d postgres docker-compose up -d postgres

View file

@ -1,6 +1,6 @@
# RedFlag # 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 `v0.2.9.3` — July 2026 · AGPL-3.0
@ -14,7 +14,9 @@
<!-- Showcase video goes here: terminal → install → multiple agents → updates. --> <!-- 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). 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 ## Quick Start
### Server ### 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 - **Proxy support** — HTTP/HTTPS/SOCKS5 for restricted networks
- **Native services** — systemd on Linux, Windows Services on Windows - **Native services** — systemd on Linux, Windows Services on Windows
- **Full audit trail** — all operations logged with context, sanitized against log injection - **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 - Setup accepts an operator-supplied signing keypair — bring-your-own-key deployments
- Reversible token encryption with one-liner restore - Reversible token encryption with one-liner restore
- Real-time heartbeat and rapid polling - 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:** **Not yet done:**
- No AUR, Snap, Flatpak, or Homebrew support - 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 - Helper-side rehashing of normal registry artifacts before mutation
- Network isolation for the privileged helper invocation - Network isolation for the privileged helper invocation
- Capability-helper execution for Docker, Winget, and Windows Update - 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
--- ---

View file

@ -251,7 +251,7 @@ func NewLoopContext(cfg *config.Config, opts LoopContextOptions) (*LoopContext,
return nil, fmt.Errorf("failed to initialize command handler: %w", err) 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 var desktopMgr *desktop.Manager
if opts.EnableDesktop { if opts.EnableDesktop {
desktopMgr = desktop.NewManager( desktopMgr = desktop.NewManager(
@ -371,12 +371,41 @@ func RunPollingLoop(loopCtx *LoopContext) error {
if ctx.DesktopManager != nil { if ctx.DesktopManager != nil {
onDesktopHealth = ctx.DesktopManager.RecordHealth 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{ localAPIServer, err := localapi.Start(localapi.Options{
Config: ctx.Cfg, Config: ctx.Cfg,
DesktopProvider: ctx.DesktopManager, DesktopProvider: ctx.DesktopManager,
TriggerScan: triggerScan, TriggerScan: triggerScan,
ApproveUpdate: approveUpdate, ApproveUpdate: approveUpdate,
OnDesktopHealth: onDesktopHealth, OnDesktopHealth: onDesktopHealth,
DockerProvider: dockerProvider,
EventsProvider: ctx.EventBuffer.ReadBufferedEvents,
}) })
if err != nil { if err != nil {
ctx.TeeLogger.Error("agent", "localapi", "localapi", fmt.Sprintf("start_failed error=%v", err), map[string]interface{}{"error": err.Error()}) ctx.TeeLogger.Error("agent", "localapi", "localapi", fmt.Sprintf("start_failed error=%v", err), map[string]interface{}{"error": err.Error()})
@ -385,7 +414,7 @@ func RunPollingLoop(loopCtx *LoopContext) error {
} }
// Start desktop app (connects to the local API socket above). // 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. // environment — the agent service must not spawn a second copy.
if ctx.DesktopManager != nil && runtime.GOOS != "linux" { if ctx.DesktopManager != nil && runtime.GOOS != "linux" {
go ctx.DesktopManager.Start(ctx.Ctx) 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/ // 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 // is autostart-launched, not agent-spawned. Merges under
// agent.metadata["desktop"] — the backend for a fleet "components // agent.metadata["desktop"] — the backend for a fleet "components
// installed" indicator. // installed" indicator.

View file

@ -23,21 +23,21 @@ func InitLogger(l *event.TeeLogger) {
// MigrationState tracks migration completion status (used by migration package) // MigrationState tracks migration completion status (used by migration package)
type MigrationState struct { type MigrationState struct {
LastCompleted map[string]time.Time `json:"last_completed"` LastCompleted map[string]time.Time `json:"last_completed"`
AgentVersion string `json:"agent_version"` AgentVersion string `json:"agent_version"`
ConfigVersion string `json:"config_version"` ConfigVersion string `json:"config_version"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
Success bool `json:"success"` Success bool `json:"success"`
RollbackPath string `json:"rollback_path,omitempty"` RollbackPath string `json:"rollback_path,omitempty"`
CompletedMigrations []string `json:"completed_migrations"` CompletedMigrations []string `json:"completed_migrations"`
} }
// ProxyConfig holds proxy configuration // ProxyConfig holds proxy configuration
type ProxyConfig struct { type ProxyConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
HTTP string `json:"http,omitempty"` // HTTP proxy URL HTTP string `json:"http,omitempty"` // HTTP proxy URL
HTTPS string `json:"https,omitempty"` // HTTPS proxy URL HTTPS string `json:"https,omitempty"` // HTTPS proxy URL
NoProxy string `json:"no_proxy,omitempty"` // Comma-separated hosts to bypass proxy NoProxy string `json:"no_proxy,omitempty"` // Comma-separated hosts to bypass proxy
Username string `json:"username,omitempty"` // Proxy username (optional) Username string `json:"username,omitempty"` // Proxy username (optional)
Password string `json:"password,omitempty"` // Proxy password (optional) Password string `json:"password,omitempty"` // Proxy password (optional)
} }
@ -45,14 +45,14 @@ type ProxyConfig struct {
// TLSConfig holds TLS/security configuration // TLSConfig holds TLS/security configuration
type TLSConfig struct { type TLSConfig struct {
InsecureSkipVerify bool `json:"insecure_skip_verify"` // Skip TLS certificate verification InsecureSkipVerify bool `json:"insecure_skip_verify"` // Skip TLS certificate verification
CertFile string `json:"cert_file,omitempty"` // Client certificate file CertFile string `json:"cert_file,omitempty"` // Client certificate file
KeyFile string `json:"key_file,omitempty"` // Client key file KeyFile string `json:"key_file,omitempty"` // Client key file
CAFile string `json:"ca_file,omitempty"` // CA certificate file CAFile string `json:"ca_file,omitempty"` // CA certificate file
} }
// NetworkConfig holds network-related configuration // NetworkConfig holds network-related configuration
type NetworkConfig struct { type NetworkConfig struct {
Timeout time.Duration `json:"timeout"` // Request timeout Timeout time.Duration `json:"timeout"` // Request timeout
RetryCount int `json:"retry_count"` // Number of retries RetryCount int `json:"retry_count"` // Number of retries
RetryDelay time.Duration `json:"retry_delay"` // Delay between retries RetryDelay time.Duration `json:"retry_delay"` // Delay between retries
MaxIdleConn int `json:"max_idle_conn"` // Maximum idle connections MaxIdleConn int `json:"max_idle_conn"` // Maximum idle connections
@ -60,11 +60,11 @@ type NetworkConfig struct {
// LoggingConfig holds logging configuration // LoggingConfig holds logging configuration
type LoggingConfig struct { type LoggingConfig struct {
Level string `json:"level"` // Log level (debug, info, warn, error) Level string `json:"level"` // Log level (debug, info, warn, error)
File string `json:"file,omitempty"` // Log file path (optional) File string `json:"file,omitempty"` // Log file path (optional)
MaxSize int `json:"max_size"` // Max log file size in MB MaxSize int `json:"max_size"` // Max log file size in MB
MaxBackups int `json:"max_backups"` // Max number of log file backups MaxBackups int `json:"max_backups"` // Max number of log file backups
MaxAge int `json:"max_age"` // Max age of log files in days MaxAge int `json:"max_age"` // Max age of log files in days
} }
// SecurityLogConfig holds configuration for security logging // SecurityLogConfig holds configuration for security logging
@ -103,19 +103,19 @@ type PollingConfig struct {
// ProcessExplorerConfig holds limits for process detail data collection. // ProcessExplorerConfig holds limits for process detail data collection.
// Zero values use the built-in defaults. // Zero values use the built-in defaults.
type ProcessExplorerConfig struct { type ProcessExplorerConfig struct {
MaxOpenFiles int `json:"max_open_files,omitempty"` // Cap on open files per process (default 2000) MaxOpenFiles int `json:"max_open_files,omitempty"` // Cap on open files per process (default 2000)
MaxSockets int `json:"max_sockets,omitempty"` // Cap on open sockets per process (default 500) MaxSockets int `json:"max_sockets,omitempty"` // Cap on open sockets per process (default 500)
MaxPipes int `json:"max_pipes,omitempty"` // Cap on open pipes per process (default 500) MaxPipes int `json:"max_pipes,omitempty"` // Cap on open pipes per process (default 500)
MaxMemoryMap int `json:"max_memory_map,omitempty"` // Cap on memory map entries per process (default 2000) MaxMemoryMap int `json:"max_memory_map,omitempty"` // Cap on memory map entries per process (default 2000)
MaxNamespaces int `json:"max_namespaces,omitempty"` // Cap on namespace entries per process (default 50) MaxNamespaces int `json:"max_namespaces,omitempty"` // Cap on namespace entries per process (default 50)
MaxEnvKeys int `json:"max_env_keys,omitempty"` // Cap on environment variable keys per process (default 200) MaxEnvKeys int `json:"max_env_keys,omitempty"` // Cap on environment variable keys per process (default 200)
MaxListeningPorts int `json:"max_listening_ports,omitempty"` // Cap on listening ports per process (default 100) MaxListeningPorts int `json:"max_listening_ports,omitempty"` // Cap on listening ports per process (default 100)
} }
// Config holds agent configuration // Config holds agent configuration
type Config struct { type Config struct {
// Version Information // Version Information
Version string `json:"version,omitempty"` // Config schema version Version string `json:"version,omitempty"` // Config schema version
AgentVersion string `json:"agent_version,omitempty"` // Agent binary version AgentVersion string `json:"agent_version,omitempty"` // Agent binary version
// Server Configuration // Server Configuration
@ -189,14 +189,14 @@ type Config struct {
MigrationState *MigrationState `json:"migration_state,omitempty"` // Migration completion tracking 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 // 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 // desktop session is detected. The binary connects back to the agent's
// local API socket. // local API socket.
type DesktopConfig struct { type DesktopConfig struct {
Enabled bool `json:"enabled"` // Whether to auto-launch the desktop app Enabled bool `json:"enabled"` // Whether to auto-launch the desktop app
MaxRestarts int `json:"max_restarts"` // Max restarts before giving up (0 = unlimited) MaxRestarts int `json:"max_restarts"` // Max restarts before giving up (0 = unlimited)
RestartDelaySec int `json:"restart_delay_sec"` // Seconds between restart attempts RestartDelaySec int `json:"restart_delay_sec"` // Seconds between restart attempts
} }
// Load reads configuration from multiple sources with priority order: // Load reads configuration from multiple sources with priority order:
@ -242,7 +242,7 @@ type CLIFlags struct {
ProxyHTTP string ProxyHTTP string
ProxyHTTPS string ProxyHTTPS string
ProxyNoProxy string ProxyNoProxy string
LogLevel string LogLevel string
ConfigFile string ConfigFile string
Tags []string Tags []string
Organization string Organization string
@ -279,16 +279,16 @@ func getDefaultConfig() *Config {
} }
return &Config{ return &Config{
Version: configVersion, // Config schema version from version package Version: configVersion, // Config schema version from version package
AgentVersion: version.Version, // Agent version from version package AgentVersion: version.Version, // Agent version from version package
ServerURL: "http://localhost:8080", ServerURL: "http://localhost:8080",
CheckInInterval: 300, // 5 minutes CheckInInterval: 300, // 5 minutes
// Server Authentication // Server Authentication
RegistrationToken: "", // One-time registration token (embedded by install script) RegistrationToken: "", // One-time registration token (embedded by install script)
AgentID: uuid.Nil, // Will be set during registration AgentID: uuid.Nil, // Will be set during registration
Token: "", // Will be set during registration Token: "", // Will be set during registration
RefreshToken: "", // Will be set during registration RefreshToken: "", // Will be set during registration
// Agent Behavior // Agent Behavior
RapidPollingEnabled: false, RapidPollingEnabled: false,
@ -783,15 +783,15 @@ func (c *Config) IsRegistered() bool {
type OSType string type OSType string
const ( const (
OSTypeLinux OSType = "linux" OSTypeLinux OSType = "linux"
OSTypeWindows OSType = "windows" OSTypeWindows OSType = "windows"
OSTypeDarwin OSType = "darwin" OSTypeDarwin OSType = "darwin"
) )
// OS holds OS-specific information // OS holds OS-specific information
type OS struct { type OS struct {
Type OSType `json:"type"` Type OSType `json:"type"`
Arch string `json:"arch,omitempty"` Arch string `json:"arch,omitempty"`
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
} }

View file

@ -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 // 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 // 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 package desktop
import ( import (
@ -30,8 +30,8 @@ type Manager struct {
lastHealth healthReport lastHealth healthReport
} }
// healthReport is the tray's most recent POST /v1/desktop self-report. On // healthReport is Desktop's most recent POST /v1/desktop self-report. On
// Linux the tray is launched by XDG autostart, not by this manager, so the // 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 // spawned-process state is always empty there — the health report is the only
// liveness and version signal the agent has. // liveness and version signal the agent has.
type healthReport struct { type healthReport struct {
@ -40,8 +40,8 @@ type healthReport struct {
reportedAt time.Time reportedAt time.Time
} }
// healthFreshness is how long a health report counts as proof of a live tray. // healthFreshness is how long a health report counts as proof of a live Desktop.
// The tray reports every 30s; three missed beats means it is gone. // Desktop reports every 30s; three missed beats means it is gone.
const healthFreshness = 90 * time.Second const healthFreshness = 90 * time.Second
// HealthSnapshot is the fleet-reportable desktop component state. // 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 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) { func (m *Manager) RecordHealth(version string, windowOpen bool) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()

View file

@ -27,7 +27,7 @@ var (
ErrApprovalBlocked = errors.New("approval blocked by supply-chain gate") 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 { type LocalApproveRequest struct {
PackageType string `json:"package_type"` PackageType string `json:"package_type"`
PackageName string `json:"package_name"` PackageName string `json:"package_name"`
@ -37,7 +37,7 @@ type LocalApproveRequest struct {
} }
// LocalApproveResult carries the gate verdicts plus the execution outcome so // 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 { type LocalApproveResult struct {
RequestID string `json:"request_id"` RequestID string `json:"request_id"`
OSVStatus string `json:"osv_status"` OSVStatus string `json:"osv_status"`

View file

@ -68,14 +68,14 @@ func RunPostUpgradeHealthcheck(cfg *config.Config) int {
desktopPath += ".exe" desktopPath += ".exe"
} }
check(fileExists(desktopPath), 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) --- // --- Autostart entry (Linux only) ---
if cfg.Desktop.Enabled && runtime.GOOS == "linux" { if cfg.Desktop.Enabled && runtime.GOOS == "linux" {
autostartPath := "/etc/xdg/autostart/redflag-desktop.desktop" autostartPath := "/etc/xdg/autostart/redflag-desktop.desktop"
check(fileExists(autostartPath), 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 --- // --- Socket directory permissions ---
@ -88,9 +88,9 @@ func RunPostUpgradeHealthcheck(cfg *config.Config) int {
if info, err := os.Stat(resolved); err == nil { if info, err := os.Stat(resolved); err == nil {
perm := info.Mode().Perm() perm := info.Mode().Perm()
check(perm&0o010 != 0, 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 { } 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++ gaps++
} }

View file

@ -19,7 +19,7 @@ type ClientOptions struct {
Timeout time.Duration 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 { type Snapshot struct {
Identity IdentityResponse `json:"identity"` Identity IdentityResponse `json:"identity"`
Status StatusResponse `json:"status"` Status StatusResponse `json:"status"`

View file

@ -10,11 +10,14 @@ import (
"net/http" "net/http"
"os" "os"
"sort" "sort"
"strconv"
"strings"
"time" "time"
"github.com/Fimeg/RedFlag/agent/internal/cache" "github.com/Fimeg/RedFlag/agent/internal/cache"
"github.com/Fimeg/RedFlag/agent/internal/client" "github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config" "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/system"
"github.com/Fimeg/RedFlag/agent/internal/version" "github.com/Fimeg/RedFlag/agent/internal/version"
) )
@ -42,16 +45,25 @@ var (
// Options configures the local read-only API. Group, socket, and pipe defaults // Options configures the local read-only API. Group, socket, and pipe defaults
// are platform-specific and enforced by the listener implementation. // are platform-specific and enforced by the listener implementation.
type Options struct { type Options struct {
Config *config.Config Config *config.Config
GroupName string GroupName string
UnixSocketPath string UnixSocketPath string
WindowsPipeName string WindowsPipeName string
LoadCache func() (*cache.LocalCache, error) LoadCache func() (*cache.LocalCache, error)
RequestLog func(format string, args ...interface{}) RequestLog func(format string, args ...interface{})
ListenerOverride net.Listener ListenerOverride net.Listener
DesktopProvider DesktopStatusProvider DesktopProvider DesktopStatusProvider
SystemProvider func() (*system.SystemInfo, error) SystemProvider func() (*system.SystemInfo, error)
ProcessProvider func(limit int) ([]system.TopProcess, 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 // TriggerScan enqueues a package-update scan through the agent's existing
// scanner primitives (FEAT-002 write path). Authorization is the OS-local // scanner primitives (FEAT-002 write path). Authorization is the OS-local
// group boundary on the socket/pipe — anyone who can connect may trigger. // 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 / // Nil disables the endpoint (503). Wrap errors in ErrApprovalConflict /
// ErrApprovalUnavailable to control the HTTP status. // ErrApprovalUnavailable to control the HTTP status.
ApproveUpdate func(body []byte) (interface{}, error) ApproveUpdate func(body []byte) (interface{}, error)
// OnDesktopHealth receives each tray self-report (POST /v1/desktop) so the // OnDesktopHealth receives each Desktop self-report (POST /v1/desktop) so the
// agent can track tray liveness/version — on Linux the tray is autostart- // agent can track app liveness/version — on Linux Desktop is autostart-
// launched and this is the only signal. Nil means reports are logged only. // launched and this is the only signal. Nil means reports are logged only.
OnDesktopHealth func(version string, windowOpen bool) OnDesktopHealth func(version string, windowOpen bool)
} }
@ -74,6 +86,7 @@ type Server struct {
listener net.Listener listener net.Listener
address string address string
logf func(format string, args ...interface{}) logf func(format string, args ...interface{})
monitor *system.ResourceMonitor
} }
// Start creates a platform-native local listener and serves the read-only local // 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 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) handler := newHandler(opts)
httpServer := &http.Server{Handler: handler} httpServer := &http.Server{Handler: handler}
@ -98,6 +117,9 @@ func Start(opts Options) (*Server, error) {
if listener == nil { if listener == nil {
listener, address, err = listen(opts) listener, address, err = listen(opts)
if err != nil { if err != nil {
if monitor != nil {
monitor.Stop()
}
return nil, err return nil, err
} }
} else { } else {
@ -109,6 +131,7 @@ func Start(opts Options) (*Server, error) {
listener: listener, listener: listener,
address: address, address: address,
logf: opts.RequestLog, logf: opts.RequestLog,
monitor: monitor,
} }
go func() { go func() {
@ -129,6 +152,9 @@ func (s *Server) Stop() {
if err := s.httpServer.Close(); err != nil && s.logf != nil { if err := s.httpServer.Close(); err != nil && s.logf != nil {
s.logf("[WARNING] [agent] [localapi] stop_failed address=%s error=%v", s.address, err) s.logf("[WARNING] [agent] [localapi] stop_failed address=%s error=%v", s.address, err)
} }
if s.monitor != nil {
s.monitor.Stop()
}
} }
type handler struct { type handler struct {
@ -140,6 +166,15 @@ type handler struct {
onDesktopHealth func(version string, windowOpen bool) onDesktopHealth func(version string, windowOpen bool)
systemInfo func() (*system.SystemInfo, error) systemInfo func() (*system.SystemInfo, error)
topProcesses func(limit int) ([]system.TopProcess, 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. // DesktopStatusProvider allows the desktop manager to report its status.
@ -205,6 +240,38 @@ type SystemResponse struct {
CollectedAt time.Time `json:"collected_at"` 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 { func newHandler(opts Options) http.Handler {
h := &handler{ h := &handler{
cfg: opts.Config, cfg: opts.Config,
@ -215,6 +282,15 @@ func newHandler(opts Options) http.Handler {
onDesktopHealth: opts.OnDesktopHealth, onDesktopHealth: opts.OnDesktopHealth,
systemInfo: opts.SystemProvider, systemInfo: opts.SystemProvider,
topProcesses: opts.ProcessProvider, 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 { if h.systemInfo == nil {
h.systemInfo = func() (*system.SystemInfo, error) { h.systemInfo = func() (*system.SystemInfo, error) {
@ -224,6 +300,24 @@ func newHandler(opts Options) http.Handler {
if h.topProcesses == nil { if h.topProcesses == nil {
h.topProcesses = system.GetTopProcesses 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 { if h.loadCache == nil {
h.loadCache = cache.Load h.loadCache = cache.Load
} }
@ -233,6 +327,16 @@ func newHandler(opts Options) http.Handler {
mux.HandleFunc("/v1/scans/latest", h.scansLatest) mux.HandleFunc("/v1/scans/latest", h.scansLatest)
mux.HandleFunc("/v1/packages", h.scansLatest) mux.HandleFunc("/v1/packages", h.scansLatest)
mux.HandleFunc("/v1/system", h.system) 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/tokens/active", h.tokensActive)
mux.HandleFunc("/v1/desktop", h.desktopHealth) mux.HandleFunc("/v1/desktop", h.desktopHealth)
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction) mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
@ -240,6 +344,192 @@ func newHandler(opts Options) http.Handler {
return mux 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) { func (h *handler) system(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) { if !requireGet(w, r) {
return return

View file

@ -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) { func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) { handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
return nil, errors.New("cannot read cache") return nil, errors.New("cannot read cache")

View file

@ -252,7 +252,7 @@ func signalDesktopRestart(target string) error {
return nil return nil
} }
// pkill exit 1 = no process matched; taskkill exit 128 = process not found. // 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 exitErr, ok := err.(*exec.ExitError); ok {
if (runtime.GOOS == "linux" && exitErr.ExitCode() == 1) || if (runtime.GOOS == "linux" && exitErr.ExitCode() == 1) ||
(runtime.GOOS == "windows" && exitErr.ExitCode() == 128) { (runtime.GOOS == "windows" && exitErr.ExitCode() == 128) {
@ -301,4 +301,3 @@ func computeFileSHA256Hex(path string) (string, error) {
} }
return hex.EncodeToString(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }

View 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()
}

View 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
}

View 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")
}

View 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 &copy, 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
}

View 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
}

View 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)
}

View 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)
}
}

View file

@ -3,7 +3,6 @@
package system package system
import ( import (
"bytes" "bytes"
"fmt" "fmt"
@ -299,6 +298,10 @@ func getProcessDetail(pid int, caps ProcessCaps) (*FullProcess, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if owner, err := FindSoftwareOwner(proc.Path); err == nil {
proc.PackageManager = owner.PackageType
proc.PackageName = owner.PackageName
}
pidStr := strconv.Itoa(pid) pidStr := strconv.Itoa(pid)
@ -395,9 +398,9 @@ func readProcSockets(pidStr string, maxSockets int) []ProcessOpenSocket {
// TCP sockets // TCP sockets
for _, proto := range []struct { for _, proto := range []struct {
file string file string
family string family string
prot string prot string
}{ }{
{fmt.Sprintf("/proc/%s/net/tcp", pidStr), "IPv4", "TCP"}, {fmt.Sprintf("/proc/%s/net/tcp", pidStr), "IPv4", "TCP"},
{fmt.Sprintf("/proc/%s/net/tcp6", pidStr), "IPv6", "TCP"}, {fmt.Sprintf("/proc/%s/net/tcp6", pidStr), "IPv6", "TCP"},
@ -474,7 +477,6 @@ func readProcSockets(pidStr string, maxSockets int) []ProcessOpenSocket {
return sockets return sockets
} }
// readEnvKeys reads /proc/[pid]/environ and returns only the key names. // readEnvKeys reads /proc/[pid]/environ and returns only the key names.
// Values are never transmitted (security: env vars may contain secrets). // Values are never transmitted (security: env vars may contain secrets).
func readEnvKeys(pidStr string, maxKeys int) []string { func readEnvKeys(pidStr string, maxKeys int) []string {

View file

@ -11,6 +11,8 @@ type ProcessOwner struct {
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
ContainerID string `json:"container_id,omitempty"` ContainerID string `json:"container_id,omitempty"`
ContainerRuntime string `json:"container_runtime,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. // parseCgroupOwner reads the contents of /proc/[pid]/cgroup.

View 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() }

View 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
}

View 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")
}

View 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) }

View 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
}

View 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
View 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

File diff suppressed because it is too large Load diff

View file

@ -4,14 +4,19 @@ version = "0.2.9"
edition = "2021" edition = "2021"
license = "AGPL-3.0" license = "AGPL-3.0"
publish = false publish = false
description = "RedFlag native local-machine operations console"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
anyhow = "1"
cxx = "1"
cxx-qt = "0.9"
cxx-qt-lib = { version = "0.9", features = ["qt_full"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri = { version = "2", features = ["tray-icon"] }
[build-dependencies]
cxx-qt-build = "0.9"
qt-build-utils = "0.9"
[[bin]] [[bin]]
name = "redflag-desktop" name = "redflag-desktop"

View file

@ -1,13 +1,26 @@
# RedFlag Desktop # 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 Desktop is the local scope of the RedFlag domain: machine health, live resources,
the existing agent local IPC surface: 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` - Linux: `/var/lib/redflag/agent/localapi/redflag-agent.sock`
- Windows: `\\.\pipe\RedFlagAgentLocal` - Windows: `\\.\pipe\RedFlagAgentLocal`
The frontend entry is `web/index.desktop.html` and `web/src/desktop/main.tsx`. Observation belongs to the Agent. Privileged mutations travel through Agent policy,
Fleet-server context should be added to this same app later rather than creating a second a signed mutation envelope, and the RedFlag helper. QML must never acquire a package,
local-only application. 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.

View file

@ -1,3 +1,31 @@
use cxx_qt_build::{CxxQtBuilder, QmlModule};
use qt_build_utils::QmlFile;
fn main() { 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

View file

@ -1 +0,0 @@
{}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
{
"name": "desktop",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

45
desktop/qml/LineGraph.qml Normal file
View 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

File diff suppressed because it is too large Load diff

View 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
View 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
View 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 }
}

View 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()))
}

View 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
}

View file

@ -0,0 +1,2 @@
pub mod local_api;
pub mod machine;

View file

@ -1,519 +1,38 @@
use serde::{Deserialize, Serialize}; use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QString, QUrl};
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,
};
#[cfg(unix)] mod bridge;
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();
}
}
fn main() { fn main() {
let start_time = Instant::now(); if std::env::args().any(|arg| arg == "--version" || arg == "-V") {
let window_open = Arc::new(AtomicBool::new(true)); println!("RedFlag v{}", env!("CARGO_PKG_VERSION"));
return;
}
tauri::Builder::default() cxx_qt::init_crate!(redflag_desktop);
.invoke_handler(tauri::generate_handler![ cxx_qt::init_qml_module!("com.redflag.desktop");
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 mut app = QGuiApplication::new();
let window_open_tray = window_open.clone(); if let Some(mut app) = app.as_mut() {
let _tray = TrayIconBuilder::new() app.as_mut()
.icon(app.default_window_icon().unwrap().clone()) .set_application_name(&QString::from("com.redflag.Desktop"));
.menu(&menu) app.as_mut()
.show_menu_on_left_click(false) .set_application_display_name(&QString::from("RedFlag"));
.on_menu_event(move |app, event| match event.id.as_ref() { app.as_mut()
"show" => { .set_organization_name(&QString::from("RedFlag"));
window_open_clone.store(true, Ordering::Relaxed); }
show_main_window(app);
}
"quit" => app.exit(0),
_ => {}
})
.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 mut engine = QQmlApplicationEngine::new();
let window_open_close = window_open.clone(); if let Some(mut engine) = engine.as_mut() {
if let Some(window) = app.get_webview_window("main") { engine
let value = window.clone(); .as_mut()
window.on_window_event(move |event| { .on_object_creation_failed(|_, url| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { eprintln!("redflag-desktop: QML root failed to construct: {url}");
api.prevent_close(); })
window_open_close.store(false, Ordering::Relaxed); .release();
let _ = value.hide(); engine.load(&QUrl::from("qrc:/qt/qml/com/redflag/desktop/qml/Main.qml"));
} }
});
}
// Spawn health reporting thread — POSTs to /v1/desktop every 30s. if let Some(app) = app.as_mut() {
let window_open_health = window_open.clone(); app.exec();
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.
}
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");
} }

View file

@ -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"
]
}
}

View file

@ -110,9 +110,8 @@ if [ -f "$FILE" ]; then
echo " [5] desktop/Cargo.toml version -> $CARGO_VERSION (from $NEW_VERSION)" echo " [5] desktop/Cargo.toml version -> $CARGO_VERSION (from $NEW_VERSION)"
fi fi
# desktop/tauri.conf.json carries no version field — Tauri inherits it from # Desktop is a Rust/Qt crate. Its Cargo version carries three semver fields;
# desktop/Cargo.toml (set above). A 4-octet there is not valid semver and Tauri's # release tags and the Go components retain RedFlag's fourth build field.
# build refuses it, so the desktop version has a single source: the crate.
echo "" echo ""

View file

@ -1,15 +1,13 @@
#!/bin/sh #!/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 # binary from the newest published release and verify it against that release's
# manifest hash. # manifest hash.
# #
# Why this exists: the agent is Go and cross-compiles to Windows for free # Why this exists: the Go agent cross-compiles to Windows without a Windows
# (Dockerfile bakes redflag-agent.exe directly). The desktop tray is Tauri/Rust # SDK. Native Qt/QML Desktop does not: it needs a matching Qt and MSVC toolchain.
# and its Windows cross-compile drags in the whole MSVC sysroot (~9GB of build # That build belongs on a native release runner, never inside every source-built
# toolchain). Forcing that into every from-source `docker-compose build` is # server image. Until that runner publishes an artifact, this script returns an
# unacceptable — build-from-source is the primary distribution path. So the # honest optional absence.
# expensive compile lives in CI (release.yml, cargo-xwin) exactly once, and
# from-source servers fetch the resulting signed artifact here.
# #
# Trust: TLS to the forge + the manifest sha256 gate this download, the same # 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 # 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. # serving path; it is not the fleet trust root.
# #
# The desktop component is OPTIONAL (manifest required:false). Every failure that # 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 # 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. # entry. A hash MISMATCH is the one hard stop — that is tampering, not absence.
set -eu set -eu
ARCH="${1:-amd64}" ARCH="${1:-amd64}"
OUT_DIR="${2:-/out}" 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; } log() { printf '%s\n' "[INFO] [build] [desktop-fetch] $*" >&2; }
warn() { printf '%s\n' "[WARN] [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. # would always come back empty. The list endpoint sorts newest-first.
log "querying newest release from $REPO_API" log "querying newest release from $REPO_API"
if ! curl -sfL "$REPO_API/releases?limit=1&draft=false" -o "$work/releases.json"; then 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 exit 0
fi fi
if [ "$(jq 'length' "$work/releases.json" 2>/dev/null || echo 0)" = "0" ]; then 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 exit 0
fi fi
manifest_url="$(jq -r '.[0].assets[]? | select(.name == "manifest.json") | .browser_download_url' "$work/releases.json" 2>/dev/null | head -1)" 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)" tag="$(jq -r '.[0].tag_name // empty' "$work/releases.json" 2>/dev/null)"
if [ -z "$manifest_url" ] || [ "$manifest_url" = "null" ]; then 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 exit 0
fi fi
log "newest release: $tag" log "newest release: $tag"
if ! curl -sfL "$manifest_url" -o "$work/manifest.json"; then 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 exit 0
fi fi
@ -71,7 +69,7 @@ exe_name="$(jq -r --arg a "$ARCH" \
"$work/manifest.json" 2>/dev/null | head -1)" "$work/manifest.json" 2>/dev/null | head -1)"
if [ -z "$expected_sha" ] || [ "$expected_sha" = "null" ]; then 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 exit 0
fi fi
@ -83,24 +81,24 @@ zip_url="$(jq -r --arg z "$zip_name" \
'.[0].assets[]? | select(.name == $z) | .browser_download_url' \ '.[0].assets[]? | select(.name == $z) | .browser_download_url' \
"$work/releases.json" 2>/dev/null | head -1)" "$work/releases.json" 2>/dev/null | head -1)"
if [ -z "$zip_url" ] || [ "$zip_url" = "null" ]; then 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 exit 0
fi fi
log "downloading $zip_name" log "downloading $zip_name"
if ! curl -sfL "$zip_url" -o "$work/win.zip"; then 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 exit 0
fi fi
if ! unzip -o -q "$work/win.zip" -d "$work/unz"; then 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 exit 0
fi fi
src="$work/unz/$exe_name" src="$work/unz/$exe_name"
if [ ! -f "$src" ]; then 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 exit 0
fi fi
@ -111,4 +109,4 @@ if [ "$actual_sha" != "$expected_sha" ]; then
fi fi
cp "$src" "$OUT_DIR/redflag-desktop.exe" 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"

View file

@ -9,10 +9,6 @@ RUN npm ci --ignore-scripts
COPY web/ ./ COPY web/ ./
RUN npx vite build 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 # 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 # 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 # 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 && \ mkdir -p /out/helper-linux-arm64 && \
cp target/aarch64-unknown-linux-musl/release/redflag-helper /out/helper-linux-arm64/redflag-helper 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 FROM rust:1-bookworm AS desktop-builder
ARG BUILD_VERSION=dev ARG BUILD_VERSION=dev
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \ qt6-base-dev \
libjavascriptcoregtk-4.1-dev \ qt6-declarative-dev \
libappindicator3-dev \ qt6-declarative-dev-tools \
librsvg2-dev \ libgl1-mesa-dev \
libgtk-3-dev \
libsoup-3.0-dev \
libglib2.0-dev \
pkg-config \ pkg-config \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /build WORKDIR /build
# Tauri's build embeds frontendDist (../web/dist-desktop) at compile time — # The QML module, bridge, and visual assets are embedded by CXX-Qt.
# 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
COPY desktop/ desktop/ COPY desktop/ desktop/
# Build the Tauri app in release mode RUN cd desktop && cargo build --release --locked && \
RUN cd desktop && cargo build --release && \
mkdir -p /out && \ mkdir -p /out && \
cp target/release/redflag-desktop /out/redflag-desktop 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 # 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 # needs a matching native Qt and MSVC SDK, which has no business in a
# toolchain), which has no business in a from-source server build. CI # from-source server build. A native release runner will build it once; this
# (release.yml, cargo-xwin) builds it once where the toolchain is ephemeral; # stage fetches that signed result. Optional component: anything short of a
# this stage fetches the signed result. Optional component: anything short of a # hash mismatch degrades to "no Windows Desktop". See scripts/fetch-desktop-windows.sh.
# hash mismatch degrades to "no Windows tray". See scripts/fetch-desktop-windows.sh.
FROM alpine:3.21 AS desktop-windows-fetcher FROM alpine:3.21 AS desktop-windows-fetcher
ARG BUILD_VERSION=dev 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} ENV DESKTOP_RELEASE_REPO_API=${DESKTOP_RELEASE_REPO_API}
RUN apk --no-cache add curl jq unzip 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 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 # 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. # rather than reusing a stale Desktop from an earlier build.
RUN echo "fetch Windows tray for build $BUILD_VERSION" && \ RUN echo "fetch Windows Desktop for build $BUILD_VERSION" && \
sh /usr/local/bin/fetch-desktop-windows.sh amd64 /out sh /usr/local/bin/fetch-desktop-windows.sh amd64 /out
# Stage 3: Final image with server and all agent binaries # 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-amd64 ./binaries/helper-linux-amd64
COPY --from=helper-builder /out/helper-linux-arm64 ./binaries/helper-linux-arm64 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 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 # 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 # 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. # happens here; only the verified artifact lands.
COPY --from=desktop-windows-fetcher /out /opt/winfetch COPY --from=desktop-windows-fetcher /out /opt/winfetch
RUN if [ -f /opt/winfetch/redflag-desktop.exe ]; then \ RUN if [ -f /opt/winfetch/redflag-desktop.exe ]; then \
mkdir -p ./binaries/windows-amd64 && \ mkdir -p ./binaries/windows-amd64 && \
mv /opt/winfetch/redflag-desktop.exe ./binaries/windows-amd64/redflag-desktop.exe && \ 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 \ 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; \ fi; \
rm -rf /opt/winfetch rm -rf /opt/winfetch
@ -253,4 +240,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["docker-entrypoint.sh"] ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["./redflag-server"] CMD ["./redflag-server"]

View file

@ -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 // Same pattern as helper: stored under "desktop-<os>", listed in
// the release manifest. Missing binary is non-fatal — installer skips. // the release manifest. Missing binary is non-fatal — installer skips.
desktopArches := []string{"amd64"} desktopArches := []string{"amd64"}
@ -620,7 +620,7 @@ func main() {
// route, same as /manifest. Signed + manifest-verified at install time. // route, same as /manifest. Signed + manifest-verified at install time.
api.GET("/helper/:arch", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadHelper) 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. // if not built for this arch, installer skips gracefully.
api.GET("/desktop/:platform/:arch", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadDesktop) api.GET("/desktop/:platform/:arch", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadDesktop)

View file

@ -350,7 +350,7 @@ func (h *DownloadHandler) DownloadHelper(c *gin.Context) {
c.File(absPath) 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, // The desktop binary is optional — if not built for a given platform/arch,
// returns 404 gracefully so the installer can skip it. // returns 404 gracefully so the installer can skip it.
func (h *DownloadHandler) DownloadDesktop(c *gin.Context) { func (h *DownloadHandler) DownloadDesktop(c *gin.Context) {

View file

@ -166,7 +166,7 @@ if $GUIDED; then
echo "" echo ""
echo "This will install the RedFlag agent and its components." echo "This will install the RedFlag agent and its components."
echo "Required components: agent, helper, web (embedded in server)." 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 ""
echo "Press Enter to continue or Ctrl-C to abort." echo "Press Enter to continue or Ctrl-C to abort."
read -r read -r
@ -279,10 +279,10 @@ if [ ! -d "$AGENT_HOME" ]; then
echo " - Logs: $AGENT_LOG_DIR" echo " - Logs: $AGENT_LOG_DIR"
fi fi
# Local-API / tray access chain — enforced on EVERY run, not just first # Local-API / Desktop access chain — enforced on EVERY run, not just first
# install. Upgrades from pre-tray versions carry agent:agent 700 dirs that # install. Upgrades from older versions carry agent:agent 700 dirs that
# block the ${LOCAL_API_GROUP} group from traversing to the socket, which # 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; # 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. # the agent re-asserts socket dir/file ownership itself on startup.
sudo mkdir -p "$AGENT_HOME/localapi" 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] [Desktop Entry]
Type=Application Type=Application
Name=RedFlag Name=RedFlag
Comment=RedFlag system tray (local agent dashboard) Comment=RedFlag local-machine operations console
Exec=${INSTALL_DIR}/redflag-desktop Exec=${INSTALL_DIR}/redflag-desktop
Terminal=false Terminal=false
X-GNOME-Autostart-enabled=true X-GNOME-Autostart-enabled=true
EOF EOF
sudo chmod 644 "${XDG_AUTOSTART_DIR}/redflag-desktop.desktop" sudo chmod 644 "${XDG_AUTOSTART_DIR}/redflag-desktop.desktop"
fi 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). # Add invoking user to redflag-local group (provisioning: desktop_user_membership).
if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
if id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$LOCAL_API_GROUP"; 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" echo "✓ $SUDO_USER already in $LOCAL_API_GROUP group"
else else
sudo usermod -aG "$LOCAL_API_GROUP" "$SUDO_USER" 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
fi fi
checkoff "$comp_name" "ok" "installed at ${INSTALL_DIR}/redflag-desktop" checkoff "$comp_name" "ok" "installed at ${INSTALL_DIR}/redflag-desktop"

View file

@ -637,7 +637,7 @@ if (-not $SkipServiceInstall) {
Start-Service -Name $ServiceName 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 # The desktop binary is optional — the installer proceeds without it if the
# server has no build for this platform. # server has no build for this platform.
$DesktopBinary = "redflag-desktop.exe" $DesktopBinary = "redflag-desktop.exe"
@ -645,7 +645,7 @@ $DesktopURL = "$ServerUrl/api/v1/desktop/windows/amd64?version=$Version"
$DesktopBinaryPath = Join-Path $InstallDir $DesktopBinary $DesktopBinaryPath = Join-Path $InstallDir $DesktopBinary
Write-Host Write-Host
Write-Host "Downloading desktop app (system tray + local dashboard)..." -ForegroundColor Yellow Write-Host "Downloading RedFlag Desktop..." -ForegroundColor Yellow
try { try {
$TmpDesktop = Join-Path $env:TEMP "redflag-desktop-download.exe" $TmpDesktop = Join-Path $env:TEMP "redflag-desktop-download.exe"
$DesktopResp = Invoke-WebRequest -Uri $DesktopURL -OutFile $TmpDesktop -UseBasicParsing -PassThru $DesktopResp = Invoke-WebRequest -Uri $DesktopURL -OutFile $TmpDesktop -UseBasicParsing -PassThru
@ -677,7 +677,7 @@ try {
} }
# Step 8: Register desktop autostart for the interactive user. # 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 # Must target HKCU (per-user); the installer runs elevated so we resolve the
# original non-elevated user via the calling process chain. # original non-elevated user via the calling process chain.
if (Test-Path $DesktopBinaryPath) { if (Test-Path $DesktopBinaryPath) {

View file

@ -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
View file

@ -11,7 +11,6 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.8.4", "@tanstack/react-query": "^5.8.4",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"@tauri-apps/api": "^2.0.0",
"axios": "^1.19.0", "axios": "^1.19.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
@ -25,7 +24,6 @@
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2", "@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1", "@testing-library/user-event": "14.6.1",
@ -1298,233 +1296,6 @@
"react": "^18 || ^19" "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": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",

View file

@ -6,16 +6,13 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:desktop": "vite --config vite.desktop.config.ts --host 127.0.0.1",
"build": "tsc && vite build", "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", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.8.4", "@tanstack/react-query": "^5.8.4",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"@tauri-apps/api": "^2.0.0",
"axios": "^1.19.0", "axios": "^1.19.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
@ -29,7 +26,6 @@
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2", "@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1", "@testing-library/user-event": "14.6.1",

View file

@ -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

View file

@ -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>,
)

View file

@ -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,
},
})