diff --git a/README.md b/README.md index 28bd2ba..612824e 100644 --- a/README.md +++ b/README.md @@ -52,33 +52,20 @@ A self-hosted, cross-platform update management platform built with: ## Screenshots -### Main Dashboard -![Main Dashboard](Screenshots/RedFlag%20Default%20Dashboard.png) -Overview showing agent status, system metrics, and update statistics +| Overview | Updates Management | Agent List | +|----------|-------------------|------------| +| ![Main Dashboard](Screenshots/RedFlag%20Default%20Dashboard.png) | ![Updates Dashboard](Screenshots/RedFlag%20Updates%20Dashboard.png) | ![Agent List](Screenshots/RedFlag%20Agent%20List.png) | +| System overview with metrics | Update approval with dependency workflow | Cross-platform agent management | -### Updates Management -![Updates Dashboard](Screenshots/RedFlag%20Updates%20Dashboard.png) -Comprehensive update listing with filtering, approval, and dependency confirmation +| Linux Agent Details | Windows Agent Details | History & Audit | +|-------------------|---------------------|----------------| +| ![Linux Agent Details](Screenshots/RedFlag%20Linux%20Agent%20Details.png) | ![Windows Agent Details](Screenshots/RedFlag%20Windows%20Agent%20Details.png) | ![History Dashboard](Screenshots/RedFlag%20History%20Dashboard.png) | +| Linux system specs and updates | Windows Updates and Winget support | Complete audit trail of activities | -### Agent Details -![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 +| Live Operations | Docker Management | +|-----------------|------------------| +| ![Live Operations](Screenshots/RedFlag%20Live%20Operations%20-%20Failed%20Dashboard.png) | ![Docker Dashboard](Screenshots/RedFlag%20Docker%20Dashboard.png) | +| Real-time operation tracking | Container image update management | ## For Developers @@ -104,9 +91,9 @@ This repository contains: ┌────┴────┬────────┐ │ │ │ ┌───▼──┐ ┌──▼──┐ ┌──▼───┐ -│Linux │ │Linux│ │Linux │ -│Agent │ │Agent│ │Agent │ -└──────┘ └─────┘ └──────┘ +│Linux │ │Windows│ │Linux │ +│Agent │ │Agent │ │Agent │ +└──────┘ └───────┘ └──────┘ ``` ## Project Structure @@ -117,19 +104,45 @@ RedFlag/ │ ├── cmd/server/ # Main entry point │ ├── internal/ │ │ ├── api/ # HTTP handlers & middleware +│ │ │ └── handlers/ # API endpoint handlers │ │ ├── database/ # Database layer & migrations -│ │ ├── models/ # Data models -│ │ └── config/ # Configuration +│ │ │ ├── migrations/ # Database schema migrations +│ │ │ └── queries/ # Database query functions +│ │ ├── models/ # Data models and structs +│ │ ├── services/ # Business logic services +│ │ ├── utils/ # Utility functions +│ │ └── config/ # Configuration management │ └── go.mod -├── aggregator-agent/ # Go agent +├── aggregator-agent/ # Go agent (cross-platform) │ ├── cmd/agent/ # Main entry point │ ├── internal/ -│ │ ├── client/ # API client -│ │ ├── installer/ # Update installers (APT, DNF, Docker) -│ │ ├── scanner/ # Update scanners (APT, Docker, DNF/RPM) +│ │ ├── cache/ # Local cache system for offline viewing +│ │ ├── client/ # API client with token renewal +│ │ ├── config/ # Configuration management +│ │ ├── display/ # Terminal output formatting +│ │ ├── installer/ # Update installers +│ │ │ ├── apt.go # APT package installer +│ │ │ ├── dnf.go # DNF package installer +│ │ │ ├── docker.go # Docker image installer +│ │ │ ├── windows.go # Windows installer base +│ │ │ ├── winget.go # Winget package installer +│ │ │ ├── security.go # Security utilities +│ │ │ └── sudoers.go # Sudo management +│ │ ├── scanner/ # Update scanners +│ │ │ ├── apt.go # APT package scanner +│ │ │ ├── dnf.go # DNF package scanner +│ │ │ ├── docker.go # Docker image scanner +│ │ │ ├── registry.go # Docker registry client +│ │ │ ├── windows.go # Windows Update scanner +│ │ │ ├── winget.go # Winget package scanner +│ │ │ └── windows_*.go # Windows Update API components │ │ ├── system/ # System information collection -│ │ └── config/ # Configuration +│ │ │ ├── info.go # System metrics +│ │ │ └── windows.go # Windows system info +│ │ └── executor/ # Command execution +│ ├── install.sh # Linux installation script +│ ├── uninstall.sh # Linux uninstallation script │ └── go.mod ├── aggregator-web/ # React dashboard @@ -141,10 +154,12 @@ RedFlag/ ## Database Schema Key Tables: -- `agents` - Registered agents with system metadata -- `update_packages` - Discovered updates -- `agent_commands` - Command queue for agents -- `update_logs` - Execution logs +- `agents` - Registered agents with system metadata and version tracking +- `refresh_tokens` - Long-lived refresh tokens for stable agent identity +- `update_events` - Immutable event storage for update discoveries +- `current_package_state` - Optimized view of current update state +- `agent_commands` - Command queue for agents (scan, install, dry-run) +- `update_logs` - Execution logs with detailed results - `agent_tags` - Agent tagging/grouping ## Configuration @@ -164,7 +179,8 @@ Auto-generated on registration: { "server_url": "http://localhost:8080", "agent_id": "uuid", - "token": "jwt-token", + "token": "jwt-access-token", + "refresh_token": "long-lived-refresh-token", "check_in_interval": 300 } ``` @@ -219,12 +235,34 @@ curl http://localhost:8080/api/v1/updates?status=pending curl -X POST http://localhost:8080/api/v1/updates/{update-id}/approve ``` +### Token Renewal (Agent Authentication) +```bash +# Exchange refresh token for new access token +curl -X POST http://localhost:8080/api/v1/agents/renew \ + -H "Content-Type: application/json" \ + -d '{ + "agent_id": "uuid", + "refresh_token": "long-lived-token" + }' +``` + +### Dependency Workflow +```bash +# Dry run to check dependencies (automatically triggered by install) +curl -X POST http://localhost:8080/api/v1/updates/{update-id}/approve + +# Confirm dependencies and install +curl -X POST http://localhost:8080/api/v1/updates/{update-id}/confirm-dependencies +``` + ## Security -- Agent Authentication: JWT tokens with 24h expiry +- Agent Authentication: Refresh token system with 90-day sliding window + 24h access tokens +- SHA-256 token hashing for secure storage - Pull-based Model: Agents poll server (firewall-friendly) - Command Validation: Whitelisted commands only - TLS Required: Production deployments must use HTTPS +- Token Renewal: `/renew` endpoint prevents daily re-registration ## License diff --git a/Screenshots/RedFlag Agent Details - old.png b/Screenshots/RedFlag Agent Details - old.png deleted file mode 100644 index f9acc99..0000000 Binary files a/Screenshots/RedFlag Agent Details - old.png and /dev/null differ diff --git a/Screenshots/RedFlag Agent List.png b/Screenshots/RedFlag Agent List.png new file mode 100644 index 0000000..6dfad09 Binary files /dev/null and b/Screenshots/RedFlag Agent List.png differ diff --git a/Screenshots/RedFlag Agent Details.png b/Screenshots/RedFlag Linux Agent Details.png similarity index 100% rename from Screenshots/RedFlag Agent Details.png rename to Screenshots/RedFlag Linux Agent Details.png diff --git a/aggregator-agent/cmd/agent/main.go b/aggregator-agent/cmd/agent/main.go index 4fc12a0..7c021ff 100644 --- a/aggregator-agent/cmd/agent/main.go +++ b/aggregator-agent/cmd/agent/main.go @@ -273,12 +273,27 @@ func runAgent(cfg *config.Config) error { windowsUpdateScanner := scanner.NewWindowsUpdateScanner() wingetScanner := scanner.NewWingetScanner() + // System info tracking + var lastSystemInfoUpdate time.Time + const systemInfoUpdateInterval = 1 * time.Hour // Update detailed system info every hour + // Main check-in loop for { // Add jitter to prevent thundering herd jitter := time.Duration(rand.Intn(30)) * time.Second time.Sleep(jitter) + // Check if we need to send detailed system info update + if time.Since(lastSystemInfoUpdate) >= systemInfoUpdateInterval { + log.Printf("Updating detailed system information...") + if err := reportSystemInfo(apiClient, cfg); err != nil { + log.Printf("Failed to report system info: %v\n", err) + } else { + lastSystemInfoUpdate = time.Now() + log.Printf("✓ System information updated\n") + } + } + log.Printf("Checking in with server... (Agent v%s)", AgentVersion) // Collect lightweight system metrics @@ -374,17 +389,25 @@ func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner log.Println("Scanning for updates...") var allUpdates []client.UpdateReportItem + var scanErrors []string + var scanResults []string // Scan APT updates if aptScanner.IsAvailable() { log.Println(" - Scanning APT packages...") updates, err := aptScanner.Scan() if err != nil { - log.Printf(" APT scan failed: %v\n", err) + errorMsg := fmt.Sprintf("APT scan failed: %v", err) + log.Printf(" %s\n", errorMsg) + scanErrors = append(scanErrors, errorMsg) } else { - log.Printf(" Found %d APT updates\n", len(updates)) + resultMsg := fmt.Sprintf("Found %d APT updates", len(updates)) + log.Printf(" %s\n", resultMsg) + scanResults = append(scanResults, resultMsg) allUpdates = append(allUpdates, updates...) } + } else { + scanResults = append(scanResults, "APT scanner not available") } // Scan DNF updates @@ -392,11 +415,17 @@ func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner log.Println(" - Scanning DNF packages...") updates, err := dnfScanner.Scan() if err != nil { - log.Printf(" DNF scan failed: %v\n", err) + errorMsg := fmt.Sprintf("DNF scan failed: %v", err) + log.Printf(" %s\n", errorMsg) + scanErrors = append(scanErrors, errorMsg) } else { - log.Printf(" Found %d DNF updates\n", len(updates)) + resultMsg := fmt.Sprintf("Found %d DNF updates", len(updates)) + log.Printf(" %s\n", resultMsg) + scanResults = append(scanResults, resultMsg) allUpdates = append(allUpdates, updates...) } + } else { + scanResults = append(scanResults, "DNF scanner not available") } // Scan Docker updates @@ -404,11 +433,17 @@ func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner log.Println(" - Scanning Docker images...") updates, err := dockerScanner.Scan() if err != nil { - log.Printf(" Docker scan failed: %v\n", err) + errorMsg := fmt.Sprintf("Docker scan failed: %v", err) + log.Printf(" %s\n", errorMsg) + scanErrors = append(scanErrors, errorMsg) } else { - log.Printf(" Found %d Docker image updates\n", len(updates)) + resultMsg := fmt.Sprintf("Found %d Docker image updates", len(updates)) + log.Printf(" %s\n", resultMsg) + scanResults = append(scanResults, resultMsg) allUpdates = append(allUpdates, updates...) } + } else { + scanResults = append(scanResults, "Docker scanner not available") } // Scan Windows updates @@ -416,11 +451,17 @@ func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner log.Println(" - Scanning Windows updates...") updates, err := windowsUpdateScanner.Scan() if err != nil { - log.Printf(" Windows Update scan failed: %v\n", err) + errorMsg := fmt.Sprintf("Windows Update scan failed: %v", err) + log.Printf(" %s\n", errorMsg) + scanErrors = append(scanErrors, errorMsg) } else { - log.Printf(" Found %d Windows updates\n", len(updates)) + resultMsg := fmt.Sprintf("Found %d Windows updates", len(updates)) + log.Printf(" %s\n", resultMsg) + scanResults = append(scanResults, resultMsg) allUpdates = append(allUpdates, updates...) } + } else { + scanResults = append(scanResults, "Windows Update scanner not available") } // Scan Winget packages @@ -428,14 +469,58 @@ func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner log.Println(" - Scanning Winget packages...") updates, err := wingetScanner.Scan() if err != nil { - log.Printf(" Winget scan failed: %v\n", err) + errorMsg := fmt.Sprintf("Winget scan failed: %v", err) + log.Printf(" %s\n", errorMsg) + scanErrors = append(scanErrors, errorMsg) } else { - log.Printf(" Found %d Winget package updates\n", len(updates)) + resultMsg := fmt.Sprintf("Found %d Winget package updates", len(updates)) + log.Printf(" %s\n", resultMsg) + scanResults = append(scanResults, resultMsg) allUpdates = append(allUpdates, updates...) } + } else { + scanResults = append(scanResults, "Winget scanner not available") } - // Report to server + // Report scan results to server (both successes and failures) + success := len(allUpdates) > 0 || len(scanErrors) == 0 + var combinedOutput string + + // Combine all scan results + if len(scanResults) > 0 { + combinedOutput += "Scan Results:\n" + strings.Join(scanResults, "\n") + } + if len(scanErrors) > 0 { + if combinedOutput != "" { + combinedOutput += "\n" + } + combinedOutput += "Scan Errors:\n" + strings.Join(scanErrors, "\n") + } + if len(allUpdates) > 0 { + if combinedOutput != "" { + combinedOutput += "\n" + } + combinedOutput += fmt.Sprintf("Total Updates Found: %d", len(allUpdates)) + } + + // Create scan log entry + logReport := client.LogReport{ + CommandID: commandID, + Action: "scan_updates", + Result: map[bool]string{true: "success", false: "failure"}[success], + Stdout: combinedOutput, + Stderr: strings.Join(scanErrors, "\n"), + ExitCode: map[bool]int{true: 0, false: 1}[success], + DurationSeconds: 0, // Could track scan duration if needed + } + + // Report the scan log + if err := apiClient.ReportLog(cfg.AgentID, logReport); err != nil { + log.Printf("Failed to report scan log: %v\n", err) + // Continue anyway - updates are more important + } + + // Report updates to server if any were found if len(allUpdates) > 0 { report := client.UpdateReport{ CommandID: commandID, @@ -452,6 +537,11 @@ func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner log.Println("✓ No updates found") } + // Return error if there were any scan failures + if len(scanErrors) > 0 && len(allUpdates) == 0 { + return fmt.Errorf("all scanners failed: %s", strings.Join(scanErrors, "; ")) + } + return nil } @@ -961,6 +1051,58 @@ func handleConfirmDependencies(apiClient *client.Client, cfg *config.Config, com return nil } +// reportSystemInfo collects and reports detailed system information to the server +func reportSystemInfo(apiClient *client.Client, cfg *config.Config) error { + // Collect detailed system information + sysInfo, err := system.GetSystemInfo(AgentVersion) + if err != nil { + return fmt.Errorf("failed to get system info: %w", err) + } + + // Create system info report + report := client.SystemInfoReport{ + Timestamp: time.Now(), + CPUModel: sysInfo.CPUInfo.ModelName, + CPUCores: sysInfo.CPUInfo.Cores, + CPUThreads: sysInfo.CPUInfo.Threads, + MemoryTotal: sysInfo.MemoryInfo.Total, + DiskTotal: uint64(0), + DiskUsed: uint64(0), + IPAddress: sysInfo.IPAddress, + Processes: sysInfo.RunningProcesses, + Uptime: sysInfo.Uptime, + Metadata: make(map[string]interface{}), + } + + // Add primary disk info + if len(sysInfo.DiskInfo) > 0 { + primaryDisk := sysInfo.DiskInfo[0] + report.DiskTotal = primaryDisk.Total + report.DiskUsed = primaryDisk.Used + report.Metadata["disk_mount"] = primaryDisk.Mountpoint + report.Metadata["disk_filesystem"] = primaryDisk.Filesystem + } + + // Add collection timestamp and additional metadata + report.Metadata["collected_at"] = time.Now().Format(time.RFC3339) + report.Metadata["hostname"] = sysInfo.Hostname + report.Metadata["os_type"] = sysInfo.OSType + report.Metadata["os_version"] = sysInfo.OSVersion + report.Metadata["os_architecture"] = sysInfo.OSArchitecture + + // Add any existing metadata from system info + for key, value := range sysInfo.Metadata { + report.Metadata[key] = value + } + + // Report to server + if err := apiClient.ReportSystemInfo(cfg.AgentID, report); err != nil { + return fmt.Errorf("failed to report system info: %w", err) + } + + return nil +} + // formatTimeSince formats a duration as "X time ago" func formatTimeSince(t time.Time) string { duration := time.Since(t) diff --git a/aggregator-agent/internal/client/client.go b/aggregator-agent/internal/client/client.go index 3860e6e..ec0332b 100644 --- a/aggregator-agent/internal/client/client.go +++ b/aggregator-agent/internal/client/client.go @@ -369,6 +369,52 @@ func (c *Client) ReportDependencies(agentID uuid.UUID, report DependencyReport) return nil } +// SystemInfoReport represents system information updates +type SystemInfoReport struct { + Timestamp time.Time `json:"timestamp"` + CPUModel string `json:"cpu_model,omitempty"` + CPUCores int `json:"cpu_cores,omitempty"` + CPUThreads int `json:"cpu_threads,omitempty"` + MemoryTotal uint64 `json:"memory_total,omitempty"` + DiskTotal uint64 `json:"disk_total,omitempty"` + DiskUsed uint64 `json:"disk_used,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + Processes int `json:"processes,omitempty"` + Uptime string `json:"uptime,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// ReportSystemInfo sends updated system information to the server +func (c *Client) ReportSystemInfo(agentID uuid.UUID, report SystemInfoReport) error { + url := fmt.Sprintf("%s/api/v1/agents/%s/system-info", c.baseURL, agentID) + + body, err := json.Marshal(report) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.token) + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Accept 200 OK or 404 Not Found (if endpoint doesn't exist yet) + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to report system info: %s - %s", resp.Status, string(bodyBytes)) + } + + return nil +} + // DetectSystem returns basic system information (deprecated, use system.GetSystemInfo instead) func DetectSystem() (osType, osVersion, osArch string) { osType = runtime.GOOS diff --git a/aggregator-agent/internal/scanner/winget.go b/aggregator-agent/internal/scanner/winget.go index 76ae36d..a7a06a3 100644 --- a/aggregator-agent/internal/scanner/winget.go +++ b/aggregator-agent/internal/scanner/winget.go @@ -6,6 +6,7 @@ import ( "os/exec" "runtime" "strings" + "time" "github.com/aggregator-project/aggregator-agent/internal/client" ) @@ -66,9 +67,17 @@ func (s *WingetScanner) Scan() ([]client.UpdateReportItem, error) { fmt.Printf("Winget basic scan failed: %v\n", err) } - // Method 3: Check if this is a known Winget issue and provide helpful error + // Method 3: Attempt automatic recovery for known issues 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)) + fmt.Printf("Attempting automatic winget recovery...\n") + if updates, err := s.attemptWingetRecovery(); err == nil { + fmt.Printf("Winget recovery successful, found %d updates\n", len(updates)) + return updates, nil + } else { + fmt.Printf("Winget recovery failed: %v\n", err) + } + + return nil, fmt.Errorf("winget encountered a known issue (exit code %s). This may be due to Windows Update service or system configuration. Automatic recovery was attempted but failed", getExitCode(lastErr)) } return nil, lastErr @@ -518,4 +527,136 @@ func (s *WingetScanner) GetInstalledPackages() ([]WingetPackage, error) { } return packages, nil +} + +// attemptWingetRecovery tries to fix common winget issues automatically +func (s *WingetScanner) attemptWingetRecovery() ([]client.UpdateReportItem, error) { + fmt.Printf("Starting winget recovery process...\n") + + // Recovery Method 1: Reset winget sources (common fix) + fmt.Printf("Attempting to reset winget sources...\n") + if err := s.resetWingetSources(); err == nil { + if updates, scanErr := s.scanWithJSON(); scanErr == nil { + fmt.Printf("Recovery successful after source reset\n") + return updates, nil + } + } + + // Recovery Method 2: Update winget itself (silent) + fmt.Printf("Attempting to update winget itself...\n") + if err := s.updateWingetSilent(); err == nil { + // Wait a moment for winget to stabilize + time.Sleep(2 * time.Second) + if updates, scanErr := s.scanWithJSON(); scanErr == nil { + fmt.Printf("Recovery successful after winget update\n") + return updates, nil + } + } + + // Recovery Method 3: Repair Windows App Installer (winget backend) + fmt.Printf("Attempting to repair Windows App Installer...\n") + if err := s.repairWindowsAppInstaller(); err == nil { + // Wait longer for system repairs + time.Sleep(5 * time.Second) + if updates, scanErr := s.scanWithJSON(); scanErr == nil { + fmt.Printf("Recovery successful after Windows App Installer repair\n") + return updates, nil + } + } + + // Recovery Method 4: Force refresh with admin privileges + fmt.Printf("Attempting admin refresh...\n") + if updates, err := s.scanWithAdminPrivileges(); err == nil { + fmt.Printf("Recovery successful with admin privileges\n") + return updates, nil + } + + // If all recovery attempts failed, return the original error + return nil, fmt.Errorf("all winget recovery attempts failed") +} + +// resetWingetSources resets winget package sources +func (s *WingetScanner) resetWingetSources() error { + // Reset winget sources silently + cmd := exec.Command("winget", "source", "reset", "--force") + _, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Failed to reset winget sources: %v\n", err) + return err + } + + // Add default sources back + cmd = exec.Command("winget", "source", "add", "winget", "--accept-package-agreements", "--accept-source-agreements") + _, err = cmd.CombinedOutput() + if err != nil { + fmt.Printf("Failed to add winget source: %v\n", err) + return err + } + + return nil +} + +// updateWingetSilent updates winget itself silently +func (s *WingetScanner) updateWingetSilent() error { + // Update winget silently with no interaction + cmd := exec.Command("winget", "upgrade", "--id", "Microsoft.AppInstaller", "--silent", "--accept-package-agreements", "--accept-source-agreements") + _, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Failed to update winget: %v\n", err) + return err + } + return nil +} + +// repairWindowsAppInstaller attempts to repair the Windows App Installer +func (s *WingetScanner) repairWindowsAppInstaller() error { + // Try to repair using PowerShell + psCmd := `Get-AppxPackage -Name "Microsoft.DesktopAppInstaller" | Repair-AppxPackage -ForceUpdateFromAnyVersion` + cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-Command", psCmd) + _, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Failed to repair Windows App Installer: %v\n", err) + return err + } + return nil +} + +// scanWithAdminPrivileges attempts to scan with elevated privileges if available +func (s *WingetScanner) scanWithAdminPrivileges() ([]client.UpdateReportItem, error) { + // Try running with elevated privileges using PowerShell + psCmd := `Start-Process winget -ArgumentList "list","--outdated","--accept-source-agreements" -Verb RunAs -Wait` + cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-Command", psCmd) + + // This will likely fail without actual admin privileges, but we try anyway + _, err := cmd.CombinedOutput() + if err != nil { + // Fallback to regular scan with different flags + return s.scanWithDifferentFlags() + } + + // If admin scan succeeded, try to get the results + return s.scanWithBasicOutput() +} + +// scanWithDifferentFlags tries alternative winget flags +func (s *WingetScanner) scanWithDifferentFlags() ([]client.UpdateReportItem, error) { + // Try different combination of flags + flagVariations := [][]string{ + {"list", "--outdated", "--accept-source-agreements"}, + {"list", "--outdated", "--include-unknown"}, + {"list", "--outdated"}, + } + + for _, flags := range flagVariations { + cmd := exec.Command("winget", flags...) + output, err := cmd.CombinedOutput() + if err == nil { + // Try to parse the output + if updates, parseErr := s.parseWingetTextOutput(string(output)); parseErr == nil { + return updates, nil + } + } + } + + return nil, fmt.Errorf("all flag variations failed") } \ No newline at end of file diff --git a/aggregator-agent/internal/system/windows.go b/aggregator-agent/internal/system/windows.go index 679963a..f97315a 100644 --- a/aggregator-agent/internal/system/windows.go +++ b/aggregator-agent/internal/system/windows.go @@ -40,47 +40,96 @@ func getWindowsCPUInfo() (*CPUInfo, error) { // Try using wmic for CPU information if cmd, err := exec.LookPath("wmic"); err == nil { - // Get CPU name + // Get CPU name with better error handling if data, err := exec.Command(cmd, "cpu", "get", "Name").Output(); err == nil { - lines := strings.Split(string(data), "\n") + output := string(data) + fmt.Printf("WMIC CPU Name output: '%s'\n", output) // Debug logging + lines := strings.Split(output, "\n") for _, line := range lines { - if strings.TrimSpace(line) != "" && !strings.Contains(line, "Name") { - cpu.ModelName = strings.TrimSpace(line) + line = strings.TrimSpace(line) + if line != "" && !strings.Contains(line, "Name") { + cpu.ModelName = line + fmt.Printf("Found CPU model: '%s'\n", line) // Debug logging break } } + } else { + fmt.Printf("Failed to get CPU name via wmic: %v\n", err) } // Get number of cores if data, err := exec.Command(cmd, "cpu", "get", "NumberOfCores").Output(); err == nil { - lines := strings.Split(string(data), "\n") + output := string(data) + fmt.Printf("WMIC CPU Cores output: '%s'\n", output) // Debug logging + lines := strings.Split(output, "\n") for _, line := range lines { - if strings.TrimSpace(line) != "" && !strings.Contains(line, "NumberOfCores") { - if cores, err := strconv.Atoi(strings.TrimSpace(line)); err == nil { + line = strings.TrimSpace(line) + if line != "" && !strings.Contains(line, "NumberOfCores") { + if cores, err := strconv.Atoi(line); err == nil { cpu.Cores = cores + fmt.Printf("Found CPU cores: %d\n", cores) // Debug logging } break } } + } else { + fmt.Printf("Failed to get CPU cores via wmic: %v\n", err) } // Get number of logical processors (threads) if data, err := exec.Command(cmd, "cpu", "get", "NumberOfLogicalProcessors").Output(); err == nil { - lines := strings.Split(string(data), "\n") + output := string(data) + fmt.Printf("WMIC CPU Threads output: '%s'\n", output) // Debug logging + lines := strings.Split(output, "\n") for _, line := range lines { - if strings.TrimSpace(line) != "" && !strings.Contains(line, "NumberOfLogicalProcessors") { - if threads, err := strconv.Atoi(strings.TrimSpace(line)); err == nil { + line = strings.TrimSpace(line) + if line != "" && !strings.Contains(line, "NumberOfLogicalProcessors") { + if threads, err := strconv.Atoi(line); err == nil { cpu.Threads = threads + fmt.Printf("Found CPU threads: %d\n", threads) // Debug logging } break } } + } else { + fmt.Printf("Failed to get CPU threads via wmic: %v\n", err) } // If we couldn't get threads, assume it's equal to cores if cpu.Threads == 0 { cpu.Threads = cpu.Cores } + } else { + fmt.Printf("WMIC command not found, unable to get CPU info\n") + } + + // Fallback to PowerShell if wmic failed + if cpu.ModelName == "" { + fmt.Printf("Attempting PowerShell fallback for CPU info...\n") + if psCmd, err := exec.LookPath("powershell"); err == nil { + // Get CPU info via PowerShell + if data, err := exec.Command(psCmd, "-Command", "Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 Name,NumberOfCores,NumberOfLogicalProcessors | ConvertTo-Json").Output(); err == nil { + fmt.Printf("PowerShell CPU output: '%s'\n", string(data)) + // Try to parse JSON output (simplified) + output := string(data) + if strings.Contains(output, "Name") { + // Simple string extraction as fallback + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "Name") && strings.Contains(line, ":") { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + cpu.ModelName = strings.TrimSpace(strings.Trim(parts[1], " ,\"")) + fmt.Printf("Found CPU via PowerShell: '%s'\n", cpu.ModelName) + break + } + } + } + } + } else { + fmt.Printf("PowerShell CPU info failed: %v\n", err) + } + } } return cpu, nil diff --git a/aggregator-server/internal/api/handlers/updates.go b/aggregator-server/internal/api/handlers/updates.go index 389dad1..6af13c1 100644 --- a/aggregator-server/internal/api/handlers/updates.go +++ b/aggregator-server/internal/api/handlers/updates.go @@ -453,7 +453,56 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) { return } - // Update the package status to pending_dependencies + // If there are NO dependencies, auto-approve and proceed directly to installation + // This prevents updates with zero dependencies from getting stuck in "pending_dependencies" + if len(req.Dependencies) == 0 { + // Get the update by package to retrieve its ID + update, err := h.updateQueries.GetUpdateByPackage(agentID, req.PackageType, req.PackageName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get update details"}) + return + } + + // Record that dependencies were checked (empty array) in metadata + 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 metadata"}) + return + } + + // Automatically create installation command since no dependencies need approval + command := &models.AgentCommand{ + ID: uuid.New(), + AgentID: agentID, + CommandType: models.CommandTypeConfirmDependencies, + Params: map[string]interface{}{ + "update_id": update.ID.String(), + "package_name": req.PackageName, + "package_type": req.PackageType, + "dependencies": []string{}, // Empty dependencies array + }, + Status: models.CommandStatusPending, + CreatedAt: time.Now(), + } + + if err := h.commandQueries.CreateCommand(command); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create installation command"}) + return + } + + // Update status to installing since no approval needed + if err := h.updateQueries.InstallUpdate(update.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status to installing"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "no dependencies found - installation command created automatically", + "command_id": command.ID.String(), + }) + return + } + + // If dependencies EXIST, require manual approval by setting 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 diff --git a/aggregator-server/internal/database/queries/updates.go b/aggregator-server/internal/database/queries/updates.go index d70d1bf..b2bb302 100644 --- a/aggregator-server/internal/database/queries/updates.go +++ b/aggregator-server/internal/database/queries/updates.go @@ -222,7 +222,10 @@ func (q *UpdateQueries) SetCheckingDependencies(id uuid.UUID) error { return err } -// SetPendingDependencies marks an update as having dependencies that need approval +// SetPendingDependencies stores dependency information and sets status based on whether dependencies exist +// If dependencies array is empty, this function only updates metadata without changing status +// (the handler should auto-approve and proceed to installation in this case) +// If dependencies array has items, status is set to 'pending_dependencies' requiring manual 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) @@ -230,6 +233,9 @@ func (q *UpdateQueries) SetPendingDependencies(agentID uuid.UUID, packageType, p return fmt.Errorf("failed to marshal dependencies: %w", err) } + // Note: When dependencies array is empty, the handler should bypass this status change + // and proceed directly to installation. This function still records the empty array + // in metadata for audit purposes before the handler transitions to 'installing'. query := ` UPDATE current_package_state SET status = 'pending_dependencies',