Session 4 complete - RedFlag update management platform
🚩 Private development - version retention only ✅ Complete web dashboard (React + TypeScript + TailwindCSS) ✅ Production-ready server backend (Go + Gin + PostgreSQL) ✅ Linux agent with APT + Docker scanning + local CLI tools ✅ JWT authentication and REST API ✅ Update discovery and approval workflow 🚧 Status: Alpha software - active development 📦 Purpose: Version retention during development ⚠️ Not for public use or deployment
This commit is contained in:
BIN
aggregator-agent/aggregator-agent
Executable file
BIN
aggregator-agent/aggregator-agent
Executable file
Binary file not shown.
360
aggregator-agent/cmd/agent/main.go
Normal file
360
aggregator-agent/cmd/agent/main.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/cache"
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
"github.com/aggregator-project/aggregator-agent/internal/config"
|
||||
"github.com/aggregator-project/aggregator-agent/internal/display"
|
||||
"github.com/aggregator-project/aggregator-agent/internal/scanner"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
AgentVersion = "0.1.0"
|
||||
ConfigPath = "/etc/aggregator/config.json"
|
||||
)
|
||||
|
||||
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")
|
||||
exportFormat := flag.String("export", "", "Export format: json, csv")
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load(ConfigPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load configuration:", err)
|
||||
}
|
||||
|
||||
// Handle registration
|
||||
if *registerCmd {
|
||||
if err := registerAgent(cfg, *serverURL); err != nil {
|
||||
log.Fatal("Registration failed:", err)
|
||||
}
|
||||
fmt.Println("✓ Agent registered successfully!")
|
||||
fmt.Printf("Agent ID: %s\n", cfg.AgentID)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle scan command
|
||||
if *scanCmd {
|
||||
if err := handleScanCommand(cfg, *exportFormat); err != nil {
|
||||
log.Fatal("Scan failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle status command
|
||||
if *statusCmd {
|
||||
if err := handleStatusCommand(cfg); err != nil {
|
||||
log.Fatal("Status command failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle list-updates command
|
||||
if *listUpdatesCmd {
|
||||
if err := handleListUpdatesCommand(cfg, *exportFormat); err != nil {
|
||||
log.Fatal("List updates failed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if registered
|
||||
if !cfg.IsRegistered() {
|
||||
log.Fatal("Agent not registered. Run with -register flag first.")
|
||||
}
|
||||
|
||||
// Start agent service
|
||||
if err := runAgent(cfg); err != nil {
|
||||
log.Fatal("Agent failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func registerAgent(cfg *config.Config, serverURL string) error {
|
||||
hostname, _ := os.Hostname()
|
||||
osType, osVersion, osArch := client.DetectSystem()
|
||||
|
||||
apiClient := client.NewClient(serverURL, "")
|
||||
|
||||
req := client.RegisterRequest{
|
||||
Hostname: hostname,
|
||||
OSType: osType,
|
||||
OSVersion: osVersion,
|
||||
OSArchitecture: osArch,
|
||||
AgentVersion: AgentVersion,
|
||||
Metadata: map[string]string{
|
||||
"installation_time": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := apiClient.Register(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
cfg.ServerURL = serverURL
|
||||
cfg.AgentID = resp.AgentID
|
||||
cfg.Token = resp.Token
|
||||
|
||||
// Get check-in interval from server config
|
||||
if interval, ok := resp.Config["check_in_interval"].(float64); ok {
|
||||
cfg.CheckInInterval = int(interval)
|
||||
} else {
|
||||
cfg.CheckInInterval = 300 // Default 5 minutes
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
return cfg.Save(ConfigPath)
|
||||
}
|
||||
|
||||
func runAgent(cfg *config.Config) error {
|
||||
log.Printf("🚩 RedFlag Agent v%s starting...\n", AgentVersion)
|
||||
log.Printf("Agent ID: %s\n", cfg.AgentID)
|
||||
log.Printf("Server: %s\n", cfg.ServerURL)
|
||||
log.Printf("Check-in interval: %ds\n", cfg.CheckInInterval)
|
||||
|
||||
apiClient := client.NewClient(cfg.ServerURL, cfg.Token)
|
||||
|
||||
// Initialize scanners
|
||||
aptScanner := scanner.NewAPTScanner()
|
||||
dockerScanner, _ := scanner.NewDockerScanner()
|
||||
|
||||
// Main check-in loop
|
||||
for {
|
||||
// Add jitter to prevent thundering herd
|
||||
jitter := time.Duration(rand.Intn(30)) * time.Second
|
||||
time.Sleep(jitter)
|
||||
|
||||
log.Println("Checking in with server...")
|
||||
|
||||
// Get commands from server
|
||||
commands, err := apiClient.GetCommands(cfg.AgentID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting commands: %v\n", err)
|
||||
time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Process each command
|
||||
for _, cmd := range commands {
|
||||
log.Printf("Processing command: %s (%s)\n", cmd.Type, cmd.ID)
|
||||
|
||||
switch cmd.Type {
|
||||
case "scan_updates":
|
||||
if err := handleScanUpdates(apiClient, cfg, aptScanner, dockerScanner, cmd.ID); err != nil {
|
||||
log.Printf("Error scanning updates: %v\n", err)
|
||||
}
|
||||
|
||||
case "collect_specs":
|
||||
log.Println("Spec collection not yet implemented")
|
||||
|
||||
case "install_updates":
|
||||
log.Println("Update installation not yet implemented")
|
||||
|
||||
default:
|
||||
log.Printf("Unknown command type: %s\n", cmd.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for next check-in
|
||||
time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func handleScanUpdates(apiClient *client.Client, cfg *config.Config, aptScanner *scanner.APTScanner, dockerScanner *scanner.DockerScanner, commandID string) error {
|
||||
log.Println("Scanning for updates...")
|
||||
|
||||
var allUpdates []client.UpdateReportItem
|
||||
|
||||
// Scan APT updates
|
||||
if aptScanner.IsAvailable() {
|
||||
log.Println(" - Scanning APT packages...")
|
||||
updates, err := aptScanner.Scan()
|
||||
if err != nil {
|
||||
log.Printf(" APT scan failed: %v\n", err)
|
||||
} else {
|
||||
log.Printf(" Found %d APT updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Docker updates
|
||||
if dockerScanner != nil && dockerScanner.IsAvailable() {
|
||||
log.Println(" - Scanning Docker images...")
|
||||
updates, err := dockerScanner.Scan()
|
||||
if err != nil {
|
||||
log.Printf(" Docker scan failed: %v\n", err)
|
||||
} else {
|
||||
log.Printf(" Found %d Docker image updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Report to server
|
||||
if len(allUpdates) > 0 {
|
||||
report := client.UpdateReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now(),
|
||||
Updates: allUpdates,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
|
||||
return fmt.Errorf("failed to report updates: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✓ Reported %d updates to server\n", len(allUpdates))
|
||||
} else {
|
||||
log.Println("✓ No updates found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleScanCommand performs a local scan and displays results
|
||||
func handleScanCommand(cfg *config.Config, exportFormat string) error {
|
||||
// Initialize scanners
|
||||
aptScanner := scanner.NewAPTScanner()
|
||||
dockerScanner, _ := scanner.NewDockerScanner()
|
||||
|
||||
fmt.Println("🔍 Scanning for updates...")
|
||||
var allUpdates []client.UpdateReportItem
|
||||
|
||||
// Scan APT updates
|
||||
if aptScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning APT packages...")
|
||||
updates, err := aptScanner.Scan()
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠️ APT scan failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d APT updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Docker updates
|
||||
if dockerScanner != nil && dockerScanner.IsAvailable() {
|
||||
fmt.Println(" - Scanning Docker images...")
|
||||
updates, err := dockerScanner.Scan()
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠️ Docker scan failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d Docker image updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
}
|
||||
}
|
||||
|
||||
// Load and update cache
|
||||
localCache, err := cache.Load()
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ Warning: Failed to load cache: %v\n", err)
|
||||
localCache = &cache.LocalCache{}
|
||||
}
|
||||
|
||||
// Update cache with scan results
|
||||
localCache.UpdateScanResults(allUpdates)
|
||||
if cfg.IsRegistered() {
|
||||
localCache.SetAgentInfo(cfg.AgentID, cfg.ServerURL)
|
||||
localCache.SetAgentStatus("online")
|
||||
}
|
||||
|
||||
// Save cache
|
||||
if err := localCache.Save(); err != nil {
|
||||
fmt.Printf("⚠️ Warning: Failed to save cache: %v\n", err)
|
||||
}
|
||||
|
||||
// Display results
|
||||
fmt.Println()
|
||||
return display.PrintScanResults(allUpdates, exportFormat)
|
||||
}
|
||||
|
||||
// handleStatusCommand displays agent status information
|
||||
func handleStatusCommand(cfg *config.Config) error {
|
||||
// Load cache
|
||||
localCache, err := cache.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load cache: %w", err)
|
||||
}
|
||||
|
||||
// Determine status
|
||||
agentStatus := "offline"
|
||||
if cfg.IsRegistered() {
|
||||
agentStatus = "online"
|
||||
}
|
||||
if localCache.AgentStatus != "" {
|
||||
agentStatus = localCache.AgentStatus
|
||||
}
|
||||
|
||||
// Use cached info if available, otherwise use config
|
||||
agentID := cfg.AgentID.String()
|
||||
if localCache.AgentID != (uuid.UUID{}) {
|
||||
agentID = localCache.AgentID.String()
|
||||
}
|
||||
|
||||
serverURL := cfg.ServerURL
|
||||
if localCache.ServerURL != "" {
|
||||
serverURL = localCache.ServerURL
|
||||
}
|
||||
|
||||
// Display status
|
||||
display.PrintAgentStatus(
|
||||
agentID,
|
||||
serverURL,
|
||||
localCache.LastCheckIn,
|
||||
localCache.LastScanTime,
|
||||
localCache.UpdateCount,
|
||||
agentStatus,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleListUpdatesCommand displays detailed update information
|
||||
func handleListUpdatesCommand(cfg *config.Config, exportFormat string) error {
|
||||
// Load cache
|
||||
localCache, err := cache.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load cache: %w", err)
|
||||
}
|
||||
|
||||
// Check if we have cached scan results
|
||||
if len(localCache.Updates) == 0 {
|
||||
fmt.Println("📋 No cached scan results found.")
|
||||
fmt.Println("💡 Run '--scan' first to discover available updates.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Warn if cache is old
|
||||
if localCache.IsExpired(24 * time.Hour) {
|
||||
fmt.Printf("⚠️ Scan results are %s old. Run '--scan' for latest results.\n\n",
|
||||
formatTimeSince(localCache.LastScanTime))
|
||||
}
|
||||
|
||||
// Display detailed results
|
||||
return display.PrintDetailedUpdates(localCache.Updates, exportFormat)
|
||||
}
|
||||
|
||||
// formatTimeSince formats a duration as "X time ago"
|
||||
func formatTimeSince(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
if duration < time.Minute {
|
||||
return fmt.Sprintf("%d seconds ago", int(duration.Seconds()))
|
||||
} else if duration < time.Hour {
|
||||
return fmt.Sprintf("%d minutes ago", int(duration.Minutes()))
|
||||
} else if duration < 24*time.Hour {
|
||||
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
|
||||
} else {
|
||||
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
|
||||
}
|
||||
}
|
||||
35
aggregator-agent/go.mod
Normal file
35
aggregator-agent/go.mod
Normal file
@@ -0,0 +1,35 @@
|
||||
module github.com/aggregator-project/aggregator-agent
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/docker/docker v27.4.1+incompatible
|
||||
github.com/google/uuid v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.4.21 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
)
|
||||
124
aggregator-agent/go.sum
Normal file
124
aggregator-agent/go.sum
Normal file
@@ -0,0 +1,124 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro=
|
||||
github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
|
||||
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.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=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
129
aggregator-agent/internal/cache/local.go
vendored
Normal file
129
aggregator-agent/internal/cache/local.go
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// LocalCache stores scan results locally for offline viewing
|
||||
type LocalCache struct {
|
||||
LastScanTime time.Time `json:"last_scan_time"`
|
||||
LastCheckIn time.Time `json:"last_check_in"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
UpdateCount int `json:"update_count"`
|
||||
Updates []client.UpdateReportItem `json:"updates"`
|
||||
AgentStatus string `json:"agent_status"`
|
||||
}
|
||||
|
||||
// CacheDir is the directory where local cache is stored
|
||||
const CacheDir = "/var/lib/aggregator"
|
||||
|
||||
// CacheFile is the file where scan results are cached
|
||||
const CacheFile = "last_scan.json"
|
||||
|
||||
// GetCachePath returns the full path to the cache file
|
||||
func GetCachePath() string {
|
||||
return filepath.Join(CacheDir, CacheFile)
|
||||
}
|
||||
|
||||
// Load reads the local cache from disk
|
||||
func Load() (*LocalCache, error) {
|
||||
cachePath := GetCachePath()
|
||||
|
||||
// Check if cache file exists
|
||||
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
|
||||
// Return empty cache if file doesn't exist
|
||||
return &LocalCache{}, nil
|
||||
}
|
||||
|
||||
// Read cache file
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read cache file: %w", err)
|
||||
}
|
||||
|
||||
var cache LocalCache
|
||||
if err := json.Unmarshal(data, &cache); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse cache file: %w", err)
|
||||
}
|
||||
|
||||
return &cache, nil
|
||||
}
|
||||
|
||||
// Save writes the local cache to disk
|
||||
func (c *LocalCache) Save() error {
|
||||
cachePath := GetCachePath()
|
||||
|
||||
// Ensure cache directory exists
|
||||
if err := os.MkdirAll(CacheDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
// Marshal cache to JSON with indentation
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal cache: %w", err)
|
||||
}
|
||||
|
||||
// Write cache file with restricted permissions
|
||||
if err := os.WriteFile(cachePath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write cache file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateScanResults updates the cache with new scan results
|
||||
func (c *LocalCache) UpdateScanResults(updates []client.UpdateReportItem) {
|
||||
c.LastScanTime = time.Now()
|
||||
c.Updates = updates
|
||||
c.UpdateCount = len(updates)
|
||||
}
|
||||
|
||||
// UpdateCheckIn updates the last check-in time
|
||||
func (c *LocalCache) UpdateCheckIn() {
|
||||
c.LastCheckIn = time.Now()
|
||||
}
|
||||
|
||||
// SetAgentInfo sets agent identification information
|
||||
func (c *LocalCache) SetAgentInfo(agentID uuid.UUID, serverURL string) {
|
||||
c.AgentID = agentID
|
||||
c.ServerURL = serverURL
|
||||
}
|
||||
|
||||
// SetAgentStatus sets the current agent status
|
||||
func (c *LocalCache) SetAgentStatus(status string) {
|
||||
c.AgentStatus = status
|
||||
}
|
||||
|
||||
// IsExpired checks if the cache is older than the specified duration
|
||||
func (c *LocalCache) IsExpired(maxAge time.Duration) bool {
|
||||
return time.Since(c.LastScanTime) > maxAge
|
||||
}
|
||||
|
||||
// GetUpdatesByType returns updates filtered by package type
|
||||
func (c *LocalCache) GetUpdatesByType(packageType string) []client.UpdateReportItem {
|
||||
var filtered []client.UpdateReportItem
|
||||
for _, update := range c.Updates {
|
||||
if update.PackageType == packageType {
|
||||
filtered = append(filtered, update)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Clear clears the cache
|
||||
func (c *LocalCache) Clear() {
|
||||
c.LastScanTime = time.Time{}
|
||||
c.LastCheckIn = time.Time{}
|
||||
c.UpdateCount = 0
|
||||
c.Updates = []client.UpdateReportItem{}
|
||||
c.AgentStatus = ""
|
||||
}
|
||||
242
aggregator-agent/internal/client/client.go
Normal file
242
aggregator-agent/internal/client/client.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Client handles API communication with the server
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new API client
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRequest is the payload for agent registration
|
||||
type RegisterRequest struct {
|
||||
Hostname string `json:"hostname"`
|
||||
OSType string `json:"os_type"`
|
||||
OSVersion string `json:"os_version"`
|
||||
OSArchitecture string `json:"os_architecture"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Register registers the agent with the server
|
||||
func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/register", c.baseURL)
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("registration failed: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result RegisterResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update client token
|
||||
c.token = result.Token
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// Command represents a command from the server
|
||||
type Command struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
}
|
||||
|
||||
// CommandsResponse contains pending commands
|
||||
type CommandsResponse struct {
|
||||
Commands []Command `json:"commands"`
|
||||
}
|
||||
|
||||
// GetCommands retrieves pending commands from the server
|
||||
func (c *Client) GetCommands(agentID uuid.UUID) ([]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
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("failed to get commands: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result CommandsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result.Commands, nil
|
||||
}
|
||||
|
||||
// UpdateReport represents discovered updates
|
||||
type UpdateReport struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Updates []UpdateReportItem `json:"updates"`
|
||||
}
|
||||
|
||||
// UpdateReportItem represents a single update
|
||||
type UpdateReportItem struct {
|
||||
PackageType string `json:"package_type"`
|
||||
PackageName string `json:"package_name"`
|
||||
PackageDescription string `json:"package_description"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
AvailableVersion string `json:"available_version"`
|
||||
Severity string `json:"severity"`
|
||||
CVEList []string `json:"cve_list"`
|
||||
KBID string `json:"kb_id"`
|
||||
RepositorySource string `json:"repository_source"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// ReportUpdates sends discovered updates to the server
|
||||
func (c *Client) ReportUpdates(agentID uuid.UUID, report UpdateReport) error {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/%s/updates", 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 updates: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogReport represents an execution log
|
||||
type LogReport struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Action string `json:"action"`
|
||||
Result string `json:"result"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
}
|
||||
|
||||
// ReportLog sends an execution log to the server
|
||||
func (c *Client) ReportLog(agentID uuid.UUID, report LogReport) error {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/%s/logs", 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 log: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetectSystem returns basic system information
|
||||
func DetectSystem() (osType, osVersion, osArch string) {
|
||||
osType = runtime.GOOS
|
||||
osArch = runtime.GOARCH
|
||||
|
||||
// Read OS version (simplified for now)
|
||||
switch osType {
|
||||
case "linux":
|
||||
data, _ := os.ReadFile("/etc/os-release")
|
||||
if data != nil {
|
||||
// Parse os-release file (simplified)
|
||||
osVersion = "Linux"
|
||||
}
|
||||
case "windows":
|
||||
osVersion = "Windows"
|
||||
case "darwin":
|
||||
osVersion = "macOS"
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
63
aggregator-agent/internal/config/config.go
Normal file
63
aggregator-agent/internal/config/config.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Config holds agent configuration
|
||||
type Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
CheckInInterval int `json:"check_in_interval"`
|
||||
}
|
||||
|
||||
// Load reads configuration from file
|
||||
func Load(configPath string) (*Config, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
// Read config file
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Return empty config if file doesn't exist
|
||||
return &Config{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// Save writes configuration to file
|
||||
func (c *Config) Save(configPath string) error {
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRegistered checks if the agent is registered
|
||||
func (c *Config) IsRegistered() bool {
|
||||
return c.AgentID != uuid.Nil && c.Token != ""
|
||||
}
|
||||
401
aggregator-agent/internal/display/terminal.go
Normal file
401
aggregator-agent/internal/display/terminal.go
Normal file
@@ -0,0 +1,401 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
)
|
||||
|
||||
// Color codes for terminal output
|
||||
const (
|
||||
ColorReset = "\033[0m"
|
||||
ColorRed = "\033[31m"
|
||||
ColorGreen = "\033[32m"
|
||||
ColorYellow = "\033[33m"
|
||||
ColorBlue = "\033[34m"
|
||||
ColorPurple = "\033[35m"
|
||||
ColorCyan = "\033[36m"
|
||||
ColorWhite = "\033[37m"
|
||||
ColorBold = "\033[1m"
|
||||
)
|
||||
|
||||
// SeverityColors maps severity levels to colors
|
||||
var SeverityColors = map[string]string{
|
||||
"critical": ColorRed,
|
||||
"high": ColorRed,
|
||||
"medium": ColorYellow,
|
||||
"moderate": ColorYellow,
|
||||
"low": ColorGreen,
|
||||
"info": ColorBlue,
|
||||
}
|
||||
|
||||
// PrintScanResults displays scan results in a pretty format
|
||||
func PrintScanResults(updates []client.UpdateReportItem, exportFormat string) error {
|
||||
// Handle export formats
|
||||
if exportFormat != "" {
|
||||
return exportResults(updates, exportFormat)
|
||||
}
|
||||
|
||||
// Count updates by type
|
||||
aptCount := 0
|
||||
dockerCount := 0
|
||||
otherCount := 0
|
||||
|
||||
for _, update := range updates {
|
||||
switch update.PackageType {
|
||||
case "apt":
|
||||
aptCount++
|
||||
case "docker":
|
||||
dockerCount++
|
||||
default:
|
||||
otherCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Header
|
||||
fmt.Printf("%s🚩 RedFlag Update Scan Results%s\n", ColorBold+ColorRed, ColorReset)
|
||||
fmt.Printf("%s%sScan completed: %s%s\n", ColorBold, ColorCyan, time.Now().Format("2006-01-02 15:04:05"), ColorReset)
|
||||
fmt.Println()
|
||||
|
||||
// Summary
|
||||
if len(updates) == 0 {
|
||||
fmt.Printf("%s✅ No updates available - system is up to date!%s\n", ColorBold+ColorGreen, ColorReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s📊 Summary:%s\n", ColorBold+ColorBlue, ColorReset)
|
||||
fmt.Printf(" Total updates: %s%d%s\n", ColorBold+ColorYellow, len(updates), ColorReset)
|
||||
|
||||
if aptCount > 0 {
|
||||
fmt.Printf(" APT packages: %s%d%s\n", ColorBold+ColorCyan, aptCount, ColorReset)
|
||||
}
|
||||
if dockerCount > 0 {
|
||||
fmt.Printf(" Docker images: %s%d%s\n", ColorBold+ColorCyan, dockerCount, ColorReset)
|
||||
}
|
||||
if otherCount > 0 {
|
||||
fmt.Printf(" Other: %s%d%s\n", ColorBold+ColorCyan, otherCount, ColorReset)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Group by package type
|
||||
if aptCount > 0 {
|
||||
printAPTUpdates(updates)
|
||||
}
|
||||
|
||||
if dockerCount > 0 {
|
||||
printDockerUpdates(updates)
|
||||
}
|
||||
|
||||
if otherCount > 0 {
|
||||
printOtherUpdates(updates)
|
||||
}
|
||||
|
||||
// Footer
|
||||
fmt.Println()
|
||||
fmt.Printf("%s💡 Tip: Use --list-updates for detailed information or --export=json for automation%s\n", ColorBold+ColorYellow, ColorReset)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printAPTUpdates displays APT package updates
|
||||
func printAPTUpdates(updates []client.UpdateReportItem) {
|
||||
fmt.Printf("%s📦 APT Package Updates%s\n", ColorBold+ColorBlue, ColorReset)
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
|
||||
for _, update := range updates {
|
||||
if update.PackageType != "apt" {
|
||||
continue
|
||||
}
|
||||
|
||||
severityColor := getSeverityColor(update.Severity)
|
||||
packageIcon := getPackageIcon(update.Severity)
|
||||
|
||||
fmt.Printf("%s %s%s%s\n", packageIcon, ColorBold, update.PackageName, ColorReset)
|
||||
fmt.Printf(" Version: %s→%s\n",
|
||||
getVersionColor(update.CurrentVersion),
|
||||
getVersionColor(update.AvailableVersion))
|
||||
|
||||
if update.Severity != "" {
|
||||
fmt.Printf(" Severity: %s%s%s\n", severityColor, update.Severity, ColorReset)
|
||||
}
|
||||
|
||||
if update.PackageDescription != "" {
|
||||
fmt.Printf(" Description: %s\n", truncateString(update.PackageDescription, 60))
|
||||
}
|
||||
|
||||
if len(update.CVEList) > 0 {
|
||||
fmt.Printf(" CVEs: %s\n", strings.Join(update.CVEList, ", "))
|
||||
}
|
||||
|
||||
if update.RepositorySource != "" {
|
||||
fmt.Printf(" Source: %s\n", update.RepositorySource)
|
||||
}
|
||||
|
||||
if update.SizeBytes > 0 {
|
||||
fmt.Printf(" Size: %s\n", formatBytes(update.SizeBytes))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// printDockerUpdates displays Docker image updates
|
||||
func printDockerUpdates(updates []client.UpdateReportItem) {
|
||||
fmt.Printf("%s🐳 Docker Image Updates%s\n", ColorBold+ColorBlue, ColorReset)
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
|
||||
for _, update := range updates {
|
||||
if update.PackageType != "docker" {
|
||||
continue
|
||||
}
|
||||
|
||||
severityColor := getSeverityColor(update.Severity)
|
||||
imageIcon := "🐳"
|
||||
|
||||
fmt.Printf("%s %s%s%s\n", imageIcon, ColorBold, update.PackageName, ColorReset)
|
||||
|
||||
if update.Severity != "" {
|
||||
fmt.Printf(" Severity: %s%s%s\n", severityColor, update.Severity, ColorReset)
|
||||
}
|
||||
|
||||
// Show digest comparison if available
|
||||
if update.CurrentVersion != "" && update.AvailableVersion != "" {
|
||||
fmt.Printf(" Digest: %s→%s\n",
|
||||
truncateString(update.CurrentVersion, 12),
|
||||
truncateString(update.AvailableVersion, 12))
|
||||
}
|
||||
|
||||
if update.PackageDescription != "" {
|
||||
fmt.Printf(" Description: %s\n", truncateString(update.PackageDescription, 60))
|
||||
}
|
||||
|
||||
if len(update.CVEList) > 0 {
|
||||
fmt.Printf(" CVEs: %s\n", strings.Join(update.CVEList, ", "))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// printOtherUpdates displays updates from other package managers
|
||||
func printOtherUpdates(updates []client.UpdateReportItem) {
|
||||
fmt.Printf("%s📋 Other Updates%s\n", ColorBold+ColorBlue, ColorReset)
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
|
||||
for _, update := range updates {
|
||||
if update.PackageType == "apt" || update.PackageType == "docker" {
|
||||
continue
|
||||
}
|
||||
|
||||
severityColor := getSeverityColor(update.Severity)
|
||||
packageIcon := "📦"
|
||||
|
||||
fmt.Printf("%s %s%s%s (%s)\n", packageIcon, ColorBold, update.PackageName, ColorReset, update.PackageType)
|
||||
fmt.Printf(" Version: %s→%s\n",
|
||||
getVersionColor(update.CurrentVersion),
|
||||
getVersionColor(update.AvailableVersion))
|
||||
|
||||
if update.Severity != "" {
|
||||
fmt.Printf(" Severity: %s%s%s\n", severityColor, update.Severity, ColorReset)
|
||||
}
|
||||
|
||||
if update.PackageDescription != "" {
|
||||
fmt.Printf(" Description: %s\n", truncateString(update.PackageDescription, 60))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// PrintDetailedUpdates shows full details for all updates
|
||||
func PrintDetailedUpdates(updates []client.UpdateReportItem, exportFormat string) error {
|
||||
// Handle export formats
|
||||
if exportFormat != "" {
|
||||
return exportResults(updates, exportFormat)
|
||||
}
|
||||
|
||||
fmt.Printf("%s🔍 Detailed Update Information%s\n", ColorBold+ColorPurple, ColorReset)
|
||||
fmt.Printf("%sGenerated: %s%s\n\n", ColorCyan, time.Now().Format("2006-01-02 15:04:05"), ColorReset)
|
||||
|
||||
if len(updates) == 0 {
|
||||
fmt.Printf("%s✅ No updates available%s\n", ColorBold+ColorGreen, ColorReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, update := range updates {
|
||||
fmt.Printf("%sUpdate #%d%s\n", ColorBold+ColorYellow, i+1, ColorReset)
|
||||
fmt.Println(strings.Repeat("═", 60))
|
||||
|
||||
fmt.Printf("%sPackage:%s %s\n", ColorBold, ColorReset, update.PackageName)
|
||||
fmt.Printf("%sType:%s %s\n", ColorBold, ColorReset, update.PackageType)
|
||||
fmt.Printf("%sCurrent Version:%s %s\n", ColorBold, ColorReset, update.CurrentVersion)
|
||||
fmt.Printf("%sAvailable Version:%s %s\n", ColorBold, ColorReset, update.AvailableVersion)
|
||||
|
||||
if update.Severity != "" {
|
||||
severityColor := getSeverityColor(update.Severity)
|
||||
fmt.Printf("%sSeverity:%s %s%s%s\n", ColorBold, ColorReset, severityColor, update.Severity, ColorReset)
|
||||
}
|
||||
|
||||
if update.PackageDescription != "" {
|
||||
fmt.Printf("%sDescription:%s %s\n", ColorBold, ColorReset, update.PackageDescription)
|
||||
}
|
||||
|
||||
if len(update.CVEList) > 0 {
|
||||
fmt.Printf("%sCVE List:%s %s\n", ColorBold, ColorReset, strings.Join(update.CVEList, ", "))
|
||||
}
|
||||
|
||||
if update.KBID != "" {
|
||||
fmt.Printf("%sKB Article:%s %s\n", ColorBold, ColorReset, update.KBID)
|
||||
}
|
||||
|
||||
if update.RepositorySource != "" {
|
||||
fmt.Printf("%sRepository:%s %s\n", ColorBold, ColorReset, update.RepositorySource)
|
||||
}
|
||||
|
||||
if update.SizeBytes > 0 {
|
||||
fmt.Printf("%sSize:%s %s\n", ColorBold, ColorReset, formatBytes(update.SizeBytes))
|
||||
}
|
||||
|
||||
if len(update.Metadata) > 0 {
|
||||
fmt.Printf("%sMetadata:%s\n", ColorBold, ColorReset)
|
||||
for key, value := range update.Metadata {
|
||||
fmt.Printf(" %s: %v\n", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintAgentStatus displays agent status information
|
||||
func PrintAgentStatus(agentID string, serverURL string, lastCheckIn time.Time, lastScan time.Time, updateCount int, agentStatus string) {
|
||||
fmt.Printf("%s🚩 RedFlag Agent Status%s\n", ColorBold+ColorRed, ColorReset)
|
||||
fmt.Println(strings.Repeat("─", 40))
|
||||
|
||||
fmt.Printf("%sAgent ID:%s %s\n", ColorBold, ColorReset, agentID)
|
||||
fmt.Printf("%sServer:%s %s\n", ColorBold, ColorReset, serverURL)
|
||||
fmt.Printf("%sStatus:%s %s%s%s\n", ColorBold, ColorReset, getSeverityColor(agentStatus), agentStatus, ColorReset)
|
||||
|
||||
if !lastCheckIn.IsZero() {
|
||||
fmt.Printf("%sLast Check-in:%s %s\n", ColorBold, ColorReset, formatTimeSince(lastCheckIn))
|
||||
} else {
|
||||
fmt.Printf("%sLast Check-in:%s %sNever%s\n", ColorBold, ColorReset, ColorYellow, ColorReset)
|
||||
}
|
||||
|
||||
if !lastScan.IsZero() {
|
||||
fmt.Printf("%sLast Scan:%s %s\n", ColorBold, ColorReset, formatTimeSince(lastScan))
|
||||
fmt.Printf("%sUpdates Found:%s %s%d%s\n", ColorBold, ColorReset, ColorYellow, updateCount, ColorReset)
|
||||
} else {
|
||||
fmt.Printf("%sLast Scan:%s %sNever%s\n", ColorBold, ColorReset, ColorYellow, ColorReset)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func getSeverityColor(severity string) string {
|
||||
if color, ok := SeverityColors[severity]; ok {
|
||||
return color
|
||||
}
|
||||
return ColorWhite
|
||||
}
|
||||
|
||||
func getPackageIcon(severity string) string {
|
||||
switch strings.ToLower(severity) {
|
||||
case "critical", "high":
|
||||
return "🔴"
|
||||
case "medium", "moderate":
|
||||
return "🟡"
|
||||
case "low":
|
||||
return "🟢"
|
||||
default:
|
||||
return "🔵"
|
||||
}
|
||||
}
|
||||
|
||||
func getVersionColor(version string) string {
|
||||
if version == "" {
|
||||
return ColorRed + "unknown" + ColorReset
|
||||
}
|
||||
return ColorCyan + version + ColorReset
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func formatTimeSince(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
if duration < time.Minute {
|
||||
return fmt.Sprintf("%d seconds ago", int(duration.Seconds()))
|
||||
} else if duration < time.Hour {
|
||||
return fmt.Sprintf("%d minutes ago", int(duration.Minutes()))
|
||||
} else if duration < 24*time.Hour {
|
||||
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
|
||||
} else {
|
||||
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
func exportResults(updates []client.UpdateReportItem, format string) error {
|
||||
switch strings.ToLower(format) {
|
||||
case "json":
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(updates)
|
||||
|
||||
case "csv":
|
||||
return exportCSV(updates)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported export format: %s (supported: json, csv)", format)
|
||||
}
|
||||
}
|
||||
|
||||
func exportCSV(updates []client.UpdateReportItem) error {
|
||||
// Print CSV header
|
||||
fmt.Println("PackageType,PackageName,CurrentVersion,AvailableVersion,Severity,CVEList,Description,SizeBytes")
|
||||
|
||||
// Print each update as CSV row
|
||||
for _, update := range updates {
|
||||
cveList := strings.Join(update.CVEList, ";")
|
||||
description := strings.ReplaceAll(update.PackageDescription, ",", ";")
|
||||
description = strings.ReplaceAll(description, "\n", " ")
|
||||
|
||||
fmt.Printf("%s,%s,%s,%s,%s,%s,%s,%d\n",
|
||||
update.PackageType,
|
||||
update.PackageName,
|
||||
update.CurrentVersion,
|
||||
update.AvailableVersion,
|
||||
update.Severity,
|
||||
cveList,
|
||||
description,
|
||||
update.SizeBytes,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
90
aggregator-agent/internal/scanner/apt.go
Normal file
90
aggregator-agent/internal/scanner/apt.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
)
|
||||
|
||||
// APTScanner scans for APT package updates
|
||||
type APTScanner struct{}
|
||||
|
||||
// NewAPTScanner creates a new APT scanner
|
||||
func NewAPTScanner() *APTScanner {
|
||||
return &APTScanner{}
|
||||
}
|
||||
|
||||
// IsAvailable checks if APT is available on this system
|
||||
func (s *APTScanner) IsAvailable() bool {
|
||||
_, err := exec.LookPath("apt")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Scan scans for available APT updates
|
||||
func (s *APTScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
// Update package cache (sudo may be required, but try anyway)
|
||||
updateCmd := exec.Command("apt-get", "update")
|
||||
updateCmd.Run() // Ignore errors since we might not have sudo
|
||||
|
||||
// Get upgradable packages
|
||||
cmd := exec.Command("apt", "list", "--upgradable")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to run apt list: %w", err)
|
||||
}
|
||||
|
||||
return parseAPTOutput(output)
|
||||
}
|
||||
|
||||
func parseAPTOutput(output []byte) ([]client.UpdateReportItem, error) {
|
||||
var updates []client.UpdateReportItem
|
||||
scanner := bufio.NewScanner(bytes.NewReader(output))
|
||||
|
||||
// Regex to parse apt output:
|
||||
// package/repo version arch [upgradable from: old_version]
|
||||
re := regexp.MustCompile(`^([^\s/]+)/([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+\[upgradable from:\s+([^\]]+)\]`)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "Listing...") {
|
||||
continue
|
||||
}
|
||||
|
||||
matches := re.FindStringSubmatch(line)
|
||||
if len(matches) < 6 {
|
||||
continue
|
||||
}
|
||||
|
||||
packageName := matches[1]
|
||||
repository := matches[2]
|
||||
newVersion := matches[3]
|
||||
oldVersion := matches[5]
|
||||
|
||||
// Determine severity (simplified - in production, query Ubuntu Security Advisories)
|
||||
severity := "moderate"
|
||||
if strings.Contains(repository, "security") {
|
||||
severity = "important"
|
||||
}
|
||||
|
||||
update := client.UpdateReportItem{
|
||||
PackageType: "apt",
|
||||
PackageName: packageName,
|
||||
CurrentVersion: oldVersion,
|
||||
AvailableVersion: newVersion,
|
||||
Severity: severity,
|
||||
RepositorySource: repository,
|
||||
Metadata: map[string]interface{}{
|
||||
"architecture": matches[4],
|
||||
},
|
||||
}
|
||||
|
||||
updates = append(updates, update)
|
||||
}
|
||||
|
||||
return updates, nil
|
||||
}
|
||||
162
aggregator-agent/internal/scanner/docker.go
Normal file
162
aggregator-agent/internal/scanner/docker.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/aggregator-project/aggregator-agent/internal/client"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// DockerScanner scans for Docker image updates
|
||||
type DockerScanner struct {
|
||||
client *dockerclient.Client
|
||||
registryClient *RegistryClient
|
||||
}
|
||||
|
||||
// NewDockerScanner creates a new Docker scanner
|
||||
func NewDockerScanner() (*DockerScanner, error) {
|
||||
cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DockerScanner{
|
||||
client: cli,
|
||||
registryClient: NewRegistryClient(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsAvailable checks if Docker is available on this system
|
||||
func (s *DockerScanner) IsAvailable() bool {
|
||||
_, err := exec.LookPath("docker")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to ping Docker daemon
|
||||
if s.client != nil {
|
||||
_, err := s.client.Ping(context.Background())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Scan scans for available Docker image updates
|
||||
func (s *DockerScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// List all containers
|
||||
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list containers: %w", err)
|
||||
}
|
||||
|
||||
var updates []client.UpdateReportItem
|
||||
seenImages := make(map[string]bool)
|
||||
|
||||
for _, c := range containers {
|
||||
imageName := c.Image
|
||||
|
||||
// Skip if we've already checked this image
|
||||
if seenImages[imageName] {
|
||||
continue
|
||||
}
|
||||
seenImages[imageName] = true
|
||||
|
||||
// Get current image details
|
||||
imageInspect, _, err := s.client.ImageInspectWithRaw(ctx, imageName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse image name and tag
|
||||
parts := strings.Split(imageName, ":")
|
||||
baseImage := parts[0]
|
||||
currentTag := "latest"
|
||||
if len(parts) > 1 {
|
||||
currentTag = parts[1]
|
||||
}
|
||||
|
||||
// Check if update is available by comparing with registry
|
||||
hasUpdate, remoteDigest := s.checkForUpdate(ctx, baseImage, currentTag, imageInspect.ID)
|
||||
|
||||
if hasUpdate {
|
||||
// Extract short digest for display (first 12 chars of sha256 hash)
|
||||
localDigest := imageInspect.ID
|
||||
remoteShortDigest := "unknown"
|
||||
if len(remoteDigest) > 7 {
|
||||
// Format: sha256:abcd... -> take first 12 chars of hash
|
||||
parts := strings.SplitN(remoteDigest, ":", 2)
|
||||
if len(parts) == 2 && len(parts[1]) >= 12 {
|
||||
remoteShortDigest = parts[1][:12]
|
||||
}
|
||||
}
|
||||
|
||||
update := client.UpdateReportItem{
|
||||
PackageType: "docker_image",
|
||||
PackageName: imageName,
|
||||
PackageDescription: fmt.Sprintf("Container: %s", strings.Join(c.Names, ", ")),
|
||||
CurrentVersion: localDigest[:12], // Short hash
|
||||
AvailableVersion: remoteShortDigest,
|
||||
Severity: "moderate",
|
||||
RepositorySource: baseImage,
|
||||
Metadata: map[string]interface{}{
|
||||
"container_id": c.ID[:12],
|
||||
"container_names": c.Names,
|
||||
"container_state": c.State,
|
||||
"image_created": imageInspect.Created,
|
||||
"local_full_digest": localDigest,
|
||||
"remote_digest": remoteDigest,
|
||||
},
|
||||
}
|
||||
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// checkForUpdate checks if a newer image version is available by comparing digests
|
||||
// Returns (hasUpdate bool, remoteDigest string)
|
||||
//
|
||||
// This implementation:
|
||||
// 1. Queries Docker Registry HTTP API v2 for remote manifest
|
||||
// 2. Compares image digests (sha256 hashes) between local and remote
|
||||
// 3. Handles authentication for Docker Hub (anonymous pull)
|
||||
// 4. Caches registry responses (5 min TTL) to respect rate limits
|
||||
// 5. Returns both the update status and remote digest for metadata
|
||||
//
|
||||
// Note: This compares exact digests. If local digest != remote digest, an update exists.
|
||||
// This works for all tags including "latest", version tags, etc.
|
||||
func (s *DockerScanner) checkForUpdate(ctx context.Context, imageName, tag, currentID string) (bool, string) {
|
||||
// Get remote digest from registry
|
||||
remoteDigest, err := s.registryClient.GetRemoteDigest(ctx, imageName, tag)
|
||||
if err != nil {
|
||||
// If we can't check the registry, log the error but don't report an update
|
||||
// This prevents false positives when registry is down or rate-limited
|
||||
fmt.Printf("Warning: Failed to check registry for %s:%s: %v\n", imageName, tag, err)
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Compare digests
|
||||
// Local Docker image ID format: sha256:abc123...
|
||||
// Remote digest format: sha256:def456...
|
||||
// If they differ, an update is available
|
||||
hasUpdate := currentID != remoteDigest
|
||||
|
||||
return hasUpdate, remoteDigest
|
||||
}
|
||||
|
||||
// Close closes the Docker client
|
||||
func (s *DockerScanner) Close() error {
|
||||
if s.client != nil {
|
||||
return s.client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
259
aggregator-agent/internal/scanner/registry.go
Normal file
259
aggregator-agent/internal/scanner/registry.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegistryClient handles communication with Docker registries (Docker Hub and custom registries)
|
||||
type RegistryClient struct {
|
||||
httpClient *http.Client
|
||||
cache *manifestCache
|
||||
}
|
||||
|
||||
// manifestCache stores registry responses to avoid hitting rate limits
|
||||
type manifestCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*cacheEntry
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
digest string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// ManifestResponse represents the response from a Docker Registry API v2 manifest request
|
||||
type ManifestResponse struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
MediaType string `json:"mediaType"`
|
||||
Config struct {
|
||||
Digest string `json:"digest"`
|
||||
} `json:"config"`
|
||||
}
|
||||
|
||||
// DockerHubTokenResponse represents the authentication token response from Docker Hub
|
||||
type DockerHubTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
}
|
||||
|
||||
// NewRegistryClient creates a new registry client with caching
|
||||
func NewRegistryClient() *RegistryClient {
|
||||
return &RegistryClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
cache: &manifestCache{
|
||||
entries: make(map[string]*cacheEntry),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetRemoteDigest fetches the digest of a remote image from the registry
|
||||
// Returns the digest string (e.g., "sha256:abc123...") or an error
|
||||
func (c *RegistryClient) GetRemoteDigest(ctx context.Context, imageName, tag string) (string, error) {
|
||||
// Parse image name to determine registry and repository
|
||||
registry, repository := parseImageName(imageName)
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("%s/%s:%s", registry, repository, tag)
|
||||
if digest := c.cache.get(cacheKey); digest != "" {
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// Get authentication token (if needed)
|
||||
token, err := c.getAuthToken(ctx, registry, repository)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get auth token: %w", err)
|
||||
}
|
||||
|
||||
// Fetch manifest from registry
|
||||
digest, err := c.fetchManifestDigest(ctx, registry, repository, tag, token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch manifest: %w", err)
|
||||
}
|
||||
|
||||
// Cache the result (5 minute TTL to avoid hammering registries)
|
||||
c.cache.set(cacheKey, digest, 5*time.Minute)
|
||||
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// parseImageName splits an image name into registry and repository
|
||||
// Examples:
|
||||
// - "nginx" -> ("registry-1.docker.io", "library/nginx")
|
||||
// - "myuser/myimage" -> ("registry-1.docker.io", "myuser/myimage")
|
||||
// - "gcr.io/myproject/myimage" -> ("gcr.io", "myproject/myimage")
|
||||
func parseImageName(imageName string) (registry, repository string) {
|
||||
parts := strings.Split(imageName, "/")
|
||||
|
||||
// Check if first part looks like a domain (contains . or :)
|
||||
if len(parts) >= 2 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
||||
// Custom registry: gcr.io/myproject/myimage
|
||||
registry = parts[0]
|
||||
repository = strings.Join(parts[1:], "/")
|
||||
} else if len(parts) == 1 {
|
||||
// Official image: nginx -> library/nginx
|
||||
registry = "registry-1.docker.io"
|
||||
repository = "library/" + parts[0]
|
||||
} else {
|
||||
// User image: myuser/myimage
|
||||
registry = "registry-1.docker.io"
|
||||
repository = imageName
|
||||
}
|
||||
|
||||
return registry, repository
|
||||
}
|
||||
|
||||
// getAuthToken obtains an authentication token for the registry
|
||||
// For Docker Hub, uses the token authentication flow
|
||||
// For other registries, may need different auth mechanisms (TODO: implement)
|
||||
func (c *RegistryClient) getAuthToken(ctx context.Context, registry, repository string) (string, error) {
|
||||
// Docker Hub token authentication
|
||||
if registry == "registry-1.docker.io" {
|
||||
return c.getDockerHubToken(ctx, repository)
|
||||
}
|
||||
|
||||
// For other registries, we'll try unauthenticated first
|
||||
// TODO: Support authentication for private registries (basic auth, bearer tokens, etc.)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// getDockerHubToken obtains a token from Docker Hub's authentication service
|
||||
func (c *RegistryClient) getDockerHubToken(ctx context.Context, repository string) (string, error) {
|
||||
authURL := fmt.Sprintf(
|
||||
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull",
|
||||
repository,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp DockerHubTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
// Docker Hub can return either 'token' or 'access_token'
|
||||
if tokenResp.Token != "" {
|
||||
return tokenResp.Token, nil
|
||||
}
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// fetchManifestDigest fetches the manifest from the registry and extracts the digest
|
||||
func (c *RegistryClient) fetchManifestDigest(ctx context.Context, registry, repository, tag, token string) (string, error) {
|
||||
// Build manifest URL
|
||||
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registry, repository, tag)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", manifestURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Set required headers
|
||||
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return "", fmt.Errorf("rate limited by registry (429 Too Many Requests)")
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return "", fmt.Errorf("unauthorized: authentication failed for %s/%s:%s", registry, repository, tag)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("manifest request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Try to get digest from Docker-Content-Digest header first (faster)
|
||||
if digest := resp.Header.Get("Docker-Content-Digest"); digest != "" {
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// Fallback: parse manifest and extract config digest
|
||||
var manifest ManifestResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
||||
return "", fmt.Errorf("failed to decode manifest: %w", err)
|
||||
}
|
||||
|
||||
if manifest.Config.Digest == "" {
|
||||
return "", fmt.Errorf("manifest does not contain a config digest")
|
||||
}
|
||||
|
||||
return manifest.Config.Digest, nil
|
||||
}
|
||||
|
||||
// manifestCache methods
|
||||
|
||||
func (mc *manifestCache) get(key string) string {
|
||||
mc.mu.RLock()
|
||||
defer mc.mu.RUnlock()
|
||||
|
||||
entry, exists := mc.entries[key]
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
// Entry expired
|
||||
delete(mc.entries, key)
|
||||
return ""
|
||||
}
|
||||
|
||||
return entry.digest
|
||||
}
|
||||
|
||||
func (mc *manifestCache) set(key, digest string, ttl time.Duration) {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
|
||||
mc.entries[key] = &cacheEntry{
|
||||
digest: digest,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupExpired removes expired entries from the cache (called periodically)
|
||||
func (mc *manifestCache) cleanupExpired() {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, entry := range mc.entries {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(mc.entries, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user