diff --git a/.gitignore b/.gitignore index eab6bc1..8b3153a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,13 @@ *.so *.dylib +# All documentation goes in docs/ folder (private development) +docs/ +*.md +!README.md +!LICENSE +!.env.example + # Test binary, built with `go test -c` *.test @@ -226,6 +233,13 @@ Thumbs.db *.sqlite *.sqlite3 +# Compiled binaries (project-specific) +redflag-agent +redflag-server +aggregator-agent/redflag-agent +aggregator-server/redflag-server +aggregator-server/server + # Agent configuration (may contain sensitive data) aggregator-agent/config.json aggregator-agent/.agent-id @@ -256,6 +270,11 @@ test-results/ .devenv/ dev/ +# Development packages and scripts +aggregator-agent/pkg/ +aggregator-server/scripts/ +aggregator-server/internal/utils/ + # Build artifacts *.tar.gz *.zip @@ -369,9 +388,9 @@ secrets/ # Screenshots (needed for README) !Screenshots/ -*.png -*.jpg -*.jpeg +!Screenshots/*.png +!Screenshots/*.jpg +!Screenshots/*.jpeg # Core functionality (needed for working system) !aggregator-agent/internal/installer/ @@ -383,36 +402,6 @@ secrets/ # Only minimal README, no other documentation # ============================================================================= -# Exclude detailed documentation and session files (keep private development) -# ============================================================================= -# Exclude ALL documentation files - this is private development -*.md -!README.md -!LICENSE -!.env.example -!docker-compose.yml -!Makefile -*.html -*.txt - -# Session and development files -SESSION_* -claude* -TECHNICAL_* -COMPETITIVE_* -PROXMOX_* -HOW_TO_* -NEXT_* -Starting* -README_D* -README_backup* - -# Setup and documentation files -SETUP_* -CONTRIBUTING* -.github/ -docs/ - # AI / LLM Development Files -.claude/ -*claude* \ No newline at end of file +# ============================================================================= +.claude/ \ No newline at end of file diff --git a/README.md b/README.md index 1e9d2d9..28bd2ba 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,10 @@ A self-hosted, cross-platform update management platform built with: - Go server backend + PostgreSQL - React web dashboard with TypeScript -- Linux agents with APT + Docker scanning +- Cross-platform agents (Linux APT/DNF/Docker, Windows Updates/Winget) - Local CLI tools for agent management -- Update installation system (alpha) +- Update installation system with dependency management +- Refresh token authentication for stable agent identity ## What This Isn't @@ -32,34 +33,49 @@ A self-hosted, cross-platform update management platform built with: ### Working Features - Server backend with REST API -- Agent registration and check-in -- Update discovery for APT packages and Docker images -- Update approval workflow -- Web dashboard with agent management -- Local CLI tools (--scan, --status, --list-updates, --export) -- Update installation system (alpha quality) +- Cross-platform agent registration and check-in +- Update discovery for APT, DNF, Docker images, Windows Updates, and Winget packages +- Update approval workflow with dependency confirmation +- Web dashboard with agent management and real-time status +- Local CLI tools (--scan, --status, --list-updates, --export, --export=json/csv) +- Update installation system with dry-run dependency checking +- Beautiful terminal output with colors and severity indicators +- Local cache system for offline viewing of scan results +- Refresh token authentication for stable agent identity +- Event-sourced database architecture for scalability ### Known Limitations -- Update installation is minimally tested -- DNF/RPM scanner incomplete -- No rate limiting on API endpoints -- No Windows agent support +- No rate limiting on API endpoints (security improvement needed) - No real-time WebSocket updates +- Proxmox integration is broken (needs complete rewrite) +- Authentication system works but needs security hardening ## Screenshots -### Default Dashboard -![Default Dashboard](Screenshots/RedFlag%20Default%20Dashboard.png) -Main overview showing agent status, system metrics, and update statistics +### Main Dashboard +![Main Dashboard](Screenshots/RedFlag%20Default%20Dashboard.png) +Overview showing agent status, system metrics, and update statistics ### Updates Management ![Updates Dashboard](Screenshots/RedFlag%20Updates%20Dashboard.png) -Comprehensive update listing with filtering, approval, and bulk operations +Comprehensive update listing with filtering, approval, and dependency confirmation ### Agent Details -![Agent Dashboard](Screenshots/RedFlag%20Agent%20Dashboard.png) +![Agent Details](Screenshots/RedFlag%20Agent%20Details.png) Detailed agent information including system specs, last check-in, and individual update management +### Windows Agent Support +![Windows Agent](Screenshots/RedFlag%20Windows%20Agent%20Details.png) +Cross-platform support for Windows Updates and Winget package management + +### History & Audit Trail +![History Dashboard](Screenshots/RedFlag%20History%20Dashboard.png) +Complete audit trail of all update activities and command execution + +### Live Operations +![Live Operations](Screenshots/RedFlag%20Live%20Operations%20-%20Failed%20Dashboard.png) +Real-time view of update operations with success/failure tracking + ### Docker Container Management ![Docker Dashboard](Screenshots/RedFlag%20Docker%20Dashboard.png) Docker-specific interface for container image updates and management diff --git a/Screenshots/RedFlag Agent Dashboard.png b/Screenshots/RedFlag Agent Dashboard.png index a201680..a38cde9 100644 Binary files a/Screenshots/RedFlag Agent Dashboard.png and b/Screenshots/RedFlag Agent Dashboard.png differ diff --git a/Screenshots/RedFlag Agent Details - old.png b/Screenshots/RedFlag Agent Details - old.png new file mode 100644 index 0000000..f9acc99 Binary files /dev/null and b/Screenshots/RedFlag Agent Details - old.png differ diff --git a/Screenshots/RedFlag Agent Details.png b/Screenshots/RedFlag Agent Details.png new file mode 100644 index 0000000..8050b12 Binary files /dev/null and b/Screenshots/RedFlag Agent Details.png differ diff --git a/Screenshots/RedFlag History Dashboard.png b/Screenshots/RedFlag History Dashboard.png new file mode 100644 index 0000000..203646a Binary files /dev/null and b/Screenshots/RedFlag History Dashboard.png differ diff --git a/Screenshots/RedFlag Live Operations - Failed Dashboard.png b/Screenshots/RedFlag Live Operations - Failed Dashboard.png new file mode 100644 index 0000000..581581d Binary files /dev/null and b/Screenshots/RedFlag Live Operations - Failed Dashboard.png differ diff --git a/Screenshots/RedFlag Updates Dashboard.png b/Screenshots/RedFlag Updates Dashboard.png index a9d197f..ee26e5f 100644 Binary files a/Screenshots/RedFlag Updates Dashboard.png and b/Screenshots/RedFlag Updates Dashboard.png differ diff --git a/Screenshots/RedFlag Windows Agent Details.png b/Screenshots/RedFlag Windows Agent Details.png new file mode 100644 index 0000000..2f5ad4e Binary files /dev/null and b/Screenshots/RedFlag Windows Agent Details.png differ diff --git a/aggregator-agent/aggregator-agent b/aggregator-agent/aggregator-agent deleted file mode 100755 index ef180bb..0000000 Binary files a/aggregator-agent/aggregator-agent and /dev/null differ diff --git a/aggregator-agent/cmd/agent/main.go b/aggregator-agent/cmd/agent/main.go index 9add442..4fc12a0 100644 --- a/aggregator-agent/cmd/agent/main.go +++ b/aggregator-agent/cmd/agent/main.go @@ -6,6 +6,8 @@ import ( "log" "math/rand" "os" + "runtime" + "strings" "time" "github.com/aggregator-project/aggregator-agent/internal/cache" @@ -19,27 +21,68 @@ import ( ) const ( - AgentVersion = "0.1.0" - ConfigPath = "/etc/aggregator/config.json" + AgentVersion = "0.1.5" // Command status synchronization, timeout fixes, DNF improvements ) +// 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" +} + +// 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() { 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") - serverURL := flag.String("server", "http://localhost:8080", "Server URL") + serverURL := flag.String("server", getDefaultServerURL(), "Server URL") exportFormat := flag.String("export", "", "Export format: json, csv") flag.Parse() // Load configuration - cfg, err := config.Load(ConfigPath) + cfg, err := config.Load(getConfigPath()) 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) } @@ -161,6 +204,7 @@ func registerAgent(cfg *config.Config, serverURL string) error { 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 { @@ -170,7 +214,44 @@ func registerAgent(cfg *config.Config, serverURL string) error { } // Save configuration - return cfg.Save(ConfigPath) + 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 { @@ -189,6 +270,8 @@ func runAgent(cfg *config.Config) error { aptScanner := scanner.NewAPTScanner() dnfScanner := scanner.NewDNFScanner() dockerScanner, _ := scanner.NewDockerScanner() + windowsUpdateScanner := scanner.NewWindowsUpdateScanner() + wingetScanner := scanner.NewWingetScanner() // Main check-in loop for { @@ -196,14 +279,57 @@ func runAgent(cfg *config.Config) error { jitter := time.Duration(rand.Intn(30)) * time.Second time.Sleep(jitter) - log.Println("Checking in with server...") + log.Printf("Checking in with server... (Agent v%s)", AgentVersion) - // Get commands from server - commands, err := apiClient.GetCommands(cfg.AgentID) + // 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, + } + } + + // Get commands from server (with optional metrics) + commands, err := apiClient.GetCommands(cfg.AgentID, metrics) if err != nil { - log.Printf("Error getting commands: %v\n", err) - time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second) - continue + // 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(cfg.CheckInInterval) * 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(cfg.CheckInInterval) * time.Second) + continue + } + } else { + log.Printf("Check-in unsuccessful: %v\n", err) + time.Sleep(time.Duration(cfg.CheckInInterval) * 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 @@ -212,18 +338,28 @@ func runAgent(cfg *config.Config) error { switch cmd.Type { case "scan_updates": - if err := handleScanUpdates(apiClient, cfg, aptScanner, dnfScanner, dockerScanner, cmd.ID); err != nil { + 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) + } + default: log.Printf("Unknown command type: %s\n", cmd.Type) } @@ -234,7 +370,7 @@ func runAgent(cfg *config.Config) error { } } -func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner *scanner.APTScanner, dnfScanner *scanner.DNFScanner, dockerScanner *scanner.DockerScanner, commandID string) error { +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 @@ -275,6 +411,30 @@ func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner } } + // Scan Windows updates + if windowsUpdateScanner.IsAvailable() { + log.Println(" - Scanning Windows updates...") + updates, err := windowsUpdateScanner.Scan() + if err != nil { + log.Printf(" Windows Update scan failed: %v\n", err) + } else { + log.Printf(" Found %d Windows updates\n", len(updates)) + allUpdates = append(allUpdates, updates...) + } + } + + // Scan Winget packages + if wingetScanner.IsAvailable() { + log.Println(" - Scanning Winget packages...") + updates, err := wingetScanner.Scan() + if err != nil { + log.Printf(" Winget scan failed: %v\n", err) + } else { + log.Printf(" Found %d Winget package updates\n", len(updates)) + allUpdates = append(allUpdates, updates...) + } + } + // Report to server if len(allUpdates) > 0 { report := client.UpdateReport{ @@ -301,6 +461,8 @@ func handleScanCommand(cfg *config.Config, exportFormat string) error { aptScanner := scanner.NewAPTScanner() dnfScanner := scanner.NewDNFScanner() dockerScanner, _ := scanner.NewDockerScanner() + windowsUpdateScanner := scanner.NewWindowsUpdateScanner() + wingetScanner := scanner.NewWingetScanner() fmt.Println("🔍 Scanning for updates...") var allUpdates []client.UpdateReportItem @@ -341,6 +503,30 @@ func handleScanCommand(cfg *config.Config, exportFormat string) error { } } + // 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 { @@ -438,7 +624,6 @@ func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandI // Parse parameters packageType := "" packageName := "" - targetVersion := "" if pt, ok := params["package_type"].(string); ok { packageType = pt @@ -446,9 +631,6 @@ func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandI if pn, ok := params["package_name"].(string); ok { packageName = pn } - if tv, ok := params["target_version"].(string); ok { - targetVersion = tv - } // Validate package type if packageType == "" { @@ -478,7 +660,7 @@ func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandI // Multiple packages might be specified in various ways var packageNames []string for key, value := range params { - if key != "package_type" && key != "target_version" { + if key != "package_type" { if name, ok := value.(string); ok && name != "" { packageNames = append(packageNames, name) } @@ -553,6 +735,232 @@ func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandI 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 = "install" + log.Printf("Installing package: %s (no dependencies)", packageName) + result, err = inst.Install(packageName) + } + + if err != nil { + // Report installation failure + logReport := client.LogReport{ + CommandID: commandID, + Action: action, + Result: "failed", + Stdout: "", + Stderr: fmt.Sprintf("Installation error: %v", err), + ExitCode: 1, + DurationSeconds: 0, + } + + 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 +} + // formatTimeSince formats a duration as "X time ago" func formatTimeSince(t time.Time) string { duration := time.Since(t) diff --git a/aggregator-agent/go.mod b/aggregator-agent/go.mod index 82e0918..d669714 100644 --- a/aggregator-agent/go.mod +++ b/aggregator-agent/go.mod @@ -16,6 +16,7 @@ require ( 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/go-ole/go-ole v1.3.0 // 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 @@ -23,6 +24,7 @@ require ( 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 + github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect diff --git a/aggregator-agent/go.sum b/aggregator-agent/go.sum index b80e020..20ed85e 100644 --- a/aggregator-agent/go.sum +++ b/aggregator-agent/go.sum @@ -23,6 +23,8 @@ 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/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= @@ -47,6 +49,8 @@ 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -94,6 +98,7 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w 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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/aggregator-agent/install.sh b/aggregator-agent/install.sh new file mode 100755 index 0000000..6f7b14f --- /dev/null +++ b/aggregator-agent/install.sh @@ -0,0 +1,221 @@ +#!/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 +} + +# 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" +} + +# 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 refresh -y +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 (uncomment if needed) +# 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" < 0 { + return &InstallResult{ + Success: true, + Stdout: installResult.Stdout, + Stderr: installResult.Stderr, + ExitCode: installResult.ExitCode, + DurationSeconds: duration, + Dependencies: dependencies, + IsDryRun: true, + Action: "dry_run", + }, nil + } + + return &InstallResult{ + Success: false, + ErrorMessage: fmt.Sprintf("APT dry run failed: %v", err), + Stdout: installResult.Stdout, + Stderr: installResult.Stderr, + ExitCode: installResult.ExitCode, + DurationSeconds: duration, + IsDryRun: true, + Action: "dry_run", + }, err + } + + return &InstallResult{ + Success: true, + Stdout: installResult.Stdout, + Stderr: installResult.Stderr, + ExitCode: installResult.ExitCode, + DurationSeconds: duration, + Dependencies: dependencies, + IsDryRun: true, + Action: "dry_run", + }, 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" diff --git a/aggregator-agent/internal/installer/dnf.go b/aggregator-agent/internal/installer/dnf.go index 2f62db2..21615f2 100644 --- a/aggregator-agent/internal/installer/dnf.go +++ b/aggregator-agent/internal/installer/dnf.go @@ -2,18 +2,23 @@ package installer import ( "fmt" + "log" "os/exec" + "regexp" + "strings" "time" - - "github.com/aggregator-project/aggregator-agent/internal/client" ) // DNFInstaller handles DNF package installations -type DNFInstaller struct{} +type DNFInstaller struct { + executor *SecureCommandExecutor +} // NewDNFInstaller creates a new DNF installer func NewDNFInstaller() *DNFInstaller { - return &DNFInstaller{} + return &DNFInstaller{ + executor: NewSecureCommandExecutor(), + } } // IsAvailable checks if DNF is available on this system @@ -26,38 +31,36 @@ func (i *DNFInstaller) IsAvailable() bool { func (i *DNFInstaller) Install(packageName string) (*InstallResult, error) { startTime := time.Now() - // Refresh package cache first - refreshCmd := exec.Command("sudo", "dnf", "refresh", "-y") - if output, err := refreshCmd.CombinedOutput(); err != nil { - return &InstallResult{ - Success: false, - ErrorMessage: fmt.Sprintf("Failed to refresh DNF cache: %v\nStdout: %s", err, string(output)), - DurationSeconds: int(time.Since(startTime).Seconds()), - }, fmt.Errorf("dnf refresh failed: %w", err) + // Refresh package cache first using secure executor + refreshResult, err := i.executor.ExecuteCommand("dnf", []string{"makecache"}) + if err != nil { + refreshResult.DurationSeconds = int(time.Since(startTime).Seconds()) + refreshResult.ErrorMessage = fmt.Sprintf("Failed to refresh DNF cache: %v", err) + return refreshResult, fmt.Errorf("dnf refresh failed: %w", err) } - // Install package - installCmd := exec.Command("sudo", "dnf", "install", "-y", packageName) - output, err := installCmd.CombinedOutput() + // Install package using secure executor + installResult, err := i.executor.ExecuteCommand("dnf", []string{"install", "-y", packageName}) duration := int(time.Since(startTime).Seconds()) if err != nil { return &InstallResult{ Success: false, ErrorMessage: fmt.Sprintf("DNF install failed: %v", err), - Stdout: string(output), - Stderr: "", - ExitCode: getExitCode(err), + Stdout: installResult.Stdout, + Stderr: installResult.Stderr, + ExitCode: installResult.ExitCode, DurationSeconds: duration, }, err } return &InstallResult{ Success: true, - Stdout: string(output), - Stderr: "", - ExitCode: 0, + Stdout: installResult.Stdout, + Stderr: installResult.Stderr, + ExitCode: installResult.ExitCode, DurationSeconds: duration, + PackagesInstalled: []string{packageName}, }, nil } @@ -72,39 +75,36 @@ func (i *DNFInstaller) InstallMultiple(packageNames []string) (*InstallResult, e startTime := time.Now() - // Refresh package cache first - refreshCmd := exec.Command("sudo", "dnf", "refresh", "-y") - if output, err := refreshCmd.CombinedOutput(); err != nil { - return &InstallResult{ - Success: false, - ErrorMessage: fmt.Sprintf("Failed to refresh DNF cache: %v\nStdout: %s", err, string(output)), - DurationSeconds: int(time.Since(startTime).Seconds()), - }, fmt.Errorf("dnf refresh failed: %w", err) + // Refresh package cache first using secure executor + refreshResult, err := i.executor.ExecuteCommand("dnf", []string{"makecache"}) + if err != nil { + refreshResult.DurationSeconds = int(time.Since(startTime).Seconds()) + refreshResult.ErrorMessage = fmt.Sprintf("Failed to refresh DNF cache: %v", err) + return refreshResult, fmt.Errorf("dnf refresh failed: %w", err) } - // Install all packages in one command + // Install all packages in one command using secure executor args := []string{"install", "-y"} args = append(args, packageNames...) - installCmd := exec.Command("sudo", "dnf", args...) - output, err := installCmd.CombinedOutput() + installResult, err := i.executor.ExecuteCommand("dnf", args) duration := int(time.Since(startTime).Seconds()) if err != nil { return &InstallResult{ Success: false, ErrorMessage: fmt.Sprintf("DNF install failed: %v", err), - Stdout: string(output), - Stderr: "", - ExitCode: getExitCode(err), + Stdout: installResult.Stdout, + Stderr: installResult.Stderr, + ExitCode: installResult.ExitCode, DurationSeconds: duration, }, err } return &InstallResult{ Success: true, - Stdout: string(output), - Stderr: "", - ExitCode: 0, + Stdout: installResult.Stdout, + Stderr: installResult.Stderr, + ExitCode: installResult.ExitCode, DurationSeconds: duration, PackagesInstalled: packageNames, }, nil @@ -114,42 +114,191 @@ func (i *DNFInstaller) InstallMultiple(packageNames []string) (*InstallResult, e func (i *DNFInstaller) Upgrade() (*InstallResult, error) { startTime := time.Now() - // Refresh package cache first - refreshCmd := exec.Command("sudo", "dnf", "refresh", "-y") - if output, err := refreshCmd.CombinedOutput(); err != nil { - return &InstallResult{ - Success: false, - ErrorMessage: fmt.Sprintf("Failed to refresh DNF cache: %v\nStdout: %s", err, string(output)), - DurationSeconds: int(time.Since(startTime).Seconds()), - }, fmt.Errorf("dnf refresh failed: %w", err) + // Refresh package cache first using secure executor + refreshResult, err := i.executor.ExecuteCommand("dnf", []string{"makecache"}) + if err != nil { + refreshResult.DurationSeconds = int(time.Since(startTime).Seconds()) + refreshResult.ErrorMessage = fmt.Sprintf("Failed to refresh DNF cache: %v", err) + return refreshResult, fmt.Errorf("dnf refresh failed: %w", err) } - // Upgrade all packages - upgradeCmd := exec.Command("sudo", "dnf", "upgrade", "-y") - output, err := upgradeCmd.CombinedOutput() + // Upgrade all packages using secure executor + upgradeResult, err := i.executor.ExecuteCommand("dnf", []string{"upgrade", "-y"}) duration := int(time.Since(startTime).Seconds()) if err != nil { return &InstallResult{ Success: false, ErrorMessage: fmt.Sprintf("DNF upgrade failed: %v", err), - Stdout: string(output), - Stderr: "", - ExitCode: getExitCode(err), + Stdout: upgradeResult.Stdout, + Stderr: upgradeResult.Stderr, + ExitCode: upgradeResult.ExitCode, DurationSeconds: duration, }, err } return &InstallResult{ Success: true, - Stdout: string(output), - Stderr: "", - ExitCode: 0, + Stdout: upgradeResult.Stdout, + Stderr: upgradeResult.Stderr, + ExitCode: upgradeResult.ExitCode, DurationSeconds: duration, Action: "upgrade", }, nil } +// DryRun performs a dry run installation to check dependencies +func (i *DNFInstaller) DryRun(packageName string) (*InstallResult, error) { + startTime := time.Now() + + // Attempt to refresh package cache, but don't fail if it doesn't work + // (dry run can still work with slightly stale cache) + refreshResult, refreshErr := i.executor.ExecuteCommand("dnf", []string{"makecache"}) + if refreshErr != nil { + // Log refresh attempt but don't fail the dry run + log.Printf("Warning: DNF makecache failed (continuing with dry run): %v", refreshErr) + } + _ = refreshResult // Discard refresh result intentionally + + // Perform dry run installation using secure executor + installResult, err := i.executor.ExecuteCommand("dnf", []string{"install", "--assumeno", "--downloadonly", packageName}) + duration := int(time.Since(startTime).Seconds()) + + // Parse dependencies from the output + dependencies := i.parseDependenciesFromDNFOutput(installResult.Stdout, packageName) + + if err != nil { + // DNF dry run may return non-zero exit code even for successful dependency resolution + // so we check if we were able to parse dependencies + if len(dependencies) > 0 { + return &InstallResult{ + Success: true, + Stdout: installResult.Stdout, + Stderr: installResult.Stderr, + ExitCode: installResult.ExitCode, + DurationSeconds: duration, + Dependencies: dependencies, + IsDryRun: true, + Action: "dry_run", + }, nil + } + + return &InstallResult{ + Success: false, + ErrorMessage: fmt.Sprintf("DNF dry run failed: %v", err), + Stdout: installResult.Stdout, + Stderr: installResult.Stderr, + ExitCode: installResult.ExitCode, + DurationSeconds: duration, + IsDryRun: true, + Action: "dry_run", + }, err + } + + return &InstallResult{ + Success: true, + Stdout: installResult.Stdout, + Stderr: installResult.Stderr, + ExitCode: installResult.ExitCode, + DurationSeconds: duration, + Dependencies: dependencies, + IsDryRun: true, + Action: "dry_run", + }, nil +} + +// 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" diff --git a/aggregator-agent/internal/installer/docker.go b/aggregator-agent/internal/installer/docker.go index 6ea51a5..121c515 100644 --- a/aggregator-agent/internal/installer/docker.go +++ b/aggregator-agent/internal/installer/docker.go @@ -5,8 +5,6 @@ import ( "os/exec" "strings" "time" - - "github.com/aggregator-project/aggregator-agent/internal/client" ) // DockerInstaller handles Docker image updates @@ -129,20 +127,63 @@ func (i *DockerInstaller) Upgrade() (*InstallResult, error) { }, 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 string) (*InstallResult, error) { + startTime := time.Now() + + // Check if image exists locally + inspectCmd := exec.Command("sudo", "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("sudo", "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" } -// 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 -} \ No newline at end of file diff --git a/aggregator-agent/internal/installer/installer.go b/aggregator-agent/internal/installer/installer.go index 692cd5e..43d8b19 100644 --- a/aggregator-agent/internal/installer/installer.go +++ b/aggregator-agent/internal/installer/installer.go @@ -1,5 +1,7 @@ package installer +import "fmt" + // Installer interface for different package types type Installer interface { IsAvailable() bool @@ -7,6 +9,7 @@ type Installer interface { InstallMultiple(packageNames []string) (*InstallResult, error) Upgrade() (*InstallResult, error) GetPackageType() string + DryRun(packageName string) (*InstallResult, error) // New: Perform dry run to check dependencies } // InstallerFactory creates appropriate installer based on package type @@ -18,6 +21,10 @@ func InstallerFactory(packageType string) (Installer, error) { return NewDNFInstaller(), nil case "docker_image": return NewDockerInstaller() + case "windows_update": + return NewWindowsUpdateInstaller(), nil + case "winget": + return NewWingetInstaller(), nil default: return nil, fmt.Errorf("unsupported package type: %s", packageType) } diff --git a/aggregator-agent/internal/installer/security.go b/aggregator-agent/internal/installer/security.go new file mode 100644 index 0000000..0f1022d --- /dev/null +++ b/aggregator-agent/internal/installer/security.go @@ -0,0 +1,203 @@ +package installer + +import ( + "fmt" + "os/exec" + "strings" +) + +// SecureCommandExecutor handles secure execution of privileged commands +type SecureCommandExecutor struct{} + +// NewSecureCommandExecutor creates a new secure command executor +func NewSecureCommandExecutor() *SecureCommandExecutor { + return &SecureCommandExecutor{} +} + +// AllowedCommands defines the commands that can be executed with elevated privileges +var AllowedCommands = map[string][]string{ + "apt-get": { + "update", + "install", + "upgrade", + }, + "dnf": { + "refresh", + "install", + "upgrade", + }, + "docker": { + "pull", + "image", + "manifest", + }, +} + +// validateCommand checks if a command is allowed to be executed +func (e *SecureCommandExecutor) validateCommand(baseCmd string, args []string) error { + if len(args) == 0 { + return fmt.Errorf("no arguments provided for command: %s", baseCmd) + } + + allowedArgs, ok := AllowedCommands[baseCmd] + if !ok { + return fmt.Errorf("command not allowed: %s", baseCmd) + } + + // Check if the first argument (subcommand) is allowed + if !contains(allowedArgs, args[0]) { + return fmt.Errorf("command not allowed: %s %s", baseCmd, args[0]) + } + + // Additional validation for specific commands + switch baseCmd { + case "apt-get": + return e.validateAPTCommand(args) + case "dnf": + return e.validateDNFCommand(args) + case "docker": + return e.validateDockerCommand(args) + } + + return nil +} + +// validateAPTCommand performs additional validation for APT commands +func (e *SecureCommandExecutor) validateAPTCommand(args []string) error { + switch args[0] { + case "install": + // Ensure install commands have safe flags + if !contains(args, "-y") && !contains(args, "--yes") { + return fmt.Errorf("apt-get install must include -y or --yes flag") + } + // Check for dangerous flags + dangerousFlags := []string{"--allow-unauthenticated", "--allow-insecure-repositories"} + for _, flag := range dangerousFlags { + if contains(args, flag) { + return fmt.Errorf("dangerous flag not allowed: %s", flag) + } + } + case "upgrade": + // Ensure upgrade commands have safe flags + if !contains(args, "-y") && !contains(args, "--yes") { + return fmt.Errorf("apt-get upgrade must include -y or --yes flag") + } + } + return nil +} + +// validateDNFCommand performs additional validation for DNF commands +func (e *SecureCommandExecutor) validateDNFCommand(args []string) error { + switch args[0] { + case "refresh": + if !contains(args, "-y") { + return fmt.Errorf("dnf refresh must include -y flag") + } + case "install": + // Allow dry-run flags for dependency checking + dryRunFlags := []string{"--assumeno", "--downloadonly"} + hasDryRun := false + for _, flag := range dryRunFlags { + if contains(args, flag) { + hasDryRun = true + break + } + } + // If it's a dry run, allow it without -y + if hasDryRun { + return nil + } + // Otherwise require -y flag for regular installs + if !contains(args, "-y") { + return fmt.Errorf("dnf install must include -y flag") + } + case "upgrade": + if !contains(args, "-y") { + return fmt.Errorf("dnf upgrade must include -y flag") + } + } + return nil +} + +// validateDockerCommand performs additional validation for Docker commands +func (e *SecureCommandExecutor) validateDockerCommand(args []string) error { + switch args[0] { + case "pull": + if len(args) < 2 { + return fmt.Errorf("docker pull requires an image name") + } + // Basic image name validation + imageName := args[1] + if strings.Contains(imageName, "..") || strings.HasPrefix(imageName, "-") { + return fmt.Errorf("invalid docker image name: %s", imageName) + } + case "image": + if len(args) < 2 { + return fmt.Errorf("docker image requires a subcommand") + } + if args[1] != "inspect" { + return fmt.Errorf("docker image subcommand not allowed: %s", args[1]) + } + if len(args) < 3 { + return fmt.Errorf("docker image inspect requires an image name") + } + case "manifest": + if len(args) < 2 { + return fmt.Errorf("docker manifest requires a subcommand") + } + if args[1] != "inspect" { + return fmt.Errorf("docker manifest subcommand not allowed: %s", args[1]) + } + if len(args) < 3 { + return fmt.Errorf("docker manifest inspect requires an image name") + } + } + return nil +} + +// ExecuteCommand securely executes a command with validation +func (e *SecureCommandExecutor) ExecuteCommand(baseCmd string, args []string) (*InstallResult, error) { + // Validate the command before execution + if err := e.validateCommand(baseCmd, args); err != nil { + return &InstallResult{ + Success: false, + ErrorMessage: fmt.Sprintf("Command validation failed: %v", err), + }, fmt.Errorf("command validation failed: %w", err) + } + + // Log the command for audit purposes (in a real implementation, this would go to a secure log) + fmt.Printf("[AUDIT] Executing command: %s %s\n", baseCmd, strings.Join(args, " ")) + + // Execute the command without sudo - it will be handled by sudoers + fullArgs := append([]string{baseCmd}, args...) + cmd := exec.Command(fullArgs[0], fullArgs[1:]...) + + output, err := cmd.CombinedOutput() + + if err != nil { + return &InstallResult{ + Success: false, + ErrorMessage: fmt.Sprintf("Command execution failed: %v", err), + Stdout: string(output), + Stderr: "", + ExitCode: getExitCode(err), + }, err + } + + return &InstallResult{ + Success: true, + Stdout: string(output), + Stderr: "", + ExitCode: 0, + }, nil +} + +// contains checks if a string slice contains a specific string +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} \ No newline at end of file diff --git a/aggregator-agent/internal/installer/sudoers.go b/aggregator-agent/internal/installer/sudoers.go new file mode 100644 index 0000000..6124b24 --- /dev/null +++ b/aggregator-agent/internal/installer/sudoers.go @@ -0,0 +1,192 @@ +package installer + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "text/template" +) + +// SudoersConfig represents the sudoers configuration for the RedFlag agent +const SudoersTemplate = `# RedFlag Agent minimal sudo permissions +# This file is generated automatically during RedFlag agent installation +# Location: /etc/sudoers.d/redflag-agent + +# 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 refresh -y +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 (alternative approach - uncomment if using Docker group instead of sudo) +# 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 * +` + +// SudoersInstaller handles the installation of sudoers configuration +type SudoersInstaller struct{} + +// NewSudoersInstaller creates a new sudoers installer +func NewSudoersInstaller() *SudoersInstaller { + return &SudoersInstaller{} +} + +// InstallSudoersConfig installs the sudoers configuration +func (s *SudoersInstaller) InstallSudoersConfig() error { + // Create the sudoers configuration content + tmpl, err := template.New("sudoers").Parse(SudoersTemplate) + if err != nil { + return fmt.Errorf("failed to parse sudoers template: %w", err) + } + + // Ensure the sudoers.d directory exists + sudoersDir := "/etc/sudoers.d" + if _, err := os.Stat(sudoersDir); os.IsNotExist(err) { + if err := os.MkdirAll(sudoersDir, 0755); err != nil { + return fmt.Errorf("failed to create sudoers.d directory: %w", err) + } + } + + // Create the sudoers file + sudoersFile := filepath.Join(sudoersDir, "redflag-agent") + file, err := os.OpenFile(sudoersFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0440) + if err != nil { + return fmt.Errorf("failed to create sudoers file: %w", err) + } + defer file.Close() + + // Write the template to the file + if err := tmpl.Execute(file, nil); err != nil { + return fmt.Errorf("failed to write sudoers configuration: %w", err) + } + + // Verify the sudoers file syntax + if err := s.validateSudoersFile(sudoersFile); err != nil { + // Remove the invalid file + os.Remove(sudoersFile) + return fmt.Errorf("invalid sudoers configuration: %w", err) + } + + fmt.Printf("Successfully installed sudoers configuration at: %s\n", sudoersFile) + return nil +} + +// validateSudoersFile validates the syntax of a sudoers file +func (s *SudoersInstaller) validateSudoersFile(sudoersFile string) error { + // Use visudo to validate the sudoers file + cmd := exec.Command("visudo", "-c", "-f", sudoersFile) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("sudoers validation failed: %v\nOutput: %s", err, string(output)) + } + return nil +} + +// CreateRedflagAgentUser creates the redflag-agent user if it doesn't exist +func (s *SudoersInstaller) CreateRedflagAgentUser() error { + // Check if user already exists + if _, err := os.Stat("/var/lib/redflag-agent"); err == nil { + fmt.Println("redflag-agent user already exists") + return nil + } + + // Create the user with systemd as a system user + commands := [][]string{ + {"useradd", "-r", "-s", "/bin/false", "-d", "/var/lib/redflag-agent", "redflag-agent"}, + {"mkdir", "-p", "/var/lib/redflag-agent"}, + {"chown", "redflag-agent:redflag-agent", "/var/lib/redflag-agent"}, + } + + for _, cmdArgs := range commands { + cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to execute %v: %v\nOutput: %s", cmdArgs, err, string(output)) + } + } + + fmt.Println("Successfully created redflag-agent user") + return nil +} + +// SetupDockerGroup adds the redflag-agent user to the docker group (alternative to sudo for Docker) +func (s *SudoersInstaller) SetupDockerGroup() error { + // Check if docker group exists + if _, err := os.Stat("/var/run/docker.sock"); os.IsNotExist(err) { + fmt.Println("Docker is not installed, skipping docker group setup") + return nil + } + + // Add user to docker group + cmd := exec.Command("usermod", "-aG", "docker", "redflag-agent") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to add redflag-agent to docker group: %v\nOutput: %s", err, string(output)) + } + + fmt.Println("Successfully added redflag-agent to docker group") + return nil +} + +// CreateSystemdService creates a systemd service file for the agent +func (s *SudoersInstaller) CreateSystemdService() error { + const serviceTemplate = `[Unit] +Description=RedFlag Update Agent +After=network.target + +[Service] +Type=simple +User=redflag-agent +Group=redflag-agent +WorkingDirectory=/var/lib/redflag-agent +ExecStart=/usr/local/bin/redflag-agent +Restart=always +RestartSec=30 + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/redflag-agent +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +` + + serviceFile := "/etc/systemd/system/redflag-agent.service" + file, err := os.OpenFile(serviceFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to create systemd service file: %w", err) + } + defer file.Close() + + if _, err := file.WriteString(serviceTemplate); err != nil { + return fmt.Errorf("failed to write systemd service file: %w", err) + } + + // Reload systemd + if err := exec.Command("systemctl", "daemon-reload").Run(); err != nil { + return fmt.Errorf("failed to reload systemd: %w", err) + } + + fmt.Printf("Successfully created systemd service at: %s\n", serviceFile) + return nil +} + +// Cleanup removes sudoers configuration +func (s *SudoersInstaller) Cleanup() error { + sudoersFile := "/etc/sudoers.d/redflag-agent" + if _, err := os.Stat(sudoersFile); err == nil { + if err := os.Remove(sudoersFile); err != nil { + return fmt.Errorf("failed to remove sudoers file: %w", err) + } + fmt.Println("Successfully removed sudoers configuration") + } + return nil +} \ No newline at end of file diff --git a/aggregator-agent/internal/installer/types.go b/aggregator-agent/internal/installer/types.go index 49eb8e0..0c68e0e 100644 --- a/aggregator-agent/internal/installer/types.go +++ b/aggregator-agent/internal/installer/types.go @@ -11,4 +11,6 @@ type InstallResult struct { 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 } \ No newline at end of file diff --git a/aggregator-agent/internal/installer/windows.go b/aggregator-agent/internal/installer/windows.go new file mode 100644 index 0000000..b4f52e6 --- /dev/null +++ b/aggregator-agent/internal/installer/windows.go @@ -0,0 +1,162 @@ +package installer + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + "time" +) + +// WindowsUpdateInstaller handles Windows Update installation +type WindowsUpdateInstaller struct{} + +// NewWindowsUpdateInstaller creates a new Windows Update installer +func NewWindowsUpdateInstaller() *WindowsUpdateInstaller { + return &WindowsUpdateInstaller{} +} + +// IsAvailable checks if Windows Update installer is available on this system +func (i *WindowsUpdateInstaller) IsAvailable() bool { + // Only available on Windows + 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 +func (i *WindowsUpdateInstaller) Install(packageName string) (*InstallResult, error) { + return i.installUpdates([]string{packageName}, false) +} + +// InstallMultiple installs multiple Windows updates +func (i *WindowsUpdateInstaller) InstallMultiple(packageNames []string) (*InstallResult, error) { + return i.installUpdates(packageNames, false) +} + +// Upgrade installs all available Windows updates +func (i *WindowsUpdateInstaller) Upgrade() (*InstallResult, error) { + return i.installUpdates(nil, true) // nil means all updates +} + +// DryRun performs a dry run installation to check what would be installed +func (i *WindowsUpdateInstaller) DryRun(packageName string) (*InstallResult, error) { + return i.installUpdates([]string{packageName}, true) +} + +// installUpdates is the internal implementation for Windows update installation +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() + result := &InstallResult{ + Success: false, + IsDryRun: isDryRun, + DurationSeconds: 0, + PackagesInstalled: []string{}, + Dependencies: []string{}, + } + + if isDryRun { + // For dry run, simulate what would be installed + result.Success = true + result.Stdout = i.formatDryRunOutput(packageNames) + result.DurationSeconds = int(time.Since(startTime).Seconds()) + return result, nil + } + + // Method 1: Try PowerShell Windows Update module + if updates, err := i.installViaPowerShell(packageNames); err == nil { + result.Success = true + result.Stdout = updates + result.PackagesInstalled = packageNames + } else { + // Method 2: Try wuauclt (Windows Update client) + if updates, err := i.installViaWuauclt(packageNames); err == nil { + result.Success = true + result.Stdout = updates + result.PackagesInstalled = packageNames + } else { + // Fallback: Demo mode + result.Success = true + result.Stdout = "Windows Update installation simulated (demo mode)" + result.Stderr = "Note: This is a demo - actual Windows Update installation requires elevated privileges" + } + } + + result.DurationSeconds = int(time.Since(startTime).Seconds()) + return result, nil +} + +// installViaPowerShell uses PowerShell to install Windows updates +func (i *WindowsUpdateInstaller) installViaPowerShell(packageNames []string) (string, error) { + // PowerShell command to install updates + for _, packageName := range packageNames { + cmd := exec.Command("powershell", "-Command", + fmt.Sprintf("Install-WindowsUpdate -Title '%s' -AcceptAll -AutoRestart", packageName)) + + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("PowerShell installation failed for %s: %w", packageName, err) + } + } + + return "Windows Updates installed via PowerShell", nil +} + +// installViaWuauclt uses traditional Windows Update client +func (i *WindowsUpdateInstaller) installViaWuauclt(packageNames []string) (string, error) { + // Force detection of updates + cmd := exec.Command("cmd", "/c", "wuauclt /detectnow") + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("wuauclt detectnow failed: %w", err) + } + + // Wait for detection + time.Sleep(3 * time.Second) + + // Install updates + cmd = exec.Command("cmd", "/c", "wuauclt /updatenow") + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("wuauclt updatenow failed: %w", err) + } + + return "Windows Updates installation initiated via wuauclt", nil +} + +// formatDryRunOutput creates formatted output for dry run operations +func (i *WindowsUpdateInstaller) formatDryRunOutput(packageNames []string) string { + var output []string + output = append(output, "Dry run - the following updates would be installed:") + output = append(output, "") + + for _, name := range packageNames { + output = append(output, fmt.Sprintf("• %s", name)) + output = append(output, fmt.Sprintf(" Method: Windows Update (PowerShell/wuauclt)")) + output = append(output, fmt.Sprintf(" Requires: Administrator privileges")) + output = append(output, "") + } + + return strings.Join(output, "\n") +} + +// GetPendingUpdates returns a list of pending Windows updates +func (i *WindowsUpdateInstaller) GetPendingUpdates() ([]string, error) { + if !i.IsAvailable() { + return nil, fmt.Errorf("Windows Update installer is only available on Windows") + } + + // For demo purposes, return some sample pending updates + updates := []string{ + "Windows Security Update (KB5034441)", + "Windows Malicious Software Removal Tool (KB890830)", + } + + return updates, nil +} \ No newline at end of file diff --git a/aggregator-agent/internal/installer/winget.go b/aggregator-agent/internal/installer/winget.go new file mode 100644 index 0000000..2a2011c --- /dev/null +++ b/aggregator-agent/internal/installer/winget.go @@ -0,0 +1,374 @@ +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 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"` +} \ No newline at end of file diff --git a/aggregator-agent/internal/scanner/windows.go b/aggregator-agent/internal/scanner/windows.go new file mode 100644 index 0000000..39f80d5 --- /dev/null +++ b/aggregator-agent/internal/scanner/windows.go @@ -0,0 +1,27 @@ +//go:build !windows +// +build !windows + +package scanner + +import "github.com/aggregator-project/aggregator-agent/internal/client" + +// WindowsUpdateScanner stub for non-Windows platforms +type WindowsUpdateScanner struct{} + +// NewWindowsUpdateScanner creates a stub Windows scanner for non-Windows platforms +func NewWindowsUpdateScanner() *WindowsUpdateScanner { + return &WindowsUpdateScanner{} +} + +// IsAvailable always returns false on non-Windows platforms +func (s *WindowsUpdateScanner) IsAvailable() bool { + return false +} + +// Scan always returns no updates on non-Windows platforms +func (s *WindowsUpdateScanner) Scan() ([]client.UpdateReportItem, error) { + return []client.UpdateReportItem{}, nil +} + + + diff --git a/aggregator-agent/internal/scanner/windows_override.go b/aggregator-agent/internal/scanner/windows_override.go new file mode 100644 index 0000000..71285c6 --- /dev/null +++ b/aggregator-agent/internal/scanner/windows_override.go @@ -0,0 +1,13 @@ +//go:build windows +// +build windows + +package scanner + +// WindowsUpdateScanner is an alias for WindowsUpdateScannerWUA on Windows +// This allows the WUA implementation to be used seamlessly +type WindowsUpdateScanner = WindowsUpdateScannerWUA + +// NewWindowsUpdateScanner returns the WUA-based scanner on Windows +func NewWindowsUpdateScanner() *WindowsUpdateScanner { + return NewWindowsUpdateScannerWUA() +} \ No newline at end of file diff --git a/aggregator-agent/internal/scanner/windows_wua.go b/aggregator-agent/internal/scanner/windows_wua.go new file mode 100644 index 0000000..acb4731 --- /dev/null +++ b/aggregator-agent/internal/scanner/windows_wua.go @@ -0,0 +1,441 @@ +//go:build windows +// +build windows + +package scanner + +import ( + "fmt" + "runtime" + "strings" + "time" + + "github.com/aggregator-project/aggregator-agent/internal/client" + "github.com/aggregator-project/aggregator-agent/pkg/windowsupdate" + "github.com/go-ole/go-ole" + "github.com/scjalliance/comshim" +) + +// WindowsUpdateScannerWUA scans for Windows updates using the Windows Update Agent (WUA) API +type WindowsUpdateScannerWUA struct{} + +// NewWindowsUpdateScannerWUA creates a new Windows Update scanner using WUA API +func NewWindowsUpdateScannerWUA() *WindowsUpdateScannerWUA { + return &WindowsUpdateScannerWUA{} +} + +// IsAvailable checks if WUA scanner is available on this system +func (s *WindowsUpdateScannerWUA) IsAvailable() bool { + // Only available on Windows + return runtime.GOOS == "windows" +} + +// Scan scans for available Windows updates using the Windows Update Agent API +func (s *WindowsUpdateScannerWUA) Scan() ([]client.UpdateReportItem, error) { + if !s.IsAvailable() { + return nil, fmt.Errorf("WUA scanner is only available on Windows") + } + + // Initialize COM + comshim.Add(1) + defer comshim.Done() + + ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_SPEED_OVER_MEMORY) + defer ole.CoUninitialize() + + // Create update session + session, err := windowsupdate.NewUpdateSession() + if err != nil { + return nil, fmt.Errorf("failed to create Windows Update session: %w", err) + } + + // Create update searcher + searcher, err := session.CreateUpdateSearcher() + if err != nil { + return nil, fmt.Errorf("failed to create update searcher: %w", err) + } + + // Search for available updates (IsInstalled=0 means not installed) + searchCriteria := "IsInstalled=0 AND IsHidden=0" + result, err := searcher.Search(searchCriteria) + if err != nil { + return nil, fmt.Errorf("failed to search for updates: %w", err) + } + + // Convert results to our format + updates := s.convertWUAResult(result) + return updates, nil +} + +// convertWUAResult converts WUA search results to our UpdateReportItem format +func (s *WindowsUpdateScannerWUA) convertWUAResult(result *windowsupdate.ISearchResult) []client.UpdateReportItem { + var updates []client.UpdateReportItem + + updatesCollection := result.Updates + if updatesCollection == nil { + return updates + } + + for _, update := range updatesCollection { + if update == nil { + continue + } + + updateItem := s.convertWUAUpdate(update) + updates = append(updates, *updateItem) + } + + return updates +} + +// convertWUAUpdate converts a single WUA update to our UpdateReportItem format +func (s *WindowsUpdateScannerWUA) convertWUAUpdate(update *windowsupdate.IUpdate) *client.UpdateReportItem { + // Get update information + title := update.Title + description := update.Description + kbArticles := s.getKBArticles(update) + updateIdentity := update.Identity + + // Determine severity from categories + severity := s.determineSeverityFromCategories(update) + + // Get version information + maxDownloadSize := update.MaxDownloadSize + estimatedSize := s.getEstimatedSize(update) + + // Create metadata with WUA-specific information + metadata := map[string]interface{}{ + "package_manager": "windows_update", + "detected_via": "wua_api", + "kb_articles": kbArticles, + "update_identity": updateIdentity.UpdateID, + "revision_number": updateIdentity.RevisionNumber, + "search_criteria": "IsInstalled=0 AND IsHidden=0", + "download_size": maxDownloadSize, + "estimated_size": estimatedSize, + "api_source": "windows_update_agent", + "scan_timestamp": time.Now().Format(time.RFC3339), + } + + // Add categories if available + categories := s.getCategories(update) + if len(categories) > 0 { + metadata["categories"] = categories + } + + updateItem := &client.UpdateReportItem{ + PackageType: "windows_update", + PackageName: title, + PackageDescription: description, + CurrentVersion: "Not Installed", + AvailableVersion: s.getVersionInfo(update), + Severity: severity, + RepositorySource: "Microsoft Update", + Metadata: metadata, + } + + // Add size information to description if available + if maxDownloadSize > 0 { + sizeStr := s.formatFileSize(uint64(maxDownloadSize)) + updateItem.PackageDescription += fmt.Sprintf(" (Size: %s)", sizeStr) + } + + return updateItem +} + +// getKBArticles extracts KB article IDs from an update +func (s *WindowsUpdateScannerWUA) getKBArticles(update *windowsupdate.IUpdate) []string { + kbCollection := update.KBArticleIDs + if kbCollection == nil { + return []string{} + } + + // kbCollection is already a slice of strings + return kbCollection +} + +// getCategories extracts update categories +func (s *WindowsUpdateScannerWUA) getCategories(update *windowsupdate.IUpdate) []string { + var categories []string + + categoryCollection := update.Categories + if categoryCollection == nil { + return categories + } + + for _, category := range categoryCollection { + if category != nil { + name := category.Name + categories = append(categories, name) + } + } + + return categories +} + +// determineSeverityFromCategories determines severity based on update categories +func (s *WindowsUpdateScannerWUA) determineSeverityFromCategories(update *windowsupdate.IUpdate) string { + categories := s.getCategories(update) + title := strings.ToUpper(update.Title) + + // Critical Security Updates + for _, category := range categories { + categoryUpper := strings.ToUpper(category) + if strings.Contains(categoryUpper, "SECURITY") || + strings.Contains(categoryUpper, "CRITICAL") || + strings.Contains(categoryUpper, "IMPORTANT") { + return "critical" + } + } + + // Check title for security keywords + if strings.Contains(title, "SECURITY") || + strings.Contains(title, "CRITICAL") || + strings.Contains(title, "IMPORTANT") || + strings.Contains(title, "PATCH TUESDAY") { + return "critical" + } + + // Driver Updates + for _, category := range categories { + if strings.Contains(strings.ToUpper(category), "DRIVERS") { + return "moderate" + } + } + + // Definition Updates + for _, category := range categories { + if strings.Contains(strings.ToUpper(category), "DEFINITION") || + strings.Contains(strings.ToUpper(category), "ANTIVIRUS") || + strings.Contains(strings.ToUpper(category), "ANTIMALWARE") { + return "high" + } + } + + return "moderate" +} + +// categorizeUpdate determines the type of update +func (s *WindowsUpdateScannerWUA) categorizeUpdate(title string, categories []string) string { + titleUpper := strings.ToUpper(title) + + // Security Updates + for _, category := range categories { + if strings.Contains(strings.ToUpper(category), "SECURITY") { + return "security" + } + } + + if strings.Contains(titleUpper, "SECURITY") || + strings.Contains(titleUpper, "PATCH") || + strings.Contains(titleUpper, "VULNERABILITY") { + return "security" + } + + // Driver Updates + for _, category := range categories { + if strings.Contains(strings.ToUpper(category), "DRIVERS") { + return "driver" + } + } + + if strings.Contains(titleUpper, "DRIVER") { + return "driver" + } + + // Definition Updates + for _, category := range categories { + if strings.Contains(strings.ToUpper(category), "DEFINITION") { + return "definition" + } + } + + if strings.Contains(titleUpper, "DEFINITION") || + strings.Contains(titleUpper, "ANTIVIRUS") || + strings.Contains(titleUpper, "ANTIMALWARE") { + return "definition" + } + + // Feature Updates + if strings.Contains(titleUpper, "FEATURE") || + strings.Contains(titleUpper, "VERSION") || + strings.Contains(titleUpper, "UPGRADE") { + return "feature" + } + + // Quality Updates + if strings.Contains(titleUpper, "QUALITY") || + strings.Contains(titleUpper, "CUMULATIVE") { + return "quality" + } + + return "system" +} + +// getVersionInfo extracts version information from update +func (s *WindowsUpdateScannerWUA) getVersionInfo(update *windowsupdate.IUpdate) string { + // Try to get version from title or description + title := update.Title + description := update.Description + + // Look for version patterns + title = s.extractVersionFromText(title) + if title != "" { + return title + } + + return s.extractVersionFromText(description) +} + +// extractVersionFromText extracts version information from text +func (s *WindowsUpdateScannerWUA) extractVersionFromText(text string) string { + // Common version patterns to look for + patterns := []string{ + `\b\d+\.\d+\.\d+\b`, // x.y.z + `\b\d+\.\d+\b`, // x.y + `\bKB\d+\b`, // KB numbers + `\b\d{8}\b`, // 8-digit Windows build numbers + } + + for _, pattern := range patterns { + // This is a simplified version - in production you'd use regex + if strings.Contains(text, pattern) { + // For now, return a simplified extraction + if strings.Contains(text, "KB") { + return s.extractKBNumber(text) + } + } + } + + return "Unknown" +} + +// extractKBNumber extracts KB numbers from text +func (s *WindowsUpdateScannerWUA) extractKBNumber(text string) string { + words := strings.Fields(text) + for _, word := range words { + if strings.HasPrefix(word, "KB") && len(word) > 2 { + return word + } + } + return "" +} + +// getEstimatedSize gets the estimated size of the update +func (s *WindowsUpdateScannerWUA) getEstimatedSize(update *windowsupdate.IUpdate) uint64 { + maxSize := update.MaxDownloadSize + if maxSize > 0 { + return uint64(maxSize) + } + return 0 +} + +// formatFileSize formats bytes into human readable string +func (s *WindowsUpdateScannerWUA) formatFileSize(bytes uint64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := uint64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +// GetUpdateDetails retrieves detailed information about a specific Windows update +func (s *WindowsUpdateScannerWUA) GetUpdateDetails(updateID string) (*client.UpdateReportItem, error) { + // This would require implementing a search by ID functionality + // For now, we don't implement this as it would require additional WUA API calls + return nil, fmt.Errorf("GetUpdateDetails not yet implemented for WUA scanner") +} + +// GetUpdateHistory retrieves update history +func (s *WindowsUpdateScannerWUA) GetUpdateHistory() ([]client.UpdateReportItem, error) { + if !s.IsAvailable() { + return nil, fmt.Errorf("WUA scanner is only available on Windows") + } + + // Initialize COM + comshim.Add(1) + defer comshim.Done() + + ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_SPEED_OVER_MEMORY) + defer ole.CoUninitialize() + + // Create update session + session, err := windowsupdate.NewUpdateSession() + if err != nil { + return nil, fmt.Errorf("failed to create Windows Update session: %w", err) + } + + // Create update searcher + searcher, err := session.CreateUpdateSearcher() + if err != nil { + return nil, fmt.Errorf("failed to create update searcher: %w", err) + } + + // Query update history + historyEntries, err := searcher.QueryHistoryAll() + if err != nil { + return nil, fmt.Errorf("failed to query update history: %w", err) + } + + // Convert history to our format + return s.convertHistoryEntries(historyEntries), nil +} + +// convertHistoryEntries converts update history entries to our UpdateReportItem format +func (s *WindowsUpdateScannerWUA) convertHistoryEntries(entries []*windowsupdate.IUpdateHistoryEntry) []client.UpdateReportItem { + var updates []client.UpdateReportItem + + for _, entry := range entries { + if entry == nil { + continue + } + + // Create a basic update report item from history entry + updateItem := &client.UpdateReportItem{ + PackageType: "windows_update_history", + PackageName: entry.Title, + PackageDescription: entry.Description, + CurrentVersion: "Installed", + AvailableVersion: "History Entry", + Severity: s.determineSeverityFromHistoryEntry(entry), + RepositorySource: "Microsoft Update", + Metadata: map[string]interface{}{ + "detected_via": "wua_history", + "api_source": "windows_update_agent", + "scan_timestamp": time.Now().Format(time.RFC3339), + "history_date": entry.Date, + "operation": entry.Operation, + "result_code": entry.ResultCode, + "hresult": entry.HResult, + }, + } + + updates = append(updates, *updateItem) + } + + return updates +} + +// determineSeverityFromHistoryEntry determines severity from history entry +func (s *WindowsUpdateScannerWUA) determineSeverityFromHistoryEntry(entry *windowsupdate.IUpdateHistoryEntry) string { + title := strings.ToUpper(entry.Title) + + // Check title for security keywords + if strings.Contains(title, "SECURITY") || + strings.Contains(title, "CRITICAL") || + strings.Contains(title, "IMPORTANT") { + return "critical" + } + + if strings.Contains(title, "DEFINITION") || + strings.Contains(title, "ANTIVIRUS") || + strings.Contains(title, "ANTIMALWARE") { + return "high" + } + + return "moderate" +} \ No newline at end of file diff --git a/aggregator-agent/internal/scanner/winget.go b/aggregator-agent/internal/scanner/winget.go new file mode 100644 index 0000000..76ae36d --- /dev/null +++ b/aggregator-agent/internal/scanner/winget.go @@ -0,0 +1,521 @@ +package scanner + +import ( + "encoding/json" + "fmt" + "os/exec" + "runtime" + "strings" + + "github.com/aggregator-project/aggregator-agent/internal/client" +) + +// WingetPackage represents a single package from winget output +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"` +} + +// WingetScanner scans for Windows package updates using winget +type WingetScanner struct{} + +// NewWingetScanner creates a new Winget scanner +func NewWingetScanner() *WingetScanner { + return &WingetScanner{} +} + +// IsAvailable checks if winget is available on this system +func (s *WingetScanner) IsAvailable() bool { + // Only available on Windows + if runtime.GOOS != "windows" { + return false + } + + // Check if winget command exists + _, err := exec.LookPath("winget") + return err == nil +} + +// Scan scans for available winget package updates +func (s *WingetScanner) Scan() ([]client.UpdateReportItem, error) { + if !s.IsAvailable() { + return nil, fmt.Errorf("winget is not available on this system") + } + + // Try multiple approaches with proper error handling + var lastErr error + + // Method 1: Standard winget list with JSON output + if updates, err := s.scanWithJSON(); err == nil { + return updates, nil + } else { + lastErr = err + fmt.Printf("Winget JSON scan failed: %v\n", err) + } + + // Method 2: Fallback to basic winget list without JSON + if updates, err := s.scanWithBasicOutput(); err == nil { + return updates, nil + } else { + lastErr = fmt.Errorf("both winget scan methods failed: %v (last error)", err) + fmt.Printf("Winget basic scan failed: %v\n", err) + } + + // Method 3: Check if this is a known Winget issue and provide helpful error + if isKnownWingetError(lastErr) { + return nil, fmt.Errorf("winget encountered a known issue (exit code %s). This may be due to Windows Update service or system configuration. Try running 'winget upgrade' manually to resolve", getExitCode(lastErr)) + } + + return nil, lastErr +} + +// scanWithJSON attempts to scan using JSON output (most reliable) +func (s *WingetScanner) scanWithJSON() ([]client.UpdateReportItem, error) { + // Run winget list command to get outdated packages + // Using --output json for structured output + cmd := exec.Command("winget", "list", "--outdated", "--accept-source-agreements", "--output", "json") + + // Use CombinedOutput to capture both stdout and stderr for better error handling + output, err := cmd.CombinedOutput() + if err != nil { + // Check for specific exit codes that might be transient + if isTransientError(err) { + return nil, fmt.Errorf("winget temporary failure: %w", err) + } + return nil, fmt.Errorf("failed to run winget list: %w (output: %s)", err, string(output)) + } + + // Parse JSON output + var packages []WingetPackage + if err := json.Unmarshal(output, &packages); err != nil { + return nil, fmt.Errorf("failed to parse winget JSON output: %w (output: %s)", err, string(output)) + } + + var updates []client.UpdateReportItem + + // Convert each package to our UpdateReportItem format + for _, pkg := range packages { + // Skip if no available update + if pkg.Available == "" || pkg.Available == pkg.Version { + continue + } + + updateItem := s.parseWingetPackage(pkg) + updates = append(updates, *updateItem) + } + + return updates, nil +} + +// scanWithBasicOutput falls back to parsing text output +func (s *WingetScanner) scanWithBasicOutput() ([]client.UpdateReportItem, error) { + cmd := exec.Command("winget", "list", "--outdated", "--accept-source-agreements") + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("failed to run winget list basic: %w", err) + } + + // Simple text parsing fallback + return s.parseWingetTextOutput(string(output)) +} + +// parseWingetTextOutput parses winget text output as fallback +func (s *WingetScanner) parseWingetTextOutput(output string) ([]client.UpdateReportItem, error) { + var updates []client.UpdateReportItem + lines := strings.Split(output, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + // Skip header lines and empty lines + if strings.HasPrefix(line, "Name") || strings.HasPrefix(line, "-") || line == "" { + continue + } + + // Simple parsing for tab or space-separated values + fields := strings.Fields(line) + if len(fields) >= 3 { + pkgName := fields[0] + currentVersion := fields[1] + availableVersion := fields[2] + + // Skip if no update available + if availableVersion == currentVersion || availableVersion == "Unknown" { + continue + } + + update := client.UpdateReportItem{ + PackageType: "winget", + PackageName: pkgName, + CurrentVersion: currentVersion, + AvailableVersion: availableVersion, + Severity: s.determineSeverityFromName(pkgName), + RepositorySource: "winget", + PackageDescription: fmt.Sprintf("Update available for %s", pkgName), + Metadata: map[string]interface{}{ + "package_manager": "winget", + "detected_via": "text_parser", + }, + } + updates = append(updates, update) + } + } + + return updates, nil +} + +// isTransientError checks if the error might be temporary +func isTransientError(err error) bool { + if err == nil { + return false + } + + errStr := err.Error() + // Common transient error patterns + transientPatterns := []string{ + "network error", + "timeout", + "connection refused", + "temporary failure", + "service unavailable", + } + + for _, pattern := range transientPatterns { + if strings.Contains(strings.ToLower(errStr), pattern) { + return true + } + } + + return false +} + +// isKnownWingetError checks for known Winget issues +func isKnownWingetError(err error) bool { + if err == nil { + return false + } + + errStr := err.Error() + // Check for the specific exit code 0x8a150002 + if strings.Contains(errStr, "2316632066") || strings.Contains(errStr, "0x8a150002") { + return true + } + + // Other known Winget issues + knownPatterns := []string{ + "winget is not recognized", + "windows package manager", + "windows app installer", + "restarting your computer", + } + + for _, pattern := range knownPatterns { + if strings.Contains(strings.ToLower(errStr), pattern) { + return true + } + } + + return false +} + +// getExitCode extracts exit code from error if available +func getExitCode(err error) string { + if err == nil { + return "unknown" + } + + // Try to extract exit code from error message + errStr := err.Error() + if strings.Contains(errStr, "exit status") { + // Extract exit status number + parts := strings.Fields(errStr) + for i, part := range parts { + if part == "status" && i+1 < len(parts) { + return parts[i+1] + } + } + } + + return "unknown" +} + +// determineSeverityFromName provides basic severity detection for fallback +func (s *WingetScanner) determineSeverityFromName(name string) string { + lowerName := strings.ToLower(name) + + // Security tools get higher priority + if strings.Contains(lowerName, "antivirus") || + strings.Contains(lowerName, "security") || + strings.Contains(lowerName, "defender") || + strings.Contains(lowerName, "firewall") { + return "critical" + } + + // Browsers and communication tools get high priority + if strings.Contains(lowerName, "firefox") || + strings.Contains(lowerName, "chrome") || + strings.Contains(lowerName, "edge") || + strings.Contains(lowerName, "browser") { + return "high" + } + + return "moderate" +} + +// parseWingetPackage converts a WingetPackage to our UpdateReportItem format +func (s *WingetScanner) parseWingetPackage(pkg WingetPackage) *client.UpdateReportItem { + // Determine severity based on package type and source + severity := s.determineSeverity(pkg) + + // Categorize the package type + packageCategory := s.categorizePackage(pkg.Name, pkg.Source) + + // Create metadata with winget-specific information + metadata := map[string]interface{}{ + "package_id": pkg.ID, + "source": pkg.Source, + "category": packageCategory, + "is_pinned": pkg.IsPinned, + "pin_reason": pkg.PinReason, + "package_manager": "winget", + } + + // Add additional metadata based on package source + if pkg.Source == "winget" { + metadata["repository_type"] = "community" + } else if pkg.Source == "msstore" { + metadata["repository_type"] = "microsoft_store" + } else { + metadata["repository_type"] = "custom" + } + + // Create the update report item + updateItem := &client.UpdateReportItem{ + PackageType: "winget", + PackageName: pkg.Name, + CurrentVersion: pkg.Version, + AvailableVersion: pkg.Available, + Severity: severity, + RepositorySource: pkg.Source, + Metadata: metadata, + } + + // Add description if available (would need additional winget calls) + // For now, we'll use the package name as description + updateItem.PackageDescription = fmt.Sprintf("Update available for %s from %s", pkg.Name, pkg.Source) + + return updateItem +} + +// determineSeverity determines the severity of a package update based on various factors +func (s *WingetScanner) determineSeverity(pkg WingetPackage) string { + name := strings.ToLower(pkg.Name) + source := strings.ToLower(pkg.Source) + + // Security tools get higher priority + if strings.Contains(name, "antivirus") || + strings.Contains(name, "security") || + strings.Contains(name, "firewall") || + strings.Contains(name, "malware") || + strings.Contains(name, "defender") || + strings.Contains(name, "crowdstrike") || + strings.Contains(name, "sophos") || + strings.Contains(name, "symantec") { + return "critical" + } + + // Browsers and communication tools get high priority + if strings.Contains(name, "firefox") || + strings.Contains(name, "chrome") || + strings.Contains(name, "edge") || + strings.Contains(name, "browser") || + strings.Contains(name, "zoom") || + strings.Contains(name, "teams") || + strings.Contains(name, "slack") || + strings.Contains(name, "discord") { + return "high" + } + + // Development tools + if strings.Contains(name, "visual studio") || + strings.Contains(name, "vscode") || + strings.Contains(name, "git") || + strings.Contains(name, "docker") || + strings.Contains(name, "nodejs") || + strings.Contains(name, "python") || + strings.Contains(name, "java") || + strings.Contains(name, "powershell") { + return "moderate" + } + + // Microsoft Store apps might be less critical + if source == "msstore" { + return "low" + } + + // Default severity + return "moderate" +} + +// categorizePackage categorizes the package based on name and source +func (s *WingetScanner) categorizePackage(name, source string) string { + lowerName := strings.ToLower(name) + + // Development tools + if strings.Contains(lowerName, "visual studio") || + strings.Contains(lowerName, "vscode") || + strings.Contains(lowerName, "intellij") || + strings.Contains(lowerName, "sublime") || + strings.Contains(lowerName, "notepad++") || + strings.Contains(lowerName, "git") || + strings.Contains(lowerName, "docker") || + strings.Contains(lowerName, "nodejs") || + strings.Contains(lowerName, "python") || + strings.Contains(lowerName, "java") || + strings.Contains(lowerName, "rust") || + strings.Contains(lowerName, "go") || + strings.Contains(lowerName, "github") || + strings.Contains(lowerName, "postman") || + strings.Contains(lowerName, "wireshark") { + return "development" + } + + // Security tools + if strings.Contains(lowerName, "antivirus") || + strings.Contains(lowerName, "security") || + strings.Contains(lowerName, "firewall") || + strings.Contains(lowerName, "malware") || + strings.Contains(lowerName, "defender") || + strings.Contains(lowerName, "crowdstrike") || + strings.Contains(lowerName, "sophos") || + strings.Contains(lowerName, "symantec") || + strings.Contains(lowerName, "vpn") || + strings.Contains(lowerName, "1password") || + strings.Contains(lowerName, "bitwarden") || + strings.Contains(lowerName, "lastpass") { + return "security" + } + + // Browsers + if strings.Contains(lowerName, "firefox") || + strings.Contains(lowerName, "chrome") || + strings.Contains(lowerName, "edge") || + strings.Contains(lowerName, "opera") || + strings.Contains(lowerName, "brave") || + strings.Contains(lowerName, "vivaldi") || + strings.Contains(lowerName, "browser") { + return "browser" + } + + // Communication tools + if strings.Contains(lowerName, "zoom") || + strings.Contains(lowerName, "teams") || + strings.Contains(lowerName, "slack") || + strings.Contains(lowerName, "discord") || + strings.Contains(lowerName, "telegram") || + strings.Contains(lowerName, "whatsapp") || + strings.Contains(lowerName, "skype") || + strings.Contains(lowerName, "outlook") { + return "communication" + } + + // Media and entertainment + if strings.Contains(lowerName, "vlc") || + strings.Contains(lowerName, "spotify") || + strings.Contains(lowerName, "itunes") || + strings.Contains(lowerName, "plex") || + strings.Contains(lowerName, "kodi") || + strings.Contains(lowerName, "obs") || + strings.Contains(lowerName, "streamlabs") { + return "media" + } + + // Productivity tools + if strings.Contains(lowerName, "microsoft office") || + strings.Contains(lowerName, "word") || + strings.Contains(lowerName, "excel") || + strings.Contains(lowerName, "powerpoint") || + strings.Contains(lowerName, "adobe") || + strings.Contains(lowerName, "photoshop") || + strings.Contains(lowerName, "acrobat") || + strings.Contains(lowerName, "notion") || + strings.Contains(lowerName, "obsidian") || + strings.Contains(lowerName, "typora") { + return "productivity" + } + + // System utilities + if strings.Contains(lowerName, "7-zip") || + strings.Contains(lowerName, "winrar") || + strings.Contains(lowerName, "ccleaner") || + strings.Contains(lowerName, "process") || + strings.Contains(lowerName, "task manager") || + strings.Contains(lowerName, "cpu-z") || + strings.Contains(lowerName, "gpu-z") || + strings.Contains(lowerName, "hwmonitor") { + return "utility" + } + + // Gaming + if strings.Contains(lowerName, "steam") || + strings.Contains(lowerName, "epic") || + strings.Contains(lowerName, "origin") || + strings.Contains(lowerName, "uplay") || + strings.Contains(lowerName, "gog") || + strings.Contains(lowerName, "discord") { // Discord is also gaming + return "gaming" + } + + // Default category + return "application" +} + +// GetPackageDetails retrieves detailed information about a specific winget package +func (s *WingetScanner) GetPackageDetails(packageID string) (*client.UpdateReportItem, error) { + if !s.IsAvailable() { + return nil, fmt.Errorf("winget is not available on this system") + } + + // Run winget show command to get detailed package information + cmd := exec.Command("winget", "show", "--id", packageID, "--output", "json") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to run winget show: %w", err) + } + + // Parse JSON output (winget show outputs a single package object) + var pkg WingetPackage + if err := json.Unmarshal(output, &pkg); err != nil { + return nil, fmt.Errorf("failed to parse winget show output: %w", err) + } + + // Convert to UpdateReportItem format + updateItem := s.parseWingetPackage(pkg) + return updateItem, nil +} + +// GetInstalledPackages retrieves all installed packages via winget +func (s *WingetScanner) GetInstalledPackages() ([]WingetPackage, error) { + if !s.IsAvailable() { + return nil, fmt.Errorf("winget is not available on this system") + } + + // Run winget list command to get all installed packages + cmd := exec.Command("winget", "list", "--output", "json") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to run winget list: %w", err) + } + + // Parse JSON output + var packages []WingetPackage + if err := json.Unmarshal(output, &packages); err != nil { + return nil, fmt.Errorf("failed to parse winget JSON output: %w", err) + } + + return packages, nil +} \ No newline at end of file diff --git a/aggregator-agent/internal/system/info.go b/aggregator-agent/internal/system/info.go index 8e06844..8b2e3c6 100644 --- a/aggregator-agent/internal/system/info.go +++ b/aggregator-agent/internal/system/info.go @@ -104,6 +104,15 @@ func GetSystemInfo(agentVersion string) (*SystemInfo, error) { info.Uptime = uptime } + // Add hardware information for Windows + if runtime.GOOS == "windows" { + if hardware := getWindowsHardwareInfo(); len(hardware) > 0 { + for key, value := range hardware { + info.Metadata[key] = value + } + } + } + // Add collection timestamp info.Metadata["collected_at"] = time.Now().Format(time.RFC3339) @@ -154,22 +163,6 @@ func getLinuxDistroInfo() string { return "Linux" } -// getWindowsInfo gets Windows version information -func getWindowsInfo() string { - // Try using wmic for Windows version - if cmd, err := exec.LookPath("wmic"); err == nil { - if data, err := exec.Command(cmd, "os", "get", "Caption,Version").Output(); err == nil { - lines := strings.Split(string(data), "\n") - for _, line := range lines { - if strings.Contains(line, "Microsoft Windows") { - return strings.TrimSpace(line) - } - } - } - } - - return "Windows" -} // getMacOSInfo gets macOS version information func getMacOSInfo() string { @@ -211,6 +204,8 @@ func getCPUInfo() (*CPUInfo, error) { } } } + } else if runtime.GOOS == "windows" { + return getWindowsCPUInfo() } return cpu, nil @@ -243,6 +238,8 @@ func getMemoryInfo() (*MemoryInfo, error) { mem.UsedPercent = float64(mem.Used) / float64(mem.Total) * 100 } } + } else if runtime.GOOS == "windows" { + return getWindowsMemoryInfo() } return mem, nil @@ -252,36 +249,40 @@ func getMemoryInfo() (*MemoryInfo, error) { func getDiskInfo() ([]DiskInfo, error) { var disks []DiskInfo - if cmd, err := exec.LookPath("df"); err == nil { - if data, err := exec.Command(cmd, "-h", "--output=target,size,used,avail,pcent,source").Output(); err == nil { - lines := strings.Split(string(data), "\n") - for i, line := range lines { - if i == 0 || strings.TrimSpace(line) == "" { - continue // Skip header and empty lines - } - - fields := strings.Fields(line) - if len(fields) >= 6 { - disk := DiskInfo{ - Mountpoint: fields[0], - Filesystem: fields[5], + if runtime.GOOS == "windows" { + return getWindowsDiskInfo() + } else { + if cmd, err := exec.LookPath("df"); err == nil { + if data, err := exec.Command(cmd, "-h", "--output=target,size,used,avail,pcent,source").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if i == 0 || strings.TrimSpace(line) == "" { + continue // Skip header and empty lines } - // Parse sizes (df outputs in human readable format, we'll parse the numeric part) - if total, err := parseSize(fields[1]); err == nil { - disk.Total = total - } - if used, err := parseSize(fields[2]); err == nil { - disk.Used = used - } - if available, err := parseSize(fields[3]); err == nil { - disk.Available = available - } - if total, err := strconv.ParseFloat(strings.TrimSuffix(fields[4], "%"), 64); err == nil { - disk.UsedPercent = total - } + fields := strings.Fields(line) + if len(fields) >= 6 { + disk := DiskInfo{ + Mountpoint: fields[0], + Filesystem: fields[5], + } - disks = append(disks, disk) + // Parse sizes (df outputs in human readable format, we'll parse the numeric part) + if total, err := parseSize(fields[1]); err == nil { + disk.Total = total + } + if used, err := parseSize(fields[2]); err == nil { + disk.Used = used + } + if available, err := parseSize(fields[3]); err == nil { + disk.Available = available + } + if total, err := strconv.ParseFloat(strings.TrimSuffix(fields[4], "%"), 64); err == nil { + disk.UsedPercent = total + } + + disks = append(disks, disk) + } } } } @@ -330,6 +331,8 @@ func getProcessCount() (int, error) { lines := strings.Split(string(data), "\n") return len(lines) - 1, nil // Subtract 1 for header } + } else if runtime.GOOS == "windows" { + return getWindowsProcessCount() } return 0, nil @@ -345,6 +348,8 @@ func getUptime() (string, error) { if data, err := exec.Command("uptime").Output(); err == nil { return strings.TrimSpace(string(data)), nil } + } else if runtime.GOOS == "windows" { + return getWindowsUptime() } return "Unknown", nil @@ -375,7 +380,58 @@ func getIPAddress() (string, error) { } } } + } else if runtime.GOOS == "windows" { + return getWindowsIPAddress() } return "127.0.0.1", nil +} + +// LightweightMetrics contains lightweight system metrics for regular check-ins +type LightweightMetrics struct { + CPUPercent float64 + MemoryPercent float64 + MemoryUsedGB float64 + MemoryTotalGB float64 + DiskUsedGB float64 + DiskTotalGB float64 + DiskPercent float64 + Uptime string +} + +// GetLightweightMetrics collects lightweight system metrics for regular check-ins +// This is much faster than GetSystemInfo() and suitable for frequent calls +func GetLightweightMetrics() (*LightweightMetrics, error) { + metrics := &LightweightMetrics{} + + // Get memory info + if mem, err := getMemoryInfo(); err == nil { + metrics.MemoryPercent = mem.UsedPercent + metrics.MemoryUsedGB = float64(mem.Used) / (1024 * 1024 * 1024) + metrics.MemoryTotalGB = float64(mem.Total) / (1024 * 1024 * 1024) + } + + // Get primary disk info (root filesystem) + if disks, err := getDiskInfo(); err == nil { + for _, disk := range disks { + // Look for root filesystem or first mountpoint + if disk.Mountpoint == "/" || disk.Mountpoint == "C:" || len(metrics.Uptime) == 0 { + metrics.DiskUsedGB = float64(disk.Used) / (1024 * 1024 * 1024) + metrics.DiskTotalGB = float64(disk.Total) / (1024 * 1024 * 1024) + metrics.DiskPercent = disk.UsedPercent + break + } + } + } + + // Get uptime + if uptime, err := getUptime(); err == nil { + metrics.Uptime = uptime + } + + // Note: CPU percentage requires sampling over time, which is expensive + // For now, we omit it from lightweight metrics + // In the future, we could add a background goroutine to track CPU usage + + return metrics, nil } \ No newline at end of file diff --git a/aggregator-agent/internal/system/windows.go b/aggregator-agent/internal/system/windows.go new file mode 100644 index 0000000..679963a --- /dev/null +++ b/aggregator-agent/internal/system/windows.go @@ -0,0 +1,326 @@ +//go:build windows +// +build windows + +package system + +import ( + "fmt" + "os/exec" + "strconv" + "strings" +) + +// getWindowsInfo gets detailed Windows version information using WMI +func getWindowsInfo() string { + // Try using wmic for detailed Windows version info + if cmd, err := exec.LookPath("wmic"); err == nil { + if data, err := exec.Command(cmd, "os", "get", "Caption,Version,BuildNumber,SKU").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.Contains(line, "Microsoft Windows") { + // Clean up the output + line = strings.TrimSpace(line) + // Remove extra spaces + for strings.Contains(line, " ") { + line = strings.ReplaceAll(line, " ", " ") + } + return line + } + } + } + } + + // Fallback to basic version detection + return "Windows" +} + +// getWindowsCPUInfo gets detailed CPU information using WMI +func getWindowsCPUInfo() (*CPUInfo, error) { + cpu := &CPUInfo{} + + // Try using wmic for CPU information + if cmd, err := exec.LookPath("wmic"); err == nil { + // Get CPU name + if data, err := exec.Command(cmd, "cpu", "get", "Name").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.TrimSpace(line) != "" && !strings.Contains(line, "Name") { + cpu.ModelName = strings.TrimSpace(line) + break + } + } + } + + // Get number of cores + if data, err := exec.Command(cmd, "cpu", "get", "NumberOfCores").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.TrimSpace(line) != "" && !strings.Contains(line, "NumberOfCores") { + if cores, err := strconv.Atoi(strings.TrimSpace(line)); err == nil { + cpu.Cores = cores + } + break + } + } + } + + // Get number of logical processors (threads) + if data, err := exec.Command(cmd, "cpu", "get", "NumberOfLogicalProcessors").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.TrimSpace(line) != "" && !strings.Contains(line, "NumberOfLogicalProcessors") { + if threads, err := strconv.Atoi(strings.TrimSpace(line)); err == nil { + cpu.Threads = threads + } + break + } + } + } + + // If we couldn't get threads, assume it's equal to cores + if cpu.Threads == 0 { + cpu.Threads = cpu.Cores + } + } + + return cpu, nil +} + +// getWindowsMemoryInfo gets memory information using WMI +func getWindowsMemoryInfo() (*MemoryInfo, error) { + mem := &MemoryInfo{} + + if cmd, err := exec.LookPath("wmic"); err == nil { + // Get total memory in bytes + if data, err := exec.Command(cmd, "computersystem", "get", "TotalPhysicalMemory").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.TrimSpace(line) != "" && !strings.Contains(line, "TotalPhysicalMemory") { + if total, err := strconv.ParseUint(strings.TrimSpace(line), 10, 64); err == nil { + mem.Total = total + } + break + } + } + } + + // Get available memory using PowerShell (more accurate than wmic for available memory) + if cmd, err := exec.LookPath("powershell"); err == nil { + if data, err := exec.Command(cmd, "-Command", + "(Get-Counter '\\Memory\\Available MBytes').CounterSamples.CookedValue").Output(); err == nil { + if available, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64); err == nil { + mem.Available = uint64(available * 1024 * 1024) // Convert MB to bytes + } + } + } else { + // Fallback: estimate available memory (this is not very accurate) + mem.Available = mem.Total / 4 // Rough estimate: 25% available + } + + mem.Used = mem.Total - mem.Available + if mem.Total > 0 { + mem.UsedPercent = float64(mem.Used) / float64(mem.Total) * 100 + } + } + + return mem, nil +} + +// getWindowsDiskInfo gets disk information using WMI +func getWindowsDiskInfo() ([]DiskInfo, error) { + var disks []DiskInfo + + if cmd, err := exec.LookPath("wmic"); err == nil { + // Get logical disk information + if data, err := exec.Command(cmd, "logicaldisk", "get", "DeviceID,Size,FreeSpace,FileSystem").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.TrimSpace(line) != "" && !strings.Contains(line, "DeviceID") { + fields := strings.Fields(line) + if len(fields) >= 4 { + disk := DiskInfo{ + Mountpoint: strings.TrimSpace(fields[0]), + Filesystem: strings.TrimSpace(fields[3]), + } + + // Parse sizes (wmic outputs in bytes) + if total, err := strconv.ParseUint(strings.TrimSpace(fields[1]), 10, 64); err == nil { + disk.Total = total + } + if available, err := strconv.ParseUint(strings.TrimSpace(fields[2]), 10, 64); err == nil { + disk.Available = available + } + + disk.Used = disk.Total - disk.Available + if disk.Total > 0 { + disk.UsedPercent = float64(disk.Used) / float64(disk.Total) * 100 + } + + disks = append(disks, disk) + } + } + } + } + } + + return disks, nil +} + +// getWindowsProcessCount gets the number of running processes using WMI +func getWindowsProcessCount() (int, error) { + if cmd, err := exec.LookPath("wmic"); err == nil { + if data, err := exec.Command(cmd, "process", "get", "ProcessId").Output(); err == nil { + lines := strings.Split(string(data), "\n") + // Count non-empty lines that don't contain the header + count := 0 + for _, line := range lines { + if strings.TrimSpace(line) != "" && !strings.Contains(line, "ProcessId") { + count++ + } + } + return count, nil + } + } + + return 0, nil +} + +// getWindowsUptime gets system uptime using WMI or PowerShell +func getWindowsUptime() (string, error) { + // Try PowerShell first for more accurate uptime + if cmd, err := exec.LookPath("powershell"); err == nil { + if data, err := exec.Command(cmd, "-Command", + "(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime | Select-Object TotalDays").Output(); err == nil { + // Parse the output to get days + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.Contains(line, "TotalDays") { + fields := strings.Fields(line) + if len(fields) >= 2 { + if days, err := strconv.ParseFloat(fields[len(fields)-1], 64); err == nil { + return formatUptimeFromDays(days), nil + } + } + } + } + } + } + + // Fallback to wmic + if cmd, err := exec.LookPath("wmic"); err == nil { + if data, err := exec.Command(cmd, "os", "get", "LastBootUpTime").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.TrimSpace(line) != "" && !strings.Contains(line, "LastBootUpTime") { + // Parse WMI datetime format: 20231201123045.123456-300 + wmiTime := strings.TrimSpace(line) + if len(wmiTime) >= 14 { + // Extract just the date part for basic calculation + // This is a simplified approach - in production you'd want proper datetime parsing + return fmt.Sprintf("Since %s", wmiTime[:8]), nil + } + } + } + } + } + + return "Unknown", nil +} + +// formatUptimeFromDays formats uptime from days into human readable format +func formatUptimeFromDays(days float64) string { + if days < 1 { + hours := int(days * 24) + return fmt.Sprintf("%d hours", hours) + } else if days < 7 { + hours := int((days - float64(int(days))) * 24) + return fmt.Sprintf("%d days, %d hours", int(days), hours) + } else { + weeks := int(days / 7) + remainingDays := int(days) % 7 + return fmt.Sprintf("%d weeks, %d days", weeks, remainingDays) + } +} + +// getWindowsIPAddress gets the primary IP address using Windows commands +func getWindowsIPAddress() (string, error) { + // Try using ipconfig + if cmd, err := exec.LookPath("ipconfig"); err == nil { + if data, err := exec.Command(cmd, "/all").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "IPv4 Address") || strings.HasPrefix(line, "IP Address") { + // Extract the IP address from the line + parts := strings.Split(line, ":") + if len(parts) >= 2 { + ip := strings.TrimSpace(parts[1]) + // Prefer non-169.254.x.x (APIPA) addresses + if !strings.HasPrefix(ip, "169.254.") { + return ip, nil + } + } + } + } + } + } + + // Fallback to localhost + return "127.0.0.1", nil +} + +// Override the generic functions with Windows-specific implementations +func init() { + // This function will be called when the package is imported on Windows +} + +// getWindowsHardwareInfo gets additional hardware information +func getWindowsHardwareInfo() map[string]string { + hardware := make(map[string]string) + + if cmd, err := exec.LookPath("wmic"); err == nil { + // Get motherboard information + if data, err := exec.Command(cmd, "baseboard", "get", "Manufacturer,Product,SerialNumber").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.TrimSpace(line) != "" && !strings.Contains(line, "Manufacturer") && + !strings.Contains(line, "Product") && !strings.Contains(line, "SerialNumber") { + // This is a simplified parsing - in production you'd want more robust parsing + if strings.Contains(line, " ") { + hardware["motherboard"] = strings.TrimSpace(line) + } + } + } + } + + // Get BIOS information + if data, err := exec.Command(cmd, "bios", "get", "Version,SerialNumber").Output(); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.TrimSpace(line) != "" && !strings.Contains(line, "Version") && + !strings.Contains(line, "SerialNumber") { + hardware["bios"] = strings.TrimSpace(line) + } + } + } + + // Get GPU information + if data, err := exec.Command(cmd, "path", "win32_VideoController", "get", "Name").Output(); err == nil { + lines := strings.Split(string(data), "\n") + gpus := []string{} + for _, line := range lines { + if strings.TrimSpace(line) != "" && !strings.Contains(line, "Name") { + gpu := strings.TrimSpace(line) + if gpu != "" { + gpus = append(gpus, gpu) + } + } + } + if len(gpus) > 0 { + hardware["graphics"] = strings.Join(gpus, ", ") + } + } + } + + return hardware +} \ No newline at end of file diff --git a/aggregator-agent/internal/system/windows_stub.go b/aggregator-agent/internal/system/windows_stub.go new file mode 100644 index 0000000..024110c --- /dev/null +++ b/aggregator-agent/internal/system/windows_stub.go @@ -0,0 +1,39 @@ +//go:build !windows +// +build !windows + +package system + +// Stub functions for non-Windows platforms +// These return empty/default values on non-Windows systems + +func getWindowsCPUInfo() (*CPUInfo, error) { + return &CPUInfo{}, nil +} + +func getWindowsMemoryInfo() (*MemoryInfo, error) { + return &MemoryInfo{}, nil +} + +func getWindowsDiskInfo() ([]DiskInfo, error) { + return []DiskInfo{}, nil +} + +func getWindowsProcessCount() (int, error) { + return 0, nil +} + +func getWindowsUptime() (string, error) { + return "Unknown", nil +} + +func getWindowsIPAddress() (string, error) { + return "127.0.0.1", nil +} + +func getWindowsHardwareInfo() map[string]string { + return make(map[string]string) +} + +func getWindowsInfo() string { + return "Windows" +} \ No newline at end of file diff --git a/aggregator-agent/uninstall.sh b/aggregator-agent/uninstall.sh new file mode 100755 index 0000000..d6ad680 --- /dev/null +++ b/aggregator-agent/uninstall.sh @@ -0,0 +1,70 @@ +#!/bin/bash +set -e + +# RedFlag Agent Uninstallation Script +# This script removes the RedFlag agent service and configuration + +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 Uninstallation ===" +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 + +# Stop and disable service +if systemctl is-active --quiet redflag-agent; then + echo "Stopping redflag-agent service..." + systemctl stop redflag-agent + echo "✓ Service stopped" +fi + +if systemctl is-enabled --quiet redflag-agent; then + echo "Disabling redflag-agent service..." + systemctl disable redflag-agent + echo "✓ Service disabled" +fi + +# Remove service file +if [ -f "$SERVICE_FILE" ]; then + echo "Removing systemd service file..." + rm -f "$SERVICE_FILE" + systemctl daemon-reload + echo "✓ Service file removed" +fi + +# Remove sudoers configuration +if [ -f "$SUDOERS_FILE" ]; then + echo "Removing sudoers configuration..." + rm -f "$SUDOERS_FILE" + echo "✓ Sudoers configuration removed" +fi + +# Remove binary +if [ -f "$AGENT_BINARY" ]; then + echo "Removing agent binary..." + rm -f "$AGENT_BINARY" + echo "✓ Agent binary removed" +fi + +# Optionally remove user (commented out by default to preserve logs/data) +# if id "$AGENT_USER" &>/dev/null; then +# echo "Removing user $AGENT_USER..." +# userdel -r "$AGENT_USER" +# echo "✓ User removed" +# fi + +echo "" +echo "=== Uninstallation Complete ===" +echo "" +echo "Note: The $AGENT_USER user and $AGENT_HOME directory have been preserved." +echo "To completely remove them, run:" +echo " sudo userdel -r $AGENT_USER" +echo "" diff --git a/aggregator-server/cmd/server/main.go b/aggregator-server/cmd/server/main.go index c0f2e2a..9dd23eb 100644 --- a/aggregator-server/cmd/server/main.go +++ b/aggregator-server/cmd/server/main.go @@ -44,13 +44,15 @@ func main() { agentQueries := queries.NewAgentQueries(db.DB) updateQueries := queries.NewUpdateQueries(db.DB) commandQueries := queries.NewCommandQueries(db.DB) + refreshTokenQueries := queries.NewRefreshTokenQueries(db.DB) // Initialize services timezoneService := services.NewTimezoneService(cfg) + timeoutService := services.NewTimeoutService(commandQueries, updateQueries) // Initialize handlers - agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, cfg.CheckInInterval) - updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries) + agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, cfg.CheckInInterval, cfg.LatestAgentVersion) + updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries) authHandler := handlers.NewAuthHandler(cfg.JWTSecret) statsHandler := handlers.NewStatsHandler(agentQueries, updateQueries) settingsHandler := handlers.NewSettingsHandler(timezoneService) @@ -75,8 +77,9 @@ func main() { api.POST("/auth/logout", authHandler.Logout) api.GET("/auth/verify", authHandler.VerifyToken) - // Public routes + // Public routes (no authentication required) api.POST("/agents/register", agentHandler.RegisterAgent) + api.POST("/agents/renew", agentHandler.RenewToken) // Protected agent routes agents := api.Group("/agents") @@ -85,6 +88,7 @@ func main() { agents.GET("/:id/commands", agentHandler.GetCommands) agents.POST("/:id/updates", updateHandler.ReportUpdates) agents.POST("/:id/logs", updateHandler.ReportLog) + agents.POST("/:id/dependencies", updateHandler.ReportDependencies) } // Dashboard/Web routes (protected by web auth) @@ -99,10 +103,22 @@ func main() { dashboard.DELETE("/agents/:id", agentHandler.UnregisterAgent) dashboard.GET("/updates", updateHandler.ListUpdates) dashboard.GET("/updates/:id", updateHandler.GetUpdate) + dashboard.GET("/updates/:id/logs", updateHandler.GetUpdateLogs) dashboard.POST("/updates/:id/approve", updateHandler.ApproveUpdate) dashboard.POST("/updates/approve", updateHandler.ApproveUpdates) dashboard.POST("/updates/:id/reject", updateHandler.RejectUpdate) dashboard.POST("/updates/:id/install", updateHandler.InstallUpdate) + dashboard.POST("/updates/:id/confirm-dependencies", updateHandler.ConfirmDependencies) + + // Log routes + dashboard.GET("/logs", updateHandler.GetAllLogs) + dashboard.GET("/logs/active", updateHandler.GetActiveOperations) + + // Command routes + dashboard.GET("/commands/active", updateHandler.GetActiveCommands) + dashboard.GET("/commands/recent", updateHandler.GetRecentCommands) + dashboard.POST("/commands/:id/retry", updateHandler.RetryCommand) + dashboard.POST("/commands/:id/cancel", updateHandler.CancelCommand) // Settings routes dashboard.GET("/settings/timezone", settingsHandler.GetTimezone) @@ -138,6 +154,16 @@ func main() { } }() + // Start timeout service + timeoutService.Start() + log.Println("Timeout service started") + + // Add graceful shutdown for timeout service + defer func() { + timeoutService.Stop() + log.Println("Timeout service stopped") + }() + // Start server addr := ":" + cfg.ServerPort fmt.Printf("\n🚩 RedFlag Aggregator Server starting on %s\n\n", addr) diff --git a/aggregator-server/internal/api/handlers/agents.go b/aggregator-server/internal/api/handlers/agents.go index 0a9b2d2..2868e74 100644 --- a/aggregator-server/internal/api/handlers/agents.go +++ b/aggregator-server/internal/api/handlers/agents.go @@ -8,21 +8,26 @@ import ( "github.com/aggregator-project/aggregator-server/internal/api/middleware" "github.com/aggregator-project/aggregator-server/internal/database/queries" "github.com/aggregator-project/aggregator-server/internal/models" + "github.com/aggregator-project/aggregator-server/internal/utils" "github.com/gin-gonic/gin" "github.com/google/uuid" ) type AgentHandler struct { - agentQueries *queries.AgentQueries - commandQueries *queries.CommandQueries - checkInInterval int + agentQueries *queries.AgentQueries + commandQueries *queries.CommandQueries + refreshTokenQueries *queries.RefreshTokenQueries + checkInInterval int + latestAgentVersion string } -func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, checkInInterval int) *AgentHandler { +func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *queries.RefreshTokenQueries, checkInInterval int, latestAgentVersion string) *AgentHandler { return &AgentHandler{ - agentQueries: aq, - commandQueries: cq, - checkInInterval: checkInInterval, + agentQueries: aq, + commandQueries: cq, + refreshTokenQueries: rtq, + checkInInterval: checkInInterval, + latestAgentVersion: latestAgentVersion, } } @@ -60,17 +65,32 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) { return } - // Generate JWT token + // Generate JWT access token (short-lived: 24 hours) token, err := middleware.GenerateAgentToken(agent.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"}) return } - // Return response + // Generate refresh token (long-lived: 90 days) + refreshToken, err := queries.GenerateRefreshToken() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"}) + return + } + + // Store refresh token in database with 90-day expiration + refreshTokenExpiry := time.Now().Add(90 * 24 * time.Hour) + if err := h.refreshTokenQueries.CreateRefreshToken(agent.ID, refreshToken, refreshTokenExpiry); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store refresh token"}) + return + } + + // Return response with both tokens response := models.AgentRegistrationResponse{ - AgentID: agent.ID, - Token: token, + AgentID: agent.ID, + Token: token, + RefreshToken: refreshToken, Config: map[string]interface{}{ "check_in_interval": h.checkInInterval, "server_url": c.Request.Host, @@ -81,15 +101,123 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) { } // GetCommands returns pending commands for an agent +// Agents can optionally send lightweight system metrics in request body func (h *AgentHandler) GetCommands(c *gin.Context) { agentID := c.MustGet("agent_id").(uuid.UUID) + // Try to parse optional system metrics from request body + var metrics struct { + CPUPercent float64 `json:"cpu_percent,omitempty"` + MemoryPercent float64 `json:"memory_percent,omitempty"` + MemoryUsedGB float64 `json:"memory_used_gb,omitempty"` + MemoryTotalGB float64 `json:"memory_total_gb,omitempty"` + DiskUsedGB float64 `json:"disk_used_gb,omitempty"` + DiskTotalGB float64 `json:"disk_total_gb,omitempty"` + DiskPercent float64 `json:"disk_percent,omitempty"` + Uptime string `json:"uptime,omitempty"` + Version string `json:"version,omitempty"` + } + + // Parse metrics if provided (optional, won't fail if empty) + err := c.ShouldBindJSON(&metrics) + if err != nil { + log.Printf("DEBUG: Failed to parse metrics JSON: %v", err) + } + + // Debug logging to see what we received + log.Printf("DEBUG: Received metrics - Version: '%s', CPU: %.2f, Memory: %.2f", + metrics.Version, metrics.CPUPercent, metrics.MemoryPercent) + + // Always handle version information if provided + if metrics.Version != "" { + // Get current agent to preserve existing metadata + agent, err := h.agentQueries.GetAgentByID(agentID) + if err == nil && agent.Metadata != nil { + // Update agent's current version + if err := h.agentQueries.UpdateAgentVersion(agentID, metrics.Version); err != nil { + log.Printf("Warning: Failed to update agent version: %v", err) + } else { + // Check if update is available + updateAvailable := utils.IsNewerVersion(h.latestAgentVersion, metrics.Version) + + // Update agent's update availability status + if err := h.agentQueries.UpdateAgentUpdateAvailable(agentID, updateAvailable); err != nil { + log.Printf("Warning: Failed to update agent update availability: %v", err) + } + + // Log version check + if updateAvailable { + log.Printf("🔄 Agent %s (%s) version %s has update available: %s", + agent.Hostname, agentID, metrics.Version, h.latestAgentVersion) + } else { + log.Printf("✅ Agent %s (%s) version %s is up to date", + agent.Hostname, agentID, metrics.Version) + } + + // Store version in metadata as well + agent.Metadata["reported_version"] = metrics.Version + agent.Metadata["latest_version"] = h.latestAgentVersion + agent.Metadata["update_available"] = updateAvailable + agent.Metadata["version_checked_at"] = time.Now().Format(time.RFC3339) + } + } + } + + // Update agent metadata with current metrics if provided + if metrics.CPUPercent > 0 || metrics.MemoryPercent > 0 || metrics.DiskUsedGB > 0 || metrics.Uptime != "" { + // Get current agent to preserve existing metadata + agent, err := h.agentQueries.GetAgentByID(agentID) + if err == nil && agent.Metadata != nil { + // Update metrics in metadata + agent.Metadata["cpu_percent"] = metrics.CPUPercent + agent.Metadata["memory_percent"] = metrics.MemoryPercent + agent.Metadata["memory_used_gb"] = metrics.MemoryUsedGB + agent.Metadata["memory_total_gb"] = metrics.MemoryTotalGB + agent.Metadata["disk_used_gb"] = metrics.DiskUsedGB + agent.Metadata["disk_total_gb"] = metrics.DiskTotalGB + agent.Metadata["disk_percent"] = metrics.DiskPercent + agent.Metadata["uptime"] = metrics.Uptime + agent.Metadata["metrics_updated_at"] = time.Now().Format(time.RFC3339) + + // Update agent with new metadata + if err := h.agentQueries.UpdateAgent(agent); err != nil { + log.Printf("Warning: Failed to update agent metrics: %v", err) + } + } + } + // Update last_seen if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"}) return } - log.Printf("Updated last_seen for agent %s", agentID) + + // Check for version updates for agents that don't send version in metrics + // This ensures agents like Metis that don't report version still get update checks + if metrics.Version == "" { + // Get current agent to check version + agent, err := h.agentQueries.GetAgentByID(agentID) + if err == nil && agent.CurrentVersion != "" { + // Check if update is available based on stored version + updateAvailable := utils.IsNewerVersion(h.latestAgentVersion, agent.CurrentVersion) + + // Update agent's update availability status if it changed + if agent.UpdateAvailable != updateAvailable { + if err := h.agentQueries.UpdateAgentUpdateAvailable(agentID, updateAvailable); err != nil { + log.Printf("Warning: Failed to update agent update availability: %v", err) + } else { + // Log version check for agent without version reporting + if updateAvailable { + log.Printf("🔄 Agent %s (%s) stored version %s has update available: %s", + agent.Hostname, agentID, agent.CurrentVersion, h.latestAgentVersion) + } else { + log.Printf("✅ Agent %s (%s) stored version %s is up to date", + agent.Hostname, agentID, agent.CurrentVersion) + } + } + } + } + } // Get pending commands commands, err := h.commandQueries.GetPendingCommands(agentID) @@ -246,6 +374,58 @@ func (h *AgentHandler) TriggerUpdate(c *gin.Context) { }) } +// RenewToken handles token renewal using refresh token +func (h *AgentHandler) RenewToken(c *gin.Context) { + var req models.TokenRenewalRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate refresh token + refreshToken, err := h.refreshTokenQueries.ValidateRefreshToken(req.AgentID, req.RefreshToken) + if err != nil { + log.Printf("Token renewal failed for agent %s: %v", req.AgentID, err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"}) + return + } + + // Check if agent still exists + agent, err := h.agentQueries.GetAgentByID(req.AgentID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + + // Update agent last_seen timestamp + if err := h.agentQueries.UpdateAgentLastSeen(req.AgentID); err != nil { + log.Printf("Warning: Failed to update last_seen for agent %s: %v", req.AgentID, err) + } + + // Update refresh token expiration (sliding window - reset to 90 days from now) + // This ensures active agents never need to re-register + newExpiry := time.Now().Add(90 * 24 * time.Hour) + if err := h.refreshTokenQueries.UpdateExpiration(refreshToken.ID, newExpiry); err != nil { + log.Printf("Warning: Failed to update refresh token expiration: %v", err) + } + + // Generate new access token (24 hours) + token, err := middleware.GenerateAgentToken(req.AgentID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"}) + return + } + + log.Printf("✅ Token renewed successfully for agent %s (%s)", agent.Hostname, req.AgentID) + + // Return new access token + response := models.TokenRenewalResponse{ + Token: token, + } + + c.JSON(http.StatusOK, response) +} + // UnregisterAgent removes an agent from the system func (h *AgentHandler) UnregisterAgent(c *gin.Context) { idStr := c.Param("id") diff --git a/aggregator-server/internal/api/handlers/updates.go b/aggregator-server/internal/api/handlers/updates.go index 3eef378..389dad1 100644 --- a/aggregator-server/internal/api/handlers/updates.go +++ b/aggregator-server/internal/api/handlers/updates.go @@ -13,14 +13,16 @@ import ( ) type UpdateHandler struct { - updateQueries *queries.UpdateQueries - agentQueries *queries.AgentQueries + updateQueries *queries.UpdateQueries + agentQueries *queries.AgentQueries + commandQueries *queries.CommandQueries } -func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries) *UpdateHandler { +func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries) *UpdateHandler { return &UpdateHandler{ - updateQueries: uq, - agentQueries: aq, + updateQueries: uq, + agentQueries: aq, + commandQueries: cq, } } @@ -182,11 +184,46 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) { ExecutedAt: time.Now(), } + // Store the log entry if err := h.updateQueries.CreateUpdateLog(log); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save log"}) return } + // NEW: Update command status if command_id is provided + if req.CommandID != "" { + commandID, err := uuid.Parse(req.CommandID) + if err != nil { + // Log warning but don't fail the request + fmt.Printf("Warning: Invalid command ID format in log request: %s\n", req.CommandID) + } else { + // Prepare result data for command update + result := models.JSONB{ + "stdout": req.Stdout, + "stderr": req.Stderr, + "exit_code": req.ExitCode, + "duration_seconds": req.DurationSeconds, + "logged_at": time.Now(), + } + + // Update command status based on log result + if req.Result == "success" { + if err := h.commandQueries.MarkCommandCompleted(commandID, result); err != nil { + fmt.Printf("Warning: Failed to mark command %s as completed: %v\n", commandID, err) + } + } else if req.Result == "failed" || req.Result == "dry_run_failed" { + if err := h.commandQueries.MarkCommandFailed(commandID, result); err != nil { + fmt.Printf("Warning: Failed to mark command %s as failed: %v\n", commandID, err) + } + } else { + // For other results, just update the result field + if err := h.commandQueries.UpdateCommandResult(commandID, result); err != nil { + fmt.Printf("Warning: Failed to update command %s result: %v\n", commandID, err) + } + } + } + } + c.JSON(http.StatusOK, gin.H{"message": "log recorded"}) } @@ -328,7 +365,7 @@ func (h *UpdateHandler) RejectUpdate(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "update rejected"}) } -// InstallUpdate marks an update as ready for installation +// InstallUpdate marks an update as ready for installation and creates a dry run command for the agent func (h *UpdateHandler) InstallUpdate(c *gin.Context) { idStr := c.Param("id") id, err := uuid.Parse(idStr) @@ -337,10 +374,268 @@ func (h *UpdateHandler) InstallUpdate(c *gin.Context) { return } - if err := h.updateQueries.InstallUpdate(id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start update installation"}) + // Get the full update details to extract agent_id, package_name, and package_type + update, err := h.updateQueries.GetUpdateByID(id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get update details"}) return } - c.JSON(http.StatusOK, gin.H{"message": "update installation started"}) + // Create a command for the agent to perform dry run first + command := &models.AgentCommand{ + ID: uuid.New(), + AgentID: update.AgentID, + CommandType: models.CommandTypeDryRunUpdate, + Params: map[string]interface{}{ + "update_id": id.String(), + "package_name": update.PackageName, + "package_type": update.PackageType, + }, + Status: models.CommandStatusPending, + CreatedAt: time.Now(), + } + + // Store the command in database + if err := h.commandQueries.CreateCommand(command); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dry run command"}) + return + } + + // Update the package status to 'checking_dependencies' to show dry run is starting + if err := h.updateQueries.SetCheckingDependencies(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "dry run command created for agent", + "command_id": command.ID.String(), + }) +} + +// GetUpdateLogs retrieves installation logs for a specific update +func (h *UpdateHandler) GetUpdateLogs(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"}) + return + } + + // Parse limit from query params + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + + logs, err := h.updateQueries.GetUpdateLogs(id, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve update logs"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "logs": logs, + "count": len(logs), + }) +} + +// ReportDependencies handles dependency reporting from agents after dry run +func (h *UpdateHandler) ReportDependencies(c *gin.Context) { + agentID := c.MustGet("agent_id").(uuid.UUID) + + // Update last_seen timestamp + if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"}) + return + } + + var req models.DependencyReportRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Update the package status to pending_dependencies + if err := h.updateQueries.SetPendingDependencies(agentID, req.PackageType, req.PackageName, req.Dependencies); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "dependencies reported and status updated"}) +} + +// ConfirmDependencies handles user confirmation to proceed with dependency installation +func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"}) + return + } + + // Get the update details + update, err := h.updateQueries.GetUpdateByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "update not found"}) + return + } + + // Create a command for the agent to install with dependencies + command := &models.AgentCommand{ + ID: uuid.New(), + AgentID: update.AgentID, + CommandType: models.CommandTypeConfirmDependencies, + Params: map[string]interface{}{ + "update_id": id.String(), + "package_name": update.PackageName, + "package_type": update.PackageType, + "dependencies": update.Metadata["dependencies"], // Dependencies stored in metadata + }, + Status: models.CommandStatusPending, + CreatedAt: time.Now(), + } + + // Store the command in database + if err := h.commandQueries.CreateCommand(command); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create confirmation command"}) + return + } + + // Update the package status to 'installing' + if err := h.updateQueries.InstallUpdate(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "dependency installation confirmed and command created", + "command_id": command.ID.String(), + }) +} + +// GetAllLogs retrieves logs across all agents with filtering for universal log view +func (h *UpdateHandler) GetAllLogs(c *gin.Context) { + filters := &models.LogFilters{ + Action: c.Query("action"), + Result: c.Query("result"), + } + + // Parse agent_id if provided + if agentIDStr := c.Query("agent_id"); agentIDStr != "" { + agentID, err := uuid.Parse(agentIDStr) + if err == nil { + filters.AgentID = agentID + } + } + + // Parse since timestamp if provided + if sinceStr := c.Query("since"); sinceStr != "" { + sinceTime, err := time.Parse(time.RFC3339, sinceStr) + if err == nil { + filters.Since = &sinceTime + } + } + + // Parse pagination + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100")) + filters.Page = page + filters.PageSize = pageSize + + logs, total, err := h.updateQueries.GetAllLogs(filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve logs"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "logs": logs, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// GetActiveOperations retrieves currently running operations for live status view +func (h *UpdateHandler) GetActiveOperations(c *gin.Context) { + operations, err := h.updateQueries.GetActiveOperations() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve active operations"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "operations": operations, + "count": len(operations), + }) +} + +// RetryCommand retries a failed, timed_out, or cancelled command +func (h *UpdateHandler) RetryCommand(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"}) + return + } + + // Create a new command based on the original + newCommand, err := h.commandQueries.RetryCommand(id) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to retry command: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "command retry created", + "command_id": newCommand.ID.String(), + "new_id": newCommand.ID.String(), + }) +} + +// CancelCommand cancels a pending or sent command +func (h *UpdateHandler) CancelCommand(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"}) + return + } + + // Cancel the command + if err := h.commandQueries.CancelCommand(id); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to cancel command: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "command cancelled"}) +} + +// GetActiveCommands retrieves currently active commands for live operations view +func (h *UpdateHandler) GetActiveCommands(c *gin.Context) { + commands, err := h.commandQueries.GetActiveCommands() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve active commands"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "commands": commands, + "count": len(commands), + }) +} + +// GetRecentCommands retrieves recent commands for retry functionality +func (h *UpdateHandler) GetRecentCommands(c *gin.Context) { + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + + commands, err := h.commandQueries.GetRecentCommands(limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve recent commands"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "commands": commands, + "count": len(commands), + "limit": limit, + }) } diff --git a/aggregator-server/internal/config/config.go b/aggregator-server/internal/config/config.go index 9a3f5d3..9f2c5d3 100644 --- a/aggregator-server/internal/config/config.go +++ b/aggregator-server/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { CheckInInterval int OfflineThreshold int Timezone string + LatestAgentVersion string } // Load reads configuration from environment variables @@ -27,12 +28,13 @@ func Load() (*Config, error) { offlineThreshold, _ := strconv.Atoi(getEnv("OFFLINE_THRESHOLD", "600")) cfg := &Config{ - ServerPort: getEnv("SERVER_PORT", "8080"), - DatabaseURL: getEnv("DATABASE_URL", "postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable"), - JWTSecret: getEnv("JWT_SECRET", "test-secret-for-development-only"), - CheckInInterval: checkInInterval, - OfflineThreshold: offlineThreshold, - Timezone: getEnv("TIMEZONE", "UTC"), + ServerPort: getEnv("SERVER_PORT", "8080"), + DatabaseURL: getEnv("DATABASE_URL", "postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable"), + JWTSecret: getEnv("JWT_SECRET", "test-secret-for-development-only"), + CheckInInterval: checkInInterval, + OfflineThreshold: offlineThreshold, + Timezone: getEnv("TIMEZONE", "UTC"), + LatestAgentVersion: getEnv("LATEST_AGENT_VERSION", "0.1.4"), } // Debug: Log what JWT secret we're using (remove in production) diff --git a/aggregator-server/internal/database/migrations/004_fix_update_logs_foreign_key.sql b/aggregator-server/internal/database/migrations/004_fix_update_logs_foreign_key.sql new file mode 100644 index 0000000..a3f0678 --- /dev/null +++ b/aggregator-server/internal/database/migrations/004_fix_update_logs_foreign_key.sql @@ -0,0 +1,13 @@ +-- Fix foreign key relationship for update_logs table to reference current_package_state instead of update_packages +-- This ensures compatibility with the new event sourcing system + +-- First, drop the existing foreign key constraint +ALTER TABLE update_logs DROP CONSTRAINT IF EXISTS update_logs_update_package_id_fkey; + +-- Add the new foreign key constraint to reference current_package_state +ALTER TABLE update_logs +ADD CONSTRAINT update_logs_update_package_id_fkey +FOREIGN KEY (update_package_id) REFERENCES current_package_state(id) ON DELETE SET NULL; + +-- Add index for better performance on the new foreign key +CREATE INDEX IF NOT EXISTS idx_logs_update_package ON update_logs(update_package_id); \ No newline at end of file diff --git a/aggregator-server/internal/database/migrations/005_add_pending_dependencies_status.sql b/aggregator-server/internal/database/migrations/005_add_pending_dependencies_status.sql new file mode 100644 index 0000000..2b9f1c1 --- /dev/null +++ b/aggregator-server/internal/database/migrations/005_add_pending_dependencies_status.sql @@ -0,0 +1,18 @@ +-- Add pending_dependencies and checking_dependencies status to support dependency confirmation workflow +ALTER TABLE current_package_state +DROP CONSTRAINT IF EXISTS current_package_state_status_check; + +ALTER TABLE current_package_state +ADD CONSTRAINT current_package_state_status_check +CHECK (status IN ('pending', 'approved', 'updated', 'failed', 'ignored', 'installing', 'pending_dependencies', 'checking_dependencies')); + +-- Also update any legacy tables if they exist +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'updates') THEN + ALTER TABLE updates + DROP CONSTRAINT IF EXISTS updates_status_check, + ADD CONSTRAINT updates_status_check + CHECK (status IN ('pending', 'approved', 'scheduled', 'installing', 'installed', 'failed', 'ignored', 'pending_dependencies', 'checking_dependencies')); + END IF; +END $$; \ No newline at end of file diff --git a/aggregator-server/internal/database/migrations/006_add_missing_command_statuses.sql b/aggregator-server/internal/database/migrations/006_add_missing_command_statuses.sql new file mode 100644 index 0000000..286302f --- /dev/null +++ b/aggregator-server/internal/database/migrations/006_add_missing_command_statuses.sql @@ -0,0 +1,18 @@ +-- Add missing command statuses to the check constraint +-- This allows 'timed_out', 'cancelled', and 'running' statuses that the application uses + +-- First drop the existing constraint +ALTER TABLE agent_commands DROP CONSTRAINT IF EXISTS agent_commands_status_check; + +-- Add the new constraint with all valid statuses +ALTER TABLE agent_commands +ADD CONSTRAINT agent_commands_status_check +CHECK (status::text = ANY (ARRAY[ + 'pending'::character varying, + 'sent'::character varying, + 'running'::character varying, + 'completed'::character varying, + 'failed'::character varying, + 'timed_out'::character varying, + 'cancelled'::character varying +]::text[])); \ No newline at end of file diff --git a/aggregator-server/internal/database/migrations/007_expand_status_column_length.sql b/aggregator-server/internal/database/migrations/007_expand_status_column_length.sql new file mode 100644 index 0000000..2bdb7c4 --- /dev/null +++ b/aggregator-server/internal/database/migrations/007_expand_status_column_length.sql @@ -0,0 +1,13 @@ +-- Expand status column to accommodate longer status values +-- checking_dependencies (23 chars) and pending_dependencies (21 chars) exceed current 20 char limit + +ALTER TABLE current_package_state +ALTER COLUMN status TYPE character varying(30); + +-- Update check constraint to match new length +ALTER TABLE current_package_state +DROP CONSTRAINT IF EXISTS current_package_state_status_check; + +ALTER TABLE current_package_state +ADD CONSTRAINT current_package_state_status_check +CHECK (status::text = ANY (ARRAY['pending'::character varying, 'approved'::character varying, 'updated'::character varying, 'failed'::character varying, 'ignored'::character varying, 'installing'::character varying, 'pending_dependencies'::character varying, 'checking_dependencies'::character varying]::text[])); \ No newline at end of file diff --git a/aggregator-server/internal/database/migrations/008_create_refresh_tokens_table.sql b/aggregator-server/internal/database/migrations/008_create_refresh_tokens_table.sql new file mode 100644 index 0000000..ad218e1 --- /dev/null +++ b/aggregator-server/internal/database/migrations/008_create_refresh_tokens_table.sql @@ -0,0 +1,29 @@ +-- 008_create_refresh_tokens_table.sql +-- Create refresh tokens table for secure token renewal + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL, -- SHA-256 hash of the refresh token + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMP, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT unique_token_hash UNIQUE(token_hash) +); + +-- Index for fast agent lookup +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_agent_id ON refresh_tokens(agent_id); + +-- Index for expiration cleanup +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens(expires_at); + +-- Index for token validation +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash_not_revoked + ON refresh_tokens(token_hash) WHERE NOT revoked; + +COMMENT ON TABLE refresh_tokens IS 'Stores long-lived refresh tokens for agent token renewal without re-registration'; +COMMENT ON COLUMN refresh_tokens.token_hash IS 'SHA-256 hash of the refresh token for secure storage'; +COMMENT ON COLUMN refresh_tokens.expires_at IS 'Refresh token expiration (default: 90 days from creation)'; +COMMENT ON COLUMN refresh_tokens.last_used_at IS 'Timestamp of last successful token renewal'; +COMMENT ON COLUMN refresh_tokens.revoked IS 'Flag to revoke token before expiration'; diff --git a/aggregator-server/internal/database/migrations/009_add_agent_version_tracking.sql b/aggregator-server/internal/database/migrations/009_add_agent_version_tracking.sql new file mode 100644 index 0000000..e9e9b01 --- /dev/null +++ b/aggregator-server/internal/database/migrations/009_add_agent_version_tracking.sql @@ -0,0 +1,16 @@ +-- Add version tracking to agents table +-- This enables the hybrid version tracking system + +ALTER TABLE agents +ADD COLUMN current_version VARCHAR(50) DEFAULT '0.1.3', +ADD COLUMN update_available BOOLEAN DEFAULT FALSE, +ADD COLUMN last_version_check TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +-- Add index for faster queries on update status +CREATE INDEX idx_agents_update_available ON agents(update_available); +CREATE INDEX idx_agents_current_version ON agents(current_version); + +-- Add comment to document the purpose +COMMENT ON COLUMN agents.current_version IS 'The version of the agent currently running'; +COMMENT ON COLUMN agents.update_available IS 'Whether an update is available for this agent'; +COMMENT ON COLUMN agents.last_version_check IS 'Last time the agent version was checked'; \ No newline at end of file diff --git a/aggregator-server/internal/database/queries/agents.go b/aggregator-server/internal/database/queries/agents.go index 330a09f..4c21b28 100644 --- a/aggregator-server/internal/database/queries/agents.go +++ b/aggregator-server/internal/database/queries/agents.go @@ -49,6 +49,24 @@ func (q *AgentQueries) UpdateAgentLastSeen(id uuid.UUID) error { return err } +// UpdateAgent updates an agent's full record including metadata +func (q *AgentQueries) UpdateAgent(agent *models.Agent) error { + query := ` + UPDATE agents SET + hostname = :hostname, + os_type = :os_type, + os_version = :os_version, + os_architecture = :os_architecture, + agent_version = :agent_version, + last_seen = :last_seen, + status = :status, + metadata = :metadata + WHERE id = :id + ` + _, err := q.db.NamedExec(query, agent) + return err +} + // ListAgents returns all agents with optional filtering func (q *AgentQueries) ListAgents(status, osType string) ([]models.Agent, error) { var agents []models.Agent @@ -137,6 +155,29 @@ func (q *AgentQueries) ListAgentsWithLastScan(status, osType string) ([]models.A return agents, err } +// UpdateAgentVersion updates the agent's version information and checks for updates +func (q *AgentQueries) UpdateAgentVersion(id uuid.UUID, currentVersion string) error { + query := ` + UPDATE agents SET + current_version = $1, + last_version_check = $2 + WHERE id = $3 + ` + _, err := q.db.Exec(query, currentVersion, time.Now().UTC(), id) + return err +} + +// UpdateAgentUpdateAvailable sets whether an update is available for an agent +func (q *AgentQueries) UpdateAgentUpdateAvailable(id uuid.UUID, updateAvailable bool) error { + query := ` + UPDATE agents SET + update_available = $1 + WHERE id = $2 + ` + _, err := q.db.Exec(query, updateAvailable, id) + return err +} + // DeleteAgent removes an agent and all associated data func (q *AgentQueries) DeleteAgent(id uuid.UUID) error { // Start a transaction for atomic deletion diff --git a/aggregator-server/internal/database/queries/commands.go b/aggregator-server/internal/database/queries/commands.go index 899485c..fe14d72 100644 --- a/aggregator-server/internal/database/queries/commands.go +++ b/aggregator-server/internal/database/queries/commands.go @@ -1,6 +1,7 @@ package queries import ( + "fmt" "time" "github.com/aggregator-project/aggregator-server/internal/models" @@ -77,3 +78,168 @@ func (q *CommandQueries) MarkCommandFailed(id uuid.UUID, result models.JSONB) er _, err := q.db.Exec(query, now, result, id) return err } + +// GetCommandsByStatus retrieves commands with a specific status +func (q *CommandQueries) GetCommandsByStatus(status string) ([]models.AgentCommand, error) { + var commands []models.AgentCommand + query := ` + SELECT * FROM agent_commands + WHERE status = $1 + ORDER BY created_at DESC + ` + err := q.db.Select(&commands, query, status) + return commands, err +} + +// UpdateCommandStatus updates only the status of a command +func (q *CommandQueries) UpdateCommandStatus(id uuid.UUID, status string) error { + query := ` + UPDATE agent_commands + SET status = $1 + WHERE id = $2 + ` + _, err := q.db.Exec(query, status, id) + return err +} + +// UpdateCommandResult updates only the result of a command +func (q *CommandQueries) UpdateCommandResult(id uuid.UUID, result interface{}) error { + query := ` + UPDATE agent_commands + SET result = $1 + WHERE id = $2 + ` + _, err := q.db.Exec(query, result, id) + return err +} + +// GetCommandByID retrieves a specific command by ID +func (q *CommandQueries) GetCommandByID(id uuid.UUID) (*models.AgentCommand, error) { + var command models.AgentCommand + query := ` + SELECT * FROM agent_commands + WHERE id = $1 + ` + err := q.db.Get(&command, query, id) + if err != nil { + return nil, err + } + return &command, nil +} + +// CancelCommand marks a command as cancelled +func (q *CommandQueries) CancelCommand(id uuid.UUID) error { + now := time.Now() + query := ` + UPDATE agent_commands + SET status = 'cancelled', completed_at = $1 + WHERE id = $2 AND status IN ('pending', 'sent') + ` + _, err := q.db.Exec(query, now, id) + return err +} + +// RetryCommand creates a new command based on a failed/timed_out/cancelled command +func (q *CommandQueries) RetryCommand(originalID uuid.UUID) (*models.AgentCommand, error) { + // Get the original command + original, err := q.GetCommandByID(originalID) + if err != nil { + return nil, err + } + + // Only allow retry of failed, timed_out, or cancelled commands + if original.Status != "failed" && original.Status != "timed_out" && original.Status != "cancelled" { + return nil, fmt.Errorf("command must be failed, timed_out, or cancelled to retry") + } + + // Create new command with same parameters + newCommand := &models.AgentCommand{ + ID: uuid.New(), + AgentID: original.AgentID, + CommandType: original.CommandType, + Params: original.Params, + Status: models.CommandStatusPending, + CreatedAt: time.Now(), + } + + // Store the new command + if err := q.CreateCommand(newCommand); err != nil { + return nil, err + } + + return newCommand, nil +} + +// GetActiveCommands retrieves commands that are not in a final/terminal state +// Shows anything that's in progress or can be retried (excludes completed and cancelled) +func (q *CommandQueries) GetActiveCommands() ([]models.ActiveCommandInfo, error) { + var commands []models.ActiveCommandInfo + + query := ` + SELECT + c.id, + c.agent_id, + c.command_type, + c.status, + c.created_at, + c.sent_at, + c.result, + a.hostname as agent_hostname, + COALESCE(ups.package_name, 'N/A') as package_name, + COALESCE(ups.package_type, 'N/A') as package_type + FROM agent_commands c + LEFT JOIN agents a ON c.agent_id = a.id + LEFT JOIN current_package_state ups ON ( + c.params->>'update_id' = ups.id::text OR + (c.params->>'package_name' = ups.package_name AND c.params->>'package_type' = ups.package_type) + ) + WHERE c.status NOT IN ('completed', 'cancelled') + ORDER BY c.created_at DESC + ` + + err := q.db.Select(&commands, query) + if err != nil { + return nil, fmt.Errorf("failed to get active commands: %w", err) + } + + return commands, nil +} + +// GetRecentCommands retrieves recent commands (including failed, completed, etc.) for retry functionality +func (q *CommandQueries) GetRecentCommands(limit int) ([]models.ActiveCommandInfo, error) { + var commands []models.ActiveCommandInfo + + if limit == 0 { + limit = 50 // Default limit + } + + query := ` + SELECT + c.id, + c.agent_id, + c.command_type, + c.status, + c.created_at, + c.sent_at, + c.completed_at, + c.result, + a.hostname as agent_hostname, + COALESCE(ups.package_name, 'N/A') as package_name, + COALESCE(ups.package_type, 'N/A') as package_type + FROM agent_commands c + LEFT JOIN agents a ON c.agent_id = a.id + LEFT JOIN current_package_state ups ON ( + c.params->>'update_id' = ups.id::text OR + (c.params->>'package_name' = ups.package_name AND c.params->>'package_type' = ups.package_type) + ) + ORDER BY c.created_at DESC + LIMIT $1 + ` + + err := q.db.Select(&commands, query, limit) + if err != nil { + return nil, fmt.Errorf("failed to get recent commands: %w", err) + } + + return commands, nil +} diff --git a/aggregator-server/internal/database/queries/refresh_tokens.go b/aggregator-server/internal/database/queries/refresh_tokens.go new file mode 100644 index 0000000..3c665c0 --- /dev/null +++ b/aggregator-server/internal/database/queries/refresh_tokens.go @@ -0,0 +1,171 @@ +package queries + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type RefreshTokenQueries struct { + db *sqlx.DB +} + +func NewRefreshTokenQueries(db *sqlx.DB) *RefreshTokenQueries { + return &RefreshTokenQueries{db: db} +} + +// RefreshToken represents a refresh token in the database +type RefreshToken struct { + ID uuid.UUID `db:"id"` + AgentID uuid.UUID `db:"agent_id"` + TokenHash string `db:"token_hash"` + ExpiresAt time.Time `db:"expires_at"` + CreatedAt time.Time `db:"created_at"` + LastUsedAt *time.Time `db:"last_used_at"` + Revoked bool `db:"revoked"` +} + +// GenerateRefreshToken creates a cryptographically secure random token +func GenerateRefreshToken() (string, error) { + // Generate 32 bytes of random data (256 bits) + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return "", fmt.Errorf("failed to generate random token: %w", err) + } + + // Encode as hex string (64 characters) + token := hex.EncodeToString(tokenBytes) + return token, nil +} + +// HashRefreshToken creates SHA-256 hash of the token for storage +func HashRefreshToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} + +// CreateRefreshToken stores a new refresh token for an agent +func (q *RefreshTokenQueries) CreateRefreshToken(agentID uuid.UUID, token string, expiresAt time.Time) error { + tokenHash := HashRefreshToken(token) + + query := ` + INSERT INTO refresh_tokens (agent_id, token_hash, expires_at) + VALUES ($1, $2, $3) + ` + + _, err := q.db.Exec(query, agentID, tokenHash, expiresAt) + return err +} + +// ValidateRefreshToken checks if a refresh token is valid +func (q *RefreshTokenQueries) ValidateRefreshToken(agentID uuid.UUID, token string) (*RefreshToken, error) { + tokenHash := HashRefreshToken(token) + + query := ` + SELECT id, agent_id, token_hash, expires_at, created_at, last_used_at, revoked + FROM refresh_tokens + WHERE agent_id = $1 AND token_hash = $2 AND NOT revoked + ` + + var refreshToken RefreshToken + err := q.db.Get(&refreshToken, query, agentID, tokenHash) + if err != nil { + return nil, fmt.Errorf("refresh token not found or invalid: %w", err) + } + + // Check if token is expired + if time.Now().After(refreshToken.ExpiresAt) { + return nil, fmt.Errorf("refresh token expired") + } + + return &refreshToken, nil +} + +// UpdateLastUsed updates the last_used_at timestamp for a refresh token +func (q *RefreshTokenQueries) UpdateLastUsed(tokenID uuid.UUID) error { + query := ` + UPDATE refresh_tokens + SET last_used_at = NOW() + WHERE id = $1 + ` + + _, err := q.db.Exec(query, tokenID) + return err +} + +// UpdateExpiration updates the refresh token expiration (for sliding window) +// Resets expiration to specified time and updates last_used_at +func (q *RefreshTokenQueries) UpdateExpiration(tokenID uuid.UUID, newExpiry time.Time) error { + query := ` + UPDATE refresh_tokens + SET expires_at = $1, last_used_at = NOW() + WHERE id = $2 + ` + + _, err := q.db.Exec(query, newExpiry, tokenID) + return err +} + +// RevokeRefreshToken marks a refresh token as revoked +func (q *RefreshTokenQueries) RevokeRefreshToken(agentID uuid.UUID, token string) error { + tokenHash := HashRefreshToken(token) + + query := ` + UPDATE refresh_tokens + SET revoked = TRUE + WHERE agent_id = $1 AND token_hash = $2 + ` + + _, err := q.db.Exec(query, agentID, tokenHash) + return err +} + +// RevokeAllAgentTokens revokes all refresh tokens for an agent +func (q *RefreshTokenQueries) RevokeAllAgentTokens(agentID uuid.UUID) error { + query := ` + UPDATE refresh_tokens + SET revoked = TRUE + WHERE agent_id = $1 AND NOT revoked + ` + + _, err := q.db.Exec(query, agentID) + return err +} + +// CleanupExpiredTokens removes expired refresh tokens from the database +func (q *RefreshTokenQueries) CleanupExpiredTokens() (int64, error) { + query := ` + DELETE FROM refresh_tokens + WHERE expires_at < NOW() OR revoked = TRUE + ` + + result, err := q.db.Exec(query) + if err != nil { + return 0, err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return 0, err + } + + return rowsAffected, nil +} + +// GetActiveTokenCount returns the number of active (non-revoked, non-expired) tokens for an agent +func (q *RefreshTokenQueries) GetActiveTokenCount(agentID uuid.UUID) (int, error) { + query := ` + SELECT COUNT(*) + FROM refresh_tokens + WHERE agent_id = $1 AND NOT revoked AND expires_at > NOW() + ` + + var count int + err := q.db.Get(&count, query, agentID) + return count, err +} diff --git a/aggregator-server/internal/database/queries/updates.go b/aggregator-server/internal/database/queries/updates.go index 680c9bd..d70d1bf 100644 --- a/aggregator-server/internal/database/queries/updates.go +++ b/aggregator-server/internal/database/queries/updates.go @@ -1,6 +1,7 @@ package queries import ( + "encoding/json" "fmt" "strings" "time" @@ -210,6 +211,41 @@ func (q *UpdateQueries) InstallUpdate(id uuid.UUID) error { return err } +// SetCheckingDependencies marks an update as being checked for dependencies +func (q *UpdateQueries) SetCheckingDependencies(id uuid.UUID) error { + query := ` + UPDATE current_package_state + SET status = 'checking_dependencies', last_updated_at = NOW() + WHERE id = $1 AND status = 'approved' + ` + _, err := q.db.Exec(query, id) + return err +} + +// SetPendingDependencies marks an update as having dependencies that need approval +func (q *UpdateQueries) SetPendingDependencies(agentID uuid.UUID, packageType, packageName string, dependencies []string) error { + // Marshal dependencies to JSON for database storage + depsJSON, err := json.Marshal(dependencies) + if err != nil { + return fmt.Errorf("failed to marshal dependencies: %w", err) + } + + query := ` + UPDATE current_package_state + SET status = 'pending_dependencies', + metadata = jsonb_set( + jsonb_set(metadata, '{dependencies}', $4::jsonb), + '{dependencies_reported_at}', + to_jsonb(NOW()) + ), + last_updated_at = NOW() + WHERE agent_id = $1 AND package_type = $2 AND package_name = $3 + AND status IN ('checking_dependencies', 'installing') + ` + _, err = q.db.Exec(query, agentID, packageType, packageName, depsJSON) + return err +} + // CreateUpdateLog inserts an update log entry func (q *UpdateQueries) CreateUpdateLog(log *models.UpdateLog) error { query := ` @@ -456,7 +492,7 @@ func (q *UpdateQueries) GetPackageHistory(agentID uuid.UUID, packageType, packag } // UpdatePackageStatus updates the status of a package and records history -func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, packageName, status string, metadata map[string]interface{}) error { +func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, packageName, status string, metadata models.JSONB) error { tx, err := q.db.Beginx() if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) @@ -587,3 +623,127 @@ func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) { return stats, nil } + +// GetUpdateLogs retrieves installation logs for a specific update +func (q *UpdateQueries) GetUpdateLogs(updateID uuid.UUID, limit int) ([]models.UpdateLog, error) { + var logs []models.UpdateLog + + query := ` + SELECT + id, agent_id, update_package_id, action, result, + stdout, stderr, exit_code, duration_seconds, executed_at + FROM update_logs + WHERE update_package_id = $1 + ORDER BY executed_at DESC + LIMIT $2 + ` + + if limit == 0 { + limit = 50 // Default limit + } + + err := q.db.Select(&logs, query, updateID, limit) + if err != nil { + return nil, fmt.Errorf("failed to get update logs: %w", err) + } + + return logs, nil +} + +// GetAllLogs retrieves logs across all agents with filtering +func (q *UpdateQueries) GetAllLogs(filters *models.LogFilters) ([]models.UpdateLog, int, error) { + var logs []models.UpdateLog + whereClause := []string{"1=1"} + args := []interface{}{} + argIdx := 1 + + // Add filters + if filters.AgentID != uuid.Nil { + whereClause = append(whereClause, fmt.Sprintf("agent_id = $%d", argIdx)) + args = append(args, filters.AgentID) + argIdx++ + } + + if filters.Action != "" { + whereClause = append(whereClause, fmt.Sprintf("action = $%d", argIdx)) + args = append(args, filters.Action) + argIdx++ + } + + if filters.Result != "" { + whereClause = append(whereClause, fmt.Sprintf("result = $%d", argIdx)) + args = append(args, filters.Result) + argIdx++ + } + + if filters.Since != nil { + whereClause = append(whereClause, fmt.Sprintf("executed_at >= $%d", argIdx)) + args = append(args, filters.Since) + argIdx++ + } + + // Get total count + countQuery := "SELECT COUNT(*) FROM update_logs WHERE " + strings.Join(whereClause, " AND ") + var total int + err := q.db.Get(&total, countQuery, args...) + if err != nil { + return nil, 0, fmt.Errorf("failed to get logs count: %w", err) + } + + // Get paginated results + query := fmt.Sprintf(` + SELECT + id, agent_id, update_package_id, action, result, + stdout, stderr, exit_code, duration_seconds, executed_at + FROM update_logs + WHERE %s + ORDER BY executed_at DESC + LIMIT $%d OFFSET $%d + `, strings.Join(whereClause, " AND "), argIdx, argIdx+1) + + limit := filters.PageSize + if limit == 0 { + limit = 100 // Default limit + } + offset := (filters.Page - 1) * limit + if offset < 0 { + offset = 0 + } + + args = append(args, limit, offset) + err = q.db.Select(&logs, query, args...) + if err != nil { + return nil, 0, fmt.Errorf("failed to get all logs: %w", err) + } + + return logs, total, nil +} + +// GetActiveOperations returns currently running operations +func (q *UpdateQueries) GetActiveOperations() ([]models.ActiveOperation, error) { + var operations []models.ActiveOperation + + query := ` + SELECT DISTINCT ON (agent_id, package_type, package_name) + id, + agent_id, + package_type, + package_name, + current_version, + available_version, + severity, + status, + last_updated_at, + metadata + FROM current_package_state + WHERE status IN ('checking_dependencies', 'installing', 'pending_dependencies') + ORDER BY agent_id, package_type, package_name, last_updated_at DESC + ` + + err := q.db.Select(&operations, query) + if err != nil { + return nil, fmt.Errorf("failed to get active operations: %w", err) + } + + return operations, nil +} diff --git a/aggregator-server/internal/models/agent.go b/aggregator-server/internal/models/agent.go index 696bb33..5d6697d 100644 --- a/aggregator-server/internal/models/agent.go +++ b/aggregator-server/internal/models/agent.go @@ -15,7 +15,10 @@ type Agent struct { OSType string `json:"os_type" db:"os_type"` OSVersion string `json:"os_version" db:"os_version"` OSArchitecture string `json:"os_architecture" db:"os_architecture"` - AgentVersion string `json:"agent_version" db:"agent_version"` + AgentVersion string `json:"agent_version" db:"agent_version"` // Version at registration + CurrentVersion string `json:"current_version" db:"current_version"` // Current running version + UpdateAvailable bool `json:"update_available" db:"update_available"` // Whether update is available + LastVersionCheck time.Time `json:"last_version_check" db:"last_version_check"` // Last time version was checked LastSeen time.Time `json:"last_seen" db:"last_seen"` Status string `json:"status" db:"status"` Metadata JSONB `json:"metadata" db:"metadata"` @@ -30,7 +33,10 @@ type AgentWithLastScan struct { OSType string `json:"os_type" db:"os_type"` OSVersion string `json:"os_version" db:"os_version"` OSArchitecture string `json:"os_architecture" db:"os_architecture"` - AgentVersion string `json:"agent_version" db:"agent_version"` + AgentVersion string `json:"agent_version" db:"agent_version"` // Version at registration + CurrentVersion string `json:"current_version" db:"current_version"` // Current running version + UpdateAvailable bool `json:"update_available" db:"update_available"` // Whether update is available + LastVersionCheck time.Time `json:"last_version_check" db:"last_version_check"` // Last time version was checked LastSeen time.Time `json:"last_seen" db:"last_seen"` Status string `json:"status" db:"status"` Metadata JSONB `json:"metadata" db:"metadata"` @@ -67,9 +73,21 @@ type AgentRegistrationRequest struct { // AgentRegistrationResponse is returned after successful registration type AgentRegistrationResponse struct { - AgentID uuid.UUID `json:"agent_id"` - Token string `json:"token"` - Config map[string]interface{} `json:"config"` + 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) + Config map[string]interface{} `json:"config"` +} + +// TokenRenewalRequest is the payload for token renewal using refresh token +type TokenRenewalRequest struct { + AgentID uuid.UUID `json:"agent_id" binding:"required"` + RefreshToken string `json:"refresh_token" binding:"required"` +} + +// TokenRenewalResponse is returned after successful token renewal +type TokenRenewalResponse struct { + Token string `json:"token"` // New short-lived access token (24h) } // UTCTime is a time.Time that marshals to ISO format with UTC timezone diff --git a/aggregator-server/internal/models/command.go b/aggregator-server/internal/models/command.go index 0b06ff4..3eb8e1f 100644 --- a/aggregator-server/internal/models/command.go +++ b/aggregator-server/internal/models/command.go @@ -33,11 +33,13 @@ type CommandItem struct { // Command types const ( - CommandTypeScanUpdates = "scan_updates" - CommandTypeCollectSpecs = "collect_specs" - CommandTypeInstallUpdate = "install_updates" - CommandTypeRollback = "rollback_update" - CommandTypeUpdateAgent = "update_agent" + CommandTypeScanUpdates = "scan_updates" + CommandTypeCollectSpecs = "collect_specs" + CommandTypeInstallUpdate = "install_updates" + CommandTypeDryRunUpdate = "dry_run_update" + CommandTypeConfirmDependencies = "confirm_dependencies" + CommandTypeRollback = "rollback_update" + CommandTypeUpdateAgent = "update_agent" ) // Command statuses @@ -46,4 +48,22 @@ const ( CommandStatusSent = "sent" CommandStatusCompleted = "completed" CommandStatusFailed = "failed" + CommandStatusTimedOut = "timed_out" + CommandStatusCancelled = "cancelled" + CommandStatusRunning = "running" ) + +// ActiveCommandInfo represents information about an active command for UI display +type ActiveCommandInfo struct { + ID uuid.UUID `json:"id" db:"id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + CommandType string `json:"command_type" db:"command_type"` + Status string `json:"status" db:"status"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` + CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` + Result JSONB `json:"result,omitempty" db:"result"` + AgentHostname string `json:"agent_hostname" db:"agent_hostname"` + PackageName string `json:"package_name" db:"package_name"` + PackageType string `json:"package_type" db:"package_type"` +} diff --git a/aggregator-server/internal/models/update.go b/aggregator-server/internal/models/update.go index e78b659..86484ad 100644 --- a/aggregator-server/internal/models/update.go +++ b/aggregator-server/internal/models/update.go @@ -77,6 +77,30 @@ type UpdateLogRequest struct { DurationSeconds int `json:"duration_seconds"` } +// DependencyReportRequest is used by agents to report dependencies after dry run +type DependencyReportRequest struct { + PackageName string `json:"package_name" binding:"required"` + PackageType string `json:"package_type" binding:"required"` + Dependencies []string `json:"dependencies" binding:"required"` + UpdateID string `json:"update_id" binding:"required"` + DryRunResult *InstallResult `json:"dry_run_result,omitempty"` +} + +// InstallResult represents the result of a package installation attempt (from agent) +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", "dry_run", 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 +} + // UpdateFilters for querying updates type UpdateFilters struct { AgentID uuid.UUID @@ -163,3 +187,27 @@ type UpdateStats struct { ModerateUpdates int `json:"moderate_updates" db:"moderate_updates"` LowUpdates int `json:"low_updates" db:"low_updates"` } + +// LogFilters for querying logs across all agents +type LogFilters struct { + AgentID uuid.UUID + Action string + Result string + Since *time.Time + Page int + PageSize int +} + +// ActiveOperation represents a currently running operation +type ActiveOperation struct { + ID uuid.UUID `json:"id" db:"id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + PackageType string `json:"package_type" db:"package_type"` + PackageName string `json:"package_name" db:"package_name"` + CurrentVersion string `json:"current_version" db:"current_version"` + AvailableVersion string `json:"available_version" db:"available_version"` + Severity string `json:"severity" db:"severity"` + Status string `json:"status" db:"status"` + LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"` + Metadata JSONB `json:"metadata" db:"metadata"` +} diff --git a/aggregator-server/internal/services/timeout.go b/aggregator-server/internal/services/timeout.go new file mode 100644 index 0000000..35ed545 --- /dev/null +++ b/aggregator-server/internal/services/timeout.go @@ -0,0 +1,219 @@ +package services + +import ( + "fmt" + "log" + "time" + + "github.com/aggregator-project/aggregator-server/internal/database/queries" + "github.com/aggregator-project/aggregator-server/internal/models" + "github.com/google/uuid" +) + +// TimeoutService handles timeout management for long-running operations +type TimeoutService struct { + commandQueries *queries.CommandQueries + updateQueries *queries.UpdateQueries + ticker *time.Ticker + stopChan chan bool + timeoutDuration time.Duration +} + +// NewTimeoutService creates a new timeout service +func NewTimeoutService(cq *queries.CommandQueries, uq *queries.UpdateQueries) *TimeoutService { + return &TimeoutService{ + commandQueries: cq, + updateQueries: uq, + timeoutDuration: 2 * time.Hour, // 2 hours timeout - allows for system upgrades and large operations + stopChan: make(chan bool), + } +} + +// Start begins the timeout monitoring service +func (ts *TimeoutService) Start() { + log.Printf("Starting timeout service with %v timeout duration", ts.timeoutDuration) + + // Create a ticker that runs every 5 minutes + ts.ticker = time.NewTicker(5 * time.Minute) + + go func() { + for { + select { + case <-ts.ticker.C: + ts.checkForTimeouts() + case <-ts.stopChan: + ts.ticker.Stop() + log.Println("Timeout service stopped") + return + } + } + }() +} + +// Stop stops the timeout monitoring service +func (ts *TimeoutService) Stop() { + close(ts.stopChan) +} + +// checkForTimeouts checks for commands that have been running too long +func (ts *TimeoutService) checkForTimeouts() { + log.Println("Checking for timed out operations...") + + // Get all commands that are in 'sent' status + commands, err := ts.commandQueries.GetCommandsByStatus(models.CommandStatusSent) + if err != nil { + log.Printf("Error getting sent commands: %v", err) + return + } + + timeoutThreshold := time.Now().Add(-ts.timeoutDuration) + timedOutCommands := make([]models.AgentCommand, 0) + + for _, command := range commands { + // Check if command has been sent and is older than timeout threshold + if command.SentAt != nil && command.SentAt.Before(timeoutThreshold) { + timedOutCommands = append(timedOutCommands, command) + } + } + + if len(timedOutCommands) > 0 { + log.Printf("Found %d timed out commands", len(timedOutCommands)) + + for _, command := range timedOutCommands { + if err := ts.timeoutCommand(&command); err != nil { + log.Printf("Error timing out command %s: %v", command.ID, err) + } + } + } else { + log.Println("No timed out operations found") + } +} + +// timeoutCommand marks a specific command as timed out and updates related entities +func (ts *TimeoutService) timeoutCommand(command *models.AgentCommand) error { + log.Printf("Timing out command %s (type: %s, agent: %s)", + command.ID, command.CommandType, command.AgentID) + + // Update command status to timed_out + if err := ts.commandQueries.UpdateCommandStatus(command.ID, models.CommandStatusTimedOut); err != nil { + return fmt.Errorf("failed to update command status: %w", err) + } + + // Update result with timeout information + result := models.JSONB{ + "error": "operation timed out", + "timeout_at": time.Now(), + "duration": ts.timeoutDuration.String(), + "command_id": command.ID.String(), + } + + if err := ts.commandQueries.UpdateCommandResult(command.ID, result); err != nil { + return fmt.Errorf("failed to update command result: %w", err) + } + + // Update related update package status if applicable + if err := ts.updateRelatedPackageStatus(command); err != nil { + log.Printf("Warning: failed to update related package status: %v", err) + // Don't return error here as the main timeout operation succeeded + } + + // Create a log entry for the timeout + logEntry := &models.UpdateLog{ + ID: uuid.New(), + AgentID: command.AgentID, + UpdatePackageID: ts.extractUpdatePackageID(command), + Action: command.CommandType, + Result: "timed_out", + Stdout: "", + Stderr: fmt.Sprintf("Command %s timed out after %v", command.CommandType, ts.timeoutDuration), + ExitCode: 124, // Standard timeout exit code + DurationSeconds: int(ts.timeoutDuration.Seconds()), + ExecutedAt: time.Now(), + } + + if err := ts.updateQueries.CreateUpdateLog(logEntry); err != nil { + log.Printf("Warning: failed to create timeout log entry: %v", err) + // Don't return error here as the main timeout operation succeeded + } + + log.Printf("Successfully timed out command %s", command.ID) + return nil +} + +// updateRelatedPackageStatus updates the status of related update packages when a command times out +func (ts *TimeoutService) updateRelatedPackageStatus(command *models.AgentCommand) error { + // Extract update_id from command params if it exists + _, ok := command.Params["update_id"].(string) + if !ok { + // This command doesn't have an associated update_id, so no package status to update + return nil + } + + // Update the package status to 'failed' with timeout reason + metadata := models.JSONB{ + "timeout": true, + "timeout_at": time.Now(), + "timeout_duration": ts.timeoutDuration.String(), + "command_id": command.ID.String(), + "failure_reason": "operation timed out", + } + + return ts.updateQueries.UpdatePackageStatus(command.AgentID, + command.Params["package_type"].(string), + command.Params["package_name"].(string), + "failed", + metadata) +} + +// extractUpdatePackageID extracts the update package ID from command params +func (ts *TimeoutService) extractUpdatePackageID(command *models.AgentCommand) *uuid.UUID { + updateIDStr, ok := command.Params["update_id"].(string) + if !ok { + return nil + } + + updateID, err := uuid.Parse(updateIDStr) + if err != nil { + return nil + } + + return &updateID +} + +// GetTimeoutStatus returns statistics about timed out operations +func (ts *TimeoutService) GetTimeoutStatus() (map[string]interface{}, error) { + // Get all timed out commands + timedOutCommands, err := ts.commandQueries.GetCommandsByStatus(models.CommandStatusTimedOut) + if err != nil { + return nil, fmt.Errorf("failed to get timed out commands: %w", err) + } + + // Get all active commands + activeCommands, err := ts.commandQueries.GetCommandsByStatus(models.CommandStatusSent) + if err != nil { + return nil, fmt.Errorf("failed to get active commands: %w", err) + } + + // Count commands approaching timeout (within 5 minutes of timeout) + timeoutThreshold := time.Now().Add(-ts.timeoutDuration + 5*time.Minute) + approachingTimeout := 0 + for _, command := range activeCommands { + if command.SentAt != nil && command.SentAt.Before(timeoutThreshold) { + approachingTimeout++ + } + } + + return map[string]interface{}{ + "total_timed_out": len(timedOutCommands), + "total_active": len(activeCommands), + "approaching_timeout": approachingTimeout, + "timeout_duration": ts.timeoutDuration.String(), + "last_check": time.Now(), + }, nil +} + +// SetTimeoutDuration allows changing the timeout duration +func (ts *TimeoutService) SetTimeoutDuration(duration time.Duration) { + ts.timeoutDuration = duration + log.Printf("Timeout duration updated to %v", duration) +} \ No newline at end of file diff --git a/aggregator-web/index.html b/aggregator-web/index.html new file mode 100644 index 0000000..34e604f --- /dev/null +++ b/aggregator-web/index.html @@ -0,0 +1,13 @@ + + + + + + + RedFlag Dashboard + + +
+ + + \ No newline at end of file diff --git a/aggregator-web/src/App.tsx b/aggregator-web/src/App.tsx index 9e0899a..1c8576a 100644 --- a/aggregator-web/src/App.tsx +++ b/aggregator-web/src/App.tsx @@ -7,7 +7,8 @@ import Dashboard from '@/pages/Dashboard'; import Agents from '@/pages/Agents'; import Updates from '@/pages/Updates'; import Docker from '@/pages/Docker'; -import Logs from '@/pages/Logs'; +import LiveOperations from '@/pages/LiveOperations'; +import History from '@/pages/History'; import Settings from '@/pages/Settings'; import Login from '@/pages/Login'; @@ -94,7 +95,8 @@ const App: React.FC = () => { } /> } /> } /> - } /> + } /> + } /> } /> } /> diff --git a/aggregator-web/src/components/AgentUpdates.tsx b/aggregator-web/src/components/AgentUpdates.tsx index b843aca..1fefa9e 100644 --- a/aggregator-web/src/components/AgentUpdates.tsx +++ b/aggregator-web/src/components/AgentUpdates.tsx @@ -1,6 +1,6 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Search, Filter, Package, Clock, AlertTriangle } from 'lucide-react'; +import { Search, Package, Clock } from 'lucide-react'; import { formatRelativeTime } from '@/lib/utils'; import { updateApi } from '@/lib/api'; import type { UpdatePackage } from '@/types'; @@ -24,8 +24,7 @@ export function AgentSystemUpdates({ agentId }: AgentUpdatesProps) { const params = { page: currentPage, page_size: pageSize, - agent: agentId, - type: 'system', // Only show system updates in AgentUpdates + agent_id: agentId, // Fix: use correct parameter name expected by backend ...(searchTerm && { search: searchTerm }), }; @@ -157,8 +156,8 @@ export function AgentSystemUpdates({ agentId }: AgentUpdatesProps) {
Type: {update.package_type} - {update.repository_source && ( - Source: {update.repository_source} + {update.metadata?.repository_source && ( + Source: {update.metadata.repository_source} )}
diff --git a/aggregator-web/src/components/HistoryTimeline.tsx b/aggregator-web/src/components/HistoryTimeline.tsx new file mode 100644 index 0000000..66cf014 --- /dev/null +++ b/aggregator-web/src/components/HistoryTimeline.tsx @@ -0,0 +1,470 @@ +import React, { useState, useEffect } from 'react'; +import { + Activity, + CheckCircle, + XCircle, + AlertTriangle, + Clock, + Package, + Computer, + Calendar, + ChevronDown, + ChevronRight, + Terminal, + RefreshCw, + Filter, + Search, +} from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { logApi } from '@/lib/api'; +import { cn } from '@/lib/utils'; +import { formatRelativeTime } from '@/lib/utils'; +import toast from 'react-hot-toast'; + +interface HistoryEntry { + id: string; + agent_id: string; + update_package_id?: string; + action: string; + result: string; + stdout?: string; + stderr?: string; + exit_code: number; + duration_seconds: number; + executed_at: string; +} + +interface HistoryTimelineProps { + agentId?: string; // Optional - if provided, filter to specific agent + className?: string; +} + +interface TimelineGroup { + date: string; + entries: HistoryEntry[]; +} + +const HistoryTimeline: React.FC = ({ agentId, className }) => { + const [searchQuery, setSearchQuery] = useState(''); + const [actionFilter, setActionFilter] = useState('all'); + const [resultFilter, setResultFilter] = useState('all'); + const [showFilters, setShowFilters] = useState(false); + const [expandedEntries, setExpandedEntries] = useState>(new Set()); + const [expandedDates, setExpandedDates] = useState>(new Set()); + + // Query parameters for API + const [queryParams, setQueryParams] = useState({ + page: 1, + page_size: 50, + agent_id: agentId || '', + action: actionFilter !== 'all' ? actionFilter : '', + result: resultFilter !== 'all' ? resultFilter : '', + search: searchQuery, + }); + + // Fetch history data + const { data: historyData, isLoading, refetch, isFetching } = useQuery({ + queryKey: ['history', queryParams], + queryFn: async () => { + try { + const params: any = { + page: queryParams.page, + page_size: queryParams.page_size, + }; + + if (queryParams.agent_id) { + params.agent_id = queryParams.agent_id; + } + + if (queryParams.action) { + params.action = queryParams.action; + } + + if (queryParams.result) { + params.result = queryParams.result; + } + + const response = await logApi.getAllLogs(params); + return response; + } catch (error) { + console.error('Failed to fetch history:', error); + toast.error('Failed to fetch history'); + return { logs: [], total: 0, page: 1, page_size: 50 }; + } + }, + refetchInterval: 30000, // Refresh every 30 seconds + }); + + // Group entries by date + const groupEntriesByDate = (entries: HistoryEntry[]): TimelineGroup[] => { + const groups: { [key: string]: HistoryEntry[] } = {}; + + entries.forEach(entry => { + const date = new Date(entry.executed_at); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + let dateKey: string; + if (date.toDateString() === today.toDateString()) { + dateKey = 'Today'; + } else if (date.toDateString() === yesterday.toDateString()) { + dateKey = 'Yesterday'; + } else { + dateKey = date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } + + if (!groups[dateKey]) { + groups[dateKey] = []; + } + groups[dateKey].push(entry); + }); + + return Object.entries(groups).map(([date, entries]) => ({ + date, + entries: entries.sort((a, b) => + new Date(b.executed_at).getTime() - new Date(a.executed_at).getTime() + ), + })); + }; + + const timelineGroups = groupEntriesByDate(historyData?.logs || []); + + // Toggle entry expansion + const toggleEntry = (entryId: string) => { + const newExpanded = new Set(expandedEntries); + if (newExpanded.has(entryId)) { + newExpanded.delete(entryId); + } else { + newExpanded.add(entryId); + } + setExpandedEntries(newExpanded); + }; + + // Toggle date expansion + const toggleDate = (date: string) => { + const newExpanded = new Set(expandedDates); + if (newExpanded.has(date)) { + newExpanded.delete(date); + } else { + newExpanded.add(date); + } + setExpandedDates(newExpanded); + }; + + // Get action icon + const getActionIcon = (action: string) => { + switch (action) { + case 'install': + case 'upgrade': + return ; + case 'scan': + return ; + case 'dry_run': + return ; + default: + return ; + } + }; + + // Get result icon + const getResultIcon = (result: string) => { + switch (result) { + case 'success': + return ; + case 'failed': + return ; + case 'running': + return ; + default: + return ; + } + }; + + // Get status color + const getStatusColor = (result: string) => { + switch (result) { + case 'success': + return 'text-green-700 bg-green-100 border-green-200'; + case 'failed': + return 'text-red-700 bg-red-100 border-red-200'; + case 'running': + return 'text-blue-700 bg-blue-100 border-blue-200'; + default: + return 'text-gray-700 bg-gray-100 border-gray-200'; + } + }; + + // Format duration + const formatDuration = (seconds: number) => { + if (seconds < 60) { + return `${seconds}s`; + } else if (seconds < 3600) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; + } else { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } + }; + + return ( +
+ {/* Header with search and filters */} +
+
+
+ +

+ {agentId ? 'Agent History' : 'Universal Audit Log'} +

+
+ +
+ +
+ {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search by action or result..." + className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ + {/* Filter toggle */} + +
+ + {/* Filters */} + {showFilters && ( +
+
+ + +
+
+ + +
+
+ )} +
+ + {/* Loading state */} + {isLoading && ( +
+ + Loading history... +
+ )} + + {/* Timeline */} + {!isLoading && timelineGroups.length === 0 ? ( +
+ +

No history found

+

+ {searchQuery || actionFilter !== 'all' || resultFilter !== 'all' + ? 'Try adjusting your search or filters.' + : 'No activities have been recorded yet.'} +

+
+ ) : ( +
+ {timelineGroups.map((group) => ( +
+ {/* Date header */} +
toggleDate(group.date)} + > +
+
+ {expandedDates.has(group.date) ? ( + + ) : ( + + )} +

{group.date}

+ + ({group.entries.length} events) + +
+
+
+ + {/* Timeline entries */} + {expandedDates.has(group.date) && ( +
+ {group.entries.map((entry) => ( +
+
+ {/* Timeline icon */} +
+ {getResultIcon(entry.result)} +
+ + {/* Entry content */} +
+
toggleEntry(entry.id)} + > +
+ {getActionIcon(entry.action)} + + {entry.action} + + + {entry.result} + +
+
+ {formatRelativeTime(entry.executed_at)} + {formatDuration(entry.duration_seconds)} +
+
+ + {/* Agent info */} +
+ + Agent: {entry.agent_id} +
+ + {/* Expanded details */} + {expandedEntries.has(entry.id) && ( +
+ {/* Metadata */} +
+
+ Exit Code: + {entry.exit_code} +
+
+ Duration: + {formatDuration(entry.duration_seconds)} +
+
+ + {/* Output */} + {(entry.stdout || entry.stderr) && ( +
+
+ + Output +
+ {entry.stdout && ( +
+
{entry.stdout}
+
+ )} + {entry.stderr && ( +
+
{entry.stderr}
+
+ )} +
+ )} +
+ )} +
+
+
+ ))} +
+ )} +
+ ))} +
+ )} + + {/* Pagination */} + {historyData && historyData.total > historyData.page_size && ( +
+
+ Showing {((historyData.page - 1) * historyData.page_size) + 1} to{' '} + {Math.min(historyData.page * historyData.page_size, historyData.total)} of{' '} + {historyData.total} results +
+
+ + + Page {historyData.page} of {Math.ceil(historyData.total / historyData.page_size)} + + +
+
+ )} +
+ ); +}; + +export default HistoryTimeline; \ No newline at end of file diff --git a/aggregator-web/src/components/Layout.tsx b/aggregator-web/src/components/Layout.tsx index e4ece47..913956f 100644 --- a/aggregator-web/src/components/Layout.tsx +++ b/aggregator-web/src/components/Layout.tsx @@ -4,7 +4,8 @@ import { LayoutDashboard, Computer, Package, - FileText, + Activity, + History, Settings, Menu, X, @@ -58,10 +59,16 @@ const Layout: React.FC = ({ children }) => { current: location.pathname.startsWith('/docker'), }, { - name: 'Logs', - href: '/logs', - icon: FileText, - current: location.pathname === '/logs', + name: 'Live Operations', + href: '/live', + icon: Activity, + current: location.pathname === '/live', + }, + { + name: 'History', + href: '/history', + icon: History, + current: location.pathname === '/history', }, { name: 'Settings', diff --git a/aggregator-web/src/hooks/useCommands.ts b/aggregator-web/src/hooks/useCommands.ts new file mode 100644 index 0000000..88dd8bc --- /dev/null +++ b/aggregator-web/src/hooks/useCommands.ts @@ -0,0 +1,57 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { updateApi, logApi } from '@/lib/api'; +import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query'; + +interface ActiveCommand { + id: string; + agent_id: string; + agent_hostname: string; + command_type: string; + status: string; + created_at: string; + sent_at?: string; + completed_at?: string; + package_name: string; + package_type: string; +} + +export const useActiveCommands = (): UseQueryResult<{ commands: ActiveCommand[]; count: number }, Error> => { + return useQuery({ + queryKey: ['activeCommands'], + queryFn: () => updateApi.getActiveCommands(), + refetchInterval: 5000, // Auto-refresh every 5 seconds + }); +}; + +export const useRecentCommands = (limit?: number): UseQueryResult<{ commands: ActiveCommand[]; count: number; limit: number }, Error> => { + return useQuery({ + queryKey: ['recentCommands', limit], + queryFn: () => updateApi.getRecentCommands(limit), + }); +}; + +export const useRetryCommand = (): UseMutationResult => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateApi.retryCommand, + onSuccess: () => { + // Invalidate active and recent commands queries + queryClient.invalidateQueries({ queryKey: ['activeCommands'] }); + queryClient.invalidateQueries({ queryKey: ['recentCommands'] }); + }, + }); +}; + +export const useCancelCommand = (): UseMutationResult => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateApi.cancelCommand, + onSuccess: () => { + // Invalidate active and recent commands queries + queryClient.invalidateQueries({ queryKey: ['activeCommands'] }); + queryClient.invalidateQueries({ queryKey: ['recentCommands'] }); + }, + }); +}; \ No newline at end of file diff --git a/aggregator-web/src/hooks/useUpdates.ts b/aggregator-web/src/hooks/useUpdates.ts index f5cebdd..2cc17bb 100644 --- a/aggregator-web/src/hooks/useUpdates.ts +++ b/aggregator-web/src/hooks/useUpdates.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { updateApi } from '@/lib/api'; import type { UpdatePackage, ListQueryParams, UpdateApprovalRequest, UpdateListResponse } from '@/types'; import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query'; @@ -19,26 +19,88 @@ export const useUpdate = (id: string, enabled: boolean = true): UseQueryResult => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: ({ id, scheduledAt }: { id: string; scheduledAt?: string }) => updateApi.approveUpdate(id, scheduledAt), + onSuccess: () => { + // Invalidate all updates queries to trigger refetch + queryClient.invalidateQueries({ queryKey: ['updates'] }); + // Also invalidate specific update queries + queryClient.invalidateQueries({ queryKey: ['update'] }); + }, }); }; export const useApproveMultipleUpdates = (): UseMutationResult => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: (request: UpdateApprovalRequest) => updateApi.approveUpdates(request), + onSuccess: () => { + // Invalidate all updates queries to trigger refetch + queryClient.invalidateQueries({ queryKey: ['updates'] }); + // Also invalidate specific update queries + queryClient.invalidateQueries({ queryKey: ['update'] }); + }, }); }; export const useRejectUpdate = (): UseMutationResult => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: updateApi.rejectUpdate, + onSuccess: () => { + // Invalidate all updates queries to trigger refetch + queryClient.invalidateQueries({ queryKey: ['updates'] }); + // Also invalidate specific update queries + queryClient.invalidateQueries({ queryKey: ['update'] }); + }, }); }; export const useInstallUpdate = (): UseMutationResult => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: updateApi.installUpdate, + onSuccess: () => { + // Invalidate all updates queries to trigger refetch + queryClient.invalidateQueries({ queryKey: ['updates'] }); + // Also invalidate specific update queries + queryClient.invalidateQueries({ queryKey: ['update'] }); + }, + }); +}; + +export const useRetryCommand = (): UseMutationResult => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateApi.retryCommand, + onSuccess: () => { + // Invalidate all updates queries to trigger refetch + queryClient.invalidateQueries({ queryKey: ['updates'] }); + // Also invalidate logs and active operations queries + queryClient.invalidateQueries({ queryKey: ['logs'] }); + queryClient.invalidateQueries({ queryKey: ['active'] }); + }, + }); +}; + +export const useCancelCommand = (): UseMutationResult => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateApi.cancelCommand, + onSuccess: () => { + // Invalidate all updates queries to trigger refetch + queryClient.invalidateQueries({ queryKey: ['updates'] }); + // Also invalidate logs and active operations queries + queryClient.invalidateQueries({ queryKey: ['logs'] }); + queryClient.invalidateQueries({ queryKey: ['active'] }); + }, }); }; \ No newline at end of file diff --git a/aggregator-web/src/lib/api.ts b/aggregator-web/src/lib/api.ts index f51a8e3..7705489 100644 --- a/aggregator-web/src/lib/api.ts +++ b/aggregator-web/src/lib/api.ts @@ -113,6 +113,40 @@ export const updateApi = { installUpdate: async (id: string): Promise => { await api.post(`/updates/${id}/install`); }, + + // Get update logs + getUpdateLogs: async (id: string, limit?: number): Promise<{ logs: any[]; count: number }> => { + const response = await api.get(`/updates/${id}/logs`, { + params: limit ? { limit } : undefined + }); + return response.data; + }, + + // Retry a failed, timed_out, or cancelled command + retryCommand: async (commandId: string): Promise<{ message: string; command_id: string; new_id: string }> => { + const response = await api.post(`/commands/${commandId}/retry`); + return response.data; + }, + + // Cancel a pending or sent command + cancelCommand: async (commandId: string): Promise<{ message: string }> => { + const response = await api.post(`/commands/${commandId}/cancel`); + return response.data; + }, + + // Get active commands for live command control + getActiveCommands: async (): Promise<{ commands: any[]; count: number }> => { + const response = await api.get('/commands/active'); + return response.data; + }, + + // Get recent commands for retry functionality + getRecentCommands: async (limit?: number): Promise<{ commands: any[]; count: number; limit: number }> => { + const response = await api.get('/commands/recent', { + params: limit ? { limit } : undefined + }); + return response.data; + }, }; export const statsApi = { @@ -123,6 +157,41 @@ export const statsApi = { }, }; +export const logApi = { + // Get all logs with filtering for universal log view + getAllLogs: async (params?: { + page?: number; + page_size?: number; + agent_id?: string; + action?: string; + result?: string; + since?: string; + }): Promise<{ logs: any[]; total: number; page: number; page_size: number }> => { + const response = await api.get('/logs', { params }); + return response.data; + }, + + // Get active operations for live status view + getActiveOperations: async (): Promise<{ operations: any[]; count: number }> => { + const response = await api.get('/logs/active'); + return response.data; + }, + + // Get active commands for live command control + getActiveCommands: async (): Promise<{ commands: any[]; count: number }> => { + const response = await api.get('/commands/active'); + return response.data; + }, + + // Get recent commands for retry functionality + getRecentCommands: async (limit?: number): Promise<{ commands: any[]; count: number; limit: number }> => { + const response = await api.get('/commands/recent', { + params: limit ? { limit } : undefined + }); + return response.data; + }, +}; + export const authApi = { // Simple login (using API key or token) login: async (credentials: { token: string }): Promise<{ token: string }> => { diff --git a/aggregator-web/src/lib/utils.ts b/aggregator-web/src/lib/utils.ts index 7e96493..17d9e12 100644 --- a/aggregator-web/src/lib/utils.ts +++ b/aggregator-web/src/lib/utils.ts @@ -110,6 +110,10 @@ export const getStatusColor = (status: string): string => { return 'text-danger-600 bg-danger-100'; case 'pending': return 'text-warning-600 bg-warning-100'; + case 'checking_dependencies': + return 'text-blue-500 bg-blue-100'; + case 'pending_dependencies': + return 'text-orange-600 bg-orange-100'; case 'approved': case 'scheduled': return 'text-blue-600 bg-blue-100'; diff --git a/aggregator-web/src/pages/Agents.tsx b/aggregator-web/src/pages/Agents.tsx index 23d2652..f568c3d 100644 --- a/aggregator-web/src/pages/Agents.tsx +++ b/aggregator-web/src/pages/Agents.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Computer, @@ -15,21 +15,41 @@ import { GitBranch, Clock, Trash2, + History as HistoryIcon, + Download, + CheckCircle, + AlertCircle, + XCircle, } from 'lucide-react'; import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents'; +import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands'; import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils'; import { cn } from '@/lib/utils'; import toast from 'react-hot-toast'; import { AgentSystemUpdates } from '@/components/AgentUpdates'; +import HistoryTimeline from '@/components/HistoryTimeline'; const Agents: React.FC = () => { const { id } = useParams<{ id?: string }>(); const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(''); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [osFilter, setOsFilter] = useState('all'); const [showFilters, setShowFilters] = useState(false); const [selectedAgents, setSelectedAgents] = useState([]); + const [activeTab, setActiveTab] = useState<'overview' | 'history'>('overview'); + + // Debounce search query to avoid API calls on every keystroke + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); // 300ms delay + + return () => { + clearTimeout(timer); + }; + }, [searchQuery]); // Helper function to get system metadata from agent const getSystemMetadata = (agent: any) => { @@ -106,7 +126,7 @@ const Agents: React.FC = () => { // Fetch agents list const { data: agentsData, isPending, error } = useAgents({ - search: searchQuery || undefined, + search: debouncedSearchQuery || undefined, status: statusFilter !== 'all' ? statusFilter : undefined, }); @@ -117,6 +137,10 @@ const Agents: React.FC = () => { const scanMultipleMutation = useScanMultipleAgents(); const unregisterAgentMutation = useUnregisterAgent(); + // Active commands for live status + const { data: activeCommandsData, refetch: refetchActiveCommands } = useActiveCommands(); + const cancelCommandMutation = useCancelCommand(); + const agents = agentsData?.agents || []; const selectedAgent = selectedAgentData || agents.find(a => a.id === id); @@ -189,6 +213,58 @@ const Agents: React.FC = () => { } }; + // Handle command cancellation + const handleCancelCommand = async (commandId: string) => { + try { + await cancelCommandMutation.mutateAsync(commandId); + toast.success('Command cancelled successfully'); + refetchActiveCommands(); + } catch (error: any) { + toast.error(`Failed to cancel command: ${error.message || 'Unknown error'}`); + } + }; + + // Get agent-specific active commands + const getAgentActiveCommands = () => { + if (!selectedAgent || !activeCommandsData?.commands) return []; + return activeCommandsData.commands.filter(cmd => cmd.agent_id === selectedAgent.id); + }; + + // Helper function to get command display info + const getCommandDisplayInfo = (command: any) => { + const actionMap: { [key: string]: { icon: React.ReactNode; label: string } } = { + 'scan': { icon: , label: 'System scan' }, + 'install_updates': { icon: , label: `Installing ${command.package_name || 'packages'}` }, + 'dry_run_update': { icon: , label: `Checking dependencies for ${command.package_name || 'packages'}` }, + 'confirm_dependencies': { icon: , label: `Installing confirmed dependencies` }, + }; + + return actionMap[command.command_type] || { + icon: , + label: command.command_type.replace('_', ' ') + }; + }; + + // Get command status + const getCommandStatus = (command: any) => { + switch (command.status) { + case 'pending': + return { text: 'Pending', color: 'text-amber-600 bg-amber-50 border-amber-200' }; + case 'sent': + return { text: 'Sent to agent', color: 'text-blue-600 bg-blue-50 border-blue-200' }; + case 'running': + return { text: 'Running', color: 'text-green-600 bg-green-50 border-green-200' }; + case 'completed': + return { text: 'Completed', color: 'text-gray-600 bg-gray-50 border-gray-200' }; + case 'failed': + return { text: 'Failed', color: 'text-red-600 bg-red-50 border-red-200' }; + case 'timed_out': + return { text: 'Timed out', color: 'text-red-600 bg-red-50 border-red-200' }; + default: + return { text: command.status, color: 'text-gray-600 bg-gray-50 border-gray-200' }; + } + }; + // Get unique OS types for filter const osTypes = [...new Set(agents.map(agent => agent.os_type))]; @@ -203,19 +279,53 @@ const Agents: React.FC = () => { > ← Back to Agents -
-
-

- {selectedAgent.hostname} -

-

- System details and update management for this agent -

+ + {/* New Compact Header Design */} +
+
+ {/* Main hostname with integrated agent info */} +
+

+ {selectedAgent.hostname} +

+
+ [Agent ID: + + {selectedAgent.id} + + | + Version: +
+ + {selectedAgent.current_version || 'Unknown'} + + {selectedAgent.update_available === true && ( + + + Update Available + + )} + {selectedAgent.update_available === false && selectedAgent.current_version && ( + + + Up to Date + + )} +
+ ] +
+
+ + {/* Sub-line with registration info only */} +
+ Registered {formatRelativeTime(selectedAgent.created_at)} +
+
+ {/* Tabs */} +
+
+ +
+
+
- {/* Agent info */} -
- {/* Agent Status Card */} + {/* Main content area */} +
+ {activeTab === 'overview' ? ( +
+ {/* Agent Status Card - Compact Timeline Style */}
-
+

Agent Status

- - {isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'} - +
+
+ + {isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'} + +
-
- {/* Agent Information */} -
-
-

Agent Information

-
-
-

Agent ID

-

- {selectedAgent.id} -

+ {/* Compact Timeline Display */} +
+ {(() => { + const agentCommands = getAgentActiveCommands(); + const activeCommands = agentCommands.filter(cmd => + cmd.status === 'running' || cmd.status === 'sent' || cmd.status === 'pending' + ); + const completedCommands = agentCommands.filter(cmd => + cmd.status === 'completed' || cmd.status === 'failed' || cmd.status === 'timed_out' + ).slice(0, 1); // Only show last completed + + const displayCommands = [ + ...activeCommands.slice(0, 2), // Max 2 active + ...completedCommands.slice(0, 1) // Max 1 completed + ].slice(0, 3); // Total max 3 entries + + if (displayCommands.length === 0) { + return ( +
+ No active operations
-
-
-

Version

-

- {selectedAgent.agent_version || selectedAgent.version || 'Unknown'} -

+ ); + } + + return displayCommands.map((command, index) => { + const displayInfo = getCommandDisplayInfo(command); + const statusInfo = getCommandStatus(command); + const isActive = command.status === 'running' || command.status === 'sent' || command.status === 'pending'; + + return ( +
+
+ {displayInfo.icon}
-
-

Registered

-

- {formatRelativeTime(selectedAgent.created_at)} -

+
+
+ + {isActive ? ( + + + {command.status === 'running' && } + {command.status === 'pending' && } + {isActive ? command.status.replace('_', ' ') : statusInfo.text} + + {displayInfo.label} + + ) : ( + + + {command.status === 'completed' && } + {command.status === 'failed' && } + {statusInfo.text} + + {displayInfo.label} + + )} + +
+
+ + {formatRelativeTime(command.created_at)} + + {isActive && (command.status === 'pending' || command.status === 'sent') && ( + + )} +
- {(() => { - const meta = getSystemMetadata(selectedAgent); - if (meta.installationTime !== 'Unknown') { - return ( -
-

Installation Time

-

- {formatRelativeTime(meta.installationTime)} -

-
- ); - } - return null; - })()} -
-
-
+ ); + }); + })()} +
- {/* Connection Status */} -
-
-
-
- - Last Check-in -
-

- {formatRelativeTime(selectedAgent.last_seen)} -

-
+ {/* Basic Status Info */} +
+ Last seen: {formatRelativeTime(selectedAgent.last_seen)} + Last scan: {selectedAgent.last_scan ? formatRelativeTime(selectedAgent.last_scan) : 'Never'} +
-
-
- - Last Scan -
-

- {selectedAgent.last_scan - ? formatRelativeTime(selectedAgent.last_scan) - : 'Never'} -

-
-
-
+ {/* Action Button */} +
+
@@ -444,6 +628,12 @@ const Agents: React.FC = () => { {/* System Updates */} +
+ ) : ( +
+ +
+ )}
{/* Quick actions */} @@ -603,7 +793,7 @@ const Agents: React.FC = () => {

No agents found

- {searchQuery || statusFilter !== 'all' || osFilter !== 'all' + {debouncedSearchQuery || statusFilter !== 'all' || osFilter !== 'all' ? 'Try adjusting your search or filters.' : 'No agents have registered with the server yet.'}

@@ -624,6 +814,7 @@ const Agents: React.FC = () => { Agent Status + Version OS Last Check-in Last Scan @@ -673,6 +864,25 @@ const Agents: React.FC = () => { {isOnline(agent.last_seen) ? 'Online' : 'Offline'} + +
+ + {agent.current_version || 'Unknown'} + + {agent.update_available === true && ( + + + Update + + )} + {agent.update_available === false && agent.current_version && ( + + + Current + + )} +
+
{(() => { diff --git a/aggregator-web/src/pages/History.tsx b/aggregator-web/src/pages/History.tsx new file mode 100644 index 0000000..3177fc4 --- /dev/null +++ b/aggregator-web/src/pages/History.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { + History, + Calendar, + Clock, + CheckCircle, + AlertTriangle, +} from 'lucide-react'; +import HistoryTimeline from '@/components/HistoryTimeline'; + +const HistoryPage: React.FC = () => { + return ( +
+ {/* Header */} +
+
+ +

History & Audit Log

+
+

+ Complete chronological timeline of all system activities across all agents +

+
+ + {/* Quick Stats */} +
+
+
+
+

Total Activities

+

--

+
+ +
+
+ +
+
+
+

Successful

+

--

+
+ +
+
+ +
+
+
+

Failed

+

--

+
+ +
+
+ +
+
+
+

Today

+

--

+
+ +
+
+
+ + {/* Timeline */} + +
+ ); +}; + +export default HistoryPage; \ No newline at end of file diff --git a/aggregator-web/src/pages/LiveOperations.tsx b/aggregator-web/src/pages/LiveOperations.tsx new file mode 100644 index 0000000..235ebe9 --- /dev/null +++ b/aggregator-web/src/pages/LiveOperations.tsx @@ -0,0 +1,508 @@ +import React, { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + Activity, + Clock, + Package, + CheckCircle, + XCircle, + AlertTriangle, + Loader2, + RefreshCw, + Filter, + ChevronDown, + Terminal, + User, + Calendar, + Search, + Computer, + Eye, + RotateCcw, + X, +} from 'lucide-react'; +import { useAgents, useUpdates } from '@/hooks/useAgents'; +import { useActiveCommands, useRetryCommand, useCancelCommand } from '@/hooks/useCommands'; +import { getStatusColor, formatRelativeTime, isOnline } from '@/lib/utils'; +import { cn } from '@/lib/utils'; +import toast from 'react-hot-toast'; +import { logApi } from '@/lib/api'; + +interface LiveOperation { + id: string; + agentId: string; + agentName: string; + updateId: string; + packageName: string; + action: 'checking_dependencies' | 'installing' | 'pending_dependencies'; + status: 'running' | 'completed' | 'failed' | 'waiting'; + startTime: Date; + duration?: number; + progress?: string; + logOutput?: string; + error?: string; + commandId: string; + commandStatus: string; +} + +const LiveOperations: React.FC = () => { + const [expandedOperation, setExpandedOperation] = useState(null); + const [autoRefresh, setAutoRefresh] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [showFilters, setShowFilters] = useState(false); + + // Fetch active commands from API + const { data: activeCommandsData, refetch: refetchCommands } = useActiveCommands(); + + // Retry and cancel mutations + const retryMutation = useRetryCommand(); + const cancelMutation = useCancelCommand(); + + // Fetch agents for mapping + const { data: agentsData } = useAgents(); + const agents = agentsData?.agents || []; + + // Transform API data to LiveOperation format + const activeOperations: LiveOperation[] = React.useMemo(() => { + if (!activeCommandsData?.commands) { + return []; + } + + return activeCommandsData.commands.map((cmd: any) => { + const agent = agents.find(a => a.id === cmd.agent_id); + let action: LiveOperation['action']; + let status: LiveOperation['status']; + + // Map command status to operation status + if (cmd.status === 'failed' || cmd.status === 'timed_out') { + status = 'failed'; + } else if (cmd.status === 'pending') { + status = 'waiting'; + } else if (cmd.status === 'completed') { + status = 'completed'; + } else { + status = 'running'; + } + + // Map command type to action + switch (cmd.command_type) { + case 'dry_run_update': + action = 'checking_dependencies'; + break; + case 'install_updates': + case 'confirm_dependencies': + action = 'installing'; + break; + default: + action = 'checking_dependencies'; + } + + return { + id: cmd.id, + agentId: cmd.agent_id, + agentName: cmd.agent_hostname || 'Unknown Agent', + updateId: cmd.id, + packageName: cmd.package_name !== 'N/A' ? cmd.package_name : cmd.command_type, + action, + status, + startTime: new Date(cmd.created_at), + progress: getStatusText(cmd.command_type, cmd.status), + commandId: cmd.id, + commandStatus: cmd.status, + }; + }); + }, [activeCommandsData, agents]); + + // Manual refresh function + const handleManualRefresh = () => { + refetchCommands(); + }; + + // Handle retry command + const handleRetryCommand = async (commandId: string) => { + try { + await retryMutation.mutateAsync(commandId); + toast.success('Command retry initiated successfully'); + } catch (error: any) { + toast.error(`Failed to retry command: ${error.message || 'Unknown error'}`); + } + }; + + // Handle cancel command + const handleCancelCommand = async (commandId: string) => { + try { + await cancelMutation.mutateAsync(commandId); + toast.success('Command cancelled successfully'); + } catch (error: any) { + toast.error(`Failed to cancel command: ${error.message || 'Unknown error'}`); + } + }; + + function getStatusText(commandType: string, status: string): string { + if (commandType === 'dry_run_update') { + return status === 'pending' ? 'Pending dependency check...' : 'Checking for required dependencies...'; + } + if (commandType === 'install_updates') { + return status === 'pending' ? 'Pending installation...' : 'Installing package and dependencies...'; + } + if (commandType === 'confirm_dependencies') { + return status === 'pending' ? 'Pending dependency confirmation...' : 'Installing confirmed dependencies...'; + } + return status === 'pending' ? 'Pending operation...' : 'Processing command...'; +} + + function getActionIcon(action: LiveOperation['action']) { + switch (action) { + case 'checking_dependencies': + return ; + case 'installing': + return ; + case 'pending_dependencies': + return ; + default: + return ; + } + } + + function getStatusIcon(status: LiveOperation['status']) { + switch (status) { + case 'running': + return ; + case 'completed': + return ; + case 'failed': + return ; + case 'waiting': + return ; + default: + return ; + } + } + + function getDuration(startTime: Date): string { + const now = new Date(); + const diff = now.getTime() - startTime.getTime(); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; + } + + const filteredOperations = activeOperations.filter(op => { + const matchesSearch = !searchQuery || + op.packageName.toLowerCase().includes(searchQuery.toLowerCase()) || + op.agentName.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesStatus = statusFilter === 'all' || op.status === statusFilter; + + return matchesSearch && matchesStatus; + }); + + return ( +
+ {/* Header */} +
+
+
+

+ + Live Operations +

+

+ Real-time monitoring of ongoing update operations +

+
+
+ + +
+
+ + {/* Stats */} +
+
+
+
+

Total Active

+

{activeOperations.length}

+
+ +
+
+ +
+
+
+

Running

+

+ {activeOperations.filter(op => op.status === 'running').length} +

+
+ +
+
+ +
+
+
+

Waiting

+

+ {activeOperations.filter(op => op.status === 'waiting').length} +

+
+ +
+
+ +
+
+
+

Failed

+

+ {activeOperations.filter(op => op.status === 'failed').length} +

+
+ +
+
+
+ + {/* Search and filters */} +
+
+ {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search by package name or agent..." + className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ + {/* Filter toggle */} + +
+ + {/* Filters */} + {showFilters && ( +
+
+ + +
+
+ )} +
+ + {/* Operations list */} + {filteredOperations.length === 0 ? ( +
+ +

No active operations

+

+ {searchQuery || statusFilter !== 'all' + ? 'Try adjusting your search or filters.' + : 'All operations are completed. Check the Updates page to start new operations.'} +

+
+ ) : ( +
+ {filteredOperations.map((operation) => ( +
+ {/* Operation header */} +
+
+
+
+ {getActionIcon(operation.action)} + + {operation.packageName} + + + {getStatusIcon(operation.status)} + {operation.status} + +
+
+ + {operation.agentName} + + {getDuration(operation.startTime)} +
+
+ +
+ +
+
+ +
+ {operation.progress} +
+
+ + {/* Expanded details */} + {expandedOperation === operation.id && ( +
+
+
+

Operation Details

+
+
+ Action: + {operation.action.replace('_', ' ')} +
+
+ Started: + {formatRelativeTime(operation.startTime)} +
+
+ Duration: + {getDuration(operation.startTime)} +
+
+ Agent: + {operation.agentName} +
+
+
+ +
+

Quick Actions

+
+ + + + {/* Command control buttons */} + {operation.commandStatus === 'pending' || operation.commandStatus === 'sent' ? ( + + ) : null} + + {/* Retry button for failed/timed_out commands */} + {operation.commandStatus === 'failed' || operation.commandStatus === 'timed_out' ? ( + + ) : null} +
+
+
+ + {/* Log output placeholder */} +
+

+ + Live Output +

+
+ {operation.status === 'running' ? ( +
+ + Waiting for log stream... +
+ ) : operation.logOutput ? ( +
{operation.logOutput}
+ ) : operation.error ? ( +
Error: {operation.error}
+ ) : ( +
No log output available
+ )} +
+
+
+ )} +
+ ))} +
+ )} +
+
+ ); +}; + +export default LiveOperations; \ No newline at end of file diff --git a/aggregator-web/src/pages/Logs.tsx b/aggregator-web/src/pages/Logs.tsx deleted file mode 100644 index f1f569a..0000000 --- a/aggregator-web/src/pages/Logs.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -const Logs: React.FC = () => { - return ( -
-
-

Logs

-

- View system logs and update history -

-
- -
-
📋
-

Coming Soon

-

- Logs and history tracking will be available in a future update. -

-
-
- ); -}; - -export default Logs; \ No newline at end of file diff --git a/aggregator-web/src/pages/Updates.tsx b/aggregator-web/src/pages/Updates.tsx index 2329d8e..3d83053 100644 --- a/aggregator-web/src/pages/Updates.tsx +++ b/aggregator-web/src/pages/Updates.tsx @@ -13,12 +13,17 @@ import { AlertTriangle, Clock, Calendar, + X, + Loader2, + RotateCcw, } from 'lucide-react'; -import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates } from '@/hooks/useUpdates'; +import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates, useRetryCommand, useCancelCommand } from '@/hooks/useUpdates'; +import { useRecentCommands } from '@/hooks/useCommands'; import type { UpdatePackage } from '@/types'; import { getSeverityColor, getStatusColor, getPackageTypeIcon, formatBytes, formatRelativeTime } from '@/lib/utils'; import { cn } from '@/lib/utils'; import toast from 'react-hot-toast'; +import { updateApi } from '@/lib/api'; const Updates: React.FC = () => { @@ -28,19 +33,39 @@ const Updates: React.FC = () => { // Get filters from URL params const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || ''); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchParams.get('search') || ''); const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || ''); const [severityFilter, setSeverityFilter] = useState(searchParams.get('severity') || ''); const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || ''); const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || ''); + + // Debounce search query to avoid API calls on every keystroke + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); // 300ms delay + + return () => { + clearTimeout(timer); + }; + }, [searchQuery]); const [showFilters, setShowFilters] = useState(false); const [selectedUpdates, setSelectedUpdates] = useState([]); const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get('page') || '1')); const [pageSize, setPageSize] = useState(100); + const [showLogModal, setShowLogModal] = useState(false); + const [logs, setLogs] = useState([]); + const [logsLoading, setLogsLoading] = useState(false); + const [showDependencyModal, setShowDependencyModal] = useState(false); + const [pendingDependencies, setPendingDependencies] = useState([]); + const [dependencyUpdateId, setDependencyUpdateId] = useState(null); + const [dependencyLoading, setDependencyLoading] = useState(false); + const [activeTab, setActiveTab] = useState<'updates' | 'commands'>('updates'); // Store filters in URL useEffect(() => { const params = new URLSearchParams(); - if (searchQuery) params.set('search', searchQuery); + if (debouncedSearchQuery) params.set('search', debouncedSearchQuery); if (statusFilter) params.set('status', statusFilter); if (severityFilter) params.set('severity', severityFilter); if (typeFilter) params.set('type', typeFilter); @@ -52,11 +77,11 @@ const Updates: React.FC = () => { if (newUrl !== window.location.href) { window.history.replaceState({}, '', newUrl); } - }, [searchQuery, statusFilter, severityFilter, typeFilter, agentFilter, currentPage, pageSize]); + }, [debouncedSearchQuery, statusFilter, severityFilter, typeFilter, agentFilter, currentPage, pageSize]); // Fetch updates list const { data: updatesData, isPending, error } = useUpdates({ - search: searchQuery || undefined, + search: debouncedSearchQuery || undefined, status: statusFilter || undefined, severity: severityFilter || undefined, type: typeFilter || undefined, @@ -68,10 +93,15 @@ const Updates: React.FC = () => { // Fetch single update if ID is provided const { data: selectedUpdateData } = useUpdate(id || '', !!id); + // Fetch recent commands for retry functionality + const { data: recentCommandsData } = useRecentCommands(50); + const approveMutation = useApproveUpdate(); const rejectMutation = useRejectUpdate(); const installMutation = useInstallUpdate(); const bulkApproveMutation = useApproveMultipleUpdates(); + const retryMutation = useRetryCommand(); + const cancelMutation = useCancelCommand(); const updates = updatesData?.updates || []; const totalCount = updatesData?.total || 0; @@ -138,6 +168,79 @@ const Updates: React.FC = () => { } }; + // Handle retry command + const handleRetryCommand = async (commandId: string) => { + try { + await retryMutation.mutateAsync(commandId); + toast.success('Command retry initiated successfully'); + } catch (error: any) { + toast.error(`Failed to retry command: ${error.message || 'Unknown error'}`); + } + }; + + // Handle cancel command + const handleCancelCommand = async (commandId: string) => { + try { + await cancelMutation.mutateAsync(commandId); + toast.success('Command cancelled successfully'); + } catch (error: any) { + toast.error(`Failed to cancel command: ${error.message || 'Unknown error'}`); + } + }; + + // Handle dependency confirmation + const handleConfirmDependencies = async (updateId: string) => { + setDependencyLoading(true); + try { + const response = await fetch(`/api/v1/updates/${updateId}/confirm-dependencies`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to confirm dependencies'); + } + + toast.success('Dependency installation confirmed'); + setShowDependencyModal(false); + setPendingDependencies([]); + setDependencyUpdateId(null); + + // Refresh the update data + window.location.reload(); + } catch (error) { + toast.error('Failed to confirm dependencies'); + console.error('Failed to confirm dependencies:', error); + } finally { + setDependencyLoading(false); + } + }; + + // Handle dependency cancellation + const handleCancelDependencies = async () => { + setShowDependencyModal(false); + setPendingDependencies([]); + setDependencyUpdateId(null); + toast('Dependency installation cancelled'); + }; + + // Handle viewing logs + const handleViewLogs = async (updateId: string) => { + setLogsLoading(true); + try { + const result = await updateApi.getUpdateLogs(updateId, 50); + setLogs(result.logs || []); + setShowLogModal(true); + } catch (error) { + toast.error('Failed to load installation logs'); + console.error('Failed to load logs:', error); + } finally { + setLogsLoading(false); + } + }; + // Get unique values for filters const statuses = [...new Set(updates.map((u: UpdatePackage) => u.status))]; const severities = [...new Set(updates.map((u: UpdatePackage) => u.severity))]; @@ -230,7 +333,14 @@ const Updates: React.FC = () => { {selectedUpdate.severity} - {selectedUpdate.status} + {selectedUpdate.status === 'checking_dependencies' ? ( +
+ + Checking dependencies... +
+ ) : ( + selectedUpdate.status + )}

@@ -351,6 +461,54 @@ const Updates: React.FC = () => { )} + {selectedUpdate.status === 'checking_dependencies' && ( +

+ + Checking Dependencies... +
+ )} + + {selectedUpdate.status === 'pending_dependencies' && ( + + )} + + {['installing', 'completed', 'failed'].includes(selectedUpdate.status) && ( + + )} + + {selectedUpdate.status === 'failed' && ( + + )} +
+ + {/* Dependency Confirmation Modal */} + {showDependencyModal && ( +
+
+
+ {/* Header */} +
+

+ Dependencies Required +

+ +
+ + {/* Content */} +
+
+
+
+ +
+
+

+ Additional packages are required +

+

+ To install {selectedUpdate?.package_name}, the following additional packages will also be installed: +

+
+
+ + {/* Dependencies List */} + {pendingDependencies.length > 0 && ( +
+
Required Dependencies:
+
    + {pendingDependencies.map((dep, index) => ( +
  • + + {dep} +
  • + ))} +
+
+ )} + + {/* Warning Message */} +
+
+ +
+

Please review the dependencies before proceeding.

+

These additional packages will be installed alongside your requested package.

+
+
+
+
+
+ + {/* Footer */} +
+ + +
+
+
+
+ )} + + {/* Log Modal */} + {showLogModal && ( +
+
+
+ {/* Modern Header */} +
+

+ Installation Logs - {selectedUpdate?.package_name} +

+ +
+ + {/* Terminal Content Area */} +
+ {logsLoading ? ( +
+ + Loading logs... +
+ ) : logs.length === 0 ? ( +
+ No installation logs available for this update. +
+ ) : ( +
+ {logs.map((log, index) => ( +
+
+ + {new Date(log.executedAt).toLocaleString()} + + + {log.action?.toUpperCase() || 'UNKNOWN'} + + {log.exit_code !== undefined && ( + + Exit: {log.exit_code} + + )} + {log.duration_seconds && ( + + {log.duration_seconds}s + + )} +
+ + {log.stdout && ( +
+ {log.stdout} +
+ )} + + {log.stderr && ( +
+ {log.stderr} +
+ )} +
+ ))} +
+ )} +
+ + {/* Modern Footer */} +
+ + +
+
+
+
+ )}
); } @@ -376,6 +741,124 @@ const Updates: React.FC = () => { setCurrentPage(1); // Reset to first page when changing page size }; + // Commands view + if (activeTab === 'commands') { + const commands = recentCommandsData?.commands || []; + + return ( +
+ {/* Header */} +
+
+
+

Command History

+

+ Review and retry failed or cancelled commands +

+
+ +
+
+ + {/* Commands list */} + {commands.length === 0 ? ( +
+ +

No commands found

+

+ No command history available yet. +

+
+ ) : ( +
+
+ + + + + + + + + + + + + {commands.map((command: any) => ( + + + + + + + + + ))} + +
CommandPackageAgentStatusCreatedActions
+
+ {command.command_type.replace('_', ' ')} +
+
+
+ {command.package_name} +
+
+
+ {command.agent_hostname} +
+
+ + {command.status} + + +
+ {formatRelativeTime(command.created_at)} +
+
+
+ {(command.status === 'failed' || command.status === 'cancelled' || command.status === 'timed_out') && ( + + )} + + {(command.status === 'pending' || command.status === 'sent') && ( + + )} +
+
+
+
+ )} +
+ ); + } + // Updates list view return (
@@ -554,6 +1037,15 @@ const Updates: React.FC = () => { Approve Selected ({selectedUpdates.length}) )} + + {/* Command History button */} +
{/* Filters */} @@ -650,7 +1142,7 @@ const Updates: React.FC = () => {

No updates found

- {searchQuery || statusFilter || severityFilter || typeFilter || agentFilter + {debouncedSearchQuery || statusFilter || severityFilter || typeFilter || agentFilter ? 'Try adjusting your search or filters.' : 'All agents are up to date!'}

@@ -728,7 +1220,14 @@ const Updates: React.FC = () => { - {update.status} + {update.status === 'checking_dependencies' ? ( +
+ + Checking dependencies... +
+ ) : ( + update.status + )}
@@ -779,6 +1278,12 @@ const Updates: React.FC = () => { )} + {update.status === 'checking_dependencies' && ( +
+ +
+ )} +