Files
Redflag/aggregator-agent/cmd/agent/main.go
Fimeg 03fee29760 v0.1.16: Security overhaul and systematic deployment preparation
Breaking changes for clean alpha releases:
- JWT authentication with user-provided secrets (no more development defaults)
- Registration token system for secure agent enrollment
- Rate limiting with user-adjustable settings
- Enhanced agent configuration with proxy support
- Interactive server setup wizard (--setup flag)
- Heartbeat architecture separation for better UX
- Package status synchronization fixes
- Accurate timestamp tracking for RMM features

Setup process for new installations:
1. docker-compose up -d postgres
2. ./redflag-server --setup
3. ./redflag-server --migrate
4. ./redflag-server
5. Generate tokens via admin UI
6. Deploy agents with registration tokens
2025-10-29 10:38:18 -04:00

1356 lines
43 KiB
Go

package main
import (
"flag"
"fmt"
"log"
"math/rand"
"os"
"runtime"
"strings"
"time"
"github.com/aggregator-project/aggregator-agent/internal/cache"
"github.com/aggregator-project/aggregator-agent/internal/client"
"github.com/aggregator-project/aggregator-agent/internal/config"
"github.com/aggregator-project/aggregator-agent/internal/display"
"github.com/aggregator-project/aggregator-agent/internal/installer"
"github.com/aggregator-project/aggregator-agent/internal/scanner"
"github.com/aggregator-project/aggregator-agent/internal/system"
"github.com/google/uuid"
)
const (
AgentVersion = "0.1.16" // Enhanced configuration system with proxy support and registration tokens
)
// getConfigPath returns the platform-specific config path
func getConfigPath() string {
if runtime.GOOS == "windows" {
return "C:\\ProgramData\\RedFlag\\config.json"
}
return "/etc/aggregator/config.json"
}
// getCurrentPollingInterval returns the appropriate polling interval based on rapid mode
func getCurrentPollingInterval(cfg *config.Config) int {
// Check if rapid polling mode is active and not expired
if cfg.RapidPollingEnabled && time.Now().Before(cfg.RapidPollingUntil) {
return 5 // Rapid polling: 5 seconds
}
// Check if rapid polling has expired and clean up
if cfg.RapidPollingEnabled && time.Now().After(cfg.RapidPollingUntil) {
cfg.RapidPollingEnabled = false
cfg.RapidPollingUntil = time.Time{}
// Save the updated config to clean up expired rapid mode
if err := cfg.Save(getConfigPath()); err != nil {
log.Printf("Warning: Failed to cleanup expired rapid polling mode: %v", err)
}
}
return cfg.CheckInInterval // Normal polling: 5 minutes (300 seconds) by default
}
// getDefaultServerURL returns the default server URL with environment variable support
func getDefaultServerURL() string {
// Check environment variable first
if envURL := os.Getenv("REDFLAG_SERVER_URL"); envURL != "" {
return envURL
}
// Platform-specific defaults
if runtime.GOOS == "windows" {
// For Windows, use a placeholder that prompts users to configure
return "http://REPLACE_WITH_SERVER_IP:8080"
}
return "http://localhost:8080"
}
func main() {
// Define CLI flags
registerCmd := flag.Bool("register", false, "Register agent with server")
scanCmd := flag.Bool("scan", false, "Scan for updates and display locally")
statusCmd := flag.Bool("status", false, "Show agent status")
listUpdatesCmd := flag.Bool("list-updates", false, "List detailed update information")
versionCmd := flag.Bool("version", false, "Show version information")
serverURL := flag.String("server", "", "Server URL")
registrationToken := flag.String("token", "", "Registration token for secure enrollment")
proxyHTTP := flag.String("proxy-http", "", "HTTP proxy URL")
proxyHTTPS := flag.String("proxy-https", "", "HTTPS proxy URL")
proxyNoProxy := flag.String("proxy-no", "", "Comma-separated hosts to bypass proxy")
logLevel := flag.String("log-level", "", "Log level (debug, info, warn, error)")
configFile := flag.String("config", "", "Configuration file path")
tagsFlag := flag.String("tags", "", "Comma-separated tags for agent")
organization := flag.String("organization", "", "Organization/group name")
displayName := flag.String("name", "", "Display name for agent")
insecureTLS := flag.Bool("insecure-tls", false, "Skip TLS certificate verification")
exportFormat := flag.String("export", "", "Export format: json, csv")
flag.Parse()
// Handle version command
if *versionCmd {
fmt.Printf("RedFlag Agent v%s\n", AgentVersion)
fmt.Printf("Self-hosted update management platform\n")
os.Exit(0)
}
// Parse tags from comma-separated string
var tags []string
if *tagsFlag != "" {
tags = strings.Split(*tagsFlag, ",")
for i, tag := range tags {
tags[i] = strings.TrimSpace(tag)
}
}
// Create CLI flags structure
cliFlags := &config.CLIFlags{
ServerURL: *serverURL,
RegistrationToken: *registrationToken,
ProxyHTTP: *proxyHTTP,
ProxyHTTPS: *proxyHTTPS,
ProxyNoProxy: *proxyNoProxy,
LogLevel: *logLevel,
ConfigFile: *configFile,
Tags: tags,
Organization: *organization,
DisplayName: *displayName,
InsecureTLS: *insecureTLS,
}
// Determine config path
configPath := getConfigPath()
if *configFile != "" {
configPath = *configFile
}
// Load configuration with priority: CLI > env > file > defaults
cfg, err := config.Load(configPath, cliFlags)
if err != nil {
log.Fatal("Failed to load configuration:", err)
}
// Handle registration
if *registerCmd {
// Validate server URL for Windows users
if runtime.GOOS == "windows" && strings.Contains(*serverURL, "REPLACE_WITH_SERVER_IP") {
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.Printf(" redflag-agent.exe -register -server http://10.10.20.159:8080\n")
fmt.Println("")
fmt.Println("Option 2 - Use environment variable:")
fmt.Println(" set REDFLAG_SERVER_URL=http://10.10.20.159:8080")
fmt.Println(" redflag-agent.exe -register")
fmt.Println("")
fmt.Println("Option 3 - Create a .env file:")
fmt.Println(" REDFLAG_SERVER_URL=http://10.10.20.159:8080")
fmt.Println("==================================================================")
os.Exit(1)
}
if err := registerAgent(cfg, *serverURL); err != nil {
log.Fatal("Registration failed:", 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
}
// Handle scan command
if *scanCmd {
if err := handleScanCommand(cfg, *exportFormat); err != nil {
log.Fatal("Scan failed:", err)
}
return
}
// Handle status command
if *statusCmd {
if err := handleStatusCommand(cfg); err != nil {
log.Fatal("Status command failed:", err)
}
return
}
// Handle list-updates command
if *listUpdatesCmd {
if err := handleListUpdatesCommand(cfg, *exportFormat); err != nil {
log.Fatal("List updates failed:", err)
}
return
}
// Check if registered
if !cfg.IsRegistered() {
log.Fatal("Agent not registered. Run with -register flag first.")
}
// Start agent service
if err := runAgent(cfg); err != nil {
log.Fatal("Agent failed:", err)
}
}
func registerAgent(cfg *config.Config, serverURL string) error {
// Get detailed system information
sysInfo, err := system.GetSystemInfo(AgentVersion)
if err != nil {
log.Printf("Warning: Failed to get detailed system info: %v\n", err)
// Fall back to basic detection
hostname, _ := os.Hostname()
osType, osVersion, osArch := client.DetectSystem()
sysInfo = &system.SystemInfo{
Hostname: hostname,
OSType: osType,
OSVersion: osVersion,
OSArchitecture: osArch,
AgentVersion: AgentVersion,
Metadata: make(map[string]string),
}
}
apiClient := client.NewClient(serverURL, "")
// Create metadata with system information
metadata := map[string]string{
"installation_time": time.Now().Format(time.RFC3339),
}
// Add system info to metadata
if sysInfo.CPUInfo.ModelName != "" {
metadata["cpu_model"] = sysInfo.CPUInfo.ModelName
}
if sysInfo.CPUInfo.Cores > 0 {
metadata["cpu_cores"] = fmt.Sprintf("%d", sysInfo.CPUInfo.Cores)
}
if sysInfo.MemoryInfo.Total > 0 {
metadata["memory_total"] = fmt.Sprintf("%d", sysInfo.MemoryInfo.Total)
}
if sysInfo.RunningProcesses > 0 {
metadata["processes"] = fmt.Sprintf("%d", sysInfo.RunningProcesses)
}
if sysInfo.Uptime != "" {
metadata["uptime"] = sysInfo.Uptime
}
// Add disk information
for i, disk := range sysInfo.DiskInfo {
if i == 0 {
metadata["disk_mount"] = disk.Mountpoint
metadata["disk_total"] = fmt.Sprintf("%d", disk.Total)
metadata["disk_used"] = fmt.Sprintf("%d", disk.Used)
break // Only add primary disk info
}
}
req := client.RegisterRequest{
Hostname: sysInfo.Hostname,
OSType: sysInfo.OSType,
OSVersion: sysInfo.OSVersion,
OSArchitecture: sysInfo.OSArchitecture,
AgentVersion: sysInfo.AgentVersion,
Metadata: metadata,
}
resp, err := apiClient.Register(req)
if err != nil {
return err
}
// Update configuration
cfg.ServerURL = serverURL
cfg.AgentID = resp.AgentID
cfg.Token = resp.Token
cfg.RefreshToken = resp.RefreshToken
// Get check-in interval from server config
if interval, ok := resp.Config["check_in_interval"].(float64); ok {
cfg.CheckInInterval = int(interval)
} else {
cfg.CheckInInterval = 300 // Default 5 minutes
}
// Save configuration
return cfg.Save(getConfigPath())
}
// renewTokenIfNeeded handles 401 errors by renewing the agent token using refresh token
func renewTokenIfNeeded(apiClient *client.Client, cfg *config.Config, err error) (*client.Client, error) {
if err != nil && strings.Contains(err.Error(), "401 Unauthorized") {
log.Printf("🔄 Access token expired - attempting renewal with refresh token...")
// Check if we have a refresh token
if cfg.RefreshToken == "" {
log.Printf("❌ No refresh token available - re-registration required")
return nil, fmt.Errorf("refresh token missing - please re-register agent")
}
// Create temporary client without token for renewal
tempClient := client.NewClient(cfg.ServerURL, "")
// Attempt to renew access token using refresh token
if err := tempClient.RenewToken(cfg.AgentID, cfg.RefreshToken); err != nil {
log.Printf("❌ Refresh token renewal failed: %v", err)
log.Printf("💡 Refresh token may be expired (>90 days) - re-registration required")
return nil, fmt.Errorf("refresh token renewal failed: %w - please re-register agent", err)
}
// Update config with new access token (agent ID and refresh token stay the same!)
cfg.Token = tempClient.GetToken()
// Save updated config
if err := cfg.Save(getConfigPath()); err != nil {
log.Printf("⚠️ Warning: Failed to save renewed access token: %v", err)
}
log.Printf("✅ Access token renewed successfully - agent ID maintained: %s", cfg.AgentID)
return tempClient, nil
}
// Return original client if no 401 error
return apiClient, nil
}
func runAgent(cfg *config.Config) error {
log.Printf("🚩 RedFlag Agent v%s starting...\n", AgentVersion)
log.Printf("==================================================================")
log.Printf("📋 AGENT ID: %s", cfg.AgentID)
log.Printf("🌐 SERVER: %s", cfg.ServerURL)
log.Printf("⏱️ CHECK-IN INTERVAL: %ds", cfg.CheckInInterval)
log.Printf("==================================================================")
log.Printf("💡 Tip: Use this Agent ID to identify this agent in the web UI")
log.Printf("")
apiClient := client.NewClient(cfg.ServerURL, cfg.Token)
// Initialize scanners
aptScanner := scanner.NewAPTScanner()
dnfScanner := scanner.NewDNFScanner()
dockerScanner, _ := scanner.NewDockerScanner()
windowsUpdateScanner := scanner.NewWindowsUpdateScanner()
wingetScanner := scanner.NewWingetScanner()
// System info tracking
var lastSystemInfoUpdate time.Time
const systemInfoUpdateInterval = 1 * time.Hour // Update detailed system info every hour
// Main check-in loop
for {
// Add jitter to prevent thundering herd
jitter := time.Duration(rand.Intn(30)) * time.Second
time.Sleep(jitter)
// Check if we need to send detailed system info update
if time.Since(lastSystemInfoUpdate) >= systemInfoUpdateInterval {
log.Printf("Updating detailed system information...")
if err := reportSystemInfo(apiClient, cfg); err != nil {
log.Printf("Failed to report system info: %v\n", err)
} else {
lastSystemInfoUpdate = time.Now()
log.Printf("✓ System information updated\n")
}
}
log.Printf("Checking in with server... (Agent v%s)", AgentVersion)
// Collect lightweight system metrics
sysMetrics, err := system.GetLightweightMetrics()
var metrics *client.SystemMetrics
if err == nil {
metrics = &client.SystemMetrics{
CPUPercent: sysMetrics.CPUPercent,
MemoryPercent: sysMetrics.MemoryPercent,
MemoryUsedGB: sysMetrics.MemoryUsedGB,
MemoryTotalGB: sysMetrics.MemoryTotalGB,
DiskUsedGB: sysMetrics.DiskUsedGB,
DiskTotalGB: sysMetrics.DiskTotalGB,
DiskPercent: sysMetrics.DiskPercent,
Uptime: sysMetrics.Uptime,
Version: AgentVersion,
}
}
// Add heartbeat status to metrics metadata if available
if metrics != nil && cfg.RapidPollingEnabled {
// Check if rapid polling is still valid
if time.Now().Before(cfg.RapidPollingUntil) {
// Include heartbeat metadata in metrics
if metrics.Metadata == nil {
metrics.Metadata = make(map[string]interface{})
}
metrics.Metadata["rapid_polling_enabled"] = true
metrics.Metadata["rapid_polling_until"] = cfg.RapidPollingUntil.Format(time.RFC3339)
metrics.Metadata["rapid_polling_duration_minutes"] = int(time.Until(cfg.RapidPollingUntil).Minutes())
} else {
// Heartbeat expired, disable it
cfg.RapidPollingEnabled = false
cfg.RapidPollingUntil = time.Time{}
}
}
// Get commands from server (with optional metrics)
commands, err := apiClient.GetCommands(cfg.AgentID, metrics)
if err != nil {
// Try to renew token if we got a 401 error
newClient, renewErr := renewTokenIfNeeded(apiClient, cfg, err)
if renewErr != nil {
log.Printf("Check-in unsuccessful and token renewal failed: %v\n", renewErr)
time.Sleep(time.Duration(getCurrentPollingInterval(cfg)) * time.Second)
continue
}
// If token was renewed, update client and retry
if newClient != apiClient {
log.Printf("🔄 Retrying check-in with renewed token...")
apiClient = newClient
commands, err = apiClient.GetCommands(cfg.AgentID, metrics)
if err != nil {
log.Printf("Check-in unsuccessful even after token renewal: %v\n", err)
time.Sleep(time.Duration(getCurrentPollingInterval(cfg)) * time.Second)
continue
}
} else {
log.Printf("Check-in unsuccessful: %v\n", err)
time.Sleep(time.Duration(getCurrentPollingInterval(cfg)) * time.Second)
continue
}
}
if len(commands) == 0 {
log.Printf("Check-in successful - no new commands")
} else {
log.Printf("Check-in successful - received %d command(s)", len(commands))
}
// Process each command
for _, cmd := range commands {
log.Printf("Processing command: %s (%s)\n", cmd.Type, cmd.ID)
switch cmd.Type {
case "scan_updates":
if err := handleScanUpdates(apiClient, cfg, aptScanner, dnfScanner, dockerScanner, windowsUpdateScanner, wingetScanner, cmd.ID); err != nil {
log.Printf("Error scanning updates: %v\n", err)
}
case "collect_specs":
log.Println("Spec collection not yet implemented")
case "dry_run_update":
if err := handleDryRunUpdate(apiClient, cfg, cmd.ID, cmd.Params); err != nil {
log.Printf("Error dry running update: %v\n", err)
}
case "install_updates":
if err := handleInstallUpdates(apiClient, cfg, cmd.ID, cmd.Params); err != nil {
log.Printf("Error installing updates: %v\n", err)
}
case "confirm_dependencies":
if err := handleConfirmDependencies(apiClient, cfg, cmd.ID, cmd.Params); err != nil {
log.Printf("Error confirming dependencies: %v\n", err)
}
case "enable_heartbeat":
if err := handleEnableHeartbeat(apiClient, cfg, cmd.ID, cmd.Params); err != nil {
log.Printf("[Heartbeat] Error enabling heartbeat: %v\n", err)
}
case "disable_heartbeat":
if err := handleDisableHeartbeat(apiClient, cfg, cmd.ID); err != nil {
log.Printf("[Heartbeat] Error disabling heartbeat: %v\n", err)
}
default:
log.Printf("Unknown command type: %s\n", cmd.Type)
}
}
// Wait for next check-in
time.Sleep(time.Duration(getCurrentPollingInterval(cfg)) * time.Second)
}
}
func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner *scanner.APTScanner, dnfScanner *scanner.DNFScanner, dockerScanner *scanner.DockerScanner, windowsUpdateScanner *scanner.WindowsUpdateScanner, wingetScanner *scanner.WingetScanner, commandID string) error {
log.Println("Scanning for updates...")
var allUpdates []client.UpdateReportItem
var scanErrors []string
var scanResults []string
// Scan APT updates
if aptScanner.IsAvailable() {
log.Println(" - Scanning APT packages...")
updates, err := aptScanner.Scan()
if err != nil {
errorMsg := fmt.Sprintf("APT scan failed: %v", err)
log.Printf(" %s\n", errorMsg)
scanErrors = append(scanErrors, errorMsg)
} else {
resultMsg := fmt.Sprintf("Found %d APT updates", len(updates))
log.Printf(" %s\n", resultMsg)
scanResults = append(scanResults, resultMsg)
allUpdates = append(allUpdates, updates...)
}
} else {
scanResults = append(scanResults, "APT scanner not available")
}
// Scan DNF updates
if dnfScanner.IsAvailable() {
log.Println(" - Scanning DNF packages...")
updates, err := dnfScanner.Scan()
if err != nil {
errorMsg := fmt.Sprintf("DNF scan failed: %v", err)
log.Printf(" %s\n", errorMsg)
scanErrors = append(scanErrors, errorMsg)
} else {
resultMsg := fmt.Sprintf("Found %d DNF updates", len(updates))
log.Printf(" %s\n", resultMsg)
scanResults = append(scanResults, resultMsg)
allUpdates = append(allUpdates, updates...)
}
} else {
scanResults = append(scanResults, "DNF scanner not available")
}
// Scan Docker updates
if dockerScanner != nil && dockerScanner.IsAvailable() {
log.Println(" - Scanning Docker images...")
updates, err := dockerScanner.Scan()
if err != nil {
errorMsg := fmt.Sprintf("Docker scan failed: %v", err)
log.Printf(" %s\n", errorMsg)
scanErrors = append(scanErrors, errorMsg)
} else {
resultMsg := fmt.Sprintf("Found %d Docker image updates", len(updates))
log.Printf(" %s\n", resultMsg)
scanResults = append(scanResults, resultMsg)
allUpdates = append(allUpdates, updates...)
}
} else {
scanResults = append(scanResults, "Docker scanner not available")
}
// Scan Windows updates
if windowsUpdateScanner.IsAvailable() {
log.Println(" - Scanning Windows updates...")
updates, err := windowsUpdateScanner.Scan()
if err != nil {
errorMsg := fmt.Sprintf("Windows Update scan failed: %v", err)
log.Printf(" %s\n", errorMsg)
scanErrors = append(scanErrors, errorMsg)
} else {
resultMsg := fmt.Sprintf("Found %d Windows updates", len(updates))
log.Printf(" %s\n", resultMsg)
scanResults = append(scanResults, resultMsg)
allUpdates = append(allUpdates, updates...)
}
} else {
scanResults = append(scanResults, "Windows Update scanner not available")
}
// Scan Winget packages
if wingetScanner.IsAvailable() {
log.Println(" - Scanning Winget packages...")
updates, err := wingetScanner.Scan()
if err != nil {
errorMsg := fmt.Sprintf("Winget scan failed: %v", err)
log.Printf(" %s\n", errorMsg)
scanErrors = append(scanErrors, errorMsg)
} else {
resultMsg := fmt.Sprintf("Found %d Winget package updates", len(updates))
log.Printf(" %s\n", resultMsg)
scanResults = append(scanResults, resultMsg)
allUpdates = append(allUpdates, updates...)
}
} else {
scanResults = append(scanResults, "Winget scanner not available")
}
// Report scan results to server (both successes and failures)
success := len(allUpdates) > 0 || len(scanErrors) == 0
var combinedOutput string
// Combine all scan results
if len(scanResults) > 0 {
combinedOutput += "Scan Results:\n" + strings.Join(scanResults, "\n")
}
if len(scanErrors) > 0 {
if combinedOutput != "" {
combinedOutput += "\n"
}
combinedOutput += "Scan Errors:\n" + strings.Join(scanErrors, "\n")
}
if len(allUpdates) > 0 {
if combinedOutput != "" {
combinedOutput += "\n"
}
combinedOutput += fmt.Sprintf("Total Updates Found: %d", len(allUpdates))
}
// Create scan log entry
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_updates",
Result: map[bool]string{true: "success", false: "failure"}[success],
Stdout: combinedOutput,
Stderr: strings.Join(scanErrors, "\n"),
ExitCode: map[bool]int{true: 0, false: 1}[success],
DurationSeconds: 0, // Could track scan duration if needed
}
// Report the scan log
if err := apiClient.ReportLog(cfg.AgentID, logReport); err != nil {
log.Printf("Failed to report scan log: %v\n", err)
// Continue anyway - updates are more important
}
// Report updates to server if any were found
if len(allUpdates) > 0 {
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now(),
Updates: allUpdates,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report updates: %w", err)
}
log.Printf("✓ Reported %d updates to server\n", len(allUpdates))
} else {
log.Println("✓ No updates found")
}
// Return error if there were any scan failures
if len(scanErrors) > 0 && len(allUpdates) == 0 {
return fmt.Errorf("all scanners failed: %s", strings.Join(scanErrors, "; "))
}
return nil
}
// handleScanCommand performs a local scan and displays results
func handleScanCommand(cfg *config.Config, exportFormat string) error {
// Initialize scanners
aptScanner := scanner.NewAPTScanner()
dnfScanner := scanner.NewDNFScanner()
dockerScanner, _ := scanner.NewDockerScanner()
windowsUpdateScanner := scanner.NewWindowsUpdateScanner()
wingetScanner := scanner.NewWingetScanner()
fmt.Println("🔍 Scanning for updates...")
var allUpdates []client.UpdateReportItem
// Scan APT updates
if aptScanner.IsAvailable() {
fmt.Println(" - Scanning APT packages...")
updates, err := aptScanner.Scan()
if err != nil {
fmt.Printf(" ⚠️ APT scan failed: %v\n", err)
} else {
fmt.Printf(" ✓ Found %d APT updates\n", len(updates))
allUpdates = append(allUpdates, updates...)
}
}
// Scan DNF updates
if dnfScanner.IsAvailable() {
fmt.Println(" - Scanning DNF packages...")
updates, err := dnfScanner.Scan()
if err != nil {
fmt.Printf(" ⚠️ DNF scan failed: %v\n", err)
} else {
fmt.Printf(" ✓ Found %d DNF updates\n", len(updates))
allUpdates = append(allUpdates, updates...)
}
}
// Scan Docker updates
if dockerScanner != nil && dockerScanner.IsAvailable() {
fmt.Println(" - Scanning Docker images...")
updates, err := dockerScanner.Scan()
if err != nil {
fmt.Printf(" ⚠️ Docker scan failed: %v\n", err)
} else {
fmt.Printf(" ✓ Found %d Docker image updates\n", len(updates))
allUpdates = append(allUpdates, updates...)
}
}
// Scan Windows updates
if windowsUpdateScanner.IsAvailable() {
fmt.Println(" - Scanning Windows updates...")
updates, err := windowsUpdateScanner.Scan()
if err != nil {
fmt.Printf(" ⚠️ Windows Update scan failed: %v\n", err)
} else {
fmt.Printf(" ✓ Found %d Windows updates\n", len(updates))
allUpdates = append(allUpdates, updates...)
}
}
// Scan Winget packages
if wingetScanner.IsAvailable() {
fmt.Println(" - Scanning Winget packages...")
updates, err := wingetScanner.Scan()
if err != nil {
fmt.Printf(" ⚠️ Winget scan failed: %v\n", err)
} else {
fmt.Printf(" ✓ Found %d Winget package updates\n", len(updates))
allUpdates = append(allUpdates, updates...)
}
}
// Load and update cache
localCache, err := cache.Load()
if err != nil {
fmt.Printf("⚠️ Warning: Failed to load cache: %v\n", err)
localCache = &cache.LocalCache{}
}
// Update cache with scan results
localCache.UpdateScanResults(allUpdates)
if cfg.IsRegistered() {
localCache.SetAgentInfo(cfg.AgentID, cfg.ServerURL)
localCache.SetAgentStatus("online")
}
// Save cache
if err := localCache.Save(); err != nil {
fmt.Printf("⚠️ Warning: Failed to save cache: %v\n", err)
}
// Display results
fmt.Println()
return display.PrintScanResults(allUpdates, exportFormat)
}
// handleStatusCommand displays agent status information
func handleStatusCommand(cfg *config.Config) error {
// Load cache
localCache, err := cache.Load()
if err != nil {
return fmt.Errorf("failed to load cache: %w", err)
}
// Determine status
agentStatus := "offline"
if cfg.IsRegistered() {
agentStatus = "online"
}
if localCache.AgentStatus != "" {
agentStatus = localCache.AgentStatus
}
// Use cached info if available, otherwise use config
agentID := cfg.AgentID.String()
if localCache.AgentID != (uuid.UUID{}) {
agentID = localCache.AgentID.String()
}
serverURL := cfg.ServerURL
if localCache.ServerURL != "" {
serverURL = localCache.ServerURL
}
// Display status
display.PrintAgentStatus(
agentID,
serverURL,
localCache.LastCheckIn,
localCache.LastScanTime,
localCache.UpdateCount,
agentStatus,
)
return nil
}
// handleListUpdatesCommand displays detailed update information
func handleListUpdatesCommand(cfg *config.Config, exportFormat string) error {
// Load cache
localCache, err := cache.Load()
if err != nil {
return fmt.Errorf("failed to load cache: %w", err)
}
// Check if we have cached scan results
if len(localCache.Updates) == 0 {
fmt.Println("📋 No cached scan results found.")
fmt.Println("💡 Run '--scan' first to discover available updates.")
return nil
}
// Warn if cache is old
if localCache.IsExpired(24 * time.Hour) {
fmt.Printf("⚠️ Scan results are %s old. Run '--scan' for latest results.\n\n",
formatTimeSince(localCache.LastScanTime))
}
// Display detailed results
return display.PrintDetailedUpdates(localCache.Updates, exportFormat)
}
// handleInstallUpdates handles install_updates command
func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandID string, params map[string]interface{}) error {
log.Println("Installing updates...")
// Parse parameters
packageType := ""
packageName := ""
if pt, ok := params["package_type"].(string); ok {
packageType = pt
}
if pn, ok := params["package_name"].(string); ok {
packageName = pn
}
// Validate package type
if packageType == "" {
return fmt.Errorf("package_type parameter is required")
}
// Create installer based on package type
inst, err := installer.InstallerFactory(packageType)
if err != nil {
return fmt.Errorf("failed to create installer for package type %s: %w", packageType, err)
}
// Check if installer is available
if !inst.IsAvailable() {
return fmt.Errorf("%s installer is not available on this system", packageType)
}
var result *installer.InstallResult
var action string
// Perform installation based on what's specified
if packageName != "" {
action = "update"
log.Printf("Updating package: %s (type: %s)", packageName, packageType)
result, err = inst.UpdatePackage(packageName)
} else if len(params) > 1 {
// Multiple packages might be specified in various ways
var packageNames []string
for key, value := range params {
if key != "package_type" {
if name, ok := value.(string); ok && name != "" {
packageNames = append(packageNames, name)
}
}
}
if len(packageNames) > 0 {
action = "install_multiple"
log.Printf("Installing multiple packages: %v (type: %s)", packageNames, packageType)
result, err = inst.InstallMultiple(packageNames)
} else {
// Upgrade all packages if no specific packages named
action = "upgrade"
log.Printf("Upgrading all packages (type: %s)", packageType)
result, err = inst.Upgrade()
}
} else {
// Upgrade all packages if no specific packages named
action = "upgrade"
log.Printf("Upgrading all packages (type: %s)", packageType)
result, err = inst.Upgrade()
}
if err != nil {
// Report installation failure with actual command output
logReport := client.LogReport{
CommandID: commandID,
Action: action,
Result: "failed",
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
}
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
log.Printf("Failed to report installation failure: %v\n", reportErr)
}
return fmt.Errorf("installation failed: %w", err)
}
// Report installation success
logReport := client.LogReport{
CommandID: commandID,
Action: result.Action,
Result: "success",
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
}
// Add additional metadata to the log report
if len(result.PackagesInstalled) > 0 {
logReport.Stdout += fmt.Sprintf("\nPackages installed: %v", result.PackagesInstalled)
}
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
log.Printf("Failed to report installation success: %v\n", reportErr)
}
if result.Success {
log.Printf("✓ Installation completed successfully in %d seconds\n", result.DurationSeconds)
if len(result.PackagesInstalled) > 0 {
log.Printf(" Packages installed: %v\n", result.PackagesInstalled)
}
} else {
log.Printf("✗ Installation failed after %d seconds\n", result.DurationSeconds)
log.Printf(" Error: %s\n", result.ErrorMessage)
}
return nil
}
// handleDryRunUpdate handles dry_run_update command
func handleDryRunUpdate(apiClient *client.Client, cfg *config.Config, commandID string, params map[string]interface{}) error {
log.Println("Performing dry run update...")
// Parse parameters
packageType := ""
packageName := ""
if pt, ok := params["package_type"].(string); ok {
packageType = pt
}
if pn, ok := params["package_name"].(string); ok {
packageName = pn
}
// Validate parameters
if packageType == "" || packageName == "" {
return fmt.Errorf("package_type and package_name parameters are required")
}
// Create installer based on package type
inst, err := installer.InstallerFactory(packageType)
if err != nil {
return fmt.Errorf("failed to create installer for package type %s: %w", packageType, err)
}
// Check if installer is available
if !inst.IsAvailable() {
return fmt.Errorf("%s installer is not available on this system", packageType)
}
// Perform dry run
log.Printf("Dry running package: %s (type: %s)", packageName, packageType)
result, err := inst.DryRun(packageName)
if err != nil {
// Report dry run failure
logReport := client.LogReport{
CommandID: commandID,
Action: "dry_run",
Result: "failed",
Stdout: "",
Stderr: fmt.Sprintf("Dry run error: %v", err),
ExitCode: 1,
DurationSeconds: 0,
}
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
log.Printf("Failed to report dry run failure: %v\n", reportErr)
}
return fmt.Errorf("dry run failed: %w", err)
}
// Convert installer.InstallResult to client.InstallResult for reporting
clientResult := &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,
}
// Report dependencies back to server
depReport := client.DependencyReport{
PackageName: packageName,
PackageType: packageType,
Dependencies: result.Dependencies,
UpdateID: params["update_id"].(string),
DryRunResult: clientResult,
}
if reportErr := apiClient.ReportDependencies(cfg.AgentID, depReport); reportErr != nil {
log.Printf("Failed to report dependencies: %v\n", reportErr)
return fmt.Errorf("failed to report dependencies: %w", reportErr)
}
// Report dry run success
logReport := client.LogReport{
CommandID: commandID,
Action: "dry_run",
Result: "success",
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
}
if len(result.Dependencies) > 0 {
logReport.Stdout += fmt.Sprintf("\nDependencies found: %v", result.Dependencies)
}
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
log.Printf("Failed to report dry run success: %v\n", reportErr)
}
if result.Success {
log.Printf("✓ Dry run completed successfully in %d seconds\n", result.DurationSeconds)
if len(result.Dependencies) > 0 {
log.Printf(" Dependencies found: %v\n", result.Dependencies)
} else {
log.Printf(" No additional dependencies found\n")
}
} else {
log.Printf("✗ Dry run failed after %d seconds\n", result.DurationSeconds)
log.Printf(" Error: %s\n", result.ErrorMessage)
}
return nil
}
// handleConfirmDependencies handles confirm_dependencies command
func handleConfirmDependencies(apiClient *client.Client, cfg *config.Config, commandID string, params map[string]interface{}) error {
log.Println("Installing update with confirmed dependencies...")
// Parse parameters
packageType := ""
packageName := ""
var dependencies []string
if pt, ok := params["package_type"].(string); ok {
packageType = pt
}
if pn, ok := params["package_name"].(string); ok {
packageName = pn
}
if deps, ok := params["dependencies"].([]interface{}); ok {
for _, dep := range deps {
if depStr, ok := dep.(string); ok {
dependencies = append(dependencies, depStr)
}
}
}
// Validate parameters
if packageType == "" || packageName == "" {
return fmt.Errorf("package_type and package_name parameters are required")
}
// Create installer based on package type
inst, err := installer.InstallerFactory(packageType)
if err != nil {
return fmt.Errorf("failed to create installer for package type %s: %w", packageType, err)
}
// Check if installer is available
if !inst.IsAvailable() {
return fmt.Errorf("%s installer is not available on this system", packageType)
}
var result *installer.InstallResult
var action string
// Perform installation with dependencies
if len(dependencies) > 0 {
action = "install_with_dependencies"
log.Printf("Installing package with dependencies: %s (dependencies: %v)", packageName, dependencies)
// Install main package + dependencies
allPackages := append([]string{packageName}, dependencies...)
result, err = inst.InstallMultiple(allPackages)
} else {
action = "upgrade"
log.Printf("Installing package: %s (no dependencies)", packageName)
// Use UpdatePackage instead of Install to handle existing packages
result, err = inst.UpdatePackage(packageName)
}
if err != nil {
// Report installation failure with actual command output
logReport := client.LogReport{
CommandID: commandID,
Action: action,
Result: "failed",
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
}
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
log.Printf("Failed to report installation failure: %v\n", reportErr)
}
return fmt.Errorf("installation failed: %w", err)
}
// Report installation success
logReport := client.LogReport{
CommandID: commandID,
Action: result.Action,
Result: "success",
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
}
// Add additional metadata to the log report
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 := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
log.Printf("Failed to report installation success: %v\n", reportErr)
}
if result.Success {
log.Printf("✓ Installation with dependencies completed successfully in %d seconds\n", result.DurationSeconds)
if len(result.PackagesInstalled) > 0 {
log.Printf(" Packages installed: %v\n", result.PackagesInstalled)
}
} else {
log.Printf("✗ Installation with dependencies failed after %d seconds\n", result.DurationSeconds)
log.Printf(" Error: %s\n", result.ErrorMessage)
}
return nil
}
// handleEnableHeartbeat handles enable_heartbeat command
func handleEnableHeartbeat(apiClient *client.Client, cfg *config.Config, commandID string, params map[string]interface{}) error {
// Parse duration parameter (default to 10 minutes)
durationMinutes := 10
if duration, ok := params["duration_minutes"]; ok {
if durationFloat, ok := duration.(float64); ok {
durationMinutes = int(durationFloat)
}
}
// Calculate when heartbeat should expire
expiryTime := time.Now().Add(time.Duration(durationMinutes) * time.Minute)
log.Printf("[Heartbeat] Enabling rapid polling for %d minutes (expires: %s)", durationMinutes, expiryTime.Format(time.RFC3339))
// Update agent config to enable rapid polling
cfg.RapidPollingEnabled = true
cfg.RapidPollingUntil = expiryTime
// Save config to persist heartbeat settings
if err := cfg.Save(getConfigPath()); err != nil {
log.Printf("[Heartbeat] Warning: Failed to save config: %v", err)
}
// Create log report for heartbeat enable
logReport := client.LogReport{
CommandID: commandID,
Action: "enable_heartbeat",
Result: "success",
Stdout: fmt.Sprintf("Heartbeat enabled for %d minutes", durationMinutes),
Stderr: "",
ExitCode: 0,
DurationSeconds: 0,
}
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
log.Printf("[Heartbeat] Failed to report heartbeat enable: %v", reportErr)
}
// Send immediate check-in to update heartbeat status in UI
log.Printf("[Heartbeat] Sending immediate check-in to update status")
sysMetrics, err := system.GetLightweightMetrics()
if err == nil {
metrics := &client.SystemMetrics{
CPUPercent: sysMetrics.CPUPercent,
MemoryPercent: sysMetrics.MemoryPercent,
MemoryUsedGB: sysMetrics.MemoryUsedGB,
MemoryTotalGB: sysMetrics.MemoryTotalGB,
DiskUsedGB: sysMetrics.DiskUsedGB,
DiskTotalGB: sysMetrics.DiskTotalGB,
DiskPercent: sysMetrics.DiskPercent,
Uptime: sysMetrics.Uptime,
Version: AgentVersion,
}
// Include heartbeat metadata to show enabled state
metrics.Metadata = map[string]interface{}{
"rapid_polling_enabled": true,
"rapid_polling_until": expiryTime.Format(time.RFC3339),
}
// Send immediate check-in with updated heartbeat status
_, checkinErr := apiClient.GetCommands(cfg.AgentID, metrics)
if checkinErr != nil {
log.Printf("[Heartbeat] Failed to send immediate check-in: %v", checkinErr)
} else {
log.Printf("[Heartbeat] Immediate check-in sent successfully")
}
} else {
log.Printf("[Heartbeat] Failed to get system metrics for immediate check-in: %v", err)
}
log.Printf("[Heartbeat] Rapid polling enabled successfully")
return nil
}
// handleDisableHeartbeat handles disable_heartbeat command
func handleDisableHeartbeat(apiClient *client.Client, cfg *config.Config, commandID string) error {
log.Printf("[Heartbeat] Disabling rapid polling")
// Update agent config to disable rapid polling
cfg.RapidPollingEnabled = false
cfg.RapidPollingUntil = time.Time{} // Zero value
// Save config to persist heartbeat settings
if err := cfg.Save(getConfigPath()); err != nil {
log.Printf("[Heartbeat] Warning: Failed to save config: %v", err)
}
// Create log report for heartbeat disable
logReport := client.LogReport{
CommandID: commandID,
Action: "disable_heartbeat",
Result: "success",
Stdout: "Heartbeat disabled",
Stderr: "",
ExitCode: 0,
DurationSeconds: 0,
}
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
log.Printf("[Heartbeat] Failed to report heartbeat disable: %v", reportErr)
}
// Send immediate check-in to update heartbeat status in UI
log.Printf("[Heartbeat] Sending immediate check-in to update status")
sysMetrics, err := system.GetLightweightMetrics()
if err == nil {
metrics := &client.SystemMetrics{
CPUPercent: sysMetrics.CPUPercent,
MemoryPercent: sysMetrics.MemoryPercent,
MemoryUsedGB: sysMetrics.MemoryUsedGB,
MemoryTotalGB: sysMetrics.MemoryTotalGB,
DiskUsedGB: sysMetrics.DiskUsedGB,
DiskTotalGB: sysMetrics.DiskTotalGB,
DiskPercent: sysMetrics.DiskPercent,
Uptime: sysMetrics.Uptime,
Version: AgentVersion,
}
// Include empty heartbeat metadata to explicitly show disabled state
metrics.Metadata = map[string]interface{}{
"rapid_polling_enabled": false,
"rapid_polling_until": "",
}
// Send immediate check-in with updated heartbeat status
_, checkinErr := apiClient.GetCommands(cfg.AgentID, metrics)
if checkinErr != nil {
log.Printf("[Heartbeat] Failed to send immediate check-in: %v", checkinErr)
} else {
log.Printf("[Heartbeat] Immediate check-in sent successfully")
}
} else {
log.Printf("[Heartbeat] Failed to get system metrics for immediate check-in: %v", err)
}
log.Printf("[Heartbeat] Rapid polling disabled successfully")
return nil
}
// reportSystemInfo collects and reports detailed system information to the server
func reportSystemInfo(apiClient *client.Client, cfg *config.Config) error {
// Collect detailed system information
sysInfo, err := system.GetSystemInfo(AgentVersion)
if err != nil {
return fmt.Errorf("failed to get system info: %w", err)
}
// Create system info report
report := client.SystemInfoReport{
Timestamp: time.Now(),
CPUModel: sysInfo.CPUInfo.ModelName,
CPUCores: sysInfo.CPUInfo.Cores,
CPUThreads: sysInfo.CPUInfo.Threads,
MemoryTotal: sysInfo.MemoryInfo.Total,
DiskTotal: uint64(0),
DiskUsed: uint64(0),
IPAddress: sysInfo.IPAddress,
Processes: sysInfo.RunningProcesses,
Uptime: sysInfo.Uptime,
Metadata: make(map[string]interface{}),
}
// Add primary disk info
if len(sysInfo.DiskInfo) > 0 {
primaryDisk := sysInfo.DiskInfo[0]
report.DiskTotal = primaryDisk.Total
report.DiskUsed = primaryDisk.Used
report.Metadata["disk_mount"] = primaryDisk.Mountpoint
report.Metadata["disk_filesystem"] = primaryDisk.Filesystem
}
// Add collection timestamp and additional metadata
report.Metadata["collected_at"] = time.Now().Format(time.RFC3339)
report.Metadata["hostname"] = sysInfo.Hostname
report.Metadata["os_type"] = sysInfo.OSType
report.Metadata["os_version"] = sysInfo.OSVersion
report.Metadata["os_architecture"] = sysInfo.OSArchitecture
// Add any existing metadata from system info
for key, value := range sysInfo.Metadata {
report.Metadata[key] = value
}
// Report to server
if err := apiClient.ReportSystemInfo(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report system info: %w", err)
}
return nil
}
// formatTimeSince formats a duration as "X time ago"
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))
}
}