Update README with current features and screenshots

- Cross-platform support (Windows/Linux) with Windows Updates and Winget
- Added dependency confirmation workflow and refresh token authentication
- New screenshots: History, Live Operations, Windows Agent Details
- Local CLI features with terminal output and cache system
- Updated known limitations - Proxmox integration is broken
- Organized docs to docs/ folder and updated .gitignore
- Probably introduced a dozen bugs with Windows agents - stay tuned
This commit is contained in:
Fimeg
2025-10-17 15:28:22 -04:00
parent 61294ba514
commit 2ade509b63
65 changed files with 7342 additions and 424 deletions

Binary file not shown.

View File

@@ -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)

View File

@@ -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

View File

@@ -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=

221
aggregator-agent/install.sh Executable file
View File

@@ -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" <<EOF
[Unit]
Description=RedFlag Update Agent
After=network.target
[Service]
Type=simple
User=$AGENT_USER
Group=$AGENT_USER
WorkingDirectory=$AGENT_HOME
ExecStart=$AGENT_BINARY
Restart=always
RestartSec=30
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=$AGENT_HOME
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
chmod 644 "$SERVICE_FILE"
echo "✓ Systemd service installed"
}
# Function to start and enable service
start_service() {
echo "Reloading systemd daemon..."
systemctl daemon-reload
# Stop service if running
if systemctl is-active --quiet redflag-agent; then
echo "Stopping existing service..."
systemctl stop redflag-agent
fi
echo "Enabling and starting redflag-agent service..."
systemctl enable redflag-agent
systemctl start redflag-agent
# Wait a moment for service to start
sleep 2
echo "✓ Service started"
}
# Function to show status
show_status() {
echo ""
echo "=== Service Status ==="
systemctl status redflag-agent --no-pager -l
echo ""
echo "=== Recent Logs ==="
journalctl -u redflag-agent -n 20 --no-pager
}
# Function to register agent
register_agent() {
local server_url="${1:-http://localhost:8080}"
echo "Registering agent with server at $server_url..."
# Create config directory
mkdir -p /etc/aggregator
# Register agent (run as regular binary, not as service)
if "$AGENT_BINARY" -register -server "$server_url"; then
echo "✓ Agent registered successfully"
else
echo "ERROR: Agent registration failed"
echo "Please ensure the RedFlag server is running at $server_url"
exit 1
fi
}
# Main installation flow
SERVER_URL="${1:-http://localhost:8080}"
echo "Step 1: Creating system user..."
create_user
echo ""
echo "Step 2: Building agent binary..."
build_agent
echo ""
echo "Step 3: Installing agent binary..."
install_binary
echo ""
echo "Step 4: Registering agent with server..."
register_agent "$SERVER_URL"
echo ""
echo "Step 5: Setting config file permissions..."
chown redflag-agent:redflag-agent /etc/aggregator/config.json
chmod 600 /etc/aggregator/config.json
echo ""
echo "Step 6: Installing sudoers configuration..."
install_sudoers
echo ""
echo "Step 7: Installing systemd service..."
install_service
echo ""
echo "Step 8: Starting service..."
start_service
echo ""
echo "=== Installation Complete ==="
echo ""
echo "The RedFlag agent is now installed and running as a systemd service."
echo "Server URL: $SERVER_URL"
echo ""
echo "Useful commands:"
echo " - Check status: sudo systemctl status redflag-agent"
echo " - View logs: sudo journalctl -u redflag-agent -f"
echo " - Restart: sudo systemctl restart redflag-agent"
echo " - Stop: sudo systemctl stop redflag-agent"
echo " - Disable: sudo systemctl disable redflag-agent"
echo ""
echo "Note: To re-register with a different server, edit /etc/aggregator/config.json"
echo ""
show_status

View File

@@ -32,6 +32,16 @@ func NewClient(baseURL, token string) *Client {
}
}
// GetToken returns the current JWT token
func (c *Client) GetToken() string {
return c.token
}
// SetToken updates the JWT token
func (c *Client) SetToken(token string) {
c.token = token
}
// RegisterRequest is the payload for agent registration
type RegisterRequest struct {
Hostname string `json:"hostname"`
@@ -44,9 +54,10 @@ type RegisterRequest struct {
// RegisterResponse is returned after successful registration
type RegisterResponse 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"`
}
// Register registers the agent with the server
@@ -86,6 +97,59 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
return &result, nil
}
// TokenRenewalRequest is the payload for token renewal using refresh token
type TokenRenewalRequest struct {
AgentID uuid.UUID `json:"agent_id"`
RefreshToken string `json:"refresh_token"`
}
// TokenRenewalResponse is returned after successful token renewal
type TokenRenewalResponse struct {
Token string `json:"token"` // New short-lived access token (24h)
}
// RenewToken uses refresh token to get a new access token (proper implementation)
func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string) error {
url := fmt.Sprintf("%s/api/v1/agents/renew", c.baseURL)
renewalReq := TokenRenewalRequest{
AgentID: agentID,
RefreshToken: refreshToken,
}
body, err := json.Marshal(renewalReq)
if err != nil {
return err
}
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("token renewal failed: %s - %s", resp.Status, string(bodyBytes))
}
var result TokenRenewalResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
// Update client token
c.token = result.Token
return nil
}
// Command represents a command from the server
type Command struct {
ID string `json:"id"`
@@ -98,14 +162,45 @@ type CommandsResponse struct {
Commands []Command `json:"commands"`
}
// SystemMetrics represents lightweight system metrics sent with check-ins
type SystemMetrics 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"` // Agent version
}
// GetCommands retrieves pending commands from the server
func (c *Client) GetCommands(agentID uuid.UUID) ([]Command, error) {
// Optionally sends lightweight system metrics in the request
func (c *Client) GetCommands(agentID uuid.UUID, metrics *SystemMetrics) ([]Command, error) {
url := fmt.Sprintf("%s/api/v1/agents/%s/commands", c.baseURL, agentID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
var req *http.Request
var err error
// If metrics provided, send them in request body
if metrics != nil {
body, err := json.Marshal(metrics)
if err != nil {
return nil, err
}
req, err = http.NewRequest("GET", url, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
} else {
req, err = http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
}
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
@@ -220,6 +315,60 @@ func (c *Client) ReportLog(agentID uuid.UUID, report LogReport) error {
return nil
}
// DependencyReport represents a dependency report after dry run
type DependencyReport struct {
PackageName string `json:"package_name"`
PackageType string `json:"package_type"`
Dependencies []string `json:"dependencies"`
UpdateID string `json:"update_id"`
DryRunResult *InstallResult `json:"dry_run_result,omitempty"`
}
// InstallResult represents the result of a package installation attempt
type InstallResult struct {
Success bool `json:"success"`
ErrorMessage string `json:"error_message,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
ExitCode int `json:"exit_code"`
DurationSeconds int `json:"duration_seconds"`
Action string `json:"action,omitempty"`
PackagesInstalled []string `json:"packages_installed,omitempty"`
ContainersUpdated []string `json:"containers_updated,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
IsDryRun bool `json:"is_dry_run"`
}
// ReportDependencies sends dependency report to the server
func (c *Client) ReportDependencies(agentID uuid.UUID, report DependencyReport) error {
url := fmt.Sprintf("%s/api/v1/agents/%s/dependencies", 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()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to report dependencies: %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

View File

@@ -13,7 +13,8 @@ import (
type Config struct {
ServerURL string `json:"server_url"`
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"`
Token string `json:"token"` // Short-lived access token (24h)
RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
CheckInInterval int `json:"check_in_interval"`
}

View File

@@ -3,17 +3,21 @@ package installer
import (
"fmt"
"os/exec"
"regexp"
"strings"
"time"
"github.com/aggregator-project/aggregator-agent/internal/client"
)
// APTInstaller handles APT package installations
type APTInstaller struct{}
type APTInstaller struct {
executor *SecureCommandExecutor
}
// NewAPTInstaller creates a new APT installer
func NewAPTInstaller() *APTInstaller {
return &APTInstaller{}
return &APTInstaller{
executor: NewSecureCommandExecutor(),
}
}
// IsAvailable checks if APT is available on this system
@@ -26,37 +30,34 @@ func (i *APTInstaller) IsAvailable() bool {
func (i *APTInstaller) Install(packageName string) (*InstallResult, error) {
startTime := time.Now()
// Update package cache first
updateCmd := exec.Command("sudo", "apt-get", "update")
if output, err := updateCmd.CombinedOutput(); err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Failed to update APT cache: %v\nStdout: %s", err, string(output)),
DurationSeconds: int(time.Since(startTime).Seconds()),
}, fmt.Errorf("apt-get update failed: %w", err)
// Update package cache first using secure executor
updateResult, err := i.executor.ExecuteCommand("apt-get", []string{"update"})
if err != nil {
updateResult.DurationSeconds = int(time.Since(startTime).Seconds())
updateResult.ErrorMessage = fmt.Sprintf("Failed to update APT cache: %v", err)
return updateResult, fmt.Errorf("apt-get update failed: %w", err)
}
// Install package
installCmd := exec.Command("sudo", "apt-get", "install", "-y", packageName)
output, err := installCmd.CombinedOutput()
// Install package using secure executor
installResult, err := i.executor.ExecuteCommand("apt-get", []string{"install", "-y", packageName})
duration := int(time.Since(startTime).Seconds())
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("APT 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
@@ -73,39 +74,36 @@ func (i *APTInstaller) InstallMultiple(packageNames []string) (*InstallResult, e
startTime := time.Now()
// Update package cache first
updateCmd := exec.Command("sudo", "apt-get", "update")
if output, err := updateCmd.CombinedOutput(); err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Failed to update APT cache: %v\nStdout: %s", err, string(output)),
DurationSeconds: int(time.Since(startTime).Seconds()),
}, fmt.Errorf("apt-get update failed: %w", err)
// Update package cache first using secure executor
updateResult, err := i.executor.ExecuteCommand("apt-get", []string{"update"})
if err != nil {
updateResult.DurationSeconds = int(time.Since(startTime).Seconds())
updateResult.ErrorMessage = fmt.Sprintf("Failed to update APT cache: %v", err)
return updateResult, fmt.Errorf("apt-get update 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", "apt-get", args...)
output, err := installCmd.CombinedOutput()
installResult, err := i.executor.ExecuteCommand("apt-get", args)
duration := int(time.Since(startTime).Seconds())
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("APT 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
@@ -115,42 +113,148 @@ func (i *APTInstaller) InstallMultiple(packageNames []string) (*InstallResult, e
func (i *APTInstaller) Upgrade() (*InstallResult, error) {
startTime := time.Now()
// Update package cache first
updateCmd := exec.Command("sudo", "apt-get", "update")
if output, err := updateCmd.CombinedOutput(); err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Failed to update APT cache: %v\nStdout: %s", err, string(output)),
DurationSeconds: int(time.Since(startTime).Seconds()),
}, fmt.Errorf("apt-get update failed: %w", err)
// Update package cache first using secure executor
updateResult, err := i.executor.ExecuteCommand("apt-get", []string{"update"})
if err != nil {
updateResult.DurationSeconds = int(time.Since(startTime).Seconds())
updateResult.ErrorMessage = fmt.Sprintf("Failed to update APT cache: %v", err)
return updateResult, fmt.Errorf("apt-get update failed: %w", err)
}
// Upgrade all packages
upgradeCmd := exec.Command("sudo", "apt-get", "upgrade", "-y")
output, err := upgradeCmd.CombinedOutput()
// Upgrade all packages using secure executor
upgradeResult, err := i.executor.ExecuteCommand("apt-get", []string{"upgrade", "-y"})
duration := int(time.Since(startTime).Seconds())
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("APT 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 *APTInstaller) DryRun(packageName string) (*InstallResult, error) {
startTime := time.Now()
// Update package cache first using secure executor
updateResult, err := i.executor.ExecuteCommand("apt-get", []string{"update"})
if err != nil {
updateResult.DurationSeconds = int(time.Since(startTime).Seconds())
updateResult.ErrorMessage = fmt.Sprintf("Failed to update APT cache: %v", err)
updateResult.IsDryRun = true
return updateResult, fmt.Errorf("apt-get update failed: %w", err)
}
// Perform dry run installation using secure executor
installResult, err := i.executor.ExecuteCommand("apt-get", []string{"install", "--dry-run", "--yes", packageName})
duration := int(time.Since(startTime).Seconds())
// Parse dependencies from the output
dependencies := i.parseDependenciesFromAPTOutput(installResult.Stdout, packageName)
if err != nil {
// APT 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("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"

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"
}

70
aggregator-agent/uninstall.sh Executable file
View File

@@ -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 ""