projection: begin the exact-path public epoch
The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally. Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
13
agent/NOTICE
Normal file
13
agent/NOTICE
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
RedFlag Agent
|
||||
Copyright 2024-2025
|
||||
|
||||
This software includes code from the following third-party projects:
|
||||
|
||||
---
|
||||
|
||||
windowsupdate
|
||||
Copyright 2022 Zheng Dayu
|
||||
Licensed under the Apache License, Version 2.0
|
||||
https://github.com/ceshihao/windowsupdate
|
||||
|
||||
Included in: agent/pkg/windowsupdate/
|
||||
165
agent/cmd/agent/cli.go
Normal file
165
agent/cmd/agent/cli.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/service"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
)
|
||||
|
||||
// CLIFlags holds all command-line flags
|
||||
type CLI struct {
|
||||
Register bool
|
||||
Scan bool
|
||||
Status bool
|
||||
LocalStatus bool
|
||||
InitStandalone 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
|
||||
func ParseFlags() *CLI {
|
||||
cli := &CLI{}
|
||||
|
||||
// Define CLI flags
|
||||
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.InitStandalone, "init-standalone", false, "Create or print this host's standalone Agent identity")
|
||||
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")
|
||||
flag.StringVar(&cli.Token, "token", "", "Registration token for secure enrollment")
|
||||
flag.StringVar(&cli.ProxyHTTP, "proxy-http", "", "HTTP proxy URL")
|
||||
flag.StringVar(&cli.ProxyHTTPS, "proxy-https", "", "HTTPS proxy URL")
|
||||
flag.StringVar(&cli.ProxyNoProxy, "proxy-no", "", "Comma-separated hosts to bypass proxy")
|
||||
flag.StringVar(&cli.LogLevel, "log-level", "", "Log level (debug, info, warn, error)")
|
||||
flag.StringVar(&cli.ConfigFile, "config", "", "Configuration file path")
|
||||
flag.StringVar(&cli.Tags, "tags", "", "Comma-separated tags for agent")
|
||||
flag.StringVar(&cli.Organization, "organization", "", "Organization/group name")
|
||||
flag.StringVar(&cli.DisplayName, "name", "", "Display name for agent")
|
||||
flag.BoolVar(&cli.InsecureTLS, "insecure-tls", false, "Skip TLS certificate verification")
|
||||
flag.StringVar(&cli.ExportFormat, "export", "", "Export format: json, csv")
|
||||
|
||||
// Windows service management commands
|
||||
flag.BoolVar(&cli.InstallService, "install-service", false, "Install as Windows service")
|
||||
flag.BoolVar(&cli.RemoveService, "remove-service", false, "Remove Windows service")
|
||||
flag.BoolVar(&cli.StartService, "start-service", false, "Start Windows service")
|
||||
flag.BoolVar(&cli.StopService, "stop-service", false, "Stop Windows service")
|
||||
flag.BoolVar(&cli.ServiceStatus, "service-status", false, "Show Windows service status")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
return cli
|
||||
}
|
||||
|
||||
// HandleVersionCommand handles the version display command
|
||||
func HandleVersionCommand() {
|
||||
fmt.Printf("RedFlag Agent v%s\n", version.Version)
|
||||
fmt.Printf("Self-hosted update management platform\n")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// HandleWindowsServiceCommands handles Windows service management commands
|
||||
func HandleWindowsServiceCommands(cli *CLI) bool {
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case cli.InstallService:
|
||||
if err := service.InstallService(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to install service: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("RedFlag service installed successfully")
|
||||
os.Exit(0)
|
||||
|
||||
case cli.RemoveService:
|
||||
if err := service.RemoveService(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to remove service: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("RedFlag service removed successfully")
|
||||
os.Exit(0)
|
||||
|
||||
case cli.StartService:
|
||||
if err := service.StartService(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to start service: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("RedFlag service started successfully")
|
||||
os.Exit(0)
|
||||
|
||||
case cli.StopService:
|
||||
if err := service.StopService(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to stop service: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("RedFlag service stopped successfully")
|
||||
os.Exit(0)
|
||||
|
||||
case cli.ServiceStatus:
|
||||
if err := service.ServiceStatus(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to get service status: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseTags parses tags from comma-separated string
|
||||
func ParseTags(tagsStr string) []string {
|
||||
if tagsStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tags := strings.Split(tagsStr, ",")
|
||||
for i, tag := range tags {
|
||||
tags[i] = strings.TrimSpace(tag)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// ToConfigFlags converts CLI flags to config.CLIFlags
|
||||
func (cli *CLI) ToConfigFlags() *config.CLIFlags {
|
||||
return &config.CLIFlags{
|
||||
ServerURL: cli.ServerURL,
|
||||
RegistrationToken: cli.Token,
|
||||
ProxyHTTP: cli.ProxyHTTP,
|
||||
ProxyHTTPS: cli.ProxyHTTPS,
|
||||
ProxyNoProxy: cli.ProxyNoProxy,
|
||||
LogLevel: cli.LogLevel,
|
||||
ConfigFile: cli.ConfigFile,
|
||||
Tags: ParseTags(cli.Tags),
|
||||
Organization: cli.Organization,
|
||||
DisplayName: cli.DisplayName,
|
||||
InsecureTLS: cli.InsecureTLS,
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
238
agent/cmd/agent/main.go
Normal file
238
agent/cmd/agent/main.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/agent"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/handlers"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/instancelock"
|
||||
agentLogging "github.com/Fimeg/RedFlag/agent/internal/logging"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/migration"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/registration"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/service"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := agentLogging.ConfigureProcessLogger(); err != nil {
|
||||
log.Printf("[ERROR] [agent] [logging] process_logger_init_failed error=%v", err)
|
||||
}
|
||||
|
||||
// Panic recovery - prevents agent crashes from unhandled panics
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[CRITICAL] Agent panic recovered: %v", r)
|
||||
log.Printf("[CRITICAL] Stack trace: %s", debug.Stack())
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
// Parse CLI flags
|
||||
cli := ParseFlags()
|
||||
|
||||
// Handle version command
|
||||
if cli.Version {
|
||||
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
|
||||
}
|
||||
|
||||
// Determine config path
|
||||
configPath := constants.GetAgentConfigPath()
|
||||
if cli.ConfigFile != "" {
|
||||
configPath = cli.ConfigFile
|
||||
}
|
||||
|
||||
// Check for migration requirements
|
||||
if err := handleMigration(configPath); err != nil {
|
||||
log.Printf("Warning: Migration handling failed: %v", err)
|
||||
}
|
||||
|
||||
// Load configuration with priority: CLI > env > file > defaults
|
||||
cfg, err := config.Load(configPath, cli.ToConfigFlags())
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load configuration:", err)
|
||||
}
|
||||
|
||||
// Update agent version in config if changed
|
||||
if cfg.AgentVersion != version.Version {
|
||||
cfg.AgentVersion = version.Version
|
||||
if err := cfg.Save(configPath); err != nil {
|
||||
log.Printf("Warning: Failed to update agent version in config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cli.InitStandalone {
|
||||
if err := cfg.InitializeStandalone(); err != nil {
|
||||
log.Fatal("Standalone initialization failed: ", err)
|
||||
}
|
||||
if err := cfg.Save(configPath); err != nil {
|
||||
log.Fatal("Standalone configuration save failed: ", err)
|
||||
}
|
||||
fmt.Println(cfg.AgentID.String())
|
||||
return
|
||||
}
|
||||
|
||||
// Handle registration command
|
||||
if cli.Register {
|
||||
if cfg.IsStandalone() {
|
||||
log.Fatal("Registration refused: standalone fleet join is not implemented; do not add fleet credentials beside local authority")
|
||||
}
|
||||
if err := handleRegistration(cfg, cli.ServerURL); err != nil {
|
||||
log.Fatal("Registration failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle scan command
|
||||
if cli.Scan {
|
||||
if err := handlers.ScanCommand(cfg, cli.ExportFormat); err != nil {
|
||||
log.Fatal("Scan failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle status command
|
||||
if cli.Status {
|
||||
if err := handlers.StatusCommand(cfg); err != nil {
|
||||
log.Fatal("Status command failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle list-updates command
|
||||
if cli.ListUpdates {
|
||||
if err := handlers.ListUpdatesCommand(cfg, cli.ExportFormat); err != nil {
|
||||
log.Fatal("List updates failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Acquire an exclusive instance lock to prevent two agent processes
|
||||
// from sharing the same config.json and renewal state. The lock is
|
||||
// released when this process exits (fd closes on os.Exit too).
|
||||
unlock, err := instancelock.Acquire()
|
||||
if err != nil {
|
||||
log.Fatalf("[FATAL] instance_lock_failed another_instance_running error=%v", err)
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
// Check if registered
|
||||
if !cfg.IsRegistered() && !cfg.IsStandalone() {
|
||||
log.Fatal("Agent has no complete identity. Register with a fleet or run the standalone provisioning script.")
|
||||
}
|
||||
|
||||
// Check if running as Windows service
|
||||
if runtime.GOOS == "windows" && service.IsService() {
|
||||
if err := service.RunService(cfg); err != nil {
|
||||
log.Fatal("Service failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Start agent service (console mode)
|
||||
if err := agent.RunAgentLoop(cfg); err != nil {
|
||||
log.Fatal("Agent failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleMigration checks and executes migrations if needed
|
||||
func handleMigration(configPath string) error {
|
||||
migrationConfig := migration.NewFileDetectionConfig()
|
||||
migrationConfig.OldConfigPath = constants.LegacyConfigPath
|
||||
migrationConfig.OldStatePath = constants.LegacyStatePath
|
||||
migrationConfig.NewConfigPath = constants.GetAgentConfigDir()
|
||||
migrationConfig.NewStatePath = constants.GetAgentStateDir()
|
||||
|
||||
migrationDetection, err := migration.DetectMigrationRequirements(migrationConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to detect migration requirements: %w", err)
|
||||
}
|
||||
|
||||
if !migrationDetection.RequiresMigration {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[RedFlag Server Migrator] Migration detected: %s → %s",
|
||||
migrationDetection.CurrentAgentVersion, version.Version)
|
||||
log.Printf("[RedFlag Server Migrator] Required migrations: %v",
|
||||
migrationDetection.RequiredMigrations)
|
||||
|
||||
migrationPlan := &migration.MigrationPlan{
|
||||
Detection: migrationDetection,
|
||||
TargetVersion: version.Version,
|
||||
Config: migrationConfig,
|
||||
BackupPath: constants.GetMigrationBackupDir(),
|
||||
}
|
||||
|
||||
executor := migration.NewMigrationExecutor(migrationPlan, configPath)
|
||||
result, err := executor.ExecuteMigration()
|
||||
if err != nil {
|
||||
log.Printf("[RedFlag Server Migrator] Migration failed: %v", err)
|
||||
log.Printf("[RedFlag Server Migrator] Backup available at: %s", result.BackupPath)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("[RedFlag Server Migrator] Migration completed successfully")
|
||||
if result.RollbackAvailable {
|
||||
log.Printf("[RedFlag Server Migrator] Rollback available at: %s", result.BackupPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRegistration handles the agent registration flow
|
||||
func handleRegistration(cfg *config.Config, serverURL string) error {
|
||||
// Validate server URL for Windows users
|
||||
if runtime.GOOS == "windows" && serverURL == "" {
|
||||
fmt.Println("❌ CONFIGURATION REQUIRED!")
|
||||
fmt.Println("==================================================================")
|
||||
fmt.Println("Please configure the server URL before registering:")
|
||||
fmt.Println("")
|
||||
fmt.Println("Option 1 - Use the -server flag:")
|
||||
fmt.Println(" redflag-agent.exe -register -server https://your-server.com")
|
||||
fmt.Println("")
|
||||
fmt.Println("Option 2 - Use environment variable:")
|
||||
fmt.Println(" set REDFLAG_SERVER_URL=https://your-server.com")
|
||||
fmt.Println(" redflag-agent.exe -register")
|
||||
fmt.Println("")
|
||||
fmt.Println("Option 3 - Create a .env file:")
|
||||
fmt.Println(" REDFLAG_SERVER_URL=https://your-server.com")
|
||||
fmt.Println("==================================================================")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Use registration package for the actual registration
|
||||
if err := registration.RegisterAgent(cfg, serverURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("==================================================================")
|
||||
fmt.Println("🎉 AGENT REGISTRATION SUCCESSFUL!")
|
||||
fmt.Println("==================================================================")
|
||||
fmt.Printf("📋 Agent ID: %s\n", cfg.AgentID)
|
||||
fmt.Printf("🌐 Server: %s\n", cfg.ServerURL)
|
||||
fmt.Printf("⏱️ Check-in Interval: %ds\n", cfg.CheckInInterval)
|
||||
fmt.Println("==================================================================")
|
||||
fmt.Println("💡 Save this Agent ID for your records!")
|
||||
fmt.Println("🚀 You can now start the agent without flags")
|
||||
fmt.Println("")
|
||||
|
||||
return nil
|
||||
}
|
||||
82
agent/cmd/ethos_emoji_test.go
Normal file
82
agent/cmd/ethos_emoji_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package main
|
||||
|
||||
// ethos_emoji_test.go — Tests for emoji in agent main.go log statements.
|
||||
// D-2 FIXED: emoji removed from token renewal and install result log paths.
|
||||
// EXCLUDES: registration CLI output and startup banner (exempt).
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func hasEmojiRune(s string) bool {
|
||||
for _, r := range s {
|
||||
if r >= 0x1F300 || (r >= 0x2600 && r <= 0x27BF) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isExemptLine checks if a line number falls in an exempt range.
|
||||
// Exempt ranges are user-facing CLI output (registration, startup banner).
|
||||
func isExemptLine(lineNum int) bool {
|
||||
// Registration CLI output: ~lines 294-322
|
||||
if lineNum >= 290 && lineNum <= 330 {
|
||||
return true
|
||||
}
|
||||
// Startup banner: ~lines 691-700
|
||||
if lineNum >= 685 && lineNum <= 705 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestMainGoHasEmojiInLogStatements(t *testing.T) {
|
||||
// POST-FIX: No emoji in non-exempt log statements.
|
||||
content, err := os.ReadFile("agent/main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read agent/main.go: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
|
||||
emojiLogCount := 0
|
||||
for i, line := range lines {
|
||||
if isExemptLine(i + 1) {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
isLog := strings.Contains(trimmed, "log.Printf") || strings.Contains(trimmed, "log.Println")
|
||||
if isLog && hasEmojiRune(trimmed) {
|
||||
emojiLogCount++
|
||||
}
|
||||
}
|
||||
|
||||
if emojiLogCount > 0 {
|
||||
t.Errorf("[ERROR] [agent] [main] D-2 NOT FIXED: %d non-exempt log statements with emoji", emojiLogCount)
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [main] D-2 FIXED: no emoji in non-exempt log statements")
|
||||
}
|
||||
|
||||
func TestMainGoLogStatementsHaveNoEmoji(t *testing.T) {
|
||||
content, err := os.ReadFile("agent/main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read agent/main.go: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
|
||||
for i, line := range lines {
|
||||
if isExemptLine(i + 1) {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
isLog := strings.Contains(trimmed, "log.Printf") || strings.Contains(trimmed, "log.Println")
|
||||
if isLog && hasEmojiRune(trimmed) {
|
||||
t.Errorf("[ERROR] [agent] [main] emoji in non-exempt log at line %d", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
43
agent/go.mod
Normal file
43
agent/go.mod
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
module github.com/Fimeg/RedFlag/agent
|
||||
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.4.21
|
||||
github.com/cilium/ebpf v0.22.0
|
||||
github.com/denisbrodbeck/machineid v1.0.1
|
||||
github.com/docker/docker v27.4.1+incompatible
|
||||
github.com/go-ole/go-ole v1.3.0
|
||||
github.com/gofrs/uuid/v5 v5.4.0
|
||||
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f
|
||||
golang.org/x/sys v0.47.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
)
|
||||
154
agent/go.sum
Normal file
154
agent/go.sum
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro=
|
||||
github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY=
|
||||
github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
|
||||
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
|
||||
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
|
||||
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
|
||||
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f h1:v+bqkkvZj6Oasqi58jzJk03XO0vaXvdb6SS9U1Rbqpw=
|
||||
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f/go.mod h1:Zt2M6t3i/fnWviIZkuw9wGn2E185P/rWZTqJkIrViGY=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
245
agent/install.sh
Executable file
245
agent/install.sh
Executable file
|
|
@ -0,0 +1,245 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# RedFlag Agent Installation Script
|
||||
# This script installs the RedFlag agent as a systemd service with proper permissions
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
AGENT_USER="redflag-agent"
|
||||
AGENT_HOME="/var/lib/redflag-agent"
|
||||
AGENT_BINARY="/usr/local/bin/redflag-agent"
|
||||
SUDOERS_FILE="/etc/sudoers.d/redflag-agent"
|
||||
SERVICE_FILE="/etc/systemd/system/redflag-agent.service"
|
||||
|
||||
echo "=== RedFlag Agent Installation ==="
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "ERROR: This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to create user if doesn't exist
|
||||
create_user() {
|
||||
if id "$AGENT_USER" &>/dev/null; then
|
||||
echo "✓ User $AGENT_USER already exists"
|
||||
else
|
||||
echo "Creating system user $AGENT_USER..."
|
||||
useradd -r -s /bin/false -d "$AGENT_HOME" -m "$AGENT_USER"
|
||||
echo "✓ User $AGENT_USER created"
|
||||
fi
|
||||
|
||||
# Add user to docker group for Docker update scanning
|
||||
if getent group docker &>/dev/null; then
|
||||
echo "Adding $AGENT_USER to docker group..."
|
||||
usermod -aG docker "$AGENT_USER"
|
||||
echo "✓ User $AGENT_USER added to docker group"
|
||||
else
|
||||
echo "⚠ Docker group not found - Docker updates will not be available"
|
||||
echo " (Install Docker first, then reinstall the agent to enable Docker support)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to build agent binary
|
||||
build_agent() {
|
||||
echo "Building agent binary..."
|
||||
cd "$SCRIPT_DIR"
|
||||
go build -o redflag-agent ./cmd/agent
|
||||
echo "✓ Agent binary built"
|
||||
}
|
||||
|
||||
# Function to install agent binary
|
||||
install_binary() {
|
||||
echo "Installing agent binary to $AGENT_BINARY..."
|
||||
cp "$SCRIPT_DIR/redflag-agent" "$AGENT_BINARY"
|
||||
chmod 755 "$AGENT_BINARY"
|
||||
chown root:root "$AGENT_BINARY"
|
||||
echo "✓ Agent binary installed"
|
||||
|
||||
# Set SELinux context for binary if SELinux is enabled
|
||||
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
|
||||
echo "SELinux detected, setting file context for binary..."
|
||||
restorecon -v "$AGENT_BINARY" || echo "[WARNING] [installer] [selinux] restorecon_failed path=$AGENT_BINARY — continuing"
|
||||
echo "✓ SELinux context set for binary"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to install sudoers configuration
|
||||
install_sudoers() {
|
||||
echo "Installing sudoers configuration..."
|
||||
cat > "$SUDOERS_FILE" <<'EOF'
|
||||
# RedFlag Agent minimal sudo permissions
|
||||
# This file is generated automatically during RedFlag agent installation
|
||||
|
||||
# APT package management commands
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get update
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install --dry-run --yes *
|
||||
|
||||
# DNF package management commands
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf makecache
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf upgrade -y *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install --assumeno --downloadonly *
|
||||
|
||||
# Docker operations
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker pull *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker image inspect *
|
||||
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
|
||||
EOF
|
||||
|
||||
chmod 440 "$SUDOERS_FILE"
|
||||
|
||||
# Validate sudoers file
|
||||
if visudo -c -f "$SUDOERS_FILE"; then
|
||||
echo "✓ Sudoers configuration installed and validated"
|
||||
else
|
||||
echo "ERROR: Sudoers configuration is invalid"
|
||||
rm -f "$SUDOERS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to install systemd service
|
||||
install_service() {
|
||||
echo "Installing systemd service..."
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=RedFlag Update Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$AGENT_USER
|
||||
Group=$AGENT_USER
|
||||
WorkingDirectory=$AGENT_HOME
|
||||
ExecStart=$AGENT_BINARY
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
# Security hardening
|
||||
# NoNewPrivileges=true - DISABLED: Prevents sudo from working
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=$AGENT_HOME /var/log /etc/aggregator
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
chmod 644 "$SERVICE_FILE"
|
||||
echo "✓ Systemd service installed"
|
||||
}
|
||||
|
||||
# Function to start and enable service
|
||||
start_service() {
|
||||
echo "Reloading systemd daemon..."
|
||||
systemctl daemon-reload
|
||||
|
||||
# Stop service if running
|
||||
if systemctl is-active --quiet redflag-agent; then
|
||||
echo "Stopping existing service..."
|
||||
systemctl stop redflag-agent
|
||||
fi
|
||||
|
||||
echo "Enabling and starting redflag-agent service..."
|
||||
systemctl enable redflag-agent
|
||||
systemctl start redflag-agent
|
||||
|
||||
# Wait a moment for service to start
|
||||
sleep 2
|
||||
|
||||
echo "✓ Service started"
|
||||
}
|
||||
|
||||
# Function to show status
|
||||
show_status() {
|
||||
echo ""
|
||||
echo "=== Service Status ==="
|
||||
systemctl status redflag-agent --no-pager -l
|
||||
echo ""
|
||||
echo "=== Recent Logs ==="
|
||||
journalctl -u redflag-agent -n 20 --no-pager
|
||||
}
|
||||
|
||||
# Function to register agent
|
||||
register_agent() {
|
||||
local server_url="${1:-http://localhost:8080}"
|
||||
|
||||
echo "Registering agent with server at $server_url..."
|
||||
|
||||
# Create config directory
|
||||
mkdir -p /etc/aggregator
|
||||
|
||||
# Set SELinux context for config directory if SELinux is enabled
|
||||
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
|
||||
echo "Setting SELinux context for config directory..."
|
||||
restorecon -Rv /etc/aggregator || echo "[WARNING] [installer] [selinux] restorecon_failed path=/etc/aggregator — continuing"
|
||||
echo "✓ SELinux context set for config directory"
|
||||
fi
|
||||
|
||||
# Register agent (run as regular binary, not as service)
|
||||
if "$AGENT_BINARY" -register -server "$server_url"; then
|
||||
echo "✓ Agent registered successfully"
|
||||
else
|
||||
echo "ERROR: Agent registration failed"
|
||||
echo "Please ensure the RedFlag server is running at $server_url"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main installation flow
|
||||
SERVER_URL="${1:-http://localhost:8080}"
|
||||
|
||||
echo "Step 1: Creating system user..."
|
||||
create_user
|
||||
|
||||
echo ""
|
||||
echo "Step 2: Building agent binary..."
|
||||
build_agent
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Installing agent binary..."
|
||||
install_binary
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Registering agent with server..."
|
||||
register_agent "$SERVER_URL"
|
||||
|
||||
echo ""
|
||||
echo "Step 5: Setting config file permissions..."
|
||||
chown redflag-agent:redflag-agent /etc/redflag/agent/config.json
|
||||
chmod 600 /etc/redflag/agent/config.json
|
||||
|
||||
echo ""
|
||||
echo "Step 6: Installing sudoers configuration..."
|
||||
install_sudoers
|
||||
|
||||
echo ""
|
||||
echo "Step 7: Installing systemd service..."
|
||||
install_service
|
||||
|
||||
echo ""
|
||||
echo "Step 8: Starting service..."
|
||||
start_service
|
||||
|
||||
echo ""
|
||||
echo "=== Installation Complete ==="
|
||||
echo ""
|
||||
echo "The RedFlag agent is now installed and running as a systemd service."
|
||||
echo "Server URL: $SERVER_URL"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " - Check status: sudo systemctl status redflag-agent"
|
||||
echo " - View logs: sudo journalctl -u redflag-agent -f"
|
||||
echo " - Restart: sudo systemctl restart redflag-agent"
|
||||
echo " - Stop: sudo systemctl stop redflag-agent"
|
||||
echo " - Disable: sudo systemctl disable redflag-agent"
|
||||
echo ""
|
||||
echo "Note: To re-register with a different server, edit /etc/aggregator/config.json"
|
||||
echo ""
|
||||
|
||||
show_status
|
||||
202
agent/internal/acknowledgment/tracker.go
Normal file
202
agent/internal/acknowledgment/tracker.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
package acknowledgment
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PendingResult represents a command result awaiting acknowledgment
|
||||
type PendingResult struct {
|
||||
CommandID string `json:"command_id"`
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
}
|
||||
|
||||
// Tracker manages pending acknowledgments for command results
|
||||
type Tracker struct {
|
||||
pending map[string]*PendingResult
|
||||
mu sync.RWMutex
|
||||
filePath string
|
||||
maxAge time.Duration // Max time to keep pending (default 24h)
|
||||
maxRetries int // Max retries before giving up (default 10)
|
||||
}
|
||||
|
||||
// NewTracker creates a new acknowledgment tracker
|
||||
func NewTracker(statePath string) *Tracker {
|
||||
return &Tracker{
|
||||
pending: make(map[string]*PendingResult),
|
||||
filePath: filepath.Join(statePath, "pending_acks.json"),
|
||||
maxAge: 24 * time.Hour,
|
||||
maxRetries: 10,
|
||||
}
|
||||
}
|
||||
|
||||
// Load restores pending acknowledgments from disk
|
||||
func (t *Tracker) Load() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
// If file doesn't exist, that's fine (fresh start)
|
||||
if _, err := os.Stat(t.filePath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(t.filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read pending acks: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil // Empty file
|
||||
}
|
||||
|
||||
var pending map[string]*PendingResult
|
||||
if err := json.Unmarshal(data, &pending); err != nil {
|
||||
return fmt.Errorf("failed to parse pending acks: %w", err)
|
||||
}
|
||||
|
||||
t.pending = pending
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save persists pending acknowledgments to disk
|
||||
func (t *Tracker) Save() error {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(t.filePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create ack directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(t.pending, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal pending acks: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(t.filePath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write pending acks: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add marks a command result as pending acknowledgment
|
||||
func (t *Tracker) Add(commandID string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
t.pending[commandID] = &PendingResult{
|
||||
CommandID: commandID,
|
||||
SentAt: time.Now().UTC(),
|
||||
RetryCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Acknowledge marks command results as acknowledged and removes them
|
||||
func (t *Tracker) Acknowledge(commandIDs []string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
for _, id := range commandIDs {
|
||||
delete(t.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
// GetPending returns list of command IDs awaiting acknowledgment
|
||||
func (t *Tracker) GetPending() []string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
ids := make([]string, 0, len(t.pending))
|
||||
for id := range t.pending {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// IncrementRetry increments retry count for a command
|
||||
func (t *Tracker) IncrementRetry(commandID string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if result, exists := t.pending[commandID]; exists {
|
||||
result.RetryCount++
|
||||
}
|
||||
}
|
||||
|
||||
// DroppedResult describes a pending result-ack that Cleanup abandoned. The agent
|
||||
// only ever redelivers the command ID (not the result payload), so a result the
|
||||
// server never recorded can sit here unrecoverable until it ages out — and a drop
|
||||
// is the silent loss of an auditable event. Cleanup returns these so the caller
|
||||
// journals each one inward (ETHOS #1) rather than discarding it to /dev/null.
|
||||
type DroppedResult struct {
|
||||
CommandID string
|
||||
Reason string // "max_age" | "max_retries"
|
||||
RetryCount int
|
||||
AgeSeconds int
|
||||
}
|
||||
|
||||
// Cleanup removes old or over-retried pending results and returns what it dropped
|
||||
// so the loss can be recorded as history. Returns an empty slice when nothing aged out.
|
||||
func (t *Tracker) Cleanup() []DroppedResult {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
var dropped []DroppedResult
|
||||
|
||||
for id, result := range t.pending {
|
||||
age := now.Sub(result.SentAt)
|
||||
switch {
|
||||
case age > t.maxAge:
|
||||
dropped = append(dropped, DroppedResult{CommandID: id, Reason: "max_age", RetryCount: result.RetryCount, AgeSeconds: int(age.Seconds())})
|
||||
delete(t.pending, id)
|
||||
case result.RetryCount >= t.maxRetries:
|
||||
dropped = append(dropped, DroppedResult{CommandID: id, Reason: "max_retries", RetryCount: result.RetryCount, AgeSeconds: int(age.Seconds())})
|
||||
delete(t.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
return dropped
|
||||
}
|
||||
|
||||
// Stats returns statistics about pending acknowledgments
|
||||
func (t *Tracker) Stats() Stats {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
stats := Stats{
|
||||
Total: len(t.pending),
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, result := range t.pending {
|
||||
age := now.Sub(result.SentAt)
|
||||
|
||||
if age > 1*time.Hour {
|
||||
stats.OlderThan1Hour++
|
||||
}
|
||||
if result.RetryCount > 0 {
|
||||
stats.WithRetries++
|
||||
}
|
||||
if result.RetryCount >= 5 {
|
||||
stats.HighRetries++
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// Stats holds statistics about pending acknowledgments
|
||||
type Stats struct {
|
||||
Total int
|
||||
OlderThan1Hour int
|
||||
WithRetries int
|
||||
HighRetries int
|
||||
}
|
||||
54
agent/internal/agent/backoff_test.go
Normal file
54
agent/internal/agent/backoff_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
)
|
||||
|
||||
// TestClassifyFailure locks in the BUG-014 policy: dead credentials and
|
||||
// machine-binding mismatches are terminal; everything else is transient.
|
||||
func TestClassifyFailure(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want failureClass
|
||||
}{
|
||||
{"machine mismatch", client.ErrMachineMismatch, failureTerminal},
|
||||
{"refresh token invalid", client.ErrRefreshTokenInvalid, failureTerminal},
|
||||
{"wrapped machine mismatch", fmt.Errorf("get commands: %w", client.ErrMachineMismatch), failureTerminal},
|
||||
{"wrapped refresh invalid", fmt.Errorf("renew: %w", client.ErrRefreshTokenInvalid), failureTerminal},
|
||||
{"unauthorized alone is not terminal (renewal may fix it)", client.ErrUnauthorized, failureTransient},
|
||||
{"plain network error", errors.New("dial tcp: connection refused"), failureTransient},
|
||||
{"nil-adjacent generic error", errors.New("502 bad gateway"), failureTransient},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := classifyFailure(tc.err); got != tc.want {
|
||||
t.Errorf("%s: classifyFailure() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelayForFailure verifies the policy curves: terminal is a long flat
|
||||
// delay independent of attempt count; transient follows the jittered
|
||||
// exponential bounded by base and max.
|
||||
func TestDelayForFailure(t *testing.T) {
|
||||
base := 5 * time.Second
|
||||
max := 5 * time.Minute
|
||||
|
||||
for _, attempt := range []int{1, 3, 50} {
|
||||
if got := delayForFailure(failureTerminal, attempt, base, max); got != terminalRetryDelay {
|
||||
t.Errorf("terminal attempt %d: delay = %s, want flat %s", attempt, got, terminalRetryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= 30; attempt++ {
|
||||
got := delayForFailure(failureTransient, attempt, base, max)
|
||||
if got < base || got > max {
|
||||
t.Errorf("transient attempt %d: delay %s outside [%s, %s]", attempt, got, base, max)
|
||||
}
|
||||
}
|
||||
}
|
||||
1232
agent/internal/agent/loop.go
Normal file
1232
agent/internal/agent/loop.go
Normal file
File diff suppressed because it is too large
Load diff
4
agent/internal/cache/cache.go
vendored
Normal file
4
agent/internal/cache/cache.go
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
package cache
|
||||
|
||||
// Init initializes the cache module
|
||||
func Init() {}
|
||||
172
agent/internal/cache/hash_cache.go
vendored
Normal file
172
agent/internal/cache/hash_cache.go
vendored
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HashCache provides LRU caching for expected package hashes
|
||||
type HashCache struct {
|
||||
mu sync.RWMutex
|
||||
hashes map[string]string
|
||||
maxSize int
|
||||
evicted map[string]time.Time
|
||||
}
|
||||
|
||||
// NewHashCache creates a new hash cache
|
||||
func NewHashCache(maxSize int) *HashCache {
|
||||
return &HashCache{
|
||||
hashes: make(map[string]string),
|
||||
maxSize: maxSize,
|
||||
evicted: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// key creates a cache key from package type and name
|
||||
func (c *HashCache) key(packageType, packageName, version string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", packageType, packageName, version)
|
||||
}
|
||||
|
||||
// Get retrieves a cached hash
|
||||
func (c *HashCache) Get(packageType, packageName, version string) (string, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
key := c.key(packageType, packageName, version)
|
||||
hash, ok := c.hashes[key]
|
||||
return hash, ok
|
||||
}
|
||||
|
||||
// Set stores a hash in the cache
|
||||
func (c *HashCache) Set(packageType, packageName, version, hash string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
key := c.key(packageType, packageName, version)
|
||||
|
||||
// Remove old entry if exists
|
||||
if _, ok := c.hashes[key]; ok {
|
||||
c.evict(key)
|
||||
}
|
||||
|
||||
c.hashes[key] = hash
|
||||
c.touch(key)
|
||||
|
||||
// Enforce size limit
|
||||
for len(c.hashes) > c.maxSize {
|
||||
c.evictOldest()
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes a hash from the cache
|
||||
func (c *HashCache) Delete(packageType, packageName, version string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
key := c.key(packageType, packageName, version)
|
||||
delete(c.hashes, key)
|
||||
}
|
||||
|
||||
// evict removes a key from the cache
|
||||
func (c *HashCache) evict(key string) {
|
||||
delete(c.hashes, key)
|
||||
delete(c.evicted, key)
|
||||
}
|
||||
|
||||
// touch updates the access time for a key
|
||||
func (c *HashCache) touch(key string) {
|
||||
c.evicted[key] = time.Now()
|
||||
}
|
||||
|
||||
// evictOldest removes the oldest accessed key when cache is full
|
||||
func (c *HashCache) evictOldest() {
|
||||
var oldest string
|
||||
var oldestTime time.Time
|
||||
|
||||
c.mu.RLock()
|
||||
for key, t := range c.evicted {
|
||||
if oldest == "" || t.Before(oldestTime) {
|
||||
oldest = key
|
||||
oldestTime = t
|
||||
}
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
if oldest != "" {
|
||||
c.evict(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyHashFromCache downloads a package only if not cached, verifies hash, and caches result
|
||||
func (c *HashCache) VerifyHashFromCache(packageType, packageName, version, expectedSHA256 string) error {
|
||||
if expectedSHA256 == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if cachedHash, cached := c.Get(packageType, packageName, version); cached {
|
||||
if cachedHash == expectedSHA256 {
|
||||
return nil // Hash verified from cache
|
||||
}
|
||||
// Cache has different hash - re-download to update cache
|
||||
}
|
||||
|
||||
// Download and compute hash
|
||||
resp, err := http.Get(fmt.Sprintf("%s/api/v1/downloads/artifact?ecosystem=%s&package_name=%s&version=%s",
|
||||
getDownloaderURL(), packageType, packageName, version))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download package: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Compute hash while streaming
|
||||
h := sha256.New()
|
||||
_, err = io.Copy(h, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read package: %w", err)
|
||||
}
|
||||
|
||||
computedSHA256 := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Verify against expected
|
||||
if computedSHA256 != expectedSHA256 {
|
||||
return fmt.Errorf("package hash mismatch: expected %s, got %s", expectedSHA256, computedSHA256)
|
||||
}
|
||||
|
||||
// Cache the verified hash
|
||||
c.Set(packageType, packageName, version, computedSHA256)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear evicts all cached hashes
|
||||
func (c *HashCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.hashes = make(map[string]string)
|
||||
c.evicted = make(map[string]time.Time)
|
||||
}
|
||||
|
||||
// Size returns current cache size
|
||||
func (c *HashCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.hashes)
|
||||
}
|
||||
|
||||
// getDownloaderURL returns the URL of the download handler
|
||||
// This is a simplified version - in production, use the download handler's getServerURL method
|
||||
func getDownloaderURL() string {
|
||||
// Default to localhost for local testing
|
||||
// In production, this would come from config
|
||||
return "http://localhost:8080"
|
||||
}
|
||||
314
agent/internal/cache/local.go
vendored
Normal file
314
agent/internal/cache/local.go
vendored
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// LocalCache stores scan results locally for offline viewing
|
||||
type LocalCache struct {
|
||||
LastScanTime time.Time `json:"last_scan_time"`
|
||||
LastCheckIn time.Time `json:"last_check_in"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
UpdateCount int `json:"update_count"`
|
||||
Updates []client.UpdateReportItem `json:"updates"`
|
||||
AgentStatus string `json:"agent_status"`
|
||||
Summary UpdateSummary `json:"summary"`
|
||||
Scanners map[string]ScannerState `json:"scanners,omitempty"`
|
||||
Capabilities CapabilityTokenState `json:"capabilities"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// UpdateSummary is the local rollup consumed by status surfaces.
|
||||
type UpdateSummary struct {
|
||||
Total int `json:"total"`
|
||||
ByEcosystem map[string]int `json:"by_ecosystem,omitempty"`
|
||||
BySeverity map[string]int `json:"by_severity,omitempty"`
|
||||
}
|
||||
|
||||
// ScannerState records the latest local scan status for one scanner.
|
||||
type ScannerState struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
LastScanTime time.Time `json:"last_scan_time,omitempty"`
|
||||
LastDurationMS int64 `json:"last_duration_ms,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
UpdateCount int `json:"update_count"`
|
||||
}
|
||||
|
||||
// CapabilityTokenState is count-only local state for the supply-chain token path.
|
||||
// It intentionally excludes token IDs, token payloads, signatures, and artifacts.
|
||||
type CapabilityTokenState struct {
|
||||
LastFetchTime time.Time `json:"last_fetch_time,omitempty"`
|
||||
LastProcessTime time.Time `json:"last_process_time,omitempty"`
|
||||
PendingCount int `json:"pending_count"`
|
||||
LastFetchedCount int `json:"last_fetched_count"`
|
||||
LastProcessedCount int `json:"last_processed_count"`
|
||||
LastFailedCount int `json:"last_failed_count"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
// cacheFile is the file where scan results are cached
|
||||
const cacheFile = "last_scan.json"
|
||||
|
||||
// GetCachePath returns the full path to the cache file
|
||||
func GetCachePath() string {
|
||||
return filepath.Join(constants.GetAgentCacheDir(), cacheFile)
|
||||
}
|
||||
|
||||
// Load reads the local cache from disk
|
||||
func Load() (*LocalCache, error) {
|
||||
return LoadFromPath(GetCachePath())
|
||||
}
|
||||
|
||||
// LoadFromPath reads a local cache file from disk.
|
||||
func LoadFromPath(cachePath string) (*LocalCache, error) {
|
||||
// Check if cache file exists
|
||||
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
|
||||
// Return empty cache if file doesn't exist
|
||||
return &LocalCache{}, nil
|
||||
}
|
||||
|
||||
// Read cache file
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read cache file: %w", err)
|
||||
}
|
||||
|
||||
var cache LocalCache
|
||||
if err := json.Unmarshal(data, &cache); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse cache file: %w", err)
|
||||
}
|
||||
|
||||
return &cache, nil
|
||||
}
|
||||
|
||||
// Save writes the local cache to disk
|
||||
func (c *LocalCache) Save() error {
|
||||
return c.SaveToPath(GetCachePath())
|
||||
}
|
||||
|
||||
// SaveToPath writes a local cache file to disk.
|
||||
func (c *LocalCache) SaveToPath(cachePath string) error {
|
||||
// Ensure cache directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
c.refreshSummary()
|
||||
if c.LastUpdated.IsZero() {
|
||||
c.LastUpdated = time.Now().UTC()
|
||||
}
|
||||
|
||||
// Marshal cache to JSON with indentation
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal cache: %w", err)
|
||||
}
|
||||
|
||||
// Write cache file with restricted permissions
|
||||
if err := os.WriteFile(cachePath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write cache file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateScanResults updates the cache with new scan results
|
||||
func (c *LocalCache) UpdateScanResults(updates []client.UpdateReportItem) {
|
||||
now := time.Now().UTC()
|
||||
c.LastScanTime = now
|
||||
c.LastUpdated = now
|
||||
c.Updates = updates
|
||||
c.UpdateCount = len(updates)
|
||||
c.refreshSummary()
|
||||
}
|
||||
|
||||
// UpdateCheckIn updates the last check-in time
|
||||
func (c *LocalCache) UpdateCheckIn() {
|
||||
now := time.Now().UTC()
|
||||
c.LastCheckIn = now
|
||||
c.LastUpdated = now
|
||||
}
|
||||
|
||||
// SetAgentInfo sets agent identification information
|
||||
func (c *LocalCache) SetAgentInfo(agentID uuid.UUID, serverURL string) {
|
||||
c.AgentID = agentID
|
||||
c.ServerURL = serverURL
|
||||
c.LastUpdated = time.Now().UTC()
|
||||
}
|
||||
|
||||
// SetAgentStatus sets the current agent status
|
||||
func (c *LocalCache) SetAgentStatus(status string) {
|
||||
c.AgentStatus = status
|
||||
c.LastUpdated = time.Now().UTC()
|
||||
}
|
||||
|
||||
// RecordScannerResult updates the local read model with one scanner execution.
|
||||
// Successful package-update scans replace only their ecosystem slice, so a
|
||||
// scan_apt command does not erase the latest DNF/Winget/Docker observations.
|
||||
func (c *LocalCache) RecordScannerResult(scannerName, status string, updates []client.UpdateReportItem, scanErr error, duration time.Duration, affectsUpdateList bool) {
|
||||
name := normalizeScannerName(scannerName)
|
||||
if status == "" {
|
||||
status = "unknown"
|
||||
}
|
||||
|
||||
if c.Scanners == nil {
|
||||
c.Scanners = make(map[string]ScannerState)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastError := ""
|
||||
if scanErr != nil {
|
||||
lastError = scanErr.Error()
|
||||
}
|
||||
|
||||
c.Scanners[name] = ScannerState{
|
||||
Name: name,
|
||||
Status: status,
|
||||
LastScanTime: now,
|
||||
LastDurationMS: duration.Milliseconds(),
|
||||
LastError: lastError,
|
||||
UpdateCount: len(updates),
|
||||
}
|
||||
c.LastUpdated = now
|
||||
|
||||
if affectsUpdateList && status == "success" {
|
||||
c.replaceScannerUpdates(name, updates)
|
||||
c.LastScanTime = now
|
||||
c.UpdateCount = len(c.Updates)
|
||||
c.refreshSummary()
|
||||
}
|
||||
}
|
||||
|
||||
// RecordCapabilityTokenFetch records the count of tokens fetched on the latest poll.
|
||||
func (c *LocalCache) RecordCapabilityTokenFetch(fetched int, fetchErr error) {
|
||||
now := time.Now().UTC()
|
||||
c.Capabilities.LastFetchTime = now
|
||||
c.Capabilities.LastFetchedCount = fetched
|
||||
c.Capabilities.PendingCount = fetched
|
||||
c.Capabilities.LastError = ""
|
||||
if fetchErr != nil {
|
||||
c.Capabilities.LastError = fetchErr.Error()
|
||||
}
|
||||
c.LastUpdated = now
|
||||
}
|
||||
|
||||
// RecordCapabilityTokenProcess records count-only token processing results.
|
||||
func (c *LocalCache) RecordCapabilityTokenProcess(processed, failed int) {
|
||||
now := time.Now().UTC()
|
||||
c.Capabilities.LastProcessTime = now
|
||||
c.Capabilities.LastProcessedCount = processed
|
||||
c.Capabilities.LastFailedCount = failed
|
||||
remaining := c.Capabilities.LastFetchedCount - processed - failed
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
c.Capabilities.PendingCount = remaining
|
||||
c.LastUpdated = now
|
||||
}
|
||||
|
||||
// IsExpired checks if the cache is older than the specified duration
|
||||
func (c *LocalCache) IsExpired(maxAge time.Duration) bool {
|
||||
return time.Since(c.LastScanTime) > maxAge
|
||||
}
|
||||
|
||||
// GetUpdatesByType returns updates filtered by package type
|
||||
func (c *LocalCache) GetUpdatesByType(packageType string) []client.UpdateReportItem {
|
||||
var filtered []client.UpdateReportItem
|
||||
for _, update := range c.Updates {
|
||||
if update.PackageType == packageType {
|
||||
filtered = append(filtered, update)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Clear clears the cache
|
||||
func (c *LocalCache) Clear() {
|
||||
c.LastScanTime = time.Time{}
|
||||
c.LastCheckIn = time.Time{}
|
||||
c.UpdateCount = 0
|
||||
c.Updates = []client.UpdateReportItem{}
|
||||
c.AgentStatus = ""
|
||||
c.Summary = UpdateSummary{}
|
||||
c.Scanners = nil
|
||||
c.Capabilities = CapabilityTokenState{}
|
||||
c.LastUpdated = time.Now().UTC()
|
||||
}
|
||||
|
||||
func (c *LocalCache) replaceScannerUpdates(scannerName string, updates []client.UpdateReportItem) {
|
||||
packageTypes := packageTypesForScanner(scannerName, updates)
|
||||
if len(packageTypes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make([]client.UpdateReportItem, 0, len(c.Updates)+len(updates))
|
||||
for _, update := range c.Updates {
|
||||
if _, replace := packageTypes[normalizeScannerName(update.PackageType)]; replace {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, update)
|
||||
}
|
||||
filtered = append(filtered, updates...)
|
||||
c.Updates = filtered
|
||||
}
|
||||
|
||||
func (c *LocalCache) refreshSummary() {
|
||||
summary := UpdateSummary{
|
||||
Total: len(c.Updates),
|
||||
ByEcosystem: make(map[string]int),
|
||||
BySeverity: make(map[string]int),
|
||||
}
|
||||
for _, update := range c.Updates {
|
||||
ecosystem := normalizeScannerName(update.PackageType)
|
||||
if ecosystem == "" {
|
||||
ecosystem = "unknown"
|
||||
}
|
||||
severity := strings.ToLower(strings.TrimSpace(update.Severity))
|
||||
if severity == "" {
|
||||
severity = "unknown"
|
||||
}
|
||||
summary.ByEcosystem[ecosystem]++
|
||||
summary.BySeverity[severity]++
|
||||
}
|
||||
if len(summary.ByEcosystem) == 0 {
|
||||
summary.ByEcosystem = nil
|
||||
}
|
||||
if len(summary.BySeverity) == 0 {
|
||||
summary.BySeverity = nil
|
||||
}
|
||||
c.UpdateCount = summary.Total
|
||||
c.Summary = summary
|
||||
}
|
||||
|
||||
func packageTypesForScanner(scannerName string, updates []client.UpdateReportItem) map[string]struct{} {
|
||||
packageTypes := make(map[string]struct{})
|
||||
switch normalizeScannerName(scannerName) {
|
||||
case "apt", "dnf", "pacman", "docker", "winget":
|
||||
packageTypes[normalizeScannerName(scannerName)] = struct{}{}
|
||||
case "windows":
|
||||
packageTypes["windows_update"] = struct{}{}
|
||||
packageTypes["windows_update_history"] = struct{}{}
|
||||
}
|
||||
|
||||
for _, update := range updates {
|
||||
if packageType := normalizeScannerName(update.PackageType); packageType != "" {
|
||||
packageTypes[packageType] = struct{}{}
|
||||
}
|
||||
}
|
||||
return packageTypes
|
||||
}
|
||||
|
||||
func normalizeScannerName(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
139
agent/internal/cache/local_test.go
vendored
Normal file
139
agent/internal/cache/local_test.go
vendored
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
)
|
||||
|
||||
func TestSaveLoadFromPathRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "last_scan.json")
|
||||
localCache := &LocalCache{
|
||||
AgentStatus: "online",
|
||||
Updates: []client.UpdateReportItem{
|
||||
{PackageType: "apt", PackageName: "openssl", Severity: "important"},
|
||||
},
|
||||
}
|
||||
localCache.UpdateScanResults(localCache.Updates)
|
||||
|
||||
if err := localCache.SaveToPath(path); err != nil {
|
||||
t.Fatalf("SaveToPath() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadFromPath(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromPath() error = %v", err)
|
||||
}
|
||||
if loaded.AgentStatus != "online" {
|
||||
t.Fatalf("AgentStatus = %q, want online", loaded.AgentStatus)
|
||||
}
|
||||
if loaded.Summary.Total != 1 {
|
||||
t.Fatalf("Summary.Total = %d, want 1", loaded.Summary.Total)
|
||||
}
|
||||
if loaded.Summary.ByEcosystem["apt"] != 1 {
|
||||
t.Fatalf("Summary.ByEcosystem[apt] = %d, want 1", loaded.Summary.ByEcosystem["apt"])
|
||||
}
|
||||
if loaded.Summary.BySeverity["important"] != 1 {
|
||||
t.Fatalf("Summary.BySeverity[important] = %d, want 1", loaded.Summary.BySeverity["important"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordScannerResultReplacesOnlyThatScanner(t *testing.T) {
|
||||
localCache := &LocalCache{}
|
||||
localCache.UpdateScanResults([]client.UpdateReportItem{
|
||||
{PackageType: "apt", PackageName: "openssl", Severity: "important"},
|
||||
{PackageType: "dnf", PackageName: "kernel", Severity: "critical"},
|
||||
})
|
||||
|
||||
localCache.RecordScannerResult("apt", "success", []client.UpdateReportItem{
|
||||
{PackageType: "apt", PackageName: "curl", Severity: "moderate"},
|
||||
}, nil, 250*time.Millisecond, true)
|
||||
|
||||
if len(localCache.Updates) != 2 {
|
||||
t.Fatalf("len(Updates) = %d, want 2", len(localCache.Updates))
|
||||
}
|
||||
if got := packageNames(localCache.Updates); got["openssl"] {
|
||||
t.Fatalf("stale apt package remained in updates: %#v", localCache.Updates)
|
||||
}
|
||||
if got := packageNames(localCache.Updates); !got["curl"] || !got["kernel"] {
|
||||
t.Fatalf("updates = %#v, want curl and kernel", localCache.Updates)
|
||||
}
|
||||
if localCache.Summary.ByEcosystem["apt"] != 1 || localCache.Summary.ByEcosystem["dnf"] != 1 {
|
||||
t.Fatalf("summary by ecosystem = %#v, want apt=1 dnf=1", localCache.Summary.ByEcosystem)
|
||||
}
|
||||
if localCache.Scanners["apt"].Status != "success" {
|
||||
t.Fatalf("scanner status = %q, want success", localCache.Scanners["apt"].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordScannerResultSuccessfulEmptyScanClearsScannerUpdates(t *testing.T) {
|
||||
localCache := &LocalCache{}
|
||||
localCache.UpdateScanResults([]client.UpdateReportItem{
|
||||
{PackageType: "windows_update", PackageName: "KB123", Severity: "important"},
|
||||
{PackageType: "winget", PackageName: "Git.Git", Severity: "moderate"},
|
||||
})
|
||||
|
||||
localCache.RecordScannerResult("windows", "success", nil, nil, 100*time.Millisecond, true)
|
||||
|
||||
if len(localCache.Updates) != 1 {
|
||||
t.Fatalf("len(Updates) = %d, want 1", len(localCache.Updates))
|
||||
}
|
||||
if localCache.Updates[0].PackageType != "winget" {
|
||||
t.Fatalf("remaining PackageType = %q, want winget", localCache.Updates[0].PackageType)
|
||||
}
|
||||
if localCache.Summary.ByEcosystem["windows_update"] != 0 {
|
||||
t.Fatalf("windows_update summary = %d, want 0", localCache.Summary.ByEcosystem["windows_update"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordScannerResultFailureDoesNotClearStaleUpdates(t *testing.T) {
|
||||
localCache := &LocalCache{}
|
||||
localCache.UpdateScanResults([]client.UpdateReportItem{
|
||||
{PackageType: "apt", PackageName: "openssl", Severity: "important"},
|
||||
})
|
||||
|
||||
localCache.RecordScannerResult("apt", "failed", nil, errors.New("scanner failed"), time.Second, true)
|
||||
|
||||
if len(localCache.Updates) != 1 {
|
||||
t.Fatalf("len(Updates) = %d, want stale update retained", len(localCache.Updates))
|
||||
}
|
||||
if localCache.Scanners["apt"].LastError != "scanner failed" {
|
||||
t.Fatalf("LastError = %q, want scanner failed", localCache.Scanners["apt"].LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordCapabilityTokenCounts(t *testing.T) {
|
||||
localCache := &LocalCache{}
|
||||
|
||||
localCache.RecordCapabilityTokenFetch(3, nil)
|
||||
localCache.RecordCapabilityTokenProcess(2, 1)
|
||||
|
||||
if localCache.Capabilities.LastFetchedCount != 3 {
|
||||
t.Fatalf("LastFetchedCount = %d, want 3", localCache.Capabilities.LastFetchedCount)
|
||||
}
|
||||
if localCache.Capabilities.LastProcessedCount != 2 {
|
||||
t.Fatalf("LastProcessedCount = %d, want 2", localCache.Capabilities.LastProcessedCount)
|
||||
}
|
||||
if localCache.Capabilities.LastFailedCount != 1 {
|
||||
t.Fatalf("LastFailedCount = %d, want 1", localCache.Capabilities.LastFailedCount)
|
||||
}
|
||||
if localCache.Capabilities.PendingCount != 0 {
|
||||
t.Fatalf("PendingCount = %d, want 0", localCache.Capabilities.PendingCount)
|
||||
}
|
||||
|
||||
localCache.RecordCapabilityTokenFetch(0, errors.New("server unavailable"))
|
||||
if localCache.Capabilities.LastError != "server unavailable" {
|
||||
t.Fatalf("LastError = %q, want server unavailable", localCache.Capabilities.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func packageNames(updates []client.UpdateReportItem) map[string]bool {
|
||||
names := make(map[string]bool, len(updates))
|
||||
for _, update := range updates {
|
||||
names[update.PackageName] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
15
agent/internal/capability/keyid.go
Normal file
15
agent/internal/capability/keyid.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package capability
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// KeyIDFor returns the authority key fingerprint: hex(sha256(pubkey)[:16]).
|
||||
// Matches SigningService.GetPublicKeyFingerprint and the executor's key_id_for.
|
||||
// Split out of token.go so retiring Token does not strand the mutation protocol.
|
||||
func KeyIDFor(pub ed25519.PublicKey) string {
|
||||
hash := sha256.Sum256(pub)
|
||||
return hex.EncodeToString(hash[:16])
|
||||
}
|
||||
423
agent/internal/capability/mutation_manifest.go
Normal file
423
agent/internal/capability/mutation_manifest.go
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
package capability
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
// MutationProtocolVersion belongs to the manifest namespace, independently
|
||||
// of the current closure-based Token format. Backends opt into the envelope
|
||||
// path explicitly; pacman begins at the helper boundary.
|
||||
MutationProtocolVersion = 1
|
||||
|
||||
// MaxAuthorizationLifetimeSeconds is the ceiling on expires_at - not_before.
|
||||
// Derived, not chosen: it is DefaultTokenTTL, the fleet minter's window
|
||||
// (server/internal/services/capability_minter.go). Standalone mint is
|
||||
// tighter still at 600s. Doctrine, not a knob — an authority that wants a
|
||||
// standing capability has to say so by minting again.
|
||||
MaxAuthorizationLifetimeSeconds = 3600
|
||||
|
||||
manifestDomain = "redflag.mutation-manifest"
|
||||
actionDomain = "redflag.resolved-action"
|
||||
evidenceDomain = "redflag.evidence"
|
||||
authorizationDomain = "redflag.mutation-authorization"
|
||||
receiptDomain = "redflag.mutation-receipt"
|
||||
)
|
||||
|
||||
// ResolvedAction carries exact backend-owned UTF-8 JSON bytes. The common
|
||||
// protocol signs those bytes but does not reinterpret pacman, WUA, Winget,
|
||||
// Docker, or self-update semantics into a fictional universal artifact.
|
||||
type ResolvedAction struct {
|
||||
Kind string `json:"kind"`
|
||||
Identity string `json:"identity"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
// Evidence identifies provenance or policy evidence by digest. Execution
|
||||
// location belongs in the resolved action payload, never in this trust class.
|
||||
type Evidence struct {
|
||||
Kind string `json:"kind"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// MutationManifest is the immutable description an authority approves and
|
||||
// an executor later receives unchanged.
|
||||
//
|
||||
// TargetID MUST be the locally provisioned RedFlag agent identity. The generic
|
||||
// name is deliberate: a later protocol may define another target namespace.
|
||||
type MutationManifest struct {
|
||||
ProtocolVersion int `json:"protocol_version"`
|
||||
OperationID string `json:"operation_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
Backend string `json:"backend"`
|
||||
Operation string `json:"operation"`
|
||||
ResolvedActions []ResolvedAction `json:"resolved_actions"`
|
||||
Evidence []Evidence `json:"evidence"`
|
||||
}
|
||||
|
||||
// MutationAuthorization binds an authority decision to one manifest and body.
|
||||
// Every field except Signature is inside CanonicalMessage, including KeyID.
|
||||
type MutationAuthorization struct {
|
||||
ProtocolVersion int `json:"protocol_version"`
|
||||
AuthorizationID string `json:"authorization_id"`
|
||||
ManifestHash string `json:"manifest_hash"`
|
||||
AuthorityKind string `json:"authority_kind"`
|
||||
AuthorityID string `json:"authority_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
NotBefore int64 `json:"not_before"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Decision string `json:"decision"`
|
||||
KeyID string `json:"key_id"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// MutationEnvelope is the indivisible object handed across an authority or
|
||||
// executor boundary. Verification always recomputes the manifest hash from the
|
||||
// manifest carried beside its authorization.
|
||||
type MutationEnvelope struct {
|
||||
Manifest MutationManifest `json:"manifest"`
|
||||
Authorization MutationAuthorization `json:"authorization"`
|
||||
}
|
||||
|
||||
func writeLP(buf *bytes.Buffer, value []byte) {
|
||||
buf.WriteString(strconv.Itoa(len(value)))
|
||||
buf.WriteByte(':')
|
||||
buf.Write(value)
|
||||
}
|
||||
|
||||
func canonicalRecord(domain string, values ...string) []byte {
|
||||
var buf bytes.Buffer
|
||||
writeLP(&buf, []byte(domain))
|
||||
for _, value := range values {
|
||||
writeLP(&buf, []byte(value))
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func (a ResolvedAction) canonicalBytes() []byte {
|
||||
return canonicalRecord(actionDomain, a.Kind, a.Identity, a.Payload)
|
||||
}
|
||||
|
||||
func (e Evidence) canonicalBytes() []byte {
|
||||
return canonicalRecord(evidenceDomain, e.Kind, e.Digest)
|
||||
}
|
||||
|
||||
func sortedRecords[T any](values []T, encode func(T) []byte) [][]byte {
|
||||
records := make([][]byte, 0, len(values))
|
||||
for _, value := range values {
|
||||
records = append(records, encode(value))
|
||||
}
|
||||
sort.Slice(records, func(i, j int) bool { return bytes.Compare(records[i], records[j]) < 0 })
|
||||
return records
|
||||
}
|
||||
|
||||
// CanonicalBytes is domain-separated and length-prefixed. Action and evidence
|
||||
// ordering is irrelevant, while exact duplicates remain present and therefore
|
||||
// change the hash. No set conversion is permitted here.
|
||||
func (m MutationManifest) CanonicalBytes() []byte {
|
||||
var buf bytes.Buffer
|
||||
writeLP(&buf, []byte(manifestDomain))
|
||||
for _, value := range []string{
|
||||
strconv.Itoa(m.ProtocolVersion),
|
||||
m.OperationID,
|
||||
m.TargetID,
|
||||
m.Backend,
|
||||
m.Operation,
|
||||
} {
|
||||
writeLP(&buf, []byte(value))
|
||||
}
|
||||
|
||||
actions := sortedRecords(m.ResolvedActions, func(a ResolvedAction) []byte { return a.canonicalBytes() })
|
||||
writeLP(&buf, []byte(strconv.Itoa(len(actions))))
|
||||
for _, action := range actions {
|
||||
writeLP(&buf, action)
|
||||
}
|
||||
|
||||
evidence := sortedRecords(m.Evidence, func(e Evidence) []byte { return e.canonicalBytes() })
|
||||
writeLP(&buf, []byte(strconv.Itoa(len(evidence))))
|
||||
for _, item := range evidence {
|
||||
writeLP(&buf, item)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func (m MutationManifest) Hash() string {
|
||||
digest := sha256.Sum256(m.CanonicalBytes())
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func (m MutationManifest) Validate() error {
|
||||
if m.ProtocolVersion != MutationProtocolVersion {
|
||||
return fmt.Errorf("mutation protocol: unsupported manifest version %d", m.ProtocolVersion)
|
||||
}
|
||||
for _, field := range [][2]string{
|
||||
{"operation_id", m.OperationID},
|
||||
{"target_id", m.TargetID},
|
||||
{"backend", m.Backend},
|
||||
{"operation", m.Operation},
|
||||
} {
|
||||
name, value := field[0], field[1]
|
||||
if value == "" {
|
||||
return fmt.Errorf("mutation protocol: manifest %s is empty", name)
|
||||
}
|
||||
}
|
||||
if len(m.ResolvedActions) == 0 {
|
||||
return fmt.Errorf("mutation protocol: manifest has no resolved actions")
|
||||
}
|
||||
for i, action := range m.ResolvedActions {
|
||||
if action.Kind == "" || action.Identity == "" || action.Payload == "" {
|
||||
return fmt.Errorf("mutation protocol: resolved action %d is incomplete", i)
|
||||
}
|
||||
if !json.Valid([]byte(action.Payload)) {
|
||||
return fmt.Errorf("mutation protocol: resolved action %d payload is not JSON", i)
|
||||
}
|
||||
}
|
||||
for i, evidence := range m.Evidence {
|
||||
if evidence.Kind == "" {
|
||||
return fmt.Errorf("mutation protocol: evidence %d kind is empty", i)
|
||||
}
|
||||
decoded, err := hex.DecodeString(evidence.Digest)
|
||||
if err != nil || len(decoded) != sha256.Size {
|
||||
return fmt.Errorf("mutation protocol: evidence %d digest is not SHA-256 hex", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a MutationAuthorization) CanonicalMessage() []byte {
|
||||
return canonicalRecord(
|
||||
authorizationDomain,
|
||||
strconv.Itoa(a.ProtocolVersion),
|
||||
a.AuthorizationID,
|
||||
a.ManifestHash,
|
||||
a.AuthorityKind,
|
||||
a.AuthorityID,
|
||||
a.TargetID,
|
||||
strconv.FormatInt(a.IssuedAt, 10),
|
||||
strconv.FormatInt(a.NotBefore, 10),
|
||||
strconv.FormatInt(a.ExpiresAt, 10),
|
||||
a.Decision,
|
||||
a.KeyID,
|
||||
)
|
||||
}
|
||||
|
||||
// IsCanonicalUUIDv4 reports whether s is 8-4-4-4-12 lowercase hex with the
|
||||
// version (4) and variant (8/9/a/b) nibbles set. Same discipline the standalone
|
||||
// mint already applies to request_id, applied here before authorization_id can
|
||||
// become an executor replay key: a newline-delimited replay ledger matched by
|
||||
// exact line has no defence against an identifier that contains a newline.
|
||||
func IsCanonicalUUIDv4(s string) bool {
|
||||
if len(s) != 36 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if i == 8 || i == 13 || i == 18 || i == 23 {
|
||||
if c != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
|
||||
if !isHex {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if s[14] != '4' {
|
||||
return false
|
||||
}
|
||||
switch s[19] {
|
||||
case '8', '9', 'a', 'b':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a MutationAuthorization) validateShape() error {
|
||||
if a.ProtocolVersion != MutationProtocolVersion {
|
||||
return fmt.Errorf("mutation protocol: unsupported authorization version %d", a.ProtocolVersion)
|
||||
}
|
||||
for _, field := range [][2]string{
|
||||
{"authorization_id", a.AuthorizationID},
|
||||
{"authority_kind", a.AuthorityKind},
|
||||
{"authority_id", a.AuthorityID},
|
||||
{"target_id", a.TargetID},
|
||||
{"decision", a.Decision},
|
||||
} {
|
||||
name, value := field[0], field[1]
|
||||
if value == "" {
|
||||
return fmt.Errorf("mutation protocol: authorization %s is empty", name)
|
||||
}
|
||||
}
|
||||
if !IsCanonicalUUIDv4(a.AuthorizationID) {
|
||||
return fmt.Errorf("mutation protocol: authorization_id is not a canonical UUID v4")
|
||||
}
|
||||
if a.IssuedAt <= 0 || a.NotBefore < a.IssuedAt || a.ExpiresAt <= a.NotBefore {
|
||||
return fmt.Errorf("mutation protocol: invalid authorization time window")
|
||||
}
|
||||
if a.ExpiresAt-a.NotBefore > MaxAuthorizationLifetimeSeconds {
|
||||
return fmt.Errorf("mutation protocol: authorization lifetime %ds exceeds the %ds ceiling",
|
||||
a.ExpiresAt-a.NotBefore, MaxAuthorizationLifetimeSeconds)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *MutationAuthorization) Sign(priv ed25519.PrivateKey, manifest MutationManifest) error {
|
||||
if len(priv) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("mutation protocol: invalid private key size %d", len(priv))
|
||||
}
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.validateShape(); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.TargetID != manifest.TargetID {
|
||||
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
|
||||
}
|
||||
a.ManifestHash = manifest.Hash()
|
||||
a.KeyID = KeyIDFor(priv.Public().(ed25519.PublicKey))
|
||||
a.Signature = hex.EncodeToString(ed25519.Sign(priv, a.CanonicalMessage()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a MutationAuthorization) Verify(pub ed25519.PublicKey, manifest MutationManifest) error {
|
||||
if len(pub) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("mutation protocol: invalid public key size %d", len(pub))
|
||||
}
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.validateShape(); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.TargetID != manifest.TargetID {
|
||||
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
|
||||
}
|
||||
if a.ManifestHash != manifest.Hash() {
|
||||
return fmt.Errorf("mutation protocol: authorization manifest hash mismatch")
|
||||
}
|
||||
if a.KeyID != KeyIDFor(pub) {
|
||||
return fmt.Errorf("mutation protocol: authorization key id mismatch")
|
||||
}
|
||||
signature, err := hex.DecodeString(a.Signature)
|
||||
if err != nil || len(signature) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("mutation protocol: malformed signature")
|
||||
}
|
||||
if !ed25519.Verify(pub, a.CanonicalMessage(), signature) {
|
||||
return fmt.Errorf("mutation protocol: signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyForExecutionAt adds the executor's decision and time checks to the
|
||||
// cryptographic envelope verification.
|
||||
func (a MutationAuthorization) VerifyForExecutionAt(pub ed25519.PublicKey, manifest MutationManifest, now int64) error {
|
||||
if err := a.Verify(pub, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Decision != "allow" {
|
||||
return fmt.Errorf("mutation protocol: authorization decision is %q", a.Decision)
|
||||
}
|
||||
if now < a.NotBefore || now > a.ExpiresAt {
|
||||
return fmt.Errorf("mutation protocol: authorization is outside its time window")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e MutationEnvelope) VerifyForExecutionAt(pub ed25519.PublicKey, now int64) error {
|
||||
return e.Authorization.VerifyForExecutionAt(pub, e.Manifest, now)
|
||||
}
|
||||
|
||||
// MutationReceipt is the response half of the contract: what the privileged
|
||||
// executor did with one envelope. It carries the audit join ARCH-002 names —
|
||||
// operation ID, manifest hash, authorization ID — so a local receipt and a
|
||||
// server history row can be joined without either guessing.
|
||||
//
|
||||
// It is not signed. The executor is not a second authority; this is a record
|
||||
// produced inside the trust boundary that already ran the operation. Decision
|
||||
// and Reason keep the PolicyResult taxonomy rather than inventing a new one.
|
||||
type MutationReceipt struct {
|
||||
ProtocolVersion int `json:"protocol_version"`
|
||||
OperationID string `json:"operation_id"`
|
||||
ManifestHash string `json:"manifest_hash"`
|
||||
AuthorizationID string `json:"authorization_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
Backend string `json:"backend"`
|
||||
Operation string `json:"operation"`
|
||||
Decision string `json:"decision"` // executed | denied | failed
|
||||
Reason string `json:"reason"`
|
||||
Executed bool `json:"executed"`
|
||||
VerifiedActions int `json:"verified_actions"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// CanonicalBytes pins the receipt the same way the manifest is pinned, so a
|
||||
// ledger can digest one without re-deriving field order from JSON. Every field
|
||||
// is present even when empty — a refusal before parse still produces a receipt,
|
||||
// and its emptiness is part of the record.
|
||||
func (r MutationReceipt) CanonicalBytes() []byte {
|
||||
return canonicalRecord(
|
||||
receiptDomain,
|
||||
strconv.Itoa(r.ProtocolVersion),
|
||||
r.OperationID,
|
||||
r.ManifestHash,
|
||||
r.AuthorizationID,
|
||||
r.TargetID,
|
||||
r.Backend,
|
||||
r.Operation,
|
||||
r.Decision,
|
||||
r.Reason,
|
||||
strconv.FormatBool(r.Executed),
|
||||
strconv.Itoa(r.VerifiedActions),
|
||||
strconv.Itoa(r.ExitCode),
|
||||
r.Error,
|
||||
strconv.FormatInt(r.Timestamp, 10),
|
||||
)
|
||||
}
|
||||
|
||||
func (r MutationReceipt) Digest() string {
|
||||
digest := sha256.Sum256(r.CanonicalBytes())
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
// MutationOutcome is what the executor did, separate from which envelope it
|
||||
// did it to.
|
||||
type MutationOutcome struct {
|
||||
Decision string
|
||||
Reason string
|
||||
Executed bool
|
||||
VerifiedActions int
|
||||
ExitCode int
|
||||
Detail string
|
||||
}
|
||||
|
||||
// ReceiptFor copies the audit join out of signed bytes rather than retyping it.
|
||||
func (e MutationEnvelope) ReceiptFor(outcome MutationOutcome, now int64) MutationReceipt {
|
||||
return MutationReceipt{
|
||||
ProtocolVersion: MutationProtocolVersion,
|
||||
OperationID: e.Manifest.OperationID,
|
||||
ManifestHash: e.Manifest.Hash(),
|
||||
AuthorizationID: e.Authorization.AuthorizationID,
|
||||
TargetID: e.Manifest.TargetID,
|
||||
Backend: e.Manifest.Backend,
|
||||
Operation: e.Manifest.Operation,
|
||||
Decision: outcome.Decision,
|
||||
Reason: outcome.Reason,
|
||||
Executed: outcome.Executed,
|
||||
VerifiedActions: outcome.VerifiedActions,
|
||||
ExitCode: outcome.ExitCode,
|
||||
Error: outcome.Detail,
|
||||
Timestamp: now,
|
||||
}
|
||||
}
|
||||
302
agent/internal/capability/mutation_manifest_test.go
Normal file
302
agent/internal/capability/mutation_manifest_test.go
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
package capability
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mutationProtocolFixture struct {
|
||||
Manifest MutationManifest `json:"manifest"`
|
||||
Authorization MutationAuthorization `json:"authorization"`
|
||||
Receipt MutationReceipt `json:"receipt"`
|
||||
TestSeed string `json:"test_seed"`
|
||||
ExpectedManifestCanonicalHex string `json:"expected_manifest_canonical_hex"`
|
||||
ExpectedManifestHash string `json:"expected_manifest_hash"`
|
||||
ExpectedAuthorizationCanonical string `json:"expected_authorization_canonical_hex"`
|
||||
ExpectedKeyID string `json:"expected_key_id"`
|
||||
ExpectedSignature string `json:"expected_signature"`
|
||||
ExpectedReceiptCanonicalHex string `json:"expected_receipt_canonical_hex"`
|
||||
ExpectedReceiptDigest string `json:"expected_receipt_digest"`
|
||||
}
|
||||
|
||||
func loadMutationProtocolFixture(t *testing.T) mutationProtocolFixture {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../../protocol/testdata/mutation-golden.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture mutationProtocolFixture
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
func signedMutationProtocolFixture(t *testing.T) (MutationManifest, MutationAuthorization, ed25519.PublicKey) {
|
||||
t.Helper()
|
||||
fixture := loadMutationProtocolFixture(t)
|
||||
seed, err := hex.DecodeString(fixture.TestSeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privateKey := ed25519.NewKeyFromSeed(seed)
|
||||
authorization := fixture.Authorization
|
||||
if err := authorization.Sign(privateKey, fixture.Manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fixture.Manifest, authorization, privateKey.Public().(ed25519.PublicKey)
|
||||
}
|
||||
|
||||
func cloneMutationManifest(t *testing.T, manifest MutationManifest) MutationManifest {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var clone MutationManifest
|
||||
if err := json.Unmarshal(raw, &clone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func TestMutationProtocolGoldenVector(t *testing.T) {
|
||||
fixture := loadMutationProtocolFixture(t)
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
if fixture.ExpectedManifestHash == "" {
|
||||
t.Fatalf(
|
||||
"fill fixture: manifest_canonical=%s\nmanifest_hash=%s\nauthorization_canonical=%s\nkey_id=%s\nsignature=%s\nreceipt_canonical=%s\nreceipt_digest=%s",
|
||||
hex.EncodeToString(manifest.CanonicalBytes()),
|
||||
manifest.Hash(),
|
||||
hex.EncodeToString(authorization.CanonicalMessage()),
|
||||
authorization.KeyID,
|
||||
authorization.Signature,
|
||||
hex.EncodeToString(fixture.Receipt.CanonicalBytes()),
|
||||
fixture.Receipt.Digest(),
|
||||
)
|
||||
}
|
||||
if got := hex.EncodeToString(manifest.CanonicalBytes()); got != fixture.ExpectedManifestCanonicalHex {
|
||||
t.Fatalf("manifest canonical bytes = %q, want %q", got, fixture.ExpectedManifestCanonicalHex)
|
||||
}
|
||||
if got := manifest.Hash(); got != fixture.ExpectedManifestHash {
|
||||
t.Fatalf("manifest hash = %q, want %q", got, fixture.ExpectedManifestHash)
|
||||
}
|
||||
if got := hex.EncodeToString(authorization.CanonicalMessage()); got != fixture.ExpectedAuthorizationCanonical {
|
||||
t.Fatalf("authorization canonical bytes = %q, want %q", got, fixture.ExpectedAuthorizationCanonical)
|
||||
}
|
||||
if authorization.KeyID != fixture.ExpectedKeyID {
|
||||
t.Fatalf("key id = %q, want %q", authorization.KeyID, fixture.ExpectedKeyID)
|
||||
}
|
||||
if authorization.Signature != fixture.ExpectedSignature {
|
||||
t.Fatalf("signature = %q, want %q", authorization.Signature, fixture.ExpectedSignature)
|
||||
}
|
||||
if got := hex.EncodeToString(fixture.Receipt.CanonicalBytes()); got != fixture.ExpectedReceiptCanonicalHex {
|
||||
t.Fatalf("receipt canonical bytes = %q, want %q", got, fixture.ExpectedReceiptCanonicalHex)
|
||||
}
|
||||
if got := fixture.Receipt.Digest(); got != fixture.ExpectedReceiptDigest {
|
||||
t.Fatalf("receipt digest = %q, want %q", got, fixture.ExpectedReceiptDigest)
|
||||
}
|
||||
envelope := MutationEnvelope{Manifest: manifest, Authorization: authorization}
|
||||
if err := envelope.VerifyForExecutionAt(publicKey, 1_700_000_100); err != nil {
|
||||
t.Fatalf("golden authorization did not verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationProtocolOrderingAndDuplicateSemantics(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
reordered := cloneMutationManifest(t, manifest)
|
||||
reordered.ResolvedActions[0], reordered.ResolvedActions[1] = reordered.ResolvedActions[1], reordered.ResolvedActions[0]
|
||||
reordered.Evidence[0], reordered.Evidence[1] = reordered.Evidence[1], reordered.Evidence[0]
|
||||
if reordered.Hash() != manifest.Hash() {
|
||||
t.Fatal("manifest hash changed when action/evidence order changed")
|
||||
}
|
||||
if err := authorization.Verify(publicKey, reordered); err != nil {
|
||||
t.Fatalf("authorization rejected reordered manifest: %v", err)
|
||||
}
|
||||
|
||||
duplicate := cloneMutationManifest(t, manifest)
|
||||
duplicate.ResolvedActions = append(duplicate.ResolvedActions, duplicate.ResolvedActions[0])
|
||||
if duplicate.Hash() == manifest.Hash() {
|
||||
t.Fatal("exact duplicate action was silently de-duplicated")
|
||||
}
|
||||
if err := authorization.Verify(publicKey, duplicate); err == nil {
|
||||
t.Fatal("authorization accepted a duplicate resolved action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationProtocolExecutorAffectingTamperFails(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
tests := map[string]func(*MutationManifest){
|
||||
"provenance": func(m *MutationManifest) { m.Evidence[0].Digest = strings.Repeat("c", 64) },
|
||||
"execution location": func(m *MutationManifest) {
|
||||
m.ResolvedActions[0].Payload = strings.Replace(m.ResolvedActions[0].Payload, "/var/cache/redflag", "/tmp", 1)
|
||||
},
|
||||
"target": func(m *MutationManifest) { m.TargetID = "6f1e2d3c-4b5a-4998-8877-665544332211" },
|
||||
"backend": func(m *MutationManifest) { m.Backend = "wua" },
|
||||
"resolved action": func(m *MutationManifest) { m.ResolvedActions[0].Identity = "zsh@6.0-1" },
|
||||
}
|
||||
for name, tamper := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
changed := cloneMutationManifest(t, manifest)
|
||||
tamper(&changed)
|
||||
if err := authorization.Verify(publicKey, changed); err == nil {
|
||||
t.Fatal("authorization accepted tampered manifest")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
changedAuthorization := authorization
|
||||
changedAuthorization.IssuedAt++
|
||||
if err := changedAuthorization.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatal("authorization accepted tampered authorization metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationProtocolUnknownVersionsFailClosed(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
manifest.ProtocolVersion++
|
||||
if err := manifest.Validate(); err == nil {
|
||||
t.Fatal("unknown manifest version passed validation")
|
||||
}
|
||||
|
||||
manifest.ProtocolVersion = MutationProtocolVersion
|
||||
authorization.ProtocolVersion++
|
||||
if err := authorization.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatal("unknown authorization version passed verification")
|
||||
}
|
||||
}
|
||||
|
||||
// The target fields carry the RedFlag agent identity. Both are signed and the
|
||||
// verifier requires them equal, so an executor that binds either one to the
|
||||
// host it read for itself has bound the whole envelope.
|
||||
func TestMutationProtocolTargetBindsManifestAndAuthorization(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
if manifest.TargetID != authorization.TargetID {
|
||||
t.Fatal("golden fixture disagrees with itself about the target")
|
||||
}
|
||||
|
||||
split := authorization
|
||||
split.TargetID = "6f1e2d3c-4b5a-4998-8877-665544332211"
|
||||
if err := split.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatal("authorization for another target verified against this manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationAuthorizationIDIsCanonicalUUIDv4(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
// A replay ledger matched line-by-line has no defence against an embedded
|
||||
// newline; the shape check is what makes the identifier safe to record.
|
||||
for _, bad := range []string{
|
||||
"",
|
||||
"not-a-uuid",
|
||||
"550e8400-e29b-41d4-a716-44665544001",
|
||||
"550e8400-e29b-11d4-a716-446655440011",
|
||||
"550e8400-e29b-41d4-c716-446655440011",
|
||||
"550E8400-E29B-41D4-A716-446655440011",
|
||||
"550e8400-e29b-41d4-a716-4466554400\n1",
|
||||
} {
|
||||
if IsCanonicalUUIDv4(bad) {
|
||||
t.Fatalf("accepted %q as a canonical UUID v4", bad)
|
||||
}
|
||||
changed := authorization
|
||||
changed.AuthorizationID = bad
|
||||
if err := changed.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatalf("authorization with id %q verified", bad)
|
||||
}
|
||||
}
|
||||
|
||||
if !IsCanonicalUUIDv4(authorization.AuthorizationID) {
|
||||
t.Fatalf("golden authorization_id %q is not a canonical UUID v4", authorization.AuthorizationID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationAuthorizationLifetimeCeiling(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
seed, err := hex.DecodeString(loadMutationProtocolFixture(t).TestSeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privateKey := ed25519.NewKeyFromSeed(seed)
|
||||
|
||||
atCeiling := authorization
|
||||
atCeiling.ExpiresAt = atCeiling.NotBefore + MaxAuthorizationLifetimeSeconds
|
||||
if err := atCeiling.Sign(privateKey, manifest); err != nil {
|
||||
t.Fatalf("an authorization exactly at the ceiling must sign: %v", err)
|
||||
}
|
||||
if err := atCeiling.Verify(publicKey, manifest); err != nil {
|
||||
t.Fatalf("an authorization exactly at the ceiling must verify: %v", err)
|
||||
}
|
||||
|
||||
overCeiling := authorization
|
||||
overCeiling.ExpiresAt = overCeiling.NotBefore + MaxAuthorizationLifetimeSeconds + 1
|
||||
if err := overCeiling.Sign(privateKey, manifest); err == nil {
|
||||
t.Fatal("minted an authorization past the lifetime ceiling")
|
||||
}
|
||||
if err := overCeiling.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatal("verified an authorization past the lifetime ceiling")
|
||||
}
|
||||
}
|
||||
|
||||
// Evidence carries digests. Operator reason prose and policy text stay in the
|
||||
// authority's journal, and the shape check is what keeps them out.
|
||||
func TestMutationEvidenceCarriesDigestsNotProse(t *testing.T) {
|
||||
manifest, _, _ := signedMutationProtocolFixture(t)
|
||||
|
||||
for _, bad := range []string{"", "operator accepted the CVE risk", strings.Repeat("a", 63), strings.Repeat("z", 64)} {
|
||||
changed := cloneMutationManifest(t, manifest)
|
||||
changed.Evidence[0].Digest = bad
|
||||
if err := changed.Validate(); err == nil {
|
||||
t.Fatalf("manifest validated with evidence digest %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationReceiptCarriesAuditJoin(t *testing.T) {
|
||||
manifest, authorization, _ := signedMutationProtocolFixture(t)
|
||||
envelope := MutationEnvelope{Manifest: manifest, Authorization: authorization}
|
||||
|
||||
receipt := envelope.ReceiptFor(MutationOutcome{
|
||||
Decision: "denied",
|
||||
Reason: "backend_not_migrated",
|
||||
ExitCode: 18,
|
||||
Detail: "backend=pacman",
|
||||
}, 1_700_000_100)
|
||||
if receipt.OperationID != manifest.OperationID ||
|
||||
receipt.ManifestHash != manifest.Hash() ||
|
||||
receipt.AuthorizationID != authorization.AuthorizationID ||
|
||||
receipt.TargetID != manifest.TargetID {
|
||||
t.Fatal("receipt lost the operation/manifest/authorization/target join")
|
||||
}
|
||||
if receipt.Backend != manifest.Backend || receipt.Operation != manifest.Operation {
|
||||
t.Fatal("receipt lost the backend/operation it answers")
|
||||
}
|
||||
|
||||
// Every recorded field is in the canonical bytes, including the ones a
|
||||
// lossy report would drop first.
|
||||
for name, mutate := range map[string]func(*MutationReceipt){
|
||||
"decision": func(r *MutationReceipt) { r.Decision = "executed" },
|
||||
"reason": func(r *MutationReceipt) { r.Reason = "operation_completed" },
|
||||
"executed": func(r *MutationReceipt) { r.Executed = true },
|
||||
"verified actions": func(r *MutationReceipt) { r.VerifiedActions = 1 },
|
||||
"exit code": func(r *MutationReceipt) { r.ExitCode = 0 },
|
||||
"error": func(r *MutationReceipt) { r.Error = "" },
|
||||
"timestamp": func(r *MutationReceipt) { r.Timestamp++ },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
changed := receipt
|
||||
mutate(&changed)
|
||||
if changed.Digest() == receipt.Digest() {
|
||||
t.Fatal("receipt digest ignored a recorded field")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
110
agent/internal/capability/token.go
Normal file
110
agent/internal/capability/token.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// Package capability defines the supply-chain capability token: an Ed25519-signed
|
||||
// authorization for exactly one package operation over a fully-resolved dependency
|
||||
// closure. The server (authority) mints and signs tokens; the agent passes them to
|
||||
// the privileged Rust executor (helper/) which independently verifies them.
|
||||
//
|
||||
// The canonical signed message and closure hash MUST stay byte-identical across
|
||||
// this package, the server's mirror of it, and helper/src/main.rs. See
|
||||
// RAF/security/05-supply-chain-gate.md for the contract.
|
||||
package capability
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Version is the only token format this code understands. Forward-only doctrine:
|
||||
// new versions add fields, never reinterpret existing ones.
|
||||
const Version = 1
|
||||
|
||||
// ClosureEntry is one resolved artifact in the dependency closure.
|
||||
type ClosureEntry struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Source string `json:"source"` // "mirror" | "registry"
|
||||
ArtifactPath string `json:"artifact_path,omitempty"` // local path or url, optional
|
||||
}
|
||||
|
||||
// Token is the full capability token exchanged between server, agent, and executor.
|
||||
type Token struct {
|
||||
Version int `json:"version"`
|
||||
TokenID string `json:"token_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
KeyID string `json:"key_id"`
|
||||
PackageType string `json:"package_type"` // apt|dnf|npm|bun|pip|docker|winget
|
||||
Operation string `json:"operation"` // install|upgrade (forward-only)
|
||||
Closure []ClosureEntry `json:"closure"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
NotBefore int64 `json:"not_before"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Signature string `json:"signature"` // hex ed25519
|
||||
}
|
||||
|
||||
// ClosureHash computes hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) )).
|
||||
// Lines are sorted and de-duplicated so neither array order nor exact duplicates
|
||||
// can change the digest. This mirrors the Rust BTreeSet construction exactly.
|
||||
func (t *Token) ClosureHash() string {
|
||||
set := make(map[string]struct{}, len(t.Closure))
|
||||
for _, e := range t.Closure {
|
||||
set[fmt.Sprintf("%s@%s#%s", e.Name, e.Version, e.SHA256)] = struct{}{}
|
||||
}
|
||||
lines := make([]string, 0, len(set))
|
||||
for line := range set {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
sort.Strings(lines)
|
||||
|
||||
h := sha256.New()
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
h.Write([]byte("\n"))
|
||||
}
|
||||
h.Write([]byte(line))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// CanonicalMessage builds the deterministic message that is signed/verified:
|
||||
// "{agent_id}:{token_id}:{operation}:{package_type}:{closure_hash}:{expires_at}".
|
||||
func (t *Token) CanonicalMessage() string {
|
||||
return fmt.Sprintf("%s:%s:%s:%s:%s:%d",
|
||||
t.AgentID, t.TokenID, t.Operation, t.PackageType, t.ClosureHash(), t.ExpiresAt)
|
||||
}
|
||||
|
||||
// Sign signs the canonical message with the authority private key, sets the
|
||||
// token's KeyID and Signature, and returns the hex signature.
|
||||
func (t *Token) Sign(priv ed25519.PrivateKey) (string, error) {
|
||||
if len(priv) != ed25519.PrivateKeySize {
|
||||
return "", fmt.Errorf("capability: invalid private key size %d", len(priv))
|
||||
}
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
t.KeyID = KeyIDFor(pub)
|
||||
sig := ed25519.Sign(priv, []byte(t.CanonicalMessage()))
|
||||
t.Signature = hex.EncodeToString(sig)
|
||||
return t.Signature, nil
|
||||
}
|
||||
|
||||
// Verify checks the token's signature against the given public key. It does not
|
||||
// check the validity window, agent binding, or artifact hashes — those are the
|
||||
// executor's responsibility (and the agent's bind-check). It verifies only that
|
||||
// this key signed this canonical message.
|
||||
func (t *Token) Verify(pub ed25519.PublicKey) error {
|
||||
if len(pub) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("capability: invalid public key size %d", len(pub))
|
||||
}
|
||||
sig, err := hex.DecodeString(t.Signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("capability: signature not hex: %w", err)
|
||||
}
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("capability: invalid signature size %d", len(sig))
|
||||
}
|
||||
if !ed25519.Verify(pub, []byte(t.CanonicalMessage()), sig) {
|
||||
return fmt.Errorf("capability: signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
63
agent/internal/capability/token_test.go
Normal file
63
agent/internal/capability/token_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package capability
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Cross-language contract vector. The Rust executor (helper/src/main.rs) asserts
|
||||
// these same strings for the same input. If either side drifts, both break.
|
||||
func TestCanonicalVector(t *testing.T) {
|
||||
tok := &Token{
|
||||
Version: 1, TokenID: "tok-1", AgentID: "agent-123",
|
||||
PackageType: "npm", Operation: "install",
|
||||
Closure: []ClosureEntry{
|
||||
{Name: "left-pad", Version: "1.3.0", SHA256: "aaaa"},
|
||||
{Name: "is-odd", Version: "2.0.0", SHA256: "bbbb"},
|
||||
},
|
||||
ExpiresAt: 1700000000,
|
||||
}
|
||||
const wantHash = "49a181cd7b6df83a6dc83c7f647f2d224effe90907dd5dcdfdaf4c8697af595f"
|
||||
const wantMsg = "agent-123:tok-1:install:npm:" + wantHash + ":1700000000"
|
||||
if got := tok.ClosureHash(); got != wantHash {
|
||||
t.Fatalf("ClosureHash() = %q, want %q", got, wantHash)
|
||||
}
|
||||
if got := tok.CanonicalMessage(); got != wantMsg {
|
||||
t.Fatalf("CanonicalMessage() = %q, want %q", got, wantMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosureHashOrderIndependent(t *testing.T) {
|
||||
a := &Token{Closure: []ClosureEntry{{Name: "a", Version: "1", SHA256: "x"}, {Name: "b", Version: "2", SHA256: "y"}}}
|
||||
b := &Token{Closure: []ClosureEntry{{Name: "b", Version: "2", SHA256: "y"}, {Name: "a", Version: "1", SHA256: "x"}}}
|
||||
if a.ClosureHash() != b.ClosureHash() {
|
||||
t.Fatalf("closure hash depends on order: %q != %q", a.ClosureHash(), b.ClosureHash())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignVerifyRoundtrip(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok := &Token{
|
||||
Version: 1, TokenID: "tok-2", AgentID: "agent-9",
|
||||
PackageType: "dnf", Operation: "upgrade",
|
||||
Closure: []ClosureEntry{{Name: "openssl", Version: "3.2.1", SHA256: "deadbeef"}},
|
||||
ExpiresAt: 1700000000,
|
||||
}
|
||||
if _, err := tok.Sign(priv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.KeyID != KeyIDFor(pub) {
|
||||
t.Fatalf("KeyID = %q, want %q", tok.KeyID, KeyIDFor(pub))
|
||||
}
|
||||
if err := tok.Verify(pub); err != nil {
|
||||
t.Fatalf("Verify after Sign failed: %v", err)
|
||||
}
|
||||
// Tamper detection: any change to the closure breaks verification.
|
||||
tok.Closure[0].Version = "3.2.2"
|
||||
if err := tok.Verify(pub); err == nil {
|
||||
t.Fatal("Verify accepted a tampered closure")
|
||||
}
|
||||
}
|
||||
233
agent/internal/circuitbreaker/circuitbreaker.go
Normal file
233
agent/internal/circuitbreaker/circuitbreaker.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State represents the circuit breaker state
|
||||
type State int
|
||||
|
||||
const (
|
||||
StateClosed State = iota // Normal operation
|
||||
StateOpen // Circuit is open, failing fast
|
||||
StateHalfOpen // Testing if service recovered
|
||||
)
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case StateClosed:
|
||||
return "closed"
|
||||
case StateOpen:
|
||||
return "open"
|
||||
case StateHalfOpen:
|
||||
return "half-open"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Config holds circuit breaker configuration
|
||||
type Config struct {
|
||||
FailureThreshold int // Number of failures before opening
|
||||
FailureWindow time.Duration // Time window to track failures
|
||||
OpenDuration time.Duration // How long circuit stays open
|
||||
HalfOpenAttempts int // Successful attempts needed to close from half-open
|
||||
}
|
||||
|
||||
// CircuitBreaker implements the circuit breaker pattern for subsystems
|
||||
type CircuitBreaker struct {
|
||||
name string
|
||||
config Config
|
||||
|
||||
mu sync.RWMutex
|
||||
state State
|
||||
failures []time.Time // Timestamps of recent failures
|
||||
consecutiveSuccess int // Consecutive successes in half-open state
|
||||
openedAt time.Time // When circuit was opened
|
||||
}
|
||||
|
||||
// New creates a new circuit breaker
|
||||
func New(name string, config Config) *CircuitBreaker {
|
||||
return &CircuitBreaker{
|
||||
name: name,
|
||||
config: config,
|
||||
state: StateClosed,
|
||||
failures: make([]time.Time, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Call executes the given function with circuit breaker protection
|
||||
func (cb *CircuitBreaker) Call(fn func() error) error {
|
||||
// Check if we can execute
|
||||
if err := cb.beforeCall(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Execute the function
|
||||
err := fn()
|
||||
|
||||
// Record the result
|
||||
cb.afterCall(err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// beforeCall checks if the call should be allowed
|
||||
func (cb *CircuitBreaker) beforeCall() error {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
switch cb.state {
|
||||
case StateClosed:
|
||||
// Normal operation, allow call
|
||||
return nil
|
||||
|
||||
case StateOpen:
|
||||
// Check if enough time has passed to try half-open
|
||||
if time.Since(cb.openedAt) >= cb.config.OpenDuration {
|
||||
cb.state = StateHalfOpen
|
||||
cb.consecutiveSuccess = 0
|
||||
return nil
|
||||
}
|
||||
// Circuit is still open, fail fast
|
||||
return fmt.Errorf("circuit breaker [%s] is OPEN (will retry at %s)",
|
||||
cb.name, cb.openedAt.Add(cb.config.OpenDuration).Format("15:04:05"))
|
||||
|
||||
case StateHalfOpen:
|
||||
// In half-open state, allow limited attempts
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown circuit breaker state")
|
||||
}
|
||||
}
|
||||
|
||||
// afterCall records the result and updates state
|
||||
func (cb *CircuitBreaker) afterCall(err error) {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if err != nil {
|
||||
// Record failure
|
||||
cb.recordFailure(now)
|
||||
|
||||
// If in half-open, go back to open on any failure
|
||||
if cb.state == StateHalfOpen {
|
||||
cb.state = StateOpen
|
||||
cb.openedAt = now
|
||||
cb.consecutiveSuccess = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we should open the circuit
|
||||
if cb.shouldOpen(now) {
|
||||
cb.state = StateOpen
|
||||
cb.openedAt = now
|
||||
cb.consecutiveSuccess = 0
|
||||
}
|
||||
} else {
|
||||
// Success
|
||||
switch cb.state {
|
||||
case StateHalfOpen:
|
||||
// Count consecutive successes
|
||||
cb.consecutiveSuccess++
|
||||
if cb.consecutiveSuccess >= cb.config.HalfOpenAttempts {
|
||||
// Enough successes, close the circuit
|
||||
cb.state = StateClosed
|
||||
cb.failures = make([]time.Time, 0)
|
||||
cb.consecutiveSuccess = 0
|
||||
}
|
||||
|
||||
case StateClosed:
|
||||
// Clean up old failures on success
|
||||
cb.cleanupOldFailures(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordFailure adds a failure timestamp
|
||||
func (cb *CircuitBreaker) recordFailure(now time.Time) {
|
||||
cb.failures = append(cb.failures, now)
|
||||
cb.cleanupOldFailures(now)
|
||||
}
|
||||
|
||||
// cleanupOldFailures removes failures outside the window
|
||||
func (cb *CircuitBreaker) cleanupOldFailures(now time.Time) {
|
||||
cutoff := now.Add(-cb.config.FailureWindow)
|
||||
validFailures := make([]time.Time, 0)
|
||||
|
||||
for _, failTime := range cb.failures {
|
||||
if failTime.After(cutoff) {
|
||||
validFailures = append(validFailures, failTime)
|
||||
}
|
||||
}
|
||||
|
||||
cb.failures = validFailures
|
||||
}
|
||||
|
||||
// shouldOpen determines if circuit should open based on failures
|
||||
func (cb *CircuitBreaker) shouldOpen(now time.Time) bool {
|
||||
cb.cleanupOldFailures(now)
|
||||
return len(cb.failures) >= cb.config.FailureThreshold
|
||||
}
|
||||
|
||||
// State returns the current circuit breaker state (thread-safe)
|
||||
func (cb *CircuitBreaker) State() State {
|
||||
cb.mu.RLock()
|
||||
defer cb.mu.RUnlock()
|
||||
return cb.state
|
||||
}
|
||||
|
||||
// GetStats returns current circuit breaker statistics
|
||||
func (cb *CircuitBreaker) GetStats() Stats {
|
||||
cb.mu.RLock()
|
||||
defer cb.mu.RUnlock()
|
||||
|
||||
stats := Stats{
|
||||
Name: cb.name,
|
||||
State: cb.state.String(),
|
||||
RecentFailures: len(cb.failures),
|
||||
ConsecutiveSuccess: cb.consecutiveSuccess,
|
||||
}
|
||||
|
||||
if cb.state == StateOpen && !cb.openedAt.IsZero() {
|
||||
nextAttempt := cb.openedAt.Add(cb.config.OpenDuration)
|
||||
stats.NextAttempt = &nextAttempt
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// Reset manually resets the circuit breaker to closed state
|
||||
func (cb *CircuitBreaker) Reset() {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
cb.state = StateClosed
|
||||
cb.failures = make([]time.Time, 0)
|
||||
cb.consecutiveSuccess = 0
|
||||
cb.openedAt = time.Time{}
|
||||
}
|
||||
|
||||
// Stats holds circuit breaker statistics
|
||||
type Stats struct {
|
||||
Name string
|
||||
State string
|
||||
RecentFailures int
|
||||
ConsecutiveSuccess int
|
||||
NextAttempt *time.Time
|
||||
}
|
||||
|
||||
// String returns a human-readable representation of the stats
|
||||
func (s Stats) String() string {
|
||||
if s.NextAttempt != nil {
|
||||
return fmt.Sprintf("[%s] state=%s, failures=%d, next_attempt=%s",
|
||||
s.Name, s.State, s.RecentFailures, s.NextAttempt.Format("15:04:05"))
|
||||
}
|
||||
return fmt.Sprintf("[%s] state=%s, failures=%d, success=%d",
|
||||
s.Name, s.State, s.RecentFailures, s.ConsecutiveSuccess)
|
||||
}
|
||||
138
agent/internal/circuitbreaker/circuitbreaker_test.go
Normal file
138
agent/internal/circuitbreaker/circuitbreaker_test.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCircuitBreaker_NormalOperation(t *testing.T) {
|
||||
cb := New("test", Config{
|
||||
FailureThreshold: 3,
|
||||
FailureWindow: 1 * time.Minute,
|
||||
OpenDuration: 1 * time.Minute,
|
||||
HalfOpenAttempts: 2,
|
||||
})
|
||||
|
||||
// Should allow calls in closed state
|
||||
err := cb.Call(func() error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if cb.State() != StateClosed {
|
||||
t.Fatalf("expected state closed, got %v", cb.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_OpensAfterFailures(t *testing.T) {
|
||||
cb := New("test", Config{
|
||||
FailureThreshold: 3,
|
||||
FailureWindow: 1 * time.Minute,
|
||||
OpenDuration: 100 * time.Millisecond,
|
||||
HalfOpenAttempts: 2,
|
||||
})
|
||||
|
||||
testErr := errors.New("test error")
|
||||
|
||||
// Record 3 failures
|
||||
for i := 0; i < 3; i++ {
|
||||
cb.Call(func() error { return testErr })
|
||||
}
|
||||
|
||||
// Should now be open
|
||||
if cb.State() != StateOpen {
|
||||
t.Fatalf("expected state open after %d failures, got %v", 3, cb.State())
|
||||
}
|
||||
|
||||
// Next call should fail fast
|
||||
err := cb.Call(func() error { return nil })
|
||||
if err == nil {
|
||||
t.Fatal("expected circuit breaker to reject call, but it succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_HalfOpenRecovery(t *testing.T) {
|
||||
cb := New("test", Config{
|
||||
FailureThreshold: 2,
|
||||
FailureWindow: 1 * time.Minute,
|
||||
OpenDuration: 50 * time.Millisecond,
|
||||
HalfOpenAttempts: 2,
|
||||
})
|
||||
|
||||
testErr := errors.New("test error")
|
||||
|
||||
// Open the circuit
|
||||
cb.Call(func() error { return testErr })
|
||||
cb.Call(func() error { return testErr })
|
||||
|
||||
if cb.State() != StateOpen {
|
||||
t.Fatal("circuit should be open")
|
||||
}
|
||||
|
||||
// Wait for open duration
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
// Should transition to half-open and allow call
|
||||
err := cb.Call(func() error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("expected call to succeed in half-open state, got %v", err)
|
||||
}
|
||||
|
||||
if cb.State() != StateHalfOpen {
|
||||
t.Fatalf("expected half-open state, got %v", cb.State())
|
||||
}
|
||||
|
||||
// One more success should close it
|
||||
cb.Call(func() error { return nil })
|
||||
|
||||
if cb.State() != StateClosed {
|
||||
t.Fatalf("expected closed state after %d successes, got %v", 2, cb.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_HalfOpenFailure(t *testing.T) {
|
||||
cb := New("test", Config{
|
||||
FailureThreshold: 2,
|
||||
FailureWindow: 1 * time.Minute,
|
||||
OpenDuration: 50 * time.Millisecond,
|
||||
HalfOpenAttempts: 2,
|
||||
})
|
||||
|
||||
testErr := errors.New("test error")
|
||||
|
||||
// Open the circuit
|
||||
cb.Call(func() error { return testErr })
|
||||
cb.Call(func() error { return testErr })
|
||||
|
||||
// Wait and attempt in half-open
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
cb.Call(func() error { return nil }) // Half-open
|
||||
|
||||
// Fail in half-open - should go back to open
|
||||
cb.Call(func() error { return testErr })
|
||||
|
||||
if cb.State() != StateOpen {
|
||||
t.Fatalf("expected open state after half-open failure, got %v", cb.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_Stats(t *testing.T) {
|
||||
cb := New("test-subsystem", Config{
|
||||
FailureThreshold: 3,
|
||||
FailureWindow: 1 * time.Minute,
|
||||
OpenDuration: 1 * time.Minute,
|
||||
HalfOpenAttempts: 2,
|
||||
})
|
||||
|
||||
stats := cb.GetStats()
|
||||
if stats.Name != "test-subsystem" {
|
||||
t.Fatalf("expected name 'test-subsystem', got %s", stats.Name)
|
||||
}
|
||||
if stats.State != "closed" {
|
||||
t.Fatalf("expected state 'closed', got %s", stats.State)
|
||||
}
|
||||
if stats.RecentFailures != 0 {
|
||||
t.Fatalf("expected 0 failures, got %d", stats.RecentFailures)
|
||||
}
|
||||
}
|
||||
1381
agent/internal/client/client.go
Normal file
1381
agent/internal/client/client.go
Normal file
File diff suppressed because it is too large
Load diff
31
agent/internal/client/inventory.go
Normal file
31
agent/internal/client/inventory.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package client
|
||||
|
||||
import "time"
|
||||
|
||||
// InventoryItem represents a single inventory record -- something present on
|
||||
// the system. Distinct from UpdateReportItem (which represents an available
|
||||
// update) and MetricsReportItem (which represents a point-in-time measurement).
|
||||
//
|
||||
// Inspired by osquery's per-ecosystem table model (rpm_packages, deb_packages,
|
||||
// programs, docker_images) where each item has typed, indexed fields rather
|
||||
// than a freeform Metadata map.
|
||||
type InventoryItem struct {
|
||||
Ecosystem string `json:"inventory_ecosystem"` // "docker", "system", "apt", "dnf", etc.
|
||||
ItemName string `json:"item_name"` // Primary identifier within ecosystem
|
||||
ItemVersion string `json:"item_version"` // Installed version / digest
|
||||
Description string `json:"description,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
InstallTime string `json:"install_time,omitempty"` // RFC3339 or ecosystem-specific
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"` // Registry, repo, manufacturer
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// InventoryReport is sent by agents when reporting inventory data.
|
||||
type InventoryReport struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Ecosystem string `json:"inventory_ecosystem"`
|
||||
Items []InventoryItem `json:"items"`
|
||||
ScanSucceeded bool `json:"scan_succeeded"`
|
||||
}
|
||||
77
agent/internal/client/machine_id_logging_test.go
Normal file
77
agent/internal/client/machine_id_logging_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package client
|
||||
|
||||
// machine_id_logging_test.go — Pre-fix tests for machine ID logging format.
|
||||
//
|
||||
// F-D1-5 LOW: client.go:39 uses fmt.Printf instead of log.Printf.
|
||||
//
|
||||
// Run: cd agent && go test ./internal/client/... -v -run TestClientMachineID
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 6.1 — Documents fmt.Printf usage (F-D1-5)
|
||||
//
|
||||
// Category: PASS-NOW (documents ETHOS violation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestClientMachineIDErrorUsesFmtPrintf(t *testing.T) {
|
||||
// POST-FIX (F-D1-5): fmt.Printf replaced with log.Printf.
|
||||
content, err := os.ReadFile("client.go")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read client.go: %v", err)
|
||||
}
|
||||
|
||||
src := string(content)
|
||||
|
||||
newClientIdx := strings.Index(src, "func NewClient(")
|
||||
if newClientIdx == -1 {
|
||||
t.Fatal("[ERROR] [agent] [client] NewClient function not found")
|
||||
}
|
||||
|
||||
fnBody := src[newClientIdx:]
|
||||
nextFn := strings.Index(fnBody[1:], "\nfunc ")
|
||||
if nextFn > 0 {
|
||||
fnBody = fnBody[:nextFn+1]
|
||||
}
|
||||
|
||||
if strings.Contains(fnBody, "fmt.Printf") {
|
||||
t.Error("[ERROR] [agent] [client] F-D1-5 NOT FIXED: fmt.Printf still in NewClient")
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [client] F-D1-5 FIXED: structured logging in NewClient")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 6.2 — Must use structured logging (assert fix)
|
||||
//
|
||||
// Category: FAIL-NOW / PASS-AFTER-FIX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestClientMachineIDErrorUsesStructuredLogging(t *testing.T) {
|
||||
content, err := os.ReadFile("client.go")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read client.go: %v", err)
|
||||
}
|
||||
|
||||
src := string(content)
|
||||
|
||||
newClientIdx := strings.Index(src, "func NewClient(")
|
||||
if newClientIdx == -1 {
|
||||
t.Fatal("[ERROR] [agent] [client] NewClient function not found")
|
||||
}
|
||||
|
||||
fnBody := src[newClientIdx:]
|
||||
nextFn := strings.Index(fnBody[1:], "\nfunc ")
|
||||
if nextFn > 0 {
|
||||
fnBody = fnBody[:nextFn+1]
|
||||
}
|
||||
|
||||
if strings.Contains(fnBody, "fmt.Printf") {
|
||||
t.Errorf("[ERROR] [agent] [client] NewClient uses fmt.Printf for machine ID error.\n" +
|
||||
"F-D1-5: use log.Printf with [WARNING] [agent] [client] format.")
|
||||
}
|
||||
}
|
||||
44
agent/internal/common/agentfile.go
Normal file
44
agent/internal/common/agentfile.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AgentFile struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedTime time.Time `json:"modified_time"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Checksum string `json:"checksum"`
|
||||
Required bool `json:"required"`
|
||||
Migrate bool `json:"migrate"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// CalculateChecksum computes SHA256 checksum of a file
|
||||
func CalculateChecksum(filePath string) (string, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := sha256.Sum256(data)
|
||||
return hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
// IsRequiredFile determines if a file is required for agent operation
|
||||
func IsRequiredFile(path string) bool {
|
||||
requiredFiles := []string{
|
||||
"/etc/redflag/agent/config.json", // Agent config in nested structure
|
||||
"/usr/local/bin/redflag-agent",
|
||||
"/etc/systemd/system/redflag-agent.service",
|
||||
}
|
||||
for _, rf := range requiredFiles {
|
||||
if path == rf {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
941
agent/internal/config/config.go
Normal file
941
agent/internal/config/config.go
Normal file
|
|
@ -0,0 +1,941 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/event"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
var teeLogger *event.TeeLogger
|
||||
|
||||
// InitLogger sets the package-level TeeLogger for dual-output logging.
|
||||
func InitLogger(l *event.TeeLogger) {
|
||||
teeLogger = l
|
||||
}
|
||||
|
||||
// MigrationState tracks migration completion status (used by migration package)
|
||||
type MigrationState struct {
|
||||
LastCompleted map[string]time.Time `json:"last_completed"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
ConfigVersion string `json:"config_version"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Success bool `json:"success"`
|
||||
RollbackPath string `json:"rollback_path,omitempty"`
|
||||
CompletedMigrations []string `json:"completed_migrations"`
|
||||
}
|
||||
|
||||
// ProxyConfig holds proxy configuration
|
||||
type ProxyConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
HTTP string `json:"http,omitempty"` // HTTP proxy URL
|
||||
HTTPS string `json:"https,omitempty"` // HTTPS proxy URL
|
||||
NoProxy string `json:"no_proxy,omitempty"` // Comma-separated hosts to bypass proxy
|
||||
Username string `json:"username,omitempty"` // Proxy username (optional)
|
||||
Password string `json:"password,omitempty"` // Proxy password (optional)
|
||||
}
|
||||
|
||||
// TLSConfig holds TLS/security configuration
|
||||
type TLSConfig struct {
|
||||
InsecureSkipVerify bool `json:"insecure_skip_verify"` // Skip TLS certificate verification
|
||||
CertFile string `json:"cert_file,omitempty"` // Client certificate file
|
||||
KeyFile string `json:"key_file,omitempty"` // Client key file
|
||||
CAFile string `json:"ca_file,omitempty"` // CA certificate file
|
||||
}
|
||||
|
||||
// NetworkConfig holds network-related configuration
|
||||
type NetworkConfig struct {
|
||||
Timeout time.Duration `json:"timeout"` // Request timeout
|
||||
RetryCount int `json:"retry_count"` // Number of retries
|
||||
RetryDelay time.Duration `json:"retry_delay"` // Delay between retries
|
||||
MaxIdleConn int `json:"max_idle_conn"` // Maximum idle connections
|
||||
}
|
||||
|
||||
// LoggingConfig holds logging configuration
|
||||
type LoggingConfig struct {
|
||||
Level string `json:"level"` // Log level (debug, info, warn, error)
|
||||
File string `json:"file,omitempty"` // Log file path (optional)
|
||||
MaxSize int `json:"max_size"` // Max log file size in MB
|
||||
MaxBackups int `json:"max_backups"` // Max number of log file backups
|
||||
MaxAge int `json:"max_age"` // Max age of log files in days
|
||||
}
|
||||
|
||||
// SecurityLogConfig holds configuration for security logging
|
||||
type SecurityLogConfig struct {
|
||||
Enabled bool `json:"enabled" env:"REDFLAG_AGENT_SECURITY_LOG_ENABLED" default:"true"`
|
||||
Level string `json:"level" env:"REDFLAG_AGENT_SECURITY_LOG_LEVEL" default:"warning"` // none, error, warn, info, debug
|
||||
LogSuccesses bool `json:"log_successes" env:"REDFLAG_AGENT_SECURITY_LOG_SUCCESSES" default:"false"`
|
||||
FilePath string `json:"file_path" env:"REDFLAG_AGENT_SECURITY_LOG_PATH"` // Relative to agent data directory
|
||||
MaxSizeMB int `json:"max_size_mb" env:"REDFLAG_AGENT_SECURITY_LOG_MAX_SIZE" default:"50"`
|
||||
MaxFiles int `json:"max_files" env:"REDFLAG_AGENT_SECURITY_LOG_MAX_FILES" default:"5"`
|
||||
BatchSize int `json:"batch_size" env:"REDFLAG_AGENT_SECURITY_LOG_BATCH_SIZE" default:"10"`
|
||||
SendToServer bool `json:"send_to_server" env:"REDFLAG_AGENT_SECURITY_LOG_SEND" default:"true"`
|
||||
}
|
||||
|
||||
// CommandSigningConfig holds configuration for command signature verification
|
||||
type CommandSigningConfig struct {
|
||||
Enabled bool `json:"enabled" env:"REDFLAG_AGENT_COMMAND_SIGNING_ENABLED" default:"true"`
|
||||
EnforcementMode string `json:"enforcement_mode" env:"REDFLAG_AGENT_COMMAND_ENFORCEMENT_MODE" default:"strict"` // strict, warning, disabled
|
||||
// StaleKeyMaxAgeHours bounds how long the agent serves its cached server
|
||||
// public key while the server is unreachable (SEC-028). 0 = built-in default.
|
||||
// The agent clamps to a doctrinal ceiling regardless; this only tunes within
|
||||
// it. Delivered fleet-wide via GET /api/v1/agents/:id/config.
|
||||
StaleKeyMaxAgeHours int `json:"stale_key_max_age_hours,omitempty" env:"REDFLAG_AGENT_STALE_KEY_MAX_AGE_HOURS"`
|
||||
}
|
||||
|
||||
// PollingConfig holds admin-adjustable polling resilience tuning. These shape
|
||||
// the check-in jitter and the reconnect backoff curve. Zero values fall back to
|
||||
// built-in defaults (see the resilience defaults in internal/agent/loop.go), so
|
||||
// older config files without these fields keep working.
|
||||
type PollingConfig struct {
|
||||
JitterMaxSeconds int `json:"jitter_max_seconds,omitempty"` // cap on proportional check-in jitter (default 30)
|
||||
BackoffBaseSeconds int `json:"backoff_base_seconds,omitempty"` // reconnect backoff floor (default 10)
|
||||
BackoffMaxSeconds int `json:"backoff_max_seconds,omitempty"` // reconnect backoff ceiling (default 300)
|
||||
}
|
||||
|
||||
// ProcessExplorerConfig holds limits for process detail data collection.
|
||||
// Zero values use the built-in defaults.
|
||||
type ProcessExplorerConfig struct {
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
MaxListeningPorts int `json:"max_listening_ports,omitempty"` // Cap on listening ports per process (default 100)
|
||||
}
|
||||
|
||||
// Config holds agent configuration
|
||||
type Config struct {
|
||||
// Version Information
|
||||
Version string `json:"version,omitempty"` // Config schema version
|
||||
AgentVersion string `json:"agent_version,omitempty"` // Agent binary version
|
||||
|
||||
// Server Configuration
|
||||
ServerURL string `json:"server_url"`
|
||||
RegistrationToken string `json:"registration_token,omitempty"` // One-time registration token
|
||||
|
||||
// Agent Authentication
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"` // Short-lived access token (24h)
|
||||
RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
|
||||
|
||||
// Agent Behavior
|
||||
CheckInInterval int `json:"check_in_interval"`
|
||||
|
||||
// Rapid polling mode for faster response during operations
|
||||
RapidPollingEnabled bool `json:"rapid_polling_enabled"`
|
||||
RapidPollingUntil time.Time `json:"rapid_polling_until"`
|
||||
|
||||
// Polling resilience tuning (admin-adjustable; server authority)
|
||||
Polling PollingConfig `json:"polling,omitempty"`
|
||||
|
||||
// Degraded mode for operation after repeated failures
|
||||
DegradedMode bool `json:"degraded_mode"`
|
||||
|
||||
// Network Configuration
|
||||
Network NetworkConfig `json:"network,omitempty"`
|
||||
|
||||
// Proxy Configuration
|
||||
Proxy ProxyConfig `json:"proxy,omitempty"`
|
||||
|
||||
// Security Configuration
|
||||
TLS TLSConfig `json:"tls,omitempty"`
|
||||
|
||||
// Logging Configuration
|
||||
Logging LoggingConfig `json:"logging,omitempty"`
|
||||
|
||||
// Security Logging Configuration
|
||||
SecurityLogging SecurityLogConfig `json:"security_logging,omitempty"`
|
||||
|
||||
// Command Signing Configuration
|
||||
CommandSigning CommandSigningConfig `json:"command_signing,omitempty"`
|
||||
|
||||
// Package Hash Verification Configuration
|
||||
PackageHashes map[string]string `json:"package_hashes,omitempty"` // package_name:expected_sha256
|
||||
|
||||
// Agent Metadata
|
||||
Tags []string `json:"tags,omitempty"` // User-defined tags
|
||||
Metadata map[string]string `json:"metadata,omitempty"` // Custom metadata
|
||||
DisplayName string `json:"display_name,omitempty"` // Human-readable name
|
||||
Organization string `json:"organization,omitempty"` // Organization/group
|
||||
|
||||
// OS Type (linux, windows, darwin)
|
||||
OSType string `json:"os_type,omitempty"`
|
||||
|
||||
// OS holds OS-specific information
|
||||
OS OS `json:"os,omitempty"`
|
||||
|
||||
// Subsystem Configuration
|
||||
Subsystems SubsystemsConfig `json:"subsystems,omitempty"` // Scanner subsystem configs
|
||||
|
||||
// Kernel Enforcement Configuration
|
||||
KernelEnforcement KernelEnforcementConfig `json:"kernel_enforcement,omitempty"`
|
||||
|
||||
// Desktop App Configuration
|
||||
Desktop DesktopConfig `json:"desktop,omitempty"`
|
||||
|
||||
// Process Explorer Configuration
|
||||
ProcessExplorer ProcessExplorerConfig `json:"process_explorer,omitempty"`
|
||||
|
||||
// Migration State
|
||||
MigrationState *MigrationState `json:"migration_state,omitempty"` // Migration completion tracking
|
||||
}
|
||||
|
||||
// DesktopConfig controls the native Qt/QML local-machine operations console.
|
||||
// The agent service spawns the desktop binary as a child process when a
|
||||
// desktop session is detected. The binary connects back to the agent's
|
||||
// local API socket.
|
||||
type DesktopConfig struct {
|
||||
Enabled bool `json:"enabled"` // Whether to auto-launch the desktop app
|
||||
MaxRestarts int `json:"max_restarts"` // Max restarts before giving up (0 = unlimited)
|
||||
RestartDelaySec int `json:"restart_delay_sec"` // Seconds between restart attempts
|
||||
}
|
||||
|
||||
// Load reads configuration from multiple sources with priority order:
|
||||
// 1. CLI flags
|
||||
// 2. Environment variables
|
||||
// 3. Configuration file
|
||||
// 4. Default values
|
||||
func Load(configPath string, cliFlags *CLIFlags) (*Config, error) {
|
||||
// Load existing config from file first
|
||||
config, err := loadFromFile(configPath)
|
||||
if err != nil {
|
||||
// Only use defaults if file doesn't exist or can't be read
|
||||
config = getDefaultConfig()
|
||||
} else {
|
||||
// Config loaded and merged with defaults. Persist any new keys that
|
||||
// the upgrade brought in but were not in the on-disk file — the loaded
|
||||
// struct has them via mergeConfigPreservingDefaults, but the file
|
||||
// does not. This catches new fields (desktop, polling, etc.) that
|
||||
// fresh installs get from the template but upgrades miss.
|
||||
persistNewDefaults(configPath, config)
|
||||
}
|
||||
|
||||
// Override with environment variables
|
||||
mergeConfig(config, loadFromEnv())
|
||||
|
||||
// Override with CLI flags (highest priority)
|
||||
if cliFlags != nil {
|
||||
mergeConfig(config, loadFromFlags(cliFlags))
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if err := validateConfig(config); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// CLIFlags holds command line flag values
|
||||
type CLIFlags struct {
|
||||
ServerURL string
|
||||
RegistrationToken string
|
||||
ProxyHTTP string
|
||||
ProxyHTTPS string
|
||||
ProxyNoProxy string
|
||||
LogLevel string
|
||||
ConfigFile string
|
||||
Tags []string
|
||||
Organization string
|
||||
DisplayName string
|
||||
InsecureTLS bool
|
||||
}
|
||||
|
||||
// getConfigVersionForAgent extracts the config version from the agent version
|
||||
// Agent version format: v0.1.23.6 where the fourth octet (.6) maps to config version
|
||||
func getConfigVersionForAgent(agentVersion string) string {
|
||||
// Strip 'v' prefix if present
|
||||
cleanVersion := strings.TrimPrefix(agentVersion, "v")
|
||||
|
||||
// Split version parts
|
||||
parts := strings.Split(cleanVersion, ".")
|
||||
if len(parts) == 4 {
|
||||
// Return the fourth octet as the config version
|
||||
// v0.1.23.6 → "6"
|
||||
return parts[3]
|
||||
}
|
||||
|
||||
// TODO: Integrate with global error logging system when available
|
||||
// For now, default to "6" to match current agent version
|
||||
return "6"
|
||||
}
|
||||
|
||||
// getDefaultConfig returns default configuration values
|
||||
func getDefaultConfig() *Config {
|
||||
// Use version package for single source of truth
|
||||
configVersion := version.ConfigVersion
|
||||
if configVersion == "dev" {
|
||||
// Fallback to extracting from agent version if not injected
|
||||
configVersion = version.ExtractConfigVersionFromAgent(version.Version)
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Version: configVersion, // Config schema version from version package
|
||||
AgentVersion: version.Version, // Agent version from version package
|
||||
ServerURL: "http://localhost:8080",
|
||||
CheckInInterval: 300, // 5 minutes
|
||||
|
||||
// Server Authentication
|
||||
RegistrationToken: "", // One-time registration token (embedded by install script)
|
||||
AgentID: uuid.Nil, // Will be set during registration
|
||||
Token: "", // Will be set during registration
|
||||
RefreshToken: "", // Will be set during registration
|
||||
|
||||
// Agent Behavior
|
||||
RapidPollingEnabled: false,
|
||||
RapidPollingUntil: time.Time{},
|
||||
DegradedMode: false,
|
||||
|
||||
// Polling resilience tuning (defaults; operator/server may override)
|
||||
Polling: PollingConfig{
|
||||
JitterMaxSeconds: 30,
|
||||
BackoffBaseSeconds: 10,
|
||||
BackoffMaxSeconds: 300,
|
||||
},
|
||||
|
||||
// Network Security
|
||||
Proxy: ProxyConfig{},
|
||||
TLS: TLSConfig{},
|
||||
Network: NetworkConfig{
|
||||
Timeout: 30 * time.Second,
|
||||
RetryCount: 3,
|
||||
RetryDelay: 5 * time.Second,
|
||||
MaxIdleConn: 10,
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "info",
|
||||
MaxSize: 100, // 100MB
|
||||
MaxBackups: 3,
|
||||
MaxAge: 28, // 28 days
|
||||
},
|
||||
SecurityLogging: SecurityLogConfig{
|
||||
Enabled: true,
|
||||
Level: "warning",
|
||||
LogSuccesses: false,
|
||||
FilePath: "security.log",
|
||||
MaxSizeMB: 50,
|
||||
MaxFiles: 5,
|
||||
BatchSize: 10,
|
||||
SendToServer: true,
|
||||
},
|
||||
CommandSigning: CommandSigningConfig{
|
||||
Enabled: true,
|
||||
EnforcementMode: "strict",
|
||||
},
|
||||
Subsystems: GetDefaultSubsystemsConfig(),
|
||||
ProcessExplorer: ProcessExplorerConfig{
|
||||
MaxOpenFiles: 2000,
|
||||
MaxSockets: 500,
|
||||
MaxPipes: 500,
|
||||
MaxMemoryMap: 2000,
|
||||
MaxNamespaces: 50,
|
||||
MaxEnvKeys: 200,
|
||||
MaxListeningPorts: 100,
|
||||
},
|
||||
Desktop: DesktopConfig{
|
||||
Enabled: true,
|
||||
MaxRestarts: 3,
|
||||
RestartDelaySec: 5,
|
||||
},
|
||||
Tags: []string{},
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// loadFromFile reads configuration from file with backward compatibility migration
|
||||
func loadFromFile(configPath string) (*Config, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
// Read config file
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("config file does not exist") // Return error so caller uses defaults
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||
}
|
||||
|
||||
// Parse the existing config into a generic map to preserve all fields
|
||||
var rawConfig map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rawConfig); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
// Create a new config with ALL defaults to fill missing fields
|
||||
config := getDefaultConfig()
|
||||
|
||||
// Carefully merge the loaded config into our defaults
|
||||
// This preserves existing values while filling missing ones with defaults
|
||||
configJSON, err := json.Marshal(rawConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to re-marshal config: %w", err)
|
||||
}
|
||||
|
||||
// Create a temporary config to hold loaded values
|
||||
tempConfig := &Config{}
|
||||
if err := json.Unmarshal(configJSON, &tempConfig); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal temp config: %w", err)
|
||||
}
|
||||
|
||||
// Merge loaded config into defaults (only non-zero values)
|
||||
mergeConfigPreservingDefaults(config, tempConfig)
|
||||
|
||||
// Handle specific migrations for known breaking changes
|
||||
migrateConfig(config)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// migrateConfig handles specific known migrations between config versions
|
||||
func migrateConfig(cfg *Config) {
|
||||
// Save the registration token before migration
|
||||
savedRegistrationToken := cfg.RegistrationToken
|
||||
|
||||
// Update config schema version to latest
|
||||
targetVersion := version.ConfigVersion
|
||||
if targetVersion == "dev" {
|
||||
// Fallback to extracting from agent version
|
||||
targetVersion = version.ExtractConfigVersionFromAgent(version.Version)
|
||||
}
|
||||
|
||||
if cfg.Version != targetVersion {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Info("agent", "config", "config", "migrating config schema", map[string]interface{}{
|
||||
"from_version": cfg.Version,
|
||||
"to_version": targetVersion,
|
||||
})
|
||||
}
|
||||
cfg.Version = targetVersion
|
||||
}
|
||||
|
||||
// Migration 1: Ensure minimum check-in interval (30 seconds)
|
||||
if cfg.CheckInInterval < 30 {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Info("agent", "config", "config", "migrating check_in_interval to minimum 30 seconds", map[string]interface{}{
|
||||
"old_value": cfg.CheckInInterval,
|
||||
})
|
||||
}
|
||||
cfg.CheckInInterval = 300 // Default to 5 minutes for better performance
|
||||
}
|
||||
|
||||
// Migration 2: Add missing subsystem fields with defaults
|
||||
// Check if subsystem is zero value (truly missing), not just has zero fields
|
||||
if cfg.Subsystems.System == (SubsystemConfig{}) {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Info("agent", "config", "config", "adding missing subsystem", map[string]interface{}{
|
||||
"subsystem": "system",
|
||||
})
|
||||
}
|
||||
cfg.Subsystems.System = GetDefaultSubsystemsConfig().System
|
||||
}
|
||||
|
||||
if cfg.Subsystems.Updates == (SubsystemConfig{}) {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Info("agent", "config", "config", "adding missing subsystem", map[string]interface{}{
|
||||
"subsystem": "updates",
|
||||
})
|
||||
}
|
||||
cfg.Subsystems.Updates = GetDefaultSubsystemsConfig().Updates
|
||||
}
|
||||
|
||||
// CRITICAL: Restore the registration token after migration
|
||||
// This ensures the token is never overwritten by migration logic
|
||||
if savedRegistrationToken != "" {
|
||||
cfg.RegistrationToken = savedRegistrationToken
|
||||
}
|
||||
}
|
||||
|
||||
// loadFromEnv loads configuration from environment variables
|
||||
func loadFromEnv() *Config {
|
||||
config := &Config{}
|
||||
|
||||
if serverURL := os.Getenv("REDFLAG_SERVER_URL"); serverURL != "" {
|
||||
config.ServerURL = serverURL
|
||||
}
|
||||
if token := os.Getenv("REDFLAG_REGISTRATION_TOKEN"); token != "" {
|
||||
config.RegistrationToken = token
|
||||
}
|
||||
if proxyHTTP := os.Getenv("REDFLAG_HTTP_PROXY"); proxyHTTP != "" {
|
||||
config.Proxy.Enabled = true
|
||||
config.Proxy.HTTP = proxyHTTP
|
||||
}
|
||||
if proxyHTTPS := os.Getenv("REDFLAG_HTTPS_PROXY"); proxyHTTPS != "" {
|
||||
config.Proxy.Enabled = true
|
||||
config.Proxy.HTTPS = proxyHTTPS
|
||||
}
|
||||
if noProxy := os.Getenv("REDFLAG_NO_PROXY"); noProxy != "" {
|
||||
config.Proxy.NoProxy = noProxy
|
||||
}
|
||||
if logLevel := os.Getenv("REDFLAG_LOG_LEVEL"); logLevel != "" {
|
||||
if config.Logging == (LoggingConfig{}) {
|
||||
config.Logging = LoggingConfig{}
|
||||
}
|
||||
config.Logging.Level = logLevel
|
||||
}
|
||||
if org := os.Getenv("REDFLAG_ORGANIZATION"); org != "" {
|
||||
config.Organization = org
|
||||
}
|
||||
if displayName := os.Getenv("REDFLAG_DISPLAY_NAME"); displayName != "" {
|
||||
config.DisplayName = displayName
|
||||
}
|
||||
|
||||
// Security logging environment variables
|
||||
if secEnabled := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_ENABLED"); secEnabled != "" {
|
||||
if config.SecurityLogging == (SecurityLogConfig{}) {
|
||||
config.SecurityLogging = SecurityLogConfig{}
|
||||
}
|
||||
config.SecurityLogging.Enabled = secEnabled == "true"
|
||||
}
|
||||
if secLevel := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_LEVEL"); secLevel != "" {
|
||||
if config.SecurityLogging == (SecurityLogConfig{}) {
|
||||
config.SecurityLogging = SecurityLogConfig{}
|
||||
}
|
||||
config.SecurityLogging.Level = secLevel
|
||||
}
|
||||
if secLogSucc := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_SUCCESSES"); secLogSucc != "" {
|
||||
if config.SecurityLogging == (SecurityLogConfig{}) {
|
||||
config.SecurityLogging = SecurityLogConfig{}
|
||||
}
|
||||
config.SecurityLogging.LogSuccesses = secLogSucc == "true"
|
||||
}
|
||||
if secPath := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_PATH"); secPath != "" {
|
||||
if config.SecurityLogging == (SecurityLogConfig{}) {
|
||||
config.SecurityLogging = SecurityLogConfig{}
|
||||
}
|
||||
config.SecurityLogging.FilePath = secPath
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// loadFromFlags loads configuration from CLI flags
|
||||
func loadFromFlags(flags *CLIFlags) *Config {
|
||||
config := &Config{}
|
||||
|
||||
if flags.ServerURL != "" {
|
||||
config.ServerURL = flags.ServerURL
|
||||
}
|
||||
if flags.RegistrationToken != "" {
|
||||
config.RegistrationToken = flags.RegistrationToken
|
||||
}
|
||||
if flags.ProxyHTTP != "" || flags.ProxyHTTPS != "" {
|
||||
config.Proxy = ProxyConfig{
|
||||
Enabled: true,
|
||||
HTTP: flags.ProxyHTTP,
|
||||
HTTPS: flags.ProxyHTTPS,
|
||||
NoProxy: flags.ProxyNoProxy,
|
||||
}
|
||||
}
|
||||
if flags.LogLevel != "" {
|
||||
config.Logging = LoggingConfig{
|
||||
Level: flags.LogLevel,
|
||||
}
|
||||
}
|
||||
if len(flags.Tags) > 0 {
|
||||
config.Tags = flags.Tags
|
||||
}
|
||||
if flags.Organization != "" {
|
||||
config.Organization = flags.Organization
|
||||
}
|
||||
if flags.DisplayName != "" {
|
||||
config.DisplayName = flags.DisplayName
|
||||
}
|
||||
if flags.InsecureTLS {
|
||||
config.TLS = TLSConfig{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// mergeConfig merges source config into target config (non-zero values only)
|
||||
func mergeConfig(target, source *Config) {
|
||||
if source.ServerURL != "" {
|
||||
target.ServerURL = source.ServerURL
|
||||
}
|
||||
if source.RegistrationToken != "" {
|
||||
target.RegistrationToken = source.RegistrationToken
|
||||
}
|
||||
if source.CheckInInterval != 0 {
|
||||
target.CheckInInterval = source.CheckInInterval
|
||||
}
|
||||
if source.Polling.JitterMaxSeconds != 0 {
|
||||
target.Polling.JitterMaxSeconds = source.Polling.JitterMaxSeconds
|
||||
}
|
||||
if source.Polling.BackoffBaseSeconds != 0 {
|
||||
target.Polling.BackoffBaseSeconds = source.Polling.BackoffBaseSeconds
|
||||
}
|
||||
if source.Polling.BackoffMaxSeconds != 0 {
|
||||
target.Polling.BackoffMaxSeconds = source.Polling.BackoffMaxSeconds
|
||||
}
|
||||
if source.AgentID != uuid.Nil {
|
||||
target.AgentID = source.AgentID
|
||||
}
|
||||
if source.Token != "" {
|
||||
target.Token = source.Token
|
||||
}
|
||||
if source.RefreshToken != "" {
|
||||
target.RefreshToken = source.RefreshToken
|
||||
}
|
||||
|
||||
// Merge nested configs
|
||||
if source.Network != (NetworkConfig{}) {
|
||||
target.Network = source.Network
|
||||
}
|
||||
if source.Proxy != (ProxyConfig{}) {
|
||||
target.Proxy = source.Proxy
|
||||
}
|
||||
if source.TLS != (TLSConfig{}) {
|
||||
target.TLS = source.TLS
|
||||
}
|
||||
if source.Logging != (LoggingConfig{}) {
|
||||
target.Logging = source.Logging
|
||||
}
|
||||
if source.SecurityLogging != (SecurityLogConfig{}) {
|
||||
target.SecurityLogging = source.SecurityLogging
|
||||
}
|
||||
if source.CommandSigning != (CommandSigningConfig{}) {
|
||||
target.CommandSigning = source.CommandSigning
|
||||
}
|
||||
|
||||
// Merge metadata
|
||||
if source.Tags != nil {
|
||||
target.Tags = source.Tags
|
||||
}
|
||||
if source.Metadata != nil {
|
||||
if target.Metadata == nil {
|
||||
target.Metadata = make(map[string]string)
|
||||
}
|
||||
for k, v := range source.Metadata {
|
||||
target.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
if source.DisplayName != "" {
|
||||
target.DisplayName = source.DisplayName
|
||||
}
|
||||
if source.Organization != "" {
|
||||
target.Organization = source.Organization
|
||||
}
|
||||
|
||||
// Merge rapid polling settings
|
||||
target.RapidPollingEnabled = source.RapidPollingEnabled
|
||||
if !source.RapidPollingUntil.IsZero() {
|
||||
target.RapidPollingUntil = source.RapidPollingUntil
|
||||
}
|
||||
|
||||
// Merge subsystems config
|
||||
if source.Subsystems != (SubsystemsConfig{}) {
|
||||
target.Subsystems = source.Subsystems
|
||||
}
|
||||
}
|
||||
|
||||
// validateConfig validates configuration values
|
||||
func validateConfig(config *Config) error {
|
||||
if config.ServerURL == "" {
|
||||
return fmt.Errorf("server_url is required")
|
||||
}
|
||||
if config.CheckInInterval < 30 {
|
||||
return fmt.Errorf("check_in_interval must be at least 30 seconds")
|
||||
}
|
||||
if config.CheckInInterval > 3600 {
|
||||
return fmt.Errorf("check_in_interval cannot exceed 3600 seconds (1 hour)")
|
||||
}
|
||||
if config.Network.Timeout <= 0 {
|
||||
return fmt.Errorf("network timeout must be positive")
|
||||
}
|
||||
if config.Network.RetryCount < 0 || config.Network.RetryCount > 10 {
|
||||
return fmt.Errorf("retry_count must be between 0 and 10")
|
||||
}
|
||||
|
||||
// Validate log level
|
||||
validLogLevels := map[string]bool{
|
||||
"debug": true, "info": true, "warn": true, "error": true,
|
||||
}
|
||||
if config.Logging.Level != "" && !validLogLevels[config.Logging.Level] {
|
||||
return fmt.Errorf("invalid log level: %s", config.Logging.Level)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeMissingKeys recursively walks fresh and injects keys that are absent
|
||||
// from raw into raw. Both maps are map[string]interface{} as parsed from JSON.
|
||||
// Returns the total number of keys added (at any depth).
|
||||
func mergeMissingKeys(raw, fresh map[string]interface{}, depth int) int {
|
||||
if depth > 16 {
|
||||
return 0 // safety limit — deeply nested config is pathological
|
||||
}
|
||||
added := 0
|
||||
for k, v := range fresh {
|
||||
rawVal, exists := raw[k]
|
||||
if !exists {
|
||||
raw[k] = v
|
||||
added++
|
||||
continue
|
||||
}
|
||||
// If both sides are maps, recurse to find missing sub-keys.
|
||||
rawMap, rawOk := rawVal.(map[string]interface{})
|
||||
freshMap, freshOk := v.(map[string]interface{})
|
||||
if rawOk && freshOk {
|
||||
added += mergeMissingKeys(rawMap, freshMap, depth+1)
|
||||
}
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
// persistNewDefaults injects new keys from the merged config into the on-disk
|
||||
// file without removing unknown keys (e.g. machine_id) that the Config struct
|
||||
// does not carry. This is how upgrades get new config fields (desktop, polling,
|
||||
// etc.) persisted without the installer re-running.
|
||||
func persistNewDefaults(configPath string, cfg *Config) {
|
||||
existing, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(existing, &raw); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Marshal the merged struct to a map to discover new-key candidates.
|
||||
cfgJSON, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var fresh map[string]interface{}
|
||||
if err := json.Unmarshal(cfgJSON, &fresh); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Inject keys present in the struct map but absent from the raw file.
|
||||
// Recurse into nested objects so new sub-fields (e.g. desktop.max_restarts
|
||||
// added in a newer version) are written even when the parent key exists.
|
||||
added := mergeMissingKeys(raw, fresh, 0)
|
||||
if added == 0 {
|
||||
return // already up to date
|
||||
}
|
||||
|
||||
if teeLogger != nil {
|
||||
teeLogger.Info("agent", "config", "config", "persisting new default keys to config.json", map[string]interface{}{
|
||||
"added": added,
|
||||
})
|
||||
}
|
||||
|
||||
merged, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(configPath, merged, 0600); err != nil {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "config", "config", "failed to persist new defaults", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save writes configuration to file
|
||||
func (c *Config) Save(configPath string) error {
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
// Create parent directory if it doesn't exist
|
||||
dir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDegradedMode sets the degraded mode flag and saves the config
|
||||
func (c *Config) SetDegradedMode(enabled bool) error {
|
||||
c.DegradedMode = enabled
|
||||
return c.Save(constants.GetAgentConfigPath())
|
||||
}
|
||||
|
||||
// IsRegistered checks if the agent is registered
|
||||
func (c *Config) IsRegistered() bool {
|
||||
return c.AgentID != uuid.Nil && c.Token != ""
|
||||
}
|
||||
|
||||
// IsStandalone reports whether this config has a stable local identity and no
|
||||
// fleet credential. A partial fleet enrollment is not standalone: refresh or
|
||||
// registration material must never silently become local mutation authority.
|
||||
func (c *Config) IsStandalone() bool {
|
||||
return c.AgentID != uuid.Nil && c.Token == "" && c.RefreshToken == "" && c.RegistrationToken == ""
|
||||
}
|
||||
|
||||
// InitializeStandalone gives a local-only Agent one durable UUID. It is
|
||||
// idempotent, but refuses any fleet credential so provisioning cannot convert a
|
||||
// fleet host into local authority by accident.
|
||||
func (c *Config) InitializeStandalone() error {
|
||||
if c.IsRegistered() || c.Token != "" || c.RefreshToken != "" || c.RegistrationToken != "" {
|
||||
return fmt.Errorf("standalone identity refused: fleet enrollment material is present")
|
||||
}
|
||||
if c.AgentID != uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
id, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate standalone agent id: %w", err)
|
||||
}
|
||||
c.AgentID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// OSType represents the operating system type
|
||||
type OSType string
|
||||
|
||||
const (
|
||||
OSTypeLinux OSType = "linux"
|
||||
OSTypeWindows OSType = "windows"
|
||||
OSTypeDarwin OSType = "darwin"
|
||||
)
|
||||
|
||||
// OS holds OS-specific information
|
||||
type OS struct {
|
||||
Type OSType `json:"type"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// GetOSType returns the OS type from the config
|
||||
func (c *Config) GetOSType() OSType {
|
||||
if c.OS.Type != "" {
|
||||
return c.OS.Type
|
||||
}
|
||||
// Default to linux if not set
|
||||
return OSTypeLinux
|
||||
}
|
||||
|
||||
// NeedsRegistration checks if the agent needs to register with a token
|
||||
func (c *Config) NeedsRegistration() bool {
|
||||
return c.RegistrationToken != "" && c.AgentID == uuid.Nil
|
||||
}
|
||||
|
||||
// HasRegistrationToken checks if the agent has a registration token
|
||||
func (c *Config) HasRegistrationToken() bool {
|
||||
return c.RegistrationToken != ""
|
||||
}
|
||||
|
||||
// mergeConfigPreservingDefaults merges source config into target config
|
||||
// but only overwrites fields that are explicitly set (non-zero)
|
||||
// This is different from mergeConfig which blindly copies non-zero values
|
||||
func mergeConfigPreservingDefaults(target, source *Config) {
|
||||
// Server Configuration
|
||||
if source.ServerURL != "" && source.ServerURL != getDefaultConfig().ServerURL {
|
||||
target.ServerURL = source.ServerURL
|
||||
}
|
||||
// IMPORTANT: Never overwrite registration token if target already has one
|
||||
if source.RegistrationToken != "" && target.RegistrationToken == "" {
|
||||
target.RegistrationToken = source.RegistrationToken
|
||||
}
|
||||
|
||||
// Agent Configuration
|
||||
if source.CheckInInterval != 0 {
|
||||
target.CheckInInterval = source.CheckInInterval
|
||||
}
|
||||
if source.Polling.JitterMaxSeconds != 0 {
|
||||
target.Polling.JitterMaxSeconds = source.Polling.JitterMaxSeconds
|
||||
}
|
||||
if source.Polling.BackoffBaseSeconds != 0 {
|
||||
target.Polling.BackoffBaseSeconds = source.Polling.BackoffBaseSeconds
|
||||
}
|
||||
if source.Polling.BackoffMaxSeconds != 0 {
|
||||
target.Polling.BackoffMaxSeconds = source.Polling.BackoffMaxSeconds
|
||||
}
|
||||
if source.AgentID != uuid.Nil {
|
||||
target.AgentID = source.AgentID
|
||||
}
|
||||
if source.Token != "" {
|
||||
target.Token = source.Token
|
||||
}
|
||||
if source.RefreshToken != "" {
|
||||
target.RefreshToken = source.RefreshToken
|
||||
}
|
||||
|
||||
// Merge nested configs only if they're not default values
|
||||
if source.Network != (NetworkConfig{}) {
|
||||
target.Network = source.Network
|
||||
}
|
||||
if source.Proxy != (ProxyConfig{}) {
|
||||
target.Proxy = source.Proxy
|
||||
}
|
||||
if source.TLS != (TLSConfig{}) {
|
||||
target.TLS = source.TLS
|
||||
}
|
||||
if source.Logging != (LoggingConfig{}) && source.Logging.Level != "" {
|
||||
target.Logging = source.Logging
|
||||
}
|
||||
if source.SecurityLogging != (SecurityLogConfig{}) {
|
||||
target.SecurityLogging = source.SecurityLogging
|
||||
}
|
||||
if source.CommandSigning != (CommandSigningConfig{}) {
|
||||
target.CommandSigning = source.CommandSigning
|
||||
}
|
||||
|
||||
// Merge metadata
|
||||
if source.Tags != nil && len(source.Tags) > 0 {
|
||||
target.Tags = source.Tags
|
||||
}
|
||||
if source.Metadata != nil {
|
||||
if target.Metadata == nil {
|
||||
target.Metadata = make(map[string]string)
|
||||
}
|
||||
for k, v := range source.Metadata {
|
||||
target.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
if source.DisplayName != "" {
|
||||
target.DisplayName = source.DisplayName
|
||||
}
|
||||
if source.Organization != "" {
|
||||
target.Organization = source.Organization
|
||||
}
|
||||
|
||||
// Merge rapid polling settings
|
||||
target.RapidPollingEnabled = source.RapidPollingEnabled
|
||||
if !source.RapidPollingUntil.IsZero() {
|
||||
target.RapidPollingUntil = source.RapidPollingUntil
|
||||
}
|
||||
|
||||
// Merge subsystems config
|
||||
if source.Subsystems != (SubsystemsConfig{}) {
|
||||
target.Subsystems = source.Subsystems
|
||||
}
|
||||
|
||||
// Desktop app config. A zero struct means the key is absent from the file —
|
||||
// keep the defaults. Any explicit setting (enabled, restart tuning) wins.
|
||||
if source.Desktop != (DesktopConfig{}) {
|
||||
target.Desktop = source.Desktop
|
||||
}
|
||||
|
||||
// Version info
|
||||
if source.Version != "" {
|
||||
target.Version = source.Version
|
||||
}
|
||||
if source.AgentVersion != "" {
|
||||
target.AgentVersion = source.AgentVersion
|
||||
}
|
||||
}
|
||||
210
agent/internal/config/config_roundtrip_test.go
Normal file
210
agent/internal/config/config_roundtrip_test.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConfigRoundtripPreservesUnknownKeys is the canonical roundtrip test.
|
||||
// The config file is the source of truth — keys unknown to the Go struct
|
||||
// (machine_id, operator-added fields, future features) MUST survive a
|
||||
// full load+save cycle intact. Regression test for the upgrade-path gap
|
||||
// where marshal-unmarshal through the struct would silently drop them.
|
||||
func TestConfigRoundtripPreservesUnknownKeys(t *testing.T) {
|
||||
// Craft a config file with keys the struct does not know about.
|
||||
// machine_id is the load-bearing example; "operator_note" simulates
|
||||
// any future field an admin or config-manager adds outside the agent.
|
||||
raw := `{
|
||||
"server_url": "http://test:8080",
|
||||
"agent_id": "00000000-0000-0000-0000-000000000001",
|
||||
"check_in_interval": 300,
|
||||
"machine_id": "host-abc-123-xyz",
|
||||
"operator_note": "do not remove",
|
||||
"desktop": {"enabled": true, "max_restarts": 3}
|
||||
}`
|
||||
|
||||
td := t.TempDir()
|
||||
configPath := filepath.Join(td, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(raw), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Load — this triggers loadFromFile + mergeConfigPreservingDefaults
|
||||
// + persistNewDefaults writes back.
|
||||
cfg, err := Load(configPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the struct got the known keys.
|
||||
if cfg.ServerURL != "http://test:8080" {
|
||||
t.Errorf("ServerURL = %q, want %q", cfg.ServerURL, "http://test:8080")
|
||||
}
|
||||
if cfg.CheckInInterval != 300 {
|
||||
t.Errorf("CheckInInterval = %d, want 300", cfg.CheckInInterval)
|
||||
}
|
||||
if !cfg.Desktop.Enabled {
|
||||
t.Error("Desktop.Enabled = false, want true (was missing from old config, should be injected by persistNewDefaults)")
|
||||
}
|
||||
|
||||
// Read the written file back raw. Parse as map[string]interface{} to
|
||||
// see keys the struct does not know about.
|
||||
written, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rawWritten map[string]interface{}
|
||||
if err := json.Unmarshal(written, &rawWritten); err != nil {
|
||||
t.Fatalf("written config is not valid JSON: %v\n%s", err, string(written))
|
||||
}
|
||||
|
||||
// DID NOT LOSE machine_id — the load-bearing unknown key.
|
||||
if _, exists := rawWritten["machine_id"]; !exists {
|
||||
t.Errorf("FATAL: machine_id lost in config roundtrip — unknown keys are silently dropped")
|
||||
}
|
||||
// DID NOT LOSE operator_note — arbitrary operator-added key.
|
||||
if _, exists := rawWritten["operator_note"]; !exists {
|
||||
t.Errorf("operator_note lost in config roundtrip — operator-added keys are silently dropped")
|
||||
}
|
||||
// DID ADD desktop — new struct key not in the original file.
|
||||
if _, exists := rawWritten["desktop"]; !exists {
|
||||
t.Errorf("desktop missing from written config — persistNewDefaults did not inject it")
|
||||
}
|
||||
|
||||
// Verify machine_id retained its value exactly.
|
||||
machineID, _ := rawWritten["machine_id"].(string)
|
||||
if machineID != "host-abc-123-xyz" {
|
||||
t.Errorf("machine_id = %q, want %q", machineID, "host-abc-123-xyz")
|
||||
}
|
||||
|
||||
// Verify operator_note retained its value exactly.
|
||||
note, _ := rawWritten["operator_note"].(string)
|
||||
if note != "do not remove" {
|
||||
t.Errorf("operator_note = %q, want %q", note, "do not remove")
|
||||
}
|
||||
|
||||
// Verify desktop sub-fields were preserved (max_restarts from the file).
|
||||
desktop, ok := rawWritten["desktop"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("desktop is not a map in written config")
|
||||
} else {
|
||||
if mr, ok := desktop["max_restarts"].(float64); !ok || mr != 3 {
|
||||
t.Errorf("desktop.max_restarts = %v, want 3 (preserved from file)", desktop["max_restarts"])
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("roundtrip OK — %d keys in written config", len(rawWritten))
|
||||
}
|
||||
|
||||
// TestPersistNewDefaultsInjectsNestedSubFields verifies that new sub-fields
|
||||
// introduced in a version upgrade are written even when the parent key already
|
||||
// exists on disk. E.g. an old config has desktop:{"enabled":true} and the new
|
||||
// version adds max_restarts / restart_delay_sec — those must land on disk.
|
||||
func TestPersistNewDefaultsInjectsNestedSubFields(t *testing.T) {
|
||||
// Old config: desktop with only "enabled" — missing max_restarts and
|
||||
// restart_delay_sec that a newer agent version carries as defaults.
|
||||
raw := `{
|
||||
"server_url": "http://test:8080",
|
||||
"agent_id": "00000000-0000-0000-0000-000000000003",
|
||||
"check_in_interval": 300,
|
||||
"desktop": {"enabled": true}
|
||||
}`
|
||||
|
||||
td := t.TempDir()
|
||||
configPath := filepath.Join(td, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(raw), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
// The merged struct must have the new sub-fields present (defaults are 0
|
||||
// for both: 0 = unlimited restarts, 0 = default restart delay).
|
||||
if cfg.Desktop.MaxRestarts != 0 {
|
||||
t.Logf("Desktop.MaxRestarts = %d (non-zero default — ok)", cfg.Desktop.MaxRestarts)
|
||||
}
|
||||
if cfg.Desktop.RestartDelaySec != 0 {
|
||||
t.Logf("Desktop.RestartDelaySec = %d (non-zero default — ok)", cfg.Desktop.RestartDelaySec)
|
||||
}
|
||||
|
||||
// Read the written file — the new sub-fields must be on disk.
|
||||
written, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rawWritten map[string]interface{}
|
||||
if err := json.Unmarshal(written, &rawWritten); err != nil {
|
||||
t.Fatalf("written config is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
desktop, ok := rawWritten["desktop"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("desktop missing from written config entirely")
|
||||
}
|
||||
if e, ok := desktop["enabled"].(bool); !ok || !e {
|
||||
t.Errorf("desktop.enabled = %v, want true (original value lost)", desktop["enabled"])
|
||||
}
|
||||
if _, exists := desktop["max_restarts"]; !exists {
|
||||
t.Error("desktop.max_restarts missing — persistNewDefaults did not inject new nested sub-field")
|
||||
}
|
||||
if _, exists := desktop["restart_delay_sec"]; !exists {
|
||||
t.Error("desktop.restart_delay_sec missing — persistNewDefaults did not inject new nested sub-field")
|
||||
}
|
||||
|
||||
t.Logf("nested sub-field injection OK — %d top-level keys, desktop has %d sub-keys",
|
||||
len(rawWritten), len(desktop))
|
||||
}
|
||||
|
||||
// TestConfigLoadDoesNotRemoveKeys checks that loading a config with extra
|
||||
// keys (from a future agent version) does not strip them in memory.
|
||||
// The file is the authority; the load path must not filter by struct tags.
|
||||
func TestConfigLoadDoesNotRemoveKeys(t *testing.T) {
|
||||
raw := `{
|
||||
"server_url": "http://test:8080",
|
||||
"agent_id": "00000000-0000-0000-0000-000000000002",
|
||||
"check_in_interval": 300,
|
||||
"machine_id": "should-survive",
|
||||
"future_feature": {"enabled": true}
|
||||
}`
|
||||
|
||||
td := t.TempDir()
|
||||
configPath := filepath.Join(td, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(raw), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
// Known fields populated.
|
||||
if cfg.ServerURL != "http://test:8080" {
|
||||
t.Errorf("ServerURL = %q", cfg.ServerURL)
|
||||
}
|
||||
if cfg.CheckInInterval != 300 {
|
||||
t.Errorf("CheckInInterval = %d", cfg.CheckInInterval)
|
||||
}
|
||||
|
||||
// The file on disk was NOT changed if it already had the struct keys.
|
||||
written, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rawWritten map[string]interface{}
|
||||
if err := json.Unmarshal(written, &rawWritten); err != nil {
|
||||
t.Fatalf("written config invalid: %v", err)
|
||||
}
|
||||
|
||||
if _, exists := rawWritten["machine_id"]; !exists {
|
||||
t.Error("machine_id removed by load")
|
||||
}
|
||||
if _, exists := rawWritten["future_feature"]; !exists {
|
||||
t.Error("future_feature removed by load")
|
||||
}
|
||||
}
|
||||
195
agent/internal/config/docker.go
Normal file
195
agent/internal/config/docker.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DockerSecretsConfig holds Docker secrets configuration
|
||||
type DockerSecretsConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
SecretsPath string `json:"secrets_path"`
|
||||
EncryptionKey string `json:"encryption_key,omitempty"`
|
||||
Secrets map[string]string `json:"secrets,omitempty"`
|
||||
}
|
||||
|
||||
// LoadDockerConfig loads Docker configuration if available
|
||||
func LoadDockerConfig(configPath string) (*DockerSecretsConfig, error) {
|
||||
dockerConfigPath := filepath.Join(configPath, "docker.json")
|
||||
|
||||
// Check if Docker config exists
|
||||
if _, err := os.Stat(dockerConfigPath); os.IsNotExist(err) {
|
||||
return &DockerSecretsConfig{Enabled: false}, nil
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadFile(dockerConfigPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Docker config: %w", err)
|
||||
}
|
||||
|
||||
var dockerConfig DockerSecretsConfig
|
||||
if err := json.Unmarshal(data, &dockerConfig); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Docker config: %w", err)
|
||||
}
|
||||
|
||||
// Set default secrets path if not specified
|
||||
if dockerConfig.SecretsPath == "" {
|
||||
dockerConfig.SecretsPath = getDefaultSecretsPath()
|
||||
}
|
||||
|
||||
return &dockerConfig, nil
|
||||
}
|
||||
|
||||
// getDefaultSecretsPath returns the default Docker secrets path for the platform
|
||||
func getDefaultSecretsPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return `C:\ProgramData\Docker\secrets`
|
||||
}
|
||||
return "/run/secrets"
|
||||
}
|
||||
|
||||
// ReadSecret reads a secret from Docker secrets or falls back to file
|
||||
func ReadSecret(secretName, fallbackPath string, dockerConfig *DockerSecretsConfig) ([]byte, error) {
|
||||
// Try Docker secrets first if enabled
|
||||
if dockerConfig != nil && dockerConfig.Enabled {
|
||||
secretPath := filepath.Join(dockerConfig.SecretsPath, secretName)
|
||||
if data, err := ioutil.ReadFile(secretPath); err == nil {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Info("agent", "config", "docker_config", "read secret from docker", map[string]interface{}{
|
||||
"secret_name": secretName,
|
||||
})
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to file system
|
||||
if fallbackPath != "" {
|
||||
if data, err := ioutil.ReadFile(fallbackPath); err == nil {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Info("agent", "config", "docker_config", "read secret from file", map[string]interface{}{
|
||||
"file_path": fallbackPath,
|
||||
})
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("secret not found: %s", secretName)
|
||||
}
|
||||
|
||||
// MergeConfigWithSecrets merges configuration with Docker secrets
|
||||
func MergeConfigWithSecrets(config *Config, dockerConfig *DockerSecretsConfig) error {
|
||||
if dockerConfig == nil || !dockerConfig.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If there's an encrypted config, decrypt and merge it
|
||||
if encryptedConfigPath, exists := dockerConfig.Secrets["config"]; exists {
|
||||
if err := mergeEncryptedConfig(config, encryptedConfigPath, dockerConfig.EncryptionKey); err != nil {
|
||||
return fmt.Errorf("failed to merge encrypted config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply other secrets to configuration
|
||||
if err := applySecretsToConfig(config, dockerConfig); err != nil {
|
||||
return fmt.Errorf("failed to apply secrets to config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeEncryptedConfig decrypts and merges encrypted configuration
|
||||
func mergeEncryptedConfig(config *Config, encryptedPath, encryptionKey string) error {
|
||||
if encryptionKey == "" {
|
||||
return fmt.Errorf("no encryption key available for encrypted config")
|
||||
}
|
||||
|
||||
// Create temporary file for decrypted config
|
||||
tempPath := encryptedPath + ".tmp"
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
// Decrypt the config file
|
||||
// Note: This would need to import the migration package's DecryptFile function
|
||||
// For now, we'll assume the decryption happens elsewhere
|
||||
return fmt.Errorf("encrypted config merge not yet implemented")
|
||||
}
|
||||
|
||||
// applySecretsToConfig applies Docker secrets to configuration fields
|
||||
func applySecretsToConfig(config *Config, dockerConfig *DockerSecretsConfig) error {
|
||||
// Apply proxy secrets
|
||||
if proxyUsername, exists := dockerConfig.Secrets["proxy_username"]; exists {
|
||||
config.Proxy.Username = proxyUsername
|
||||
}
|
||||
if proxyPassword, exists := dockerConfig.Secrets["proxy_password"]; exists {
|
||||
config.Proxy.Password = proxyPassword
|
||||
}
|
||||
|
||||
// Apply TLS secrets
|
||||
if certFile, exists := dockerConfig.Secrets["tls_cert"]; exists {
|
||||
config.TLS.CertFile = certFile
|
||||
}
|
||||
if keyFile, exists := dockerConfig.Secrets["tls_key"]; exists {
|
||||
config.TLS.KeyFile = keyFile
|
||||
}
|
||||
if caFile, exists := dockerConfig.Secrets["tls_ca"]; exists {
|
||||
config.TLS.CAFile = caFile
|
||||
}
|
||||
|
||||
// Apply registration token
|
||||
if regToken, exists := dockerConfig.Secrets["registration_token"]; exists {
|
||||
config.RegistrationToken = regToken
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDockerEnvironment checks if the agent is running in Docker
|
||||
func IsDockerEnvironment() bool {
|
||||
// Check for .dockerenv file
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for Docker in cgroup
|
||||
if data, err := ioutil.ReadFile("/proc/1/cgroup"); err == nil {
|
||||
if contains(string(data), "docker") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SaveDockerConfig saves Docker configuration to disk
|
||||
func SaveDockerConfig(dockerConfig *DockerSecretsConfig, configPath string) error {
|
||||
dockerConfigPath := filepath.Join(configPath, "docker.json")
|
||||
|
||||
data, err := json.MarshalIndent(dockerConfig, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal Docker config: %w", err)
|
||||
}
|
||||
|
||||
if err := ioutil.WriteFile(dockerConfigPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write Docker config: %w", err)
|
||||
}
|
||||
|
||||
if teeLogger != nil {
|
||||
teeLogger.Info("agent", "config", "docker_config", "saved docker config", map[string]interface{}{
|
||||
"config_path": dockerConfigPath,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// contains checks if a string contains a substring (case-insensitive)
|
||||
func contains(s, substr string) bool {
|
||||
s = strings.ToLower(s)
|
||||
substr = strings.ToLower(substr)
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
55
agent/internal/config/kernel_enforcement.go
Normal file
55
agent/internal/config/kernel_enforcement.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
)
|
||||
|
||||
// KernelEnforcementConfig holds configuration for kernel-level enforcement
|
||||
type KernelEnforcementConfig struct {
|
||||
// Enable kernel enforcement (eBPF on Linux, WDAC on Windows)
|
||||
Enabled bool `json:"enabled" env:"REDFLAG_KERNEL_ENFORCEMENT_ENABLED" default:"true"`
|
||||
|
||||
// Ring buffer path for eBPF events (Linux only)
|
||||
RingBufferPath string `json:"ring_buffer_path" env:"REDFLAG_RING_BUFFER_PATH" default:"/var/run/redflag/ebpf-ring"`
|
||||
|
||||
// Socket path for rs-helper communication
|
||||
RsHelperSocket string `json:"rs_helper_socket" env:"REDFLAG_RS_HELPER_SOCKET" default:"/var/run/redflag/rs-helper.sock"`
|
||||
|
||||
// Policy check timeout
|
||||
PolicyCheckTimeout time.Duration `json:"policy_check_timeout" env:"REDFLAG_POLICY_CHECK_TIMEOUT" default:"10s"`
|
||||
|
||||
// Fail-closed mode (deny on kernel enforcement failure)
|
||||
FailClosed bool `json:"fail_closed" env:"REDFLAG_KERNEL_ENFORCEMENT_FAIL_CLOSED" default:"true"`
|
||||
}
|
||||
|
||||
// GetDefaultKernelEnforcementConfig returns default kernel enforcement configuration
|
||||
func GetDefaultKernelEnforcementConfig() KernelEnforcementConfig {
|
||||
return KernelEnforcementConfig{
|
||||
Enabled: true,
|
||||
RingBufferPath: constants.GetAgentStateDir() + "/ebpf-ring",
|
||||
RsHelperSocket: "/var/run/redflag/rs-helper.sock",
|
||||
PolicyCheckTimeout: 10 * time.Second,
|
||||
FailClosed: true,
|
||||
}
|
||||
}
|
||||
|
||||
// MergeKernelEnforcement merges kernel enforcement config from source into target
|
||||
func MergeKernelEnforcement(target, source KernelEnforcementConfig) {
|
||||
if source.Enabled {
|
||||
target.Enabled = source.Enabled
|
||||
}
|
||||
if source.RingBufferPath != "" {
|
||||
target.RingBufferPath = source.RingBufferPath
|
||||
}
|
||||
if source.RsHelperSocket != "" {
|
||||
target.RsHelperSocket = source.RsHelperSocket
|
||||
}
|
||||
if source.PolicyCheckTimeout > 0 {
|
||||
target.PolicyCheckTimeout = source.PolicyCheckTimeout
|
||||
}
|
||||
if source.FailClosed {
|
||||
target.FailClosed = source.FailClosed
|
||||
}
|
||||
}
|
||||
49
agent/internal/config/mode_test.go
Normal file
49
agent/internal/config/mode_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func TestInitializeStandaloneIsStable(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := cfg.InitializeStandalone(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := cfg.AgentID
|
||||
if first == uuid.Nil || !cfg.IsStandalone() || cfg.IsRegistered() {
|
||||
t.Fatalf("standalone identity not established: id=%s", first)
|
||||
}
|
||||
if err := cfg.InitializeStandalone(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.AgentID != first {
|
||||
t.Fatalf("standalone identity changed: %s -> %s", first, cfg.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeStandaloneRefusesFleetMaterial(t *testing.T) {
|
||||
for name, cfg := range map[string]*Config{
|
||||
"registration token": {RegistrationToken: "register"},
|
||||
"access token": {Token: "access"},
|
||||
"refresh token": {RefreshToken: "refresh"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := cfg.InitializeStandalone(); err == nil {
|
||||
t.Fatal("fleet material became standalone authority")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialFleetEnrollmentIsNotStandalone(t *testing.T) {
|
||||
id, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &Config{AgentID: id, RefreshToken: "refresh"}
|
||||
if cfg.IsStandalone() {
|
||||
t.Fatal("partial fleet enrollment reported as standalone")
|
||||
}
|
||||
}
|
||||
126
agent/internal/config/subsystems.go
Normal file
126
agent/internal/config/subsystems.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// SubsystemConfig holds configuration for individual subsystems
|
||||
type SubsystemConfig struct {
|
||||
// Execution settings
|
||||
Enabled bool `json:"enabled"`
|
||||
Timeout time.Duration `json:"timeout"` // Timeout for this subsystem
|
||||
|
||||
// Interval for this subsystem (in minutes)
|
||||
// This controls how often the server schedules scans for this subsystem
|
||||
IntervalMinutes int `json:"interval_minutes,omitempty"`
|
||||
|
||||
// Circuit breaker settings
|
||||
CircuitBreaker CircuitBreakerConfig `json:"circuit_breaker"`
|
||||
}
|
||||
|
||||
// CircuitBreakerConfig holds circuit breaker settings for subsystems
|
||||
type CircuitBreakerConfig struct {
|
||||
// Enabled controls whether circuit breaker is active
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// FailureThreshold is the number of consecutive failures before opening the circuit
|
||||
FailureThreshold int `json:"failure_threshold"`
|
||||
|
||||
// FailureWindow is the time window to track failures (e.g., 3 failures in 10 minutes)
|
||||
FailureWindow time.Duration `json:"failure_window"`
|
||||
|
||||
// OpenDuration is how long the circuit stays open before attempting recovery
|
||||
OpenDuration time.Duration `json:"open_duration"`
|
||||
|
||||
// HalfOpenAttempts is the number of test attempts in half-open state before fully closing
|
||||
HalfOpenAttempts int `json:"half_open_attempts"`
|
||||
}
|
||||
|
||||
// SubsystemsConfig holds all subsystem configurations
|
||||
type SubsystemsConfig struct {
|
||||
System SubsystemConfig `json:"system"` // System metrics scanner
|
||||
Updates SubsystemConfig `json:"updates"` // Virtual subsystem for package update scheduling
|
||||
APT SubsystemConfig `json:"apt"`
|
||||
DNF SubsystemConfig `json:"dnf"`
|
||||
Pacman SubsystemConfig `json:"pacman"`
|
||||
Docker SubsystemConfig `json:"docker"`
|
||||
Windows SubsystemConfig `json:"windows"`
|
||||
Winget SubsystemConfig `json:"winget"`
|
||||
Storage SubsystemConfig `json:"storage"`
|
||||
}
|
||||
|
||||
// GetDefaultSubsystemsConfig returns default subsystem configurations
|
||||
func GetDefaultSubsystemsConfig() SubsystemsConfig {
|
||||
// Default circuit breaker config
|
||||
defaultCB := CircuitBreakerConfig{
|
||||
Enabled: true,
|
||||
FailureThreshold: 3, // 3 consecutive failures
|
||||
FailureWindow: 10 * time.Minute, // within 10 minutes
|
||||
OpenDuration: 30 * time.Minute, // circuit open for 30 min
|
||||
HalfOpenAttempts: 2, // 2 successful attempts to close circuit
|
||||
}
|
||||
|
||||
// Aggressive circuit breaker for Windows Update (known to be slow/problematic)
|
||||
windowsCB := CircuitBreakerConfig{
|
||||
Enabled: true,
|
||||
FailureThreshold: 2, // Only 2 failures
|
||||
FailureWindow: 15 * time.Minute,
|
||||
OpenDuration: 60 * time.Minute, // Open for 1 hour
|
||||
HalfOpenAttempts: 3,
|
||||
}
|
||||
|
||||
return SubsystemsConfig{
|
||||
System: SubsystemConfig{
|
||||
Enabled: true, // System scanner always available
|
||||
Timeout: 10 * time.Second, // System info should be fast
|
||||
IntervalMinutes: 5, // Default: 5 minutes
|
||||
CircuitBreaker: defaultCB,
|
||||
},
|
||||
Updates: SubsystemConfig{
|
||||
Enabled: true, // Virtual subsystem for package update scheduling
|
||||
Timeout: 0, // Not used - delegates to individual package scanners
|
||||
IntervalMinutes: 720, // Default: 12 hours (more reasonable for update checks)
|
||||
CircuitBreaker: CircuitBreakerConfig{Enabled: false}, // No circuit breaker for virtual subsystem
|
||||
},
|
||||
APT: SubsystemConfig{
|
||||
Enabled: true,
|
||||
Timeout: 30 * time.Second,
|
||||
IntervalMinutes: 15, // Default: 15 minutes
|
||||
CircuitBreaker: defaultCB,
|
||||
},
|
||||
DNF: SubsystemConfig{
|
||||
Enabled: true,
|
||||
Timeout: 15 * time.Minute, // TODO: Make scanner timeouts user-adjustable via settings. DNF operations can take a long time on large systems
|
||||
IntervalMinutes: 15, // Default: 15 minutes
|
||||
CircuitBreaker: defaultCB,
|
||||
},
|
||||
Pacman: SubsystemConfig{
|
||||
Enabled: true,
|
||||
Timeout: 5 * time.Minute, // checkupdates syncs repo DBs into a temp dir; usually fast but can lag on slow mirrors
|
||||
IntervalMinutes: 15, // Default: 15 minutes
|
||||
CircuitBreaker: defaultCB,
|
||||
},
|
||||
Docker: SubsystemConfig{
|
||||
Enabled: true,
|
||||
Timeout: 60 * time.Second, // Registry queries can be slow
|
||||
IntervalMinutes: 15, // Default: 15 minutes
|
||||
CircuitBreaker: defaultCB,
|
||||
},
|
||||
Windows: SubsystemConfig{
|
||||
Enabled: true,
|
||||
Timeout: 10 * time.Minute, // Windows Update can be VERY slow
|
||||
IntervalMinutes: 15, // Default: 15 minutes
|
||||
CircuitBreaker: windowsCB,
|
||||
},
|
||||
Winget: SubsystemConfig{
|
||||
Enabled: true,
|
||||
Timeout: 2 * time.Minute, // Winget has multiple retry strategies
|
||||
IntervalMinutes: 15, // Default: 15 minutes
|
||||
CircuitBreaker: defaultCB,
|
||||
},
|
||||
Storage: SubsystemConfig{
|
||||
Enabled: true,
|
||||
Timeout: 10 * time.Second, // Disk info should be fast
|
||||
IntervalMinutes: 5, // Default: 5 minutes
|
||||
CircuitBreaker: defaultCB,
|
||||
},
|
||||
}
|
||||
}
|
||||
169
agent/internal/constants/paths.go
Normal file
169
agent/internal/constants/paths.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// Package constants provides centralized path definitions for the RedFlag agent.
|
||||
// This package ensures consistency across all components and makes path management
|
||||
// maintainable and testable.
|
||||
package constants
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Base directories
|
||||
const (
|
||||
LinuxBaseDir = "/var/lib/redflag"
|
||||
WindowsBaseDir = "C:\\ProgramData\\RedFlag"
|
||||
)
|
||||
|
||||
// Subdirectory structure
|
||||
const (
|
||||
AgentDir = "agent"
|
||||
ServerDir = "server"
|
||||
CacheSubdir = "cache"
|
||||
StateSubdir = "state"
|
||||
MigrationSubdir = "migration_backups"
|
||||
)
|
||||
|
||||
// Config paths
|
||||
const (
|
||||
LinuxConfigBase = "/etc/redflag"
|
||||
WindowsConfigBase = "C:\\ProgramData\\RedFlag"
|
||||
ConfigFile = "config.json"
|
||||
)
|
||||
|
||||
// Log paths
|
||||
const (
|
||||
LinuxLogBase = "/var/log/redflag"
|
||||
LogSubdir = "logs"
|
||||
AgentLogFile = "agent.log"
|
||||
)
|
||||
|
||||
// Legacy paths for migration
|
||||
const (
|
||||
LegacyConfigPath = "/etc/aggregator/config.json"
|
||||
LegacyStatePath = "/var/lib/aggregator"
|
||||
)
|
||||
|
||||
// GetBaseDir returns platform-specific base directory
|
||||
func GetBaseDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return WindowsBaseDir
|
||||
}
|
||||
return LinuxBaseDir
|
||||
}
|
||||
|
||||
// GetAgentStateDir returns /var/lib/redflag/agent/state
|
||||
func GetAgentStateDir() string {
|
||||
return filepath.Join(GetBaseDir(), AgentDir, StateSubdir)
|
||||
}
|
||||
|
||||
// GetAgentCacheDir returns /var/lib/redflag/agent/cache
|
||||
func GetAgentCacheDir() string {
|
||||
return filepath.Join(GetBaseDir(), AgentDir, CacheSubdir)
|
||||
}
|
||||
|
||||
// GetMigrationBackupDir returns /var/lib/redflag/agent/migration_backups
|
||||
func GetMigrationBackupDir() string {
|
||||
return filepath.Join(GetBaseDir(), AgentDir, MigrationSubdir)
|
||||
}
|
||||
|
||||
// GetAgentConfigPath returns /etc/redflag/agent/config.json
|
||||
func GetAgentConfigPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(WindowsConfigBase, AgentDir, ConfigFile)
|
||||
}
|
||||
return filepath.Join(LinuxConfigBase, AgentDir, ConfigFile)
|
||||
}
|
||||
|
||||
// GetAgentConfigDir returns /etc/redflag/agent
|
||||
func GetAgentConfigDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(WindowsConfigBase, AgentDir)
|
||||
}
|
||||
return filepath.Join(LinuxConfigBase, AgentDir)
|
||||
}
|
||||
|
||||
// GetServerPublicKeyPath returns /etc/redflag/server/server_public_key
|
||||
func GetServerPublicKeyPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(WindowsConfigBase, ServerDir, "server_public_key")
|
||||
}
|
||||
return filepath.Join(LinuxConfigBase, ServerDir, "server_public_key")
|
||||
}
|
||||
|
||||
// GetServerPublicKeyDir returns /etc/redflag/server (directory for server public keys)
|
||||
func GetServerPublicKeyDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(WindowsConfigBase, ServerDir)
|
||||
}
|
||||
return filepath.Join(LinuxConfigBase, ServerDir)
|
||||
}
|
||||
|
||||
// GetAgentLogDir returns the platform-specific agent log directory.
|
||||
func GetAgentLogDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(WindowsBaseDir, LogSubdir)
|
||||
}
|
||||
return filepath.Join(LinuxLogBase, AgentDir)
|
||||
}
|
||||
|
||||
// GetAgentLogPath returns the platform-specific primary agent log file.
|
||||
func GetAgentLogPath() string {
|
||||
return filepath.Join(GetAgentLogDir(), AgentLogFile)
|
||||
}
|
||||
|
||||
// GetLegacyAgentConfigPath returns legacy /etc/aggregator/config.json
|
||||
func GetLegacyAgentConfigPath() string {
|
||||
return LegacyConfigPath
|
||||
}
|
||||
|
||||
// GetLegacyAgentStatePath returns legacy /var/lib/aggregator
|
||||
func GetLegacyAgentStatePath() string {
|
||||
return LegacyStatePath
|
||||
}
|
||||
|
||||
// Staging directory for self-update binaries (pending-upgrade.bin etc.).
|
||||
// Mirrors /var/lib/redflag/agent on Linux, C:\ProgramData\RedFlag\agent on Windows.
|
||||
func GetAgentStagingDir() string {
|
||||
return filepath.Join(GetBaseDir(), AgentDir)
|
||||
}
|
||||
|
||||
// GetAgentStagingPath returns the staging path for a self-update binary.
|
||||
// suffix is e.g. "pending-upgrade.bin", "pending-helper.bin", "pending-desktop.bin".
|
||||
func GetAgentStagingPath(suffix string) string {
|
||||
return filepath.Join(GetBaseDir(), AgentDir, suffix)
|
||||
}
|
||||
|
||||
// Native packages set this at build time; source installs retain their layout.
|
||||
var LinuxBinaryInstallDir = "/usr/local/bin"
|
||||
|
||||
// Binary install paths — source/package layout / C:\Program Files\RedFlag.
|
||||
func GetBinaryInstallDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return `C:\Program Files\RedFlag`
|
||||
}
|
||||
return LinuxBinaryInstallDir
|
||||
}
|
||||
|
||||
func GetAgentBinaryPath() string {
|
||||
name := "redflag-agent"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "redflag-agent.exe"
|
||||
}
|
||||
return filepath.Join(GetBinaryInstallDir(), name)
|
||||
}
|
||||
|
||||
func GetHelperBinaryPath() string {
|
||||
name := "redflag-helper"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "redflag-helper.exe"
|
||||
}
|
||||
return filepath.Join(GetBinaryInstallDir(), name)
|
||||
}
|
||||
|
||||
func GetDesktopBinaryPath() string {
|
||||
name := "redflag-desktop"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "redflag-desktop.exe"
|
||||
}
|
||||
return filepath.Join(GetBinaryInstallDir(), name)
|
||||
}
|
||||
382
agent/internal/crypto/pubkey.go
Normal file
382
agent/internal/crypto/pubkey.go
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/event"
|
||||
)
|
||||
|
||||
var teeLogger *event.TeeLogger
|
||||
|
||||
// InitLogger sets the package-level TeeLogger for dual-output logging.
|
||||
func InitLogger(l *event.TeeLogger) {
|
||||
teeLogger = l
|
||||
}
|
||||
|
||||
const defaultCacheTTLHours = 24
|
||||
|
||||
// Stale-cache fallback window (SEC-028). When the server is unreachable the
|
||||
// agent keeps working on its last-known public key, but only for a bounded
|
||||
// time: a key the server rotated OUT must not stay trusted forever just because
|
||||
// the agent can't phone home. The WINDOW LENGTH is operator policy delivered via
|
||||
// security settings (command_signing.stale_key_max_age_hours). The BOUNDS are
|
||||
// doctrine and live here, so neither a setting nor a tampered local config can
|
||||
// widen it past the ceiling or disable it. Forward-only is not a knob; the
|
||||
// existence of a fail-closed ceiling is fixed, only its length is tunable.
|
||||
const (
|
||||
defaultStaleKeyMaxAge = 7 * 24 * time.Hour // policy default; mirrors the server-side setting default
|
||||
minStaleKeyMaxAge = 1 * time.Hour // doctrinal floor — the window is always bounded
|
||||
maxStaleKeyMaxAge = 30 * 24 * time.Hour // doctrinal ceiling — forward-only cap, cannot be exceeded
|
||||
)
|
||||
|
||||
// staleKeyMaxAgeNanos holds the active window. Written by SetStaleKeyMaxAge from
|
||||
// the config-refresh goroutine, read on the command-verify path — atomic so the
|
||||
// two don't race. Zero means "unset": staleKeyMaxAge() falls back to the default.
|
||||
var staleKeyMaxAgeNanos atomic.Int64
|
||||
|
||||
// staleKeyMaxAge returns the active stale-cache window.
|
||||
func staleKeyMaxAge() time.Duration {
|
||||
if n := staleKeyMaxAgeNanos.Load(); n > 0 {
|
||||
return time.Duration(n)
|
||||
}
|
||||
return defaultStaleKeyMaxAge
|
||||
}
|
||||
|
||||
// SetStaleKeyMaxAge applies an operator-configured window (in hours), clamped to
|
||||
// the doctrinal [min,max] range. hours <= 0 means "unset" and restores the
|
||||
// default. The clamp is the enforcement point: a setting or local config that
|
||||
// asks for more than the ceiling gets the ceiling, never the request. Returns
|
||||
// the window actually applied.
|
||||
func SetStaleKeyMaxAge(hours int) time.Duration {
|
||||
d := defaultStaleKeyMaxAge
|
||||
if hours > 0 {
|
||||
d = time.Duration(hours) * time.Hour
|
||||
if d < minStaleKeyMaxAge {
|
||||
d = minStaleKeyMaxAge
|
||||
}
|
||||
if d > maxStaleKeyMaxAge {
|
||||
d = maxStaleKeyMaxAge
|
||||
}
|
||||
}
|
||||
staleKeyMaxAgeNanos.Store(int64(d))
|
||||
return d
|
||||
}
|
||||
|
||||
// getPublicKeyDir returns the platform-specific directory for key cache files
|
||||
// Uses constants package to ensure consistency with other path definitions.
|
||||
func getPublicKeyDir() string {
|
||||
return constants.GetServerPublicKeyDir()
|
||||
}
|
||||
|
||||
// getPrimaryKeyPath returns the path for the primary cached public key
|
||||
// Uses constants package as single source of truth (BUG-012 fix).
|
||||
func getPrimaryKeyPath() string {
|
||||
return constants.GetServerPublicKeyPath()
|
||||
}
|
||||
|
||||
// getKeyPathByID returns the path for a specific key cached by key_id
|
||||
func getKeyPathByID(keyID string) string {
|
||||
return filepath.Join(getPublicKeyDir(), "server_public_key_"+keyID)
|
||||
}
|
||||
|
||||
// getPrimaryMetaPath returns the metadata file path for the primary key
|
||||
func getPrimaryMetaPath() string {
|
||||
return filepath.Join(getPublicKeyDir(), "server_public_key.meta")
|
||||
}
|
||||
|
||||
// CacheMetadata holds metadata about the cached public key
|
||||
type CacheMetadata struct {
|
||||
KeyID string `json:"key_id"`
|
||||
Version int `json:"version"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
TTLHours int `json:"ttl_hours"`
|
||||
}
|
||||
|
||||
// IsExpired returns true if the cache TTL has been exceeded
|
||||
func (m *CacheMetadata) IsExpired() bool {
|
||||
ttl := time.Duration(m.TTLHours) * time.Hour
|
||||
if ttl <= 0 {
|
||||
ttl = defaultCacheTTLHours * time.Hour
|
||||
}
|
||||
return time.Since(m.CachedAt) > ttl
|
||||
}
|
||||
|
||||
// PublicKeyResponse represents the server's public key response
|
||||
type PublicKeyResponse struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
KeySize int `json:"key_size"`
|
||||
KeyID string `json:"key_id"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// ActivePublicKeyEntry represents one entry from GET /api/v1/public-keys
|
||||
type ActivePublicKeyEntry struct {
|
||||
KeyID string `json:"key_id"`
|
||||
PublicKey string `json:"public_key"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
Version int `json:"version"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
}
|
||||
|
||||
// loadCacheMetadata loads the metadata sidecar file for the primary key
|
||||
func loadCacheMetadata() (*CacheMetadata, error) {
|
||||
data, err := os.ReadFile(getPrimaryMetaPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var meta CacheMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
// saveCacheMetadata writes the metadata sidecar file
|
||||
func saveCacheMetadata(meta *CacheMetadata) error {
|
||||
dir := getPublicKeyDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create key dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
return os.WriteFile(getPrimaryMetaPath(), data, 0644)
|
||||
}
|
||||
|
||||
// FetchAndCacheServerPublicKey fetches the server's Ed25519 primary public key.
|
||||
// Uses a TTL+key_id cache: skips the fetch only if both TTL is valid AND key_id matches.
|
||||
// Implements Trust-On-First-Use (TOFU) with rotation awareness.
|
||||
func FetchAndCacheServerPublicKey(serverURL string) (ed25519.PublicKey, error) {
|
||||
// Check if cache is still valid
|
||||
if meta, err := loadCacheMetadata(); err == nil && meta.KeyID != "" && !meta.IsExpired() {
|
||||
// Cache metadata is valid and within TTL — try to load the cached key
|
||||
if cachedKey, err := LoadCachedPublicKey(); err == nil && cachedKey != nil {
|
||||
return cachedKey, nil
|
||||
}
|
||||
// Cache file missing despite valid metadata — fall through to re-fetch
|
||||
}
|
||||
|
||||
// Fetch primary key from server
|
||||
resp, err := http.Get(serverURL + "/api/v1/public-key")
|
||||
if err != nil {
|
||||
// Network failed — serve the cached key only within the bounded staleness
|
||||
// window (SEC-028). Past the ceiling, or when the cache age can't be
|
||||
// established, fail closed: forward-only forbids trusting a possibly
|
||||
// rotated-out key indefinitely.
|
||||
cachedKey, loadErr := LoadCachedPublicKey()
|
||||
if loadErr != nil {
|
||||
return nil, fmt.Errorf("failed to fetch public key from server: %w", err)
|
||||
}
|
||||
meta, metaErr := loadCacheMetadata()
|
||||
if metaErr != nil {
|
||||
return nil, fmt.Errorf("public key fetch failed and cache age is unknown (no metadata); refusing stale key: %w", err)
|
||||
}
|
||||
age := time.Since(meta.CachedAt)
|
||||
window := staleKeyMaxAge()
|
||||
if age > window {
|
||||
return nil, fmt.Errorf("public key fetch failed and cached key is stale (age %s > max %s); refusing: %w",
|
||||
age.Round(time.Hour), window, err)
|
||||
}
|
||||
// Degraded trust, not a routine warning: surface at ERROR so an operator
|
||||
// sees an agent running on an un-refreshed signing key.
|
||||
if teeLogger != nil {
|
||||
teeLogger.Error("agent", "crypto", "pubkey", "server unreachable, serving stale cached public key within bounded window", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"cache_age": age.Round(time.Minute).String(),
|
||||
"max_stale": window.String(),
|
||||
"key_id": meta.KeyID,
|
||||
})
|
||||
}
|
||||
return cachedKey, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var keyResp PublicKeyResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&keyResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public key response: %w", err)
|
||||
}
|
||||
|
||||
if keyResp.Algorithm != "ed25519" {
|
||||
return nil, fmt.Errorf("unsupported signature algorithm: %s (expected ed25519)", keyResp.Algorithm)
|
||||
}
|
||||
|
||||
pubKeyBytes, err := hex.DecodeString(keyResp.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid public key format: %w", err)
|
||||
}
|
||||
if len(pubKeyBytes) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("invalid public key size: expected %d bytes, got %d", ed25519.PublicKeySize, len(pubKeyBytes))
|
||||
}
|
||||
|
||||
publicKey := ed25519.PublicKey(pubKeyBytes)
|
||||
|
||||
// Cache the primary key
|
||||
if err := cachePublicKey(publicKey); err != nil {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "pubkey", "failed to cache primary public key", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Use key_id from response (fall back to fingerprint for old servers)
|
||||
keyID := keyResp.KeyID
|
||||
if keyID == "" {
|
||||
keyID = keyResp.Fingerprint
|
||||
}
|
||||
|
||||
// Write metadata sidecar
|
||||
meta := &CacheMetadata{
|
||||
KeyID: keyID,
|
||||
Version: keyResp.Version,
|
||||
CachedAt: time.Now().UTC(),
|
||||
TTLHours: defaultCacheTTLHours,
|
||||
}
|
||||
if err := saveCacheMetadata(meta); err != nil {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "pubkey", "failed to save key cache metadata", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Also cache by key_id for multi-key lookup
|
||||
if keyID != "" {
|
||||
if err := CachePublicKeyByID(keyID, publicKey); err != nil {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "pubkey", "failed to cache key by ID", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if teeLogger != nil {
|
||||
teeLogger.Info("agent", "crypto", "pubkey", "public key fetched and cached", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
"version": keyResp.Version,
|
||||
})
|
||||
}
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
// FetchAndCacheAllActiveKeys fetches all active public keys from GET /api/v1/public-keys
|
||||
// and caches each one by its key_id. Used during key rotation transition windows.
|
||||
func FetchAndCacheAllActiveKeys(serverURL string) ([]ActivePublicKeyEntry, error) {
|
||||
resp, err := http.Get(serverURL + "/api/v1/public-keys")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch active public keys: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var entries []ActivePublicKeyEntry
|
||||
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode public keys list: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Algorithm != "ed25519" {
|
||||
continue
|
||||
}
|
||||
pubKeyBytes, err := hex.DecodeString(entry.PublicKey)
|
||||
if err != nil || len(pubKeyBytes) != ed25519.PublicKeySize {
|
||||
continue
|
||||
}
|
||||
if err := CachePublicKeyByID(entry.KeyID, ed25519.PublicKey(pubKeyBytes)); err != nil {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "pubkey", "failed to cache key", map[string]interface{}{
|
||||
"key_id": entry.KeyID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// LoadCachedPublicKey loads the primary cached public key from disk (backward compat path)
|
||||
func LoadCachedPublicKey() (ed25519.PublicKey, error) {
|
||||
data, err := os.ReadFile(getPrimaryKeyPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("cached public key has invalid size: %d bytes", len(data))
|
||||
}
|
||||
return ed25519.PublicKey(data), nil
|
||||
}
|
||||
|
||||
// LoadCachedPublicKeyByID loads a cached public key by its key_id.
|
||||
// Falls back to the primary key if the key_id-specific file does not exist.
|
||||
func LoadCachedPublicKeyByID(keyID string) (ed25519.PublicKey, error) {
|
||||
if keyID == "" {
|
||||
return LoadCachedPublicKey()
|
||||
}
|
||||
data, err := os.ReadFile(getKeyPathByID(keyID))
|
||||
if err == nil && len(data) == ed25519.PublicKeySize {
|
||||
return ed25519.PublicKey(data), nil
|
||||
}
|
||||
// Fall back to primary
|
||||
return LoadCachedPublicKey()
|
||||
}
|
||||
|
||||
// IsKeyIDCached returns true if a key with the given key_id is cached locally
|
||||
func IsKeyIDCached(keyID string) bool {
|
||||
if keyID == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(getKeyPathByID(keyID))
|
||||
return err == nil && info.Size() == ed25519.PublicKeySize
|
||||
}
|
||||
|
||||
// cachePublicKey saves the primary public key to disk (backward compat path)
|
||||
func cachePublicKey(publicKey ed25519.PublicKey) error {
|
||||
dir := getPublicKeyDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
return os.WriteFile(getPrimaryKeyPath(), publicKey, 0644)
|
||||
}
|
||||
|
||||
// CachePublicKeyByID saves a public key under its key_id filename
|
||||
func CachePublicKeyByID(keyID string, publicKey ed25519.PublicKey) error {
|
||||
if keyID == "" {
|
||||
return fmt.Errorf("keyID cannot be empty")
|
||||
}
|
||||
dir := getPublicKeyDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
return os.WriteFile(getKeyPathByID(keyID), publicKey, 0644)
|
||||
}
|
||||
|
||||
// GetPublicKey returns the primary cached public key or fetches it from the server
|
||||
func GetPublicKey(serverURL string) (ed25519.PublicKey, error) {
|
||||
// Try with TTL-aware fetch (will use cache if valid)
|
||||
return FetchAndCacheServerPublicKey(serverURL)
|
||||
}
|
||||
50
agent/internal/crypto/pubkey_test.go
Normal file
50
agent/internal/crypto/pubkey_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package crypto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCacheMetadataIsExpired(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
meta CacheMetadata
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "fresh_within_ttl",
|
||||
meta: CacheMetadata{CachedAt: time.Now(), TTLHours: 24},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "expired_past_ttl",
|
||||
meta: CacheMetadata{CachedAt: time.Now().Add(-25 * time.Hour), TTLHours: 24},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "zero_ttl_defaults_24h_fresh",
|
||||
meta: CacheMetadata{CachedAt: time.Now(), TTLHours: 0},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "zero_ttl_defaults_24h_expired",
|
||||
meta: CacheMetadata{CachedAt: time.Now().Add(-25 * time.Hour), TTLHours: 0},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "exactly_at_ttl_boundary",
|
||||
meta: CacheMetadata{CachedAt: time.Now().Add(-24 * time.Hour), TTLHours: 24},
|
||||
expected: true, // at exactly TTL, treat as expired
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.meta.IsExpired()
|
||||
if got != tt.expected {
|
||||
t.Errorf("IsExpired() = %v, want %v (cachedAt=%v, ttl=%dh)",
|
||||
got, tt.expected, tt.meta.CachedAt, tt.meta.TTLHours)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
241
agent/internal/crypto/replay_test.go
Normal file
241
agent/internal/crypto/replay_test.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
package crypto
|
||||
|
||||
// replay_test.go — Pre-fix tests for command replay attack surface on the agent side.
|
||||
//
|
||||
// These tests document the current (buggy) behaviour of the agent's command
|
||||
// verification path. All tests use helpers defined in verification_test.go
|
||||
// (generateKeyPair, signCommand, signCommandOld) which are in the same package.
|
||||
//
|
||||
// Each test is categorised:
|
||||
//
|
||||
// PASS-NOW / FAIL-AFTER-FIX — documents a bug as-is; flips to fail when fix is applied.
|
||||
//
|
||||
// Run: cd agent && go test ./internal/crypto/... -v -run TestReplay
|
||||
// cd agent && go test ./internal/crypto/... -v -run TestOld
|
||||
// cd agent && go test ./internal/crypto/... -v -run TestNew
|
||||
// cd agent && go test ./internal/crypto/... -v -run TestSame
|
||||
// cd agent && go test ./internal/crypto/... -v -run TestCross
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2.1 — BUG F-3: Old-format commands are valid forever
|
||||
//
|
||||
// Category: PASS-NOW / FAIL-AFTER-FIX
|
||||
//
|
||||
// VerifyCommand (crypto/verification.go:25) reconstructs the message as
|
||||
// "{id}:{type}:{sha256(params)}" and verifies the Ed25519 signature.
|
||||
// There is NO time check. A command signed 72 hours ago — or 72 years ago —
|
||||
// passes without error. The test PASSES now (bug is present).
|
||||
// After fix (add expiry to VerifyCommand or deprecate old format): FAILS.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOldFormatReplayIsUnbounded(t *testing.T) {
|
||||
// POST-FIX (F-3): Old-format commands with CreatedAt older than 48h are rejected.
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
// Old-format command created 72 hours ago
|
||||
createdAt := time.Now().Add(-72 * time.Hour)
|
||||
cmd := client.Command{
|
||||
ID: "old-replay-cmd-72h",
|
||||
Type: "install_updates",
|
||||
Params: map[string]interface{}{"package": "nginx"},
|
||||
SignedAt: nil,
|
||||
CreatedAt: &createdAt,
|
||||
}
|
||||
cmd.Signature = signCommandOld(t, priv, &cmd)
|
||||
|
||||
// After F-3 fix: 72h old-format command must be rejected
|
||||
err := v.VerifyCommand(cmd, pub)
|
||||
if err == nil {
|
||||
t.Error("F-3 FIX BROKEN: old-format command 72h old should be rejected, but passed")
|
||||
}
|
||||
t.Logf("F-3 FIXED: Old-format command created 72h ago correctly rejected: %v", err)
|
||||
}
|
||||
|
||||
func TestOldFormatRecentCommandStillPasses(t *testing.T) {
|
||||
// POST-FIX (F-3): Old-format commands WITHIN 48h still pass (backward compat).
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
// Old-format command created 12 hours ago (within 48h limit)
|
||||
createdAt := time.Now().Add(-12 * time.Hour)
|
||||
cmd := client.Command{
|
||||
ID: "old-recent-cmd",
|
||||
Type: "install_updates",
|
||||
Params: map[string]interface{}{"package": "nginx"},
|
||||
SignedAt: nil,
|
||||
CreatedAt: &createdAt,
|
||||
}
|
||||
cmd.Signature = signCommandOld(t, priv, &cmd)
|
||||
|
||||
err := v.VerifyCommand(cmd, pub)
|
||||
if err != nil {
|
||||
t.Errorf("old-format command within 48h should pass: %v", err)
|
||||
}
|
||||
t.Log("F-3 BACKWARD COMPAT: Old-format command created 12h ago passes verification.")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2.2 — F-4: New-format commands can be replayed for 24 hours
|
||||
//
|
||||
// Category: PASS-NOW / MAY-REMAIN-PASSING-UNTIL-maxAge-IS-REDUCED
|
||||
//
|
||||
// VerifyCommandWithTimestamp allows commands signed up to commandMaxAge (24h)
|
||||
// in the past. A captured command from 23 hours and 59 minutes ago still
|
||||
// passes verification. This documents the replay window.
|
||||
//
|
||||
// Note: This test reflects an intentional (but generous) design decision.
|
||||
// It will only flip to FAIL if commandMaxAge is reduced below 24h.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewFormatCommandCanBeReplayedWithin24Hours(t *testing.T) {
|
||||
// POST-FIX (F-4): commandMaxAge reduced to 4h. Test updated to use 3h59m.
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
// Command signed almost 4 hours ago — still within the new 4h window.
|
||||
signedAt := time.Now().UTC().Add(-3*time.Hour - 59*time.Minute)
|
||||
|
||||
cmd := client.Command{
|
||||
ID: "replay-4h-cmd",
|
||||
Type: "install_updates",
|
||||
Params: map[string]interface{}{"package": "nginx"},
|
||||
AgentID: "agent-replay-test",
|
||||
}
|
||||
cmd.SignedAt = &signedAt
|
||||
cmd.Signature = signCommand(t, priv, &cmd, signedAt)
|
||||
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("expected 3h59m old command to pass within 4h window, got: %v", err)
|
||||
}
|
||||
|
||||
t.Log("F-4 FIXED: Command signed 3h59m ago passes VerifyCommandWithTimestamp with 4h window.")
|
||||
t.Logf(" SignedAt: %v (window: 4h)", signedAt.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func TestCommandBeyond4HoursIsRejected(t *testing.T) {
|
||||
// POST-FIX (F-4): Commands older than 4h must be rejected.
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
signedAt := time.Now().UTC().Add(-4*time.Hour - 1*time.Minute)
|
||||
cmd := client.Command{
|
||||
ID: "expired-4h-cmd",
|
||||
Type: "install_updates",
|
||||
Params: map[string]interface{}{"package": "nginx"},
|
||||
AgentID: "agent-replay-test",
|
||||
}
|
||||
cmd.SignedAt = &signedAt
|
||||
cmd.Signature = signCommand(t, priv, &cmd, signedAt)
|
||||
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
|
||||
if err == nil {
|
||||
t.Error("expected 4h1m old command to be rejected, but it passed")
|
||||
}
|
||||
t.Logf("F-4 FIXED: Command signed 4h1m ago correctly rejected: %v", err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2.3 — BUG F-2: The same command can be verified any number of times
|
||||
//
|
||||
// Category: PASS-NOW / FAIL-AFTER-FIX
|
||||
//
|
||||
// VerifyCommandWithTimestamp is a pure function — given the same inputs it
|
||||
// returns the same output every time. There is no nonce, no single-use token,
|
||||
// and no agent-side deduplication. A replayed command passes verification
|
||||
// identically on the second, third, and Nth call within the time window.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSameCommandCanBeVerifiedTwice(t *testing.T) {
|
||||
// POST-FIX (F-2): Deduplication is now at the ProcessCommand level,
|
||||
// not at the VerifyCommandWithTimestamp level. The verifier is a pure
|
||||
// function — it still returns success on repeated calls. The dedup
|
||||
// is handled by CommandHandler.ProcessCommand's executedIDs set.
|
||||
//
|
||||
// This test documents that the VERIFIER allows repeated verification
|
||||
// (which is correct — dedup is a higher-layer concern).
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
signedAt := time.Now().UTC()
|
||||
cmd := client.Command{
|
||||
ID: "no-nonce-cmd",
|
||||
Type: "reboot",
|
||||
Params: map[string]interface{}{},
|
||||
AgentID: "agent-dedup-test",
|
||||
}
|
||||
cmd.SignedAt = &signedAt
|
||||
cmd.Signature = signCommand(t, priv, &cmd, signedAt)
|
||||
|
||||
// Verifier-level: still passes multiple times (pure function)
|
||||
err1 := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
|
||||
if err1 != nil {
|
||||
t.Fatalf("first verification should pass: %v", err1)
|
||||
}
|
||||
|
||||
err2 := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
|
||||
if err2 != nil {
|
||||
t.Fatalf("second verification should also pass at verifier level: %v", err2)
|
||||
}
|
||||
|
||||
t.Log("F-2 NOTE: Verifier is a pure function — dedup is at ProcessCommand layer.")
|
||||
t.Log("CommandHandler.ProcessCommand maintains executedIDs set for single-use enforcement.")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2.4 — BUG F-1: Signed message contains no agent binding
|
||||
//
|
||||
// Category: PASS-NOW / FAIL-AFTER-FIX
|
||||
//
|
||||
// The signed message format is: "{id}:{type}:{sha256(params)}:{timestamp}"
|
||||
// None of these components are bound to a specific agent. The client.Command
|
||||
// struct has no agent_id field at all — it is stripped before delivery.
|
||||
// The same signature verifies regardless of which agent receives the command.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCrossAgentSignatureVerifies(t *testing.T) {
|
||||
// POST-FIX (F-1): agent_id is now in the signed message.
|
||||
// A command signed for agent A must fail verification when presented
|
||||
// with agent B's ID.
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
signedAt := time.Now().UTC()
|
||||
agentA := "agent-aaa-111"
|
||||
agentB := "agent-bbb-222"
|
||||
|
||||
// Sign command for agent A using v3 format
|
||||
cmd := client.Command{
|
||||
ID: "cross-agent-cmd",
|
||||
Type: "install_updates",
|
||||
Params: map[string]interface{}{"package": "nginx"},
|
||||
AgentID: agentA,
|
||||
}
|
||||
cmd.SignedAt = &signedAt
|
||||
cmd.Signature = signCommand(t, priv, &cmd, signedAt)
|
||||
|
||||
// Verify with correct agent A — should pass
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 24*time.Hour, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("verification with correct agent A should pass, got: %v", err)
|
||||
}
|
||||
|
||||
// Now try with agent B's ID — should FAIL
|
||||
cmdForB := cmd
|
||||
cmdForB.AgentID = agentB
|
||||
err = v.VerifyCommandWithTimestamp(cmdForB, pub, 24*time.Hour, 5*time.Minute)
|
||||
if err == nil {
|
||||
t.Error("F-1 FIX BROKEN: cross-agent verification with agent B should FAIL but passed")
|
||||
}
|
||||
|
||||
t.Logf("F-1 FIXED: Command signed for agent %q fails verification when presented as agent %q", agentA, agentB)
|
||||
t.Log("The signature is now bound to the target agent_id in the v3 message format.")
|
||||
}
|
||||
299
agent/internal/crypto/verification.go
Normal file
299
agent/internal/crypto/verification.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
)
|
||||
|
||||
// CommandVerifier handles Ed25519 signature verification for commands
|
||||
type CommandVerifier struct{}
|
||||
|
||||
// NewCommandVerifier creates a new command verifier
|
||||
func NewCommandVerifier() *CommandVerifier {
|
||||
return &CommandVerifier{}
|
||||
}
|
||||
|
||||
// oldFormatMaxAge is the maximum age for old-format commands (no signed_at).
|
||||
// Phase 1 (F-3 fix): reject old-format commands older than 48h.
|
||||
// Phase 2 (future): remove old-format fallback entirely after 90 days from migration 025 deployment.
|
||||
const oldFormatMaxAge = 48 * time.Hour
|
||||
|
||||
// VerifyCommand verifies a command using the old signing format (no timestamp).
|
||||
// Used for backward compatibility with commands signed before key rotation support.
|
||||
// Format: "{id}:{command_type}:{sha256(params)}"
|
||||
// F-3 fix: rejects commands older than 48h if CreatedAt is available.
|
||||
func (v *CommandVerifier) VerifyCommand(cmd client.Command, serverPubKey ed25519.PublicKey) error {
|
||||
// F-3 fix: check age using server-side created_at if available
|
||||
if cmd.CreatedAt != nil {
|
||||
age := time.Since(*cmd.CreatedAt)
|
||||
if age > oldFormatMaxAge {
|
||||
return fmt.Errorf("command too old: old-format command exceeds 48h age limit (created %v ago)", age.Round(time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
if cmd.Signature == "" {
|
||||
return fmt.Errorf("command missing signature")
|
||||
}
|
||||
sig, err := hex.DecodeString(cmd.Signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid signature encoding: %w", err)
|
||||
}
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("invalid signature length: expected %d bytes, got %d", ed25519.SignatureSize, len(sig))
|
||||
}
|
||||
message, err := v.reconstructMessage(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reconstruct message: %w", err)
|
||||
}
|
||||
if !ed25519.Verify(serverPubKey, message, sig) {
|
||||
return fmt.Errorf("signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconstructMessage recreates the signed message using the old format (no timestamp).
|
||||
// Format: "{id}:{command_type}:{sha256(params)}"
|
||||
func (v *CommandVerifier) reconstructMessage(cmd client.Command) ([]byte, error) {
|
||||
paramsJSON, err := json.Marshal(cmd.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal parameters: %w", err)
|
||||
}
|
||||
paramsHash := sha256.Sum256(paramsJSON)
|
||||
paramsHashHex := hex.EncodeToString(paramsHash[:])
|
||||
message := fmt.Sprintf("%s:%s:%s", cmd.ID, cmd.Type, paramsHashHex)
|
||||
return []byte(message), nil
|
||||
}
|
||||
|
||||
// reconstructMessageV3 recreates the signed message using v3 format (with agent_id + timestamp).
|
||||
// Format: "{agent_id}:{id}:{command_type}:{sha256(params)}:{unix_timestamp}"
|
||||
func (v *CommandVerifier) reconstructMessageV3(cmd client.Command) ([]byte, error) {
|
||||
if cmd.SignedAt == nil {
|
||||
return nil, fmt.Errorf("command SignedAt is nil, cannot reconstruct v3 message")
|
||||
}
|
||||
if cmd.AgentID == "" {
|
||||
return nil, fmt.Errorf("command AgentID is empty, cannot reconstruct v3 message")
|
||||
}
|
||||
paramsJSON, err := json.Marshal(cmd.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal parameters: %w", err)
|
||||
}
|
||||
paramsHash := sha256.Sum256(paramsJSON)
|
||||
paramsHashHex := hex.EncodeToString(paramsHash[:])
|
||||
message := fmt.Sprintf("%s:%s:%s:%s:%d", cmd.AgentID, cmd.ID, cmd.Type, paramsHashHex, cmd.SignedAt.Unix())
|
||||
return []byte(message), nil
|
||||
}
|
||||
|
||||
// reconstructMessageWithTimestamp recreates the signed message using v2 format (timestamp, no agent_id).
|
||||
// Format: "{id}:{command_type}:{sha256(params)}:{unix_timestamp}"
|
||||
func (v *CommandVerifier) reconstructMessageWithTimestamp(cmd client.Command) ([]byte, error) {
|
||||
if cmd.SignedAt == nil {
|
||||
return nil, fmt.Errorf("command SignedAt is nil, cannot reconstruct timestamped message")
|
||||
}
|
||||
paramsJSON, err := json.Marshal(cmd.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal parameters: %w", err)
|
||||
}
|
||||
paramsHash := sha256.Sum256(paramsJSON)
|
||||
paramsHashHex := hex.EncodeToString(paramsHash[:])
|
||||
message := fmt.Sprintf("%s:%s:%s:%d", cmd.ID, cmd.Type, paramsHashHex, cmd.SignedAt.Unix())
|
||||
return []byte(message), nil
|
||||
}
|
||||
|
||||
// VerifyCommandWithTimestamp verifies a command signature AND validates the signing timestamp.
|
||||
// Rejects commands signed more than maxAge in the past, or more than clockSkew in the future.
|
||||
// Uses the new timestamped message format.
|
||||
// If cmd.SignedAt is nil, falls back to the old (no-timestamp) verification format for backward compatibility.
|
||||
//
|
||||
// The default maxAge used by command_handler.go is 4 hours (reduced from 24h in A-2 fix F-4).
|
||||
// This balances security (shorter replay window) against operational flexibility (agents
|
||||
// polling every few minutes have ample time to receive and verify commands).
|
||||
// See commandMaxAge constant in orchestrator/command_handler.go.
|
||||
func (v *CommandVerifier) VerifyCommandWithTimestamp(
|
||||
cmd client.Command,
|
||||
serverPubKey ed25519.PublicKey,
|
||||
maxAge time.Duration,
|
||||
clockSkew time.Duration,
|
||||
) error {
|
||||
if cmd.SignedAt == nil {
|
||||
// No timestamp — fall back to old format (backward compat)
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "verification", "deprecated command format: oldest format, no signed_at", map[string]interface{}{
|
||||
"command_id": cmd.ID,
|
||||
})
|
||||
}
|
||||
return v.VerifyCommand(cmd, serverPubKey)
|
||||
}
|
||||
|
||||
// Validate timestamp window
|
||||
now := time.Now().UTC()
|
||||
age := now.Sub(*cmd.SignedAt)
|
||||
if age > maxAge {
|
||||
return fmt.Errorf("command timestamp too old: signed %v ago (max %v)", age.Round(time.Second), maxAge)
|
||||
}
|
||||
if age < -clockSkew {
|
||||
return fmt.Errorf("command timestamp is in the future: %v ahead (max skew %v)", (-age).Round(time.Second), clockSkew)
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if cmd.Signature == "" {
|
||||
return fmt.Errorf("command missing signature")
|
||||
}
|
||||
sig, err := hex.DecodeString(cmd.Signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid signature encoding: %w", err)
|
||||
}
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("invalid signature length: expected %d bytes, got %d", ed25519.SignatureSize, len(sig))
|
||||
}
|
||||
|
||||
// Try v3 format first (with agent_id) if AgentID is present
|
||||
if cmd.AgentID != "" {
|
||||
message, err := v.reconstructMessageV3(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reconstruct v3 message: %w", err)
|
||||
}
|
||||
if ed25519.Verify(serverPubKey, message, sig) {
|
||||
return nil // v3 verification succeeded
|
||||
}
|
||||
// v3 failed — try v2 as fallback (server may not have been upgraded yet)
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "verification", "v3 verification failed, trying v2", map[string]interface{}{
|
||||
"command_id": cmd.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// v2 format: timestamp but no agent_id (backward compat)
|
||||
if cmd.AgentID == "" {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "verification", "deprecated command format: v2, no agent_id", map[string]interface{}{
|
||||
"command_id": cmd.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
message, err := v.reconstructMessageWithTimestamp(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reconstruct timestamped message: %w", err)
|
||||
}
|
||||
if !ed25519.Verify(serverPubKey, message, sig) {
|
||||
return fmt.Errorf("signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckKeyRotation checks if the key_id in a command is cached locally.
|
||||
// If not cached, it fetches all active keys from the server and caches them.
|
||||
// Returns the correct public key to use for verifying this command.
|
||||
func (v *CommandVerifier) CheckKeyRotation(keyID string, serverURL string) (ed25519.PublicKey, bool, error) {
|
||||
if keyID == "" {
|
||||
// No key_id in command — backward compat: use primary cached key
|
||||
key, err := LoadCachedPublicKey()
|
||||
return key, false, err
|
||||
}
|
||||
|
||||
// Check if this key is already cached
|
||||
if IsKeyIDCached(keyID) {
|
||||
key, err := LoadCachedPublicKeyByID(keyID)
|
||||
return key, false, err
|
||||
}
|
||||
|
||||
// Key not cached — fetch all active keys from server
|
||||
if teeLogger != nil {
|
||||
teeLogger.Info("agent", "crypto", "verification", "key not cached, fetching active keys from server", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
})
|
||||
}
|
||||
entries, err := FetchAndCacheAllActiveKeys(serverURL)
|
||||
if err != nil {
|
||||
// Forward-only (SEC-028): the active-set fetch failed, but trusting the
|
||||
// primary cache here with NO age bound is a wider hole than the primary
|
||||
// fetch path (FetchAndCacheServerPublicKey) closes on its own. Apply the
|
||||
// same bounded-stale ceiling: serve the primary only within the window,
|
||||
// fail closed past it. A primary the server rotated OUT must not verify a
|
||||
// key_id'd command just because /public-keys is unreachable.
|
||||
key, loadErr := LoadCachedPublicKey()
|
||||
if loadErr != nil {
|
||||
return nil, false, fmt.Errorf("key %s not cached and active-set fetch failed: fetch=%v, load=%v", keyID, err, loadErr)
|
||||
}
|
||||
meta, metaErr := loadCacheMetadata()
|
||||
if metaErr != nil {
|
||||
return nil, false, fmt.Errorf("active-set fetch failed and primary cache age unknown (no metadata); refusing: %w", err)
|
||||
}
|
||||
age := time.Since(meta.CachedAt)
|
||||
window := staleKeyMaxAge()
|
||||
if age > window {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Error("agent", "crypto", "verification", "active-set fetch failed and primary cache stale; refusing key_id'd command", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
"cache_age": age.Round(time.Hour).String(),
|
||||
"max_stale": window.String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return nil, false, fmt.Errorf("active-set fetch failed and primary cache stale (age %s > %s); refusing: %w",
|
||||
age.Round(time.Hour), window, err)
|
||||
}
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "verification", "active-set fetch failed, serving bounded-stale primary", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
"cache_age": age.Round(time.Minute).String(),
|
||||
"max_stale": window.String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return key, false, nil
|
||||
}
|
||||
|
||||
// Check if we got the requested key
|
||||
for _, entry := range entries {
|
||||
if entry.KeyID == keyID {
|
||||
key, err := LoadCachedPublicKeyByID(keyID)
|
||||
return key, true, err
|
||||
}
|
||||
}
|
||||
|
||||
// Requested key_id was fetched but is not in the server's active set. A key
|
||||
// the server has rotated OUT is dead — forward-only doctrine refuses it
|
||||
// rather than silently falling back to the primary key (SEC-028). The caller
|
||||
// surfaces this as a command-verification failure.
|
||||
if teeLogger != nil {
|
||||
teeLogger.Error("agent", "crypto", "verification", "command key_id not in server active set, refusing", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
})
|
||||
}
|
||||
return nil, false, fmt.Errorf("key %s not in server active set", keyID)
|
||||
}
|
||||
|
||||
// VerifyCommandBatch verifies multiple commands efficiently
|
||||
func (v *CommandVerifier) VerifyCommandBatch(
|
||||
commands []client.Command,
|
||||
serverPubKey ed25519.PublicKey,
|
||||
) []error {
|
||||
errors := make([]error, len(commands))
|
||||
for i, cmd := range commands {
|
||||
errors[i] = v.VerifyCommand(cmd, serverPubKey)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
// ExtractCommandIDFromSignature attempts to verify a signature and returns the command ID
|
||||
func (v *CommandVerifier) ExtractCommandIDFromSignature(
|
||||
signature string,
|
||||
expectedMessage string,
|
||||
serverPubKey ed25519.PublicKey,
|
||||
) (string, error) {
|
||||
sig, err := hex.DecodeString(signature)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid signature encoding: %w", err)
|
||||
}
|
||||
if !ed25519.Verify(serverPubKey, []byte(expectedMessage), sig) {
|
||||
return "", fmt.Errorf("signature verification failed")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
216
agent/internal/crypto/verification_test.go
Normal file
216
agent/internal/crypto/verification_test.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
)
|
||||
|
||||
// signCommand is a test helper that signs a command using the v3 format (with agent_id).
|
||||
// Format: "{agent_id}:{id}:{type}:{sha256(params)}:{unix_timestamp}"
|
||||
// Falls back to v2 format if cmd.AgentID is empty (for backward compat tests).
|
||||
func signCommand(t *testing.T, privKey ed25519.PrivateKey, cmd *client.Command, signedAt time.Time) string {
|
||||
t.Helper()
|
||||
paramsJSON, err := json.Marshal(cmd.Params)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal params: %v", err)
|
||||
}
|
||||
paramsHash := sha256.Sum256(paramsJSON)
|
||||
paramsHashHex := hex.EncodeToString(paramsHash[:])
|
||||
var message string
|
||||
if cmd.AgentID != "" {
|
||||
// v3 format with agent_id
|
||||
message = fmt.Sprintf("%s:%s:%s:%s:%d", cmd.AgentID, cmd.ID, cmd.Type, paramsHashHex, signedAt.Unix())
|
||||
} else {
|
||||
// v2 format without agent_id (backward compat)
|
||||
message = fmt.Sprintf("%s:%s:%s:%d", cmd.ID, cmd.Type, paramsHashHex, signedAt.Unix())
|
||||
}
|
||||
sig := ed25519.Sign(privKey, []byte(message))
|
||||
return hex.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// signCommandV2 explicitly signs using v2 format (no agent_id) for backward compat tests.
|
||||
func signCommandV2(t *testing.T, privKey ed25519.PrivateKey, cmd *client.Command, signedAt time.Time) string {
|
||||
t.Helper()
|
||||
paramsJSON, err := json.Marshal(cmd.Params)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal params: %v", err)
|
||||
}
|
||||
paramsHash := sha256.Sum256(paramsJSON)
|
||||
paramsHashHex := hex.EncodeToString(paramsHash[:])
|
||||
message := fmt.Sprintf("%s:%s:%s:%d", cmd.ID, cmd.Type, paramsHashHex, signedAt.Unix())
|
||||
sig := ed25519.Sign(privKey, []byte(message))
|
||||
return hex.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// signCommandOld is a test helper that signs using the old format (no timestamp).
|
||||
// Format: "{id}:{type}:{sha256(params)}"
|
||||
func signCommandOld(t *testing.T, privKey ed25519.PrivateKey, cmd *client.Command) string {
|
||||
t.Helper()
|
||||
paramsJSON, _ := json.Marshal(cmd.Params)
|
||||
paramsHash := sha256.Sum256(paramsJSON)
|
||||
paramsHashHex := hex.EncodeToString(paramsHash[:])
|
||||
message := fmt.Sprintf("%s:%s:%s", cmd.ID, cmd.Type, paramsHashHex)
|
||||
sig := ed25519.Sign(privKey, []byte(message))
|
||||
return hex.EncodeToString(sig)
|
||||
}
|
||||
|
||||
func generateKeyPair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key pair: %v", err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func TestVerifyCommandWithTimestamp_ValidRecent(t *testing.T) {
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
now := time.Now().UTC()
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-1",
|
||||
Type: "scan_updates",
|
||||
Params: map[string]interface{}{"target": "apt"},
|
||||
AgentID: "agent-001",
|
||||
}
|
||||
cmd.SignedAt = &now
|
||||
cmd.Signature = signCommand(t, priv, &cmd, now)
|
||||
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 24*time.Hour, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("expected valid recent command to pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCommandWithTimestamp_TooOld(t *testing.T) {
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
oldTime := time.Now().UTC().Add(-2 * time.Hour)
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-2",
|
||||
Type: "scan_updates",
|
||||
Params: map[string]interface{}{},
|
||||
AgentID: "agent-002",
|
||||
}
|
||||
cmd.SignedAt = &oldTime
|
||||
cmd.Signature = signCommand(t, priv, &cmd, oldTime)
|
||||
|
||||
// With maxAge of 1 hour — should fail
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 1*time.Hour, 5*time.Minute)
|
||||
if err == nil {
|
||||
t.Error("expected old command to fail timestamp check, but it passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCommandWithTimestamp_FutureBeyondSkew(t *testing.T) {
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
futureTime := time.Now().UTC().Add(10 * time.Minute)
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-3",
|
||||
Type: "scan_updates",
|
||||
Params: map[string]interface{}{},
|
||||
AgentID: "agent-003",
|
||||
}
|
||||
cmd.SignedAt = &futureTime
|
||||
cmd.Signature = signCommand(t, priv, &cmd, futureTime)
|
||||
|
||||
// With clockSkew of 5 min — should fail (10 min future)
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 24*time.Hour, 5*time.Minute)
|
||||
if err == nil {
|
||||
t.Error("expected future-dated command to fail, but it passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCommandWithTimestamp_FutureWithinSkew(t *testing.T) {
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
futureTime := time.Now().UTC().Add(2 * time.Minute) // within 5 min skew
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-4",
|
||||
Type: "scan_updates",
|
||||
Params: map[string]interface{}{},
|
||||
AgentID: "agent-004",
|
||||
}
|
||||
cmd.SignedAt = &futureTime
|
||||
cmd.Signature = signCommand(t, priv, &cmd, futureTime)
|
||||
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 24*time.Hour, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("expected command within clock skew to pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCommandWithTimestamp_BackwardCompatNoTimestamp(t *testing.T) {
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
// Set CreatedAt to recent time so F-3 48h check passes
|
||||
createdAt := time.Now().Add(-1 * time.Hour)
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-5",
|
||||
Type: "scan_updates",
|
||||
Params: map[string]interface{}{"pkg": "nginx"},
|
||||
SignedAt: nil, // no timestamp — old server
|
||||
CreatedAt: &createdAt,
|
||||
}
|
||||
cmd.Signature = signCommandOld(t, priv, &cmd)
|
||||
|
||||
// Should fall back to old verification and succeed (within 48h)
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("expected backward-compat (no timestamp) command to pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCommandWithTimestamp_WrongKey(t *testing.T) {
|
||||
_, priv := generateKeyPair(t)
|
||||
wrongPub, _ := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
now := time.Now().UTC()
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-6",
|
||||
Type: "scan_updates",
|
||||
Params: map[string]interface{}{},
|
||||
AgentID: "agent-006",
|
||||
}
|
||||
cmd.SignedAt = &now
|
||||
cmd.Signature = signCommand(t, priv, &cmd, now)
|
||||
|
||||
err := v.VerifyCommandWithTimestamp(cmd, wrongPub, 24*time.Hour, 5*time.Minute)
|
||||
if err == nil {
|
||||
t.Error("expected wrong-key verification to fail, but it passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCommand_BackwardCompat(t *testing.T) {
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
// Set CreatedAt to recent time so F-3 48h check passes
|
||||
createdAt := time.Now().Add(-1 * time.Hour)
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-7",
|
||||
Type: "install_updates",
|
||||
Params: map[string]interface{}{"package": "nginx", "version": "1.20.0"},
|
||||
CreatedAt: &createdAt,
|
||||
}
|
||||
cmd.Signature = signCommandOld(t, priv, &cmd)
|
||||
|
||||
if err := v.VerifyCommand(cmd, pub); err != nil {
|
||||
t.Errorf("expected old-format verification to pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
240
agent/internal/desktop/manager.go
Normal file
240
agent/internal/desktop/manager.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
// Package desktop manages the native Qt/QML local-machine operations console.
|
||||
// The agent service spawns the desktop binary as a child process when a desktop
|
||||
// session is available. The binary connects back to the agent's local API socket
|
||||
// and presents Agent-owned machine state and bounded operator intent.
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager handles spawning and monitoring the desktop app process.
|
||||
type Manager struct {
|
||||
binPath string
|
||||
enabled bool
|
||||
maxRestarts int
|
||||
restartDelay time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
stopped bool
|
||||
lastHealth healthReport
|
||||
}
|
||||
|
||||
// healthReport is Desktop's most recent POST /v1/desktop self-report. On
|
||||
// Linux Desktop is launched by XDG autostart, not by this manager, so the
|
||||
// spawned-process state is always empty there — the health report is the only
|
||||
// liveness and version signal the agent has.
|
||||
type healthReport struct {
|
||||
version string
|
||||
windowOpen bool
|
||||
reportedAt time.Time
|
||||
}
|
||||
|
||||
// healthFreshness is how long a health report counts as proof of a live Desktop.
|
||||
// Desktop reports every 30s; three missed beats means it is gone.
|
||||
const healthFreshness = 90 * time.Second
|
||||
|
||||
// HealthSnapshot is the fleet-reportable desktop component state.
|
||||
type HealthSnapshot struct {
|
||||
Installed bool `json:"installed"`
|
||||
Running bool `json:"running"`
|
||||
Version string `json:"version,omitempty"`
|
||||
WindowOpen bool `json:"window_open,omitempty"`
|
||||
LastReport string `json:"last_report,omitempty"`
|
||||
}
|
||||
|
||||
// NewManager creates a desktop manager. binPath is the path to the redflag-desktop
|
||||
// binary (empty string = auto-detect alongside the agent binary).
|
||||
func NewManager(binPath string, enabled bool, maxRestarts, restartDelaySec int) *Manager {
|
||||
if binPath == "" {
|
||||
binPath = autoDetectBinary()
|
||||
}
|
||||
if restartDelaySec <= 0 {
|
||||
restartDelaySec = 5
|
||||
}
|
||||
return &Manager{
|
||||
binPath: binPath,
|
||||
enabled: enabled,
|
||||
maxRestarts: maxRestarts,
|
||||
restartDelay: time.Duration(restartDelaySec) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// autoDetectBinary finds the desktop binary alongside the agent binary.
|
||||
func autoDetectBinary() string {
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Dir(execPath)
|
||||
name := "redflag-desktop"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "redflag-desktop.exe"
|
||||
}
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
|
||||
// IsAvailable checks if the desktop binary exists and a desktop session is detectable.
|
||||
func (m *Manager) IsAvailable() bool {
|
||||
if m.binPath == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(m.binPath)
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
return hasDesktopSession()
|
||||
}
|
||||
|
||||
// Start begins managing the desktop process. It blocks until Stop() is called
|
||||
// or the context is cancelled. Spawns the binary, restarts on crash up to
|
||||
// maxRestarts times.
|
||||
func (m *Manager) Start(ctx context.Context) {
|
||||
if !m.enabled {
|
||||
log.Printf("[INFO] [agent] [desktop] disabled by config")
|
||||
return
|
||||
}
|
||||
|
||||
if !m.IsAvailable() {
|
||||
log.Printf("[INFO] [agent] [desktop] not available — binary=%s session_detected=false", m.binPath)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [desktop] starting binary=%s", m.binPath)
|
||||
|
||||
m.mu.Lock()
|
||||
ctx, m.cancel = context.WithCancel(ctx)
|
||||
m.stopped = false
|
||||
m.mu.Unlock()
|
||||
|
||||
restarts := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("[INFO] [agent] [desktop] stopped (context cancelled)")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if m.maxRestarts > 0 && restarts >= m.maxRestarts {
|
||||
log.Printf("[WARNING] [agent] [desktop] max restarts reached (%d), giving up", m.maxRestarts)
|
||||
return
|
||||
}
|
||||
|
||||
err := m.run(ctx)
|
||||
if err == nil {
|
||||
// Clean exit — don't restart.
|
||||
log.Printf("[INFO] [agent] [desktop] exited cleanly")
|
||||
return
|
||||
}
|
||||
|
||||
restarts++
|
||||
log.Printf("[WARNING] [agent] [desktop] process exited with error: %v (restart %d)", err, restarts)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(m.restartDelay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// run spawns the desktop binary and waits for it to exit.
|
||||
func (m *Manager) run(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
cmd := exec.CommandContext(ctx, m.binPath)
|
||||
m.cmd = cmd
|
||||
m.mu.Unlock()
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start desktop: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [desktop] spawned pid=%d", cmd.Process.Pid)
|
||||
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
// Stop terminates the desktop process if running.
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
m.stopped = true
|
||||
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
log.Printf("[INFO] [agent] [desktop] terminating pid=%d", m.cmd.Process.Pid)
|
||||
m.cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
// Status returns current desktop process state.
|
||||
func (m *Manager) Status() (running bool, pid int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.cmd == nil || m.cmd.Process == nil {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// Check if process is still alive.
|
||||
if m.cmd.ProcessState != nil && m.cmd.ProcessState.Exited() {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
return true, m.cmd.Process.Pid
|
||||
}
|
||||
|
||||
// RecordHealth stores Desktop's self-report (POST /v1/desktop via localapi).
|
||||
func (m *Manager) RecordHealth(version string, windowOpen bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.lastHealth = healthReport{
|
||||
version: version,
|
||||
windowOpen: windowOpen,
|
||||
reportedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// Health returns the desktop component state for fleet reporting. Running is
|
||||
// true when this manager spawned a live process (Windows) or when a health
|
||||
// report landed within the freshness window (Linux autostart path).
|
||||
func (m *Manager) Health() HealthSnapshot {
|
||||
running, _ := m.Status()
|
||||
|
||||
m.mu.Lock()
|
||||
last := m.lastHealth
|
||||
m.mu.Unlock()
|
||||
|
||||
snap := HealthSnapshot{Running: running}
|
||||
if m.binPath != "" {
|
||||
if info, err := os.Stat(m.binPath); err == nil && !info.IsDir() {
|
||||
snap.Installed = true
|
||||
}
|
||||
}
|
||||
if !last.reportedAt.IsZero() {
|
||||
snap.Version = last.version
|
||||
snap.WindowOpen = last.windowOpen
|
||||
snap.LastReport = last.reportedAt.Format(time.RFC3339)
|
||||
if time.Since(last.reportedAt) <= healthFreshness {
|
||||
snap.Running = true
|
||||
}
|
||||
}
|
||||
return snap
|
||||
}
|
||||
55
agent/internal/desktop/session_linux.go
Normal file
55
agent/internal/desktop/session_linux.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package desktop
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// hasDesktopSession checks if a desktop session is available on Linux.
|
||||
// Looks for DISPLAY or WAYLAND_DISPLAY in the environment, which indicates
|
||||
// an X11 or Wayland session is active.
|
||||
func hasDesktopSession() bool {
|
||||
// Check standard environment variables.
|
||||
if os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Fallback: scan /proc for a running desktop session (the agent service
|
||||
// may not inherit DISPLAY from systemd).
|
||||
return findDesktopSessionInProc()
|
||||
}
|
||||
|
||||
// findDesktopSessionInProc scans /proc/*/environ for DISPLAY or WAYLAND_DISPLAY.
|
||||
// Returns true if any user process has a desktop session active.
|
||||
func findDesktopSessionInProc() bool {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
// Only check numeric (PID) directories.
|
||||
if entry.Name()[0] < '0' || entry.Name()[0] > '9' {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("/proc/" + entry.Name() + "/environ")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range strings.Split(string(data), "\x00") {
|
||||
if strings.HasPrefix(v, "DISPLAY=") && len(v) > 8 {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(v, "WAYLAND_DISPLAY=") && len(v) > 17 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
11
agent/internal/desktop/session_other.go
Normal file
11
agent/internal/desktop/session_other.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//go:build !linux && !windows
|
||||
|
||||
package desktop
|
||||
|
||||
import "os"
|
||||
|
||||
// hasDesktopSession checks if a desktop session is available on other platforms.
|
||||
// Default: check for DISPLAY environment variable.
|
||||
func hasDesktopSession() bool {
|
||||
return os.Getenv("DISPLAY") != ""
|
||||
}
|
||||
27
agent/internal/desktop/session_windows.go
Normal file
27
agent/internal/desktop/session_windows.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package desktop
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// hasDesktopSession checks if a desktop session is available on Windows.
|
||||
// Looks for an active console session via query session.
|
||||
func hasDesktopSession() bool {
|
||||
// query session lists all sessions. An active console session with
|
||||
// a logged-in user means a desktop is available.
|
||||
out, err := exec.Command("query", "session").Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for _, line := range lines {
|
||||
// Active console sessions have "Active" in the state column.
|
||||
if strings.Contains(line, "Active") && strings.Contains(line, "console") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
43
agent/internal/display/ethos_exempt_test.go
Normal file
43
agent/internal/display/ethos_exempt_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package display
|
||||
|
||||
// ethos_exempt_test.go — Exemption documentation for terminal display emoji.
|
||||
// D-2: display/terminal.go emoji is EXEMPT from ETHOS #1.
|
||||
// This is intentional user-facing terminal UI, not log output.
|
||||
// Do NOT modify this file in the D-2 fix pass.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTerminalDisplayIsExemptFromEthos(t *testing.T) {
|
||||
// D-2: display/terminal.go emoji is EXEMPT.
|
||||
// This is intentional user-facing terminal UI.
|
||||
// ETHOS #1 applies to log statements, not UI rendering.
|
||||
_, err := os.Stat("terminal.go")
|
||||
if err != nil {
|
||||
t.Skip("[INFO] [agent] [display] terminal.go not found")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile("terminal.go")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read terminal.go: %v", err)
|
||||
}
|
||||
|
||||
// Confirm emoji IS present (intentional)
|
||||
hasEmoji := false
|
||||
for _, r := range string(content) {
|
||||
if r >= 0x1F300 || (r >= 0x2600 && r <= 0x27BF) {
|
||||
hasEmoji = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasEmoji {
|
||||
t.Log("[INFO] [agent] [display] terminal.go has no emoji (ok — may have been cleaned)")
|
||||
} else {
|
||||
t.Log("[INFO] [agent] [display] terminal.go has emoji (EXEMPT — intentional terminal UI)")
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [display] EXEMPTION: display/terminal.go emoji is intentional UI, not log output")
|
||||
}
|
||||
401
agent/internal/display/terminal.go
Normal file
401
agent/internal/display/terminal.go
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
)
|
||||
|
||||
// Color codes for terminal output
|
||||
const (
|
||||
ColorReset = "\033[0m"
|
||||
ColorRed = "\033[31m"
|
||||
ColorGreen = "\033[32m"
|
||||
ColorYellow = "\033[33m"
|
||||
ColorBlue = "\033[34m"
|
||||
ColorPurple = "\033[35m"
|
||||
ColorCyan = "\033[36m"
|
||||
ColorWhite = "\033[37m"
|
||||
ColorBold = "\033[1m"
|
||||
)
|
||||
|
||||
// SeverityColors maps severity levels to colors
|
||||
var SeverityColors = map[string]string{
|
||||
"critical": ColorRed,
|
||||
"high": ColorRed,
|
||||
"medium": ColorYellow,
|
||||
"moderate": ColorYellow,
|
||||
"low": ColorGreen,
|
||||
"info": ColorBlue,
|
||||
}
|
||||
|
||||
// PrintScanResults displays scan results in a pretty format
|
||||
func PrintScanResults(updates []client.UpdateReportItem, exportFormat string) error {
|
||||
// Handle export formats
|
||||
if exportFormat != "" {
|
||||
return exportResults(updates, exportFormat)
|
||||
}
|
||||
|
||||
// Count updates by type
|
||||
aptCount := 0
|
||||
dockerCount := 0
|
||||
otherCount := 0
|
||||
|
||||
for _, update := range updates {
|
||||
switch update.PackageType {
|
||||
case "apt":
|
||||
aptCount++
|
||||
case "docker":
|
||||
dockerCount++
|
||||
default:
|
||||
otherCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Header
|
||||
fmt.Printf("%s🚩 RedFlag Update Scan Results%s\n", ColorBold+ColorRed, ColorReset)
|
||||
fmt.Printf("%s%sScan completed: %s%s\n", ColorBold, ColorCyan, time.Now().Format("2006-01-02 15:04:05"), ColorReset)
|
||||
fmt.Println()
|
||||
|
||||
// Summary
|
||||
if len(updates) == 0 {
|
||||
fmt.Printf("%s✅ No updates available - system is up to date!%s\n", ColorBold+ColorGreen, ColorReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s📊 Summary:%s\n", ColorBold+ColorBlue, ColorReset)
|
||||
fmt.Printf(" Total updates: %s%d%s\n", ColorBold+ColorYellow, len(updates), ColorReset)
|
||||
|
||||
if aptCount > 0 {
|
||||
fmt.Printf(" APT packages: %s%d%s\n", ColorBold+ColorCyan, aptCount, ColorReset)
|
||||
}
|
||||
if dockerCount > 0 {
|
||||
fmt.Printf(" Docker images: %s%d%s\n", ColorBold+ColorCyan, dockerCount, ColorReset)
|
||||
}
|
||||
if otherCount > 0 {
|
||||
fmt.Printf(" Other: %s%d%s\n", ColorBold+ColorCyan, otherCount, ColorReset)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Group by package type
|
||||
if aptCount > 0 {
|
||||
printAPTUpdates(updates)
|
||||
}
|
||||
|
||||
if dockerCount > 0 {
|
||||
printDockerUpdates(updates)
|
||||
}
|
||||
|
||||
if otherCount > 0 {
|
||||
printOtherUpdates(updates)
|
||||
}
|
||||
|
||||
// Footer
|
||||
fmt.Println()
|
||||
fmt.Printf("%s💡 Tip: Use --list-updates for detailed information or --export=json for automation%s\n", ColorBold+ColorYellow, ColorReset)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printAPTUpdates displays APT package updates
|
||||
func printAPTUpdates(updates []client.UpdateReportItem) {
|
||||
fmt.Printf("%s📦 APT Package Updates%s\n", ColorBold+ColorBlue, ColorReset)
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
|
||||
for _, update := range updates {
|
||||
if update.PackageType != "apt" {
|
||||
continue
|
||||
}
|
||||
|
||||
severityColor := getSeverityColor(update.Severity)
|
||||
packageIcon := getPackageIcon(update.Severity)
|
||||
|
||||
fmt.Printf("%s %s%s%s\n", packageIcon, ColorBold, update.PackageName, ColorReset)
|
||||
fmt.Printf(" Version: %s→%s\n",
|
||||
getVersionColor(update.CurrentVersion),
|
||||
getVersionColor(update.AvailableVersion))
|
||||
|
||||
if update.Severity != "" {
|
||||
fmt.Printf(" Severity: %s%s%s\n", severityColor, update.Severity, ColorReset)
|
||||
}
|
||||
|
||||
if update.PackageDescription != "" {
|
||||
fmt.Printf(" Description: %s\n", truncateString(update.PackageDescription, 60))
|
||||
}
|
||||
|
||||
if len(update.CVEList) > 0 {
|
||||
fmt.Printf(" CVEs: %s\n", strings.Join(update.CVEList, ", "))
|
||||
}
|
||||
|
||||
if update.RepositorySource != "" {
|
||||
fmt.Printf(" Source: %s\n", update.RepositorySource)
|
||||
}
|
||||
|
||||
if update.SizeBytes > 0 {
|
||||
fmt.Printf(" Size: %s\n", formatBytes(update.SizeBytes))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// printDockerUpdates displays Docker image updates
|
||||
func printDockerUpdates(updates []client.UpdateReportItem) {
|
||||
fmt.Printf("%s🐳 Docker Image Updates%s\n", ColorBold+ColorBlue, ColorReset)
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
|
||||
for _, update := range updates {
|
||||
if update.PackageType != "docker" {
|
||||
continue
|
||||
}
|
||||
|
||||
severityColor := getSeverityColor(update.Severity)
|
||||
imageIcon := "🐳"
|
||||
|
||||
fmt.Printf("%s %s%s%s\n", imageIcon, ColorBold, update.PackageName, ColorReset)
|
||||
|
||||
if update.Severity != "" {
|
||||
fmt.Printf(" Severity: %s%s%s\n", severityColor, update.Severity, ColorReset)
|
||||
}
|
||||
|
||||
// Show digest comparison if available
|
||||
if update.CurrentVersion != "" && update.AvailableVersion != "" {
|
||||
fmt.Printf(" Digest: %s→%s\n",
|
||||
truncateString(update.CurrentVersion, 12),
|
||||
truncateString(update.AvailableVersion, 12))
|
||||
}
|
||||
|
||||
if update.PackageDescription != "" {
|
||||
fmt.Printf(" Description: %s\n", truncateString(update.PackageDescription, 60))
|
||||
}
|
||||
|
||||
if len(update.CVEList) > 0 {
|
||||
fmt.Printf(" CVEs: %s\n", strings.Join(update.CVEList, ", "))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// printOtherUpdates displays updates from other package managers
|
||||
func printOtherUpdates(updates []client.UpdateReportItem) {
|
||||
fmt.Printf("%s📋 Other Updates%s\n", ColorBold+ColorBlue, ColorReset)
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
|
||||
for _, update := range updates {
|
||||
if update.PackageType == "apt" || update.PackageType == "docker" {
|
||||
continue
|
||||
}
|
||||
|
||||
severityColor := getSeverityColor(update.Severity)
|
||||
packageIcon := "📦"
|
||||
|
||||
fmt.Printf("%s %s%s%s (%s)\n", packageIcon, ColorBold, update.PackageName, ColorReset, update.PackageType)
|
||||
fmt.Printf(" Version: %s→%s\n",
|
||||
getVersionColor(update.CurrentVersion),
|
||||
getVersionColor(update.AvailableVersion))
|
||||
|
||||
if update.Severity != "" {
|
||||
fmt.Printf(" Severity: %s%s%s\n", severityColor, update.Severity, ColorReset)
|
||||
}
|
||||
|
||||
if update.PackageDescription != "" {
|
||||
fmt.Printf(" Description: %s\n", truncateString(update.PackageDescription, 60))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// PrintDetailedUpdates shows full details for all updates
|
||||
func PrintDetailedUpdates(updates []client.UpdateReportItem, exportFormat string) error {
|
||||
// Handle export formats
|
||||
if exportFormat != "" {
|
||||
return exportResults(updates, exportFormat)
|
||||
}
|
||||
|
||||
fmt.Printf("%s🔍 Detailed Update Information%s\n", ColorBold+ColorPurple, ColorReset)
|
||||
fmt.Printf("%sGenerated: %s%s\n\n", ColorCyan, time.Now().Format("2006-01-02 15:04:05"), ColorReset)
|
||||
|
||||
if len(updates) == 0 {
|
||||
fmt.Printf("%s✅ No updates available%s\n", ColorBold+ColorGreen, ColorReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, update := range updates {
|
||||
fmt.Printf("%sUpdate #%d%s\n", ColorBold+ColorYellow, i+1, ColorReset)
|
||||
fmt.Println(strings.Repeat("═", 60))
|
||||
|
||||
fmt.Printf("%sPackage:%s %s\n", ColorBold, ColorReset, update.PackageName)
|
||||
fmt.Printf("%sType:%s %s\n", ColorBold, ColorReset, update.PackageType)
|
||||
fmt.Printf("%sCurrent Version:%s %s\n", ColorBold, ColorReset, update.CurrentVersion)
|
||||
fmt.Printf("%sAvailable Version:%s %s\n", ColorBold, ColorReset, update.AvailableVersion)
|
||||
|
||||
if update.Severity != "" {
|
||||
severityColor := getSeverityColor(update.Severity)
|
||||
fmt.Printf("%sSeverity:%s %s%s%s\n", ColorBold, ColorReset, severityColor, update.Severity, ColorReset)
|
||||
}
|
||||
|
||||
if update.PackageDescription != "" {
|
||||
fmt.Printf("%sDescription:%s %s\n", ColorBold, ColorReset, update.PackageDescription)
|
||||
}
|
||||
|
||||
if len(update.CVEList) > 0 {
|
||||
fmt.Printf("%sCVE List:%s %s\n", ColorBold, ColorReset, strings.Join(update.CVEList, ", "))
|
||||
}
|
||||
|
||||
if update.KBID != "" {
|
||||
fmt.Printf("%sKB Article:%s %s\n", ColorBold, ColorReset, update.KBID)
|
||||
}
|
||||
|
||||
if update.RepositorySource != "" {
|
||||
fmt.Printf("%sRepository:%s %s\n", ColorBold, ColorReset, update.RepositorySource)
|
||||
}
|
||||
|
||||
if update.SizeBytes > 0 {
|
||||
fmt.Printf("%sSize:%s %s\n", ColorBold, ColorReset, formatBytes(update.SizeBytes))
|
||||
}
|
||||
|
||||
if len(update.Metadata) > 0 {
|
||||
fmt.Printf("%sMetadata:%s\n", ColorBold, ColorReset)
|
||||
for key, value := range update.Metadata {
|
||||
fmt.Printf(" %s: %v\n", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintAgentStatus displays agent status information
|
||||
func PrintAgentStatus(agentID string, serverURL string, lastCheckIn time.Time, lastScan time.Time, updateCount int, agentStatus string) {
|
||||
fmt.Printf("%s🚩 RedFlag Agent Status%s\n", ColorBold+ColorRed, ColorReset)
|
||||
fmt.Println(strings.Repeat("─", 40))
|
||||
|
||||
fmt.Printf("%sAgent ID:%s %s\n", ColorBold, ColorReset, agentID)
|
||||
fmt.Printf("%sServer:%s %s\n", ColorBold, ColorReset, serverURL)
|
||||
fmt.Printf("%sStatus:%s %s%s%s\n", ColorBold, ColorReset, getSeverityColor(agentStatus), agentStatus, ColorReset)
|
||||
|
||||
if !lastCheckIn.IsZero() {
|
||||
fmt.Printf("%sLast Check-in:%s %s\n", ColorBold, ColorReset, formatTimeSince(lastCheckIn))
|
||||
} else {
|
||||
fmt.Printf("%sLast Check-in:%s %sNever%s\n", ColorBold, ColorReset, ColorYellow, ColorReset)
|
||||
}
|
||||
|
||||
if !lastScan.IsZero() {
|
||||
fmt.Printf("%sLast Scan:%s %s\n", ColorBold, ColorReset, formatTimeSince(lastScan))
|
||||
fmt.Printf("%sUpdates Found:%s %s%d%s\n", ColorBold, ColorReset, ColorYellow, updateCount, ColorReset)
|
||||
} else {
|
||||
fmt.Printf("%sLast Scan:%s %sNever%s\n", ColorBold, ColorReset, ColorYellow, ColorReset)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func getSeverityColor(severity string) string {
|
||||
if color, ok := SeverityColors[severity]; ok {
|
||||
return color
|
||||
}
|
||||
return ColorWhite
|
||||
}
|
||||
|
||||
func getPackageIcon(severity string) string {
|
||||
switch strings.ToLower(severity) {
|
||||
case "critical", "high":
|
||||
return "🔴"
|
||||
case "medium", "moderate":
|
||||
return "🟡"
|
||||
case "low":
|
||||
return "🟢"
|
||||
default:
|
||||
return "🔵"
|
||||
}
|
||||
}
|
||||
|
||||
func getVersionColor(version string) string {
|
||||
if version == "" {
|
||||
return ColorRed + "unknown" + ColorReset
|
||||
}
|
||||
return ColorCyan + version + ColorReset
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func formatTimeSince(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
if duration < time.Minute {
|
||||
return fmt.Sprintf("%d seconds ago", int(duration.Seconds()))
|
||||
} else if duration < time.Hour {
|
||||
return fmt.Sprintf("%d minutes ago", int(duration.Minutes()))
|
||||
} else if duration < 24*time.Hour {
|
||||
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
|
||||
} else {
|
||||
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
func exportResults(updates []client.UpdateReportItem, format string) error {
|
||||
switch strings.ToLower(format) {
|
||||
case "json":
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(updates)
|
||||
|
||||
case "csv":
|
||||
return exportCSV(updates)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported export format: %s (supported: json, csv)", format)
|
||||
}
|
||||
}
|
||||
|
||||
func exportCSV(updates []client.UpdateReportItem) error {
|
||||
// Print CSV header
|
||||
fmt.Println("PackageType,PackageName,CurrentVersion,AvailableVersion,Severity,CVEList,Description,SizeBytes")
|
||||
|
||||
// Print each update as CSV row
|
||||
for _, update := range updates {
|
||||
cveList := strings.Join(update.CVEList, ";")
|
||||
description := strings.ReplaceAll(update.PackageDescription, ",", ";")
|
||||
description = strings.ReplaceAll(description, "\n", " ")
|
||||
|
||||
fmt.Printf("%s,%s,%s,%s,%s,%s,%s,%d\n",
|
||||
update.PackageType,
|
||||
update.PackageName,
|
||||
update.CurrentVersion,
|
||||
update.AvailableVersion,
|
||||
update.Severity,
|
||||
cveList,
|
||||
description,
|
||||
update.SizeBytes,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
247
agent/internal/event/buffer.go
Normal file
247
agent/internal/event/buffer.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
|
||||
"sync"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxBufferSize = 1000 // Max events to buffer
|
||||
)
|
||||
|
||||
// Buffer handles local event buffering for offline resilience
|
||||
type Buffer struct {
|
||||
filePath string
|
||||
maxSize int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewBuffer creates a new event buffer with the specified file path
|
||||
func NewBuffer(filePath string) *Buffer {
|
||||
return &Buffer{
|
||||
filePath: filePath,
|
||||
maxSize: defaultMaxBufferSize,
|
||||
}
|
||||
}
|
||||
|
||||
// BufferEvent saves an event to the local buffer file
|
||||
func (b *Buffer) BufferEvent(event *models.SystemEvent) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
// Ensure event has an ID
|
||||
if event.ID == uuid.Nil {
|
||||
return fmt.Errorf("event ID cannot be nil")
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(b.filePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create buffer directory: %w", err)
|
||||
}
|
||||
|
||||
// Read existing buffer
|
||||
var events []*models.SystemEvent
|
||||
if data, err := os.ReadFile(b.filePath); err == nil {
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
// If we can't unmarshal, start fresh
|
||||
events = []*models.SystemEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
// Append new event
|
||||
events = append(events, event)
|
||||
|
||||
// Desktop history survives successful fleet delivery. Seed it from the
|
||||
// pending queue on upgrade, then deduplicate retries by the event identity.
|
||||
historyErr := b.retainHistoryLocked(events)
|
||||
|
||||
// Keep only last N events if buffer too large (circular buffer)
|
||||
if len(events) > b.maxSize {
|
||||
events = events[len(events)-b.maxSize:]
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
data, err := json.Marshal(events)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal events: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(b.filePath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write buffer file: %w", err)
|
||||
}
|
||||
|
||||
return historyErr
|
||||
}
|
||||
|
||||
const localHistoryLimit = 5000
|
||||
|
||||
func (b *Buffer) historyPath() string { return b.filePath + ".history.json" }
|
||||
|
||||
func (b *Buffer) retainHistoryLocked(pending []*models.SystemEvent) error {
|
||||
var history []*models.SystemEvent
|
||||
data, err := os.ReadFile(b.historyPath())
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read local history: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(data, &history); err != nil {
|
||||
return fmt.Errorf("decode local history: %w", err)
|
||||
}
|
||||
}
|
||||
seen := make(map[uuid.UUID]bool, len(history))
|
||||
for _, e := range history {
|
||||
if e != nil {
|
||||
seen[e.ID] = true
|
||||
}
|
||||
}
|
||||
for _, e := range pending {
|
||||
if e != nil && !seen[e.ID] {
|
||||
history = append(history, e)
|
||||
seen[e.ID] = true
|
||||
}
|
||||
}
|
||||
if len(history) > localHistoryLimit {
|
||||
history = history[len(history)-localHistoryLimit:]
|
||||
}
|
||||
data, err = json.Marshal(history)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode local history: %w", err)
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(b.filePath), ".history-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if _, err = f.Write(data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(f.Name(), b.historyPath()); err != nil {
|
||||
return fmt.Errorf("save local history: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadHistory returns retained events independently of the delivery queue.
|
||||
// Before the first new event after upgrade, the existing queue remains visible.
|
||||
func (b *Buffer) ReadHistory() ([]*models.SystemEvent, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
data, err := os.ReadFile(b.historyPath())
|
||||
if os.IsNotExist(err) {
|
||||
data, err = os.ReadFile(b.filePath)
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var events []*models.SystemEvent
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// GetBufferedEvents retrieves and clears the buffer atomically.
|
||||
func (b *Buffer) GetBufferedEvents() ([]*models.SystemEvent, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
var events []*models.SystemEvent
|
||||
data, err := os.ReadFile(b.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read buffer file: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal events: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(b.filePath); err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// ReadBufferedEvents retrieves buffered events without clearing them.
|
||||
func (b *Buffer) ReadBufferedEvents() ([]*models.SystemEvent, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
// Read buffer file
|
||||
var events []*models.SystemEvent
|
||||
data, err := os.ReadFile(b.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // No buffer file means no events
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read buffer file: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal events: %w", err)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// Clear removes the current buffer file.
|
||||
func (b *Buffer) Clear() error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if err := os.Remove(b.filePath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMaxSize sets the maximum number of events to buffer
|
||||
func (b *Buffer) SetMaxSize(size int) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.maxSize = size
|
||||
}
|
||||
|
||||
// GetStats returns buffer statistics
|
||||
func (b *Buffer) GetStats() (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(b.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var events []*models.SystemEvent
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return len(events), nil
|
||||
}
|
||||
318
agent/internal/event/helpers.go
Normal file
318
agent/internal/event/helpers.go
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
// Package event provides event helper functions and buffering for the RedFlag agent.
|
||||
//
|
||||
// ETHOS Compliance:
|
||||
// (1) Errors are History, Not /dev/null
|
||||
// - All failures are captured as events
|
||||
// - Events include full context and metadata
|
||||
// - Events are buffered for offline scenarios
|
||||
//
|
||||
// (3) Assume Failure; Build for Resilience
|
||||
// - Best-effort event buffering (fails gracefully if buffer unavailable)
|
||||
// - Events persist to disk for recovery
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// ScannerEventType represents a scanner operation event
|
||||
type ScannerEventType string
|
||||
|
||||
const (
|
||||
// Event types for agent operations
|
||||
EventTypeAgentScan = "agent_scan"
|
||||
EventTypeAgentConfig = "agent_config"
|
||||
EventTypeAgentCheckIn = "agent_checkin"
|
||||
EventTypeAgentOffline = "agent_offline"
|
||||
)
|
||||
|
||||
// ScanResult indicates the outcome of a scan operation
|
||||
type ScanResult string
|
||||
|
||||
const (
|
||||
ScanResultSuccess ScanResult = "success"
|
||||
ScanResultFailed ScanResult = "failed"
|
||||
ScanResultTimeout ScanResult = "timeout"
|
||||
ScanResultSkipped ScanResult = "skipped"
|
||||
)
|
||||
|
||||
// BufferSystemEvent records a SystemEvent into the operational event buffer.
|
||||
// A nil buffer degrades to local-only behavior; callers should still log or
|
||||
// report command results through their normal channel.
|
||||
func BufferSystemEvent(buffer *Buffer, agentID uuid.UUID, eventType, eventSubtype, severity, component, message string, metadata map[string]interface{}) {
|
||||
if buffer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
evt := &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: eventType,
|
||||
EventSubtype: eventSubtype,
|
||||
Severity: severity,
|
||||
Component: component,
|
||||
Message: message,
|
||||
Metadata: metadata,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := buffer.BufferEvent(evt); err != nil {
|
||||
log.Printf("[WARNING] [agent] [event] buffer_system_event_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// NewScanFailureEvent creates a SystemEvent for a failed scan operation
|
||||
//
|
||||
// Parameters:
|
||||
// - scannerName: The name of the scanner that failed (e.g., "apt", "docker")
|
||||
// - err: The error that occurred
|
||||
// - duration: How long the scan took before failing
|
||||
// - agentID: The agent UUID (empty for client-side creation without binding)
|
||||
//
|
||||
// Returns a SystemEvent ready to buffer or send to the server.
|
||||
func NewScanFailureEvent(scannerName string, err error, duration time.Duration, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
// Determine severity based on error type and scanner
|
||||
severity := models.SeverityWarning
|
||||
if isCriticalScanner(scannerName) {
|
||||
severity = models.SeverityCritical
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentScan,
|
||||
EventSubtype: models.SubtypeFailed,
|
||||
Severity: severity,
|
||||
Component: scannerName + "_scanner",
|
||||
Message: fmt.Sprintf("%s scan failed after %v: %s", scannerName, duration.Round(time.Millisecond), err.Error()),
|
||||
Metadata: map[string]interface{}{
|
||||
"scanner": scannerName,
|
||||
"error": err.Error(),
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
"result": string(ScanResultFailed),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewScanSuccessEvent creates a SystemEvent for a successful scan operation
|
||||
//
|
||||
// Parameters:
|
||||
// - scannerName: The name of the scanner
|
||||
// - updateCount: Number of updates found
|
||||
// - duration: How long the scan took
|
||||
// - agentID: The agent UUID (empty for client-side creation without binding)
|
||||
func NewScanSuccessEvent(scannerName string, updateCount int, duration time.Duration, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("%s scan completed in %v", scannerName, duration.Round(time.Millisecond))
|
||||
if updateCount > 0 {
|
||||
message = fmt.Sprintf("%s scan found %d update(s) in %v", scannerName, updateCount, duration.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentScan,
|
||||
EventSubtype: models.SubtypeSuccess,
|
||||
Severity: models.SeverityInfo,
|
||||
Component: scannerName + "_scanner",
|
||||
Message: message,
|
||||
Metadata: map[string]interface{}{
|
||||
"scanner": scannerName,
|
||||
"update_count": updateCount,
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
"result": string(ScanResultSuccess),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewScanTimeoutEvent creates a SystemEvent for a scan that timed out
|
||||
func NewScanTimeoutEvent(scannerName string, duration time.Duration, timeout time.Duration, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentScan,
|
||||
EventSubtype: models.SubtypeNetworkTimeout,
|
||||
Severity: models.SeverityWarning,
|
||||
Component: scannerName + "_scanner",
|
||||
Message: fmt.Sprintf("%s scan timed out after %v (timeout: %v)", scannerName, duration.Round(time.Millisecond), timeout),
|
||||
Metadata: map[string]interface{}{
|
||||
"scanner": scannerName,
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
"timeout_ms": timeout.Milliseconds(),
|
||||
"result": string(ScanResultTimeout),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewConfigSyncEvent creates a SystemEvent for a config sync operation
|
||||
func NewConfigSyncEvent(success bool, changes []string, attempt int, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
subtype := models.SubtypeSuccess
|
||||
severity := models.SeverityInfo
|
||||
message := fmt.Sprintf("Config synced successfully (attempt %d)", attempt)
|
||||
|
||||
if !success {
|
||||
subtype = models.SubtypeFailed
|
||||
severity = models.SeverityWarning
|
||||
message = fmt.Sprintf("Config sync failed (attempt %d)", attempt)
|
||||
} else if len(changes) > 0 {
|
||||
message = fmt.Sprintf("Config updated: %v (attempt %d)", changes, attempt)
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentConfig,
|
||||
EventSubtype: subtype,
|
||||
Severity: severity,
|
||||
Component: models.ComponentAgent,
|
||||
Message: message,
|
||||
Metadata: map[string]interface{}{
|
||||
"success": success,
|
||||
"changes": changes,
|
||||
"attempt": attempt,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewOfflineEvent creates a SystemEvent when the agent goes offline
|
||||
func NewOfflineEvent(reason string, lastCheckIn time.Time, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
message := "Agent went offline"
|
||||
if reason != "" {
|
||||
message = fmt.Sprintf("Agent went offline: %s", reason)
|
||||
}
|
||||
|
||||
var offlineDuration string
|
||||
if !lastCheckIn.IsZero() {
|
||||
offlineDuration = fmt.Sprintf("%v", time.Since(lastCheckIn).Round(time.Second))
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentOffline,
|
||||
EventSubtype: models.SubtypeTokenExpired, // Reusing existing subtype
|
||||
Severity: models.SeverityWarning,
|
||||
Component: models.ComponentAgent,
|
||||
Message: message,
|
||||
Metadata: map[string]interface{}{
|
||||
"reason": reason,
|
||||
"last_check_in": lastCheckIn.Format(time.RFC3339),
|
||||
"offline_duration": offlineDuration,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewReconnectionEvent creates a SystemEvent when the agent reconnects
|
||||
func NewReconnectionEvent(offlineDuration time.Duration, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentCheckIn,
|
||||
EventSubtype: models.SubtypeSuccess,
|
||||
Severity: models.SeverityInfo,
|
||||
Component: models.ComponentAgent,
|
||||
Message: fmt.Sprintf("Agent reconnected after %v offline", offlineDuration.Round(time.Second)),
|
||||
Metadata: map[string]interface{}{
|
||||
"offline_duration_ms": offlineDuration.Milliseconds(),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewCheckInEvent creates a SystemEvent for a successful check-in
|
||||
func NewCheckInEvent(hasCommands bool, commandCount int, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
subtype := models.SubtypeInfo
|
||||
message := "Check-in successful - no new commands"
|
||||
if hasCommands {
|
||||
subtype = models.SubtypeSuccess
|
||||
message = fmt.Sprintf("Check-in successful - received %d command(s)", commandCount)
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentCheckIn,
|
||||
EventSubtype: subtype,
|
||||
Severity: models.SeverityInfo,
|
||||
Component: models.ComponentAgent,
|
||||
Message: message,
|
||||
Metadata: map[string]interface{}{
|
||||
"has_commands": hasCommands,
|
||||
"command_count": commandCount,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// isCriticalScanner returns true if the scanner is considered critical
|
||||
// Critical scanner failures are logged with SeverityCritical
|
||||
func isCriticalScanner(scannerName string) bool {
|
||||
// Update-related scanners that indicate potential security or update issues
|
||||
criticalScanners := map[string]bool{
|
||||
"apt": true, // Security patches
|
||||
"dnf": true, // Security patches
|
||||
"windows": true, // Security patches
|
||||
"winget": false, // Optional updates
|
||||
"docker": false, // Optional feature
|
||||
"storage": false, // Monitoring only
|
||||
"system": false, // Monitoring only
|
||||
}
|
||||
|
||||
return criticalScanners[scannerName]
|
||||
}
|
||||
56
agent/internal/event/history_test.go
Normal file
56
agent/internal/event/history_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func TestLocalHistorySurvivesDeliveryAndRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "events.json")
|
||||
buffer := NewBuffer(path)
|
||||
e := &models.SystemEvent{ID: uuid.Must(uuid.NewV4()), Message: "scan failed", Severity: "error"}
|
||||
if err := buffer.BufferEvent(e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := buffer.Clear(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened := NewBuffer(path)
|
||||
history, err := reopened.ReadHistory()
|
||||
if err != nil || len(history) != 1 || history[0].ID != e.ID {
|
||||
t.Fatalf("history=%v error=%v", history, err)
|
||||
}
|
||||
if err := reopened.BufferEvent(e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := reopened.GetBufferedEvents(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err = reopened.ReadHistory()
|
||||
if err != nil || len(history) != 1 {
|
||||
t.Fatalf("retried event duplicated in history: count=%d error=%v", len(history), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryFailureDoesNotDiscardDeliveryQueue(t *testing.T) {
|
||||
buffer := NewBuffer(filepath.Join(t.TempDir(), "events.json"))
|
||||
if err := os.WriteFile(buffer.historyPath(), []byte("damaged history"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &models.SystemEvent{ID: uuid.Must(uuid.NewV4()), Message: "keep this event"}
|
||||
if err := buffer.BufferEvent(e); err == nil {
|
||||
t.Fatal("history damage was silent")
|
||||
}
|
||||
pending, err := buffer.ReadBufferedEvents()
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Fatalf("delivery queue lost: count=%d error=%v", len(pending), err)
|
||||
}
|
||||
data, err := os.ReadFile(buffer.historyPath())
|
||||
if err != nil || string(data) != "damaged history" {
|
||||
t.Fatal("damaged history was overwritten")
|
||||
}
|
||||
}
|
||||
166
agent/internal/event/tee_logger.go
Normal file
166
agent/internal/event/tee_logger.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// Package event provides event helper functions and buffering for the RedFlag agent.
|
||||
//
|
||||
// TeeLogger emits an ETHOS-tagged log.Printf AND a buffered SystemEvent in a
|
||||
// single call. When the internal buffer is nil (e.g. during early boot or in
|
||||
// tests), it degrades to log-only mode.
|
||||
//
|
||||
// ETHOS Compliance:
|
||||
// (1) Errors are History, Not /dev/null
|
||||
// - Every TeeLogger call produces a log line (stderr/journald)
|
||||
// - Every TeeLogger call buffers a SystemEvent for server delivery
|
||||
// - Buffer failures are logged, never silently dropped
|
||||
//
|
||||
// (3) Assume Failure; Build for Resilience
|
||||
// - Nil buffer degrades gracefully to log-only
|
||||
// - Buffer write is best-effort; log line is the durability guarantee
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// TeeLogger emits an ETHOS-tagged log.Printf AND a buffered SystemEvent
|
||||
// in a single call. When the internal buffer is nil (e.g. during early boot
|
||||
// or in test), it degrades to log-only mode.
|
||||
type TeeLogger struct {
|
||||
buffer *Buffer
|
||||
agentID uuid.UUID
|
||||
}
|
||||
|
||||
// NewTeeLogger creates a TeeLogger. Pass a nil buffer for log-only mode.
|
||||
func NewTeeLogger(buffer *Buffer, agentID uuid.UUID) *TeeLogger {
|
||||
return &TeeLogger{buffer: buffer, agentID: agentID}
|
||||
}
|
||||
|
||||
// Buffer returns the operational event buffer used by this logger.
|
||||
func (l *TeeLogger) Buffer() *Buffer {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
return l.buffer
|
||||
}
|
||||
|
||||
// LogParams carries all structured fields for a dual-output event.
|
||||
type LogParams struct {
|
||||
Level string // ETHOS level tag: "INFO", "WARNING", "ERROR", "CRITICAL"
|
||||
System string // ETHOS system tag: "agent"
|
||||
Component string // ETHOS component tag: "migration", "crypto", "config"
|
||||
EventType string // SystemEvent.EventType
|
||||
EventSubtype string // SystemEvent.EventSubtype
|
||||
Severity string // SystemEvent.Severity
|
||||
ServerComponent string // SystemEvent.Component
|
||||
Message string // Human-readable message
|
||||
Metadata map[string]interface{} // Structured key-value pairs
|
||||
}
|
||||
|
||||
// Log is the core dual-output method.
|
||||
func (l *TeeLogger) Log(params LogParams) {
|
||||
// Nil receiver: log-only mode (no buffer, no agent ID).
|
||||
if l == nil {
|
||||
log.Printf("[%s] [%s] [%s] %s", params.Level, params.System, params.Component, params.Message)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. ETHOS-tagged log line
|
||||
log.Printf("[%s] [%s] [%s] %s", params.Level, params.System, params.Component, params.Message)
|
||||
|
||||
// 2. Buffer SystemEvent (best-effort, nil-safe)
|
||||
if l.buffer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var agentIDPtr *uuid.UUID
|
||||
if l.agentID != uuid.Nil {
|
||||
agentIDPtr = &l.agentID
|
||||
}
|
||||
|
||||
evt := &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: params.EventType,
|
||||
EventSubtype: params.EventSubtype,
|
||||
Severity: params.Severity,
|
||||
Component: params.ServerComponent,
|
||||
Message: params.Message,
|
||||
Metadata: params.Metadata,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := l.buffer.BufferEvent(evt); err != nil {
|
||||
log.Printf("[WARNING] [agent] [event] tee_buffer_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Info emits an INFO-level dual-output event.
|
||||
func (l *TeeLogger) Info(system, component, serverComponent, message string, metadata map[string]interface{}) {
|
||||
l.Log(LogParams{
|
||||
Level: "INFO", System: system, Component: component,
|
||||
EventType: deriveEventType(component), EventSubtype: models.SubtypeInfo,
|
||||
Severity: models.SeverityInfo, ServerComponent: serverComponent,
|
||||
Message: message, Metadata: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// Warning emits a WARNING-level dual-output event.
|
||||
func (l *TeeLogger) Warning(system, component, serverComponent, message string, metadata map[string]interface{}) {
|
||||
l.Log(LogParams{
|
||||
Level: "WARNING", System: system, Component: component,
|
||||
EventType: deriveEventType(component), EventSubtype: models.SubtypeWarning,
|
||||
Severity: models.SeverityWarning, ServerComponent: serverComponent,
|
||||
Message: message, Metadata: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// Error emits an ERROR-level dual-output event.
|
||||
func (l *TeeLogger) Error(system, component, serverComponent, message string, metadata map[string]interface{}) {
|
||||
l.Log(LogParams{
|
||||
Level: "ERROR", System: system, Component: component,
|
||||
EventType: deriveEventType(component), EventSubtype: models.SubtypeFailed,
|
||||
Severity: models.SeverityError, ServerComponent: serverComponent,
|
||||
Message: message, Metadata: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// Critical emits a CRITICAL-level dual-output event.
|
||||
func (l *TeeLogger) Critical(system, component, serverComponent, message string, metadata map[string]interface{}) {
|
||||
l.Log(LogParams{
|
||||
Level: "CRITICAL", System: system, Component: component,
|
||||
EventType: deriveEventType(component), EventSubtype: models.SubtypeCritical,
|
||||
Severity: models.SeverityCritical, ServerComponent: serverComponent,
|
||||
Message: message, Metadata: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// deriveEventType maps an ETHOS component tag to a SystemEvent.EventType.
|
||||
// Callers can always override by using Log() directly with explicit params.
|
||||
func deriveEventType(component string) string {
|
||||
switch component {
|
||||
case "migration":
|
||||
return models.EventTypeAgentMigration
|
||||
case "crypto":
|
||||
return models.EventTypeAgentCrypto
|
||||
case "config":
|
||||
return models.EventTypeAgentConfig
|
||||
case "docker":
|
||||
return models.EventTypeAgentDocker
|
||||
case "installer":
|
||||
return models.EventTypeAgentInstall
|
||||
case "kernel":
|
||||
return models.EventTypeAgentStartup
|
||||
case "acknowledgment":
|
||||
return models.EventTypeAgentCheckIn
|
||||
case "receipt":
|
||||
return models.EventTypeAgentCheckIn
|
||||
case "localapi":
|
||||
return models.EventTypeAgentStartup
|
||||
case "loop":
|
||||
return models.EventTypeAgentStartup
|
||||
default:
|
||||
return models.EventTypeError
|
||||
}
|
||||
}
|
||||
169
agent/internal/event/tee_logger_test.go
Normal file
169
agent/internal/event/tee_logger_test.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package event
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func TestTeeLoggerNilBuffer(t *testing.T) {
|
||||
// TeeLogger with nil buffer should not panic
|
||||
logger := NewTeeLogger(nil, uuid.Nil)
|
||||
|
||||
// Should not panic — log-only mode
|
||||
logger.Info("agent", "test", "test_component", "test message", nil)
|
||||
logger.Warning("agent", "test", "test_component", "test warning", nil)
|
||||
logger.Error("agent", "test", "test_component", "test error", nil)
|
||||
logger.Critical("agent", "test", "test_component", "test critical", nil)
|
||||
}
|
||||
|
||||
func TestTeeLoggerNilAgentID(t *testing.T) {
|
||||
// TeeLogger with Nil agent ID should produce events with nil AgentID
|
||||
buffer := NewBuffer(t.TempDir() + "/events.json")
|
||||
logger := NewTeeLogger(buffer, uuid.Nil)
|
||||
|
||||
logger.Info("agent", "test", "test_component", "test message", nil)
|
||||
|
||||
events, err := buffer.GetBufferedEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("GetBufferedEvents failed: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
if events[0].AgentID != nil {
|
||||
t.Errorf("expected nil AgentID, got %v", events[0].AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeeLoggerEventFields(t *testing.T) {
|
||||
buffer := NewBuffer(t.TempDir() + "/events.json")
|
||||
agentID := uuid.Must(uuid.NewV4())
|
||||
logger := NewTeeLogger(buffer, agentID)
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
}
|
||||
logger.Warning("agent", "crypto", "pubkey", "key fetch failed", metadata)
|
||||
|
||||
events, err := buffer.GetBufferedEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("GetBufferedEvents failed: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
|
||||
evt := events[0]
|
||||
if evt.AgentID == nil || *evt.AgentID != agentID {
|
||||
t.Errorf("expected AgentID %v, got %v", agentID, evt.AgentID)
|
||||
}
|
||||
if evt.EventType != models.EventTypeAgentCrypto {
|
||||
t.Errorf("expected EventType %q, got %q", models.EventTypeAgentCrypto, evt.EventType)
|
||||
}
|
||||
if evt.EventSubtype != models.SubtypeWarning {
|
||||
t.Errorf("expected EventSubtype %q, got %q", models.SubtypeWarning, evt.EventSubtype)
|
||||
}
|
||||
if evt.Severity != models.SeverityWarning {
|
||||
t.Errorf("expected Severity %q, got %q", models.SeverityWarning, evt.Severity)
|
||||
}
|
||||
if evt.Component != "pubkey" {
|
||||
t.Errorf("expected Component %q, got %q", "pubkey", evt.Component)
|
||||
}
|
||||
if evt.Message != "key fetch failed" {
|
||||
t.Errorf("expected Message %q, got %q", "key fetch failed", evt.Message)
|
||||
}
|
||||
if evt.Metadata["key1"] != "value1" {
|
||||
t.Errorf("expected metadata key1=value1, got %v", evt.Metadata["key1"])
|
||||
}
|
||||
// JSON round-trip converts int to float64
|
||||
if int(evt.Metadata["key2"].(float64)) != 42 {
|
||||
t.Errorf("expected metadata key2=42, got %v", evt.Metadata["key2"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeeLoggerConvenienceMethods(t *testing.T) {
|
||||
buffer := NewBuffer(t.TempDir() + "/events.json")
|
||||
logger := NewTeeLogger(buffer, uuid.Must(uuid.NewV4()))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
call func()
|
||||
expectedLevel string
|
||||
expectedSubtype string
|
||||
expectedSeverity string
|
||||
}{
|
||||
{"Info", func() { logger.Info("agent", "config", "config", "loaded", nil) }, "INFO", models.SubtypeInfo, models.SeverityInfo},
|
||||
{"Warning", func() { logger.Warning("agent", "config", "config", "stale", nil) }, "WARNING", models.SubtypeWarning, models.SeverityWarning},
|
||||
{"Error", func() { logger.Error("agent", "config", "config", "failed", nil) }, "ERROR", models.SubtypeFailed, models.SeverityError},
|
||||
{"Critical", func() { logger.Critical("agent", "config", "config", "corrupt", nil) }, "CRITICAL", models.SubtypeCritical, models.SeverityCritical},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.call()
|
||||
})
|
||||
}
|
||||
|
||||
events, err := buffer.GetBufferedEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("GetBufferedEvents failed: %v", err)
|
||||
}
|
||||
if len(events) != len(tests) {
|
||||
t.Fatalf("expected %d events, got %d", len(tests), len(events))
|
||||
}
|
||||
|
||||
for i, tt := range tests {
|
||||
evt := events[i]
|
||||
if evt.EventSubtype != tt.expectedSubtype {
|
||||
t.Errorf("%s: expected EventSubtype %q, got %q", tt.name, tt.expectedSubtype, evt.EventSubtype)
|
||||
}
|
||||
if evt.Severity != tt.expectedSeverity {
|
||||
t.Errorf("%s: expected Severity %q, got %q", tt.name, tt.expectedSeverity, evt.Severity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveEventType(t *testing.T) {
|
||||
tests := []struct {
|
||||
component string
|
||||
expected string
|
||||
}{
|
||||
{"migration", models.EventTypeAgentMigration},
|
||||
{"crypto", models.EventTypeAgentCrypto},
|
||||
{"config", models.EventTypeAgentConfig},
|
||||
{"docker", models.EventTypeAgentDocker},
|
||||
{"installer", models.EventTypeAgentInstall},
|
||||
{"unknown", models.EventTypeError},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.component, func(t *testing.T) {
|
||||
got := deriveEventType(tt.component)
|
||||
if got != tt.expected {
|
||||
t.Errorf("deriveEventType(%q) = %q, want %q", tt.component, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeeLoggerMetadataNil(t *testing.T) {
|
||||
buffer := NewBuffer(t.TempDir() + "/events.json")
|
||||
logger := NewTeeLogger(buffer, uuid.Must(uuid.NewV4()))
|
||||
|
||||
// Should not panic with nil metadata
|
||||
logger.Info("agent", "test", "test_component", "test message", nil)
|
||||
|
||||
events, err := buffer.GetBufferedEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("GetBufferedEvents failed: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
if events[0].Metadata != nil {
|
||||
t.Errorf("expected nil metadata, got %v", events[0].Metadata)
|
||||
}
|
||||
}
|
||||
63
agent/internal/guardian/interval_guardian.go
Normal file
63
agent/internal/guardian/interval_guardian.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package guardian
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// IntervalGuardian protects against accidental check-in interval overrides
|
||||
type IntervalGuardian struct {
|
||||
mu sync.Mutex
|
||||
lastCheckInValue int
|
||||
violationCount int
|
||||
}
|
||||
|
||||
// NewIntervalGuardian creates a new guardian with zero violations
|
||||
func NewIntervalGuardian() *IntervalGuardian {
|
||||
return &IntervalGuardian{
|
||||
lastCheckInValue: 0,
|
||||
violationCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// SetBaseline records the expected check-in interval
|
||||
func (g *IntervalGuardian) SetBaseline(interval int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.lastCheckInValue = interval
|
||||
}
|
||||
|
||||
// CheckForOverrideAttempt validates that proposed interval matches baseline
|
||||
// Returns error if mismatch detected (indicating a regression)
|
||||
func (g *IntervalGuardian) CheckForOverrideAttempt(currentBaseline, proposedValue int) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if currentBaseline != proposedValue {
|
||||
g.violationCount++
|
||||
return fmt.Errorf("INTERVAL_OVERRIDE_DETECTED: baseline=%d, proposed=%d, violations=%d",
|
||||
currentBaseline, proposedValue, g.violationCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetViolationCount returns total number of violations detected
|
||||
func (g *IntervalGuardian) GetViolationCount() int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.violationCount
|
||||
}
|
||||
|
||||
// Reset clears violation count (use after legitimate config change)
|
||||
func (g *IntervalGuardian) Reset() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.violationCount = 0
|
||||
}
|
||||
|
||||
// GetBaseline returns current baseline value
|
||||
func (g *IntervalGuardian) GetBaseline() int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.lastCheckInValue
|
||||
}
|
||||
13
agent/internal/handlers/agent_restart_other.go
Normal file
13
agent/internal/handlers/agent_restart_other.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//go:build !windows
|
||||
|
||||
package handlers
|
||||
|
||||
import "fmt"
|
||||
|
||||
// dispatchWindowsRestart is a non-Windows stub so the package compiles on every
|
||||
// platform. restartAgentService only invokes it under runtime.GOOS == "windows";
|
||||
// on Linux the privileged helper owns the restart, and macOS has no self-restart
|
||||
// path yet (its binaries aren't signed).
|
||||
func dispatchWindowsRestart(service string) error {
|
||||
return fmt.Errorf("windows restart path invoked on non-windows platform")
|
||||
}
|
||||
35
agent/internal/handlers/agent_restart_windows.go
Normal file
35
agent/internal/handlers/agent_restart_windows.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//go:build windows
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// dispatchWindowsRestart cycles the agent's own Windows service after the binary
|
||||
// has already been swapped on disk.
|
||||
//
|
||||
// A service cannot stop itself and then start again from the same process: the
|
||||
// stop terminates this process before the start runs, so nothing issues the start.
|
||||
// The work is handed to a detached child that outlives us — it waits a few seconds
|
||||
// for this process to exit, then stops and starts the service. This is the standard
|
||||
// self-update restart pattern for a Windows service, the same shape any tray/agent
|
||||
// app (Avira, AGV, etc.) uses to relaunch itself after replacing its own binary.
|
||||
func dispatchWindowsRestart(service string) error {
|
||||
// `ping -n 4 localhost` is a dependency-free ~3s sleep; then stop and start.
|
||||
script := fmt.Sprintf("ping -n 4 127.0.0.1 >nul & sc stop %s & sc start %s", service, service)
|
||||
cmd := exec.Command("cmd", "/C", script)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
// DETACHED_PROCESS (0x8) | CREATE_NEW_PROCESS_GROUP (0x200): fully decouple
|
||||
// the child so it survives this process being killed by the service stop.
|
||||
CreationFlags: 0x00000008 | 0x00000200,
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to launch detached restart helper: %w", err)
|
||||
}
|
||||
log.Printf("[INFO] [agent] [service] windows_restart_dispatched service=%s", service)
|
||||
return nil
|
||||
}
|
||||
557
agent/internal/handlers/agent_update.go
Normal file
557
agent/internal/handlers/agent_update.go
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/capability"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
|
||||
)
|
||||
|
||||
// HandleUpdateAgent handles agent update commands with signature verification
|
||||
func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
|
||||
version, ok := params["version"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("missing version parameter")
|
||||
}
|
||||
|
||||
platform, ok := params["platform"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("missing platform parameter")
|
||||
}
|
||||
|
||||
downloadURL, ok := params["download_url"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("missing download_url parameter")
|
||||
}
|
||||
|
||||
signature, ok := params["signature"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("missing signature parameter")
|
||||
}
|
||||
|
||||
checksum, ok := params["checksum"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("missing checksum parameter")
|
||||
}
|
||||
|
||||
nonceUUIDStr, ok := params["nonce_uuid"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("missing nonce_uuid parameter")
|
||||
}
|
||||
|
||||
nonceTimestampStr, ok := params["nonce_timestamp"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("missing nonce_timestamp parameter")
|
||||
}
|
||||
|
||||
nonceSignature, ok := params["nonce_signature"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("missing nonce_signature parameter")
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [update] starting version=%s platform=%s", version, platform)
|
||||
|
||||
// Nonce max-age = 2 × check-in interval so the nonce survives the gap between
|
||||
// being queued server-side and being fetched at the agent's next poll cycle.
|
||||
nonceMaxAge := time.Duration(cfg.CheckInInterval*2) * time.Second
|
||||
|
||||
log.Printf("[tunturi_ed25519] Validating nonce...")
|
||||
if err := validateNonce(nonceUUIDStr, nonceTimestampStr, nonceSignature, nonceMaxAge); err != nil {
|
||||
log.Printf("[ERROR] [agent] [security] nonce_validation_failed error=%v", err)
|
||||
return fmt.Errorf("[tunturi_ed25519] nonce validation failed: %w", err)
|
||||
}
|
||||
log.Printf("[INFO] [agent] [security] nonce_validated")
|
||||
|
||||
updateStartTime := time.Now()
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "update_agent",
|
||||
Result: "started",
|
||||
Stdout: fmt.Sprintf("Starting agent update to version %s\n", version),
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
DurationSeconds: 0,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Agent Update",
|
||||
"subsystem": "agent",
|
||||
"target_version": version,
|
||||
},
|
||||
}
|
||||
|
||||
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[ERROR] [agent] [update] report_start_failed error=%v", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [update] download_start url=%s", downloadURL)
|
||||
|
||||
// The /api/v1/downloads/updates/:package_id route is protected by AuthMiddleware
|
||||
// — the prior implementation used a raw http.Client.Get and would 401 in
|
||||
// production. Use the authenticated client so JWT + X-Machine-ID accompany the
|
||||
// request. Relative-URL resolution happens inside DownloadAuthenticatedToFile.
|
||||
tempBinaryPath, err := downloadUpdatePackage(apiClient, downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download update package: %w", err)
|
||||
}
|
||||
defer os.Remove(tempBinaryPath)
|
||||
log.Printf("[INFO] [agent] [update] download_complete")
|
||||
|
||||
actualChecksum, err := computeSHA256(tempBinaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compute checksum: %w", err)
|
||||
}
|
||||
|
||||
if actualChecksum != checksum {
|
||||
return fmt.Errorf("checksum mismatch: expected %s, got %s", checksum, actualChecksum)
|
||||
}
|
||||
log.Printf("[INFO] [agent] [upgrade] checksum_verified checksum=%s", actualChecksum)
|
||||
|
||||
log.Printf("[tunturi_ed25519] Step 3: Verifying Ed25519 signature...")
|
||||
if err := verifyBinarySignature(tempBinaryPath, signature); err != nil {
|
||||
return fmt.Errorf("[tunturi_ed25519] signature verification failed: %w", err)
|
||||
}
|
||||
log.Printf("[INFO] [agent] [upgrade] ed25519_signature_verified")
|
||||
|
||||
// Linux: the privileged binary swap is performed by the root helper, not the
|
||||
// agent's own sudo. The server signed an agent-self capability token over this
|
||||
// binary's hash; the helper re-verifies and installs. Other platforms keep the
|
||||
// legacy direct path below until a platform executor exists.
|
||||
if runtime.GOOS == "linux" {
|
||||
// Download the helper binary if the server provided a URL.
|
||||
var helperBinaryPath string
|
||||
if helperURL, ok := params["helper_download_url"].(string); ok && helperURL != "" {
|
||||
var dlErr error
|
||||
helperBinaryPath, dlErr = downloadUpdatePackage(apiClient, helperURL)
|
||||
if dlErr != nil {
|
||||
log.Printf("[WARNING] [agent] [upgrade] helper_download_failed error=%v — agent will update, helper stays as-is", dlErr)
|
||||
} else {
|
||||
defer os.Remove(helperBinaryPath)
|
||||
// Verify helper checksum if provided
|
||||
if helperChecksum, ok := params["helper_checksum"].(string); ok && helperChecksum != "" {
|
||||
actual, err := computeSHA256(helperBinaryPath)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [upgrade] helper_checksum_compute_failed error=%v", err)
|
||||
helperBinaryPath = ""
|
||||
} else if actual != helperChecksum {
|
||||
log.Printf("[WARNING] [agent] [upgrade] helper_checksum_mismatch expected=%s got=%s", helperChecksum, actual)
|
||||
helperBinaryPath = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return installAgentViaHelper(tempBinaryPath, helperBinaryPath, params, commandID, version, updateStartTime)
|
||||
}
|
||||
|
||||
currentBinaryPath, err := getCurrentBinaryPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine current binary path: %w", err)
|
||||
}
|
||||
|
||||
backupPath := currentBinaryPath + ".bak"
|
||||
var updateSuccess bool = false
|
||||
|
||||
if err := createBackup(currentBinaryPath, backupPath); err != nil {
|
||||
log.Printf("[ERROR] [agent] [upgrade] backup_failed error=%v", err)
|
||||
} else {
|
||||
defer func() {
|
||||
if updateSuccess {
|
||||
// Binary swap and restart dispatch succeeded. systemd's SIGTERM
|
||||
// is imminent and may abort this defer before completion. .bak
|
||||
// stays on disk: the new binary cleans it up after first
|
||||
// successful check-in attestation (cleanupPostUpdateBackup),
|
||||
// or the operator restores from it if the new binary fails to
|
||||
// start. Completion is owned by server-side
|
||||
// timeout.reconcileAgentUpdates.
|
||||
return
|
||||
}
|
||||
log.Printf("[INFO] [agent] [upgrade] rollback_start reason=pre_restart_error")
|
||||
if restoreErr := restoreFromBackup(backupPath, currentBinaryPath); restoreErr != nil {
|
||||
log.Printf("[ERROR] [agent] [upgrade] rollback_failed error=%v", restoreErr)
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [upgrade] rollback_success")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [upgrade] install_start")
|
||||
if err := installNewBinary(tempBinaryPath, currentBinaryPath); err != nil {
|
||||
return fmt.Errorf("failed to install new binary: %w", err)
|
||||
}
|
||||
|
||||
// Past the point of no return on disk. The watchdog-and-final-log-report
|
||||
// pattern used to live here; it could not survive systemd's SIGTERM and
|
||||
// has been removed. Server-side reconcileAgentUpdates closes the command
|
||||
// when the new binary reports its version on next check-in.
|
||||
updateSuccess = true
|
||||
log.Printf("[INFO] [agent] [upgrade] install_complete duration_seconds=%d", int(time.Since(updateStartTime).Seconds()))
|
||||
|
||||
// Binary is committed on disk — even if the restart dispatch below fails, the
|
||||
// next boot runs the new binary, so the marker stays valid either way.
|
||||
if err := WriteUpgradeAttestation(commandID, version); err != nil {
|
||||
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_write_failed error=%v — upgrade proceeds unattested", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [upgrade] restart_dispatch")
|
||||
if err := restartAgentService(); err != nil {
|
||||
// Binary is swapped on disk, but restart failed. The current process
|
||||
// keeps running on the OLD code; .bak remains for manual restore on
|
||||
// next manual boot.
|
||||
return fmt.Errorf("failed to restart agent: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// installAgentViaHelper performs the Linux agent self-upgrade through the
|
||||
// privileged helper. The agent stages the verified binary on the real filesystem
|
||||
// (the helper's transient-unit mount namespace cannot see the agent's PrivateTmp)
|
||||
// and hands the helper the server-signed agent-self token. The helper verifies the
|
||||
// token signature and the staged binary's hash, backs up, installs, chmods, and
|
||||
// restarts the agent — the agent holds no sudo for cp/chmod/systemctl.
|
||||
func installAgentViaHelper(tempBinaryPath, helperBinaryPath string, params map[string]interface{}, commandID, targetVersion string, startTime time.Time) error {
|
||||
// Must match the helper's DEFAULT_AGENT_UPGRADE_SOURCE.
|
||||
upgradeStagingPath := constants.GetAgentStagingPath("pending-upgrade.bin")
|
||||
if err := copyFile(tempBinaryPath, upgradeStagingPath); err != nil {
|
||||
return fmt.Errorf("failed to stage new binary for helper: %w", err)
|
||||
}
|
||||
|
||||
// Stage the helper binary if we downloaded one. The helper will
|
||||
// self-update from this staged copy before installing the new agent.
|
||||
helperStagingPath := constants.GetAgentStagingPath("pending-helper.bin")
|
||||
hasHelper := false
|
||||
if helperBinaryPath != "" {
|
||||
if err := copyFile(helperBinaryPath, helperStagingPath); err != nil {
|
||||
log.Printf("[WARNING] [agent] [upgrade] helper_stage_failed error=%v — agent will update, helper stays as-is", err)
|
||||
} else {
|
||||
hasHelper = true
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up staged binaries on failure. On success the helper restarts
|
||||
// us (SIGTERM) before we reach this defer, so the files are already
|
||||
// replaced and this is a harmless no-op. The attestation marker is also
|
||||
// dropped on failure — no restart happened, so the next boot must not
|
||||
// attest against this command.
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
for _, p := range []string{upgradeStagingPath, helperStagingPath} {
|
||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[WARNING] [agent] [upgrade] staging_cleanup_failed path=%s error=%v", p, err)
|
||||
}
|
||||
}
|
||||
ClearUpgradeAttestation()
|
||||
}
|
||||
}()
|
||||
|
||||
tokenJSON, ok := params["capability_token"].(string)
|
||||
if !ok || tokenJSON == "" {
|
||||
return fmt.Errorf("missing capability_token — server did not authorize the helper swap")
|
||||
}
|
||||
var token capability.Token
|
||||
if err := json.Unmarshal([]byte(tokenJSON), &token); err != nil {
|
||||
return fmt.Errorf("failed to parse capability_token: %w", err)
|
||||
}
|
||||
|
||||
// The marker must be on disk before the helper runs: on success the helper
|
||||
// restarts this process mid-Execute, and the post-upgrade healthcheck in the
|
||||
// new binary (RunUpgradeAttestation) is what verifies the swap actually took.
|
||||
if err := WriteUpgradeAttestation(commandID, targetVersion); err != nil {
|
||||
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_write_failed error=%v — upgrade proceeds unattested", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [upgrade] invoking_helper token_id=%s has_helper=%v", token.TokenID, hasHelper)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Build args for the helper. --helper-file tells the helper where the
|
||||
// staged helper binary lives so it can self-update before installing
|
||||
// the new agent.
|
||||
var helperArgs []string
|
||||
if hasHelper {
|
||||
helperArgs = []string{"--helper-file", helperStagingPath}
|
||||
}
|
||||
|
||||
result, err := supplychain.NewExecutor("").Execute(ctx, &token, helperArgs...)
|
||||
if err != nil {
|
||||
// On success the helper restarts this agent, which can SIGTERM us before
|
||||
// Execute returns. The new binary reporting its version on next check-in is
|
||||
// the authoritative success signal (server reconcileAgentUpdates closes the
|
||||
// command), so a transport error here is not necessarily a failed upgrade.
|
||||
return fmt.Errorf("helper invocation failed (or agent restarted mid-swap): %w", err)
|
||||
}
|
||||
if result.Decision != "executed" {
|
||||
return fmt.Errorf("helper refused agent upgrade: decision=%s reason=%s exit=%d",
|
||||
result.Decision, result.Reason, result.ExitCode)
|
||||
}
|
||||
success = true
|
||||
log.Printf("[INFO] [agent] [upgrade] helper_swap_complete token_id=%s duration_seconds=%d",
|
||||
token.TokenID, int(time.Since(startTime).Seconds()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile copies src to dst (truncating dst). No privilege required — used to
|
||||
// place the verified binary on the real filesystem where the root helper can read
|
||||
// it (the agent's own /var/lib/redflag/agent is writable and outside PrivateTmp).
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
// downloadUpdatePackage streams the new agent binary into a temp file using the
|
||||
// agent's authenticated client. Caller owns the returned path (use defer os.Remove).
|
||||
// The 500MB cap mirrors the prior implementation; an oversize response returns an
|
||||
// error rather than a silently-truncated binary that would fail signature verify.
|
||||
func downloadUpdatePackage(apiClient *client.Client, downloadURL string) (string, error) {
|
||||
tempFile, err := os.CreateTemp("", "redflag-update-*.bin")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
tempFile.Close() // DownloadAuthenticatedToFile re-opens via os.Create
|
||||
|
||||
const maxBinarySize = 500 * 1024 * 1024
|
||||
if _, err := apiClient.DownloadAuthenticatedToFile(downloadURL, tempPath, maxBinarySize); err != nil {
|
||||
os.Remove(tempPath)
|
||||
return "", fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
return tempPath, nil
|
||||
}
|
||||
|
||||
func computeSHA256(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", fmt.Errorf("failed to compute hash: %w", err)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func getCurrentBinaryPath() (string, error) {
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
return execPath, nil
|
||||
}
|
||||
|
||||
// No post-update backup sweep exists by design. On Linux the helper owns the
|
||||
// binary swap and writes <binary>.bak as root into the root-owned install dir
|
||||
// (helper/src/main.rs install_staged_agent_binary). The agent runs unprivileged
|
||||
// and its only sudo grant is the systemd-run helper invocation — it cannot, and
|
||||
// must not, remove that file directly. Each upgrade's fs::copy truncates .bak in
|
||||
// place, so it is always a single slot holding exactly the version before the one
|
||||
// now running: the correct rollback target, maintained without the agent touching
|
||||
// it. Discarding it after first check-in (the prior behavior) only threw away
|
||||
// rollback while the new binary was still unproven.
|
||||
|
||||
// createBackup copies the current binary to dst as the rollback slot. Pure file
|
||||
// I/O, no privilege escalation: this runs only on non-Linux platforms (Windows,
|
||||
// macOS), where the agent process owns its own install directory. Linux never
|
||||
// reaches here — the privileged helper owns the swap and writes .bak itself.
|
||||
func createBackup(src, dst string) error {
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
return fmt.Errorf("failed to create backup: %w", err)
|
||||
}
|
||||
if err := os.Chmod(dst, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to set backup permissions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func restoreFromBackup(backup, target string) error {
|
||||
// The live target may be a running, locked binary. On Windows it can't be
|
||||
// overwritten in place but can be renamed out of the way first; on POSIX it
|
||||
// can be unlinked while the old image keeps running off its open inode.
|
||||
if runtime.GOOS == "windows" {
|
||||
aside := target + ".old"
|
||||
_ = os.Remove(aside)
|
||||
_ = os.Rename(target, aside)
|
||||
} else if _, err := os.Stat(target); err == nil {
|
||||
if err := os.Remove(target); err != nil {
|
||||
return fmt.Errorf("failed to remove current binary: %w", err)
|
||||
}
|
||||
}
|
||||
return createBackup(backup, target)
|
||||
}
|
||||
|
||||
// installNewBinary stages the verified binary beside the current one and swaps it
|
||||
// into place with pure file I/O — no sudo, no helper. Reached only on non-Linux
|
||||
// platforms (Linux swaps through the helper). The agent service account has write
|
||||
// access to its own install directory on both Windows and macOS.
|
||||
func installNewBinary(src, dst string) error {
|
||||
staged := dst + ".new"
|
||||
if err := copyFile(src, staged); err != nil {
|
||||
return fmt.Errorf("failed to stage new binary: %w", err)
|
||||
}
|
||||
if err := os.Chmod(staged, 0o755); err != nil {
|
||||
os.Remove(staged)
|
||||
return fmt.Errorf("failed to set binary permissions: %w", err)
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
// A running .exe can't be overwritten, but Windows permits renaming it.
|
||||
// Move the live binary aside, then move the staged binary into the real
|
||||
// path. The aside copy is locked until this process exits; it self-clears
|
||||
// on the next upgrade (the os.Remove above) — a single stale slot at rest.
|
||||
aside := dst + ".old"
|
||||
_ = os.Remove(aside) // rename won't clobber an existing target on Windows
|
||||
if err := os.Rename(dst, aside); err != nil {
|
||||
os.Remove(staged)
|
||||
return fmt.Errorf("failed to move running binary aside: %w", err)
|
||||
}
|
||||
if err := os.Rename(staged, dst); err != nil {
|
||||
os.Rename(aside, dst) // undo: restore the original
|
||||
return fmt.Errorf("failed to install new binary: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// POSIX: atomically replace the directory entry. The running process keeps its
|
||||
// already-open image until it restarts.
|
||||
if err := os.Rename(staged, dst); err != nil {
|
||||
os.Remove(staged)
|
||||
return fmt.Errorf("failed to install new binary: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartAgentService cycles the agent's own service after the on-disk binary swap.
|
||||
// Only reached on non-Linux platforms: Linux self-upgrades run end to end through
|
||||
// the privileged helper (installAgentViaHelper), which restarts the unit itself.
|
||||
func restartAgentService() error {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return dispatchWindowsRestart("RedFlagAgent")
|
||||
default:
|
||||
return fmt.Errorf("self-restart not supported on %s; restart the service manually", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Signature verification ---
|
||||
|
||||
func verifyBinarySignature(binaryPath, signatureHex string) error {
|
||||
publicKey, err := getServerPublicKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get server public key: %w", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(binaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read binary: %w", err)
|
||||
}
|
||||
|
||||
signatureBytes, err := hex.DecodeString(signatureHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode signature: %w", err)
|
||||
}
|
||||
|
||||
if len(signatureBytes) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("invalid signature length: expected %d bytes, got %d", ed25519.SignatureSize, len(signatureBytes))
|
||||
}
|
||||
|
||||
valid := ed25519.Verify(ed25519.PublicKey(publicKey), content, signatureBytes)
|
||||
if !valid {
|
||||
return fmt.Errorf("signature verification failed: invalid signature")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getServerPublicKey() ([]byte, error) {
|
||||
publicKey, err := loadCachedPublicKeyDirect()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load server public key: %w (hint: key is fetched at agent startup)", err)
|
||||
}
|
||||
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
func loadCachedPublicKeyDirect() ([]byte, error) {
|
||||
keyPath := constants.GetServerPublicKeyPath()
|
||||
|
||||
data, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("public key not found at %s: %w", keyPath, err)
|
||||
}
|
||||
|
||||
if len(data) != 32 {
|
||||
return nil, fmt.Errorf("invalid public key size: expected 32 bytes, got %d", len(data))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func validateNonce(nonceUUIDStr, nonceTimestampStr, nonceSignature string, maxAge time.Duration) error {
|
||||
nonceTimestamp, err := time.Parse(time.RFC3339, nonceTimestampStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid nonce timestamp format: %w", err)
|
||||
}
|
||||
|
||||
age := time.Since(nonceTimestamp)
|
||||
if age > maxAge {
|
||||
return fmt.Errorf("nonce expired: age %v > %v", age, maxAge)
|
||||
}
|
||||
|
||||
if age < 0 {
|
||||
return fmt.Errorf("nonce timestamp in the future: %v", nonceTimestamp)
|
||||
}
|
||||
|
||||
publicKey, err := getServerPublicKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get server public key: %w", err)
|
||||
}
|
||||
|
||||
nonceData := fmt.Sprintf("%s:%d", nonceUUIDStr, nonceTimestamp.Unix())
|
||||
|
||||
signatureBytes, err := hex.DecodeString(nonceSignature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid nonce signature format: %w", err)
|
||||
}
|
||||
|
||||
if len(signatureBytes) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("invalid nonce signature length: expected %d bytes, got %d",
|
||||
ed25519.SignatureSize, len(signatureBytes))
|
||||
}
|
||||
|
||||
valid := ed25519.Verify(ed25519.PublicKey(publicKey), []byte(nonceData), signatureBytes)
|
||||
if !valid {
|
||||
return fmt.Errorf("invalid nonce signature")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
241
agent/internal/handlers/commands.go
Normal file
241
agent/internal/handlers/commands.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"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/display"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/scanner"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
)
|
||||
|
||||
// ReportLogWithAck reports a command log to the server and tracks it for acknowledgment
|
||||
func ReportLogWithAck(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, logReport client.LogReport) error {
|
||||
// Track this command result as pending acknowledgment
|
||||
ackTracker.Add(logReport.CommandID)
|
||||
|
||||
// Save acknowledgment state immediately
|
||||
if err := ackTracker.Save(); err != nil {
|
||||
log.Printf("Warning: Failed to save acknowledgment for command %s: %v", logReport.CommandID, err)
|
||||
}
|
||||
|
||||
// Report the log to the server
|
||||
if err := apiClient.ReportLog(cfg.AgentID, logReport); err != nil {
|
||||
// If reporting failed, increment retry count but don't remove from pending
|
||||
ackTracker.IncrementRetry(logReport.CommandID)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScanCommand performs a local scan and displays results
|
||||
func ScanCommand(cfg *config.Config, exportFormat string) error {
|
||||
// Initialize scanners
|
||||
aptScanner := scanner.NewAPTScanner()
|
||||
dnfScanner := scanner.NewDNFScanner()
|
||||
dockerScanner, _ := orchestrator.NewDockerScanner()
|
||||
windowsUpdateScanner := scanner.NewWindowsUpdateScanner()
|
||||
wingetScanner := scanner.NewWingetScanner()
|
||||
|
||||
fmt.Println("Scanning for updates...")
|
||||
var allUpdates []client.UpdateReportItem
|
||||
var scanResults []orchestrator.ScanResult
|
||||
|
||||
// Scan APT updates
|
||||
if aptScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning APT packages...")
|
||||
result := runLocalScanner("apt", aptScanner.Scan)
|
||||
scanResults = append(scanResults, result)
|
||||
if result.Error != nil {
|
||||
fmt.Printf(" APT scan failed: %v\n", result.Error)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d APT updates\n", len(result.Updates))
|
||||
allUpdates = append(allUpdates, result.Updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan DNF updates
|
||||
if dnfScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning DNF packages...")
|
||||
result := runLocalScanner("dnf", dnfScanner.Scan)
|
||||
scanResults = append(scanResults, result)
|
||||
if result.Error != nil {
|
||||
fmt.Printf(" DNF scan failed: %v\n", result.Error)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d DNF updates\n", len(result.Updates))
|
||||
allUpdates = append(allUpdates, result.Updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Docker updates
|
||||
if dockerScanner != nil && dockerScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning Docker images...")
|
||||
result := runLocalScanner("docker", dockerScanner.Scan)
|
||||
scanResults = append(scanResults, result)
|
||||
if result.Error != nil {
|
||||
fmt.Printf(" Docker scan failed: %v\n", result.Error)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d Docker image updates\n", len(result.Updates))
|
||||
allUpdates = append(allUpdates, result.Updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Windows updates
|
||||
if windowsUpdateScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning Windows updates...")
|
||||
result := runLocalScanner("windows", windowsUpdateScanner.Scan)
|
||||
scanResults = append(scanResults, result)
|
||||
if result.Error != nil {
|
||||
fmt.Printf(" Windows Update scan failed: %v\n", result.Error)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d Windows updates\n", len(result.Updates))
|
||||
allUpdates = append(allUpdates, result.Updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Winget packages
|
||||
if wingetScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning Winget packages...")
|
||||
result := runLocalScanner("winget", wingetScanner.Scan)
|
||||
scanResults = append(scanResults, result)
|
||||
if result.Error != nil {
|
||||
fmt.Printf(" Winget scan failed: %v\n", result.Error)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d Winget package updates\n", len(result.Updates))
|
||||
allUpdates = append(allUpdates, result.Updates...)
|
||||
}
|
||||
}
|
||||
|
||||
recordLocalFullScan(cfg, allUpdates, scanResults)
|
||||
|
||||
// Display results
|
||||
fmt.Println()
|
||||
return display.PrintScanResults(allUpdates, exportFormat)
|
||||
}
|
||||
|
||||
func runLocalScanner(scannerName string, scan func() ([]client.UpdateReportItem, error)) orchestrator.ScanResult {
|
||||
startTime := time.Now()
|
||||
updates, err := scan()
|
||||
result := orchestrator.ScanResult{
|
||||
ScannerName: scannerName,
|
||||
Updates: updates,
|
||||
Error: err,
|
||||
Duration: time.Since(startTime),
|
||||
Status: "success",
|
||||
}
|
||||
if err != nil {
|
||||
result.Status = "failed"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// StatusCommand displays agent status information
|
||||
func StatusCommand(cfg *config.Config) error {
|
||||
fmt.Println("==================================================================")
|
||||
fmt.Println("🚩 RedFlag Agent Status")
|
||||
fmt.Println("==================================================================")
|
||||
|
||||
// Agent information
|
||||
fmt.Printf("Agent ID: %s\n", cfg.AgentID)
|
||||
fmt.Printf("Server: %s\n", cfg.ServerURL)
|
||||
fmt.Printf("Check-in Interval: %ds\n", cfg.CheckInInterval)
|
||||
fmt.Printf("Version: %s\n", version.Version)
|
||||
|
||||
// Registration status
|
||||
if cfg.IsRegistered() {
|
||||
fmt.Println("Registration: Registered")
|
||||
} else {
|
||||
fmt.Println("Registration: Not registered")
|
||||
}
|
||||
|
||||
// System information
|
||||
sysInfo, err := system.GetSystemInfo(version.Version)
|
||||
if err == nil {
|
||||
fmt.Printf("Hostname: %s\n", sysInfo.Hostname)
|
||||
fmt.Printf("OS: %s %s (%s)\n", sysInfo.OSType, sysInfo.OSVersion, sysInfo.OSArchitecture)
|
||||
fmt.Printf("Uptime: %s\n", sysInfo.Uptime)
|
||||
}
|
||||
|
||||
// Cache status
|
||||
localCache, err := cache.Load()
|
||||
if err == nil && !localCache.LastScanTime.IsZero() {
|
||||
fmt.Printf("Last Scan: %s\n", localCache.LastScanTime.Format(time.RFC3339))
|
||||
fmt.Printf("Updates Available: %d\n", len(localCache.Updates))
|
||||
}
|
||||
|
||||
fmt.Println("==================================================================")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUpdatesCommand lists detailed update information
|
||||
func ListUpdatesCommand(cfg *config.Config, exportFormat string) error {
|
||||
// Load cache to get last scan results
|
||||
localCache, err := cache.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load cache: %w", err)
|
||||
}
|
||||
|
||||
if localCache.LastScanTime.IsZero() {
|
||||
fmt.Println("No scan results available. Run -scan first.")
|
||||
return nil
|
||||
}
|
||||
|
||||
updates := localCache.Updates
|
||||
if len(updates) == 0 {
|
||||
fmt.Println("No updates available. System is up to date!")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle export formats
|
||||
switch exportFormat {
|
||||
case "json":
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(updates)
|
||||
|
||||
case "csv":
|
||||
writer := csv.NewWriter(os.Stdout)
|
||||
// Write header
|
||||
writer.Write([]string{"Name", "Current Version", "Available Version", "Source", "Severity"})
|
||||
// Write data
|
||||
for _, u := range updates {
|
||||
writer.Write([]string{u.PackageName, u.CurrentVersion, u.AvailableVersion, u.RepositorySource, u.Severity})
|
||||
}
|
||||
writer.Flush()
|
||||
return writer.Error()
|
||||
|
||||
default:
|
||||
// Table format
|
||||
fmt.Printf("Found %d updates:\n\n", len(updates))
|
||||
fmt.Printf("%-30s %-20s %-20s %-10s\n", "NAME", "CURRENT", "AVAILABLE", "SOURCE")
|
||||
fmt.Println(string(make([]byte, 80)))
|
||||
for _, u := range updates {
|
||||
fmt.Printf("%-30s %-20s %-20s %-10s\n",
|
||||
truncate(u.PackageName, 30),
|
||||
truncate(u.CurrentVersion, 20),
|
||||
truncate(u.AvailableVersion, 20),
|
||||
u.RepositorySource)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// truncate truncates a string to max length with ellipsis
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
90
agent/internal/handlers/dispatch.go
Normal file
90
agent/internal/handlers/dispatch.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/event"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
|
||||
)
|
||||
|
||||
// reportFailure sends a command failure back to the server so it doesn't
|
||||
// get stuck in "received" indefinitely. Called when a handler that already
|
||||
// reported "started" hits an error before completing.
|
||||
func reportFailure(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, cmdType, cmdID, errMsg string) {
|
||||
logReport := client.LogReport{
|
||||
CommandID: cmdID,
|
||||
Action: cmdType,
|
||||
Result: "failed",
|
||||
Stderr: errMsg,
|
||||
ExitCode: 1,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
// Best-effort: don't block the caller if the server is unreachable.
|
||||
if rErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); rErr != nil {
|
||||
log.Printf("[ERROR] [agent] [dispatch] failure_report_failed cmd_type=%s cmd_id=%s error=%q", cmdType, cmdID, rErr)
|
||||
}
|
||||
}
|
||||
|
||||
// DispatchCrossPlatformCommand handles command types that work on every
|
||||
// platform (scans, agent self-update). Returns true if cmd.Type matched a
|
||||
// known cross-platform command; the caller is then responsible only for
|
||||
// platform-specific command types. Errors from the underlying handler are
|
||||
// logged here so both the regular agent loop and the Windows service path
|
||||
// produce a uniform error trail.
|
||||
func DispatchCrossPlatformCommand(
|
||||
apiClient *client.Client,
|
||||
cfg *config.Config,
|
||||
ackTracker *acknowledgment.Tracker,
|
||||
orch *orchestrator.Orchestrator,
|
||||
eventBuffer *event.Buffer,
|
||||
cmd client.Command,
|
||||
) bool {
|
||||
var err error
|
||||
switch cmd.Type {
|
||||
case "scan_storage":
|
||||
err = HandleScanStorage(apiClient, cfg, ackTracker, orch, cmd.ID)
|
||||
case "scan_system":
|
||||
err = HandleScanSystem(apiClient, cfg, ackTracker, orch, cmd.ID)
|
||||
case "scan_docker":
|
||||
err = HandleScanDocker(apiClient, cfg, ackTracker, orch, cmd.ID)
|
||||
case "scan_apt":
|
||||
err = HandleScanAPT(apiClient, cfg, ackTracker, orch, cmd.ID)
|
||||
case "scan_dnf":
|
||||
err = HandleScanDNF(apiClient, cfg, ackTracker, orch, cmd.ID)
|
||||
case "scan_windows":
|
||||
err = HandleScanWindows(apiClient, cfg, ackTracker, orch, cmd.ID)
|
||||
case "scan_winget":
|
||||
err = HandleScanWinget(apiClient, cfg, ackTracker, orch, cmd.ID)
|
||||
case "scan_updates":
|
||||
err = HandleScanUpdates(apiClient, cfg, ackTracker, orch, cmd.ID)
|
||||
case "install_updates":
|
||||
err = HandleInstallUpdates(apiClient, cfg, ackTracker, eventBuffer, cmd.Params, cmd.ID)
|
||||
case "dry_run_update":
|
||||
err = HandleDryRunUpdate(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
|
||||
case "confirm_dependencies":
|
||||
err = HandleConfirmDependencies(apiClient, cfg, ackTracker, eventBuffer, cmd.Params, cmd.ID)
|
||||
case "enable_heartbeat":
|
||||
err = HandleEnableHeartbeat(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
|
||||
case "disable_heartbeat":
|
||||
err = HandleDisableHeartbeat(apiClient, cfg, ackTracker, cmd.ID)
|
||||
case "update_agent":
|
||||
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)
|
||||
case "scan_processes":
|
||||
err = HandleScanProcesses(apiClient, cfg, ackTracker, cmd.ID)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [%s] command_failed error=%q", cmd.Type, err)
|
||||
reportFailure(apiClient, cfg, ackTracker, cmd.Type, cmd.ID, fmt.Sprintf("command failed: %s", err))
|
||||
}
|
||||
return true
|
||||
}
|
||||
289
agent/internal/handlers/dry_run.go
Normal file
289
agent/internal/handlers/dry_run.go
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/event"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/installer"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
)
|
||||
|
||||
// HandleDryRunUpdate runs a package-manager dry-run for the named update so
|
||||
// dependency resolution can be surfaced to the dashboard operator before the
|
||||
// real install fires. Reports dependencies via the dedicated endpoint and the
|
||||
// command result via the standard log path; the install itself does not happen
|
||||
// here — the operator's "Confirm" produces a separate confirm_dependencies
|
||||
// command (see HandleConfirmDependencies).
|
||||
func HandleDryRunUpdate(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
|
||||
packageType, _ := params["package_type"].(string)
|
||||
packageName, _ := params["package_name"].(string)
|
||||
updateID, _ := params["update_id"].(string)
|
||||
availableVersion, _ := params["available_version"].(string)
|
||||
targetVersion, _ := params["target_version"].(string)
|
||||
if targetVersion == "" {
|
||||
targetVersion = availableVersion
|
||||
}
|
||||
|
||||
if packageType == "" || packageName == "" {
|
||||
return fmt.Errorf("package_type and package_name parameters are required")
|
||||
}
|
||||
|
||||
inst, err := installer.InstallerFactory(packageType, cfg.ServerURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", packageType, err)
|
||||
}
|
||||
|
||||
if !inst.IsAvailable() {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", packageType)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [installer] dry_run_start package=%s version=%s type=%s", packageName, targetVersion, packageType)
|
||||
|
||||
result, err := inst.DryRun(packageName, targetVersion)
|
||||
if err != nil {
|
||||
stdout, stderr, exitCode, duration := "", err.Error(), 1, 0
|
||||
if result != nil {
|
||||
stdout, stderr, exitCode, duration = result.Stdout, result.Stderr, result.ExitCode, result.DurationSeconds
|
||||
}
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "dry_run",
|
||||
Result: "failed",
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: duration,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Install",
|
||||
"subsystem": packageType,
|
||||
},
|
||||
}
|
||||
ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
|
||||
return fmt.Errorf("dry run failed: %w", err)
|
||||
}
|
||||
|
||||
// Resolve the canonical artifact hashes for the closure (top-level package +
|
||||
// dependencies) from the agent's signed repo metadata. This is the registry's
|
||||
// hash source for OS package managers — the server cannot reach the agent's
|
||||
// repos. Best-effort per package: a resolution failure is logged and that
|
||||
// entry is omitted, never guessed. The server mints only over what resolved.
|
||||
closure := resolveClosureHashes(packageType, packageName, targetVersion, result.Dependencies)
|
||||
|
||||
// Mirror the result into the client's wire type and post dependencies.
|
||||
depReport := client.DependencyReport{
|
||||
PackageName: packageName,
|
||||
PackageType: packageType,
|
||||
TargetVersion: targetVersion,
|
||||
Dependencies: result.Dependencies,
|
||||
UpdateID: updateID,
|
||||
DryRunResult: &client.InstallResult{
|
||||
Success: result.Success,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: result.DurationSeconds,
|
||||
Action: result.Action,
|
||||
PackagesInstalled: result.PackagesInstalled,
|
||||
ContainersUpdated: result.ContainersUpdated,
|
||||
Dependencies: result.Dependencies,
|
||||
IsDryRun: true,
|
||||
},
|
||||
Closure: closure,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportDependencies(cfg.AgentID, depReport); err != nil {
|
||||
log.Printf("[ERROR] [agent] [installer] report_dependencies_failed error=%v", err)
|
||||
return fmt.Errorf("failed to report dependencies: %w", err)
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "dry_run",
|
||||
Result: "success",
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: result.DurationSeconds,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Install",
|
||||
"subsystem": packageType,
|
||||
},
|
||||
}
|
||||
if len(result.Dependencies) > 0 {
|
||||
logReport.Stdout += fmt.Sprintf("\nDependencies found: %v", result.Dependencies)
|
||||
}
|
||||
|
||||
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); reportErr != nil {
|
||||
log.Printf("[WARNING] [agent] [installer] report_dry_run_failed error=%v", reportErr)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [installer] dry_run_complete package=%s deps=%d duration=%ds",
|
||||
packageName, len(result.Dependencies), result.DurationSeconds)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveClosureHashes resolves the canonical SHA256 for the top-level package
|
||||
// and each dependency via the agent's signed repo metadata. The top-level entry
|
||||
// is required: if it cannot be resolved the closure is dropped entirely (no
|
||||
// partial pin). Dependencies are best-effort and logged on failure. Returns nil
|
||||
// for package types whose hash is sourced server-side (npm/PyPI) or unsupported.
|
||||
func resolveClosureHashes(packageType, packageName, targetVersion string, dependencies []string) []client.ClosureItem {
|
||||
top, err := installer.ResolveArtifactSHA256(packageType, packageName, targetVersion)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [installer] closure_toplevel_unresolved pkg=%s type=%s error=%v",
|
||||
packageName, packageType, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
closure := []client.ClosureItem{{
|
||||
Name: top.Name,
|
||||
Version: top.Version,
|
||||
SHA256: top.SHA256,
|
||||
Source: "registry",
|
||||
}}
|
||||
|
||||
for _, dep := range dependencies {
|
||||
if dep == "" || dep == packageName {
|
||||
continue
|
||||
}
|
||||
resolved, err := installer.ResolveArtifactSHA256(packageType, dep, "")
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [installer] closure_dependency_unresolved dep=%s type=%s error=%v",
|
||||
dep, packageType, err)
|
||||
continue
|
||||
}
|
||||
closure = append(closure, client.ClosureItem{
|
||||
Name: resolved.Name,
|
||||
Version: resolved.Version,
|
||||
SHA256: resolved.SHA256,
|
||||
Source: "registry",
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [installer] closure_resolved pkg=%s type=%s entries=%d",
|
||||
packageName, packageType, len(closure))
|
||||
return closure
|
||||
}
|
||||
|
||||
// HandleConfirmDependencies installs a package together with the dependencies
|
||||
// the operator confirmed during the dry-run review. Empty dependency list is
|
||||
// allowed — that path resolves to a simple UpdatePackage on the named target.
|
||||
func HandleConfirmDependencies(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, eventBuffer *event.Buffer, params map[string]interface{}, commandID string) (err error) {
|
||||
packageType, _ := params["package_type"].(string)
|
||||
packageName, _ := params["package_name"].(string)
|
||||
|
||||
// Every error return below surfaces on the History channel as a failed
|
||||
// install event (ETHOS #1), inward via the operational event buffer.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeFailed, models.SeverityError, packageType, packageName, commandID, err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if packageType == "" || packageName == "" {
|
||||
return fmt.Errorf("package_type and package_name parameters are required")
|
||||
}
|
||||
|
||||
// Gated ecosystems — confirmation goes through capability-token path.
|
||||
if packageType == "dnf" || packageType == "apt" {
|
||||
return fmt.Errorf("[SECURITY] [agent] [installer] direct_confirm_refused type=%s — confirmation must go through capability-token path", packageType)
|
||||
}
|
||||
|
||||
var dependencies []string
|
||||
if deps, ok := params["dependencies"].([]interface{}); ok {
|
||||
for _, dep := range deps {
|
||||
if s, ok := dep.(string); ok && s != "" {
|
||||
dependencies = append(dependencies, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inst, err := installer.InstallerFactory(packageType, cfg.ServerURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", packageType, err)
|
||||
}
|
||||
|
||||
if !inst.IsAvailable() {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", packageType)
|
||||
}
|
||||
|
||||
var result *installer.InstallResult
|
||||
var action string
|
||||
|
||||
// Non-gated ecosystems mutate directly. dnf/apt do not implement
|
||||
// NonGatedInstaller, so the assertion fails for them — the gate boundary
|
||||
// is structural here, not a packageType guard.
|
||||
mut, ok := inst.(installer.NonGatedInstaller)
|
||||
if !ok {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] direct_confirm_not_supported type=%s", packageType)
|
||||
}
|
||||
|
||||
if len(dependencies) > 0 {
|
||||
action = "install_with_dependencies"
|
||||
log.Printf("[INFO] [agent] [installer] install_with_deps package=%s deps=%v", packageName, dependencies)
|
||||
allPackages := append([]string{packageName}, dependencies...)
|
||||
result, err = mut.InstallMultiple(allPackages)
|
||||
} else {
|
||||
action = "update"
|
||||
log.Printf("[INFO] [agent] [installer] update_package package=%s", packageName)
|
||||
result, err = mut.UpdatePackage(packageName)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
stdout, stderr, exitCode, duration := "", err.Error(), 1, 0
|
||||
if result != nil {
|
||||
stdout, stderr, exitCode, duration = result.Stdout, result.Stderr, result.ExitCode, result.DurationSeconds
|
||||
}
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: action,
|
||||
Result: "failed",
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: duration,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Install",
|
||||
"subsystem": packageType,
|
||||
},
|
||||
}
|
||||
ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
|
||||
return fmt.Errorf("installation failed: %w", err)
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: result.Action,
|
||||
Result: "success",
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: result.DurationSeconds,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Install",
|
||||
"subsystem": packageType,
|
||||
},
|
||||
}
|
||||
if len(result.PackagesInstalled) > 0 {
|
||||
logReport.Stdout += fmt.Sprintf("\nPackages installed: %v", result.PackagesInstalled)
|
||||
}
|
||||
if len(dependencies) > 0 {
|
||||
logReport.Stdout += fmt.Sprintf("\nDependencies included: %v", dependencies)
|
||||
}
|
||||
|
||||
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); reportErr != nil {
|
||||
log.Printf("[WARNING] [agent] [installer] report_install_failed error=%v", reportErr)
|
||||
}
|
||||
|
||||
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeSuccess, models.SeverityInfo, packageType, packageName, commandID,
|
||||
fmt.Sprintf("%s of %s completed in %ds", action, packageName, result.DurationSeconds))
|
||||
|
||||
log.Printf("[INFO] [agent] [installer] install_complete action=%s package=%s duration=%ds",
|
||||
action, packageName, result.DurationSeconds)
|
||||
return nil
|
||||
}
|
||||
72
agent/internal/handlers/heartbeat.go
Normal file
72
agent/internal/handlers/heartbeat.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
)
|
||||
|
||||
// HandleEnableHeartbeat flips the agent into rapid-polling mode for the
|
||||
// duration the server requested. The polling loop reads RapidPollingEnabled
|
||||
// and RapidPollingUntil on every iteration (see agent/loop.go), so persisting
|
||||
// the change to config is enough — the next loop tick picks it up.
|
||||
func HandleEnableHeartbeat(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
|
||||
durationMinutes := 60
|
||||
if d, ok := params["duration_minutes"].(float64); ok && d > 0 {
|
||||
durationMinutes = int(d)
|
||||
}
|
||||
|
||||
cfg.RapidPollingEnabled = true
|
||||
cfg.RapidPollingUntil = time.Now().UTC().Add(time.Duration(durationMinutes) * time.Minute)
|
||||
|
||||
if err := cfg.Save(constants.GetAgentConfigPath()); err != nil {
|
||||
log.Printf("[WARNING] [agent] [heartbeat] config_save_failed error=%v", err)
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "enable_heartbeat",
|
||||
Result: "success",
|
||||
Stdout: fmt.Sprintf("Heartbeat enabled for %d minutes", durationMinutes),
|
||||
ExitCode: 0,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[WARNING] [agent] [heartbeat] report_enable_failed error=%v", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [heartbeat] enabled duration_minutes=%d until=%s",
|
||||
durationMinutes, cfg.RapidPollingUntil.Format(time.RFC3339))
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleDisableHeartbeat clears rapid-polling state. Called by the server when
|
||||
// a multi-step flow finishes (or by the timeout reconciler if the flow stalls).
|
||||
func HandleDisableHeartbeat(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string) error {
|
||||
cfg.RapidPollingEnabled = false
|
||||
cfg.RapidPollingUntil = time.Time{}
|
||||
|
||||
if err := cfg.Save(constants.GetAgentConfigPath()); err != nil {
|
||||
log.Printf("[WARNING] [agent] [heartbeat] config_save_failed error=%v", err)
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "disable_heartbeat",
|
||||
Result: "success",
|
||||
Stdout: "Heartbeat disabled",
|
||||
ExitCode: 0,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[WARNING] [agent] [heartbeat] report_disable_failed error=%v", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [heartbeat] disabled")
|
||||
return nil
|
||||
}
|
||||
180
agent/internal/handlers/install.go
Normal file
180
agent/internal/handlers/install.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"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/event"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/installer"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// emitInstallEvent records a package-install outcome on the system-event
|
||||
// channel so it reaches the History page (ETHOS #1: errors are history).
|
||||
// It routes inward through the operational event buffer, which persists to disk
|
||||
// and flushes to the server independently of the command-result path. A
|
||||
// failed install is auditable even if the result report is lost.
|
||||
func emitInstallEvent(buffer *event.Buffer, agentID uuid.UUID, subtype, severity, packageType, packageName, commandID, message string) {
|
||||
event.BufferSystemEvent(buffer, agentID,
|
||||
models.EventTypeAgentInstall, subtype, severity,
|
||||
packageType+"_installer", message,
|
||||
map[string]interface{}{
|
||||
"package_type": packageType,
|
||||
"package_name": packageName,
|
||||
"command_id": commandID,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ExpectedHashes maps package names to their expected SHA256 hashes
|
||||
func ExpectedHashes(cfg *config.Config) map[string]string {
|
||||
hashes := make(map[string]string)
|
||||
if cfg.PackageHashes != nil {
|
||||
for name, hash := range cfg.PackageHashes {
|
||||
hashes[name] = hash
|
||||
}
|
||||
}
|
||||
return hashes
|
||||
}
|
||||
|
||||
// FetchExpectedHash retrieves the expected SHA256 hash for a package from the server
|
||||
func FetchExpectedHash(apiClient *client.Client, cfg *config.Config, packageName, version, packageType string) (string, error) {
|
||||
return apiClient.GetExpectedHash(packageType, packageName, cfg.AgentID)
|
||||
}
|
||||
|
||||
func HandleInstallUpdates(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, eventBuffer *event.Buffer, params map[string]interface{}, commandID string) (err error) {
|
||||
packageType, _ := params["package_type"].(string)
|
||||
packageName, _ := params["package_name"].(string)
|
||||
|
||||
// Any error return below lands on the History channel as a failed install
|
||||
// event — one inward funnel covering every failure path (factory, hash
|
||||
// verification, the install itself).
|
||||
defer func() {
|
||||
if err != nil {
|
||||
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeFailed, models.SeverityError, packageType, packageName, commandID, err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if packageType == "" {
|
||||
return fmt.Errorf("package_type parameter is required")
|
||||
}
|
||||
|
||||
inst, err := installer.InstallerFactory(packageType, cfg.ServerURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", packageType, err)
|
||||
}
|
||||
|
||||
if !inst.IsAvailable() {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", packageType)
|
||||
}
|
||||
|
||||
// Layer 1: Hash Registry — verify package hash before installation
|
||||
// Fetch the expected hash from the server
|
||||
expectedHash, err := FetchExpectedHash(apiClient, cfg, packageName, "", packageType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] hash_fetch_failed package=%s error=%w", packageName, err)
|
||||
}
|
||||
|
||||
if expectedHash != "" {
|
||||
log.Printf("[INFO] [agent] [installer] verifying_hash package=%s expected_sha256=%s", packageName, expectedHash[:16]+"...")
|
||||
if err := inst.VerifyHash(packageName, "", expectedHash); err != nil {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] hash_verification_failed package=%s error=%w", packageName, err)
|
||||
}
|
||||
log.Printf("[INFO] [agent] [installer] hash_verified package=%s", packageName)
|
||||
}
|
||||
|
||||
var result *installer.InstallResult
|
||||
var action string
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// NEW: Check cache first for expected hash
|
||||
hashCache := cache.NewHashCache(100) // 100 entries max
|
||||
if cachedHash, cached := hashCache.Get(packageType, packageName, ""); cached {
|
||||
log.Printf("[INFO] [agent] [installer] hash_cached package=%s hash=%s", packageName, cachedHash[:16]+"...")
|
||||
}
|
||||
|
||||
// Gated ecosystems (dnf, apt) — mutation is through capability-token path only.
|
||||
if packageType == "dnf" || packageType == "apt" {
|
||||
return fmt.Errorf("[SECURITY] [agent] [installer] direct_mutation_refused type=%s — mutation must go through capability-token path", packageType)
|
||||
}
|
||||
|
||||
// Non-gated ecosystems mutate directly. dnf/apt are excluded above and
|
||||
// do not implement NonGatedInstaller, so the assertion fails for them.
|
||||
mut, ok := inst.(installer.NonGatedInstaller)
|
||||
if !ok {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] direct_mutation_not_supported type=%s", packageType)
|
||||
}
|
||||
|
||||
if packageName != "" {
|
||||
action = "update"
|
||||
log.Printf("[INFO] [agent] [installer] updating_package package=%s type=%s", packageName, packageType)
|
||||
result, err = mut.UpdatePackage(packageName)
|
||||
} else {
|
||||
action = "upgrade"
|
||||
log.Printf("[INFO] [agent] [installer] upgrading_all type=%s", packageType)
|
||||
result, err = mut.Upgrade()
|
||||
}
|
||||
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
if err != nil {
|
||||
stdout := ""
|
||||
stderr := err.Error()
|
||||
exitCode := 1
|
||||
if result != nil {
|
||||
stdout = result.Stdout
|
||||
stderr = result.Stderr
|
||||
exitCode = result.ExitCode
|
||||
}
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: action,
|
||||
Result: "failed",
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: duration,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Install",
|
||||
"subsystem": packageType,
|
||||
},
|
||||
}
|
||||
ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] install_failed action=%s type=%s error=%w", action, packageType, err)
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: result.Action,
|
||||
Result: "success",
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: result.DurationSeconds,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Install",
|
||||
"subsystem": packageType,
|
||||
},
|
||||
}
|
||||
|
||||
if len(result.PackagesInstalled) > 0 {
|
||||
logReport.Stdout += fmt.Sprintf("\nPackages installed: %v", result.PackagesInstalled)
|
||||
}
|
||||
|
||||
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); reportErr != nil {
|
||||
log.Printf("[WARNING] [agent] [installer] report_failed error=%v", reportErr)
|
||||
}
|
||||
|
||||
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeSuccess, models.SeverityInfo, packageType, packageName, commandID,
|
||||
fmt.Sprintf("%s of %s completed in %ds", result.Action, packageName, duration))
|
||||
|
||||
log.Printf("[INFO] [agent] [installer] install_complete action=%s type=%s duration=%ds", action, packageType, duration)
|
||||
return nil
|
||||
}
|
||||
272
agent/internal/handlers/local_approve.go
Normal file
272
agent/internal/handlers/local_approve.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/capability"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/installer"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// Standalone local approval (FEAT-003). The full gate pipeline runs locally:
|
||||
// dry-run closure resolve → hash pin → OSV best-effort → mint request → the
|
||||
// privileged helper signs with the root-owned authority → the normal execute
|
||||
// path verifies and installs. Fleet mode refuses: the server is the sole
|
||||
// authority there, and this path never weakens that.
|
||||
|
||||
// Typed failures the local API maps to honest HTTP codes.
|
||||
var (
|
||||
// ErrApprovalFleetMode: this host is registered to a fleet server.
|
||||
ErrApprovalFleetMode = errors.New("local approval refused: fleet mode — approve via the server")
|
||||
// ErrApprovalBlocked: a gate refused and no override reason was supplied.
|
||||
ErrApprovalBlocked = errors.New("approval blocked by supply-chain gate")
|
||||
// ErrApprovalNoStandaloneIdentity: local authority was not provisioned.
|
||||
ErrApprovalNoStandaloneIdentity = errors.New("local approval refused: standalone identity is not provisioned")
|
||||
)
|
||||
|
||||
// LocalApproveRequest is RedFlag Desktop's approval submission.
|
||||
type LocalApproveRequest struct {
|
||||
PackageType string `json:"package_type"`
|
||||
PackageName string `json:"package_name"`
|
||||
AvailableVersion string `json:"available_version"`
|
||||
Operator string `json:"operator"`
|
||||
OverrideReason string `json:"override_reason"`
|
||||
}
|
||||
|
||||
// LocalApproveResult carries the gate verdicts plus the execution outcome so
|
||||
// Desktop can render exactly what was checked and what happened.
|
||||
type LocalApproveResult struct {
|
||||
RequestID string `json:"request_id"`
|
||||
OSVStatus string `json:"osv_status"`
|
||||
OSVVulnCount int `json:"osv_vuln_count"`
|
||||
ClosureSize int `json:"closure_size"`
|
||||
Policy *supplychain.PolicyResult `json:"policy,omitempty"`
|
||||
Receipt *capability.MutationReceipt `json:"receipt,omitempty"`
|
||||
}
|
||||
|
||||
// gatedLocalApproval limits local approval to the ecosystems whose closures
|
||||
// the agent can resolve and pin from signed repo metadata. Mirrors the
|
||||
// capability-gate set, not the full installer set.
|
||||
func gatedLocalApproval(packageType string) bool {
|
||||
return packageType == "dnf" || packageType == "apt" || packageType == "pacman"
|
||||
}
|
||||
|
||||
type pacmanExecutionLocation struct {
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type pacmanActionPayload struct {
|
||||
ArtifactSHA256 string `json:"artifact_sha256"`
|
||||
ExecutionLocation pacmanExecutionLocation `json:"execution_location"`
|
||||
Repository string `json:"repository"`
|
||||
Requested bool `json:"requested"`
|
||||
SignatureSHA256 string `json:"signature_sha256"`
|
||||
SignatureLocation pacmanExecutionLocation `json:"signature_location"`
|
||||
}
|
||||
|
||||
func handleLocalPacmanApproval(
|
||||
ctx context.Context,
|
||||
cfg *config.Config,
|
||||
req LocalApproveRequest,
|
||||
) (*LocalApproveResult, error) {
|
||||
// OSV has no Arch ecosystem mapping in RedFlag. Preserve that as an
|
||||
// unsupported gate, which requires the same recorded operator override as
|
||||
// an outage. Refuse before network and artifact work when intent is absent.
|
||||
osvStatus := supplychain.OSVStatusUnsupported
|
||||
if req.OverrideReason == "" {
|
||||
return nil, fmt.Errorf("%w: osv_status=%s — override requires an explicit reason",
|
||||
ErrApprovalBlocked, osvStatus)
|
||||
}
|
||||
resolution, err := installer.ResolvePacmanClosure(req.PackageName, req.AvailableVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve pacman closure: %w", err)
|
||||
}
|
||||
defer resolution.Cleanup()
|
||||
resolvedAt := time.Now().UTC()
|
||||
|
||||
actions := make([]capability.ResolvedAction, 0, len(resolution.Artifacts))
|
||||
evidence := make([]capability.Evidence, 0, len(resolution.Artifacts)*2)
|
||||
for _, artifact := range resolution.Artifacts {
|
||||
payload, err := json.Marshal(pacmanActionPayload{
|
||||
ArtifactSHA256: artifact.ArchiveSHA256,
|
||||
ExecutionLocation: pacmanExecutionLocation{Kind: "cache", Value: artifact.ArchivePath},
|
||||
Repository: artifact.Repository,
|
||||
Requested: artifact.Name == req.PackageName && (req.AvailableVersion == "" || artifact.Version == req.AvailableVersion),
|
||||
SignatureSHA256: artifact.SignatureSHA256,
|
||||
SignatureLocation: pacmanExecutionLocation{Kind: "cache", Value: artifact.SignaturePath},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode pacman action %s: %w", artifact.Name, err)
|
||||
}
|
||||
actions = append(actions, capability.ResolvedAction{
|
||||
Kind: "package", Identity: artifact.Name + "@" + artifact.Version, Payload: string(payload),
|
||||
})
|
||||
evidence = append(evidence,
|
||||
capability.Evidence{Kind: "pacman-package-archive", Digest: artifact.ArchiveSHA256},
|
||||
capability.Evidence{Kind: "pacman-package-signature", Digest: artifact.SignatureSHA256},
|
||||
)
|
||||
}
|
||||
|
||||
operationID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate pacman operation id: %w", err)
|
||||
}
|
||||
manifest := capability.MutationManifest{
|
||||
ProtocolVersion: capability.MutationProtocolVersion,
|
||||
OperationID: operationID.String(),
|
||||
TargetID: cfg.AgentID.String(),
|
||||
Backend: "pacman",
|
||||
Operation: "upgrade",
|
||||
ResolvedActions: actions,
|
||||
Evidence: evidence,
|
||||
}
|
||||
gateEvidence := supplychain.GateEvidence{
|
||||
ResolvedAt: resolvedAt.Unix(),
|
||||
OSVCheckedAt: 0,
|
||||
OSVStatus: osvStatus,
|
||||
OSVVulnCount: 0,
|
||||
AgeGate: "not_applicable",
|
||||
SoakGate: "not_applicable",
|
||||
Operator: req.Operator,
|
||||
OverrideReason: req.OverrideReason,
|
||||
}
|
||||
executor := supplychain.NewExecutor("")
|
||||
envelope, requestID, err := executor.MintEnvelope(ctx, manifest, gateEvidence)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receipt, err := executor.ExecuteEnvelope(ctx, envelope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute pacman envelope authorization_id=%s: %w",
|
||||
envelope.Authorization.AuthorizationID, err)
|
||||
}
|
||||
log.Printf("[INFO] [agent] [localapprove] approval_completed pkg=%s decision=%s exit=%d authorization_id=%s",
|
||||
req.PackageName, receipt.Decision, receipt.ExitCode, receipt.AuthorizationID)
|
||||
return &LocalApproveResult{
|
||||
RequestID: requestID,
|
||||
OSVStatus: osvStatus,
|
||||
OSVVulnCount: 0,
|
||||
ClosureSize: len(resolution.Artifacts),
|
||||
Receipt: receipt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleLocalApprove runs the standalone approval flow end to end. Synchronous:
|
||||
// the caller holds the local socket connection until the helper's verdict (and,
|
||||
// on success, the install) completes.
|
||||
func HandleLocalApprove(ctx context.Context, cfg *config.Config, req LocalApproveRequest) (*LocalApproveResult, error) {
|
||||
if cfg.IsRegistered() {
|
||||
return nil, ErrApprovalFleetMode
|
||||
}
|
||||
if !cfg.IsStandalone() {
|
||||
return nil, ErrApprovalNoStandaloneIdentity
|
||||
}
|
||||
if req.PackageType == "" || req.PackageName == "" {
|
||||
return nil, fmt.Errorf("package_type and package_name are required")
|
||||
}
|
||||
if !gatedLocalApproval(req.PackageType) {
|
||||
return nil, fmt.Errorf("local approval not supported for package_type=%s (dnf|apt|pacman only)", req.PackageType)
|
||||
}
|
||||
if req.Operator == "" {
|
||||
return nil, fmt.Errorf("operator is required")
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [localapprove] approval_started pkg=%s type=%s version=%s operator=%s",
|
||||
req.PackageName, req.PackageType, req.AvailableVersion, req.Operator)
|
||||
if req.PackageType == "pacman" {
|
||||
return handleLocalPacmanApproval(ctx, cfg, req)
|
||||
}
|
||||
|
||||
// Resolve the closure exactly as the fleet dry-run path does.
|
||||
inst, err := installer.InstallerFactory(req.PackageType, cfg.ServerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", req.PackageType, err)
|
||||
}
|
||||
if !inst.IsAvailable() {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", req.PackageType)
|
||||
}
|
||||
dryRun, err := inst.DryRun(req.PackageName, req.AvailableVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dry run failed: %w", err)
|
||||
}
|
||||
resolvedAt := time.Now().UTC()
|
||||
|
||||
closureItems := resolveClosureHashes(req.PackageType, req.PackageName, req.AvailableVersion, dryRun.Dependencies)
|
||||
if len(closureItems) == 0 {
|
||||
// No pin, no mint. The execute path could not verify anything.
|
||||
return nil, fmt.Errorf("closure hash resolution failed for %s — refusing unpinned approval", req.PackageName)
|
||||
}
|
||||
|
||||
closure := make([]capability.ClosureEntry, len(closureItems))
|
||||
pkgs := make([]supplychain.PkgVersion, len(closureItems))
|
||||
for i, c := range closureItems {
|
||||
closure[i] = capability.ClosureEntry{
|
||||
Name: c.Name,
|
||||
Version: c.Version,
|
||||
SHA256: c.SHA256,
|
||||
Source: c.Source,
|
||||
}
|
||||
pkgs[i] = supplychain.PkgVersion{Name: c.Name, Version: c.Version}
|
||||
}
|
||||
|
||||
// OSV best-effort with honest verdict: vulnerable or unreachable proceeds
|
||||
// only over an explicit operator reason, refused here before the helper is
|
||||
// ever invoked (the helper re-enforces — defense in depth).
|
||||
osvStatus, vulnCount := supplychain.CheckClosureOSV(ctx, req.PackageType, pkgs)
|
||||
osvCheckedAt := time.Now().UTC()
|
||||
if osvStatus != supplychain.OSVStatusClear && req.OverrideReason == "" {
|
||||
log.Printf("[SECURITY] [agent] [localapprove] approval_blocked pkg=%s osv_status=%s vulns=%d",
|
||||
req.PackageName, osvStatus, vulnCount)
|
||||
return nil, fmt.Errorf("%w: osv_status=%s vulns=%d — override requires an explicit reason",
|
||||
ErrApprovalBlocked, osvStatus, vulnCount)
|
||||
}
|
||||
|
||||
mintReq := &supplychain.MintRequest{
|
||||
AgentID: cfg.AgentID.String(),
|
||||
PackageType: req.PackageType,
|
||||
Operation: "install",
|
||||
Closure: closure,
|
||||
GateEvidence: supplychain.GateEvidence{
|
||||
ResolvedAt: resolvedAt.Unix(),
|
||||
OSVCheckedAt: osvCheckedAt.Unix(),
|
||||
OSVStatus: osvStatus,
|
||||
OSVVulnCount: vulnCount,
|
||||
// Standalone has no registry age data for dnf/apt (matches the
|
||||
// fleet age gate's ecosystem scope) and no local version
|
||||
// first-seen tracking yet — journaled honestly as not_applicable.
|
||||
AgeGate: "not_applicable",
|
||||
SoakGate: "not_applicable",
|
||||
Operator: req.Operator,
|
||||
OverrideReason: req.OverrideReason,
|
||||
},
|
||||
}
|
||||
|
||||
executor := supplychain.NewExecutor("")
|
||||
token, err := executor.Mint(ctx, mintReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
policy, err := executor.Execute(ctx, token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute after mint failed (token_id=%s): %w", token.TokenID, err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [localapprove] approval_completed pkg=%s decision=%s exit=%d",
|
||||
req.PackageName, policy.Decision, policy.ExitCode)
|
||||
return &LocalApproveResult{
|
||||
RequestID: mintReq.RequestID,
|
||||
OSVStatus: osvStatus,
|
||||
OSVVulnCount: vulnCount,
|
||||
ClosureSize: len(closure),
|
||||
Policy: policy,
|
||||
}, nil
|
||||
}
|
||||
49
agent/internal/handlers/local_approve_test.go
Normal file
49
agent/internal/handlers/local_approve_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func TestGatedLocalApprovalEcosystems(t *testing.T) {
|
||||
for _, packageType := range []string{"apt", "dnf", "pacman"} {
|
||||
if !gatedLocalApproval(packageType) {
|
||||
t.Errorf("%s is not routed through local authority", packageType)
|
||||
}
|
||||
}
|
||||
for _, packageType := range []string{"docker", "winget", "windows_update", "cargo"} {
|
||||
if gatedLocalApproval(packageType) {
|
||||
t.Errorf("%s entered local authority without a migrated backend", packageType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalApprovalRefusesFleetModeBeforeResolution(t *testing.T) {
|
||||
id, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id, Token: "fleet"}, LocalApproveRequest{
|
||||
PackageType: "pacman", PackageName: "linux", Operator: "operator", OverrideReason: "accepted",
|
||||
})
|
||||
if !errors.Is(err, ErrApprovalFleetMode) {
|
||||
t.Fatalf("fleet approval error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacmanUnsupportedOSVRequiresReasonBeforeResolution(t *testing.T) {
|
||||
id, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id}, LocalApproveRequest{
|
||||
PackageType: "pacman", PackageName: "not-a-real-package", Operator: "operator",
|
||||
})
|
||||
if !errors.Is(err, ErrApprovalBlocked) {
|
||||
t.Fatalf("pacman approval error = %v", err)
|
||||
}
|
||||
}
|
||||
60
agent/internal/handlers/local_state.go
Normal file
60
agent/internal/handlers/local_state.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"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/orchestrator"
|
||||
)
|
||||
|
||||
func persistLocalState(cfg *config.Config, update func(*cache.LocalCache)) {
|
||||
localCache, err := cache.Load()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [local_state] load_failed error=%v", err)
|
||||
localCache = &cache.LocalCache{}
|
||||
}
|
||||
|
||||
applyLocalIdentity(localCache, cfg)
|
||||
if update != nil {
|
||||
update(localCache)
|
||||
}
|
||||
|
||||
if err := localCache.Save(); err != nil {
|
||||
log.Printf("[WARNING] [agent] [local_state] save_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func applyLocalIdentity(localCache *cache.LocalCache, cfg *config.Config) {
|
||||
if localCache == nil || cfg == nil || !cfg.IsRegistered() {
|
||||
return
|
||||
}
|
||||
localCache.SetAgentInfo(cfg.AgentID, cfg.ServerURL)
|
||||
}
|
||||
|
||||
func recordLocalFullScan(cfg *config.Config, updates []client.UpdateReportItem, results []orchestrator.ScanResult) {
|
||||
persistLocalState(cfg, func(localCache *cache.LocalCache) {
|
||||
localCache.SetAgentStatus("online")
|
||||
localCache.UpdateScanResults(updates)
|
||||
for _, result := range results {
|
||||
localCache.RecordScannerResult(result.ScannerName, result.Status, result.Updates, result.Error, result.Duration, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func recordLocalScanResult(cfg *config.Config, result orchestrator.ScanResult, affectsUpdateList bool) {
|
||||
persistLocalState(cfg, func(localCache *cache.LocalCache) {
|
||||
localCache.SetAgentStatus("online")
|
||||
localCache.RecordScannerResult(result.ScannerName, result.Status, result.Updates, result.Error, result.Duration, affectsUpdateList)
|
||||
})
|
||||
}
|
||||
|
||||
func recordLocalScanResults(cfg *config.Config, results []orchestrator.ScanResult, affectsUpdateList bool) {
|
||||
persistLocalState(cfg, func(localCache *cache.LocalCache) {
|
||||
localCache.SetAgentStatus("online")
|
||||
for _, result := range results {
|
||||
localCache.RecordScannerResult(result.ScannerName, result.Status, result.Updates, result.Error, result.Duration, affectsUpdateList)
|
||||
}
|
||||
})
|
||||
}
|
||||
84
agent/internal/handlers/local_trigger.go
Normal file
84
agent/internal/handlers/local_trigger.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/scanner"
|
||||
)
|
||||
|
||||
// HandleLocalTriggeredScan runs the full package-update scan on behalf of the
|
||||
// local API write path (FEAT-002). There is no signed server command behind
|
||||
// it, so no command ID exists and nothing is ack-tracked.
|
||||
//
|
||||
// Registered agents run the same HandleScanUpdates path a server scan command
|
||||
// uses — results are reported so the server ingests and reconciles them
|
||||
// (RECONCILE-001 close-by-absence included). Unregistered (standalone) agents
|
||||
// run the scanners through the orchestrator and record the local read model
|
||||
// only; no doomed HTTP calls are attempted.
|
||||
func HandleLocalTriggeredScan(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, source string) error {
|
||||
log.Printf("[INFO] [agent] [localapi] local_scan_started source=%s registered=%v", source, cfg.IsRegistered())
|
||||
|
||||
if cfg.IsRegistered() {
|
||||
return HandleScanUpdates(apiClient, cfg, ackTracker, orch, "")
|
||||
}
|
||||
return runStandaloneUpdateScan(cfg, orch)
|
||||
}
|
||||
|
||||
// runStandaloneUpdateScan mirrors HandleScanUpdates's scanner set (the virtual
|
||||
// "updates" subsystem) without any server reporting: orchestrator-managed
|
||||
// scans (circuit breakers, timeouts) feeding the local read model.
|
||||
func runStandaloneUpdateScan(cfg *config.Config, orch *orchestrator.Orchestrator) error {
|
||||
ctx := context.Background()
|
||||
var results []orchestrator.ScanResult
|
||||
var errs []string
|
||||
|
||||
type updateScanner struct {
|
||||
name string
|
||||
available func() bool
|
||||
}
|
||||
var candidates []updateScanner
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
candidates = []updateScanner{
|
||||
{"apt", scanner.NewAPTScanner().IsAvailable},
|
||||
{"dnf", scanner.NewDNFScanner().IsAvailable},
|
||||
{"pacman", scanner.NewPacmanScanner().IsAvailable},
|
||||
}
|
||||
case "windows":
|
||||
candidates = []updateScanner{
|
||||
{"windows", scanner.NewWindowsUpdateScanner().IsAvailable},
|
||||
{"winget", scanner.NewWingetScanner().IsAvailable},
|
||||
}
|
||||
}
|
||||
|
||||
ran := 0
|
||||
for _, cand := range candidates {
|
||||
if !cand.available() {
|
||||
continue
|
||||
}
|
||||
ran++
|
||||
result, err := orch.ScanSingle(ctx, cand.name)
|
||||
results = append(results, result)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", cand.name, err))
|
||||
}
|
||||
}
|
||||
|
||||
recordLocalScanResults(cfg, results, true)
|
||||
|
||||
if len(errs) > 0 {
|
||||
err := fmt.Errorf("standalone scan errors: %s", strings.Join(errs, "; "))
|
||||
log.Printf("[ERROR] [agent] [localapi] standalone_scan_failed scanners_run=%d error=%v", ran, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("[INFO] [agent] [localapi] standalone_scan_completed scanners_run=%d", ran)
|
||||
return nil
|
||||
}
|
||||
62
agent/internal/handlers/processes.go
Normal file
62
agent/internal/handlers/processes.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
)
|
||||
|
||||
// HandleScanProcesses performs a full on-demand process scan and reports
|
||||
// the results to the server. Triggered by the "scan_processes" command
|
||||
// (issued when a user opens the Processes tab in the dashboard).
|
||||
func HandleScanProcesses(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string) error {
|
||||
log.Printf("[INFO] [agent] [processes] scan_started command_id=%s", commandID)
|
||||
|
||||
startTime := time.Now()
|
||||
snapshot, err := system.GetFullProcessSnapshot()
|
||||
duration := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [processes] scan_failed command_id=%s duration=%s error=%v", commandID, duration, err)
|
||||
return fmt.Errorf("process scan failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [processes] scan_complete command_id=%s process_count=%d duration=%s", commandID, snapshot.ProcessCount, duration)
|
||||
|
||||
// Report to server
|
||||
report := client.ProcessScanReport{
|
||||
AgentID: cfg.AgentID,
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Snapshot: *snapshot,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportProcessScan(cfg.AgentID, report); err != nil {
|
||||
log.Printf("[ERROR] [agent] [processes] report_failed command_id=%s error=%v", commandID, err)
|
||||
return fmt.Errorf("failed to report process scan: %w", err)
|
||||
}
|
||||
|
||||
// Audit log
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "scan_processes",
|
||||
Result: "success",
|
||||
ExitCode: 0,
|
||||
DurationSeconds: int(duration.Seconds()),
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Processes",
|
||||
"subsystem": "processes",
|
||||
"process_count": fmt.Sprintf("%d", snapshot.ProcessCount),
|
||||
},
|
||||
}
|
||||
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[ERROR] [agent] [processes] report_log_failed: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
81
agent/internal/handlers/reboot.go
Normal file
81
agent/internal/handlers/reboot.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
)
|
||||
|
||||
// HandleReboot schedules a system reboot in response to a server-issued
|
||||
// `reboot` command. Ported from the pre-TD-001 main.go (commit 9da5134e^)
|
||||
// where it was lost in the god-function split. The server-side endpoint
|
||||
// (POST /api/v1/agents/:id/reboot) creates the command; without this
|
||||
// handler the agent silently drops it in dispatch's default case.
|
||||
func HandleReboot(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string, params map[string]interface{}) error {
|
||||
delayMinutes := 1
|
||||
message := "System reboot requested by RedFlag"
|
||||
|
||||
if delay, ok := params["delay_minutes"]; ok {
|
||||
if delayFloat, ok := delay.(float64); ok {
|
||||
delayMinutes = int(delayFloat)
|
||||
}
|
||||
}
|
||||
if msg, ok := params["message"].(string); ok && msg != "" {
|
||||
message = msg
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [reboot] scheduled delay_minutes=%d message=%q", delayMinutes, message)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd = exec.Command("shutdown", "-r", fmt.Sprintf("+%d", delayMinutes), message)
|
||||
case "windows":
|
||||
delaySeconds := delayMinutes * 60
|
||||
cmd = exec.Command("shutdown", "/r", "/t", fmt.Sprintf("%d", delaySeconds), "/c", message)
|
||||
default:
|
||||
err := fmt.Errorf("reboot not supported on platform: %s", runtime.GOOS)
|
||||
log.Printf("[ERROR] [agent] [reboot] unsupported_platform os=%s", runtime.GOOS)
|
||||
ReportLogWithAck(apiClient, cfg, ackTracker, client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "reboot",
|
||||
Result: "failed",
|
||||
Stderr: err.Error(),
|
||||
ExitCode: 1,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [reboot] schedule_failed error=%v output=%q", err, string(output))
|
||||
ReportLogWithAck(apiClient, cfg, ackTracker, client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "reboot",
|
||||
Result: "failed",
|
||||
Stdout: string(output),
|
||||
Stderr: err.Error(),
|
||||
ExitCode: 1,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [reboot] scheduled_ok delay_minutes=%d", delayMinutes)
|
||||
|
||||
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "reboot",
|
||||
Result: "success",
|
||||
Stdout: fmt.Sprintf("System reboot scheduled for %d minute(s) from now. Message: %s", delayMinutes, message),
|
||||
ExitCode: 0,
|
||||
}); reportErr != nil {
|
||||
log.Printf("[ERROR] [agent] [reboot] report_failed error=%v", reportErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
742
agent/internal/handlers/scan.go
Normal file
742
agent/internal/handlers/scan.go
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/scanner"
|
||||
)
|
||||
|
||||
// mapAny extracts a typed value from map[string]interface{} with zero fallback.
|
||||
func mapAny[T any](v interface{}) T {
|
||||
r, _ := v.(T)
|
||||
return r
|
||||
}
|
||||
|
||||
// reportLogWithAck reports a command log to the server and tracks it for acknowledgment.
|
||||
// A log with no command ID (locally triggered scan — no signed server command behind it)
|
||||
// is reported without ack tracking: there is no command completion to guarantee.
|
||||
func reportLogWithAck(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, logReport client.LogReport) error {
|
||||
if logReport.CommandID == "" {
|
||||
return apiClient.ReportLog(cfg.AgentID, logReport)
|
||||
}
|
||||
|
||||
// Track this command result as pending acknowledgment
|
||||
ackTracker.Add(logReport.CommandID)
|
||||
|
||||
// Save acknowledgment state immediately
|
||||
if err := ackTracker.Save(); err != nil {
|
||||
log.Printf("Warning: Failed to save acknowledgment for command %s: %v", logReport.CommandID, err)
|
||||
}
|
||||
|
||||
// Report the log to the server
|
||||
if err := apiClient.ReportLog(cfg.AgentID, logReport); err != nil {
|
||||
// If reporting failed, increment retry count but don't remove from pending
|
||||
ackTracker.IncrementRetry(logReport.CommandID)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleScanUpdates scans for ALL package updates across all available package managers
|
||||
// This is the virtual "updates" subsystem that triggers apt, dnf, winget, and windows scans
|
||||
func HandleScanUpdates(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
|
||||
log.Println("Scanning for all package updates...")
|
||||
|
||||
ctx := context.Background()
|
||||
startTime := time.Now()
|
||||
var totalUpdates int
|
||||
var scanResults []string
|
||||
var localResults []orchestrator.ScanResult
|
||||
var errors []string
|
||||
|
||||
// Detect OS and run appropriate scanners
|
||||
osType := runtime.GOOS
|
||||
|
||||
// Linux: Try APT first, then DNF
|
||||
if osType == "linux" {
|
||||
// Try APT
|
||||
aptScanner := scanner.NewAPTScanner()
|
||||
if aptScanner.IsAvailable() {
|
||||
log.Println("[updates] Running APT scan...")
|
||||
result, err := orch.ScanSingle(ctx, "apt")
|
||||
localResults = append(localResults, result)
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Sprintf("APT: %v", err))
|
||||
} else {
|
||||
scanResults = append(scanResults, fmt.Sprintf("APT: %d updates", len(result.Updates)))
|
||||
totalUpdates += len(result.Updates)
|
||||
// RECONCILE-001: always report on a successful scan, even 0 updates,
|
||||
// so the server can close rows absent from this scan.
|
||||
if result.Status == "success" {
|
||||
report := client.UpdateReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Updates: result.Updates,
|
||||
Ecosystem: "apt",
|
||||
ScanSucceeded: true,
|
||||
}
|
||||
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
|
||||
log.Printf("[WARNING] [agent] [updates] apt_report_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try DNF
|
||||
dnfScanner := scanner.NewDNFScanner()
|
||||
if dnfScanner.IsAvailable() {
|
||||
log.Println("[updates] Running DNF scan...")
|
||||
result, err := orch.ScanSingle(ctx, "dnf")
|
||||
localResults = append(localResults, result)
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Sprintf("DNF: %v", err))
|
||||
} else {
|
||||
scanResults = append(scanResults, fmt.Sprintf("DNF: %d updates", len(result.Updates)))
|
||||
totalUpdates += len(result.Updates)
|
||||
// RECONCILE-001: always report on a successful scan, even 0 updates,
|
||||
// so the server can close rows absent from this scan.
|
||||
if result.Status == "success" {
|
||||
report := client.UpdateReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Updates: result.Updates,
|
||||
Ecosystem: "dnf",
|
||||
ScanSucceeded: true,
|
||||
}
|
||||
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
|
||||
log.Printf("[WARNING] [agent] [updates] dnf_report_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Windows: Try Windows Update and Winget
|
||||
if osType == "windows" {
|
||||
// Try Windows Update
|
||||
windowsScanner := scanner.NewWindowsUpdateScanner()
|
||||
if windowsScanner.IsAvailable() {
|
||||
log.Println("[updates] Running Windows Update scan...")
|
||||
result, err := orch.ScanSingle(ctx, "windows")
|
||||
localResults = append(localResults, result)
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Sprintf("Windows: %v", err))
|
||||
} else {
|
||||
scanResults = append(scanResults, fmt.Sprintf("Windows: %d updates", len(result.Updates)))
|
||||
totalUpdates += len(result.Updates)
|
||||
// Report Windows updates
|
||||
if len(result.Updates) > 0 {
|
||||
report := client.UpdateReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Updates: result.Updates,
|
||||
}
|
||||
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
|
||||
log.Printf("[WARNING] [updates] Failed to report Windows updates: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try Winget
|
||||
wingetScanner := scanner.NewWingetScanner()
|
||||
if wingetScanner.IsAvailable() {
|
||||
log.Println("[updates] Running Winget scan...")
|
||||
result, err := orch.ScanSingle(ctx, "winget")
|
||||
localResults = append(localResults, result)
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Sprintf("Winget: %v", err))
|
||||
} else {
|
||||
scanResults = append(scanResults, fmt.Sprintf("Winget: %d updates", len(result.Updates)))
|
||||
totalUpdates += len(result.Updates)
|
||||
// Report Winget updates
|
||||
if len(result.Updates) > 0 {
|
||||
report := client.UpdateReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Updates: result.Updates,
|
||||
}
|
||||
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
|
||||
log.Printf("[WARNING] [updates] Failed to report Winget updates: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
recordLocalScanResults(cfg, localResults, true)
|
||||
|
||||
duration := time.Since(startTime)
|
||||
stdout := fmt.Sprintf("Package update scan completed in %.2f seconds\n\nResults:\n%s\n\nTotal updates found: %d",
|
||||
duration.Seconds(),
|
||||
strings.Join(scanResults, "\n"),
|
||||
totalUpdates)
|
||||
|
||||
stderr := ""
|
||||
exitCode := 0
|
||||
if len(errors) > 0 {
|
||||
stderr = fmt.Sprintf("Errors encountered:\n%s", strings.Join(errors, "\n"))
|
||||
exitCode = 1
|
||||
}
|
||||
|
||||
// Create history entry
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "scan_updates",
|
||||
Result: map[bool]string{true: "success", false: "partial_failure"}[exitCode == 0],
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: int(duration.Seconds()),
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Updates",
|
||||
"subsystem": "updates",
|
||||
"total_updates": fmt.Sprintf("%d", totalUpdates),
|
||||
"scanners_run": fmt.Sprintf("%d", len(scanResults)),
|
||||
},
|
||||
}
|
||||
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[ERROR] [agent] [updates] report_log_failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [updates] scan completed: %d updates found", totalUpdates)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleScanStorage scans disk usage metrics only
|
||||
func HandleScanStorage(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
|
||||
log.Println("Scanning storage...")
|
||||
|
||||
ctx := context.Background()
|
||||
startTime := time.Now()
|
||||
|
||||
result, err := orch.ScanSingle(ctx, "storage")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan storage: %w", err)
|
||||
}
|
||||
recordLocalScanResult(cfg, result, false)
|
||||
|
||||
results := []orchestrator.ScanResult{result}
|
||||
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
|
||||
|
||||
duration := time.Since(startTime)
|
||||
stdout += fmt.Sprintf("\nStorage scan completed in %.2f seconds\n", duration.Seconds())
|
||||
|
||||
// Report storage metrics to server using dedicated endpoint
|
||||
if len(result.Updates) > 0 {
|
||||
metricItems := make([]models.StorageMetric, 0, len(result.Updates))
|
||||
for _, u := range result.Updates {
|
||||
m := u.Metadata
|
||||
metricItems = append(metricItems, models.StorageMetric{
|
||||
Mountpoint: u.PackageName,
|
||||
Device: u.RepositorySource,
|
||||
DiskType: mapAny[string](m["disk_type"]),
|
||||
Filesystem: mapAny[string](m["filesystem"]),
|
||||
TotalBytes: mapAny[int64](m["total_bytes"]),
|
||||
UsedBytes: mapAny[int64](m["used_bytes"]),
|
||||
AvailableBytes: mapAny[int64](m["available_bytes"]),
|
||||
UsedPercent: mapAny[float64](m["used_percent"]),
|
||||
IsRoot: mapAny[bool](m["is_root"]),
|
||||
IsLargest: mapAny[bool](m["is_largest"]),
|
||||
Severity: u.Severity,
|
||||
})
|
||||
}
|
||||
|
||||
report := models.StorageMetricReport{
|
||||
AgentID: cfg.AgentID,
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Metrics: metricItems,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportStorageMetrics(cfg.AgentID, report); err != nil {
|
||||
return fmt.Errorf("failed to report storage metrics: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [storage] Successfully reported %d storage metrics to server\n", len(result.Updates))
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "scan_storage",
|
||||
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: int(duration.Seconds()),
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Disk Usage",
|
||||
"subsystem": "storage",
|
||||
"metrics_count": fmt.Sprintf("%d", len(result.Updates)),
|
||||
},
|
||||
}
|
||||
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[ERROR] [agent] [storage] report_log_failed: %v", err)
|
||||
log.Printf("[HISTORY] [agent] [storage] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [storage] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
|
||||
log.Printf("[HISTORY] [agent] [scan_storage] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleScanSystem scans system metrics (CPU, memory, processes, uptime)
|
||||
func HandleScanSystem(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
|
||||
log.Println("Scanning system metrics...")
|
||||
|
||||
ctx := context.Background()
|
||||
startTime := time.Now()
|
||||
|
||||
result, err := orch.ScanSingle(ctx, "system")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan system: %w", err)
|
||||
}
|
||||
recordLocalScanResult(cfg, result, false)
|
||||
|
||||
results := []orchestrator.ScanResult{result}
|
||||
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
|
||||
|
||||
duration := time.Since(startTime)
|
||||
stdout += fmt.Sprintf("\nSystem scan completed in %.2f seconds\n", duration.Seconds())
|
||||
|
||||
// Report system metrics to server using dedicated endpoint
|
||||
if len(result.Updates) > 0 {
|
||||
metricItems := make([]client.MetricsReportItem, 0, len(result.Updates))
|
||||
for _, u := range result.Updates {
|
||||
metricItems = append(metricItems, client.MetricsReportItem{
|
||||
PackageType: u.PackageType,
|
||||
PackageName: u.PackageName,
|
||||
CurrentVersion: u.CurrentVersion,
|
||||
AvailableVersion: u.AvailableVersion,
|
||||
Severity: u.Severity,
|
||||
RepositorySource: u.RepositorySource,
|
||||
Metadata: u.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
report := client.MetricsReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Metrics: metricItems,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportMetrics(cfg.AgentID, report); err != nil {
|
||||
return fmt.Errorf("failed to report system metrics: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [system] Reported %d system metrics to server\n", len(result.Updates))
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "scan_system",
|
||||
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: int(duration.Seconds()),
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "System Metrics",
|
||||
"subsystem": "system",
|
||||
"metrics_count": fmt.Sprintf("%d", len(result.Updates)),
|
||||
},
|
||||
}
|
||||
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[ERROR] [agent] [system] report_log_failed: %v", err)
|
||||
log.Printf("[HISTORY] [agent] [system] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [system] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
|
||||
log.Printf("[HISTORY] [agent] [scan_system] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleScanDocker scans Docker image inventory and container enrichment data.
|
||||
// Uses the InventoryScanner path for image inventory, and the existing
|
||||
// DockerReport path for container/stack/engine enrichment.
|
||||
func HandleScanDocker(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
|
||||
log.Println("Scanning Docker images...")
|
||||
|
||||
ctx := context.Background()
|
||||
startTime := time.Now()
|
||||
|
||||
// Use inventory scanner path for image data
|
||||
invResult, err := orch.ScanInventorySingle(ctx, "docker")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan Docker inventory: %w", err)
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Report inventory items to the new inventory endpoint
|
||||
if len(invResult.Items) > 0 {
|
||||
invReport := client.InventoryReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Ecosystem: "docker",
|
||||
Items: invResult.Items,
|
||||
ScanSucceeded: invResult.Status == "success",
|
||||
}
|
||||
if err := apiClient.ReportInventory(cfg.AgentID, invReport); err != nil {
|
||||
log.Printf("[WARNING] [agent] [docker] report_inventory_failed error=%v", err)
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [docker] Reported %d Docker images to inventory", len(invResult.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// Collect container/stack enrichment data (existing DockerReport path)
|
||||
updateCount := 0
|
||||
for _, item := range invResult.Items {
|
||||
if hasUpdate, ok := item.Metadata["has_update"].(bool); ok && hasUpdate {
|
||||
updateCount++
|
||||
}
|
||||
}
|
||||
|
||||
dockerScanner, err := orchestrator.NewDockerScanner()
|
||||
if err != nil {
|
||||
log.Printf("[WARN] [agent] [docker] could not create scanner for enrichment: %v", err)
|
||||
} else {
|
||||
defer dockerScanner.Close()
|
||||
|
||||
containers, err := dockerScanner.ScanContainers()
|
||||
if err != nil {
|
||||
log.Printf("[WARN] [agent] [docker] container scan failed: %v", err)
|
||||
} else {
|
||||
report := client.DockerReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Containers: containers,
|
||||
Stacks: dockerScanner.ScanStacks(containers),
|
||||
EngineVersion: dockerScanner.GetEngineVersion(),
|
||||
}
|
||||
if err := apiClient.ReportDockerImages(cfg.AgentID, report); err != nil {
|
||||
log.Printf("[WARNING] [agent] [docker] report_enrichment_failed error=%v", err)
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [docker] Reported %d containers, %d stacks for enrichment",
|
||||
len(report.Containers), len(report.Stacks))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build log report
|
||||
stdout := fmt.Sprintf("Docker inventory scan completed in %.2f seconds\n\nItems found: %d\nUpdates available: %d",
|
||||
duration.Seconds(), len(invResult.Items), updateCount)
|
||||
stderr := ""
|
||||
exitCode := 0
|
||||
if invResult.Status == "failed" {
|
||||
stderr = fmt.Sprintf("Error: %v", invResult.Error)
|
||||
exitCode = 1
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "scan_docker",
|
||||
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: int(duration.Seconds()),
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Docker Images",
|
||||
"subsystem": "docker",
|
||||
"images_count": fmt.Sprintf("%d", len(invResult.Items)),
|
||||
"updates_found": fmt.Sprintf("%d", updateCount),
|
||||
},
|
||||
}
|
||||
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[ERROR] [agent] [docker] report_log_failed: %v", err)
|
||||
log.Printf("[HISTORY] [agent] [docker] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [docker] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
|
||||
log.Printf("[HISTORY] [agent] [scan_docker] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleScanAPT scans APT package updates only
|
||||
func HandleScanAPT(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
|
||||
log.Println("Scanning APT packages...")
|
||||
|
||||
ctx := context.Background()
|
||||
startTime := time.Now()
|
||||
|
||||
// Execute APT scanner
|
||||
result, err := orch.ScanSingle(ctx, "apt")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan APT: %w", err)
|
||||
}
|
||||
recordLocalScanResult(cfg, result, true)
|
||||
|
||||
// Format results
|
||||
results := []orchestrator.ScanResult{result}
|
||||
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
|
||||
|
||||
duration := time.Since(startTime)
|
||||
stdout += fmt.Sprintf("\nAPT scan completed in %.2f seconds\n", duration.Seconds())
|
||||
|
||||
// Report APT updates to server.
|
||||
// RECONCILE-001: always report on a successful scan, even when 0 updates are
|
||||
// found. An empty reported set + ScanSucceeded=true tells the server this
|
||||
// ecosystem is fully patched — it must close all tracked non-resting rows.
|
||||
// Declare updates at function scope for ReportLog access.
|
||||
var updates []client.UpdateReportItem
|
||||
if result.Status == "success" {
|
||||
updates = result.Updates
|
||||
report := client.UpdateReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Updates: updates,
|
||||
Ecosystem: "apt",
|
||||
ScanSucceeded: true,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
|
||||
log.Printf("[WARNING] [agent] [apt] report_updates_failed error=%v", err)
|
||||
// Do not return error here — the scan succeeded locally; the report failure
|
||||
// is a transport problem. ReportLog below will still record the scan.
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [apt] reported %d APT updates to server", len(updates))
|
||||
}
|
||||
}
|
||||
|
||||
// Create history entry for unified view with proper formatting
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "scan_apt",
|
||||
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: int(duration.Seconds()),
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "APT Packages",
|
||||
"subsystem": "apt",
|
||||
"updates_found": fmt.Sprintf("%d", len(updates)),
|
||||
},
|
||||
}
|
||||
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[ERROR] [agent] [apt] report_log_failed: %v", err)
|
||||
log.Printf("[HISTORY] [agent] [apt] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [apt] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
|
||||
log.Printf("[HISTORY] [agent] [scan_apt] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleScanDNF scans DNF package updates only
|
||||
func HandleScanDNF(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
|
||||
log.Println("Scanning DNF packages...")
|
||||
|
||||
ctx := context.Background()
|
||||
startTime := time.Now()
|
||||
|
||||
// Execute DNF scanner
|
||||
result, err := orch.ScanSingle(ctx, "dnf")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan DNF: %w", err)
|
||||
}
|
||||
recordLocalScanResult(cfg, result, true)
|
||||
|
||||
// Format results
|
||||
results := []orchestrator.ScanResult{result}
|
||||
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
|
||||
|
||||
duration := time.Since(startTime)
|
||||
stdout += fmt.Sprintf("\nDNF scan completed in %.2f seconds\n", duration.Seconds())
|
||||
|
||||
// Report DNF updates to server.
|
||||
// RECONCILE-001: always report on a successful scan, even when 0 updates are
|
||||
// found. An empty reported set + ScanSucceeded=true tells the server this
|
||||
// ecosystem is fully patched — it must close all tracked non-resting rows.
|
||||
// Declare updates at function scope for ReportLog access.
|
||||
var updates []client.UpdateReportItem
|
||||
if result.Status == "success" {
|
||||
updates = result.Updates
|
||||
report := client.UpdateReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Updates: updates,
|
||||
Ecosystem: "dnf",
|
||||
ScanSucceeded: true,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
|
||||
log.Printf("[WARNING] [agent] [dnf] report_updates_failed error=%v", err)
|
||||
// Do not return error here — the scan succeeded locally; the report failure
|
||||
// is a transport problem. ReportLog below will still record the scan.
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [dnf] reported %d DNF updates to server", len(updates))
|
||||
}
|
||||
}
|
||||
|
||||
// Create history entry for unified view with proper formatting
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "scan_dnf",
|
||||
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: int(duration.Seconds()),
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "DNF Packages",
|
||||
"subsystem": "dnf",
|
||||
"updates_found": fmt.Sprintf("%d", len(updates)),
|
||||
},
|
||||
}
|
||||
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[ERROR] [agent] [dnf] report_log_failed: %v", err)
|
||||
log.Printf("[HISTORY] [agent] [dnf] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [dnf] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
|
||||
log.Printf("[HISTORY] [agent] [scan_dnf] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleScanWindows scans Windows Updates only
|
||||
func HandleScanWindows(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
|
||||
log.Println("Scanning Windows Updates...")
|
||||
|
||||
ctx := context.Background()
|
||||
startTime := time.Now()
|
||||
|
||||
// Execute Windows Update scanner
|
||||
result, err := orch.ScanSingle(ctx, "windows")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan Windows Updates: %w", err)
|
||||
}
|
||||
recordLocalScanResult(cfg, result, true)
|
||||
|
||||
// Format results
|
||||
results := []orchestrator.ScanResult{result}
|
||||
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
|
||||
|
||||
duration := time.Since(startTime)
|
||||
stdout += fmt.Sprintf("\nWindows Update scan completed in %.2f seconds\n", duration.Seconds())
|
||||
|
||||
// Report Windows updates to server if any were found
|
||||
// Declare updates at function scope for ReportLog access
|
||||
var updates []client.UpdateReportItem
|
||||
if result.Status == "success" && len(result.Updates) > 0 {
|
||||
updates = result.Updates
|
||||
report := client.UpdateReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Updates: updates,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
|
||||
return fmt.Errorf("failed to report Windows updates: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [windows] Successfully reported %d Windows updates to server\n", len(updates))
|
||||
}
|
||||
|
||||
// Create history entry for unified view with proper formatting
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "scan_windows",
|
||||
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: int(duration.Seconds()),
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Windows Updates",
|
||||
"subsystem": "windows",
|
||||
"updates_found": fmt.Sprintf("%d", len(updates)),
|
||||
},
|
||||
}
|
||||
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[ERROR] [agent] [windows] report_log_failed: %v", err)
|
||||
log.Printf("[HISTORY] [agent] [windows] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [windows] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
|
||||
log.Printf("[HISTORY] [agent] [scan_windows] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleScanWinget scans Winget package updates only
|
||||
func HandleScanWinget(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
|
||||
log.Println("Scanning Winget packages...")
|
||||
|
||||
ctx := context.Background()
|
||||
startTime := time.Now()
|
||||
|
||||
// Execute Winget scanner
|
||||
result, err := orch.ScanSingle(ctx, "winget")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan Winget: %w", err)
|
||||
}
|
||||
recordLocalScanResult(cfg, result, true)
|
||||
|
||||
// Format results
|
||||
results := []orchestrator.ScanResult{result}
|
||||
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
|
||||
|
||||
duration := time.Since(startTime)
|
||||
stdout += fmt.Sprintf("\nWinget scan completed in %.2f seconds\n", duration.Seconds())
|
||||
|
||||
// Report Winget updates to server if any were found
|
||||
// Declare updates at function scope for ReportLog access
|
||||
var updates []client.UpdateReportItem
|
||||
if result.Status == "success" && len(result.Updates) > 0 {
|
||||
updates = result.Updates
|
||||
report := client.UpdateReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Updates: updates,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
|
||||
return fmt.Errorf("failed to report Winget updates: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [winget] Successfully reported %d Winget updates to server\n", len(updates))
|
||||
}
|
||||
|
||||
// Create history entry for unified view with proper formatting
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "scan_winget",
|
||||
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: int(duration.Seconds()),
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Winget Packages",
|
||||
"subsystem": "winget",
|
||||
"updates_found": fmt.Sprintf("%d", len(updates)),
|
||||
},
|
||||
}
|
||||
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[ERROR] [agent] [winget] report_log_failed: %v", err)
|
||||
log.Printf("[HISTORY] [agent] [winget] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [winget] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
|
||||
log.Printf("[HISTORY] [agent] [scan_winget] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
381
agent/internal/handlers/screenshot.go
Normal file
381
agent/internal/handlers/screenshot.go
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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)
|
||||
|
||||
diag, err := captureScreen(tmpPath)
|
||||
if err != nil {
|
||||
// Report detailed diagnostics to the server instead of a generic message.
|
||||
diagJSON, _ := json.Marshal(diag)
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "capture_screenshot",
|
||||
Result: "failed",
|
||||
Stderr: fmt.Sprintf("screen capture failed: %v", err),
|
||||
ExitCode: 1,
|
||||
Metadata: map[string]string{
|
||||
"diagnostics": string(diagJSON),
|
||||
},
|
||||
DurationSeconds: int(time.Since(start).Seconds()),
|
||||
}
|
||||
_ = ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
|
||||
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.
|
||||
// Returns diagnostics for health reporting on failure.
|
||||
func captureScreen(outputPath string) (*screenshotDiagnostics, error) {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return captureScreenLinux(outputPath)
|
||||
case "windows":
|
||||
if err := captureScreenWindows(outputPath); err != nil {
|
||||
return &screenshotDiagnostics{SessionType: "windows"}, err
|
||||
}
|
||||
return &screenshotDiagnostics{SessionType: "windows"}, nil
|
||||
default:
|
||||
return &screenshotDiagnostics{}, fmt.Errorf("screenshot not supported on %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
// sessionDisplayInfo holds discovered display environment variables and the
|
||||
// detected session type (x11, wayland, or unknown).
|
||||
type sessionDisplayInfo struct {
|
||||
env []string
|
||||
sessionType string // "x11", "wayland", or ""
|
||||
sourcePID int // pid whose environ provided the vars (0 = none)
|
||||
}
|
||||
|
||||
// toolAttempt records one screenshot tool invocation for diagnostic reporting.
|
||||
type toolAttempt struct {
|
||||
Name string `json:"name"`
|
||||
Found bool `json:"found"` // in PATH
|
||||
Tried bool `json:"tried"` // actually executed
|
||||
ExitCode int `json:"exit_code,omitempty"` // 0 = success, -1 = not tried
|
||||
Stderr string `json:"stderr,omitempty"` // first 200 chars of output
|
||||
}
|
||||
|
||||
// screenshotDiagnostics captures the full picture for the server when a
|
||||
// screenshot fails. The generic "no tool found" message hid the real cause;
|
||||
// this exposes it as structured health data.
|
||||
type screenshotDiagnostics struct {
|
||||
SessionType string `json:"session_type"`
|
||||
SessionFound bool `json:"session_found"`
|
||||
DisplayVars int `json:"display_vars"`
|
||||
SessionPID int `json:"session_pid,omitempty"`
|
||||
Tools []toolAttempt `json:"tools"`
|
||||
CapSysPtrace bool `json:"cap_sys_ptrace"`
|
||||
ProcReadable bool `json:"proc_readable"`
|
||||
}
|
||||
|
||||
// captureScreenLinux captures the display. The agent service does not inherit
|
||||
// DISPLAY/WAYLAND_DISPLAY from systemd, so we discover them from the running
|
||||
// user session before invoking any capture tool.
|
||||
//
|
||||
// Tool priority depends on session type:
|
||||
//
|
||||
// X11: scrot → magick import → import (ImageMagick v6)
|
||||
// Wayland: grim (wlroots) → gnome-screenshot (GNOME) → spectacle (KDE)
|
||||
// → magick import (fallback, needs root on some compositors)
|
||||
// Unknown: try all tools in order
|
||||
func captureScreenLinux(outputPath string) (*screenshotDiagnostics, error) {
|
||||
info := discoverSessionDisplay()
|
||||
env := append(os.Environ(), info.env...)
|
||||
|
||||
diag := &screenshotDiagnostics{
|
||||
SessionType: info.sessionType,
|
||||
SessionFound: info.sessionType != "",
|
||||
DisplayVars: len(info.env),
|
||||
SessionPID: info.sourcePID,
|
||||
CapSysPtrace: hasCapSysPtrace(),
|
||||
ProcReadable: info.sourcePID != 0,
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [screenshot] session_type=%s display_vars=%d session_pid=%d cap_sys_ptrace=%v",
|
||||
orDefault(info.sessionType, "unknown"), len(info.env), info.sourcePID, diag.CapSysPtrace)
|
||||
|
||||
type cmdFunc func(string, ...string) bool
|
||||
var tryCmd cmdFunc
|
||||
tryCmd = func(name string, args ...string) bool {
|
||||
_, lookErr := exec.LookPath(name)
|
||||
attempt := toolAttempt{Name: name, Found: lookErr == nil, ExitCode: -1}
|
||||
if lookErr != nil {
|
||||
diag.Tools = append(diag.Tools, attempt)
|
||||
return false
|
||||
}
|
||||
attempt.Tried = true
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = env
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
attempt.Stderr = truncate(string(out), 200)
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
attempt.ExitCode = exitErr.ExitCode()
|
||||
}
|
||||
log.Printf("[WARN] [agent] [screenshot] %s_failed exit=%d output=%q error=%v", name, attempt.ExitCode, string(out), err)
|
||||
diag.Tools = append(diag.Tools, attempt)
|
||||
return false
|
||||
}
|
||||
attempt.ExitCode = 0
|
||||
diag.Tools = append(diag.Tools, attempt)
|
||||
return true
|
||||
}
|
||||
|
||||
var err error
|
||||
switch info.sessionType {
|
||||
case "wayland":
|
||||
err = captureWayland(outputPath, env, tryCmd)
|
||||
case "x11":
|
||||
err = captureX11(outputPath, tryCmd)
|
||||
default:
|
||||
err = captureFallback(outputPath, tryCmd)
|
||||
}
|
||||
if err != nil {
|
||||
return diag, err
|
||||
}
|
||||
return diag, nil
|
||||
}
|
||||
|
||||
// captureX11 tries X11 screenshot tools in priority order.
|
||||
func captureX11(outputPath string, tryCmd func(string, ...string) bool) error {
|
||||
if tryCmd("scrot", "-o", outputPath) {
|
||||
return nil
|
||||
}
|
||||
// ImageMagick v7: standalone `import` is gone, use `magick import`.
|
||||
if tryCmd("magick", "import", "-window", "root", outputPath) {
|
||||
return nil
|
||||
}
|
||||
// ImageMagick v6 compat.
|
||||
if tryCmd("import", "-window", "root", outputPath) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("no X11 screenshot tool found (tried scrot, magick import)")
|
||||
}
|
||||
|
||||
// captureWayland tries Wayland screenshot tools. Tool availability depends on
|
||||
// the compositor:
|
||||
// - wlroots (Sway, Hyprland, River): grim
|
||||
// - GNOME (Mutter): gnome-screenshot or D-Bus org.gnome.Shell.Screenshot
|
||||
// - KDE (KWin): spectacle --background --output
|
||||
// - Fallback: magick import (may work under XWayland or as root)
|
||||
func captureWayland(outputPath string, env []string, tryCmd func(string, ...string) bool) error {
|
||||
// wlroots compositors — grim speaks wlr-screencopy / ext-image-capture-source.
|
||||
if tryCmd("grim", outputPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GNOME Wayland — gnome-screenshot uses the GNOME Shell D-Bus API.
|
||||
if tryCmd("gnome-screenshot", "-f", outputPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// KDE Wayland — spectacle's --background flag captures without opening the GUI.
|
||||
if tryCmd("spectacle", "--background", "--output", outputPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImageMagick — may work via XWayland or with compositor-specific backends.
|
||||
if tryCmd("magick", "import", "-window", "root", outputPath) {
|
||||
return nil
|
||||
}
|
||||
if tryCmd("import", "-window", "root", outputPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("no Wayland screenshot tool found (tried grim, gnome-screenshot, spectacle, magick import)")
|
||||
}
|
||||
|
||||
// captureFallback tries all tools regardless of session type.
|
||||
func captureFallback(outputPath string, tryCmd func(string, ...string) bool) error {
|
||||
if tryCmd("scrot", "-o", outputPath) {
|
||||
return nil
|
||||
}
|
||||
if tryCmd("grim", outputPath) {
|
||||
return nil
|
||||
}
|
||||
if tryCmd("gnome-screenshot", "-f", outputPath) {
|
||||
return nil
|
||||
}
|
||||
if tryCmd("spectacle", "--background", "--output", outputPath) {
|
||||
return nil
|
||||
}
|
||||
if tryCmd("magick", "import", "-window", "root", outputPath) {
|
||||
return nil
|
||||
}
|
||||
if tryCmd("import", "-window", "root", outputPath) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("no screenshot tool found (tried scrot, grim, gnome-screenshot, spectacle, magick import)")
|
||||
}
|
||||
|
||||
// discoverSessionDisplay reads /proc environ entries to find DISPLAY,
|
||||
// WAYLAND_DISPLAY, XDG_RUNTIME_DIR, and XDG_SESSION_TYPE from an active
|
||||
// user session. The agent service runs without these inherited from systemd.
|
||||
//
|
||||
// XDG_SESSION_TYPE is used to select the right screenshot tool chain:
|
||||
// "x11" → scrot/magick, "wayland" → grim/gnome-screenshot/spectacle.
|
||||
func discoverSessionDisplay() sessionDisplayInfo {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return sessionDisplayInfo{}
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.Atoi(entry.Name()); err != nil {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile("/proc/" + entry.Name() + "/environ")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var display, wayland, xdgRuntime, sessionType string
|
||||
for _, v := range bytes.Split(data, []byte{0}) {
|
||||
s := string(v)
|
||||
switch {
|
||||
case strings.HasPrefix(s, "DISPLAY=") && display == "":
|
||||
display = s
|
||||
case strings.HasPrefix(s, "WAYLAND_DISPLAY=") && wayland == "":
|
||||
wayland = s
|
||||
case strings.HasPrefix(s, "XDG_RUNTIME_DIR=") && xdgRuntime == "":
|
||||
xdgRuntime = s
|
||||
case strings.HasPrefix(s, "XDG_SESSION_TYPE=") && sessionType == "":
|
||||
sessionType = strings.TrimPrefix(s, "XDG_SESSION_TYPE=")
|
||||
}
|
||||
}
|
||||
if display != "" || wayland != "" {
|
||||
var env []string
|
||||
if display != "" {
|
||||
env = append(env, display)
|
||||
}
|
||||
if wayland != "" {
|
||||
env = append(env, wayland)
|
||||
}
|
||||
if xdgRuntime != "" {
|
||||
env = append(env, xdgRuntime)
|
||||
}
|
||||
// Infer session type from env if XDG_SESSION_TYPE was not set.
|
||||
if sessionType == "" {
|
||||
if wayland != "" {
|
||||
sessionType = "wayland"
|
||||
} else if display != "" {
|
||||
sessionType = "x11"
|
||||
}
|
||||
}
|
||||
pid, _ := strconv.Atoi(entry.Name())
|
||||
return sessionDisplayInfo{env: env, sessionType: sessionType, sourcePID: pid}
|
||||
}
|
||||
}
|
||||
return sessionDisplayInfo{}
|
||||
}
|
||||
|
||||
// orDefault returns s if non-empty, otherwise fallback.
|
||||
func orDefault(s, fallback string) string {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// hasCapSysPtrace checks if the current process has CAP_SYS_PTRACE in its
|
||||
// effective set. Reads /proc/self/status to avoid a cgo dependency on libcap.
|
||||
func hasCapSysPtrace() bool {
|
||||
data, err := os.ReadFile("/proc/self/status")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "CapEff:") {
|
||||
// CAP_SYS_PTRACE is bit 19 = 0x80000.
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
var cap uint64
|
||||
fmt.Sscanf(fields[1], "%x", &cap)
|
||||
return cap&0x80000 != 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
171
agent/internal/handlers/upgrade_attestation.go
Normal file
171
agent/internal/handlers/upgrade_attestation.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
)
|
||||
|
||||
// UpgradeAttestation is the marker the old binary writes immediately before the
|
||||
// self-upgrade restart. The new binary reads it at startup and attests whether
|
||||
// the process now running is actually the version the swap installed. Without
|
||||
// this, a swap that silently failed (or was rolled back from .bak) leaves the
|
||||
// old binary checking in normally and the server waiting out the full
|
||||
// stuck-update timeout before anyone notices.
|
||||
type UpgradeAttestation struct {
|
||||
CommandID string `json:"command_id"`
|
||||
FromVersion string `json:"from_version"`
|
||||
ToVersion string `json:"to_version"`
|
||||
InitiatedAt time.Time `json:"initiated_at"`
|
||||
}
|
||||
|
||||
// attestationMaxAge bounds marker retries. Past this the server's
|
||||
// reconcileAgentUpdates sweep has long since timed the command out, so a
|
||||
// report would only 409; drop the marker instead of retrying forever.
|
||||
const attestationMaxAge = 24 * time.Hour
|
||||
|
||||
func upgradeAttestationPath() string {
|
||||
return filepath.Join(constants.GetAgentStateDir(), "upgrade-attestation.json")
|
||||
}
|
||||
|
||||
// WriteUpgradeAttestation persists the pre-restart marker. Called by the
|
||||
// upgrade handler after the binary swap is committed (or handed to the helper)
|
||||
// and before the service restart.
|
||||
func WriteUpgradeAttestation(commandID, toVersion string) error {
|
||||
att := UpgradeAttestation{
|
||||
CommandID: commandID,
|
||||
FromVersion: version.Version,
|
||||
ToVersion: toVersion,
|
||||
InitiatedAt: time.Now().UTC(),
|
||||
}
|
||||
data, err := json.Marshal(att)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal upgrade attestation: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(upgradeAttestationPath(), data, 0o600); err != nil {
|
||||
return fmt.Errorf("write upgrade attestation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearUpgradeAttestation removes the marker. Used on upgrade paths that fail
|
||||
// before the restart is dispatched — the next boot is not a post-upgrade boot.
|
||||
func ClearUpgradeAttestation() {
|
||||
if err := os.Remove(upgradeAttestationPath()); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_remove_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RunUpgradeAttestation is the post-upgrade healthcheck, called once at agent
|
||||
// startup before the polling loop. If a marker exists, the running version is
|
||||
// checked against the upgrade target:
|
||||
//
|
||||
// - running >= target: the swap worked. Local log only — success closure is
|
||||
// deliberately owned by the server (version check-in confirm in
|
||||
// ReportMetrics + the reconcileAgentUpdates sweep), not this report.
|
||||
// - running < target: the swap failed or was rolled back. Report a failed
|
||||
// update_agent log under the original command_id — the server marks the
|
||||
// command failed, clears is_updating immediately, and journals a system
|
||||
// event, instead of the operator waiting out the stuck-update timeout.
|
||||
func RunUpgradeAttestation(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker) {
|
||||
data, err := os.ReadFile(upgradeAttestationPath())
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_read_failed error=%v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var att UpgradeAttestation
|
||||
if err := json.Unmarshal(data, &att); err != nil {
|
||||
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_corrupt error=%v", err)
|
||||
ClearUpgradeAttestation()
|
||||
return
|
||||
}
|
||||
|
||||
if versionAtLeast(version.Version, att.ToVersion) {
|
||||
log.Printf("[INFO] [agent] [upgrade] post_upgrade_attestation_ok running=%s target=%s command_id=%s",
|
||||
version.Version, att.ToVersion, att.CommandID)
|
||||
ClearUpgradeAttestation()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[CRITICAL] [agent] [upgrade] post_upgrade_attestation_failed running=%s target=%s from=%s command_id=%s",
|
||||
version.Version, att.ToVersion, att.FromVersion, att.CommandID)
|
||||
|
||||
expired := time.Since(att.InitiatedAt) > attestationMaxAge
|
||||
|
||||
report := client.LogReport{
|
||||
CommandID: att.CommandID,
|
||||
Action: "update_agent",
|
||||
Result: "failed",
|
||||
Stderr: fmt.Sprintf(
|
||||
"post-upgrade attestation failed: running version %s, expected %s (was %s before the swap) — binary swap failed or was rolled back",
|
||||
version.Version, att.ToVersion, att.FromVersion),
|
||||
ExitCode: 1,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Agent Update",
|
||||
"subsystem": "agent",
|
||||
"target_version": att.ToVersion,
|
||||
"running_version": version.Version,
|
||||
"attested_at": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
if err := ReportLogWithAck(apiClient, cfg, ackTracker, report); err != nil {
|
||||
// 409 = the command is already finalized server-side (timeout sweep or an
|
||||
// earlier report won the race) — the marker has nothing left to say.
|
||||
if strings.Contains(err.Error(), "409") {
|
||||
log.Printf("[INFO] [agent] [upgrade] attestation_already_finalized command_id=%s", att.CommandID)
|
||||
ClearUpgradeAttestation()
|
||||
return
|
||||
}
|
||||
if expired {
|
||||
log.Printf("[ERROR] [agent] [upgrade] attestation_report_failed error=%v — marker expired (>%v), dropping", err, attestationMaxAge)
|
||||
ClearUpgradeAttestation()
|
||||
return
|
||||
}
|
||||
log.Printf("[ERROR] [agent] [upgrade] attestation_report_failed error=%v — marker retained for retry on next start", err)
|
||||
return
|
||||
}
|
||||
ClearUpgradeAttestation()
|
||||
}
|
||||
|
||||
// versionAtLeast reports whether running >= target, comparing dotted numeric
|
||||
// versions (leading "v" tolerated). Mirrors the server's IsNewerOrEqualVersion
|
||||
// check-in confirm: a running version past the target still proves the swap
|
||||
// took. Non-numeric segments fall back to string comparison.
|
||||
func versionAtLeast(running, target string) bool {
|
||||
r := strings.Split(strings.TrimPrefix(running, "v"), ".")
|
||||
t := strings.Split(strings.TrimPrefix(target, "v"), ".")
|
||||
for i := 0; i < len(r) || i < len(t); i++ {
|
||||
var rs, ts string
|
||||
if i < len(r) {
|
||||
rs = r[i]
|
||||
}
|
||||
if i < len(t) {
|
||||
ts = t[i]
|
||||
}
|
||||
rn, rErr := strconv.Atoi(rs)
|
||||
tn, tErr := strconv.Atoi(ts)
|
||||
if rErr != nil || tErr != nil {
|
||||
if rs == ts {
|
||||
continue
|
||||
}
|
||||
return rs > ts
|
||||
}
|
||||
if rn != tn {
|
||||
return rn > tn
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
26
agent/internal/handlers/upgrade_attestation_test.go
Normal file
26
agent/internal/handlers/upgrade_attestation_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVersionAtLeast(t *testing.T) {
|
||||
cases := []struct {
|
||||
running, target string
|
||||
want bool
|
||||
}{
|
||||
{"0.2.7.0", "0.2.7.0", true},
|
||||
{"v0.2.7.0", "0.2.7.0", true},
|
||||
{"0.2.7.0", "v0.2.7.0", true},
|
||||
{"0.2.7.1", "0.2.7.0", true}, // past the target still proves the swap
|
||||
{"0.2.8.0", "0.2.7.9", true},
|
||||
{"0.2.7.0", "0.2.7.1", false}, // rolled back / swap failed
|
||||
{"0.2.6.9", "0.2.7.0", false},
|
||||
{"0.2.10.0", "0.2.9.0", true}, // numeric, not lexicographic
|
||||
{"0.2.7", "0.2.7.0", false}, // shorter = missing segment treated as lower
|
||||
{"0.2.7.0", "0.2.7", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := versionAtLeast(c.running, c.target); got != c.want {
|
||||
t.Errorf("versionAtLeast(%q, %q) = %v, want %v", c.running, c.target, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
119
agent/internal/handlers/upgrade_healthcheck.go
Normal file
119
agent/internal/handlers/upgrade_healthcheck.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
)
|
||||
|
||||
// RunPostUpgradeHealthcheck checks the agent's environment against what this
|
||||
// version expects. Called once at startup after RunUpgradeAttestation, before
|
||||
// the polling loop. All findings are warnings — the agent continues regardless.
|
||||
// Findings are logged and a non-zero return indicates gaps found.
|
||||
func RunPostUpgradeHealthcheck(cfg *config.Config) int {
|
||||
gaps := 0
|
||||
|
||||
// --- Config key completeness ---
|
||||
// The install template's default JSON is the source of truth for freshly
|
||||
// installed agents. Upgraded agents carry over their old config verbatim,
|
||||
// so new keys added this release may be absent. The agent's merge logic
|
||||
// (loadFromFile → getDefaultConfig chain) fills zero values on access, but
|
||||
// visible gaps help the operator understand why defaults are shipping.
|
||||
check := func(present bool, format string, args ...interface{}) {
|
||||
if !present {
|
||||
log.Printf("[WARN] [agent] [healthcheck] "+format, args...)
|
||||
gaps++
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level keys the template should have set. The agent.Config struct
|
||||
// with json tags is the canonical list — these are the non-omitempty keys
|
||||
// the template writes for fresh installs. We check the config struct
|
||||
// was populated (not zero-valued per the template JSON) and not missing.
|
||||
check(cfg.ServerURL != "", "config key 'server_url' is empty — should be set by installer")
|
||||
check(cfg.CheckInInterval > 0, "config key 'check_in_interval' is zero — should be 300 from template")
|
||||
|
||||
// PollingConfig: this version added these keys; warn if empty.
|
||||
check(cfg.Polling.JitterMaxSeconds > 0 || cfg.Polling.BackoffBaseSeconds > 0,
|
||||
"config key 'polling' missing — polling resilience tuning will use defaults")
|
||||
// CommandSigning: recommended on.
|
||||
check(cfg.CommandSigning.Enabled,
|
||||
"config key 'command_signing.enabled' is false — recommend true")
|
||||
|
||||
// DesktopConfig: warn about missing keys if enabled.
|
||||
if cfg.Desktop.Enabled {
|
||||
check(cfg.Desktop.MaxRestarts > 0,
|
||||
"config key 'desktop.max_restarts' is zero — agent will not auto-restart the desktop app on crash")
|
||||
}
|
||||
|
||||
// --- Binary presence ---
|
||||
// The helper binary (redflag-helper) should exist when command signing is on.
|
||||
if cfg.CommandSigning.Enabled {
|
||||
helperPath := filepath.Join(filepath.Dir(os.Args[0]), "redflag-helper")
|
||||
if runtime.GOOS == "windows" {
|
||||
helperPath += ".exe"
|
||||
}
|
||||
check(fileExists(helperPath),
|
||||
"helper binary not found at %s — capability-gated installs will fail", helperPath)
|
||||
}
|
||||
|
||||
// Desktop binary should exist when desktop is enabled.
|
||||
if cfg.Desktop.Enabled {
|
||||
desktopPath := filepath.Join(filepath.Dir(os.Args[0]), "redflag-desktop")
|
||||
if runtime.GOOS == "windows" {
|
||||
desktopPath += ".exe"
|
||||
}
|
||||
check(fileExists(desktopPath),
|
||||
"desktop binary not found at %s — local operations console will not appear", desktopPath)
|
||||
}
|
||||
|
||||
// --- Autostart entry (Linux only) ---
|
||||
if cfg.Desktop.Enabled && runtime.GOOS == "linux" {
|
||||
autostartPath := "/etc/xdg/autostart/redflag-desktop.desktop"
|
||||
check(fileExists(autostartPath),
|
||||
"desktop autostart entry not found at %s — RedFlag Desktop will not start on next login", autostartPath)
|
||||
}
|
||||
|
||||
// --- Socket directory permissions ---
|
||||
// The local API socket lives under GetAgentStateDir()'s parent with 0710.
|
||||
socketDir := filepath.Join(constants.GetAgentStateDir(), "..", "localapi")
|
||||
resolved, _ := filepath.EvalSymlinks(socketDir)
|
||||
if resolved == "" {
|
||||
resolved = socketDir
|
||||
}
|
||||
if info, err := os.Stat(resolved); err == nil {
|
||||
perm := info.Mode().Perm()
|
||||
check(perm&0o010 != 0,
|
||||
"localapi socket dir %s has permissions %#o — the Desktop user cannot traverse to the socket (needs 0o10 group execute)", resolved, perm)
|
||||
} else {
|
||||
log.Printf("[WARN] [agent] [healthcheck] localapi socket dir %s not found — Desktop may not work: %v", resolved, err)
|
||||
gaps++
|
||||
}
|
||||
|
||||
if gaps > 0 {
|
||||
log.Printf("[WARN] [agent] [healthcheck] complete gaps=%d — re-run the install script or correct manually", gaps)
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [healthcheck] all_ok")
|
||||
}
|
||||
return gaps
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// ReportHealthcheckGapsAsEvents sends system event entries for each gap found.
|
||||
// Called when gaps > 0 after RunPostUpgradeHealthcheck.
|
||||
// TODO: wire into system event logger once that's available from the handler context.
|
||||
func ReportHealthcheckGapsAsEvents(gaps int, cfg *config.Config) {
|
||||
// Placeholder — gaps are logged above.
|
||||
// A future iteration will emit proper system events via the agent's
|
||||
// security logger or the server's event logging endpoint.
|
||||
_ = gaps
|
||||
_ = cfg
|
||||
}
|
||||
168
agent/internal/installer/apt.go
Normal file
168
agent/internal/installer/apt.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package installer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APTInstaller handles APT package installations
|
||||
type APTInstaller struct{}
|
||||
|
||||
// NewAPTInstaller creates a new APT installer
|
||||
func NewAPTInstaller() *APTInstaller {
|
||||
return &APTInstaller{}
|
||||
}
|
||||
|
||||
// IsAvailable checks if APT is available on this system
|
||||
func (i *APTInstaller) IsAvailable() bool {
|
||||
_, err := exec.LookPath("apt-get")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// DryRun performs a dry run installation to check dependencies
|
||||
func (i *APTInstaller) DryRun(packageName, version string) (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
runner, err := NewDiscoveryRunner("apt")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [installer] discovery_runner_create error=%w", err)
|
||||
}
|
||||
|
||||
_, updateErr := runner.Run(context.Background(), "update")
|
||||
if updateErr != nil {
|
||||
log.Printf("Warning: APT update failed (continuing with dry run): %v", updateErr)
|
||||
}
|
||||
|
||||
target := packageName
|
||||
if version != "" {
|
||||
target = packageName + "=" + version
|
||||
}
|
||||
|
||||
result, err := runner.Run(context.Background(), "install", "--dry-run", "--yes", target)
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
// nil guard
|
||||
if result == nil {
|
||||
result = &RunResult{}
|
||||
}
|
||||
deps := i.parseDependenciesFromAPTOutput(result.Stdout, packageName)
|
||||
|
||||
installResult := &InstallResult{
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if len(deps) > 0 {
|
||||
installResult.Success = true
|
||||
installResult.Dependencies = deps
|
||||
return installResult, nil
|
||||
}
|
||||
installResult.Success = false
|
||||
installResult.ErrorMessage = fmt.Sprintf("APT dry run failed: %v", err)
|
||||
return installResult, err
|
||||
}
|
||||
|
||||
installResult.Success = true
|
||||
installResult.Dependencies = deps
|
||||
return installResult, nil
|
||||
}
|
||||
|
||||
// parseDependenciesFromAPTOutput extracts dependency package names from APT dry run output
|
||||
func (i *APTInstaller) parseDependenciesFromAPTOutput(output string, packageName string) []string {
|
||||
var dependencies []string
|
||||
|
||||
// Regex patterns to find dependencies in APT output
|
||||
patterns := []*regexp.Regexp{
|
||||
// Match "The following additional packages will be installed:" section
|
||||
regexp.MustCompile(`(?s)The following additional packages will be installed:(.*?)(\n\n|\z)`),
|
||||
// Match "The following NEW packages will be installed:" section
|
||||
regexp.MustCompile(`(?s)The following NEW packages will be installed:(.*?)(\n\n|\z)`),
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
matches := pattern.FindStringSubmatch(output)
|
||||
if len(matches) > 1 {
|
||||
// Extract package names from the matched section
|
||||
packageLines := strings.Split(matches[1], "\n")
|
||||
for _, line := range packageLines {
|
||||
line = strings.TrimSpace(line)
|
||||
// Skip empty lines and section headers
|
||||
if line != "" && !strings.Contains(line, "will be installed") && !strings.Contains(line, "packages") {
|
||||
// Extract package names (they're typically space-separated)
|
||||
packages := strings.Fields(line)
|
||||
for _, pkg := range packages {
|
||||
pkg = strings.TrimSpace(pkg)
|
||||
// Filter out common non-package words
|
||||
if pkg != "" && !strings.Contains(pkg, "recommended") &&
|
||||
!strings.Contains(pkg, "suggested") && !strings.Contains(pkg, "following") {
|
||||
dependencies = append(dependencies, pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and filter out the original package
|
||||
uniqueDeps := make([]string, 0)
|
||||
seen := make(map[string]bool)
|
||||
for _, dep := range dependencies {
|
||||
if dep != packageName && !seen[dep] {
|
||||
seen[dep] = true
|
||||
uniqueDeps = append(uniqueDeps, dep)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueDeps
|
||||
}
|
||||
|
||||
// GetPackageType returns type of packages this installer handles
|
||||
func (i *APTInstaller) GetPackageType() string {
|
||||
return "apt"
|
||||
}
|
||||
|
||||
// getExitCode extracts exit code from exec error
|
||||
func getExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
return exitError.ExitCode()
|
||||
}
|
||||
|
||||
return 1 // Default error code
|
||||
}
|
||||
|
||||
// VerifyHash checks that the package's current candidate .deb hash matches the
|
||||
// pinned expected value. The hash is read from the signed apt index (the same
|
||||
// SHA256 apt itself verifies the download against), so this catches an artifact
|
||||
// swapped under a fixed version since pinning. apt's own GPG verification of the
|
||||
// index and package remains the integrity layer at install.
|
||||
func (i *APTInstaller) VerifyHash(packageName, version, expectedSHA256 string) error {
|
||||
if expectedSHA256 == "" {
|
||||
return fmt.Errorf("no expected hash registered for package %q — hash verification is mandatory for capability-gated installs", packageName)
|
||||
}
|
||||
|
||||
resolved, err := ResolveArtifactSHA256("apt", packageName, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve package artifact: %w", err)
|
||||
}
|
||||
|
||||
if !strings.EqualFold(resolved.SHA256, expectedSHA256) {
|
||||
return fmt.Errorf("package hash mismatch: expected %s, got %s", expectedSHA256, resolved.SHA256)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [installer] hash_verified package=%s", packageName)
|
||||
return nil
|
||||
}
|
||||
251
agent/internal/installer/artifact_hash.go
Normal file
251
agent/internal/installer/artifact_hash.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
package installer
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolvedArtifact is one package resolved to the exact artifact a pinned install
|
||||
// would fetch, with the SHA256 the package manager verifies against its GPG-signed
|
||||
// repository metadata. It is the unit the server pins and the capability token binds.
|
||||
type ResolvedArtifact struct {
|
||||
Name string // package name as the package manager records it
|
||||
Version string // exact version (dnf: EVR; apt: Version field)
|
||||
SHA256 string // lowercase hex; canonical artifact hash from signed metadata
|
||||
}
|
||||
|
||||
// packageNamePattern bounds package identifiers to the characters real dnf/apt
|
||||
// names use. Resolution shells out (no shell, argv only — so this is defense in
|
||||
// depth, not the sole guard) and the name originates in a server command, so we
|
||||
// refuse anything that isn't a plausible package reference rather than pass it on.
|
||||
var packageNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+:~-]*$`)
|
||||
|
||||
func validatePackageName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("empty package name")
|
||||
}
|
||||
if len(name) > 256 {
|
||||
return fmt.Errorf("package name too long")
|
||||
}
|
||||
if !packageNamePattern.MatchString(name) {
|
||||
return fmt.Errorf("package name contains disallowed characters: %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveArtifactSHA256 resolves the canonical artifact hash for a package on the
|
||||
// given package manager. When version is non-empty, resolution is for that exact
|
||||
// target version, not the current candidate. It is the agent-side hash source for
|
||||
// the registry: the server cannot reach an agent's repos, so the agent reads the
|
||||
// hash its own signed metadata anchors. Returns an error (never a wrong hash)
|
||||
// when resolution is not possible for the package type or the package cannot be
|
||||
// resolved.
|
||||
func ResolveArtifactSHA256(packageType, packageName, version string) (*ResolvedArtifact, error) {
|
||||
if err := validatePackageName(packageName); err != nil {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_invalid_name type=%s error=%w", packageType, err)
|
||||
}
|
||||
if version != "" {
|
||||
if err := validatePackageName(version); err != nil {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_invalid_version type=%s error=%w", packageType, err)
|
||||
}
|
||||
}
|
||||
|
||||
switch packageType {
|
||||
case "dnf":
|
||||
return resolveDNFArtifact(packageName, version)
|
||||
case "apt":
|
||||
return resolveAPTArtifact(packageName, version)
|
||||
default:
|
||||
return nil, fmt.Errorf("artifact hash resolution not supported for package type %q", packageType)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDNFArtifact downloads the package to a private temp dir with `dnf
|
||||
// download` (which verifies the artifact against the signed primary.xml during
|
||||
// download), reads its exact NEVRA via rpm, and hashes the file. The download is
|
||||
// unprivileged and the temp dir is removed afterward.
|
||||
func resolveDNFArtifact(packageName, version string) (*ResolvedArtifact, error) {
|
||||
tmpDir, err := os.MkdirTemp("", "redflag-hash-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_tmpdir error=%w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
target := packageName
|
||||
if version != "" {
|
||||
target = packageName + "-" + version
|
||||
}
|
||||
out, err := exec.Command("dnf", "download", "--destdir", tmpDir, target).CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_dnf_download pkg=%s error=%w output=%s",
|
||||
target, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
rpmPath, err := singleRPMInDir(tmpDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_dnf_artifact pkg=%s error=%w", packageName, err)
|
||||
}
|
||||
|
||||
sum, err := fileSHA256(rpmPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_dnf_hash pkg=%s error=%w", packageName, err)
|
||||
}
|
||||
|
||||
name, version := rpmNameVersion(rpmPath, packageName)
|
||||
return &ResolvedArtifact{Name: name, Version: version, SHA256: sum}, nil
|
||||
}
|
||||
|
||||
// singleRPMInDir returns the path of the one binary .rpm a `dnf download` of a
|
||||
// single named package produces. Source rpms are dropped first: dnf5 pulls the
|
||||
// matching .src.rpm alongside the binary when the source lives in an enabled repo
|
||||
// (COPR repos commonly do), but the source is never an install artifact, so it
|
||||
// must not count toward ambiguity. More than one *binary* rpm remaining means the
|
||||
// request was genuinely ambiguous and we refuse rather than guess the pinned one.
|
||||
func singleRPMInDir(dir string) (string, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "*.rpm"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
binaries := matches[:0]
|
||||
for _, m := range matches {
|
||||
if strings.HasSuffix(m, ".src.rpm") {
|
||||
continue
|
||||
}
|
||||
binaries = append(binaries, m)
|
||||
}
|
||||
switch len(binaries) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("no binary rpm downloaded")
|
||||
case 1:
|
||||
return binaries[0], nil
|
||||
default:
|
||||
return "", fmt.Errorf("ambiguous download: %d binary rpms produced", len(binaries))
|
||||
}
|
||||
}
|
||||
|
||||
// rpmNameVersion reads the exact NAME and EVR from a downloaded rpm header.
|
||||
// It formats EVR the same way dnf check-update does: epoch is included only when
|
||||
// non-zero. Falls back to the requested name and an empty version if rpm is
|
||||
// unavailable — the hash is still authoritative, only the recorded version label
|
||||
// degrades.
|
||||
func rpmNameVersion(rpmPath, requestedName string) (name, version string) {
|
||||
out, err := exec.Command("rpm", "-qp", "--nosignature", "--queryformat", "%{NAME}|%{EPOCHNUM}|%{VERSION}|%{RELEASE}", rpmPath).Output()
|
||||
if err != nil {
|
||||
return requestedName, ""
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(string(out)), "|")
|
||||
if len(parts) != 4 || parts[0] == "" {
|
||||
return requestedName, ""
|
||||
}
|
||||
return parts[0], formatRPMVersion(parts[1], parts[2], parts[3])
|
||||
}
|
||||
|
||||
func formatRPMVersion(epoch, version, release string) string {
|
||||
epoch = strings.TrimSpace(epoch)
|
||||
version = strings.TrimSpace(version)
|
||||
release = strings.TrimSpace(release)
|
||||
if version == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
evr := version
|
||||
if release != "" {
|
||||
evr += "-" + release
|
||||
}
|
||||
if epoch != "" && epoch != "0" && epoch != "(none)" {
|
||||
evr = epoch + ":" + evr
|
||||
}
|
||||
return evr
|
||||
}
|
||||
|
||||
// resolveAPTArtifact reads the target version's .deb SHA256 straight from the
|
||||
// signed apt index via apt-cache. With no explicit version, it uses the current
|
||||
// candidate. No download is needed: apt already exposes the hash the
|
||||
// Release/Packages index commits to.
|
||||
func resolveAPTArtifact(packageName, version string) (*ResolvedArtifact, error) {
|
||||
candidate := version
|
||||
if candidate == "" {
|
||||
var err error
|
||||
candidate, err = aptCandidateVersion(packageName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
out, err := exec.Command("apt-cache", "show", packageName+"="+candidate).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_apt_show pkg=%s version=%s error=%w",
|
||||
packageName, candidate, err)
|
||||
}
|
||||
|
||||
sum := aptFieldFromStanza(string(out), "SHA256:")
|
||||
if sum == "" {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_apt_no_sha256 pkg=%s version=%s", packageName, candidate)
|
||||
}
|
||||
return &ResolvedArtifact{Name: packageName, Version: candidate, SHA256: strings.ToLower(sum)}, nil
|
||||
}
|
||||
|
||||
// aptCandidateVersion returns the version apt would install for the package — the
|
||||
// "Candidate:" line of apt-cache policy. That is the exact version a pinned
|
||||
// install targets.
|
||||
func aptCandidateVersion(packageName string) (string, error) {
|
||||
out, err := exec.Command("apt-cache", "policy", packageName).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_apt_policy pkg=%s error=%w", packageName, err)
|
||||
}
|
||||
v := parseAptCandidate(string(out))
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_apt_no_candidate pkg=%s", packageName)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// parseAptCandidate extracts the installable version from `apt-cache policy`
|
||||
// output ("Candidate:" line). Returns "" when absent or "(none)".
|
||||
func parseAptCandidate(output string) string {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Candidate:") {
|
||||
v := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
|
||||
if v == "(none)" {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// aptFieldFromStanza returns the value of the first matching field prefix in an
|
||||
// apt-cache show stanza (e.g. "SHA256:").
|
||||
func aptFieldFromStanza(output, prefix string) string {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fileSHA256 returns the lowercase hex SHA256 of a file, streaming.
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
134
agent/internal/installer/artifact_hash_test.go
Normal file
134
agent/internal/installer/artifact_hash_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package installer
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidatePackageName(t *testing.T) {
|
||||
valid := []string{
|
||||
"bash",
|
||||
"kernel-core",
|
||||
"glibc-common",
|
||||
"python3.12",
|
||||
"lib64gcc-s1",
|
||||
"foo+bar",
|
||||
"3:firefox", // epoch-prefixed reference
|
||||
"gcc~snapshot",
|
||||
}
|
||||
for _, name := range valid {
|
||||
if err := validatePackageName(name); err != nil {
|
||||
t.Errorf("validatePackageName(%q) = %v, want nil", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
"-leadingdash",
|
||||
"semi;colon",
|
||||
"pipe|inject",
|
||||
"space here",
|
||||
"back`tick",
|
||||
"new\nline",
|
||||
"$(cmd)",
|
||||
}
|
||||
for _, name := range invalid {
|
||||
if err := validatePackageName(name); err == nil {
|
||||
t.Errorf("validatePackageName(%q) = nil, want error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRPMVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
epoch string
|
||||
version string
|
||||
release string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no epoch",
|
||||
epoch: "0",
|
||||
version: "3.4.8",
|
||||
release: "1.fc43",
|
||||
want: "3.4.8-1.fc43",
|
||||
},
|
||||
{
|
||||
name: "epoch",
|
||||
epoch: "3",
|
||||
version: "29.5.2",
|
||||
release: "1.fc43",
|
||||
want: "3:29.5.2-1.fc43",
|
||||
},
|
||||
{
|
||||
name: "none epoch",
|
||||
epoch: "(none)",
|
||||
version: "8.15.0",
|
||||
release: "7.fc43",
|
||||
want: "8.15.0-7.fc43",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := formatRPMVersion(tt.epoch, tt.version, tt.release); got != tt.want {
|
||||
t.Fatalf("formatRPMVersion(%q, %q, %q) = %q, want %q",
|
||||
tt.epoch, tt.version, tt.release, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAptCandidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
output string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal candidate",
|
||||
output: `nginx:
|
||||
Installed: 1.18.0-6ubuntu14.4
|
||||
Candidate: 1.18.0-6ubuntu14.5
|
||||
Version table:
|
||||
1.18.0-6ubuntu14.5 500
|
||||
500 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 Packages`,
|
||||
want: "1.18.0-6ubuntu14.5",
|
||||
},
|
||||
{
|
||||
name: "no candidate",
|
||||
output: `bogus:
|
||||
Installed: (none)
|
||||
Candidate: (none)
|
||||
Version table:`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "missing line",
|
||||
output: "some unrelated output\n",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := parseAptCandidate(tc.output); got != tc.want {
|
||||
t.Errorf("parseAptCandidate() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAptFieldFromStanza(t *testing.T) {
|
||||
stanza := `Package: nginx
|
||||
Version: 1.18.0-6ubuntu14.5
|
||||
Architecture: amd64
|
||||
SHA256: 9f7c1e2b3a4d5e6f7081920a1b2c3d4e5f60718293a4b5c6d7e8f90112233445
|
||||
Filename: pool/main/n/nginx/nginx_1.18.0-6ubuntu14.5_amd64.deb`
|
||||
|
||||
if got := aptFieldFromStanza(stanza, "SHA256:"); got != "9f7c1e2b3a4d5e6f7081920a1b2c3d4e5f60718293a4b5c6d7e8f90112233445" {
|
||||
t.Errorf("aptFieldFromStanza(SHA256) = %q", got)
|
||||
}
|
||||
if got := aptFieldFromStanza(stanza, "Version:"); got != "1.18.0-6ubuntu14.5" {
|
||||
t.Errorf("aptFieldFromStanza(Version) = %q", got)
|
||||
}
|
||||
if got := aptFieldFromStanza(stanza, "MD5sum:"); got != "" {
|
||||
t.Errorf("aptFieldFromStanza(missing) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
176
agent/internal/installer/discovery.go
Normal file
176
agent/internal/installer/discovery.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// discovery.go — single chokepoint for all read-only package-manager operations.
|
||||
// Every scan, dry-run, and hash-resolve goes through here. Discovery runs
|
||||
// in the agent's sandbox; the runner owns sandbox compatibility (temp logdir
|
||||
// for dnf, etc.) per ecosystem.
|
||||
package installer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EcosystemConfig describes one package manager's discovery behaviour.
|
||||
type EcosystemConfig struct {
|
||||
Binary string
|
||||
SudoForDiscovery bool
|
||||
// SandboxOpts returns extra args to insert before the subcommand
|
||||
// so the package manager can write incidental logs/cache inside
|
||||
// a ProtectSystem=strict systemd unit. tmpDir is a private temp
|
||||
// dir created by the runner; it is cleaned up after the command.
|
||||
SandboxOpts func(tmpDir string) []string
|
||||
}
|
||||
|
||||
// ecosystemRegistry is the canonical set of known ecosystems.
|
||||
// Adding a new ecosystem (AUR, Snap, Flatpak, Homebrew) means one entry here.
|
||||
var ecosystemRegistry = map[string]EcosystemConfig{
|
||||
"dnf": {
|
||||
Binary: "dnf",
|
||||
// Discovery is unprivileged. SandboxOpts redirect dnf's log and cache
|
||||
// into an agent-writable temp dir, so check-update / makecache /
|
||||
// install --downloadonly / download all run without root. Mutation is
|
||||
// the helper's job (root via systemd-run); the agent holds no dnf sudo.
|
||||
SudoForDiscovery: false,
|
||||
SandboxOpts: func(tmpDir string) []string {
|
||||
return []string{
|
||||
"--setopt=logdir=" + tmpDir,
|
||||
"--setopt=cachedir=" + tmpDir,
|
||||
}
|
||||
},
|
||||
},
|
||||
"apt": {
|
||||
Binary: "apt",
|
||||
// Discovery is unprivileged, same model as dnf. SandboxOpts redirect apt's
|
||||
// lists/cache/state/log into an agent-writable temp dir so `apt update`,
|
||||
// `apt list --upgradable`, and `apt install --dry-run` run without root.
|
||||
// Mutation is the helper's job; the agent holds no apt sudo. Cost: each
|
||||
// scan re-fetches repo metadata into the ephemeral dir (perf, not
|
||||
// correctness), mirroring dnf's tradeoff.
|
||||
SudoForDiscovery: false,
|
||||
SandboxOpts: func(tmpDir string) []string {
|
||||
return []string{
|
||||
"-o", "Dir::State::Lists=" + tmpDir + "/lists",
|
||||
"-o", "Dir::Cache=" + tmpDir + "/cache",
|
||||
"-o", "Dir::State=" + tmpDir + "/state",
|
||||
"-o", "Dir::Log=" + tmpDir,
|
||||
}
|
||||
},
|
||||
},
|
||||
"docker": {
|
||||
Binary: "docker",
|
||||
SudoForDiscovery: false,
|
||||
},
|
||||
"pacman": {
|
||||
Binary: "checkupdates",
|
||||
SudoForDiscovery: false,
|
||||
// checkupdates handles its own sandboxing internally — it syncs a
|
||||
// private copy of the repo databases into a temp dir and runs
|
||||
// pacman -Qu against it. No root required, no mutation of the live
|
||||
// pacman database. Discovery is purely read-only.
|
||||
},
|
||||
"winget": {
|
||||
Binary: "winget",
|
||||
SudoForDiscovery: false,
|
||||
},
|
||||
"windows_update": {
|
||||
Binary: "powershell",
|
||||
SudoForDiscovery: false,
|
||||
},
|
||||
}
|
||||
|
||||
// ConfigFor returns the EcosystemConfig for a package type.
|
||||
func ConfigFor(packageType string) (EcosystemConfig, error) {
|
||||
cfg, ok := ecosystemRegistry[packageType]
|
||||
if !ok {
|
||||
return EcosystemConfig{}, fmt.Errorf("unknown ecosystem: %q", packageType)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// DiscoveryRunner executes read-only package-manager commands inside the
|
||||
// agent's sandbox.
|
||||
type DiscoveryRunner struct {
|
||||
cfg EcosystemConfig
|
||||
}
|
||||
|
||||
// NewDiscoveryRunner returns a runner for the given ecosystem.
|
||||
func NewDiscoveryRunner(packageType string) (*DiscoveryRunner, error) {
|
||||
cfg, err := ConfigFor(packageType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DiscoveryRunner{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// RunResult is the structured output of a discovery command.
|
||||
type RunResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// Run executes a discovery command. args is the subcommand and its arguments.
|
||||
// SandboxOpts are prepended if set; sudo is used if SudoForDiscovery is true.
|
||||
func (r *DiscoveryRunner) Run(ctx context.Context, args ...string) (*RunResult, error) {
|
||||
start := time.Now()
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [discovery] no_args ecosystem=%s", r.cfg.Binary)
|
||||
}
|
||||
|
||||
var tmpDir string
|
||||
var fullArgs []string
|
||||
|
||||
if r.cfg.SandboxOpts != nil {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "redflag-discovery-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [discovery] tmpdir_create ecosystem=%s error=%w", r.cfg.Binary, err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
fullArgs = append(fullArgs, r.cfg.SandboxOpts(tmpDir)...)
|
||||
}
|
||||
fullArgs = append(fullArgs, args...)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if r.cfg.SudoForDiscovery {
|
||||
fullPath, err := exec.LookPath(r.cfg.Binary)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [discovery] binary_not_found ecosystem=%s binary=%s error=%w",
|
||||
r.cfg.Binary, r.cfg.Binary, err)
|
||||
}
|
||||
sudoArgs := append([]string{fullPath}, fullArgs...)
|
||||
cmd = exec.CommandContext(ctx, "sudo", sudoArgs...)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, r.cfg.Binary, fullArgs...)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
runErr := cmd.Run()
|
||||
dur := time.Since(start)
|
||||
|
||||
result := &RunResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
Duration: dur,
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
if exitErr, ok := runErr.(*exec.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
result.ExitCode = -1
|
||||
}
|
||||
return result, fmt.Errorf("[ERROR] [agent] [discovery] command_failed ecosystem=%s args=%v exit=%d stderr=%s error=%w",
|
||||
r.cfg.Binary, args, result.ExitCode, strings.TrimSpace(result.Stderr), runErr)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
67
agent/internal/installer/discovery_test.go
Normal file
67
agent/internal/installer/discovery_test.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package installer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigForKnownEcosystems(t *testing.T) {
|
||||
for _, eco := range []string{"dnf", "apt", "docker", "winget", "windows_update"} {
|
||||
cfg, err := ConfigFor(eco)
|
||||
if err != nil {
|
||||
t.Errorf("ConfigFor(%q): unexpected error: %v", eco, err)
|
||||
}
|
||||
if cfg.Binary == "" {
|
||||
t.Errorf("ConfigFor(%q): empty Binary", eco)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigForUnknownEcosystem(t *testing.T) {
|
||||
_, err := ConfigFor("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("ConfigFor(nonexistent): expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNFConfigSandboxOpts(t *testing.T) {
|
||||
cfg, _ := ConfigFor("dnf")
|
||||
if cfg.SandboxOpts == nil {
|
||||
t.Fatal("dnf must have SandboxOpts")
|
||||
}
|
||||
opts := cfg.SandboxOpts("/tmp/test")
|
||||
if len(opts) != 2 {
|
||||
t.Fatalf("expected 2 opts, got %d: %v", len(opts), opts)
|
||||
}
|
||||
if !strings.HasPrefix(opts[0], "--setopt=logdir=") {
|
||||
t.Errorf("expected --setopt=logdir=..., got %s", opts[0])
|
||||
}
|
||||
if !strings.HasPrefix(opts[1], "--setopt=cachedir=") {
|
||||
t.Errorf("expected --setopt=cachedir=..., got %s", opts[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPTConfigSandboxOpts(t *testing.T) {
|
||||
cfg, _ := ConfigFor("apt")
|
||||
if cfg.SandboxOpts == nil {
|
||||
t.Fatal("apt must have SandboxOpts")
|
||||
}
|
||||
opts := cfg.SandboxOpts("/tmp/test")
|
||||
// APT sandbox opts: -o Dir::State::Lists=... -o Dir::Cache=... etc.
|
||||
if len(opts) < 2 {
|
||||
t.Fatalf("expected at least 2 opts, got %d: %v", len(opts), opts)
|
||||
}
|
||||
if opts[0] != "-o" {
|
||||
t.Errorf("expected first opt to be -o, got %s", opts[0])
|
||||
}
|
||||
if !strings.HasPrefix(opts[1], "Dir::") {
|
||||
t.Errorf("expected Dir:: prefix, got %s", opts[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDiscoveryRunnerUnknown(t *testing.T) {
|
||||
_, err := NewDiscoveryRunner("auraura")
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown ecosystem")
|
||||
}
|
||||
}
|
||||
240
agent/internal/installer/dnf.go
Normal file
240
agent/internal/installer/dnf.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
package installer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HashVerifier checks package hashes against expected values
|
||||
type HashVerifier struct {
|
||||
serverURL string
|
||||
}
|
||||
|
||||
// NewHashVerifier creates a new hash verifier
|
||||
func NewHashVerifier(serverURL string) *HashVerifier {
|
||||
return &HashVerifier{
|
||||
serverURL: serverURL,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyPackageHash checks that the package's current canonical artifact hash
|
||||
// matches the pinned expected value. The hash is re-resolved locally from the
|
||||
// agent's signed repo metadata (the same source the server pinned), so this
|
||||
// catches an artifact swapped under a fixed version since pinning. dnf cannot be
|
||||
// fetched from the server (the server has no access to the agent's repos), so
|
||||
// verification is agent-local; RPM's own GPG check remains as a second layer at
|
||||
// install.
|
||||
func (h *HashVerifier) VerifyPackageHash(packageName, version, expectedSHA256 string) error {
|
||||
if expectedSHA256 == "" {
|
||||
return fmt.Errorf("no expected hash registered for package %q — hash verification is mandatory for capability-gated installs", packageName)
|
||||
}
|
||||
|
||||
resolved, err := ResolveArtifactSHA256("dnf", packageName, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve package artifact: %w", err)
|
||||
}
|
||||
|
||||
if !strings.EqualFold(resolved.SHA256, expectedSHA256) {
|
||||
return fmt.Errorf("package hash mismatch: expected %s, got %s", expectedSHA256, resolved.SHA256)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DNFInstaller handles DNF package installations
|
||||
type DNFInstaller struct {
|
||||
hashVerifier *HashVerifier
|
||||
expectedSHA256 string
|
||||
}
|
||||
|
||||
// NewDNFInstaller creates a new DNF installer with hash verification
|
||||
func NewDNFInstaller(serverURL string) *DNFInstaller {
|
||||
return &DNFInstaller{
|
||||
hashVerifier: NewHashVerifier(serverURL),
|
||||
expectedSHA256: "",
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable checks if DNF is available on this system
|
||||
func (i *DNFInstaller) IsAvailable() bool {
|
||||
_, err := exec.LookPath("dnf")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// DryRun performs a dry run installation to check dependencies
|
||||
func (i *DNFInstaller) DryRun(packageName, version string) (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
runner, err := NewDiscoveryRunner("dnf")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[ERROR] [agent] [installer] discovery_runner_create error=%w", err)
|
||||
}
|
||||
|
||||
// makecache — best effort, don't fail if it doesn't work
|
||||
if _, refreshErr := runner.Run(context.Background(), "makecache"); refreshErr != nil {
|
||||
log.Printf("Warning: DNF makecache failed (continuing with dry run): %v", refreshErr)
|
||||
}
|
||||
|
||||
// NEVRA: packageName-version resolves the specific upgrade, not "is it installed?"
|
||||
target := packageName
|
||||
if version != "" {
|
||||
target = packageName + "-" + version
|
||||
}
|
||||
|
||||
result, err := runner.Run(context.Background(), "install", "--assumeno", "--downloadonly", target)
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
if result == nil {
|
||||
result = &RunResult{}
|
||||
}
|
||||
deps := i.parseDependenciesFromDNFOutput(result.Stdout, packageName)
|
||||
|
||||
installResult := &InstallResult{
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: duration,
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}
|
||||
|
||||
// --assumeno cancels the transaction, so DNF exits non-zero on a dry run
|
||||
// that resolved successfully — a non-zero exit is not by itself a failure.
|
||||
// But a non-empty stdout is not success either: "No match for argument",
|
||||
// "Nothing to do", and "Error:" all print output and exit non-zero. Success
|
||||
// means DNF actually resolved a transaction that would change packages,
|
||||
// marked by a "Transaction Summary" block (which never coexists with
|
||||
// "Nothing to do"). Gate on that, whether or not there are extra deps.
|
||||
if dnfTransactionResolved(result.Stdout) {
|
||||
installResult.Success = true
|
||||
installResult.Dependencies = deps
|
||||
return installResult, nil
|
||||
}
|
||||
|
||||
installResult.Success = false
|
||||
if err != nil {
|
||||
installResult.ErrorMessage = fmt.Sprintf("DNF dry run did not resolve a transaction: %v", err)
|
||||
return installResult, err
|
||||
}
|
||||
installResult.ErrorMessage = "DNF dry run did not resolve an installable transaction"
|
||||
return installResult, fmt.Errorf("[ERROR] [agent] [installer] dnf_dry_run_no_transaction package=%s", target)
|
||||
}
|
||||
|
||||
// dnfTransactionResolved reports whether DNF dry-run output describes a
|
||||
// transaction that would actually change packages. "Transaction Summary" is
|
||||
// printed only when at least one package will be installed/upgraded/removed and
|
||||
// never appears alongside "Nothing to do", so it is the reliable success signal
|
||||
// — distinct from "got output," which failed resolutions also produce.
|
||||
func dnfTransactionResolved(output string) bool {
|
||||
if strings.Contains(output, "Nothing to do") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(output, "Transaction Summary")
|
||||
}
|
||||
|
||||
// parseDependenciesFromDNFOutput extracts dependency package names from DNF dry run output
|
||||
func (i *DNFInstaller) parseDependenciesFromDNFOutput(output string, packageName string) []string {
|
||||
var dependencies []string
|
||||
|
||||
// Regex patterns to find dependencies in DNF output
|
||||
patterns := []*regexp.Regexp{
|
||||
// Match "Installing dependencies:" section
|
||||
regexp.MustCompile(`(?s)Installing dependencies:(.*?)(\n\n|\z|Transaction Summary:)`),
|
||||
// Match "Dependencies resolved." section and package list
|
||||
regexp.MustCompile(`(?s)Dependencies resolved\.(.*?)(\n\n|\z|Transaction Summary:)`),
|
||||
// Match package installation lines
|
||||
regexp.MustCompile(`^\s*([a-zA-Z0-9][a-zA-Z0-9+._-]*)\s+[a-zA-Z0-9:.]+(?:\s+[a-zA-Z]+)?$`),
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if strings.Contains(pattern.String(), "Installing dependencies:") {
|
||||
matches := pattern.FindStringSubmatch(output)
|
||||
if len(matches) > 1 {
|
||||
// Extract package names from the dependencies section
|
||||
lines := strings.Split(matches[1], "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.Contains(line, "Dependencies") {
|
||||
pkg := i.extractPackageNameFromDNFLine(line)
|
||||
if pkg != "" {
|
||||
dependencies = append(dependencies, pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also look for transaction summary which lists all packages to be installed
|
||||
transactionPattern := regexp.MustCompile(`(?s)Transaction Summary:\s*\n\s*Install\s+(\d+) Packages?\s*\n((?:\s+\d+\s+[a-zA-Z0-9+._-]+\s+[a-zA-Z0-9:.]+.*\n?)*)`)
|
||||
matches := transactionPattern.FindStringSubmatch(output)
|
||||
if len(matches) > 2 {
|
||||
installLines := strings.Split(matches[2], "\n")
|
||||
for _, line := range installLines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
pkg := i.extractPackageNameFromDNFLine(line)
|
||||
if pkg != "" && pkg != packageName {
|
||||
dependencies = append(dependencies, pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
uniqueDeps := make([]string, 0)
|
||||
seen := make(map[string]bool)
|
||||
for _, dep := range dependencies {
|
||||
if dep != packageName && !seen[dep] {
|
||||
seen[dep] = true
|
||||
uniqueDeps = append(uniqueDeps, dep)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueDeps
|
||||
}
|
||||
|
||||
// extractPackageNameFromDNFLine extracts package name from a DNF output line
|
||||
func (i *DNFInstaller) extractPackageNameFromDNFLine(line string) string {
|
||||
// Remove architecture info if present
|
||||
if idx := strings.LastIndex(line, "."); idx > 0 {
|
||||
archSuffix := line[idx:]
|
||||
if strings.Contains(archSuffix, ".x86_64") || strings.Contains(archSuffix, ".noarch") ||
|
||||
strings.Contains(archSuffix, ".i386") || strings.Contains(archSuffix, ".arm64") {
|
||||
line = line[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
// Extract package name (typically at the start of the line)
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 {
|
||||
pkg := fields[0]
|
||||
// Remove version info if present
|
||||
if idx := strings.Index(pkg, "-"); idx > 0 {
|
||||
potentialName := pkg[:idx]
|
||||
// Check if this looks like a version (contains numbers)
|
||||
versionPart := pkg[idx+1:]
|
||||
if strings.Contains(versionPart, ".") || regexp.MustCompile(`\d`).MatchString(versionPart) {
|
||||
return potentialName
|
||||
}
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetPackageType returns type of packages this installer handles
|
||||
func (i *DNFInstaller) GetPackageType() string {
|
||||
return "dnf"
|
||||
}
|
||||
|
||||
// VerifyHash verifies the package hash before installation
|
||||
func (i *DNFInstaller) VerifyHash(packageName, version, expectedSHA256 string) error {
|
||||
return i.hashVerifier.VerifyPackageHash(packageName, version, expectedSHA256)
|
||||
}
|
||||
205
agent/internal/installer/docker.go
Normal file
205
agent/internal/installer/docker.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DockerInstaller handles Docker image updates
|
||||
type DockerInstaller struct{}
|
||||
|
||||
// NewDockerInstaller creates a new Docker installer
|
||||
func NewDockerInstaller() (*DockerInstaller, error) {
|
||||
// Check if docker is available first
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
return nil, fmt.Errorf("docker not found")
|
||||
}
|
||||
|
||||
return &DockerInstaller{}, nil
|
||||
}
|
||||
|
||||
// IsAvailable checks if Docker is available on this system
|
||||
func (i *DockerInstaller) IsAvailable() bool {
|
||||
_, err := exec.LookPath("docker")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Update pulls a new image using docker CLI
|
||||
func (i *DockerInstaller) Update(imageName, targetVersion string) (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Pull the new image
|
||||
fmt.Printf("Pulling Docker image: %s...\n", imageName)
|
||||
pullCmd := exec.Command("docker", "pull", imageName)
|
||||
output, err := pullCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Failed to pull Docker image: %v\nStdout: %s", err, string(output)),
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: getExitCode(err),
|
||||
DurationSeconds: int(time.Since(startTime).Seconds()),
|
||||
Action: "pull",
|
||||
}, fmt.Errorf("docker pull failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully pulled image: %s\n", string(output))
|
||||
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: string(output),
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
DurationSeconds: duration,
|
||||
Action: "pull",
|
||||
ContainersUpdated: []string{}, // Would find and recreate containers in a real implementation
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdatePackage updates a specific Docker image (alias for Update method)
|
||||
func (i *DockerInstaller) UpdatePackage(imageName string) (*InstallResult, error) {
|
||||
// Docker uses same logic for updating as installing
|
||||
return i.Update(imageName, "")
|
||||
}
|
||||
|
||||
// Install installs a Docker image (alias for Update)
|
||||
func (i *DockerInstaller) Install(imageName string) (*InstallResult, error) {
|
||||
return i.Update(imageName, "")
|
||||
}
|
||||
|
||||
// InstallMultiple installs multiple Docker images
|
||||
func (i *DockerInstaller) InstallMultiple(imageNames []string) (*InstallResult, error) {
|
||||
if len(imageNames) == 0 {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: "No images specified for installation",
|
||||
}, fmt.Errorf("no images specified")
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
var allOutput strings.Builder
|
||||
var errors []string
|
||||
|
||||
for _, imageName := range imageNames {
|
||||
fmt.Printf("Pulling Docker image: %s...\n", imageName)
|
||||
pullCmd := exec.Command("docker", "pull", imageName)
|
||||
output, err := pullCmd.CombinedOutput()
|
||||
allOutput.WriteString(string(output))
|
||||
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Sprintf("Failed to pull %s: %v", imageName, err))
|
||||
} else {
|
||||
fmt.Printf("Successfully pulled image: %s\n", imageName)
|
||||
}
|
||||
}
|
||||
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
if len(errors) > 0 {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Docker pull errors: %v", strings.Join(errors, "; ")),
|
||||
Stdout: allOutput.String(),
|
||||
Stderr: "",
|
||||
ExitCode: 1,
|
||||
DurationSeconds: duration,
|
||||
Action: "pull_multiple",
|
||||
}, fmt.Errorf("docker pull failed for some images")
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: allOutput.String(),
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
DurationSeconds: duration,
|
||||
Action: "pull_multiple",
|
||||
ContainersUpdated: imageNames,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Upgrade is not applicable for Docker in the same way
|
||||
func (i *DockerInstaller) Upgrade() (*InstallResult, error) {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: "Docker upgrade not implemented - use specific image updates",
|
||||
ExitCode: 1,
|
||||
DurationSeconds: 0,
|
||||
Action: "upgrade",
|
||||
}, fmt.Errorf("docker upgrade not implemented")
|
||||
}
|
||||
|
||||
// DryRun for Docker images checks if the image can be pulled without actually pulling it
|
||||
func (i *DockerInstaller) DryRun(imageName, version string) (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Check if image exists locally
|
||||
inspectCmd := exec.Command("docker", "image", "inspect", imageName)
|
||||
output, err := inspectCmd.CombinedOutput()
|
||||
|
||||
if err == nil {
|
||||
// Image exists locally
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: fmt.Sprintf("Docker image %s is already available locally", imageName),
|
||||
Stderr: string(output),
|
||||
ExitCode: 0,
|
||||
DurationSeconds: duration,
|
||||
Dependencies: []string{}, // Docker doesn't have traditional dependencies
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Image doesn't exist locally, check if it exists in registry
|
||||
// Use docker manifest command to check remote availability
|
||||
manifestCmd := exec.Command("docker", "manifest", "inspect", imageName)
|
||||
manifestOutput, manifestErr := manifestCmd.CombinedOutput()
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
if manifestErr != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Docker image %s not found locally or in remote registry", imageName),
|
||||
Stdout: string(output),
|
||||
Stderr: string(manifestOutput),
|
||||
ExitCode: getExitCode(manifestErr),
|
||||
DurationSeconds: duration,
|
||||
Dependencies: []string{},
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}, fmt.Errorf("docker image not found")
|
||||
}
|
||||
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Stdout: fmt.Sprintf("Docker image %s is available for download", imageName),
|
||||
Stderr: string(manifestOutput),
|
||||
ExitCode: 0,
|
||||
DurationSeconds: duration,
|
||||
Dependencies: []string{}, // Docker doesn't have traditional dependencies
|
||||
IsDryRun: true,
|
||||
Action: "dry_run",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPackageType returns type of packages this installer handles
|
||||
func (i *DockerInstaller) GetPackageType() string {
|
||||
return "docker_image"
|
||||
}
|
||||
|
||||
// VerifyHash verifies the Docker image hash before pulling
|
||||
func (i *DockerInstaller) VerifyHash(imageName, version, expectedSHA256 string) error {
|
||||
if expectedSHA256 == "" {
|
||||
return fmt.Errorf("no expected hash registered for image %q — hash verification is mandatory for capability-gated installs", imageName)
|
||||
}
|
||||
|
||||
// TODO: Docker images should use registry digest verification (Docker Content Trust).
|
||||
// Until implemented, fail closed — returning an error blocks the install.
|
||||
return fmt.Errorf("hash verification not implemented for docker image %q — expected=%s", imageName, expectedSHA256)
|
||||
}
|
||||
51
agent/internal/installer/installer.go
Normal file
51
agent/internal/installer/installer.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package installer
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Installer is the agent-side interface for package ecosystems.
|
||||
// Discovery methods only. Mutation (install, upgrade) goes through
|
||||
// the capability-token path (supplychain.Consumer → redflag-helper),
|
||||
// not through this interface.
|
||||
type Installer interface {
|
||||
IsAvailable() bool
|
||||
GetPackageType() string
|
||||
DryRun(packageName, version string) (*InstallResult, error)
|
||||
VerifyHash(packageName, version, expectedSHA256 string) error
|
||||
}
|
||||
|
||||
// NonGatedInstaller is the set of ecosystems still permitted to mutate
|
||||
// directly from the agent (winget, docker, windows_update). dnf/apt
|
||||
// deliberately do NOT implement these methods — their mutation flows only
|
||||
// through the capability-token path (supplychain.Consumer → redflag-helper).
|
||||
//
|
||||
// This interface is the ledger of "what may still bypass the gate." As
|
||||
// ecosystems move behind the capability gate, drop them from this set and
|
||||
// the type assertions in the handlers begin failing for them automatically —
|
||||
// the boundary is structural, not a guard someone has to remember to add.
|
||||
type NonGatedInstaller interface {
|
||||
UpdatePackage(packageName string) (*InstallResult, error)
|
||||
Upgrade() (*InstallResult, error)
|
||||
InstallMultiple(packageNames []string) (*InstallResult, error)
|
||||
}
|
||||
|
||||
// InstallerFactory creates appropriate installer based on package type
|
||||
func InstallerFactory(packageType string, serverURL string) (Installer, error) {
|
||||
switch packageType {
|
||||
case "apt":
|
||||
return NewAPTInstaller(), nil
|
||||
case "dnf":
|
||||
return NewDNFInstaller(serverURL), nil
|
||||
case "docker_image":
|
||||
installer, err := NewDockerInstaller()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker installer failed: %w", err)
|
||||
}
|
||||
return installer, nil
|
||||
case "windows_update":
|
||||
return NewWindowsUpdateInstaller(), nil
|
||||
case "winget":
|
||||
return NewWingetInstaller(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported package type: %s", packageType)
|
||||
}
|
||||
}
|
||||
21
agent/internal/installer/pacman_resolver.go
Normal file
21
agent/internal/installer/pacman_resolver.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package installer
|
||||
|
||||
// PacmanResolvedArtifact is one exact archive in the transaction pacman
|
||||
// resolved for a requested package. Paths remain valid until Cleanup is called.
|
||||
type PacmanResolvedArtifact struct {
|
||||
Name string
|
||||
Version string
|
||||
Repository string
|
||||
ArchivePath string
|
||||
ArchiveSHA256 string
|
||||
SignaturePath string
|
||||
SignatureSHA256 string
|
||||
}
|
||||
|
||||
// PacmanResolution owns the private sync database and cache backing a resolved
|
||||
// transaction. The caller keeps it alive through helper execution, then calls
|
||||
// Cleanup even when mint or execution refuses.
|
||||
type PacmanResolution struct {
|
||||
Artifacts []PacmanResolvedArtifact
|
||||
Cleanup func()
|
||||
}
|
||||
216
agent/internal/installer/pacman_resolver_linux.go
Normal file
216
agent/internal/installer/pacman_resolver_linux.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
//go:build linux
|
||||
|
||||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
)
|
||||
|
||||
const pacmanCommandPath = "/usr/bin/pacman"
|
||||
|
||||
var pacmanResolverEnv = []string{
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"LC_ALL=C",
|
||||
"LANG=C",
|
||||
}
|
||||
|
||||
func pacmanResolverCommand(name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Env = pacmanResolverEnv
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(string(output))
|
||||
if len(message) > 4096 {
|
||||
message = message[:4096]
|
||||
}
|
||||
return nil, fmt.Errorf("%s failed: %w: %s", filepath.Base(name), err, message)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func parsePacmanPrint(output string) (map[string]string, error) {
|
||||
repositories := make(map[string]string)
|
||||
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, "|")
|
||||
if len(fields) != 3 || fields[0] == "" || fields[1] == "" || fields[2] == "" {
|
||||
return nil, fmt.Errorf("pacman printed malformed closure identity %q", line)
|
||||
}
|
||||
repositories[fields[0]+"@"+fields[1]] = fields[2]
|
||||
}
|
||||
if len(repositories) == 0 {
|
||||
return nil, fmt.Errorf("pacman resolved an empty transaction")
|
||||
}
|
||||
return repositories, nil
|
||||
}
|
||||
|
||||
func inspectPacmanArchive(path string) (string, string, error) {
|
||||
output, err := pacmanResolverCommand(pacmanCommandPath, "-Qp", "--", path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
fields := strings.Fields(string(output))
|
||||
if len(fields) != 2 || fields[0] == "" || fields[1] == "" {
|
||||
return "", "", fmt.Errorf("pacman printed malformed archive identity for %s", filepath.Base(path))
|
||||
}
|
||||
return fields[0], fields[1], nil
|
||||
}
|
||||
|
||||
func regularPacmanArtifact(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("not a regular file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolvePacmanClosure refreshes a private sync database, resolves the exact
|
||||
// transaction, and downloads its archives plus detached signatures into an
|
||||
// Agent-owned cache. It never mutates the host pacman database or package set.
|
||||
func ResolvePacmanClosure(packageName, version string) (*PacmanResolution, error) {
|
||||
if err := validatePackageName(packageName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if version != "" {
|
||||
if err := validatePackageName(version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(pacmanCommandPath); err != nil {
|
||||
return nil, fmt.Errorf("pacman is unavailable: %w", err)
|
||||
}
|
||||
if _, err := exec.LookPath("fakeroot"); err != nil {
|
||||
return nil, fmt.Errorf("fakeroot is required for private pacman resolution: %w", err)
|
||||
}
|
||||
|
||||
agentDir := filepath.Join(constants.GetBaseDir(), constants.AgentDir)
|
||||
resolutionRoot := filepath.Join(agentDir, "pacman-resolve")
|
||||
if err := os.MkdirAll(resolutionRoot, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create pacman resolution root: %w", err)
|
||||
}
|
||||
root, err := os.MkdirTemp(resolutionRoot, "operation-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pacman resolution: %w", err)
|
||||
}
|
||||
cleanup := func() { _ = os.RemoveAll(root) }
|
||||
fail := func(err error) (*PacmanResolution, error) {
|
||||
cleanup()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
database := filepath.Join(root, "db")
|
||||
cache := filepath.Join(root, "cache")
|
||||
if err := os.Mkdir(database, 0o700); err != nil {
|
||||
return fail(fmt.Errorf("create pacman database: %w", err))
|
||||
}
|
||||
if err := os.Mkdir(cache, 0o700); err != nil {
|
||||
return fail(fmt.Errorf("create pacman cache: %w", err))
|
||||
}
|
||||
if err := os.Symlink("/var/lib/pacman/local", filepath.Join(database, "local")); err != nil {
|
||||
return fail(fmt.Errorf("bind installed pacman state: %w", err))
|
||||
}
|
||||
|
||||
fakeroot, err := exec.LookPath("fakeroot")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if _, err := pacmanResolverCommand(
|
||||
fakeroot, "--", pacmanCommandPath, "-Sy", "--noconfirm",
|
||||
"--disable-sandbox-filesystem", "--dbpath", database, "--logfile", "/dev/null",
|
||||
); err != nil {
|
||||
return fail(fmt.Errorf("refresh private pacman metadata: %w", err))
|
||||
}
|
||||
|
||||
target := packageName
|
||||
if version != "" {
|
||||
target += "=" + version
|
||||
}
|
||||
printOutput, err := pacmanResolverCommand(
|
||||
pacmanCommandPath, "-Sp", "--print-format", "%n|%v|%r",
|
||||
"--dbpath", database, "--", target,
|
||||
)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("resolve pacman transaction: %w", err))
|
||||
}
|
||||
repositories, err := parsePacmanPrint(string(printOutput))
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if _, err := pacmanResolverCommand(
|
||||
fakeroot, "--", pacmanCommandPath, "-Sw", "--noconfirm", "--needed",
|
||||
"--disable-sandbox-filesystem", "--dbpath", database, "--cachedir", cache,
|
||||
"--logfile", "/dev/null", "--", target,
|
||||
); err != nil {
|
||||
return fail(fmt.Errorf("download pacman transaction: %w", err))
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(cache)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("read pacman cache: %w", err))
|
||||
}
|
||||
artifacts := make([]PacmanResolvedArtifact, 0, len(entries)/2)
|
||||
rootFound := false
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !strings.Contains(name, ".pkg.tar.") || strings.HasSuffix(name, ".sig") || strings.HasSuffix(name, ".part") {
|
||||
continue
|
||||
}
|
||||
archivePath := filepath.Join(cache, name)
|
||||
if err := regularPacmanArtifact(archivePath); err != nil {
|
||||
return fail(fmt.Errorf("unsafe pacman archive %s: %w", name, err))
|
||||
}
|
||||
signaturePath := archivePath + ".sig"
|
||||
if err := regularPacmanArtifact(signaturePath); err != nil {
|
||||
return fail(fmt.Errorf("pacman archive %s has no regular detached signature: %w", name, err))
|
||||
}
|
||||
resolvedName, resolvedVersion, err := inspectPacmanArchive(archivePath)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
repository, ok := repositories[resolvedName+"@"+resolvedVersion]
|
||||
if !ok {
|
||||
return fail(fmt.Errorf("pacman repository missing for %s@%s", resolvedName, resolvedVersion))
|
||||
}
|
||||
archiveHash, err := fileSHA256(archivePath)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("hash pacman archive %s: %w", name, err))
|
||||
}
|
||||
signatureHash, err := fileSHA256(signaturePath)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("hash pacman signature %s: %w", filepath.Base(signaturePath), err))
|
||||
}
|
||||
artifacts = append(artifacts, PacmanResolvedArtifact{
|
||||
Name: resolvedName, Version: resolvedVersion, Repository: repository,
|
||||
ArchivePath: archivePath, ArchiveSHA256: archiveHash,
|
||||
SignaturePath: signaturePath, SignatureSHA256: signatureHash,
|
||||
})
|
||||
if resolvedName == packageName && (version == "" || resolvedVersion == version) {
|
||||
rootFound = true
|
||||
}
|
||||
}
|
||||
if len(artifacts) == 0 {
|
||||
return fail(fmt.Errorf("pacman downloaded no package archives"))
|
||||
}
|
||||
if !rootFound {
|
||||
return fail(fmt.Errorf("pacman transaction did not contain requested root %s@%s", packageName, version))
|
||||
}
|
||||
sort.Slice(artifacts, func(i, j int) bool {
|
||||
if artifacts[i].Name == artifacts[j].Name {
|
||||
return artifacts[i].Version < artifacts[j].Version
|
||||
}
|
||||
return artifacts[i].Name < artifacts[j].Name
|
||||
})
|
||||
return &PacmanResolution{Artifacts: artifacts, Cleanup: cleanup}, nil
|
||||
}
|
||||
26
agent/internal/installer/pacman_resolver_linux_test.go
Normal file
26
agent/internal/installer/pacman_resolver_linux_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//go:build linux
|
||||
|
||||
package installer
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParsePacmanPrint(t *testing.T) {
|
||||
repositories, err := parsePacmanPrint("linux|1:6.19.14-1|core\nmkinitcpio|39.2-3|core\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := repositories["linux@1:6.19.14-1"]; got != "core" {
|
||||
t.Fatalf("epoch-bearing identity repository = %q, want core", got)
|
||||
}
|
||||
if got := repositories["mkinitcpio@39.2-3"]; got != "core" {
|
||||
t.Fatalf("dependency repository = %q, want core", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePacmanPrintRefusesMalformedIdentity(t *testing.T) {
|
||||
for _, output := range []string{"", "linux 6.19 core", "linux|6.19|"} {
|
||||
if _, err := parsePacmanPrint(output); err == nil {
|
||||
t.Fatalf("parsePacmanPrint(%q) succeeded", output)
|
||||
}
|
||||
}
|
||||
}
|
||||
9
agent/internal/installer/pacman_resolver_other.go
Normal file
9
agent/internal/installer/pacman_resolver_other.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build !linux
|
||||
|
||||
package installer
|
||||
|
||||
import "fmt"
|
||||
|
||||
func ResolvePacmanClosure(packageName, version string) (*PacmanResolution, error) {
|
||||
return nil, fmt.Errorf("pacman resolution is only available on Linux")
|
||||
}
|
||||
17
agent/internal/installer/types.go
Normal file
17
agent/internal/installer/types.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package installer
|
||||
|
||||
// InstallResult represents the result of a package installation attempt
|
||||
type InstallResult struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
Action string `json:"action,omitempty"` // "install", "upgrade", etc.
|
||||
PackagesInstalled []string `json:"packages_installed,omitempty"`
|
||||
ContainersUpdated []string `json:"containers_updated,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"` // List of dependency packages found during dry run
|
||||
IsDryRun bool `json:"is_dry_run"` // Whether this is a dry run result
|
||||
RebootRequired bool `json:"reboot_required,omitempty"` // Whether a reboot is needed (F-C1-3 ghost update fix)
|
||||
}
|
||||
292
agent/internal/installer/windows.go
Normal file
292
agent/internal/installer/windows.go
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/pkg/windowsupdate"
|
||||
"github.com/go-ole/go-ole"
|
||||
"github.com/scjalliance/comshim"
|
||||
)
|
||||
|
||||
// Windows Update Agent operation result codes.
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/wuapi/ne-wuapi-operationresultcode
|
||||
const (
|
||||
orcSucceeded int32 = 2
|
||||
orcSucceededWithErrors int32 = 3
|
||||
)
|
||||
|
||||
// WindowsUpdateInstaller installs Windows updates through the Windows Update
|
||||
// Agent COM API (Microsoft.Update.Session) via go-ole — the same binding the WUA
|
||||
// scanner uses. No shelling out: wuauclt's /updatenow was removed on Windows 10,
|
||||
// and Install-WindowsUpdate needs the third-party PSWindowsUpdate module. The COM
|
||||
// binding compiles cross-platform (go-ole ships non-Windows stubs); IsAvailable()
|
||||
// gates execution to Windows at runtime.
|
||||
type WindowsUpdateInstaller struct{}
|
||||
|
||||
// NewWindowsUpdateInstaller creates a new Windows Update installer
|
||||
func NewWindowsUpdateInstaller() *WindowsUpdateInstaller {
|
||||
return &WindowsUpdateInstaller{}
|
||||
}
|
||||
|
||||
// IsAvailable reports whether this installer can run on the current host.
|
||||
func (i *WindowsUpdateInstaller) IsAvailable() bool {
|
||||
return runtime.GOOS == "windows"
|
||||
}
|
||||
|
||||
// GetPackageType returns the package type this installer handles
|
||||
func (i *WindowsUpdateInstaller) GetPackageType() string {
|
||||
return "windows_update"
|
||||
}
|
||||
|
||||
// Install installs a specific Windows update by title.
|
||||
func (i *WindowsUpdateInstaller) Install(packageName string) (*InstallResult, error) {
|
||||
return i.installUpdates([]string{packageName}, false)
|
||||
}
|
||||
|
||||
// InstallMultiple installs multiple Windows updates by title.
|
||||
func (i *WindowsUpdateInstaller) InstallMultiple(packageNames []string) (*InstallResult, error) {
|
||||
return i.installUpdates(packageNames, false)
|
||||
}
|
||||
|
||||
// Upgrade installs every available Windows update.
|
||||
func (i *WindowsUpdateInstaller) Upgrade() (*InstallResult, error) {
|
||||
return i.installUpdates(nil, true)
|
||||
}
|
||||
|
||||
// UpdatePackage updates a specific Windows update (alias for Install).
|
||||
func (i *WindowsUpdateInstaller) UpdatePackage(packageName string) (*InstallResult, error) {
|
||||
return i.Install(packageName)
|
||||
}
|
||||
|
||||
// DryRun reports which updates would be installed for the given title without
|
||||
// installing anything.
|
||||
func (i *WindowsUpdateInstaller) DryRun(packageName, version string) (*InstallResult, error) {
|
||||
return i.installUpdates([]string{packageName}, true)
|
||||
}
|
||||
|
||||
// VerifyHash is a no-op for Windows Update: the WUA validates update payloads
|
||||
// against Microsoft's signed catalog itself, and individual updates expose no
|
||||
// addressable download URL to hash. Fail-open by design.
|
||||
func (i *WindowsUpdateInstaller) VerifyHash(packageName, version, expectedSHA256 string) error {
|
||||
log.Printf("[INFO] [agent] [installer] hash_verification_skipped package=%s reason=windows_update_uses_wua_verification", packageName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// installUpdates searches the Windows Update Agent for the requested updates and
|
||||
// runs the download -> accept-EULA -> install lifecycle through the COM API.
|
||||
// packageNames are matched against update titles (the scanner reports Title as the
|
||||
// package name); a nil/empty slice means "all available updates" (upgrade).
|
||||
func (i *WindowsUpdateInstaller) installUpdates(packageNames []string, isDryRun bool) (*InstallResult, error) {
|
||||
if !i.IsAvailable() {
|
||||
return nil, fmt.Errorf("Windows Update installer is only available on Windows")
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
action := "install"
|
||||
if len(packageNames) == 0 {
|
||||
action = "upgrade"
|
||||
}
|
||||
|
||||
result := &InstallResult{
|
||||
Success: false,
|
||||
IsDryRun: isDryRun,
|
||||
Action: action,
|
||||
PackagesInstalled: []string{},
|
||||
Dependencies: []string{},
|
||||
}
|
||||
|
||||
// Initialize COM (mirror the WUA scanner's pattern).
|
||||
comshim.Add(1)
|
||||
defer comshim.Done()
|
||||
ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_SPEED_OVER_MEMORY)
|
||||
defer ole.CoUninitialize()
|
||||
|
||||
session, err := windowsupdate.NewUpdateSession()
|
||||
if err != nil {
|
||||
return i.fail(result, startTime, fmt.Errorf("create Windows Update session: %w", err))
|
||||
}
|
||||
|
||||
searcher, err := session.CreateUpdateSearcher()
|
||||
if err != nil {
|
||||
return i.fail(result, startTime, fmt.Errorf("create update searcher: %w", err))
|
||||
}
|
||||
|
||||
searchResult, err := searcher.Search("IsInstalled=0 AND IsHidden=0")
|
||||
if err != nil {
|
||||
return i.fail(result, startTime, fmt.Errorf("search for updates: %w", err))
|
||||
}
|
||||
|
||||
selected := selectUpdates(searchResult.Updates, packageNames)
|
||||
if len(selected) == 0 {
|
||||
if len(packageNames) == 0 {
|
||||
// Nothing to upgrade — a clean no-op, not a failure.
|
||||
result.Success = true
|
||||
result.Stdout = "No applicable Windows updates available"
|
||||
result.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
return result, nil
|
||||
}
|
||||
return i.fail(result, startTime,
|
||||
fmt.Errorf("requested update(s) not found among available Windows updates: %v", packageNames))
|
||||
}
|
||||
|
||||
if isDryRun {
|
||||
result.Success = true
|
||||
result.Stdout = formatSelected(selected)
|
||||
result.PackagesInstalled = updateTitles(selected) // what WOULD be installed
|
||||
result.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Accept EULAs where required before download/install.
|
||||
for _, u := range selected {
|
||||
if !u.EulaAccepted {
|
||||
if err := u.AcceptEula(); err != nil {
|
||||
return i.fail(result, startTime, fmt.Errorf("accept EULA for %q: %w", u.Title, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download any updates not already cached.
|
||||
downloader, err := session.CreateUpdateDownloader()
|
||||
if err != nil {
|
||||
return i.fail(result, startTime, fmt.Errorf("create update downloader: %w", err))
|
||||
}
|
||||
dlResult, err := downloader.Download(selected)
|
||||
if err != nil {
|
||||
return i.fail(result, startTime, fmt.Errorf("download updates: %w", err))
|
||||
}
|
||||
if !wuaSucceeded(dlResult.ResultCode) {
|
||||
return i.fail(result, startTime,
|
||||
fmt.Errorf("Windows Update download failed: ResultCode=%d HResult=0x%08X", dlResult.ResultCode, uint32(dlResult.HResult)))
|
||||
}
|
||||
|
||||
// Install.
|
||||
inst, err := session.CreateUpdateInstaller()
|
||||
if err != nil {
|
||||
return i.fail(result, startTime, fmt.Errorf("create update installer: %w", err))
|
||||
}
|
||||
instResult, err := inst.Install(selected)
|
||||
if err != nil {
|
||||
return i.fail(result, startTime, fmt.Errorf("install updates: %w", err))
|
||||
}
|
||||
if !wuaSucceeded(instResult.ResultCode) {
|
||||
return i.fail(result, startTime,
|
||||
fmt.Errorf("Windows Update install failed: ResultCode=%d HResult=0x%08X", instResult.ResultCode, uint32(instResult.HResult)))
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
result.PackagesInstalled = updateTitles(selected)
|
||||
result.RebootRequired = instResult.RebootRequired
|
||||
result.Stdout = fmt.Sprintf("Installed %d Windows update(s): %s", len(selected), strings.Join(updateTitles(selected), "; "))
|
||||
result.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
|
||||
log.Printf("[INFO] [agent] [installer] windows_update_installed packages=%v reboot_required=%v duration=%ds",
|
||||
result.PackagesInstalled, result.RebootRequired, result.DurationSeconds)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPendingUpdates returns the titles of updates the Windows Update Agent reports
|
||||
// as applicable but not yet installed.
|
||||
func (i *WindowsUpdateInstaller) GetPendingUpdates() ([]string, error) {
|
||||
if !i.IsAvailable() {
|
||||
return nil, fmt.Errorf("Windows Update installer is only available on Windows")
|
||||
}
|
||||
|
||||
comshim.Add(1)
|
||||
defer comshim.Done()
|
||||
ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_SPEED_OVER_MEMORY)
|
||||
defer ole.CoUninitialize()
|
||||
|
||||
session, err := windowsupdate.NewUpdateSession()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create Windows Update session: %w", err)
|
||||
}
|
||||
searcher, err := session.CreateUpdateSearcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create update searcher: %w", err)
|
||||
}
|
||||
searchResult, err := searcher.Search("IsInstalled=0 AND IsHidden=0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search for updates: %w", err)
|
||||
}
|
||||
return updateTitles(searchResult.Updates), nil
|
||||
}
|
||||
|
||||
// fail finalizes a failed InstallResult: records the error, stamps duration, and
|
||||
// returns it alongside the error so the caller reports ground truth (no fake success).
|
||||
func (i *WindowsUpdateInstaller) fail(result *InstallResult, start time.Time, err error) (*InstallResult, error) {
|
||||
result.Success = false
|
||||
result.ErrorMessage = err.Error()
|
||||
result.Stderr = err.Error()
|
||||
result.ExitCode = 1
|
||||
result.DurationSeconds = int(time.Since(start).Seconds())
|
||||
return result, err
|
||||
}
|
||||
|
||||
// selectUpdates picks the updates to act on. An empty names slice selects every
|
||||
// available update (upgrade-all). Otherwise an update is selected when a requested
|
||||
// name matches its title (exact, then case-insensitive contains) or one of its KB
|
||||
// article IDs.
|
||||
func selectUpdates(available []*windowsupdate.IUpdate, names []string) []*windowsupdate.IUpdate {
|
||||
if len(names) == 0 {
|
||||
return available
|
||||
}
|
||||
var selected []*windowsupdate.IUpdate
|
||||
for _, u := range available {
|
||||
if updateMatchesAny(u, names) {
|
||||
selected = append(selected, u)
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func updateMatchesAny(u *windowsupdate.IUpdate, names []string) bool {
|
||||
title := strings.TrimSpace(u.Title)
|
||||
lowerTitle := strings.ToLower(title)
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if title == name || strings.Contains(lowerTitle, strings.ToLower(name)) {
|
||||
return true
|
||||
}
|
||||
for _, kb := range u.KBArticleIDs {
|
||||
// KB IDs come back without the "KB" prefix; match either form.
|
||||
if strings.EqualFold(kb, name) || strings.EqualFold("KB"+kb, name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func updateTitles(updates []*windowsupdate.IUpdate) []string {
|
||||
titles := make([]string, 0, len(updates))
|
||||
for _, u := range updates {
|
||||
titles = append(titles, u.Title)
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
func formatSelected(updates []*windowsupdate.IUpdate) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Dry run - the following Windows updates would be installed:\n")
|
||||
for _, u := range updates {
|
||||
fmt.Fprintf(&b, " - %s", u.Title)
|
||||
if len(u.KBArticleIDs) > 0 {
|
||||
fmt.Fprintf(&b, " (KB%s)", strings.Join(u.KBArticleIDs, ", KB"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func wuaSucceeded(resultCode int32) bool {
|
||||
return resultCode == orcSucceeded || resultCode == orcSucceededWithErrors
|
||||
}
|
||||
391
agent/internal/installer/winget.go
Normal file
391
agent/internal/installer/winget.go
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
package installer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WingetInstaller handles winget package installation
|
||||
type WingetInstaller struct{}
|
||||
|
||||
// NewWingetInstaller creates a new Winget installer
|
||||
func NewWingetInstaller() *WingetInstaller {
|
||||
return &WingetInstaller{}
|
||||
}
|
||||
|
||||
// IsAvailable checks if winget is available on this system
|
||||
func (i *WingetInstaller) IsAvailable() bool {
|
||||
// Only available on Windows
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if winget command exists
|
||||
_, err := exec.LookPath("winget")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetPackageType returns the package type this installer handles
|
||||
func (i *WingetInstaller) GetPackageType() string {
|
||||
return "winget"
|
||||
}
|
||||
|
||||
// Install installs a specific winget package
|
||||
func (i *WingetInstaller) Install(packageName string) (*InstallResult, error) {
|
||||
return i.installPackage(packageName, false)
|
||||
}
|
||||
|
||||
// InstallMultiple installs multiple winget packages
|
||||
func (i *WingetInstaller) InstallMultiple(packageNames []string) (*InstallResult, error) {
|
||||
if len(packageNames) == 0 {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: "No packages specified for installation",
|
||||
}, fmt.Errorf("no packages specified")
|
||||
}
|
||||
|
||||
// For winget, we'll install packages one by one to better track results
|
||||
startTime := time.Now()
|
||||
result := &InstallResult{
|
||||
Success: true,
|
||||
Action: "install_multiple",
|
||||
PackagesInstalled: []string{},
|
||||
Stdout: "",
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
|
||||
var combinedStdout []string
|
||||
var combinedStderr []string
|
||||
|
||||
for _, packageName := range packageNames {
|
||||
singleResult, err := i.installPackage(packageName, false)
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
result.Stderr += fmt.Sprintf("Failed to install %s: %v\n", packageName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !singleResult.Success {
|
||||
result.Success = false
|
||||
if singleResult.Stderr != "" {
|
||||
combinedStderr = append(combinedStderr, fmt.Sprintf("%s: %s", packageName, singleResult.Stderr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
result.PackagesInstalled = append(result.PackagesInstalled, packageName)
|
||||
if singleResult.Stdout != "" {
|
||||
combinedStdout = append(combinedStdout, fmt.Sprintf("%s: %s", packageName, singleResult.Stdout))
|
||||
}
|
||||
}
|
||||
|
||||
result.Stdout = strings.Join(combinedStdout, "\n")
|
||||
result.Stderr = strings.Join(combinedStderr, "\n")
|
||||
result.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
|
||||
if result.Success {
|
||||
result.ExitCode = 0
|
||||
} else {
|
||||
result.ExitCode = 1
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Upgrade upgrades all outdated winget packages
|
||||
func (i *WingetInstaller) Upgrade() (*InstallResult, error) {
|
||||
if !i.IsAvailable() {
|
||||
return nil, fmt.Errorf("winget is not available on this system")
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Get list of outdated packages first
|
||||
outdatedPackages, err := i.getOutdatedPackages()
|
||||
if err != nil {
|
||||
return &InstallResult{
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("Failed to get outdated packages: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
if len(outdatedPackages) == 0 {
|
||||
return &InstallResult{
|
||||
Success: true,
|
||||
Action: "upgrade",
|
||||
Stdout: "No outdated packages found",
|
||||
ExitCode: 0,
|
||||
DurationSeconds: int(time.Since(startTime).Seconds()),
|
||||
PackagesInstalled: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Upgrade all outdated packages
|
||||
return i.upgradeAllPackages(outdatedPackages)
|
||||
}
|
||||
|
||||
// DryRun performs a dry run installation to check what would be installed
|
||||
func (i *WingetInstaller) DryRun(packageName, version string) (*InstallResult, error) {
|
||||
return i.installPackage(packageName, true)
|
||||
}
|
||||
|
||||
// installPackage is the internal implementation for package installation
|
||||
func (i *WingetInstaller) installPackage(packageName string, isDryRun bool) (*InstallResult, error) {
|
||||
if !i.IsAvailable() {
|
||||
return nil, fmt.Errorf("winget is not available on this system")
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
result := &InstallResult{
|
||||
Success: false,
|
||||
IsDryRun: isDryRun,
|
||||
ExitCode: 0,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
|
||||
// Build winget command
|
||||
var cmd *exec.Cmd
|
||||
if isDryRun {
|
||||
// For dry run, we'll check if the package would be upgraded
|
||||
cmd = exec.Command("winget", "show", "--id", packageName, "--accept-source-agreements")
|
||||
result.Action = "dry_run"
|
||||
} else {
|
||||
// Install the package with upgrade flag
|
||||
cmd = exec.Command("winget", "install", "--id", packageName,
|
||||
"--upgrade", "--accept-package-agreements", "--accept-source-agreements", "--force")
|
||||
result.Action = "install"
|
||||
}
|
||||
|
||||
// Execute command
|
||||
output, err := cmd.CombinedOutput()
|
||||
result.Stdout = string(output)
|
||||
result.Stderr = ""
|
||||
result.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
|
||||
if err != nil {
|
||||
result.ExitCode = 1
|
||||
result.ErrorMessage = fmt.Sprintf("Command failed: %v", err)
|
||||
|
||||
// Check if this is a "no update needed" scenario
|
||||
if strings.Contains(strings.ToLower(string(output)), "no upgrade available") ||
|
||||
strings.Contains(strings.ToLower(string(output)), "already installed") {
|
||||
result.Success = true
|
||||
result.Stdout = "Package is already up to date"
|
||||
result.ExitCode = 0
|
||||
result.ErrorMessage = ""
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
result.ExitCode = 0
|
||||
result.PackagesInstalled = []string{packageName}
|
||||
|
||||
// Parse output to extract additional information
|
||||
if !isDryRun {
|
||||
result.Stdout = i.parseInstallOutput(string(output), packageName)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getOutdatedPackages retrieves a list of outdated packages
|
||||
func (i *WingetInstaller) getOutdatedPackages() ([]string, error) {
|
||||
cmd := exec.Command("winget", "list", "--outdated", "--accept-source-agreements", "--output", "json")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get outdated packages: %w", err)
|
||||
}
|
||||
|
||||
var packages []WingetPackage
|
||||
if err := json.Unmarshal(output, &packages); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse winget output: %w", err)
|
||||
}
|
||||
|
||||
var outdatedNames []string
|
||||
for _, pkg := range packages {
|
||||
if pkg.Available != "" && pkg.Available != pkg.Version {
|
||||
outdatedNames = append(outdatedNames, pkg.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return outdatedNames, nil
|
||||
}
|
||||
|
||||
// upgradeAllPackages upgrades all specified packages
|
||||
func (i *WingetInstaller) upgradeAllPackages(packageIDs []string) (*InstallResult, error) {
|
||||
startTime := time.Now()
|
||||
result := &InstallResult{
|
||||
Success: true,
|
||||
Action: "upgrade",
|
||||
PackagesInstalled: []string{},
|
||||
Stdout: "",
|
||||
Stderr: "",
|
||||
ExitCode: 0,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
|
||||
var combinedStdout []string
|
||||
var combinedStderr []string
|
||||
|
||||
for _, packageID := range packageIDs {
|
||||
upgradeResult, err := i.installPackage(packageID, false)
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
combinedStderr = append(combinedStderr, fmt.Sprintf("Failed to upgrade %s: %v", packageID, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if !upgradeResult.Success {
|
||||
result.Success = false
|
||||
if upgradeResult.Stderr != "" {
|
||||
combinedStderr = append(combinedStderr, fmt.Sprintf("%s: %s", packageID, upgradeResult.Stderr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
result.PackagesInstalled = append(result.PackagesInstalled, packageID)
|
||||
if upgradeResult.Stdout != "" {
|
||||
combinedStdout = append(combinedStdout, upgradeResult.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
result.Stdout = strings.Join(combinedStdout, "\n")
|
||||
result.Stderr = strings.Join(combinedStderr, "\n")
|
||||
result.DurationSeconds = int(time.Since(startTime).Seconds())
|
||||
|
||||
if result.Success {
|
||||
result.ExitCode = 0
|
||||
} else {
|
||||
result.ExitCode = 1
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseInstallOutput parses and formats winget install output
|
||||
func (i *WingetInstaller) parseInstallOutput(output, packageName string) string {
|
||||
lines := strings.Split(output, "\n")
|
||||
var relevantLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Include important status messages
|
||||
if strings.Contains(strings.ToLower(line), "successfully") ||
|
||||
strings.Contains(strings.ToLower(line), "installed") ||
|
||||
strings.Contains(strings.ToLower(line), "upgraded") ||
|
||||
strings.Contains(strings.ToLower(line), "modified") ||
|
||||
strings.Contains(strings.ToLower(line), "completed") ||
|
||||
strings.Contains(strings.ToLower(line), "failed") ||
|
||||
strings.Contains(strings.ToLower(line), "error") {
|
||||
relevantLines = append(relevantLines, line)
|
||||
}
|
||||
|
||||
// Include download progress
|
||||
if strings.Contains(line, "Downloading") ||
|
||||
strings.Contains(line, "Installing") ||
|
||||
strings.Contains(line, "Extracting") {
|
||||
relevantLines = append(relevantLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if len(relevantLines) == 0 {
|
||||
return fmt.Sprintf("Package %s installation completed", packageName)
|
||||
}
|
||||
|
||||
return strings.Join(relevantLines, "\n")
|
||||
}
|
||||
|
||||
// parseDependencies analyzes package dependencies (winget doesn't explicitly expose dependencies)
|
||||
func (i *WingetInstaller) parseDependencies(packageName string) ([]string, error) {
|
||||
// Winget doesn't provide explicit dependency information in its basic output
|
||||
// This is a placeholder for future enhancement where we might parse
|
||||
// additional metadata or use Windows package management APIs
|
||||
|
||||
// For now, we'll return empty dependencies as winget handles this automatically
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// GetPackageInfo retrieves detailed information about a specific package
|
||||
func (i *WingetInstaller) GetPackageInfo(packageID string) (map[string]interface{}, error) {
|
||||
if !i.IsAvailable() {
|
||||
return nil, fmt.Errorf("winget is not available on this system")
|
||||
}
|
||||
|
||||
cmd := exec.Command("winget", "show", "--id", packageID, "--accept-source-agreements", "--output", "json")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get package info: %w", err)
|
||||
}
|
||||
|
||||
var packageInfo map[string]interface{}
|
||||
if err := json.Unmarshal(output, &packageInfo); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse package info: %w", err)
|
||||
}
|
||||
|
||||
return packageInfo, nil
|
||||
}
|
||||
|
||||
// IsPackageInstalled checks if a package is already installed
|
||||
func (i *WingetInstaller) IsPackageInstalled(packageID string) (bool, string, error) {
|
||||
if !i.IsAvailable() {
|
||||
return false, "", fmt.Errorf("winget is not available on this system")
|
||||
}
|
||||
|
||||
cmd := exec.Command("winget", "list", "--id", packageID, "--accept-source-agreements", "--output", "json")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Command failed, package is likely not installed
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
var packages []WingetPackage
|
||||
if err := json.Unmarshal(output, &packages); err != nil {
|
||||
return false, "", fmt.Errorf("failed to parse package list: %w", err)
|
||||
}
|
||||
|
||||
if len(packages) > 0 {
|
||||
return true, packages[0].Version, nil
|
||||
}
|
||||
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// WingetPackage represents a winget package structure for JSON parsing
|
||||
type WingetPackage struct {
|
||||
Name string `json:"Name"`
|
||||
ID string `json:"Id"`
|
||||
Version string `json:"Version"`
|
||||
Available string `json:"Available"`
|
||||
Source string `json:"Source"`
|
||||
IsPinned bool `json:"IsPinned"`
|
||||
PinReason string `json:"PinReason,omitempty"`
|
||||
}
|
||||
|
||||
// UpdatePackage updates a specific winget package (alias for Install method)
|
||||
func (i *WingetInstaller) UpdatePackage(packageName string) (*InstallResult, error) {
|
||||
// Winget uses same logic for updating as installing
|
||||
return i.Install(packageName)
|
||||
}
|
||||
|
||||
// VerifyHash verifies the winget package hash before installation
|
||||
func (i *WingetInstaller) VerifyHash(packageName, version, expectedSHA256 string) error {
|
||||
if expectedSHA256 == "" {
|
||||
return fmt.Errorf("no expected hash registered for package %q — hash verification is mandatory for capability-gated installs", packageName)
|
||||
}
|
||||
|
||||
// TODO: Winget packages should use Microsoft Store API for hash verification.
|
||||
// Until implemented, fail closed — returning an error blocks the install.
|
||||
return fmt.Errorf("hash verification not implemented for winget package %q — expected=%s", packageName, expectedSHA256)
|
||||
}
|
||||
37
agent/internal/instancelock/lock.go
Normal file
37
agent/internal/instancelock/lock.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Package instancelock prevents multiple agent processes from running
|
||||
// concurrently on the same host by holding an exclusive lock on a
|
||||
// well-known path (Unix: flock on /var/lib/.../agent.lock; Windows:
|
||||
// named kernel mutex Global\RedFlagAgent_v1 + per-user file lock).
|
||||
// The lock is released when the process exits (the OS cleans up).
|
||||
// If the lock cannot be acquired, the instance should exit immediately
|
||||
// rather than share config.json and renewal state.
|
||||
package instancelock
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LockPath returns the well-known path for the Unix instance lockfile.
|
||||
// Windows uses a named kernel mutex instead; this function is only called
|
||||
// from lock_unix.go and is referenced here so the package compiles on
|
||||
// Windows.
|
||||
func LockPath() string {
|
||||
return filepath.Join("/var/lib/redflag/agent/state", "agent.lock")
|
||||
}
|
||||
|
||||
// Acquire attempts to acquire an exclusive instance lock. It returns a
|
||||
// release function and nil on success, or an error if another agent
|
||||
// process is already running on this host.
|
||||
//
|
||||
// The release function must be called on graceful shutdown.
|
||||
// On process crash the OS releases the lock automatically:
|
||||
// - Unix: the kernel closes the flock fd on process exit.
|
||||
// - Windows: the kernel transitions the mutex to "abandoned" state;
|
||||
// a crashed owner's mutex is acquired cleanly by the next waiter.
|
||||
func Acquire() (release func(), err error) {
|
||||
return acquireLock()
|
||||
}
|
||||
|
||||
// noopRelease is a safe no-op for platforms that don't need cleanup.
|
||||
func noopRelease() {}
|
||||
|
||||
45
agent/internal/instancelock/lock_unix.go
Normal file
45
agent/internal/instancelock/lock_unix.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//go:build linux || darwin || freebsd
|
||||
|
||||
package instancelock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// acquireLock opens (or creates) the lockfile and takes an exclusive
|
||||
// flock. The lock is released when the process exits (the kernel
|
||||
// closes the fd, which releases the flock).
|
||||
func acquireLock() (func(), error) {
|
||||
path := LockPath()
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("instancelock: mkdir %s: %w", dir, err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instancelock: open %s: %w", path, err)
|
||||
}
|
||||
|
||||
fd := int(f.Fd())
|
||||
if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("instancelock: %s is locked by another process: %w", path, err)
|
||||
}
|
||||
|
||||
// Write our PID so operators can see who holds the lock.
|
||||
_, _ = f.WriteAt([]byte(fmt.Sprintf("%d\n", os.Getpid())), 0)
|
||||
_ = f.Truncate(64)
|
||||
|
||||
release := func() {
|
||||
_ = f.Close()
|
||||
// Don't remove the file — leaving it is harmless and prevents a
|
||||
// TOCTOU race where a fresh open might get an unlocked fd before
|
||||
// we flock it.
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
127
agent/internal/instancelock/lock_windows.go
Normal file
127
agent/internal/instancelock/lock_windows.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
//go:build windows
|
||||
|
||||
package instancelock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
// mu guards handle — the OS mutex handle must be closed exactly once,
|
||||
// but it must outlive the caller's release function (it's process-wide).
|
||||
mu sync.Mutex
|
||||
handle windows.Handle
|
||||
)
|
||||
|
||||
// lockName is the well-known kernel-object name. The Global\ prefix makes
|
||||
// it visible across all sessions (including the service session), which is
|
||||
// necessary because the agent may run both as a service and as a console
|
||||
// process under different sessions on the same host.
|
||||
const lockName = `Global\RedFlagAgent_v1`
|
||||
|
||||
// acquireLock opens a named kernel mutex and attempts to acquire it
|
||||
// without blocking. If the mutex is held by another process, we fail
|
||||
// immediately. If it doesn't exist, CreateMutex creates it for us.
|
||||
//
|
||||
// Unlike the Unix flock (which lives on an fd scoped to the file system
|
||||
// path), a Windows mutex is purely a kernel object — there is no file
|
||||
// to write a PID or leak. The name is the key: any process on the system
|
||||
// (service, console, WSL bridge) that opens the same name gets the same
|
||||
// mutex object.
|
||||
func acquireLock() (func(), error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// If we already hold the lock in this process (shouldn't happen in
|
||||
// normal use, but guard it), don't re-acquire.
|
||||
if handle != 0 {
|
||||
return noopRelease, nil
|
||||
}
|
||||
|
||||
name, err := windows.UTF16PtrFromString(lockName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instancelock: utf16: %w", err)
|
||||
}
|
||||
|
||||
// CreateMutex opens or creates the named mutex. It does NOT set the
|
||||
// initial ownership — we do that with WaitForSingleObject below.
|
||||
h, err := windows.CreateMutex(nil, false, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instancelock: CreateMutex: %w", err)
|
||||
}
|
||||
|
||||
// Attempt to acquire with zero timeout (non-blocking).
|
||||
// WAIT_OBJECT_0 (0) = we got it.
|
||||
// WAIT_ABANDONED (128) = previous owner died holding it — it's ours.
|
||||
// WAIT_TIMEOUT (258) = someone else has it.
|
||||
switch waitResult, _ := windows.WaitForSingleObject(h, 0); waitResult {
|
||||
case 0, windows.WAIT_ABANDONED:
|
||||
handle = h
|
||||
case 258: // WAIT_TIMEOUT
|
||||
_ = windows.CloseHandle(h)
|
||||
return nil, fmt.Errorf("instancelock: another agent process is already running on this host (locked mutex: %s)", lockName)
|
||||
default:
|
||||
_ = windows.CloseHandle(h)
|
||||
return nil, fmt.Errorf("instancelock: WaitForSingleObject failed on %s", lockName)
|
||||
}
|
||||
|
||||
// Release is called on graceful shutdown. If the process crashes or
|
||||
// is killed, the kernel releases the mutex automatically — unlike
|
||||
// Unix where flock is tied to the fd and the fd closes on exit, a
|
||||
// Windows mutex held by a dead thread transitions to "abandoned"
|
||||
// and the next waiter acquires it cleanly.
|
||||
release := func() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if handle != 0 {
|
||||
_ = windows.ReleaseMutex(handle)
|
||||
_ = windows.CloseHandle(handle)
|
||||
handle = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Also create a per-user lockfile under APPDATA to catch the case
|
||||
// where the same user runs two console-mode agents without going
|
||||
// through the SCM. The kernel mutex covers cross-session; the file
|
||||
// lock covers same-user-duplicates where both instances open the
|
||||
// same file.
|
||||
localAppData := os.Getenv("LOCALAPPDATA")
|
||||
if localAppData != "" {
|
||||
lockFilePath := filepath.Join(localAppData, "RedFlag", "agent.lock")
|
||||
|
||||
// Best-effort: if we can't write it, the kernel mutex still
|
||||
// protects us across all sessions.
|
||||
_ = os.MkdirAll(filepath.Dir(lockFilePath), 0755)
|
||||
if f, fErr := os.OpenFile(lockFilePath, os.O_RDWR|os.O_CREATE, 0644); fErr == nil {
|
||||
if lErr := lockFile(f); lErr == nil {
|
||||
// Extend release to also close the file.
|
||||
prevRelease := release
|
||||
release = func() {
|
||||
_ = f.Close()
|
||||
prevRelease()
|
||||
}
|
||||
} else {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// lockFile takes an advisory lock on an *os.File using LockFileEx (the
|
||||
// Windows equivalent of flock). This is belt-and-suspenders with the
|
||||
// kernel mutex — both must be acquired for the lock to count.
|
||||
func lockFile(f *os.File) error {
|
||||
ol := &windows.Overlapped{}
|
||||
return windows.LockFileEx(
|
||||
windows.Handle(f.Fd()),
|
||||
windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY,
|
||||
0, 1, 0, ol,
|
||||
)
|
||||
}
|
||||
23
agent/internal/integrations/integrations.go
Normal file
23
agent/internal/integrations/integrations.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Package integrations detects third-party software the agent can observe on its
|
||||
// own host (Sunshine, etc.) and folds a compact report into the agent's regular
|
||||
// system-info payload under metadata["integrations"].
|
||||
//
|
||||
// Architecture: observe-only. Detection inspects the local host and reports what
|
||||
// it finds. It never starts, stops, configures, or otherwise commands the software
|
||||
// it observes — that boundary is what keeps a remote-desktop integration from
|
||||
// becoming a remote-control backdoor. The dashboard renders what the agent reports;
|
||||
// it does not reach into the host.
|
||||
package integrations
|
||||
|
||||
// Detect returns the integrations block for metadata["integrations"]. primaryIP is
|
||||
// the agent's primary address, used to construct local management URLs (e.g. the
|
||||
// Sunshine web UI). An empty map means nothing was detected — callers may omit it.
|
||||
func Detect(primaryIP string) map[string]interface{} {
|
||||
out := make(map[string]interface{})
|
||||
|
||||
if s := detectSunshine(primaryIP); s != nil {
|
||||
out["sunshine"] = s
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
123
agent/internal/integrations/sunshine.go
Normal file
123
agent/internal/integrations/sunshine.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package integrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sunshineDefaultWebPort is Sunshine's default HTTPS management UI port. If the
|
||||
// operator moved it, the constructed URL will be wrong — that's a known limit of
|
||||
// observe-only detection until we parse sunshine.conf (a later slice).
|
||||
const sunshineDefaultWebPort = 47990
|
||||
|
||||
// sunshineReport mirrors the ReportedIntegration contract the web UI reads
|
||||
// (web/src/types/integrations.ts). JSON keys must stay in sync.
|
||||
type sunshineReport struct {
|
||||
State string `json:"state"` // detected | running | not_detected
|
||||
Version string `json:"version,omitempty"` // observed via `sunshine --version`
|
||||
WebUI string `json:"web_ui,omitempty"` // https://<ip>:47990 (default)
|
||||
SessionActive bool `json:"session_active"` // not yet observable (slice 1)
|
||||
LastObserved string `json:"last_observed"` // RFC3339, when detection ran
|
||||
}
|
||||
|
||||
// detectSunshine inspects the host for Sunshine. Returns nil only if Sunshine is
|
||||
// neither installed nor running (so the integration block stays empty rather than
|
||||
// carrying a noisy "not_detected" for every host that never had it). Observe-only:
|
||||
// the only subprocess it ever spawns is `sunshine --version`, which is read-only.
|
||||
func detectSunshine(primaryIP string) *sunshineReport {
|
||||
installed := false
|
||||
if _, err := exec.LookPath("sunshine"); err == nil {
|
||||
installed = true
|
||||
}
|
||||
running := sunshineRunning()
|
||||
|
||||
if !installed && !running {
|
||||
return nil
|
||||
}
|
||||
|
||||
rep := &sunshineReport{
|
||||
LastObserved: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
switch {
|
||||
case running:
|
||||
rep.State = "running"
|
||||
default:
|
||||
rep.State = "detected"
|
||||
}
|
||||
|
||||
if installed {
|
||||
if v := sunshineVersion(); v != "" {
|
||||
rep.Version = v
|
||||
}
|
||||
}
|
||||
if primaryIP != "" {
|
||||
rep.WebUI = fmt.Sprintf("https://%s:%d", primaryIP, sunshineDefaultWebPort)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [integrations] sunshine_detected state=%s version=%q", rep.State, rep.Version)
|
||||
return rep
|
||||
}
|
||||
|
||||
// sunshineRunning reports whether a sunshine process is currently live. Uses the
|
||||
// same plain-exec, GOOS-switched idiom as the rest of the system package — no new
|
||||
// dependency. Best-effort: a failure to enumerate returns false, never an error.
|
||||
func sunshineRunning() bool {
|
||||
const name = "sunshine"
|
||||
switch runtime.GOOS {
|
||||
case "linux", "darwin":
|
||||
// pgrep is the cheapest exact-match; exit 0 means a match exists.
|
||||
if exec.Command("pgrep", "-x", name).Run() == nil {
|
||||
return true
|
||||
}
|
||||
// Fallback for hosts without pgrep: scan the process table.
|
||||
if out, err := exec.Command("ps", "-e", "-o", "comm").Output(); err == nil {
|
||||
return processListContains(string(out), name)
|
||||
}
|
||||
case "windows":
|
||||
if out, err := exec.Command("tasklist", "/fo", "csv", "/nh").Output(); err == nil {
|
||||
return processListContains(strings.ToLower(string(out)), name)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func processListContains(output, name string) bool {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if strings.Contains(strings.TrimSpace(line), name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sunshineVersion reads the reported version. `sunshine --version` is read-only and
|
||||
// does not start the streaming service. Guarded by a short timeout so a wedged
|
||||
// binary can never stall the system-info report (ETHOS #3 — assume failure).
|
||||
func sunshineVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "sunshine", "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// Output is typically a single line like "Sunshine version: v0.23.1".
|
||||
line := strings.TrimSpace(string(out))
|
||||
if line == "" {
|
||||
return ""
|
||||
}
|
||||
if idx := strings.LastIndex(line, ":"); idx >= 0 && idx+1 < len(line) {
|
||||
return strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
// Fall back to the last whitespace-delimited token.
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 {
|
||||
return fields[len(fields)-1]
|
||||
}
|
||||
return line
|
||||
}
|
||||
310
agent/internal/kernel/ebpf_consumer.go
Normal file
310
agent/internal/kernel/ebpf_consumer.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
package kernel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/event"
|
||||
"github.com/cilium/ebpf"
|
||||
"github.com/cilium/ebpf/ringbuf"
|
||||
"github.com/cilium/ebpf/rlimit"
|
||||
)
|
||||
|
||||
const (
|
||||
// MAGIC_NUMBER matches the eBPF program
|
||||
MAGIC_NUMBER = 0xDEADBEEF
|
||||
)
|
||||
|
||||
// EBPFConsumer reads events from the eBPF ring buffer and forwards them to the policy checker
|
||||
type EBPFConsumer struct {
|
||||
config *config.Config
|
||||
running bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
wgLock sync.Mutex
|
||||
rd *ringbuf.Reader
|
||||
elfPath string
|
||||
teeLogger *event.TeeLogger
|
||||
}
|
||||
|
||||
// NewEBPFConsumer creates a new eBPF ring buffer consumer
|
||||
func NewEBPFConsumer(cfg *config.Config) (*EBPFConsumer, error) {
|
||||
return NewEBPFConsumerWithLogger(cfg, nil)
|
||||
}
|
||||
|
||||
func NewEBPFConsumerWithLogger(cfg *config.Config, teeLogger *event.TeeLogger) (*EBPFConsumer, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &EBPFConsumer{
|
||||
config: cfg,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
elfPath: cfg.KernelEnforcement.RingBufferPath + "/pkg-gate.o",
|
||||
teeLogger: teeLogger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins consuming events from the eBPF ring buffer
|
||||
func (e *EBPFConsumer) Start() error {
|
||||
e.wgLock.Lock()
|
||||
if e.running {
|
||||
e.wgLock.Unlock()
|
||||
return nil
|
||||
}
|
||||
e.running = true
|
||||
e.wgLock.Unlock()
|
||||
|
||||
log.Printf("[INFO] [kernel] [ebpf] consumer_started")
|
||||
|
||||
// Remove memlock limit for eBPF
|
||||
if err := rlimit.RemoveMemlock(); err != nil {
|
||||
return fmt.Errorf("failed to remove memlock: %w", err)
|
||||
}
|
||||
|
||||
// Load the eBPF collection from the ELF file
|
||||
spec, err := ebpf.LoadCollectionSpec(e.elfPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load eBPF collection spec: %w", err)
|
||||
}
|
||||
|
||||
// Find the ring buffer map by name
|
||||
_, ok := spec.Maps["rb_map"]
|
||||
if !ok {
|
||||
return fmt.Errorf("ring buffer map 'rb_map' not found in eBPF collection")
|
||||
}
|
||||
|
||||
// Load collection to get loaded maps
|
||||
coll, err := ebpf.NewCollection(spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load eBPF collection: %w", err)
|
||||
}
|
||||
|
||||
// Access the loaded ring buffer map
|
||||
loadedMap := coll.Maps["rb_map"]
|
||||
if loadedMap == nil {
|
||||
coll.Close()
|
||||
return fmt.Errorf("ring buffer map 'rb_map' not found in loaded collection")
|
||||
}
|
||||
|
||||
// Create ring buffer reader
|
||||
rd, err := ringbuf.NewReader(loadedMap)
|
||||
if err != nil {
|
||||
coll.Close()
|
||||
return fmt.Errorf("failed to create ring buffer: %w", err)
|
||||
}
|
||||
|
||||
e.rd = rd
|
||||
e.elfPath = "" // Don't load again on stop
|
||||
|
||||
// Set up signal handling for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigChan
|
||||
log.Printf("[INFO] [kernel] [ebpf] shutdown_signal_received")
|
||||
e.cancel()
|
||||
e.wg.Wait()
|
||||
e.rd.Close()
|
||||
}()
|
||||
|
||||
e.wg.Add(1)
|
||||
go e.consumeEvents()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the consumer
|
||||
func (e *EBPFConsumer) Stop() error {
|
||||
e.cancel()
|
||||
e.wgLock.Lock()
|
||||
e.running = false
|
||||
e.wgLock.Unlock()
|
||||
|
||||
e.wg.Wait()
|
||||
if e.rd != nil {
|
||||
e.rd.Close()
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [kernel] [ebpf] consumer_stopped")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// consumeEvents reads from the ring buffer and forwards events to the policy checker
|
||||
func (e *EBPFConsumer) consumeEvents() {
|
||||
defer e.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-e.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Read event from ring buffer
|
||||
record, err := e.rd.Read()
|
||||
if err != nil {
|
||||
if errors.Is(err, ringbuf.ErrClosed) {
|
||||
return
|
||||
}
|
||||
e.teeLogger.Error("agent", "kernel", "kernel_enforcer", fmt.Sprintf("ringbuf_read_failed error=%v", err), map[string]interface{}{"error": err.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
e.processEvent(record.RawSample)
|
||||
}
|
||||
}
|
||||
|
||||
// processEvent handles a single eBPF event
|
||||
func (e *EBPFConsumer) processEvent(data []byte) {
|
||||
// Decode using the struct from eBPF (must match exactly)
|
||||
// Layout: timestamp(8) pid(4) pgid(4) uid(8) gid(8) parent_pid(4) comm(16) cmdline(256) parent_comm(16) magic(4) = 328 bytes
|
||||
event := &redflagEvent{}
|
||||
// Use unsafe to copy raw bytes into the struct
|
||||
bytess := (*[328]byte)(unsafe.Pointer(event))
|
||||
copy(bytess[0:8], data[0:8])
|
||||
copy(bytess[8:12], data[8:12])
|
||||
copy(bytess[12:16], data[12:16])
|
||||
copy(bytess[16:24], data[16:24])
|
||||
copy(bytess[24:32], data[24:32])
|
||||
copy(bytess[32:36], data[32:36])
|
||||
copy(bytess[36:52], data[36:52])
|
||||
copy(bytess[52:308], data[52:308])
|
||||
copy(bytess[308:324], data[308:324])
|
||||
copy(bytess[324:328], data[324:328])
|
||||
|
||||
// Validate magic number
|
||||
if event.Magic != MAGIC_NUMBER {
|
||||
e.teeLogger.Error("agent", "kernel", "kernel_enforcer", fmt.Sprintf("invalid_magic magic=0x%x expected=0x%x", event.Magic, MAGIC_NUMBER), map[string]interface{}{
|
||||
"magic": fmt.Sprintf("0x%x", event.Magic),
|
||||
"expected": fmt.Sprintf("0x%x", MAGIC_NUMBER),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Forward to rs-helper for policy decision
|
||||
decision, reason, err := e.checkPolicy(event)
|
||||
if err != nil {
|
||||
e.teeLogger.Error("agent", "kernel", "kernel_enforcer", fmt.Sprintf("policy_check_failed error=%v", err), map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if decision == "deny" {
|
||||
e.teeLogger.Error("agent", "kernel", "kernel_enforcer", fmt.Sprintf("execve_deny pid=%d comm=%s reason=%s", event.PID, event.Comm, reason), map[string]interface{}{
|
||||
"pid": event.PID,
|
||||
"comm": fmt.Sprintf("%s", event.Comm),
|
||||
"reason": reason,
|
||||
})
|
||||
} else {
|
||||
log.Printf("[INFO] [kernel] [ebpf] execve_allowed pid=%d comm=%s",
|
||||
event.PID, event.Comm)
|
||||
}
|
||||
}
|
||||
|
||||
// redflagEvent matches the eBPF struct redflag_event layout
|
||||
type redflagEvent struct {
|
||||
Timestamp uint64
|
||||
PID uint32
|
||||
PGID uint32
|
||||
UID uint64
|
||||
GID uint64
|
||||
ParentPID uint32
|
||||
Comm [16]byte
|
||||
Cmdline [256]byte
|
||||
ParentComm [16]byte
|
||||
Magic uint32
|
||||
}
|
||||
|
||||
// checkPolicy evaluates the policy for a package manager invocation
|
||||
func (e *EBPFConsumer) checkPolicy(event *redflagEvent) (string, string, error) {
|
||||
// Decode strings for policy check
|
||||
comm := *(*string)(unsafe.Pointer(&event.Comm))
|
||||
cmdline := *(*string)(unsafe.Pointer(&event.Cmdline))
|
||||
parentComm := *(*string)(unsafe.Pointer(&event.ParentComm))
|
||||
|
||||
log.Printf("[DEBUG] [kernel] [ebpf] policy_check comm=%s cmdline=%s parent=%s uid=%d",
|
||||
comm, cmdline, parentComm, event.UID)
|
||||
|
||||
// Check if this is a package manager command
|
||||
isPackageManager := isPackageManager(comm)
|
||||
if !isPackageManager {
|
||||
log.Printf("[INFO] [kernel] [ebpf] not_package_manager comm=%s", comm)
|
||||
return "allow", "not_package_manager", nil
|
||||
}
|
||||
|
||||
// TODO: Call rs-helper via Unix socket for policy decision
|
||||
// For now, return allow (agent should have rs-helper running)
|
||||
|
||||
return "allow", "policy_evaluation_pending", nil
|
||||
}
|
||||
|
||||
// isPackageManager checks if a command is a known package manager
|
||||
func isPackageManager(comm string) bool {
|
||||
pkgManagers := []string{"apt", "apt-get", "apt-cache", "dnf", "yum", "rpm-ostree",
|
||||
"npm", "pnpm", "bun", "pip", "pip3", "uv",
|
||||
"docker", "crun", "containerd"}
|
||||
|
||||
for _, pm := range pkgManagers {
|
||||
if comm == pm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Event represents a typed intercept event for API use
|
||||
type Event struct {
|
||||
Timestamp time.Time
|
||||
PID uint32
|
||||
PGID uint32
|
||||
Comm string
|
||||
Cmdline string
|
||||
UID uint64
|
||||
GID uint64
|
||||
ParentPID uint32
|
||||
ParentComm string
|
||||
}
|
||||
|
||||
// FromBinary creates an Event from binary data (deprecated, use direct struct access)
|
||||
func FromBinary(data []byte) (*Event, error) {
|
||||
if len(data) < 328 {
|
||||
return nil, fmt.Errorf("data too short: %d", len(data))
|
||||
}
|
||||
|
||||
event := &redflagEvent{}
|
||||
// Use unsafe to copy raw bytes into the struct
|
||||
bytess := (*[328]byte)(unsafe.Pointer(event))
|
||||
copy(bytess[0:8], data[0:8])
|
||||
copy(bytess[8:12], data[8:12])
|
||||
copy(bytess[12:16], data[12:16])
|
||||
copy(bytess[16:24], data[16:24])
|
||||
copy(bytess[24:32], data[24:32])
|
||||
copy(bytess[32:36], data[32:36])
|
||||
copy(bytess[36:52], data[36:52])
|
||||
copy(bytess[52:308], data[52:308])
|
||||
copy(bytess[308:324], data[308:324])
|
||||
copy(bytess[324:328], data[324:328])
|
||||
|
||||
return &Event{
|
||||
Timestamp: time.Unix(0, int64(event.Timestamp)),
|
||||
PID: event.PID,
|
||||
PGID: event.PGID,
|
||||
Comm: string(bytes.TrimRight(event.Comm[:], "\x00")),
|
||||
Cmdline: string(bytes.TrimRight(event.Cmdline[:], "\x00")),
|
||||
UID: event.UID,
|
||||
GID: event.GID,
|
||||
ParentPID: event.ParentPID,
|
||||
ParentComm: string(bytes.TrimRight(event.ParentComm[:], "\x00")),
|
||||
}, nil
|
||||
}
|
||||
84
agent/internal/kernel/kernel.go
Normal file
84
agent/internal/kernel/kernel.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package kernel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/event"
|
||||
)
|
||||
|
||||
type Enforcer interface {
|
||||
Start(ctx context.Context) error
|
||||
Stop() error
|
||||
GetPackageType() string
|
||||
}
|
||||
|
||||
type EnforcerFactory func(*config.Config, *event.TeeLogger) (Enforcer, error)
|
||||
|
||||
var enforcers = map[string]EnforcerFactory{}
|
||||
|
||||
func RegisterEnforcer(pkgType string, factory EnforcerFactory) {
|
||||
enforcers[pkgType] = factory
|
||||
}
|
||||
|
||||
func NewEnforcerWithLogger(cfg *config.Config, teeLogger *event.TeeLogger) (Enforcer, error) {
|
||||
if !cfg.KernelEnforcement.Enabled {
|
||||
return &noopEnforcer{}, nil
|
||||
}
|
||||
|
||||
// Determine package type based on platform
|
||||
pkgType := "linux"
|
||||
if cfg.OS.Type == "windows" {
|
||||
pkgType = "windows"
|
||||
}
|
||||
|
||||
if factory, ok := enforcers[pkgType]; ok {
|
||||
return factory(cfg, teeLogger)
|
||||
}
|
||||
|
||||
log.Printf("[WARNING] [kernel] unknown_package_type=%s", pkgType)
|
||||
return &noopEnforcer{}, nil
|
||||
}
|
||||
|
||||
type noopEnforcer struct{}
|
||||
|
||||
func (n *noopEnforcer) Start(ctx context.Context) error { return nil }
|
||||
func (n *noopEnforcer) Stop() error { return nil }
|
||||
func (n *noopEnforcer) GetPackageType() string { return "noop" }
|
||||
|
||||
type EBPFEnforcer struct {
|
||||
consumer *EBPFConsumer
|
||||
}
|
||||
|
||||
func NewEBPFEnforcer(cfg *config.Config) (*EBPFEnforcer, error) {
|
||||
return NewEBPFEnforcerWithLogger(cfg, nil)
|
||||
}
|
||||
|
||||
func NewEBPFEnforcerWithLogger(cfg *config.Config, teeLogger *event.TeeLogger) (*EBPFEnforcer, error) {
|
||||
consumer, err := NewEBPFConsumerWithLogger(cfg, teeLogger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EBPFEnforcer{consumer: consumer}, nil
|
||||
}
|
||||
|
||||
func (e *EBPFEnforcer) Start(ctx context.Context) error {
|
||||
return e.consumer.Start()
|
||||
}
|
||||
|
||||
func (e *EBPFEnforcer) Stop() error {
|
||||
return e.consumer.Stop()
|
||||
}
|
||||
|
||||
func (e *EBPFEnforcer) GetPackageType() string { return "linux" }
|
||||
|
||||
func init() {
|
||||
RegisterEnforcer("linux", func(cfg *config.Config, teeLogger *event.TeeLogger) (Enforcer, error) {
|
||||
consumer, err := NewEBPFConsumerWithLogger(cfg, teeLogger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EBPFEnforcer{consumer: consumer}, nil
|
||||
})
|
||||
}
|
||||
164
agent/internal/kernel/windows_wdac.go
Normal file
164
agent/internal/kernel/windows_wdac.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package kernel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
)
|
||||
|
||||
// WDACPolicy handles Windows WDAC (Windows Defender Application Control) enforcement
|
||||
type WDACPolicy struct {
|
||||
client *client.Client
|
||||
config *config.Config
|
||||
policyName string
|
||||
policyHash string
|
||||
}
|
||||
|
||||
// NewWDACPolicy creates a new WDACPolicy enforcer
|
||||
func NewWDACPolicy(c *client.Client, cfg *config.Config) *WDACPolicy {
|
||||
return &WDACPolicy{
|
||||
client: c,
|
||||
config: cfg,
|
||||
policyName: "RedFlag-Package-Manager",
|
||||
}
|
||||
}
|
||||
|
||||
// GetPackageType returns the package type this enforcer handles
|
||||
func (w *WDACPolicy) GetPackageType() string {
|
||||
return "wdac"
|
||||
}
|
||||
|
||||
// IsAvailable checks if WDAC is available on this system
|
||||
func (w *WDACPolicy) IsAvailable() bool {
|
||||
// WDAC only available on Windows
|
||||
return true // Platform check done by caller
|
||||
}
|
||||
|
||||
// CheckPolicy evaluates if a package manager invocation is allowed
|
||||
func (w *WDACPolicy) CheckPolicy(packageType, packageName, packageVersion string) (bool, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
log.Printf("[INFO] [kernel] [wdac] policy_check package=%s type=%s version=%s", packageName, packageType, packageVersion)
|
||||
|
||||
result, err := w.fetchPolicy(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [kernel] [wdac] policy_fetch_failed package=%s error=%v", packageName, err)
|
||||
return false, "policy_fetch_failed: " + err.Error(), nil
|
||||
}
|
||||
|
||||
if result == "" {
|
||||
return false, "no_policy", nil
|
||||
}
|
||||
|
||||
// Parse policy and check if package manager is allowed
|
||||
allowed, reason := w.evaluatePolicy(packageType, packageName, result)
|
||||
if !allowed {
|
||||
log.Printf("[ERROR] [kernel] [wdac] policy_denied package=%s reason=%s", packageName, reason)
|
||||
} else {
|
||||
log.Printf("[INFO] [kernel] [wdac] policy_allowed package=%s", packageName)
|
||||
}
|
||||
|
||||
return allowed, reason, nil
|
||||
}
|
||||
|
||||
// fetchPolicy retrieves the current WDAC policy from the server
|
||||
func (w *WDACPolicy) fetchPolicy(ctx context.Context) (string, error) {
|
||||
// TODO: Replace with actual WDAC COM API calls via go-ole
|
||||
// For now, fetch policy hash from server and validate against local policy
|
||||
|
||||
hash, err := w.client.GetWdacPolicyHash(w.config.AgentID, w.config.ServerURL)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [kernel] [wdac] policy_hash_fetch_skipped error=%v", err)
|
||||
// For now, treat as no policy available (fail-closed)
|
||||
return "", fmt.Errorf("no_policy")
|
||||
}
|
||||
|
||||
if hash == "" {
|
||||
return "", fmt.Errorf("no WDAC policy available from server")
|
||||
}
|
||||
|
||||
if hash != w.policyHash {
|
||||
log.Printf("[WARNING] [kernel] [wdac] policy_hash_mismatch local=%s server=%s", w.policyHash, hash)
|
||||
// Policy needs update - return empty to trigger update
|
||||
return "", fmt.Errorf("policy_hash_mismatch")
|
||||
}
|
||||
|
||||
return w.policyHash, nil
|
||||
}
|
||||
|
||||
// evaluatePolicy checks if the package manager is allowed by the current policy
|
||||
func (w *WDACPolicy) evaluatePolicy(packageType, packageName, policyHash string) (bool, string) {
|
||||
// Allowed package managers (fail-closed: deny all others)
|
||||
allowed := map[string]bool{
|
||||
"apt": true,
|
||||
"apt-get": true,
|
||||
"apt-cache": true,
|
||||
"dnf": true,
|
||||
"yum": true,
|
||||
"rpm-ostree": true,
|
||||
"npm": true,
|
||||
"pnpm": true,
|
||||
"bun": true,
|
||||
"pip": true,
|
||||
"pip3": true,
|
||||
"uv": true,
|
||||
"docker": true,
|
||||
"crun": true,
|
||||
"containerd": true,
|
||||
}
|
||||
|
||||
if allowed[packageType] {
|
||||
return true, "allowed_by_policy"
|
||||
}
|
||||
|
||||
return false, "package_manager_not_in_policy"
|
||||
}
|
||||
|
||||
// UpdatePolicy downloads and installs a new WDAC policy from the server
|
||||
func (w *WDACPolicy) UpdatePolicy() error {
|
||||
log.Printf("[INFO] [kernel] [wdac] update_policy_start")
|
||||
|
||||
newHash, err := w.client.GetWdacPolicyHash(w.config.AgentID, w.config.ServerURL)
|
||||
if err != nil {
|
||||
// For now, keep existing policy hash (no update)
|
||||
log.Printf("[WARNING] [kernel] [wdac] policy_update_skipped error=%v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if newHash == "" {
|
||||
return fmt.Errorf("no policy available from server")
|
||||
}
|
||||
|
||||
// TODO: Download policy file and apply via Set-CIPolicy
|
||||
// For now, just update the hash
|
||||
w.policyHash = newHash
|
||||
|
||||
log.Printf("[INFO] [kernel] [wdac] update_policy_complete hash=%s", newHash[:16]+"...")
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallPolicy installs a WDAC policy from a binary or XML file
|
||||
func (w *WDACPolicy) InstallPolicy(policyPath string) error {
|
||||
// TODO: Implement actual WDAC policy installation via COM API
|
||||
// Uses Microsoft.Management.Ops or similar COM interface
|
||||
|
||||
log.Printf("[INFO] [kernel] [wdac] install_policy path=%s", policyPath)
|
||||
return fmt.Errorf("install_policy_not_implemented_yet")
|
||||
}
|
||||
|
||||
// GetPolicyStatus returns the current WDAC policy status
|
||||
func (w *WDACPolicy) GetPolicyStatus() (string, error) {
|
||||
// TODO: Query WDAC service for status
|
||||
return "unknown", nil
|
||||
}
|
||||
|
||||
// ResetPolicy resets the WDAC policy to a known-good state
|
||||
func (w *WDACPolicy) ResetPolicy() error {
|
||||
log.Printf("[WARNING] [kernel] [wdac] reset_policy_initiated")
|
||||
return fmt.Errorf("reset_policy_not_implemented_yet")
|
||||
}
|
||||
100
agent/internal/localapi/approve_test.go
Normal file
100
agent/internal/localapi/approve_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
||||
)
|
||||
|
||||
func approveHandler(t *testing.T, approve func(body []byte) (interface{}, error)) http.Handler {
|
||||
t.Helper()
|
||||
return newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
}, ApproveUpdate: approve})
|
||||
}
|
||||
|
||||
func postApprove(t *testing.T, handler http.Handler, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/actions/approve-update", strings.NewReader(body)))
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestApproveUpdateSuccess(t *testing.T) {
|
||||
var gotBody string
|
||||
handler := approveHandler(t, func(body []byte) (interface{}, error) {
|
||||
gotBody = string(body)
|
||||
return map[string]interface{}{"request_id": "req-9", "osv_status": "clear"}, nil
|
||||
})
|
||||
|
||||
rec := postApprove(t, handler, `{"package_type":"dnf","package_name":"hyprutils"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(gotBody, "hyprutils") {
|
||||
t.Fatalf("callback body = %q, want raw request body", gotBody)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp["request_id"] != "req-9" {
|
||||
t.Fatalf("request_id = %v, want req-9", resp["request_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveUpdateConflictIs409(t *testing.T) {
|
||||
handler := approveHandler(t, func([]byte) (interface{}, error) {
|
||||
return nil, fmt.Errorf("%w: gate refused", ErrApprovalConflict)
|
||||
})
|
||||
rec := postApprove(t, handler, `{}`)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "gate refused") {
|
||||
t.Fatalf("body = %s, want gate refusal detail", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveUpdateNoAuthorityIs503(t *testing.T) {
|
||||
handler := approveHandler(t, func([]byte) (interface{}, error) {
|
||||
return nil, fmt.Errorf("%w: no local authority", ErrApprovalUnavailable)
|
||||
})
|
||||
rec := postApprove(t, handler, `{}`)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveUpdateNilCallbackIs503(t *testing.T) {
|
||||
handler := approveHandler(t, nil)
|
||||
rec := postApprove(t, handler, `{}`)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveUpdateInternalErrorIs500(t *testing.T) {
|
||||
handler := approveHandler(t, func([]byte) (interface{}, error) {
|
||||
return nil, errors.New("dry run exploded")
|
||||
})
|
||||
rec := postApprove(t, handler, `{}`)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveUpdateRejectsGet(t *testing.T) {
|
||||
handler := approveHandler(t, func([]byte) (interface{}, error) { return nil, nil })
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/actions/approve-update", nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want 405", rec.Code)
|
||||
}
|
||||
}
|
||||
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 Desktop.
|
||||
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)
|
||||
}
|
||||
}
|
||||
829
agent/internal/localapi/server.go
Normal file
829
agent/internal/localapi/server.go
Normal file
|
|
@ -0,0 +1,829 @@
|
|||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultUnixGroupName = "redflag-local"
|
||||
DefaultWindowsGroupName = "RedFlagLocal"
|
||||
DefaultWindowsPipeName = `\\.\pipe\RedFlagAgentLocal`
|
||||
)
|
||||
|
||||
// ErrScanInFlight is returned by a TriggerScan callback while a previously
|
||||
// triggered scan is still running. The handler maps it to 409 Conflict.
|
||||
var ErrScanInFlight = errors.New("localapi: scan already in flight")
|
||||
|
||||
// Approval callback sentinels. The loop wiring translates handler/supplychain
|
||||
// errors into these so this package stays decoupled from the approval stack.
|
||||
var (
|
||||
// ErrApprovalConflict → 409: gate refused, fleet mode, duplicate, or an
|
||||
// approval already in flight. The wrapped message carries the specifics.
|
||||
ErrApprovalConflict = errors.New("localapi: approval conflict")
|
||||
// ErrApprovalUnavailable → 503: no local authority on this host.
|
||||
ErrApprovalUnavailable = errors.New("localapi: approval unavailable")
|
||||
)
|
||||
|
||||
// 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
|
||||
DesktopProvider DesktopStatusProvider
|
||||
SystemProvider func() (*system.SystemInfo, error)
|
||||
ProcessProvider func(limit int) ([]system.TopProcess, error)
|
||||
MonitorProvider func() (*system.ResourceSnapshot, error)
|
||||
ProcessesProvider func() (*system.FullProcessSnapshot, error)
|
||||
ProcessDetailProvider func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
|
||||
SoftwareProvider func() (*system.SoftwareSnapshot, error)
|
||||
PackageDetailProvider func(packageType, identity string) (*system.PackageDetail, error)
|
||||
ConnectionsProvider func() (*system.ConnectionSnapshot, error)
|
||||
ServicesProvider func() (*system.ServiceSnapshot, error)
|
||||
DockerProvider func() (*DockerResponse, error)
|
||||
EventsProvider func() ([]*models.SystemEvent, error)
|
||||
ScanRunning func() bool
|
||||
// TriggerScan enqueues a package-update scan through the agent's existing
|
||||
// scanner primitives (FEAT-002 write path). Authorization is the OS-local
|
||||
// group boundary on the socket/pipe — anyone who can connect may trigger.
|
||||
// Nil disables the endpoint (503). Return ErrScanInFlight to signal 409.
|
||||
TriggerScan func(source string) error
|
||||
// ApproveUpdate runs the standalone approval flow (FEAT-003): gates →
|
||||
// mint → execute. Synchronous; the response carries the full verdict.
|
||||
// Nil disables the endpoint (503). Wrap errors in ErrApprovalConflict /
|
||||
// ErrApprovalUnavailable to control the HTTP status.
|
||||
ApproveUpdate func(body []byte) (interface{}, error)
|
||||
// OnDesktopHealth receives each Desktop self-report (POST /v1/desktop) so the
|
||||
// agent can track app liveness/version — on Linux Desktop is autostart-
|
||||
// launched and this is the only signal. Nil means reports are logged only.
|
||||
OnDesktopHealth func(version string, windowOpen bool)
|
||||
}
|
||||
|
||||
// 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{})
|
||||
monitor *system.ResourceMonitor
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
var monitor *system.ResourceMonitor
|
||||
if opts.MonitorProvider == nil {
|
||||
monitor = system.NewResourceMonitor(time.Second, 300)
|
||||
monitor.Start()
|
||||
opts.MonitorProvider = monitor.Snapshot
|
||||
}
|
||||
handler := newHandler(opts)
|
||||
httpServer := &http.Server{Handler: handler}
|
||||
|
||||
listener := opts.ListenerOverride
|
||||
address := ""
|
||||
var err error
|
||||
if listener == nil {
|
||||
listener, address, err = listen(opts)
|
||||
if err != nil {
|
||||
if monitor != nil {
|
||||
monitor.Stop()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
address = listener.Addr().String()
|
||||
}
|
||||
|
||||
srv := &Server{
|
||||
httpServer: httpServer,
|
||||
listener: listener,
|
||||
address: address,
|
||||
logf: opts.RequestLog,
|
||||
monitor: monitor,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if s.monitor != nil {
|
||||
s.monitor.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
cfg *config.Config
|
||||
loadCache func() (*cache.LocalCache, error)
|
||||
desktop DesktopStatusProvider
|
||||
triggerScan func(source string) error
|
||||
approveUpdate func(body []byte) (interface{}, error)
|
||||
onDesktopHealth func(version string, windowOpen bool)
|
||||
systemInfo func() (*system.SystemInfo, error)
|
||||
topProcesses func(limit int) ([]system.TopProcess, error)
|
||||
monitorSnapshot func() (*system.ResourceSnapshot, error)
|
||||
processes func() (*system.FullProcessSnapshot, error)
|
||||
processDetail func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
|
||||
software func() (*system.SoftwareSnapshot, error)
|
||||
packageDetail func(packageType, identity string) (*system.PackageDetail, error)
|
||||
connections func() (*system.ConnectionSnapshot, error)
|
||||
services func() (*system.ServiceSnapshot, error)
|
||||
docker func() (*DockerResponse, error)
|
||||
events func() ([]*models.SystemEvent, error)
|
||||
scanRunning func() bool
|
||||
}
|
||||
|
||||
// DesktopStatusProvider allows the desktop manager to report its status.
|
||||
type DesktopStatusProvider interface {
|
||||
Status() (running bool, pid int)
|
||||
}
|
||||
|
||||
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 {
|
||||
ScanRunning bool `json:"scan_running"`
|
||||
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"`
|
||||
Desktop *DesktopStatus `json:"desktop,omitempty"`
|
||||
}
|
||||
|
||||
type DesktopStatus struct {
|
||||
Running bool `json:"running"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// DesktopHealthRequest is sent by the desktop app to report its health.
|
||||
type DesktopHealthRequest struct {
|
||||
Version string `json:"version"`
|
||||
Uptime int64 `json:"uptime_seconds"`
|
||||
WindowOpen bool `json:"window_open"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type SystemResponse struct {
|
||||
System *system.SystemInfo `json:"system"`
|
||||
TopProcesses []system.TopProcess `json:"top_processes"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type DockerResponse struct {
|
||||
Available bool `json:"available"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Containers []client.DockerReportContainer `json:"containers"`
|
||||
Stacks []client.DockerReportStack `json:"stacks"`
|
||||
Count int `json:"count"`
|
||||
Running int `json:"running"`
|
||||
Unhealthy int `json:"unhealthy"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type EventsResponse struct {
|
||||
Events []*models.SystemEvent `json:"events"`
|
||||
Count int `json:"count"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type SecurityResponse struct {
|
||||
CommandSigningEnabled bool `json:"command_signing_enabled"`
|
||||
CommandEnforcement string `json:"command_enforcement"`
|
||||
TLSVerification bool `json:"tls_verification"`
|
||||
SecurityLogging bool `json:"security_logging"`
|
||||
KernelEnforcement bool `json:"kernel_enforcement"`
|
||||
KernelFailClosed bool `json:"kernel_fail_closed"`
|
||||
DegradedMode bool `json:"degraded_mode"`
|
||||
Registered bool `json:"registered"`
|
||||
CriticalUpdates int `json:"critical_updates"`
|
||||
HighUpdates int `json:"high_updates"`
|
||||
Capabilities cache.CapabilityTokenState `json:"capabilities"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
func newHandler(opts Options) http.Handler {
|
||||
h := &handler{
|
||||
cfg: opts.Config,
|
||||
loadCache: opts.LoadCache,
|
||||
desktop: opts.DesktopProvider,
|
||||
triggerScan: opts.TriggerScan,
|
||||
approveUpdate: opts.ApproveUpdate,
|
||||
onDesktopHealth: opts.OnDesktopHealth,
|
||||
systemInfo: opts.SystemProvider,
|
||||
topProcesses: opts.ProcessProvider,
|
||||
monitorSnapshot: opts.MonitorProvider,
|
||||
processes: opts.ProcessesProvider,
|
||||
processDetail: opts.ProcessDetailProvider,
|
||||
software: opts.SoftwareProvider,
|
||||
packageDetail: opts.PackageDetailProvider,
|
||||
connections: opts.ConnectionsProvider,
|
||||
services: opts.ServicesProvider,
|
||||
docker: opts.DockerProvider,
|
||||
events: opts.EventsProvider,
|
||||
scanRunning: opts.ScanRunning,
|
||||
}
|
||||
if h.systemInfo == nil {
|
||||
h.systemInfo = func() (*system.SystemInfo, error) {
|
||||
return system.GetSystemInfo(version.Version)
|
||||
}
|
||||
}
|
||||
if h.topProcesses == nil {
|
||||
h.topProcesses = system.GetTopProcesses
|
||||
}
|
||||
if h.processes == nil {
|
||||
h.processes = system.GetFullProcessSnapshot
|
||||
}
|
||||
if h.processDetail == nil {
|
||||
h.processDetail = system.GetProcessDetail
|
||||
}
|
||||
if h.software == nil {
|
||||
h.software = system.GetSoftwareSnapshot
|
||||
}
|
||||
if h.packageDetail == nil {
|
||||
h.packageDetail = system.GetPackageDetail
|
||||
}
|
||||
if h.connections == nil {
|
||||
h.connections = system.GetConnectionsSnapshot
|
||||
}
|
||||
if h.services == nil {
|
||||
h.services = system.GetServicesSnapshot
|
||||
}
|
||||
if h.loadCache == nil {
|
||||
h.loadCache = cache.Load
|
||||
}
|
||||
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/system", h.system)
|
||||
mux.HandleFunc("/v1/monitor", h.monitor)
|
||||
mux.HandleFunc("/v1/processes", h.processList)
|
||||
mux.HandleFunc("/v1/processes/", h.processByPID)
|
||||
mux.HandleFunc("/v1/software", h.softwareList)
|
||||
mux.HandleFunc("/v1/software/detail", h.softwareDetail)
|
||||
mux.HandleFunc("/v1/connections", h.connectionList)
|
||||
mux.HandleFunc("/v1/services", h.serviceList)
|
||||
mux.HandleFunc("/v1/containers", h.containerList)
|
||||
mux.HandleFunc("/v1/events", h.eventList)
|
||||
mux.HandleFunc("/v1/security", h.security)
|
||||
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
|
||||
mux.HandleFunc("/v1/desktop", h.desktopHealth)
|
||||
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
|
||||
mux.HandleFunc("/v1/actions/approve-update", h.approveUpdateAction)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (h *handler) containerList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
if h.docker == nil {
|
||||
writeJSON(w, DockerResponse{Available: false, CollectedAt: time.Now().UTC()})
|
||||
return
|
||||
}
|
||||
snapshot, err := h.docker()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] container_inventory_failed error=%v", err)
|
||||
http.Error(w, "container inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) eventList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
if h.events == nil {
|
||||
writeJSON(w, EventsResponse{CollectedAt: time.Now().UTC()})
|
||||
return
|
||||
}
|
||||
events, err := h.events()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] event_history_failed error=%v", err)
|
||||
http.Error(w, "local event history unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
sort.Slice(events, func(i, j int) bool { return events[i].CreatedAt.After(events[j].CreatedAt) })
|
||||
writeJSON(w, EventsResponse{Events: events, Count: len(events), CollectedAt: time.Now().UTC()})
|
||||
}
|
||||
|
||||
func (h *handler) security(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
localCache, ok := h.load(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response := SecurityResponse{
|
||||
CommandSigningEnabled: h.cfg.CommandSigning.Enabled,
|
||||
CommandEnforcement: h.cfg.CommandSigning.EnforcementMode,
|
||||
TLSVerification: !h.cfg.TLS.InsecureSkipVerify,
|
||||
SecurityLogging: h.cfg.SecurityLogging.Enabled,
|
||||
KernelEnforcement: h.cfg.KernelEnforcement.Enabled,
|
||||
KernelFailClosed: h.cfg.KernelEnforcement.FailClosed,
|
||||
DegradedMode: h.cfg.DegradedMode,
|
||||
Registered: h.cfg.IsRegistered(),
|
||||
CriticalUpdates: localCache.Summary.BySeverity["critical"],
|
||||
HighUpdates: localCache.Summary.BySeverity["high"] + localCache.Summary.BySeverity["important"],
|
||||
Capabilities: localCache.Capabilities,
|
||||
CollectedAt: time.Now().UTC(),
|
||||
}
|
||||
writeJSON(w, response)
|
||||
}
|
||||
|
||||
func (h *handler) connectionList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
snapshot, err := h.connections()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] connection_inventory_failed error=%v", err)
|
||||
http.Error(w, "connection inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) serviceList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
snapshot, err := h.services()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] service_inventory_failed error=%v", err)
|
||||
http.Error(w, "service inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) monitor(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
if h.monitorSnapshot == nil {
|
||||
http.Error(w, "resource monitor unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
snapshot, err := h.monitorSnapshot()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] resource_monitor_failed error=%v", err)
|
||||
http.Error(w, "resource monitor unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) processList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
snapshot, err := h.processes()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] process_inventory_failed error=%v", err)
|
||||
http.Error(w, "process inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) processByPID(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
rawPID := strings.TrimPrefix(r.URL.Path, "/v1/processes/")
|
||||
pid, err := strconv.Atoi(rawPID)
|
||||
if err != nil || pid <= 0 || strings.Contains(rawPID, "/") {
|
||||
http.Error(w, "invalid process id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
caps := processCaps(h.cfg.ProcessExplorer)
|
||||
process, err := h.processDetail(pid, caps)
|
||||
if err != nil {
|
||||
log.Printf("[INFO] [agent] [localapi] process_detail_unavailable pid=%d error=%v", pid, err)
|
||||
http.Error(w, "process unavailable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, process)
|
||||
}
|
||||
|
||||
func (h *handler) softwareList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
snapshot, err := h.software()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] software_inventory_failed error=%v", err)
|
||||
http.Error(w, "software inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) softwareDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
packageType := strings.TrimSpace(r.URL.Query().Get("manager"))
|
||||
identity := strings.TrimSpace(r.URL.Query().Get("identity"))
|
||||
if packageType == "" || identity == "" {
|
||||
http.Error(w, "manager and identity are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
detail, err := h.packageDetail(packageType, identity)
|
||||
if err != nil {
|
||||
log.Printf("[INFO] [agent] [localapi] software_detail_unavailable manager=%s identity=%s error=%v", packageType, identity, err)
|
||||
http.Error(w, "software detail unavailable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, detail)
|
||||
}
|
||||
|
||||
func processCaps(cfg config.ProcessExplorerConfig) system.ProcessCaps {
|
||||
capOr := func(value, fallback int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
return system.ProcessCaps{
|
||||
MaxOpenFiles: capOr(cfg.MaxOpenFiles, 2000),
|
||||
MaxSockets: capOr(cfg.MaxSockets, 500),
|
||||
MaxPipes: capOr(cfg.MaxPipes, 500),
|
||||
MaxMemoryMap: capOr(cfg.MaxMemoryMap, 2000),
|
||||
MaxNamespaces: capOr(cfg.MaxNamespaces, 50),
|
||||
MaxEnvKeys: capOr(cfg.MaxEnvKeys, 200),
|
||||
MaxListeningPorts: capOr(cfg.MaxListeningPorts, 100),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) system(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
info, err := h.systemInfo()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [localapi] system_info_failed error=%v", err)
|
||||
http.Error(w, "system health unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
processes, err := h.topProcesses(8)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] top_processes_failed error=%v", err)
|
||||
processes = nil
|
||||
}
|
||||
writeJSON(w, SystemResponse{
|
||||
System: info,
|
||||
TopProcesses: processes,
|
||||
CollectedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
resp := StatusResponse{
|
||||
ScanRunning: h.scanRunning != nil && h.scanRunning(),
|
||||
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(),
|
||||
}
|
||||
|
||||
if h.desktop != nil {
|
||||
running, pid := h.desktop.Status()
|
||||
resp.Desktop = &DesktopStatus{
|
||||
Running: running,
|
||||
PID: pid,
|
||||
Enabled: h.cfg.Desktop.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
// desktopHealth handles POST /v1/desktop — the desktop app reports its health.
|
||||
func (h *handler) desktopHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req DesktopHealthRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Log the desktop health report.
|
||||
log.Printf("[INFO] [agent] [localapi] desktop_health version=%s uptime=%ds window_open=%v",
|
||||
req.Version, req.Uptime, req.WindowOpen)
|
||||
|
||||
if h.onDesktopHealth != nil {
|
||||
h.onDesktopHealth(req.Version, req.WindowOpen)
|
||||
}
|
||||
|
||||
// Respond with agent status so the desktop app can display it.
|
||||
desktopStatus := DesktopStatus{Enabled: h.cfg.Desktop.Enabled}
|
||||
if h.desktop != nil {
|
||||
running, pid := h.desktop.Status()
|
||||
desktopStatus.Running = running
|
||||
desktopStatus.PID = pid
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"agent_version": version.Version,
|
||||
"desktop": desktopStatus,
|
||||
})
|
||||
}
|
||||
|
||||
// triggerScanAction handles POST /v1/actions/trigger-scan — the first local
|
||||
// write endpoint (FEAT-002). The OS-local group ACL on the socket/pipe is the
|
||||
// authorization boundary. The scan runs through the agent's existing scanner
|
||||
// primitives; this never bypasses server command-signing or package
|
||||
// authorization paths because it cannot install anything.
|
||||
func (h *handler) triggerScanAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.triggerScan == nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] scan_trigger_unavailable")
|
||||
http.Error(w, "scan trigger unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.triggerScan("localapi")
|
||||
if errors.Is(err, ErrScanInFlight) {
|
||||
log.Printf("[INFO] [agent] [localapi] scan_trigger_rejected reason=in_flight")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
writeJSONBody(w, map[string]interface{}{"accepted": false, "error": "scan already in flight"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [localapi] scan_trigger_failed error=%v", err)
|
||||
http.Error(w, "scan trigger failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [localapi] scan_trigger_accepted source=localapi")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
writeJSONBody(w, map[string]interface{}{"accepted": true})
|
||||
}
|
||||
|
||||
// approveUpdateAction handles POST /v1/actions/approve-update — the standalone
|
||||
// approval flow (FEAT-003). Authorization is the OS-local group ACL on the
|
||||
// socket/pipe; the real judgment lives in the gates and the root-owned mint
|
||||
// key. Synchronous: the connection is held until verdict + install complete.
|
||||
func (h *handler) approveUpdateAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.approveUpdate == nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] approve_unavailable")
|
||||
http.Error(w, "local approval unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "request read failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.approveUpdate(body)
|
||||
switch {
|
||||
case err == nil:
|
||||
log.Printf("[INFO] [agent] [localapi] approve_completed")
|
||||
writeJSON(w, result)
|
||||
case errors.Is(err, ErrApprovalConflict):
|
||||
log.Printf("[SECURITY] [agent] [localapi] approve_conflict error=%v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
case errors.Is(err, ErrApprovalUnavailable):
|
||||
log.Printf("[WARNING] [agent] [localapi] approve_unavailable error=%v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
default:
|
||||
log.Printf("[ERROR] [agent] [localapi] approve_failed error=%v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
writeJSONBody(w, value)
|
||||
}
|
||||
|
||||
// writeJSONBody encodes without touching headers — for callers that already
|
||||
// wrote a non-200 status code.
|
||||
func writeJSONBody(w http.ResponseWriter, value interface{}) {
|
||||
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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue