feat: FEAT-002 local agent API, desktop tray spine, screenshot handler
Slices 1-3 of the local agent IPC surface: - Read model (local_status.go, loop wired): update counts, scanner status, check-in state, token receipt counts — no token material exposed - Local IPC (localapi/): Unix socket (group=redflag-local, 0660) + Windows named pipe (SDDL: LocalSystem/Admins/RedFlagLocal); five read-only endpoints - `redflag-agent -local-status` CLI probe of the local API surface - Screenshot capture handler (screenshot.go, dispatch wired) - Tauri desktop spine (desktop/): tray icon, left-click window, local IPC reader - Desktop React entry (web/src/desktop/LocalAgentApp.tsx, index.desktop.html, vite.desktop.config.ts) - Installer group provisioning: linux.sh creates redflag-local, sets SupplementaryGroups; windows.ps1 creates RedFlagLocal security group - Server-side: screenshot receipt handler on agents, updates handler additions - web/package.json: @tauri-apps/api + tauri CLI dev dep added
This commit is contained in:
parent
b0e2a77528
commit
ffffe9b956
35 changed files with 1996 additions and 31 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -152,6 +152,8 @@ out
|
|||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
web/dist-desktop/
|
||||
desktop/target/
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
|
@ -473,6 +475,9 @@ docker-compose.dev.yml
|
|||
.migration_temp/
|
||||
|
||||
# =============================================================================
|
||||
# Root-level stray npm artifact (npm should be run from web/)
|
||||
/package-lock.json
|
||||
|
||||
# Kate editor swap files
|
||||
# =============================================================================
|
||||
*.swp
|
||||
|
|
|
|||
|
|
@ -14,28 +14,29 @@ import (
|
|||
|
||||
// CLIFlags holds all command-line flags
|
||||
type CLI struct {
|
||||
Register bool
|
||||
Scan bool
|
||||
Status bool
|
||||
ListUpdates bool
|
||||
Version bool
|
||||
ServerURL string
|
||||
Token string
|
||||
ProxyHTTP string
|
||||
ProxyHTTPS string
|
||||
ProxyNoProxy string
|
||||
LogLevel string
|
||||
ConfigFile string
|
||||
Tags string
|
||||
Organization string
|
||||
DisplayName string
|
||||
InsecureTLS bool
|
||||
ExportFormat string
|
||||
InstallService bool
|
||||
RemoveService bool
|
||||
StartService bool
|
||||
StopService bool
|
||||
ServiceStatus bool
|
||||
Register bool
|
||||
Scan bool
|
||||
Status bool
|
||||
LocalStatus bool
|
||||
ListUpdates bool
|
||||
Version bool
|
||||
ServerURL string
|
||||
Token string
|
||||
ProxyHTTP string
|
||||
ProxyHTTPS string
|
||||
ProxyNoProxy string
|
||||
LogLevel string
|
||||
ConfigFile string
|
||||
Tags string
|
||||
Organization string
|
||||
DisplayName string
|
||||
InsecureTLS bool
|
||||
ExportFormat string
|
||||
InstallService bool
|
||||
RemoveService bool
|
||||
StartService bool
|
||||
StopService bool
|
||||
ServiceStatus bool
|
||||
}
|
||||
|
||||
// ParseFlags parses all command-line flags and returns the CLI struct
|
||||
|
|
@ -46,6 +47,7 @@ func ParseFlags() *CLI {
|
|||
flag.BoolVar(&cli.Register, "register", false, "Register agent with server")
|
||||
flag.BoolVar(&cli.Scan, "scan", false, "Scan for updates and display locally")
|
||||
flag.BoolVar(&cli.Status, "status", false, "Show agent status")
|
||||
flag.BoolVar(&cli.LocalStatus, "local-status", false, "Show live local agent status over local IPC")
|
||||
flag.BoolVar(&cli.ListUpdates, "list-updates", false, "List detailed update information")
|
||||
flag.BoolVar(&cli.Version, "version", false, "Show version information")
|
||||
flag.StringVar(&cli.ServerURL, "server", "", "Server URL")
|
||||
|
|
|
|||
119
agent/cmd/agent/local_status.go
Normal file
119
agent/cmd/agent/local_status.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/localapi"
|
||||
)
|
||||
|
||||
// HandleLocalStatusCommand displays live agent status through the local IPC API.
|
||||
// It intentionally runs before config loading, so membership in the local access
|
||||
// group is enough to inspect local state without reading protected config files.
|
||||
func HandleLocalStatusCommand(exportFormat string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
snapshot, err := localapi.FetchSnapshot(ctx, localapi.ClientOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exportFormat == "json" {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(snapshot)
|
||||
}
|
||||
|
||||
printLocalStatus(snapshot)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printLocalStatus(snapshot *localapi.Snapshot) {
|
||||
identity := snapshot.Identity
|
||||
status := snapshot.Status
|
||||
|
||||
fmt.Println("==================================================================")
|
||||
fmt.Println("RedFlag Local Agent Status")
|
||||
fmt.Println("==================================================================")
|
||||
fmt.Printf("Agent ID: %s\n", identity.AgentID)
|
||||
fmt.Printf("Server: %s\n", identity.ServerURL)
|
||||
if identity.Hostname != "" {
|
||||
fmt.Printf("Hostname: %s\n", identity.Hostname)
|
||||
}
|
||||
if identity.OSType != "" {
|
||||
fmt.Printf("OS Type: %s\n", identity.OSType)
|
||||
}
|
||||
if identity.DisplayName != "" {
|
||||
fmt.Printf("Display Name: %s\n", identity.DisplayName)
|
||||
}
|
||||
if len(identity.Tags) > 0 {
|
||||
fmt.Printf("Tags: %s\n", strings.Join(identity.Tags, ", "))
|
||||
}
|
||||
fmt.Printf("Version: %s\n", identity.AgentVersion)
|
||||
fmt.Printf("Registered: %t\n", identity.Registered)
|
||||
fmt.Println()
|
||||
|
||||
fmt.Printf("Agent Status: %s\n", fallback(status.AgentStatus, "unknown"))
|
||||
if !status.LastCheckIn.IsZero() {
|
||||
fmt.Printf("Last Check-in: %s\n", status.LastCheckIn.Format(time.RFC3339))
|
||||
}
|
||||
if !status.LastScan.IsZero() {
|
||||
fmt.Printf("Last Scan: %s\n", status.LastScan.Format(time.RFC3339))
|
||||
}
|
||||
fmt.Printf("Updates Available: %d\n", status.UpdateCount)
|
||||
fmt.Printf("Summary Total: %d\n", status.Summary.Total)
|
||||
|
||||
if len(status.Summary.ByEcosystem) > 0 {
|
||||
fmt.Printf("By Ecosystem: %s\n", formatCounts(status.Summary.ByEcosystem))
|
||||
}
|
||||
if len(status.Summary.BySeverity) > 0 {
|
||||
fmt.Printf("By Severity: %s\n", formatCounts(status.Summary.BySeverity))
|
||||
}
|
||||
|
||||
if len(status.Scanners) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println("Scanners:")
|
||||
names := make([]string, 0, len(status.Scanners))
|
||||
for name := range status.Scanners {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
scanner := status.Scanners[name]
|
||||
line := fmt.Sprintf(" - %s: %s (%d updates)", scanner.Name, fallback(scanner.Status, "unknown"), scanner.UpdateCount)
|
||||
if scanner.LastError != "" {
|
||||
line += fmt.Sprintf(" error=%q", scanner.LastError)
|
||||
}
|
||||
fmt.Println(line)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("==================================================================")
|
||||
}
|
||||
|
||||
func formatCounts(counts map[string]int) string {
|
||||
keys := make([]string, 0, len(counts))
|
||||
for key := range counts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%d", key, counts[key]))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func fallback(value, replacement string) string {
|
||||
if value == "" {
|
||||
return replacement
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
|
@ -41,6 +41,13 @@ func main() {
|
|||
HandleVersionCommand()
|
||||
}
|
||||
|
||||
if cli.LocalStatus {
|
||||
if err := HandleLocalStatusCommand(cli.ExportFormat); err != nil {
|
||||
log.Fatal("Local status command failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Windows service management commands
|
||||
if HandleWindowsServiceCommands(cli) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/handlers"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/integrations"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/kernel"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/localapi"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/logging"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
|
||||
|
|
@ -171,6 +172,13 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
|
||||
ctx := loopCtx
|
||||
|
||||
localAPIServer, err := localapi.Start(localapi.Options{Config: ctx.Cfg})
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [localapi] start_failed error=%v", err)
|
||||
} else {
|
||||
defer localAPIServer.Stop()
|
||||
}
|
||||
|
||||
// Start kernel enforcement enforcer
|
||||
if ctx.KernelEnforcer != nil {
|
||||
if err := ctx.KernelEnforcer.Start(ctx.Ctx); err != nil {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ func DispatchCrossPlatformCommand(
|
|||
err = HandleUpdateAgent(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
|
||||
case "reboot":
|
||||
err = HandleReboot(apiClient, cfg, ackTracker, cmd.ID, cmd.Params)
|
||||
case "capture_screenshot":
|
||||
err = HandleCaptureScreenshot(apiClient, cfg, ackTracker, cmd.ID)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
139
agent/internal/handlers/screenshot.go
Normal file
139
agent/internal/handlers/screenshot.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
)
|
||||
|
||||
// HandleCaptureScreenshot captures the current display output and reports it
|
||||
// back as a base64-encoded PNG in the command result's stdout field.
|
||||
// Observe-only: reads the screen, does not interact with it.
|
||||
//
|
||||
// Linux: uses scrot (X11) or import (ImageMagick) as fallback.
|
||||
// Windows: uses PowerShell with .NET System.Drawing.
|
||||
// The temp file is cleaned up after encoding.
|
||||
func HandleCaptureScreenshot(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string) error {
|
||||
start := time.Now()
|
||||
|
||||
tmpPath := filepath.Join(os.TempDir(), fmt.Sprintf("redflag_screenshot_%d.png", time.Now().UnixNano()))
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if err := captureScreen(tmpPath); err != nil {
|
||||
return fmt.Errorf("screen capture failed: %w", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(tmpPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read screenshot: %w", err)
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "capture_screenshot",
|
||||
Result: "success",
|
||||
Stdout: encoded,
|
||||
ExitCode: 0,
|
||||
DurationSeconds: int(time.Since(start).Seconds()),
|
||||
}
|
||||
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
return fmt.Errorf("failed to report screenshot: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [screenshot] captured size_bytes=%d duration=%s",
|
||||
len(data), time.Since(start).Round(time.Millisecond))
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureScreen writes a PNG screenshot to outputPath. Platform-specific.
|
||||
func captureScreen(outputPath string) error {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return captureScreenLinux(outputPath)
|
||||
case "windows":
|
||||
return captureScreenWindows(outputPath)
|
||||
default:
|
||||
return fmt.Errorf("screenshot not supported on %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
// captureScreenLinux captures the display using scrot (X11) with import
|
||||
// (ImageMagick) as fallback. Both are read-only and do not interact with
|
||||
// the display server.
|
||||
func captureScreenLinux(outputPath string) error {
|
||||
// Try scrot first — most common on Fedora/Ubuntu with X11.
|
||||
if _, err := exec.LookPath("scrot"); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "scrot", "-o", outputPath)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Printf("[WARN] [agent] [screenshot] scrot_failed output=%q error=%v", string(out), err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: ImageMagick import — also X11, widely available.
|
||||
if _, err := exec.LookPath("import"); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "import", "-window", "root", outputPath)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Printf("[WARN] [agent] [screenshot] import_failed output=%q error=%v", string(out), err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: grim (Wayland native).
|
||||
if _, err := exec.LookPath("grim"); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "grim", outputPath)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Printf("[WARN] [agent] [screenshot] grim_failed output=%q error=%v", string(out), err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("no screenshot tool found (tried scrot, import, grim)")
|
||||
}
|
||||
|
||||
// captureScreenWindows captures the display using PowerShell + .NET
|
||||
// System.Drawing. No extra tools required — always available on Windows.
|
||||
func captureScreenWindows(outputPath string) error {
|
||||
psScript := fmt.Sprintf(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||
$bmp = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height)
|
||||
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$gfx.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
||||
$bmp.Save('%s', [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$gfx.Dispose()
|
||||
$bmp.Dispose()
|
||||
`, outputPath)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("powershell screenshot failed: %s: %w", string(out), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
86
agent/internal/localapi/client.go
Normal file
86
agent/internal/localapi/client.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const localHTTPBaseURL = "http://redflag.local"
|
||||
|
||||
// ClientOptions configures local IPC client access. Defaults mirror the agent
|
||||
// listener and remain platform-specific.
|
||||
type ClientOptions struct {
|
||||
UnixSocketPath string
|
||||
WindowsPipeName string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Snapshot is the compact local state needed by status probes and tray shells.
|
||||
type Snapshot struct {
|
||||
Identity IdentityResponse `json:"identity"`
|
||||
Status StatusResponse `json:"status"`
|
||||
}
|
||||
|
||||
// FetchSnapshot reads the local agent API over the platform IPC transport.
|
||||
func FetchSnapshot(ctx context.Context, opts ClientOptions) (*Snapshot, error) {
|
||||
httpClient := newLocalHTTPClient(opts)
|
||||
|
||||
identity, err := fetchJSON[IdentityResponse](ctx, httpClient, "/v1/identity")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch identity: %w", err)
|
||||
}
|
||||
status, err := fetchJSON[StatusResponse](ctx, httpClient, "/v1/status")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch status: %w", err)
|
||||
}
|
||||
|
||||
return &Snapshot{
|
||||
Identity: identity,
|
||||
Status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newLocalHTTPClient(opts ClientOptions) *http.Client {
|
||||
timeout := opts.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: dialLocalContext(opts),
|
||||
DisableKeepAlives: true,
|
||||
Proxy: nil,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fetchJSON[T any](ctx context.Context, httpClient *http.Client, path string) (T, error) {
|
||||
var value T
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, localHTTPBaseURL+path, nil)
|
||||
if err != nil {
|
||||
return value, err
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return value, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return value, fmt.Errorf("local API returned %s", resp.Status)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&value); err != nil {
|
||||
return value, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func dialLocalContext(opts ClientOptions) func(context.Context, string, string) (net.Conn, error) {
|
||||
return platformDialLocalContext(opts)
|
||||
}
|
||||
20
agent/internal/localapi/client_unix.go
Normal file
20
agent/internal/localapi/client_unix.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//go:build !windows
|
||||
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
)
|
||||
|
||||
func platformDialLocalContext(opts ClientOptions) func(context.Context, string, string) (net.Conn, error) {
|
||||
path := opts.UnixSocketPath
|
||||
if path == "" {
|
||||
path = defaultUnixSocketPath()
|
||||
}
|
||||
|
||||
return func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, "unix", path)
|
||||
}
|
||||
}
|
||||
63
agent/internal/localapi/client_unix_test.go
Normal file
63
agent/internal/localapi/client_unix_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
//go:build !windows
|
||||
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
||||
)
|
||||
|
||||
func TestFetchSnapshotOverUnixSocket(t *testing.T) {
|
||||
socketPath := filepath.Join(t.TempDir(), "redflag-agent.sock")
|
||||
ln, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.EPERM) {
|
||||
t.Skipf("unix sockets are not permitted in this sandbox: %v", err)
|
||||
}
|
||||
t.Fatalf("listen unix socket: %v", err)
|
||||
}
|
||||
|
||||
cfg := testConfig(t)
|
||||
server, err := Start(Options{
|
||||
Config: cfg,
|
||||
LoadCache: func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{
|
||||
AgentStatus: "online",
|
||||
UpdateCount: 7,
|
||||
Summary: cache.UpdateSummary{
|
||||
Total: 7,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
RequestLog: t.Logf,
|
||||
ListenerOverride: ln,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start local API: %v", err)
|
||||
}
|
||||
defer server.Stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
snapshot, err := FetchSnapshot(ctx, ClientOptions{UnixSocketPath: socketPath})
|
||||
if err != nil {
|
||||
t.Fatalf("fetch snapshot: %v", err)
|
||||
}
|
||||
|
||||
if snapshot.Identity.AgentID != cfg.AgentID.String() {
|
||||
t.Fatalf("agent_id = %q, want %q", snapshot.Identity.AgentID, cfg.AgentID.String())
|
||||
}
|
||||
if snapshot.Status.AgentStatus != "online" {
|
||||
t.Fatalf("agent_status = %q, want online", snapshot.Status.AgentStatus)
|
||||
}
|
||||
if snapshot.Status.UpdateCount != 7 {
|
||||
t.Fatalf("update_count = %d, want 7", snapshot.Status.UpdateCount)
|
||||
}
|
||||
}
|
||||
21
agent/internal/localapi/client_windows.go
Normal file
21
agent/internal/localapi/client_windows.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//go:build windows
|
||||
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
winio "github.com/Microsoft/go-winio"
|
||||
)
|
||||
|
||||
func platformDialLocalContext(opts ClientOptions) func(context.Context, string, string) (net.Conn, error) {
|
||||
pipeName := opts.WindowsPipeName
|
||||
if pipeName == "" {
|
||||
pipeName = DefaultWindowsPipeName
|
||||
}
|
||||
|
||||
return func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return winio.DialPipeContext(ctx, pipeName)
|
||||
}
|
||||
}
|
||||
109
agent/internal/localapi/listener_unix.go
Normal file
109
agent/internal/localapi/listener_unix.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//go:build !windows
|
||||
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
)
|
||||
|
||||
const unixSocketFile = "redflag-agent.sock"
|
||||
|
||||
func defaultGroupName() string {
|
||||
return DefaultUnixGroupName
|
||||
}
|
||||
|
||||
func defaultUnixSocketPath() string {
|
||||
return filepath.Join(constants.GetBaseDir(), constants.AgentDir, "localapi", unixSocketFile)
|
||||
}
|
||||
|
||||
func listen(opts Options) (net.Listener, string, error) {
|
||||
path := unixSocketPath(opts)
|
||||
group := groupName(opts)
|
||||
|
||||
gid, err := lookupGroupID(group)
|
||||
if err != nil {
|
||||
return nil, "", formatGroupMissing(group, err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, "", fmt.Errorf("localapi: create socket directory %q: %w", dir, err)
|
||||
}
|
||||
if err := os.Chown(dir, -1, gid); err != nil {
|
||||
return nil, "", fmt.Errorf("localapi: chown socket directory %q to group %q: %w", dir, group, err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o750); err != nil {
|
||||
return nil, "", fmt.Errorf("localapi: chmod socket directory %q: %w", dir, err)
|
||||
}
|
||||
|
||||
if err := removeStaleSocket(path); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("localapi: listen on unix socket %q: %w", path, err)
|
||||
}
|
||||
|
||||
if err := os.Chown(path, -1, gid); err != nil {
|
||||
_ = ln.Close()
|
||||
_ = os.Remove(path)
|
||||
return nil, "", fmt.Errorf("localapi: chown socket %q to group %q: %w", path, group, err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o660); err != nil {
|
||||
_ = ln.Close()
|
||||
_ = os.Remove(path)
|
||||
return nil, "", fmt.Errorf("localapi: chmod socket %q: %w", path, err)
|
||||
}
|
||||
|
||||
return &cleanupListener{Listener: ln, path: path}, path, nil
|
||||
}
|
||||
|
||||
func lookupGroupID(groupName string) (int, error) {
|
||||
group, err := user.LookupGroup(groupName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
gid, err := strconv.Atoi(group.Gid)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse gid %q: %w", group.Gid, err)
|
||||
}
|
||||
return gid, nil
|
||||
}
|
||||
|
||||
func removeStaleSocket(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("localapi: stat socket path %q: %w", path, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
return fmt.Errorf("localapi: refusing to replace non-socket path %q", path)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("localapi: remove stale socket %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type cleanupListener struct {
|
||||
net.Listener
|
||||
path string
|
||||
}
|
||||
|
||||
func (l *cleanupListener) Close() error {
|
||||
err := l.Listener.Close()
|
||||
if removeErr := os.Remove(l.path); err == nil && removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
err = removeErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
54
agent/internal/localapi/listener_windows.go
Normal file
54
agent/internal/localapi/listener_windows.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//go:build windows
|
||||
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
winio "github.com/Microsoft/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func defaultGroupName() string {
|
||||
return DefaultWindowsGroupName
|
||||
}
|
||||
|
||||
func defaultUnixSocketPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func listen(opts Options) (net.Listener, string, error) {
|
||||
pipeName := windowsPipeName(opts)
|
||||
group := groupName(opts)
|
||||
|
||||
groupSID, err := lookupGroupSID(group)
|
||||
if err != nil {
|
||||
return nil, "", formatGroupMissing(group, err)
|
||||
}
|
||||
|
||||
cfg := &winio.PipeConfig{
|
||||
// LocalSystem and Administrators get full access; the RedFlag local UI
|
||||
// group gets read/write transport rights so it can issue HTTP GETs and
|
||||
// receive responses over the pipe.
|
||||
SecurityDescriptor: fmt.Sprintf("D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;%s)", groupSID),
|
||||
}
|
||||
ln, err := winio.ListenPipe(pipeName, cfg)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("localapi: listen on named pipe %q: %w", pipeName, err)
|
||||
}
|
||||
return ln, pipeName, nil
|
||||
}
|
||||
|
||||
func lookupGroupSID(groupName string) (string, error) {
|
||||
sid, _, accountType, err := windows.LookupSID("", groupName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch accountType {
|
||||
case windows.SidTypeGroup, windows.SidTypeAlias, windows.SidTypeWellKnownGroup:
|
||||
return sid.String(), nil
|
||||
default:
|
||||
return "", fmt.Errorf("account %q has unexpected SID type %d", groupName, accountType)
|
||||
}
|
||||
}
|
||||
288
agent/internal/localapi/server.go
Normal file
288
agent/internal/localapi/server.go
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultUnixGroupName = "redflag-local"
|
||||
DefaultWindowsGroupName = "RedFlagLocal"
|
||||
DefaultWindowsPipeName = `\\.\pipe\RedFlagAgentLocal`
|
||||
)
|
||||
|
||||
// Options configures the local read-only API. Group, socket, and pipe defaults
|
||||
// are platform-specific and enforced by the listener implementation.
|
||||
type Options struct {
|
||||
Config *config.Config
|
||||
GroupName string
|
||||
UnixSocketPath string
|
||||
WindowsPipeName string
|
||||
LoadCache func() (*cache.LocalCache, error)
|
||||
RequestLog func(format string, args ...interface{})
|
||||
ListenerOverride net.Listener
|
||||
}
|
||||
|
||||
// Server owns the local API listener and HTTP server.
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
listener net.Listener
|
||||
address string
|
||||
logf func(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// Start creates a platform-native local listener and serves the read-only local
|
||||
// API. It returns an error before serving if the OS-local ACL cannot be applied.
|
||||
func Start(opts Options) (*Server, error) {
|
||||
if opts.Config == nil {
|
||||
return nil, errors.New("localapi: config is required")
|
||||
}
|
||||
if opts.LoadCache == nil {
|
||||
opts.LoadCache = cache.Load
|
||||
}
|
||||
if opts.RequestLog == nil {
|
||||
opts.RequestLog = log.Printf
|
||||
}
|
||||
|
||||
handler := newHandler(opts.Config, opts.LoadCache)
|
||||
httpServer := &http.Server{Handler: handler}
|
||||
|
||||
listener := opts.ListenerOverride
|
||||
address := ""
|
||||
var err error
|
||||
if listener == nil {
|
||||
listener, address, err = listen(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
address = listener.Addr().String()
|
||||
}
|
||||
|
||||
srv := &Server{
|
||||
httpServer: httpServer,
|
||||
listener: listener,
|
||||
address: address,
|
||||
logf: opts.RequestLog,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
opts.RequestLog("[ERROR] [agent] [localapi] serve_failed address=%s error=%v", address, err)
|
||||
}
|
||||
}()
|
||||
|
||||
opts.RequestLog("[INFO] [agent] [localapi] started address=%s", address)
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Stop shuts down the local API listener.
|
||||
func (s *Server) Stop() {
|
||||
if s == nil || s.httpServer == nil {
|
||||
return
|
||||
}
|
||||
if err := s.httpServer.Close(); err != nil && s.logf != nil {
|
||||
s.logf("[WARNING] [agent] [localapi] stop_failed address=%s error=%v", s.address, err)
|
||||
}
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
cfg *config.Config
|
||||
loadCache func() (*cache.LocalCache, error)
|
||||
}
|
||||
|
||||
type IdentityResponse struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
OSType string `json:"os_type,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Organization string `json:"organization,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
ConfigVersion string `json:"config_version,omitempty"`
|
||||
CheckInInterval int `json:"check_in_interval"`
|
||||
Registered bool `json:"registered"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
AgentStatus string `json:"agent_status"`
|
||||
LastCheckIn time.Time `json:"last_check_in,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated,omitempty"`
|
||||
LastScan time.Time `json:"last_scan_time,omitempty"`
|
||||
UpdateCount int `json:"update_count"`
|
||||
Summary cache.UpdateSummary `json:"summary"`
|
||||
Scanners map[string]cache.ScannerState `json:"scanners,omitempty"`
|
||||
Registered bool `json:"registered"`
|
||||
}
|
||||
|
||||
type ScanResponse struct {
|
||||
LastScanTime time.Time `json:"last_scan_time,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated,omitempty"`
|
||||
UpdateCount int `json:"update_count"`
|
||||
Summary cache.UpdateSummary `json:"summary"`
|
||||
Scanners map[string]cache.ScannerState `json:"scanners,omitempty"`
|
||||
Updates []client.UpdateReportItem `json:"updates"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
Capabilities cache.CapabilityTokenState `json:"capabilities"`
|
||||
}
|
||||
|
||||
func newHandler(cfg *config.Config, loadCache func() (*cache.LocalCache, error)) http.Handler {
|
||||
h := &handler{cfg: cfg, loadCache: loadCache}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/identity", h.identity)
|
||||
mux.HandleFunc("/v1/status", h.status)
|
||||
mux.HandleFunc("/v1/scans/latest", h.scansLatest)
|
||||
mux.HandleFunc("/v1/packages", h.scansLatest)
|
||||
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (h *handler) identity(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] hostname_failed error=%v", err)
|
||||
}
|
||||
|
||||
resp := IdentityResponse{
|
||||
AgentID: h.cfg.AgentID.String(),
|
||||
ServerURL: h.cfg.ServerURL,
|
||||
Hostname: hostname,
|
||||
OSType: h.cfg.OSType,
|
||||
DisplayName: h.cfg.DisplayName,
|
||||
Organization: h.cfg.Organization,
|
||||
Tags: sortedCopy(h.cfg.Tags),
|
||||
AgentVersion: version.Version,
|
||||
ConfigVersion: h.cfg.Version,
|
||||
CheckInInterval: h.cfg.CheckInInterval,
|
||||
Registered: h.cfg.IsRegistered(),
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
func (h *handler) status(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
localCache, ok := h.load(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, StatusResponse{
|
||||
AgentStatus: localCache.AgentStatus,
|
||||
LastCheckIn: localCache.LastCheckIn,
|
||||
LastUpdated: localCache.LastUpdated,
|
||||
LastScan: localCache.LastScanTime,
|
||||
UpdateCount: localCache.UpdateCount,
|
||||
Summary: localCache.Summary,
|
||||
Scanners: localCache.Scanners,
|
||||
Registered: h.cfg.IsRegistered(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) scansLatest(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
localCache, ok := h.load(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, ScanResponse{
|
||||
LastScanTime: localCache.LastScanTime,
|
||||
LastUpdated: localCache.LastUpdated,
|
||||
UpdateCount: localCache.UpdateCount,
|
||||
Summary: localCache.Summary,
|
||||
Scanners: localCache.Scanners,
|
||||
Updates: localCache.Updates,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) tokensActive(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
localCache, ok := h.load(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, TokenResponse{Capabilities: localCache.Capabilities})
|
||||
}
|
||||
|
||||
func (h *handler) load(w http.ResponseWriter) (*cache.LocalCache, bool) {
|
||||
localCache, err := h.loadCache()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [localapi] cache_load_failed error=%v", err)
|
||||
http.Error(w, "local state unavailable", http.StatusServiceUnavailable)
|
||||
return nil, false
|
||||
}
|
||||
return localCache, true
|
||||
}
|
||||
|
||||
func requireGet(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Method == http.MethodGet {
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return false
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, value interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(value); err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] response_encode_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sortedCopy(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
copied := append([]string(nil), values...)
|
||||
sort.Strings(copied)
|
||||
return copied
|
||||
}
|
||||
|
||||
func groupName(opts Options) string {
|
||||
if opts.GroupName != "" {
|
||||
return opts.GroupName
|
||||
}
|
||||
return defaultGroupName()
|
||||
}
|
||||
|
||||
func unixSocketPath(opts Options) string {
|
||||
if opts.UnixSocketPath != "" {
|
||||
return opts.UnixSocketPath
|
||||
}
|
||||
return defaultUnixSocketPath()
|
||||
}
|
||||
|
||||
func windowsPipeName(opts Options) string {
|
||||
if opts.WindowsPipeName != "" {
|
||||
return opts.WindowsPipeName
|
||||
}
|
||||
return DefaultWindowsPipeName
|
||||
}
|
||||
|
||||
func formatGroupMissing(name string, err error) error {
|
||||
return fmt.Errorf("localapi: local access group %q unavailable: %w", name, err)
|
||||
}
|
||||
184
agent/internal/localapi/server_test.go
Normal file
184
agent/internal/localapi/server_test.go
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func TestIdentityRedactsSecrets(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
handler := newHandler(cfg, func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/identity", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, forbidden := range []string{"secret-access-token", "secret-refresh-token", "registration-token", "refresh_token", "token"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("identity response leaked %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
|
||||
var resp IdentityResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.AgentID != cfg.AgentID.String() {
|
||||
t.Fatalf("agent_id = %q, want %q", resp.AgentID, cfg.AgentID.String())
|
||||
}
|
||||
if resp.Registered != true {
|
||||
t.Fatalf("registered = false, want true")
|
||||
}
|
||||
if got := strings.Join(resp.Tags, ","); got != "alpha,zeta" {
|
||||
t.Fatalf("tags = %q, want sorted alpha,zeta", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusAndPackagesReadLocalCache(t *testing.T) {
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
localCache := &cache.LocalCache{
|
||||
LastScanTime: now.Add(-2 * time.Minute),
|
||||
LastCheckIn: now.Add(-1 * time.Minute),
|
||||
LastUpdated: now,
|
||||
UpdateCount: 1,
|
||||
AgentStatus: "online",
|
||||
Summary: cache.UpdateSummary{
|
||||
Total: 1,
|
||||
ByEcosystem: map[string]int{"windows": 1},
|
||||
BySeverity: map[string]int{"critical": 1},
|
||||
},
|
||||
Scanners: map[string]cache.ScannerState{
|
||||
"windows": {
|
||||
Name: "windows",
|
||||
Status: "success",
|
||||
UpdateCount: 1,
|
||||
},
|
||||
},
|
||||
Updates: []client.UpdateReportItem{
|
||||
{
|
||||
PackageType: "windows",
|
||||
PackageName: "KB5000001",
|
||||
CurrentVersion: "1",
|
||||
AvailableVersion: "2",
|
||||
Severity: "critical",
|
||||
},
|
||||
},
|
||||
Capabilities: cache.CapabilityTokenState{
|
||||
PendingCount: 2,
|
||||
LastFetchedCount: 3,
|
||||
LastProcessedCount: 1,
|
||||
},
|
||||
}
|
||||
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
|
||||
return localCache, nil
|
||||
})
|
||||
|
||||
var status StatusResponse
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/status", &status)
|
||||
if status.AgentStatus != "online" {
|
||||
t.Fatalf("agent_status = %q, want online", status.AgentStatus)
|
||||
}
|
||||
if status.Summary.Total != 1 {
|
||||
t.Fatalf("summary.total = %d, want 1", status.Summary.Total)
|
||||
}
|
||||
|
||||
var packages ScanResponse
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/packages", &packages)
|
||||
if len(packages.Updates) != 1 {
|
||||
t.Fatalf("updates len = %d, want 1", len(packages.Updates))
|
||||
}
|
||||
if packages.Updates[0].PackageName != "KB5000001" {
|
||||
t.Fatalf("package name = %q, want KB5000001", packages.Updates[0].PackageName)
|
||||
}
|
||||
|
||||
var tokens TokenResponse
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/tokens/active", &tokens)
|
||||
if tokens.Capabilities.PendingCount != 2 {
|
||||
t.Fatalf("pending tokens = %d, want 2", tokens.Capabilities.PendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
|
||||
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
|
||||
return nil, errors.New("cannot read cache")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyGetMethodsAllowed(t *testing.T) {
|
||||
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
if rec.Header().Get("Allow") != http.MethodGet {
|
||||
t.Fatalf("Allow = %q, want GET", rec.Header().Get("Allow"))
|
||||
}
|
||||
}
|
||||
|
||||
func requestJSON(t *testing.T, handler http.Handler, method, path string, target interface{}) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s %s status = %d, want %d; body=%s", method, path, rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), target); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testConfig(t *testing.T) *config.Config {
|
||||
t.Helper()
|
||||
|
||||
agentID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatalf("new uuid: %v", err)
|
||||
}
|
||||
return &config.Config{
|
||||
Version: "5",
|
||||
ServerURL: "https://redflag.example",
|
||||
RegistrationToken: "registration-token",
|
||||
AgentID: agentID,
|
||||
Token: "secret-access-token",
|
||||
RefreshToken: "secret-refresh-token",
|
||||
CheckInInterval: 300,
|
||||
Tags: []string{"zeta", "alpha"},
|
||||
DisplayName: "workstation-01",
|
||||
Organization: "lab",
|
||||
OSType: "windows",
|
||||
}
|
||||
}
|
||||
17
desktop/Cargo.toml
Normal file
17
desktop/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "redflag-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
|
||||
[[bin]]
|
||||
name = "redflag-desktop"
|
||||
path = "src/main.rs"
|
||||
13
desktop/README.md
Normal file
13
desktop/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# RedFlag Desktop
|
||||
|
||||
Tauri shell for the same RedFlag app in local-agent context.
|
||||
|
||||
The desktop shell does not read protected config or state files. Its Rust backend talks to
|
||||
the existing agent local IPC surface:
|
||||
|
||||
- Linux: `/var/lib/redflag/agent/localapi/redflag-agent.sock`
|
||||
- Windows: `\\.\pipe\RedFlagAgentLocal`
|
||||
|
||||
The frontend entry is `web/index.desktop.html` and `web/src/desktop/main.tsx`.
|
||||
Fleet-server context should be added to this same app later rather than creating a second
|
||||
local-only application.
|
||||
3
desktop/build.rs
Normal file
3
desktop/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
6
desktop/package-lock.json
generated
Normal file
6
desktop/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "desktop",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
167
desktop/src/main.rs
Normal file
167
desktop/src/main.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager,
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
const LOCAL_SOCKET_PATH: &str = "/var/lib/redflag/agent/localapi/redflag-agent.sock";
|
||||
#[cfg(windows)]
|
||||
const LOCAL_PIPE_NAME: &str = r"\\.\pipe\RedFlagAgentLocal";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct LocalSnapshot {
|
||||
identity: IdentityResponse,
|
||||
status: StatusResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct IdentityResponse {
|
||||
agent_id: String,
|
||||
server_url: String,
|
||||
hostname: Option<String>,
|
||||
os_type: Option<String>,
|
||||
display_name: Option<String>,
|
||||
organization: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
agent_version: String,
|
||||
config_version: Option<String>,
|
||||
check_in_interval: i64,
|
||||
registered: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct StatusResponse {
|
||||
agent_status: String,
|
||||
last_check_in: Option<String>,
|
||||
last_updated: Option<String>,
|
||||
last_scan_time: Option<String>,
|
||||
update_count: i64,
|
||||
summary: UpdateSummary,
|
||||
scanners: Option<BTreeMap<String, ScannerState>>,
|
||||
registered: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct UpdateSummary {
|
||||
total: i64,
|
||||
by_ecosystem: Option<BTreeMap<String, i64>>,
|
||||
by_severity: Option<BTreeMap<String, i64>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ScannerState {
|
||||
name: String,
|
||||
status: String,
|
||||
last_scan_time: Option<String>,
|
||||
last_duration_ms: Option<i64>,
|
||||
last_error: Option<String>,
|
||||
update_count: i64,
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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_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())
|
||||
}
|
||||
|
||||
#[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| format!("connect local API socket {LOCAL_SOCKET_PATH}: {err}"))?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
#[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() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![local_status])
|
||||
.setup(|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 _tray = TrayIconBuilder::new()
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"show" => show_main_window(app),
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error running RedFlag desktop");
|
||||
}
|
||||
32
desktop/tauri.conf.json
Normal file
32
desktop/tauri.conf.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "RedFlag",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.redflag.local",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm --prefix ../web run dev:desktop",
|
||||
"devUrl": "http://127.0.0.1:3001",
|
||||
"beforeBuildCommand": "npm --prefix ../web run build:desktop",
|
||||
"frontendDist": "../web/dist-desktop"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "RedFlag Local Agent",
|
||||
"width": 720,
|
||||
"height": 760,
|
||||
"minWidth": 420,
|
||||
"minHeight": 560,
|
||||
"resizable": true,
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": false
|
||||
}
|
||||
}
|
||||
|
|
@ -709,6 +709,7 @@ func main() {
|
|||
dashboard.POST("/agents/:id/heartbeat", agentHandler.TriggerHeartbeat)
|
||||
dashboard.GET("/agents/:id/heartbeat", agentHandler.GetHeartbeatStatus)
|
||||
dashboard.POST("/agents/:id/reboot", agentHandler.TriggerReboot)
|
||||
dashboard.POST("/agents/:id/screenshot", agentHandler.TriggerCaptureScreenshot)
|
||||
// BUG-013: admin-initiated agent removal lives in the web-auth group
|
||||
// (not the agent-auth group above), since the caller is the dashboard
|
||||
// admin, not the agent itself.
|
||||
|
|
@ -771,6 +772,7 @@ func main() {
|
|||
// Command routes
|
||||
dashboard.GET("/commands/active", updateHandler.GetActiveCommands)
|
||||
dashboard.GET("/commands/recent", updateHandler.GetRecentCommands)
|
||||
dashboard.GET("/commands/:id", updateHandler.GetCommandByID)
|
||||
dashboard.POST("/commands/:id/retry", updateHandler.RetryCommand)
|
||||
dashboard.POST("/commands/:id/cancel", updateHandler.CancelCommand)
|
||||
dashboard.DELETE("/commands/failed", updateHandler.ClearFailedCommands)
|
||||
|
|
|
|||
|
|
@ -1782,6 +1782,47 @@ func (h *AgentHandler) TriggerReboot(c *gin.Context) {
|
|||
})
|
||||
}
|
||||
|
||||
// TriggerCaptureScreenshot sends a capture_screenshot command to an agent.
|
||||
// The agent captures its display, base64-encodes the PNG, and reports it back
|
||||
// in the command result's stdout field. The UI polls GET /commands/:id to
|
||||
// retrieve the image once the command completes.
|
||||
func (h *AgentHandler) TriggerCaptureScreenshot(c *gin.Context) {
|
||||
agentID, err := uuid.FromString(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify agent exists
|
||||
if _, err := h.agentQueries.GetAgentByID(agentID); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
|
||||
cmd := &models.AgentCommand{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentID,
|
||||
CommandType: models.CommandTypeCaptureScreenshot,
|
||||
Params: models.JSONB{},
|
||||
Status: models.CommandStatusPending,
|
||||
Source: models.CommandSourceManual,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := h.signAndCreateCommand(cmd); err != nil {
|
||||
log.Printf("[ERROR] [server] [screenshot] command_create_failed agent_id=%s error=%v", agentID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create screenshot command"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [server] [screenshot] command_created agent_id=%s command_id=%s", agentID, cmd.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "screenshot command sent",
|
||||
"command_id": cmd.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAgentConfig returns current subsystem configuration for an agent
|
||||
// GET /api/v1/agents/:id/config
|
||||
func (h *AgentHandler) GetAgentConfig(c *gin.Context) {
|
||||
|
|
|
|||
|
|
@ -2716,6 +2716,24 @@ func (h *UpdateHandler) GetActiveCommands(c *gin.Context) {
|
|||
})
|
||||
}
|
||||
|
||||
// GetCommandByID retrieves a single command by ID. Used by the UI to poll
|
||||
// for the result of a capture_screenshot command.
|
||||
func (h *UpdateHandler) GetCommandByID(c *gin.Context) {
|
||||
commandID, err := uuid.FromString(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := h.commandQueries.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "command not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, command)
|
||||
}
|
||||
|
||||
// GetRecentCommands retrieves recent commands for retry functionality
|
||||
func (h *UpdateHandler) GetRecentCommands(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ const (
|
|||
CommandTypeEnableHeartbeat = "enable_heartbeat"
|
||||
CommandTypeDisableHeartbeat = "disable_heartbeat"
|
||||
CommandTypeReboot = "reboot"
|
||||
CommandTypeCaptureScreenshot = "capture_screenshot"
|
||||
)
|
||||
|
||||
// Command statuses
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ fi
|
|||
# Variables
|
||||
AGENT_ID="{{.AgentID}}"
|
||||
AGENT_USER="redflag-agent"
|
||||
LOCAL_API_GROUP="redflag-local"
|
||||
AGENT_HOME="{{.AgentHome}}"
|
||||
BASE_DIR="/var/lib/redflag"
|
||||
CONFIG_DIR="/etc/redflag"
|
||||
|
|
@ -116,6 +117,21 @@ else
|
|||
echo "✓ User $AGENT_USER created"
|
||||
fi
|
||||
|
||||
echo "Creating local API access group..."
|
||||
if getent group "$LOCAL_API_GROUP" >/dev/null 2>&1; then
|
||||
echo "✓ Group $LOCAL_API_GROUP already exists"
|
||||
else
|
||||
sudo groupadd --system "$LOCAL_API_GROUP"
|
||||
echo "✓ Group $LOCAL_API_GROUP created"
|
||||
fi
|
||||
|
||||
if id -nG "$AGENT_USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$LOCAL_API_GROUP"; then
|
||||
echo "✓ $AGENT_USER already in $LOCAL_API_GROUP group"
|
||||
else
|
||||
sudo usermod -aG "$LOCAL_API_GROUP" "$AGENT_USER"
|
||||
echo "✓ Added $AGENT_USER to $LOCAL_API_GROUP group (local API socket ownership)"
|
||||
fi
|
||||
|
||||
# Grant docker socket access so the container scanner can reach the daemon.
|
||||
# Sudoers entries below only cover pull/inspect; IsAvailable() pings the
|
||||
# unix socket, which is root:docker. Membership is the standard pattern.
|
||||
|
|
@ -137,16 +153,21 @@ if [ ! -d "$AGENT_HOME" ]; then
|
|||
sudo mkdir -p "$AGENT_HOME"
|
||||
sudo mkdir -p "$AGENT_HOME/cache"
|
||||
sudo mkdir -p "$AGENT_HOME/state"
|
||||
sudo mkdir -p "$AGENT_HOME/localapi"
|
||||
sudo mkdir -p "$AGENT_CONFIG_DIR"
|
||||
sudo mkdir -p "$SERVER_KEY_DIR"
|
||||
sudo mkdir -p "$AGENT_LOG_DIR"
|
||||
|
||||
# Set ownership and permissions
|
||||
sudo chown -R "$AGENT_USER:$AGENT_USER" "$BASE_DIR"
|
||||
sudo chmod 750 "$BASE_DIR"
|
||||
sudo chmod 750 "$AGENT_HOME"
|
||||
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$BASE_DIR"
|
||||
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$AGENT_HOME"
|
||||
sudo chmod 710 "$BASE_DIR"
|
||||
sudo chmod 710 "$AGENT_HOME"
|
||||
sudo chmod 750 "$AGENT_HOME/cache"
|
||||
sudo chmod 750 "$AGENT_HOME/state"
|
||||
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$AGENT_HOME/localapi"
|
||||
sudo chmod 750 "$AGENT_HOME/localapi"
|
||||
sudo chmod 755 "$AGENT_CONFIG_DIR"
|
||||
sudo chown "$AGENT_USER:$AGENT_USER" "$SERVER_KEY_DIR"
|
||||
sudo chmod 755 "$SERVER_KEY_DIR"
|
||||
|
|
@ -686,6 +707,7 @@ StartLimitIntervalSec=60
|
|||
Type=simple
|
||||
User={{.AgentUser}}
|
||||
Group={{.AgentUser}}
|
||||
SupplementaryGroups=${LOCAL_API_GROUP}
|
||||
WorkingDirectory={{.AgentHome}}
|
||||
ExecStart=${INSTALL_DIR}/${SERVICE_NAME}
|
||||
Restart=always
|
||||
|
|
@ -727,7 +749,13 @@ sudo chown -R {{.AgentUser}}:{{.AgentUser}} "{{.AgentConfigDir}}"
|
|||
sudo chown {{.AgentUser}}:{{.AgentUser}} "{{.AgentConfigDir}}/config.json"
|
||||
sudo chmod 600 "{{.AgentConfigDir}}/config.json"
|
||||
sudo chown -R {{.AgentUser}}:{{.AgentUser}} "{{.AgentHome}}"
|
||||
sudo chmod 750 "{{.AgentHome}}"
|
||||
sudo chown {{.AgentUser}}:${LOCAL_API_GROUP} "${BASE_DIR}"
|
||||
sudo chown {{.AgentUser}}:${LOCAL_API_GROUP} "{{.AgentHome}}"
|
||||
sudo chmod 710 "${BASE_DIR}"
|
||||
sudo chmod 710 "{{.AgentHome}}"
|
||||
sudo mkdir -p "{{.AgentHome}}/localapi"
|
||||
sudo chown {{.AgentUser}}:${LOCAL_API_GROUP} "{{.AgentHome}}/localapi"
|
||||
sudo chmod 750 "{{.AgentHome}}/localapi"
|
||||
sudo chown -R {{.AgentUser}}:{{.AgentUser}} "{{.AgentLogDir}}"
|
||||
sudo chmod 750 "{{.AgentLogDir}}"
|
||||
# Server public key directory - agent needs to write the TOFU cached key here
|
||||
|
|
@ -810,6 +838,7 @@ if systemctl is-active --quiet ${SERVICE_NAME}; then
|
|||
echo "=== Security Information ==="
|
||||
echo "Agent is running with security hardening:"
|
||||
echo " ✓ Dedicated system user: {{.AgentUser}}"
|
||||
echo " ✓ Local API group: ${LOCAL_API_GROUP}"
|
||||
echo " ✓ Limited sudo access for package management only"
|
||||
echo " ✓ Systemd service with security restrictions"
|
||||
echo " ✓ Protected configuration directory"
|
||||
|
|
|
|||
|
|
@ -128,6 +128,81 @@ function Repair-AgentConfigAccessIfNeeded {
|
|||
}
|
||||
}
|
||||
|
||||
function Ensure-LocalApiGroup {
|
||||
param([Parameter(Mandatory=$true)][string]$GroupName)
|
||||
|
||||
Write-Host "Ensuring local API access group..." -ForegroundColor Yellow
|
||||
|
||||
$LocalGroupCommandsAvailable = $null -ne (Get-Command Get-LocalGroup -ErrorAction SilentlyContinue) -and
|
||||
$null -ne (Get-Command New-LocalGroup -ErrorAction SilentlyContinue) -and
|
||||
$null -ne (Get-Command Add-LocalGroupMember -ErrorAction SilentlyContinue)
|
||||
|
||||
if ($LocalGroupCommandsAvailable) {
|
||||
$Group = Get-LocalGroup -Name $GroupName -ErrorAction SilentlyContinue
|
||||
if (-not $Group) {
|
||||
New-LocalGroup -Name $GroupName -Description "RedFlag local agent API access" | Out-Null
|
||||
Write-Host "✓ Group $GroupName created" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "✓ Group $GroupName already exists" -ForegroundColor Green
|
||||
}
|
||||
|
||||
$CurrentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if ($CurrentIdentity.User.Value -eq "S-1-5-18") {
|
||||
Write-Host "[INFO] [installer] [localapi] Running as LocalSystem; no interactive user added to $GroupName" -ForegroundColor Gray
|
||||
return
|
||||
}
|
||||
|
||||
$CurrentUser = $CurrentIdentity.Name
|
||||
try {
|
||||
Add-LocalGroupMember -Group $GroupName -Member $CurrentUser -ErrorAction Stop
|
||||
Write-Host "✓ Added $CurrentUser to $GroupName" -ForegroundColor Green
|
||||
Write-Host "[INFO] [installer] [localapi] Sign out and back in if non-elevated local API access is not immediately available" -ForegroundColor Gray
|
||||
} catch {
|
||||
if ($_.Exception.Message -match "already.*member|already.*exists") {
|
||||
Write-Host "✓ $CurrentUser already in $GroupName" -ForegroundColor Green
|
||||
} else {
|
||||
throw
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# Windows PowerShell fallback for hosts without Microsoft.PowerShell.LocalAccounts.
|
||||
$ComputerName = $env:COMPUTERNAME
|
||||
try {
|
||||
$Group = [ADSI]"WinNT://$ComputerName/$GroupName,group"
|
||||
$null = $Group.Name
|
||||
Write-Host "✓ Group $GroupName already exists" -ForegroundColor Green
|
||||
} catch {
|
||||
$Machine = [ADSI]"WinNT://$ComputerName"
|
||||
$Group = $Machine.Create("group", $GroupName)
|
||||
$Group.SetInfo()
|
||||
$Group.Description = "RedFlag local agent API access"
|
||||
$Group.SetInfo()
|
||||
Write-Host "✓ Group $GroupName created" -ForegroundColor Green
|
||||
}
|
||||
|
||||
$CurrentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if ($CurrentIdentity.User.Value -eq "S-1-5-18") {
|
||||
Write-Host "[INFO] [installer] [localapi] Running as LocalSystem; no interactive user added to $GroupName" -ForegroundColor Gray
|
||||
return
|
||||
}
|
||||
|
||||
$CurrentUser = $CurrentIdentity.Name
|
||||
$MemberPath = "WinNT://" + $CurrentUser.Replace("\", "/")
|
||||
try {
|
||||
$Group.Add($MemberPath)
|
||||
Write-Host "✓ Added $CurrentUser to $GroupName" -ForegroundColor Green
|
||||
Write-Host "[INFO] [installer] [localapi] Sign out and back in if non-elevated local API access is not immediately available" -ForegroundColor Gray
|
||||
} catch {
|
||||
if ($_.Exception.Message -match "already.*member|The object already exists") {
|
||||
Write-Host "✓ $CurrentUser already in $GroupName" -ForegroundColor Green
|
||||
} else {
|
||||
throw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Runtime admin check for better error messaging
|
||||
if (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
|
||||
Write-Error "This installer must be run as Administrator."
|
||||
|
|
@ -155,6 +230,7 @@ $AgentConfigDir = "C:\ProgramData\RedFlag\agent"
|
|||
$ServerKeyDir = "C:\ProgramData\RedFlag\server"
|
||||
$OldConfigDir = "C:\ProgramData\Aggregator"
|
||||
$ServiceName = "RedFlagAgent"
|
||||
$LocalApiGroup = "RedFlagLocal"
|
||||
$Version = "{{.Version}}"
|
||||
$Timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$BackupDir = Join-Path $ConfigDir "backups\backup.$Timestamp"
|
||||
|
|
@ -273,6 +349,8 @@ New-Item -ItemType Directory -Force -Path "$ConfigDir\backups" | Out-Null
|
|||
New-Item -ItemType Directory -Force -Path "$ConfigDir\state" | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path "$ConfigDir\logs" | Out-Null
|
||||
|
||||
Ensure-LocalApiGroup -GroupName $LocalApiGroup
|
||||
|
||||
# Step 3: Download agent binary
|
||||
Write-Host "Downloading agent binary..." -ForegroundColor Yellow
|
||||
$BinaryPath = Join-Path $InstallDir "redflag-agent.exe"
|
||||
|
|
|
|||
13
web/index.desktop.html
Normal file
13
web/index.desktop.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!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>
|
||||
|
|
@ -5,13 +5,16 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:desktop": "vite --config vite.desktop.config.ts --host 127.0.0.1",
|
||||
"build": "tsc && vite build",
|
||||
"build:desktop": "tsc && vite build --config vite.desktop.config.ts",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"axios": "^1.15.0",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
|
|
@ -29,6 +32,7 @@
|
|||
"@types/react-dom": "^18.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
|
|
|
|||
253
web/src/desktop/LocalAgentApp.tsx
Normal file
253
web/src/desktop/LocalAgentApp.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Monitor,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type HealthState = 'healthy' | 'warning' | 'error'
|
||||
|
||||
const LocalAgentApp: React.FC = () => {
|
||||
const [snapshot, setSnapshot] = useState<LocalSnapshot | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const next = await invoke<LocalSnapshot>('local_status')
|
||||
setSnapshot(next)
|
||||
setLastRefresh(new Date())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const id = window.setInterval(load, 5000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
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') return 'warning'
|
||||
const critical = snapshot.status.summary.by_severity?.critical ?? 0
|
||||
return critical > 0 ? 'warning' : 'healthy'
|
||||
}, [error, snapshot])
|
||||
|
||||
const scanners = useMemo(() => {
|
||||
if (!snapshot?.status.scanners) return []
|
||||
return Object.values(snapshot.status.scanners).sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [snapshot])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<header className="border-b border-gray-200 bg-white">
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-md',
|
||||
health === 'healthy' && 'bg-success-50 text-success-700',
|
||||
health === 'warning' && 'bg-warning-50 text-warning-700',
|
||||
health === 'error' && 'bg-danger-50 text-danger-700',
|
||||
)}>
|
||||
{health === 'healthy' ? <ShieldCheck className="h-5 w-5" /> : health === 'warning' ? <AlertTriangle className="h-5 w-5" /> : <WifiOff className="h-5 w-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold leading-tight">RedFlag Local Agent</h1>
|
||||
<p className="text-xs text-gray-500">{snapshot?.identity.hostname || 'Local machine'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="px-5 py-4">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md border border-danger-200 bg-danger-50 px-3 py-2 text-sm text-danger-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Metric
|
||||
icon={Server}
|
||||
label="Fleet"
|
||||
value={snapshot?.identity.registered ? 'Bound' : 'Unbound'}
|
||||
tone={snapshot?.identity.registered ? 'success' : 'warning'}
|
||||
/>
|
||||
<Metric
|
||||
icon={Package}
|
||||
label="Updates"
|
||||
value={String(snapshot?.status.update_count ?? 0)}
|
||||
tone={(snapshot?.status.update_count ?? 0) > 0 ? 'warning' : 'success'}
|
||||
/>
|
||||
<Metric
|
||||
icon={Clock}
|
||||
label="Check-in"
|
||||
value={formatMaybeRelative(snapshot?.status.last_check_in)}
|
||||
/>
|
||||
<Metric
|
||||
icon={Activity}
|
||||
label="Scan"
|
||||
value={formatMaybeRelative(snapshot?.status.last_scan_time)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-gray-900">Identity</h2>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[112px_1fr] gap-x-3 gap-y-2 px-4 py-3 text-sm">
|
||||
<dt className="text-gray-500">Agent</dt>
|
||||
<dd className="truncate font-mono text-xs text-gray-900">{snapshot?.identity.agent_id || '-'}</dd>
|
||||
<dt className="text-gray-500">Server</dt>
|
||||
<dd className="truncate text-gray-900">{snapshot?.identity.server_url || '-'}</dd>
|
||||
<dt className="text-gray-500">Version</dt>
|
||||
<dd className="text-gray-900">{snapshot?.identity.agent_version || '-'}</dd>
|
||||
<dt className="text-gray-500">Platform</dt>
|
||||
<dd className="text-gray-900">{snapshot?.identity.os_type || '-'}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-gray-200 bg-white">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-gray-900">Scanners</h2>
|
||||
<span className="text-xs text-gray-500">{scanners.length}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{scanners.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500">No scanner state reported</div>
|
||||
) : (
|
||||
scanners.map((scanner) => (
|
||||
<div key={scanner.name} className="flex items-center gap-3 px-4 py-3">
|
||||
<ScannerIcon status={scanner.status} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-gray-900">{scanner.name}</p>
|
||||
<span className="text-xs text-gray-500">{scanner.update_count} updates</span>
|
||||
</div>
|
||||
<p className={cn('truncate text-xs', scanner.last_error ? 'text-danger-700' : 'text-gray-500')}>
|
||||
{scanner.last_error || formatMaybeRelative(scanner.last_scan_time)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="mt-4 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{lastRefresh ? `Updated ${formatRelativeTime(lastRefresh.toISOString())}` : 'Awaiting status'}</span>
|
||||
<span>{snapshot?.status.agent_status || 'unknown'}</span>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
interface MetricProps {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: string
|
||||
tone?: 'success' | 'warning' | 'neutral'
|
||||
}
|
||||
|
||||
const Metric: React.FC<MetricProps> = ({ icon: Icon, label, value, tone = 'neutral' }) => (
|
||||
<div className="rounded-md border border-gray-200 bg-white p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Icon className="h-4 w-4 text-gray-500" />
|
||||
<span className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
tone === 'success' && 'bg-success-500',
|
||||
tone === 'warning' && 'bg-warning-500',
|
||||
tone === 'neutral' && 'bg-gray-300',
|
||||
)} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-gray-900">{value}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ScannerIcon: React.FC<{ status: string }> = ({ status }) => {
|
||||
if (status === 'success') {
|
||||
return <CheckCircle2 className="h-4 w-4 flex-shrink-0 text-success-600" />
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return <AlertTriangle className="h-4 w-4 flex-shrink-0 text-danger-600" />
|
||||
}
|
||||
return <Monitor className="h-4 w-4 flex-shrink-0 text-gray-400" />
|
||||
}
|
||||
|
||||
function formatMaybeRelative(value?: string): string {
|
||||
if (!value || value.startsWith('0001-')) return '-'
|
||||
return formatRelativeTime(value)
|
||||
}
|
||||
|
||||
export default LocalAgentApp
|
||||
10
web/src/desktop/main.tsx
Normal file
10
web/src/desktop/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import LocalAgentApp from './LocalAgentApp'
|
||||
import '../index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<LocalAgentApp />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { updateApi } from '@/lib/api';
|
||||
import { updateApi, agentApi } from '@/lib/api';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
interface ActiveCommand {
|
||||
|
|
@ -72,4 +72,35 @@ export const useClearFailedCommands = (): UseMutationResult<{ message: string; c
|
|||
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Trigger a screenshot capture on an agent. Returns the command ID.
|
||||
export const useCaptureScreenshot = (): UseMutationResult<{ message: string; command_id: string }, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (agentId: string) => agentApi.captureScreenshot(agentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Poll a specific command until it completes. Used to retrieve the
|
||||
// screenshot image from the command result's stdout field.
|
||||
export const useCommand = (commandId: string | null, enabled: boolean = true): UseQueryResult<any, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['command', commandId],
|
||||
queryFn: () => agentApi.getCommand(commandId!),
|
||||
enabled: !!commandId && enabled,
|
||||
refetchInterval: (query: any) => {
|
||||
// Stop polling once the command reaches a terminal state
|
||||
const status = query.state.data?.status;
|
||||
if (status === 'completed' || status === 'failed' || status === 'timed_out' || status === 'cancelled') {
|
||||
return false;
|
||||
}
|
||||
return 2000; // Poll every 2 seconds while in flight
|
||||
},
|
||||
staleTime: 0,
|
||||
});
|
||||
};
|
||||
|
|
@ -154,6 +154,18 @@ export const agentApi = {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
// Trigger screenshot capture on an agent
|
||||
captureScreenshot: async (agentId: string): Promise<{ message: string; command_id: string }> => {
|
||||
const response = await api.post(`/agents/${agentId}/screenshot`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get a single command by ID (used to poll screenshot result)
|
||||
getCommand: async (commandId: string): Promise<any> => {
|
||||
const response = await api.get(`/commands/${commandId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Trigger agent reboot
|
||||
rebootAgent: async (id: string, delayMinutes: number = 1, message?: string): Promise<void> => {
|
||||
await api.post(`/agents/${id}/reboot`, {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
import { SearchInput, FilterDropdown } from '@/components/primitives';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands';
|
||||
import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand } from '@/hooks/useCommands';
|
||||
import { useHeartbeatStatus } from '@/hooks/useHeartbeat';
|
||||
import { agentApi } from '@/lib/api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
|
@ -40,7 +40,7 @@ import { AgentUpdatesModal } from '@/components/AgentUpdatesModal';
|
|||
import { BulkAgentUpdate } from '@/components/RelayList';
|
||||
import ChatTimeline from '@/components/ChatTimeline';
|
||||
import AgentSoftwareBindings from '@/components/AgentSoftwareBindings';
|
||||
import { AgentIntegrations } from '@/components/AgentIntegrations';
|
||||
import { readIntegrations, resolveState } from '@/types/integrations';
|
||||
|
||||
type AgentDetailTab = 'overview' | 'storage' | 'updates' | 'software' | 'scanners' | 'history';
|
||||
|
||||
|
|
@ -66,6 +66,7 @@ const Agents: React.FC = () => {
|
|||
const [heartbeatLoading, setHeartbeatLoading] = useState(false); // Loading state for heartbeat toggle
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false); // Update modal state
|
||||
const [singleAgentUpdate, setSingleAgentUpdate] = useState<string | null>(null); // Single agent update modal
|
||||
const [screenshotCommandId, setScreenshotCommandId] = useState<string | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
|
|
@ -228,7 +229,10 @@ const Agents: React.FC = () => {
|
|||
const { data: activeCommandsData, refetch: refetchActiveCommands } = useActiveCommands();
|
||||
const cancelCommandMutation = useCancelCommand();
|
||||
|
||||
|
||||
// Screenshot capture
|
||||
const captureScreenshotMutation = useCaptureScreenshot();
|
||||
const { data: screenshotCommand } = useCommand(screenshotCommandId, !!screenshotCommandId);
|
||||
|
||||
const agents = agentsData?.agents || [];
|
||||
const selectedAgent = selectedAgentData || agents.find(a => a.id === id);
|
||||
|
||||
|
|
@ -323,6 +327,22 @@ const Agents: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Handle screenshot capture
|
||||
const handleCaptureScreenshot = async (agentId: string) => {
|
||||
try {
|
||||
const result = await captureScreenshotMutation.mutateAsync(agentId);
|
||||
setScreenshotCommandId(result.command_id);
|
||||
toast.success('Screenshot capture requested');
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to capture screenshot: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Extract base64 screenshot from command result
|
||||
const screenshotImage = screenshotCommand?.status === 'completed'
|
||||
? screenshotCommand?.result?.stdout || null
|
||||
: null;
|
||||
|
||||
// Handle rapid polling toggle
|
||||
const handleRapidPollingToggle = async (agentId: string, enabled: boolean, durationMinutes?: number) => {
|
||||
// Prevent multiple clicks
|
||||
|
|
@ -796,8 +816,93 @@ const Agents: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Integrations — observed by the agent, rendered above system info */}
|
||||
<AgentIntegrations metadata={selectedAgent.metadata} />
|
||||
{/* Screen square — screenshot capture / Sunshine connection link */}
|
||||
{(() => {
|
||||
const integrations = readIntegrations(selectedAgent.metadata);
|
||||
const sunshine = integrations.sunshine;
|
||||
const state = resolveState(sunshine);
|
||||
const live = state === 'active';
|
||||
const running = state === 'running';
|
||||
const sunshineReady = live || running;
|
||||
|
||||
const isCapturing = captureScreenshotMutation.isPending;
|
||||
const isPolling = screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status);
|
||||
|
||||
// Determine what the square shows
|
||||
const hasImage = !!screenshotImage;
|
||||
const squareClickable = sunshineReady || !isCapturing;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div
|
||||
className={cn(
|
||||
'relative aspect-video w-full max-w-sm overflow-hidden rounded border',
|
||||
'bg-gradient-to-br from-slate-800 to-slate-900 border-slate-700',
|
||||
'flex flex-col items-center justify-center gap-2 text-slate-400',
|
||||
squareClickable && 'cursor-pointer hover:from-slate-700 hover:to-slate-800 transition-colors'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (sunshineReady && sunshine?.web_ui) {
|
||||
window.open(sunshine.web_ui, '_blank', 'noreferrer');
|
||||
} else if (!isCapturing && !isPolling) {
|
||||
handleCaptureScreenshot(selectedAgent.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasImage ? (
|
||||
<img
|
||||
src={`data:image/png;base64,${screenshotImage}`}
|
||||
alt="Agent screenshot"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<MonitorPlay className="h-10 w-10 opacity-70" />
|
||||
<span className="text-xs">
|
||||
{isCapturing ? 'Requesting...'
|
||||
: isPolling ? 'Capturing...'
|
||||
: sunshineReady ? 'Open stream host'
|
||||
: 'Click to capture'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* LIVE marker when Sunshine session is active */}
|
||||
{live && (
|
||||
<span className="absolute top-2 left-2 flex items-center gap-1 rounded bg-red-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-white animate-pulse" />
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Sunshine status badge */}
|
||||
{sunshineReady && (
|
||||
<span className="absolute top-2 right-2 rounded bg-black/50 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
{live ? sunshine?.client_name || 'Connected' : 'Sunshine running'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Loading spinner overlay */}
|
||||
{(isCapturing || isPolling) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<RefreshCw className="h-8 w-8 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Caption row beneath the square */}
|
||||
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500">
|
||||
{sunshine?.version && <span>Sunshine v{sunshine.version}</span>}
|
||||
{screenshotCommand?.status === 'completed' && screenshotCommand?.completed_at && (
|
||||
<span className="ml-auto">Captured {formatRelativeTime(screenshotCommand.completed_at)}</span>
|
||||
)}
|
||||
{screenshotCommand?.status === 'failed' && (
|
||||
<span className="text-red-500 ml-auto">Capture failed</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* System info */}
|
||||
<div className="card">
|
||||
|
|
|
|||
23
web/vite.desktop.config.ts
Normal file
23
web/vite.desktop.config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist-desktop",
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: path.resolve(__dirname, "index.desktop.html"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue