commit 55b7d030108893f13e51622d8878c442d027e191 Author: Fimeg Date: Mon Oct 13 16:46:31 2025 -0400 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9dd752f --- /dev/null +++ b/.gitignore @@ -0,0 +1,426 @@ +# RedFlag .gitignore +# Comprehensive ignore file for Go, Node.js, and development files + +# ============================================================================= +# Go / Go Modules +# ============================================================================= +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +go.work + +# Dependency directories (remove comment if using vendoring) +vendor/ + +# Go build cache +.cache/ + +# Go mod download cache (can be large) +*.modcache + +# ============================================================================= +# Node.js / npm / yarn / pnpm +# ============================================================================= +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# ============================================================================= +# React / Vite / Frontend Build +# ============================================================================= +# Vite build output +dist/ +dist-ssr/ +build/ + +# Storybook build outputs +storybook-static + +# Temporary folders +tmp/ +temp/ + +# ============================================================================= +# IDE / Editor Files +# ============================================================================= +# VSCode +.vscode/ +!.vscode/extensions.json +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json + +# JetBrains / IntelliJ IDEA +.idea/ +*.iml +*.ipr +*.iws + +# Vim +*.swp +*.swo +*~ + +# Emacs +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Sublime Text +*.sublime-project +*.sublime-workspace + +# Kate +.session + +# Gedit +*~ + +# ============================================================================= +# OS Generated Files +# ============================================================================= +# macOS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Windows +*.cab +*.msi +*.msix +*.msm +*.msp +*.lnk + +# Linux +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* + +# ============================================================================= +# Application Specific +# ============================================================================= +# RedFlag specific files +*.db +*.sqlite +*.sqlite3 + +# Agent configuration (may contain sensitive data) +aggregator-agent/config.json +aggregator-agent/.agent-id +aggregator-agent/.token + +# Server runtime files +aggregator-server/logs/ +aggregator-server/data/ +aggregator-server/uploads/ + +# Local cache files +aggregator-agent/cache/ +aggregator-agent/*.cache +/var/lib/aggregator/ +/var/cache/aggregator/ +/var/log/aggregator/ + +# Test files and coverage +coverage.txt +coverage.html +*.cover +*.prof +test-results/ + +# Local development files +*.local +*.dev +.devenv/ +dev/ + +# Build artifacts +*.tar.gz +*.zip +*.rpm +*.deb +*.snap + +# Documentation build +docs/_build/ +docs/build/ + +# ============================================================================= +# Docker / Container Related +# ============================================================================= +# Docker volumes (avoid committing data) +volumes/ +data/ + +# Docker build context +.dockerignore + +# ============================================================================= +# Security / Credentials +# ============================================================================= +# Private keys and certificates +*.key +*.pem +*.crt +*.p12 +*.pfx +id_rsa +id_rsa.pub +id_ed25519 +id_ed25519.pub + +# Passwords and secrets +secrets/ +*.secret +*.password +*.token +.auth + +# Cloud provider credentials +.aws/ +.azure/ +.gcp/ +.kube/ + +# ============================================================================= +# Miscellaneous +# ============================================================================= +# Large files +*.iso +*.dmg +*.img +*.bin +*.dat + +# Backup files +*.bak +*.backup +*.old +*.orig +*.save + +# Temporary files +*.tmp +*.temp +*.swp +*.swo + +# Lock files (keep some, ignore others) +*.lock +# Keep package-lock.json and yarn.lock for dependency management +# yarn.lock +# package-lock.json + +# Archive files +*.7z +*.rar +*.tar +*.tgz +*.gz + +# Profiling and performance data +*.prof +*.pprof +*.cpu +*.mem + +# Local database files +*.db-shm +*.db-wal + +# ============================================================================= +# AI / LLM Development Files +# ============================================================================= +# Claude AI settings and cache +.claude/ +*claude* + +# ============================================================================= +# Include ONLY essential project files +# ============================================================================= +!README.md +!LICENSE +!.gitignore +!.env.example +!docker-compose.yml +!Dockerfile* +!Makefile + +# Only minimal README, no other documentation + +# ============================================================================= +# ALL DOCUMENTATION (private development - version retention only) +# ============================================================================= +# Exclude ALL documentation files - this is private development +*.md +!LICENSE +*.html +*.txt + +# Session and development files +SESSION_* +claude* +TECHNICAL_* +COMPETITIVE_* +PROXMOX_* +HOW_TO_* +NEXT_* +Starting* + +# Setup and documentation files +SETUP_* +CONTRIBUTING* +.github/ +docs/ + +# Only keep actual project code, no documentation \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0c95bae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 RedFlag Project + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..27db635 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: help db-up db-down server agent clean + +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Available targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +db-up: ## Start PostgreSQL database + docker-compose up -d aggregator-db + @echo "Waiting for database to be ready..." + @sleep 3 + +db-down: ## Stop PostgreSQL database + docker-compose down + +server: ## Build and run the server + cd aggregator-server && go mod tidy && go run cmd/server/main.go + +agent: ## Build and run the agent + cd aggregator-agent && go mod tidy && go run cmd/agent/main.go + +build-server: ## Build server binary + cd aggregator-server && go build -o bin/aggregator-server cmd/server/main.go + +build-agent: ## Build agent binary + cd aggregator-agent && go build -o bin/aggregator-agent cmd/agent/main.go + +clean: ## Clean build artifacts + rm -rf aggregator-server/bin aggregator-agent/bin + +test: ## Run tests + cd aggregator-server && go test ./... + cd aggregator-agent && go test ./... diff --git a/aggregator-agent/aggregator-agent b/aggregator-agent/aggregator-agent new file mode 100755 index 0000000..8b2ec5d Binary files /dev/null and b/aggregator-agent/aggregator-agent differ diff --git a/aggregator-agent/cmd/agent/main.go b/aggregator-agent/cmd/agent/main.go new file mode 100644 index 0000000..823b804 --- /dev/null +++ b/aggregator-agent/cmd/agent/main.go @@ -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)) + } +} diff --git a/aggregator-agent/go.mod b/aggregator-agent/go.mod new file mode 100644 index 0000000..82e0918 --- /dev/null +++ b/aggregator-agent/go.mod @@ -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 +) diff --git a/aggregator-agent/go.sum b/aggregator-agent/go.sum new file mode 100644 index 0000000..b80e020 --- /dev/null +++ b/aggregator-agent/go.sum @@ -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= diff --git a/aggregator-agent/internal/cache/local.go b/aggregator-agent/internal/cache/local.go new file mode 100644 index 0000000..8fc7373 --- /dev/null +++ b/aggregator-agent/internal/cache/local.go @@ -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 = "" +} \ No newline at end of file diff --git a/aggregator-agent/internal/client/client.go b/aggregator-agent/internal/client/client.go new file mode 100644 index 0000000..8614765 --- /dev/null +++ b/aggregator-agent/internal/client/client.go @@ -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 +} diff --git a/aggregator-agent/internal/config/config.go b/aggregator-agent/internal/config/config.go new file mode 100644 index 0000000..a36233a --- /dev/null +++ b/aggregator-agent/internal/config/config.go @@ -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 != "" +} diff --git a/aggregator-agent/internal/display/terminal.go b/aggregator-agent/internal/display/terminal.go new file mode 100644 index 0000000..9154d55 --- /dev/null +++ b/aggregator-agent/internal/display/terminal.go @@ -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 +} \ No newline at end of file diff --git a/aggregator-agent/internal/scanner/apt.go b/aggregator-agent/internal/scanner/apt.go new file mode 100644 index 0000000..c0ee5ae --- /dev/null +++ b/aggregator-agent/internal/scanner/apt.go @@ -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 +} diff --git a/aggregator-agent/internal/scanner/docker.go b/aggregator-agent/internal/scanner/docker.go new file mode 100644 index 0000000..151528c --- /dev/null +++ b/aggregator-agent/internal/scanner/docker.go @@ -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 +} diff --git a/aggregator-agent/internal/scanner/registry.go b/aggregator-agent/internal/scanner/registry.go new file mode 100644 index 0000000..a922b97 --- /dev/null +++ b/aggregator-agent/internal/scanner/registry.go @@ -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) + } + } +} diff --git a/aggregator-server/.env.example b/aggregator-server/.env.example new file mode 100644 index 0000000..01011a6 --- /dev/null +++ b/aggregator-server/.env.example @@ -0,0 +1,12 @@ +# Server Configuration +SERVER_PORT=8080 + +# Database Configuration +DATABASE_URL=postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable + +# JWT Secret (CHANGE IN PRODUCTION!) +JWT_SECRET=change-me-in-production-use-long-random-string + +# Agent Configuration +CHECK_IN_INTERVAL=300 # seconds +OFFLINE_THRESHOLD=600 # seconds before marking agent offline diff --git a/aggregator-server/cmd/server/main.go b/aggregator-server/cmd/server/main.go new file mode 100644 index 0000000..c2c3cc7 --- /dev/null +++ b/aggregator-server/cmd/server/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "log" + "path/filepath" + + "github.com/aggregator-project/aggregator-server/internal/api/handlers" + "github.com/aggregator-project/aggregator-server/internal/api/middleware" + "github.com/aggregator-project/aggregator-server/internal/config" + "github.com/aggregator-project/aggregator-server/internal/database" + "github.com/aggregator-project/aggregator-server/internal/database/queries" + "github.com/gin-gonic/gin" +) + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatal("Failed to load configuration:", err) + } + + // Set JWT secret + middleware.JWTSecret = cfg.JWTSecret + + // Connect to database + db, err := database.Connect(cfg.DatabaseURL) + if err != nil { + log.Fatal("Failed to connect to database:", err) + } + defer db.Close() + + // Run migrations + migrationsPath := filepath.Join("internal", "database", "migrations") + if err := db.Migrate(migrationsPath); err != nil { + log.Fatal("Failed to run migrations:", err) + } + + // Initialize queries + agentQueries := queries.NewAgentQueries(db.DB) + updateQueries := queries.NewUpdateQueries(db.DB) + commandQueries := queries.NewCommandQueries(db.DB) + + // Initialize handlers + agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, cfg.CheckInInterval) + updateHandler := handlers.NewUpdateHandler(updateQueries) + + // Setup router + router := gin.Default() + + // Health check + router.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "healthy"}) + }) + + // API routes + api := router.Group("/api/v1") + { + // Public routes + api.POST("/agents/register", agentHandler.RegisterAgent) + + // Protected agent routes + agents := api.Group("/agents") + agents.Use(middleware.AuthMiddleware()) + { + agents.GET("/:id/commands", agentHandler.GetCommands) + agents.POST("/:id/updates", updateHandler.ReportUpdates) + agents.POST("/:id/logs", updateHandler.ReportLog) + } + + // Dashboard/Web routes (will add proper auth later) + api.GET("/agents", agentHandler.ListAgents) + api.GET("/agents/:id", agentHandler.GetAgent) + api.POST("/agents/:id/scan", agentHandler.TriggerScan) + api.GET("/updates", updateHandler.ListUpdates) + api.GET("/updates/:id", updateHandler.GetUpdate) + api.POST("/updates/:id/approve", updateHandler.ApproveUpdate) + } + + // Start server + addr := ":" + cfg.ServerPort + fmt.Printf("\n🚩 RedFlag Aggregator Server starting on %s\n\n", addr) + if err := router.Run(addr); err != nil { + log.Fatal("Failed to start server:", err) + } +} diff --git a/aggregator-server/go.mod b/aggregator-server/go.mod new file mode 100644 index 0000000..38e377c --- /dev/null +++ b/aggregator-server/go.mod @@ -0,0 +1,46 @@ +module github.com/aggregator-project/aggregator-server + +go 1.25 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 + github.com/jmoiron/sqlx v1.4.0 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) diff --git a/aggregator-server/go.sum b/aggregator-server/go.sum new file mode 100644 index 0000000..4560e26 --- /dev/null +++ b/aggregator-server/go.sum @@ -0,0 +1,104 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/aggregator-server/internal/api/handlers/agents.go b/aggregator-server/internal/api/handlers/agents.go new file mode 100644 index 0000000..c7dd41a --- /dev/null +++ b/aggregator-server/internal/api/handlers/agents.go @@ -0,0 +1,178 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/aggregator-project/aggregator-server/internal/api/middleware" + "github.com/aggregator-project/aggregator-server/internal/database/queries" + "github.com/aggregator-project/aggregator-server/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type AgentHandler struct { + agentQueries *queries.AgentQueries + commandQueries *queries.CommandQueries + checkInInterval int +} + +func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, checkInInterval int) *AgentHandler { + return &AgentHandler{ + agentQueries: aq, + commandQueries: cq, + checkInInterval: checkInInterval, + } +} + +// RegisterAgent handles agent registration +func (h *AgentHandler) RegisterAgent(c *gin.Context) { + var req models.AgentRegistrationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Create new agent + agent := &models.Agent{ + ID: uuid.New(), + Hostname: req.Hostname, + OSType: req.OSType, + OSVersion: req.OSVersion, + OSArchitecture: req.OSArchitecture, + AgentVersion: req.AgentVersion, + LastSeen: time.Now(), + Status: "online", + Metadata: models.JSONB{}, + } + + // Add metadata if provided + if req.Metadata != nil { + for k, v := range req.Metadata { + agent.Metadata[k] = v + } + } + + // Save to database + if err := h.agentQueries.CreateAgent(agent); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register agent"}) + return + } + + // Generate JWT token + token, err := middleware.GenerateAgentToken(agent.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"}) + return + } + + // Return response + response := models.AgentRegistrationResponse{ + AgentID: agent.ID, + Token: token, + Config: map[string]interface{}{ + "check_in_interval": h.checkInInterval, + "server_url": c.Request.Host, + }, + } + + c.JSON(http.StatusOK, response) +} + +// GetCommands returns pending commands for an agent +func (h *AgentHandler) GetCommands(c *gin.Context) { + agentID := c.MustGet("agent_id").(uuid.UUID) + + // Update last_seen + if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"}) + return + } + + // Get pending commands + commands, err := h.commandQueries.GetPendingCommands(agentID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve commands"}) + return + } + + // Convert to response format + commandItems := make([]models.CommandItem, 0, len(commands)) + for _, cmd := range commands { + commandItems = append(commandItems, models.CommandItem{ + ID: cmd.ID.String(), + Type: cmd.CommandType, + Params: cmd.Params, + }) + + // Mark as sent + h.commandQueries.MarkCommandSent(cmd.ID) + } + + response := models.CommandsResponse{ + Commands: commandItems, + } + + c.JSON(http.StatusOK, response) +} + +// ListAgents returns all agents +func (h *AgentHandler) ListAgents(c *gin.Context) { + status := c.Query("status") + osType := c.Query("os_type") + + agents, err := h.agentQueries.ListAgents(status, osType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agents"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "agents": agents, + "total": len(agents), + }) +} + +// GetAgent returns a single agent by ID +func (h *AgentHandler) GetAgent(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"}) + return + } + + agent, err := h.agentQueries.GetAgentByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + + c.JSON(http.StatusOK, agent) +} + +// TriggerScan creates a scan command for an agent +func (h *AgentHandler) TriggerScan(c *gin.Context) { + idStr := c.Param("id") + agentID, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"}) + return + } + + // Create scan command + cmd := &models.AgentCommand{ + ID: uuid.New(), + AgentID: agentID, + CommandType: models.CommandTypeScanUpdates, + Params: models.JSONB{}, + Status: models.CommandStatusPending, + } + + if err := h.commandQueries.CreateCommand(cmd); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create command"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "scan triggered", "command_id": cmd.ID}) +} diff --git a/aggregator-server/internal/api/handlers/updates.go b/aggregator-server/internal/api/handlers/updates.go new file mode 100644 index 0000000..7a47c70 --- /dev/null +++ b/aggregator-server/internal/api/handlers/updates.go @@ -0,0 +1,163 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/aggregator-project/aggregator-server/internal/database/queries" + "github.com/aggregator-project/aggregator-server/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type UpdateHandler struct { + updateQueries *queries.UpdateQueries +} + +func NewUpdateHandler(uq *queries.UpdateQueries) *UpdateHandler { + return &UpdateHandler{updateQueries: uq} +} + +// ReportUpdates handles update reports from agents +func (h *UpdateHandler) ReportUpdates(c *gin.Context) { + agentID := c.MustGet("agent_id").(uuid.UUID) + + var req models.UpdateReportRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Process each update + for _, item := range req.Updates { + update := &models.UpdatePackage{ + ID: uuid.New(), + AgentID: agentID, + PackageType: item.PackageType, + PackageName: item.PackageName, + PackageDescription: item.PackageDescription, + CurrentVersion: item.CurrentVersion, + AvailableVersion: item.AvailableVersion, + Severity: item.Severity, + CVEList: models.StringArray(item.CVEList), + KBID: item.KBID, + RepositorySource: item.RepositorySource, + SizeBytes: item.SizeBytes, + Status: "pending", + Metadata: item.Metadata, + } + + if err := h.updateQueries.UpsertUpdate(update); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save update"}) + return + } + } + + c.JSON(http.StatusOK, gin.H{ + "message": "updates recorded", + "count": len(req.Updates), + }) +} + +// ListUpdates retrieves updates with filtering +func (h *UpdateHandler) ListUpdates(c *gin.Context) { + filters := &models.UpdateFilters{ + Status: c.Query("status"), + Severity: c.Query("severity"), + PackageType: c.Query("package_type"), + } + + // Parse agent_id if provided + if agentIDStr := c.Query("agent_id"); agentIDStr != "" { + agentID, err := uuid.Parse(agentIDStr) + if err == nil { + filters.AgentID = &agentID + } + } + + // Parse pagination + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + filters.Page = page + filters.PageSize = pageSize + + updates, total, err := h.updateQueries.ListUpdates(filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list updates"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "updates": updates, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// GetUpdate retrieves a single update by ID +func (h *UpdateHandler) GetUpdate(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"}) + return + } + + update, err := h.updateQueries.GetUpdateByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "update not found"}) + return + } + + c.JSON(http.StatusOK, update) +} + +// ApproveUpdate marks an update as approved +func (h *UpdateHandler) ApproveUpdate(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"}) + return + } + + // For now, use "admin" as approver. Will integrate with proper auth later + if err := h.updateQueries.ApproveUpdate(id, "admin"); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to approve update"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "update approved"}) +} + +// ReportLog handles update execution logs from agents +func (h *UpdateHandler) ReportLog(c *gin.Context) { + agentID := c.MustGet("agent_id").(uuid.UUID) + + var req models.UpdateLogRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + log := &models.UpdateLog{ + ID: uuid.New(), + AgentID: agentID, + Action: req.Action, + Result: req.Result, + Stdout: req.Stdout, + Stderr: req.Stderr, + ExitCode: req.ExitCode, + DurationSeconds: req.DurationSeconds, + ExecutedAt: time.Now(), + } + + if err := h.updateQueries.CreateUpdateLog(log); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save log"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "log recorded"}) +} diff --git a/aggregator-server/internal/api/middleware/auth.go b/aggregator-server/internal/api/middleware/auth.go new file mode 100644 index 0000000..c811c5d --- /dev/null +++ b/aggregator-server/internal/api/middleware/auth.go @@ -0,0 +1,71 @@ +package middleware + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// AgentClaims represents JWT claims for agent authentication +type AgentClaims struct { + AgentID uuid.UUID `json:"agent_id"` + jwt.RegisteredClaims +} + +// JWTSecret is set by the server at initialization +var JWTSecret string + +// GenerateAgentToken creates a new JWT token for an agent +func GenerateAgentToken(agentID uuid.UUID) (string, error) { + claims := AgentClaims{ + AgentID: agentID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(JWTSecret)) +} + +// AuthMiddleware validates JWT tokens from agents +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"}) + c.Abort() + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization format"}) + c.Abort() + return + } + + token, err := jwt.ParseWithClaims(tokenString, &AgentClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(JWTSecret), nil + }) + + if err != nil || !token.Valid { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + c.Abort() + return + } + + if claims, ok := token.Claims.(*AgentClaims); ok { + c.Set("agent_id", claims.AgentID) + c.Next() + } else { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"}) + c.Abort() + } + } +} diff --git a/aggregator-server/internal/config/config.go b/aggregator-server/internal/config/config.go new file mode 100644 index 0000000..9c55ec5 --- /dev/null +++ b/aggregator-server/internal/config/config.go @@ -0,0 +1,41 @@ +package config + +import ( + "os" + "strconv" + + "github.com/joho/godotenv" +) + +// Config holds the application configuration +type Config struct { + ServerPort string + DatabaseURL string + JWTSecret string + CheckInInterval int + OfflineThreshold int +} + +// Load reads configuration from environment variables +func Load() (*Config, error) { + // Load .env file if it exists (for development) + _ = godotenv.Load() + + checkInInterval, _ := strconv.Atoi(getEnv("CHECK_IN_INTERVAL", "300")) + offlineThreshold, _ := strconv.Atoi(getEnv("OFFLINE_THRESHOLD", "600")) + + return &Config{ + ServerPort: getEnv("SERVER_PORT", "8080"), + DatabaseURL: getEnv("DATABASE_URL", "postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable"), + JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"), + CheckInInterval: checkInInterval, + OfflineThreshold: offlineThreshold, + }, nil +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/aggregator-server/internal/database/db.go b/aggregator-server/internal/database/db.go new file mode 100644 index 0000000..bb8585c --- /dev/null +++ b/aggregator-server/internal/database/db.go @@ -0,0 +1,76 @@ +package database + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" +) + +// DB wraps the database connection +type DB struct { + *sqlx.DB +} + +// Connect establishes a connection to the PostgreSQL database +func Connect(databaseURL string) (*DB, error) { + db, err := sqlx.Connect("postgres", databaseURL) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + // Configure connection pool + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + + // Test the connection + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return &DB{db}, nil +} + +// Migrate runs database migrations +func (db *DB) Migrate(migrationsPath string) error { + // Read migration files + files, err := os.ReadDir(migrationsPath) + if err != nil { + return fmt.Errorf("failed to read migrations directory: %w", err) + } + + // Filter and sort .up.sql files + var migrationFiles []string + for _, file := range files { + if strings.HasSuffix(file.Name(), ".up.sql") { + migrationFiles = append(migrationFiles, file.Name()) + } + } + sort.Strings(migrationFiles) + + // Execute migrations + for _, filename := range migrationFiles { + path := filepath.Join(migrationsPath, filename) + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read migration %s: %w", filename, err) + } + + if _, err := db.Exec(string(content)); err != nil { + return fmt.Errorf("failed to execute migration %s: %w", filename, err) + } + + fmt.Printf("āœ“ Executed migration: %s\n", filename) + } + + return nil +} + +// Close closes the database connection +func (db *DB) Close() error { + return db.DB.Close() +} diff --git a/aggregator-server/internal/database/migrations/001_initial_schema.down.sql b/aggregator-server/internal/database/migrations/001_initial_schema.down.sql new file mode 100644 index 0000000..8bae21a --- /dev/null +++ b/aggregator-server/internal/database/migrations/001_initial_schema.down.sql @@ -0,0 +1,11 @@ +-- Drop tables in reverse order (respecting foreign key constraints) +DROP TABLE IF EXISTS agent_commands; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS agent_tags; +DROP TABLE IF EXISTS update_logs; +DROP TABLE IF EXISTS update_packages; +DROP TABLE IF EXISTS agent_specs; +DROP TABLE IF EXISTS agents; + +-- Drop extension +DROP EXTENSION IF EXISTS "uuid-ossp"; diff --git a/aggregator-server/internal/database/migrations/001_initial_schema.up.sql b/aggregator-server/internal/database/migrations/001_initial_schema.up.sql new file mode 100644 index 0000000..6f398b7 --- /dev/null +++ b/aggregator-server/internal/database/migrations/001_initial_schema.up.sql @@ -0,0 +1,127 @@ +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Agents table +CREATE TABLE agents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + hostname VARCHAR(255) NOT NULL, + os_type VARCHAR(50) NOT NULL CHECK (os_type IN ('windows', 'linux', 'macos')), + os_version VARCHAR(100), + os_architecture VARCHAR(20), + agent_version VARCHAR(20) NOT NULL, + last_seen TIMESTAMP NOT NULL DEFAULT NOW(), + status VARCHAR(20) DEFAULT 'online' CHECK (status IN ('online', 'offline', 'error')), + metadata JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_agents_status ON agents(status); +CREATE INDEX idx_agents_os_type ON agents(os_type); +CREATE INDEX idx_agents_last_seen ON agents(last_seen); + +-- Agent specs +CREATE TABLE agent_specs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + agent_id UUID REFERENCES agents(id) ON DELETE CASCADE, + cpu_model VARCHAR(255), + cpu_cores INTEGER, + memory_total_mb INTEGER, + disk_total_gb INTEGER, + disk_free_gb INTEGER, + network_interfaces JSONB, + docker_installed BOOLEAN DEFAULT false, + docker_version VARCHAR(50), + package_managers TEXT[], + collected_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_agent_specs_agent_id ON agent_specs(agent_id); + +-- Update packages +CREATE TABLE update_packages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + agent_id UUID REFERENCES agents(id) ON DELETE CASCADE, + package_type VARCHAR(50) NOT NULL, + package_name VARCHAR(500) NOT NULL, + package_description TEXT, + current_version VARCHAR(100), + available_version VARCHAR(100) NOT NULL, + severity VARCHAR(20) CHECK (severity IN ('critical', 'important', 'moderate', 'low', 'none')), + cve_list TEXT[], + kb_id VARCHAR(50), + repository_source VARCHAR(255), + size_bytes BIGINT, + status VARCHAR(30) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'scheduled', 'installing', 'installed', 'failed', 'ignored')), + discovered_at TIMESTAMP DEFAULT NOW(), + approved_by VARCHAR(255), + approved_at TIMESTAMP, + scheduled_for TIMESTAMP, + installed_at TIMESTAMP, + error_message TEXT, + metadata JSONB, + UNIQUE(agent_id, package_type, package_name, available_version) +); + +CREATE INDEX idx_updates_status ON update_packages(status); +CREATE INDEX idx_updates_agent ON update_packages(agent_id); +CREATE INDEX idx_updates_severity ON update_packages(severity); +CREATE INDEX idx_updates_package_type ON update_packages(package_type); +CREATE INDEX idx_updates_composite ON update_packages(status, severity, agent_id); + +-- Update logs +CREATE TABLE update_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + agent_id UUID REFERENCES agents(id) ON DELETE CASCADE, + update_package_id UUID REFERENCES update_packages(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + result VARCHAR(20) NOT NULL CHECK (result IN ('success', 'failed', 'partial')), + stdout TEXT, + stderr TEXT, + exit_code INTEGER, + duration_seconds INTEGER, + executed_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_logs_agent ON update_logs(agent_id); +CREATE INDEX idx_logs_result ON update_logs(result); +CREATE INDEX idx_logs_executed_at ON update_logs(executed_at DESC); + +-- Agent tags +CREATE TABLE agent_tags ( + agent_id UUID REFERENCES agents(id) ON DELETE CASCADE, + tag VARCHAR(100) NOT NULL, + PRIMARY KEY (agent_id, tag) +); + +CREATE INDEX idx_agent_tags_tag ON agent_tags(tag); + +-- Users (for authentication) +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + username VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'user' CHECK (role IN ('admin', 'user', 'readonly')), + created_at TIMESTAMP DEFAULT NOW(), + last_login TIMESTAMP +); + +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_email ON users(email); + +-- Commands queue (for agent orchestration) +CREATE TABLE agent_commands ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + agent_id UUID REFERENCES agents(id) ON DELETE CASCADE, + command_type VARCHAR(50) NOT NULL, + params JSONB, + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'completed', 'failed')), + created_at TIMESTAMP DEFAULT NOW(), + sent_at TIMESTAMP, + completed_at TIMESTAMP, + result JSONB +); + +CREATE INDEX idx_commands_agent_status ON agent_commands(agent_id, status); +CREATE INDEX idx_commands_created_at ON agent_commands(created_at DESC); diff --git a/aggregator-server/internal/database/queries/agents.go b/aggregator-server/internal/database/queries/agents.go new file mode 100644 index 0000000..25cdc8c --- /dev/null +++ b/aggregator-server/internal/database/queries/agents.go @@ -0,0 +1,83 @@ +package queries + +import ( + "time" + + "github.com/aggregator-project/aggregator-server/internal/models" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type AgentQueries struct { + db *sqlx.DB +} + +func NewAgentQueries(db *sqlx.DB) *AgentQueries { + return &AgentQueries{db: db} +} + +// CreateAgent inserts a new agent into the database +func (q *AgentQueries) CreateAgent(agent *models.Agent) error { + query := ` + INSERT INTO agents ( + id, hostname, os_type, os_version, os_architecture, + agent_version, last_seen, status, metadata + ) VALUES ( + :id, :hostname, :os_type, :os_version, :os_architecture, + :agent_version, :last_seen, :status, :metadata + ) + ` + _, err := q.db.NamedExec(query, agent) + return err +} + +// GetAgentByID retrieves an agent by ID +func (q *AgentQueries) GetAgentByID(id uuid.UUID) (*models.Agent, error) { + var agent models.Agent + query := `SELECT * FROM agents WHERE id = $1` + err := q.db.Get(&agent, query, id) + if err != nil { + return nil, err + } + return &agent, nil +} + +// UpdateAgentLastSeen updates the agent's last_seen timestamp +func (q *AgentQueries) UpdateAgentLastSeen(id uuid.UUID) error { + query := `UPDATE agents SET last_seen = $1, status = 'online' WHERE id = $2` + _, err := q.db.Exec(query, time.Now(), id) + return err +} + +// ListAgents returns all agents with optional filtering +func (q *AgentQueries) ListAgents(status, osType string) ([]models.Agent, error) { + var agents []models.Agent + query := `SELECT * FROM agents WHERE 1=1` + args := []interface{}{} + argIdx := 1 + + if status != "" { + query += ` AND status = $` + string(rune(argIdx+'0')) + args = append(args, status) + argIdx++ + } + if osType != "" { + query += ` AND os_type = $` + string(rune(argIdx+'0')) + args = append(args, osType) + } + + query += ` ORDER BY last_seen DESC` + err := q.db.Select(&agents, query, args...) + return agents, err +} + +// MarkOfflineAgents marks agents as offline if they haven't checked in recently +func (q *AgentQueries) MarkOfflineAgents(threshold time.Duration) error { + query := ` + UPDATE agents + SET status = 'offline' + WHERE last_seen < $1 AND status = 'online' + ` + _, err := q.db.Exec(query, time.Now().Add(-threshold)) + return err +} diff --git a/aggregator-server/internal/database/queries/commands.go b/aggregator-server/internal/database/queries/commands.go new file mode 100644 index 0000000..899485c --- /dev/null +++ b/aggregator-server/internal/database/queries/commands.go @@ -0,0 +1,79 @@ +package queries + +import ( + "time" + + "github.com/aggregator-project/aggregator-server/internal/models" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type CommandQueries struct { + db *sqlx.DB +} + +func NewCommandQueries(db *sqlx.DB) *CommandQueries { + return &CommandQueries{db: db} +} + +// CreateCommand inserts a new command for an agent +func (q *CommandQueries) CreateCommand(cmd *models.AgentCommand) error { + query := ` + INSERT INTO agent_commands ( + id, agent_id, command_type, params, status + ) VALUES ( + :id, :agent_id, :command_type, :params, :status + ) + ` + _, err := q.db.NamedExec(query, cmd) + return err +} + +// GetPendingCommands retrieves pending commands for an agent +func (q *CommandQueries) GetPendingCommands(agentID uuid.UUID) ([]models.AgentCommand, error) { + var commands []models.AgentCommand + query := ` + SELECT * FROM agent_commands + WHERE agent_id = $1 AND status = 'pending' + ORDER BY created_at ASC + LIMIT 10 + ` + err := q.db.Select(&commands, query, agentID) + return commands, err +} + +// MarkCommandSent updates a command's status to sent +func (q *CommandQueries) MarkCommandSent(id uuid.UUID) error { + now := time.Now() + query := ` + UPDATE agent_commands + SET status = 'sent', sent_at = $1 + WHERE id = $2 + ` + _, err := q.db.Exec(query, now, id) + return err +} + +// MarkCommandCompleted updates a command's status to completed +func (q *CommandQueries) MarkCommandCompleted(id uuid.UUID, result models.JSONB) error { + now := time.Now() + query := ` + UPDATE agent_commands + SET status = 'completed', completed_at = $1, result = $2 + WHERE id = $3 + ` + _, err := q.db.Exec(query, now, result, id) + return err +} + +// MarkCommandFailed updates a command's status to failed +func (q *CommandQueries) MarkCommandFailed(id uuid.UUID, result models.JSONB) error { + now := time.Now() + query := ` + UPDATE agent_commands + SET status = 'failed', completed_at = $1, result = $2 + WHERE id = $3 + ` + _, err := q.db.Exec(query, now, result, id) + return err +} diff --git a/aggregator-server/internal/database/queries/updates.go b/aggregator-server/internal/database/queries/updates.go new file mode 100644 index 0000000..f56d65e --- /dev/null +++ b/aggregator-server/internal/database/queries/updates.go @@ -0,0 +1,141 @@ +package queries + +import ( + "fmt" + "strings" + + "github.com/aggregator-project/aggregator-server/internal/models" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type UpdateQueries struct { + db *sqlx.DB +} + +func NewUpdateQueries(db *sqlx.DB) *UpdateQueries { + return &UpdateQueries{db: db} +} + +// UpsertUpdate inserts or updates an update package +func (q *UpdateQueries) UpsertUpdate(update *models.UpdatePackage) error { + query := ` + INSERT INTO update_packages ( + id, agent_id, package_type, package_name, package_description, + current_version, available_version, severity, cve_list, kb_id, + repository_source, size_bytes, status, metadata + ) VALUES ( + :id, :agent_id, :package_type, :package_name, :package_description, + :current_version, :available_version, :severity, :cve_list, :kb_id, + :repository_source, :size_bytes, :status, :metadata + ) + ON CONFLICT (agent_id, package_type, package_name, available_version) + DO UPDATE SET + package_description = EXCLUDED.package_description, + current_version = EXCLUDED.current_version, + severity = EXCLUDED.severity, + cve_list = EXCLUDED.cve_list, + kb_id = EXCLUDED.kb_id, + repository_source = EXCLUDED.repository_source, + size_bytes = EXCLUDED.size_bytes, + metadata = EXCLUDED.metadata, + discovered_at = NOW() + ` + _, err := q.db.NamedExec(query, update) + return err +} + +// ListUpdates retrieves updates with filtering +func (q *UpdateQueries) ListUpdates(filters *models.UpdateFilters) ([]models.UpdatePackage, int, error) { + var updates []models.UpdatePackage + whereClause := []string{"1=1"} + args := []interface{}{} + argIdx := 1 + + if filters.AgentID != nil { + whereClause = append(whereClause, fmt.Sprintf("agent_id = $%d", argIdx)) + args = append(args, *filters.AgentID) + argIdx++ + } + if filters.Status != "" { + whereClause = append(whereClause, fmt.Sprintf("status = $%d", argIdx)) + args = append(args, filters.Status) + argIdx++ + } + if filters.Severity != "" { + whereClause = append(whereClause, fmt.Sprintf("severity = $%d", argIdx)) + args = append(args, filters.Severity) + argIdx++ + } + if filters.PackageType != "" { + whereClause = append(whereClause, fmt.Sprintf("package_type = $%d", argIdx)) + args = append(args, filters.PackageType) + argIdx++ + } + + // Get total count + countQuery := "SELECT COUNT(*) FROM update_packages WHERE " + strings.Join(whereClause, " AND ") + var total int + err := q.db.Get(&total, countQuery, args...) + if err != nil { + return nil, 0, err + } + + // Get paginated results + query := fmt.Sprintf(` + SELECT * FROM update_packages + WHERE %s + ORDER BY discovered_at DESC + LIMIT $%d OFFSET $%d + `, strings.Join(whereClause, " AND "), argIdx, argIdx+1) + + limit := filters.PageSize + if limit == 0 { + limit = 50 + } + offset := (filters.Page - 1) * limit + if offset < 0 { + offset = 0 + } + + args = append(args, limit, offset) + err = q.db.Select(&updates, query, args...) + return updates, total, err +} + +// GetUpdateByID retrieves a single update by ID +func (q *UpdateQueries) GetUpdateByID(id uuid.UUID) (*models.UpdatePackage, error) { + var update models.UpdatePackage + query := `SELECT * FROM update_packages WHERE id = $1` + err := q.db.Get(&update, query, id) + if err != nil { + return nil, err + } + return &update, nil +} + +// ApproveUpdate marks an update as approved +func (q *UpdateQueries) ApproveUpdate(id uuid.UUID, approvedBy string) error { + query := ` + UPDATE update_packages + SET status = 'approved', approved_by = $1, approved_at = NOW() + WHERE id = $2 AND status = 'pending' + ` + _, err := q.db.Exec(query, approvedBy, id) + return err +} + +// CreateUpdateLog inserts an update log entry +func (q *UpdateQueries) CreateUpdateLog(log *models.UpdateLog) error { + query := ` + INSERT INTO update_logs ( + id, agent_id, update_package_id, action, result, + stdout, stderr, exit_code, duration_seconds + ) VALUES ( + :id, :agent_id, :update_package_id, :action, :result, + :stdout, :stderr, :exit_code, :duration_seconds + ) + ` + _, err := q.db.NamedExec(query, log) + return err +} diff --git a/aggregator-server/internal/models/agent.go b/aggregator-server/internal/models/agent.go new file mode 100644 index 0000000..e2ac25e --- /dev/null +++ b/aggregator-server/internal/models/agent.go @@ -0,0 +1,105 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// Agent represents a registered update agent +type Agent struct { + ID uuid.UUID `json:"id" db:"id"` + Hostname string `json:"hostname" db:"hostname"` + OSType string `json:"os_type" db:"os_type"` + OSVersion string `json:"os_version" db:"os_version"` + OSArchitecture string `json:"os_architecture" db:"os_architecture"` + AgentVersion string `json:"agent_version" db:"agent_version"` + LastSeen time.Time `json:"last_seen" db:"last_seen"` + Status string `json:"status" db:"status"` + Metadata JSONB `json:"metadata" db:"metadata"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// AgentSpecs represents system specifications for an agent +type AgentSpecs struct { + ID uuid.UUID `json:"id" db:"id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + CPUModel string `json:"cpu_model" db:"cpu_model"` + CPUCores int `json:"cpu_cores" db:"cpu_cores"` + MemoryTotalMB int `json:"memory_total_mb" db:"memory_total_mb"` + DiskTotalGB int `json:"disk_total_gb" db:"disk_total_gb"` + DiskFreeGB int `json:"disk_free_gb" db:"disk_free_gb"` + NetworkInterfaces JSONB `json:"network_interfaces" db:"network_interfaces"` + DockerInstalled bool `json:"docker_installed" db:"docker_installed"` + DockerVersion string `json:"docker_version" db:"docker_version"` + PackageManagers StringArray `json:"package_managers" db:"package_managers"` + CollectedAt time.Time `json:"collected_at" db:"collected_at"` +} + +// AgentRegistrationRequest is the payload for agent registration +type AgentRegistrationRequest struct { + Hostname string `json:"hostname" binding:"required"` + OSType string `json:"os_type" binding:"required"` + OSVersion string `json:"os_version"` + OSArchitecture string `json:"os_architecture"` + AgentVersion string `json:"agent_version" binding:"required"` + Metadata map[string]string `json:"metadata"` +} + +// AgentRegistrationResponse is returned after successful registration +type AgentRegistrationResponse struct { + AgentID uuid.UUID `json:"agent_id"` + Token string `json:"token"` + Config map[string]interface{} `json:"config"` +} + +// JSONB type for PostgreSQL JSONB columns +type JSONB map[string]interface{} + +// Value implements driver.Valuer for database storage +func (j JSONB) Value() (driver.Value, error) { + if j == nil { + return nil, nil + } + return json.Marshal(j) +} + +// Scan implements sql.Scanner for database retrieval +func (j *JSONB) Scan(value interface{}) error { + if value == nil { + *j = nil + return nil + } + bytes, ok := value.([]byte) + if !ok { + return nil + } + return json.Unmarshal(bytes, j) +} + +// StringArray type for PostgreSQL text[] columns +type StringArray []string + +// Value implements driver.Valuer +func (s StringArray) Value() (driver.Value, error) { + if s == nil { + return nil, nil + } + return json.Marshal(s) +} + +// Scan implements sql.Scanner +func (s *StringArray) Scan(value interface{}) error { + if value == nil { + *s = nil + return nil + } + bytes, ok := value.([]byte) + if !ok { + return nil + } + return json.Unmarshal(bytes, s) +} diff --git a/aggregator-server/internal/models/command.go b/aggregator-server/internal/models/command.go new file mode 100644 index 0000000..0b06ff4 --- /dev/null +++ b/aggregator-server/internal/models/command.go @@ -0,0 +1,49 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// AgentCommand represents a command to be executed by an agent +type AgentCommand struct { + ID uuid.UUID `json:"id" db:"id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + CommandType string `json:"command_type" db:"command_type"` + Params JSONB `json:"params" db:"params"` + Status string `json:"status" db:"status"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` + CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` + Result JSONB `json:"result,omitempty" db:"result"` +} + +// CommandsResponse is returned when an agent checks in for commands +type CommandsResponse struct { + Commands []CommandItem `json:"commands"` +} + +// CommandItem represents a command in the response +type CommandItem struct { + ID string `json:"id"` + Type string `json:"type"` + Params JSONB `json:"params"` +} + +// Command types +const ( + CommandTypeScanUpdates = "scan_updates" + CommandTypeCollectSpecs = "collect_specs" + CommandTypeInstallUpdate = "install_updates" + CommandTypeRollback = "rollback_update" + CommandTypeUpdateAgent = "update_agent" +) + +// Command statuses +const ( + CommandStatusPending = "pending" + CommandStatusSent = "sent" + CommandStatusCompleted = "completed" + CommandStatusFailed = "failed" +) diff --git a/aggregator-server/internal/models/update.go b/aggregator-server/internal/models/update.go new file mode 100644 index 0000000..3f5cb7e --- /dev/null +++ b/aggregator-server/internal/models/update.go @@ -0,0 +1,88 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// UpdatePackage represents a single update available for installation +type UpdatePackage struct { + ID uuid.UUID `json:"id" db:"id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + PackageType string `json:"package_type" db:"package_type"` + PackageName string `json:"package_name" db:"package_name"` + PackageDescription string `json:"package_description" db:"package_description"` + CurrentVersion string `json:"current_version" db:"current_version"` + AvailableVersion string `json:"available_version" db:"available_version"` + Severity string `json:"severity" db:"severity"` + CVEList StringArray `json:"cve_list" db:"cve_list"` + KBID string `json:"kb_id" db:"kb_id"` + RepositorySource string `json:"repository_source" db:"repository_source"` + SizeBytes int64 `json:"size_bytes" db:"size_bytes"` + Status string `json:"status" db:"status"` + DiscoveredAt time.Time `json:"discovered_at" db:"discovered_at"` + ApprovedBy string `json:"approved_by,omitempty" db:"approved_by"` + ApprovedAt *time.Time `json:"approved_at,omitempty" db:"approved_at"` + ScheduledFor *time.Time `json:"scheduled_for,omitempty" db:"scheduled_for"` + InstalledAt *time.Time `json:"installed_at,omitempty" db:"installed_at"` + ErrorMessage string `json:"error_message,omitempty" db:"error_message"` + Metadata JSONB `json:"metadata" db:"metadata"` +} + +// UpdateReportRequest is sent by agents when reporting discovered updates +type UpdateReportRequest struct { + CommandID string `json:"command_id"` + Timestamp time.Time `json:"timestamp"` + Updates []UpdateReportItem `json:"updates"` +} + +// UpdateReportItem represents a single update discovered by an agent +type UpdateReportItem struct { + PackageType string `json:"package_type" binding:"required"` + PackageName string `json:"package_name" binding:"required"` + PackageDescription string `json:"package_description"` + CurrentVersion string `json:"current_version"` + AvailableVersion string `json:"available_version" binding:"required"` + 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 JSONB `json:"metadata"` +} + +// UpdateLog represents an execution log entry +type UpdateLog struct { + ID uuid.UUID `json:"id" db:"id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + UpdatePackageID *uuid.UUID `json:"update_package_id,omitempty" db:"update_package_id"` + Action string `json:"action" db:"action"` + Result string `json:"result" db:"result"` + Stdout string `json:"stdout" db:"stdout"` + Stderr string `json:"stderr" db:"stderr"` + ExitCode int `json:"exit_code" db:"exit_code"` + DurationSeconds int `json:"duration_seconds" db:"duration_seconds"` + ExecutedAt time.Time `json:"executed_at" db:"executed_at"` +} + +// UpdateLogRequest is sent by agents when reporting execution results +type UpdateLogRequest struct { + CommandID string `json:"command_id"` + Action string `json:"action" binding:"required"` + Result string `json:"result" binding:"required"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + DurationSeconds int `json:"duration_seconds"` +} + +// UpdateFilters for querying updates +type UpdateFilters struct { + AgentID *uuid.UUID + Status string + Severity string + PackageType string + Page int + PageSize int +} diff --git a/aggregator-web/.env.example b/aggregator-web/.env.example new file mode 100644 index 0000000..56e5019 --- /dev/null +++ b/aggregator-web/.env.example @@ -0,0 +1,5 @@ +# API Configuration +VITE_API_URL=http://localhost:8080/api/v1 + +# Environment +VITE_NODE_ENV=development \ No newline at end of file diff --git a/aggregator-web/package.json b/aggregator-web/package.json new file mode 100644 index 0000000..f9eb745 --- /dev/null +++ b/aggregator-web/package.json @@ -0,0 +1,40 @@ +{ + "name": "aggregator-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.8.4", + "@tanstack/react-query-devtools": "^5.90.2", + "axios": "^1.6.2", + "clsx": "^2.0.0", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hot-toast": "^2.6.0", + "react-router-dom": "^6.20.1", + "tailwind-merge": "^2.0.0", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "@vitejs/plugin-react": "^4.1.1", + "autoprefixer": "^10.4.16", + "eslint": "^8.53.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.4", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } +} diff --git a/aggregator-web/postcss.config.js b/aggregator-web/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/aggregator-web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/aggregator-web/src/App.tsx b/aggregator-web/src/App.tsx new file mode 100644 index 0000000..1f0c4a5 --- /dev/null +++ b/aggregator-web/src/App.tsx @@ -0,0 +1,112 @@ +import React, { useEffect } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import { Toaster } from 'react-hot-toast'; +import { useAuthStore } from '@/lib/store'; +import { useSettingsStore } from '@/lib/store'; +import Layout from '@/components/Layout'; +import Dashboard from '@/pages/Dashboard'; +import Agents from '@/pages/Agents'; +import Updates from '@/pages/Updates'; +import Logs from '@/pages/Logs'; +import Settings from '@/pages/Settings'; +import Login from '@/pages/Login'; +import NotificationCenter from '@/components/NotificationCenter'; + +// Protected route component +const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { isAuthenticated } = useAuthStore(); + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +}; + +const App: React.FC = () => { + const { isAuthenticated, token } = useAuthStore(); + const { theme } = useSettingsStore(); + + // Apply theme to document + useEffect(() => { + if (theme === 'dark') { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + }, [theme]); + + // Check for existing token on app start + useEffect(() => { + const storedToken = localStorage.getItem('auth_token'); + if (storedToken && !token) { + useAuthStore.getState().setToken(storedToken); + } + }, [token]); + + return ( +
+ {/* Toast notifications */} + + + {/* Notification center */} + {isAuthenticated && } + + {/* App routes */} + + {/* Login route */} + : } + /> + + {/* Protected routes */} + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/aggregator-web/src/components/Layout.tsx b/aggregator-web/src/components/Layout.tsx new file mode 100644 index 0000000..c32d194 --- /dev/null +++ b/aggregator-web/src/components/Layout.tsx @@ -0,0 +1,214 @@ +import React, { useState } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { + LayoutDashboard, + Computer, + Package, + FileText, + Settings, + Menu, + X, + LogOut, + Bell, + Search, + RefreshCw, +} from 'lucide-react'; +import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store'; +import { cn } from '@/lib/utils'; + +interface LayoutProps { + children: React.ReactNode; +} + +const Layout: React.FC = ({ children }) => { + const location = useLocation(); + const navigate = useNavigate(); + const { sidebarOpen, setSidebarOpen, setActiveTab } = useUIStore(); + const { logout } = useAuthStore(); + const { notifications } = useRealtimeStore(); + const [searchQuery, setSearchQuery] = useState(''); + + const unreadCount = notifications.filter(n => !n.read).length; + + const navigation = [ + { + name: 'Dashboard', + href: '/dashboard', + icon: LayoutDashboard, + current: location.pathname === '/' || location.pathname === '/dashboard', + }, + { + name: 'Agents', + href: '/agents', + icon: Computer, + current: location.pathname.startsWith('/agents'), + }, + { + name: 'Updates', + href: '/updates', + icon: Package, + current: location.pathname.startsWith('/updates'), + }, + { + name: 'Logs', + href: '/logs', + icon: FileText, + current: location.pathname === '/logs', + }, + { + name: 'Settings', + href: '/settings', + icon: Settings, + current: location.pathname === '/settings', + }, + ]; + + const handleLogout = () => { + logout(); + localStorage.removeItem('auth_token'); + navigate('/login'); + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (searchQuery.trim()) { + // Navigate to updates page with search query + navigate(`/updates?search=${encodeURIComponent(searchQuery.trim())}`); + setSearchQuery(''); + } + }; + + return ( +
+ {/* Sidebar */} +
+
+
+
+ 🚩 +
+

RedFlag

+
+ +
+ + + + {/* User section */} +
+ +
+
+ + {/* Main content */} +
+ {/* Top header */} +
+
+
+ + + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search updates..." + className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+
+ +
+ {/* Refresh button */} + + + {/* Notifications */} + +
+
+
+ + {/* Page content */} +
+
+ {children} +
+
+
+ + {/* Mobile sidebar overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} + >
+ )} +
+ ); +}; + +export default Layout; \ No newline at end of file diff --git a/aggregator-web/src/components/NotificationCenter.tsx b/aggregator-web/src/components/NotificationCenter.tsx new file mode 100644 index 0000000..d265355 --- /dev/null +++ b/aggregator-web/src/components/NotificationCenter.tsx @@ -0,0 +1,128 @@ +import React, { useState } from 'react'; +import { Bell, X, Check, Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react'; +import { useRealtimeStore } from '@/lib/store'; +import { cn, formatRelativeTime } from '@/lib/utils'; + +const NotificationCenter: React.FC = () => { + const [isOpen, setIsOpen] = useState(false); + const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore(); + + const unreadCount = notifications.filter(n => !n.read).length; + + const getNotificationIcon = (type: string) => { + switch (type) { + case 'success': + return ; + case 'error': + return ; + case 'warning': + return ; + default: + return ; + } + }; + + const getNotificationColor = (type: string) => { + switch (type) { + case 'success': + return 'border-success-200 bg-success-50'; + case 'error': + return 'border-danger-200 bg-danger-50'; + case 'warning': + return 'border-warning-200 bg-warning-50'; + default: + return 'border-blue-200 bg-blue-50'; + } + }; + + return ( +
+ {/* Notification bell */} + + + {/* Notifications dropdown */} + {isOpen && ( +
+ {/* Header */} +
+

Notifications

+
+ {notifications.length > 0 && ( + + )} + +
+
+ + {/* Notifications list */} +
+ {notifications.length === 0 ? ( +
+ +

No notifications

+
+ ) : ( + notifications.map((notification) => ( +
markNotificationRead(notification.id)} + > +
+
+ {getNotificationIcon(notification.type)} +
+
+
+

+ {notification.title} +

+ {!notification.read && ( + + New + + )} +
+

+ {notification.message} +

+

+ {formatRelativeTime(notification.timestamp)} +

+
+
+
+ )) + )} +
+
+ )} +
+ ); +}; + +export default NotificationCenter; \ No newline at end of file diff --git a/aggregator-web/src/hooks/useAgents.ts b/aggregator-web/src/hooks/useAgents.ts new file mode 100644 index 0000000..3e83614 --- /dev/null +++ b/aggregator-web/src/hooks/useAgents.ts @@ -0,0 +1,99 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { agentApi } from '@/lib/api'; +import { Agent, ListQueryParams } from '@/types'; +import { useAgentStore, useRealtimeStore } from '@/lib/store'; +import { handleApiError } from '@/lib/api'; + +export const useAgents = (params?: ListQueryParams) => { + const { setAgents, setLoading, setError, updateAgentStatus } = useAgentStore(); + + return useQuery({ + queryKey: ['agents', params], + queryFn: () => agentApi.getAgents(params), + onSuccess: (data) => { + setAgents(data.agents); + setLoading(false); + setError(null); + }, + onError: (error) => { + setError(handleApiError(error).message); + setLoading(false); + }, + onSettled: () => { + setLoading(false); + }, + }); +}; + +export const useAgent = (id: string, enabled: boolean = true) => { + const { setSelectedAgent, setLoading, setError } = useAgentStore(); + + return useQuery({ + queryKey: ['agent', id], + queryFn: () => agentApi.getAgent(id), + enabled: enabled && !!id, + onSuccess: (data) => { + setSelectedAgent(data); + setLoading(false); + setError(null); + }, + onError: (error) => { + setError(handleApiError(error).message); + setLoading(false); + }, + }); +}; + +export const useScanAgent = () => { + const queryClient = useQueryClient(); + const { addNotification } = useRealtimeStore(); + + return useMutation({ + mutationFn: agentApi.scanAgent, + onSuccess: () => { + // Invalidate agents query to refresh data + queryClient.invalidateQueries({ queryKey: ['agents'] }); + + // Show success notification + addNotification({ + type: 'success', + title: 'Scan Triggered', + message: 'Agent scan has been triggered successfully.', + }); + }, + onError: (error) => { + addNotification({ + type: 'error', + title: 'Scan Failed', + message: handleApiError(error).message, + }); + }, + }); +}; + +export const useScanMultipleAgents = () => { + const queryClient = useQueryClient(); + const { addNotification } = useRealtimeStore(); + + return useMutation({ + mutationFn: agentApi.triggerScan, + onSuccess: () => { + // Invalidate agents query to refresh data + queryClient.invalidateQueries({ queryKey: ['agents'] }); + + // Show success notification + addNotification({ + type: 'success', + title: 'Bulk Scan Triggered', + message: 'Scan has been triggered for selected agents.', + }); + }, + onError: (error) => { + addNotification({ + type: 'error', + title: 'Bulk Scan Failed', + message: handleApiError(error).message, + }); + }, + }); +}; \ No newline at end of file diff --git a/aggregator-web/src/hooks/useStats.ts b/aggregator-web/src/hooks/useStats.ts new file mode 100644 index 0000000..8f0793b --- /dev/null +++ b/aggregator-web/src/hooks/useStats.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { statsApi } from '@/lib/api'; +import { DashboardStats } from '@/types'; +import { handleApiError } from '@/lib/api'; + +export const useDashboardStats = () => { + return useQuery({ + queryKey: ['dashboard-stats'], + queryFn: statsApi.getDashboardStats, + refetchInterval: 30000, // Refresh every 30 seconds + staleTime: 15000, // Consider data stale after 15 seconds + onError: (error) => { + console.error('Failed to fetch dashboard stats:', handleApiError(error)); + }, + }); +}; \ No newline at end of file diff --git a/aggregator-web/src/hooks/useUpdates.ts b/aggregator-web/src/hooks/useUpdates.ts new file mode 100644 index 0000000..fbf570c --- /dev/null +++ b/aggregator-web/src/hooks/useUpdates.ts @@ -0,0 +1,173 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { updateApi } from '@/lib/api'; +import { UpdatePackage, ListQueryParams, UpdateApprovalRequest } from '@/types'; +import { useUpdateStore, useRealtimeStore } from '@/lib/store'; +import { handleApiError } from '@/lib/api'; + +export const useUpdates = (params?: ListQueryParams) => { + const { setUpdates, setLoading, setError } = useUpdateStore(); + + return useQuery({ + queryKey: ['updates', params], + queryFn: () => updateApi.getUpdates(params), + onSuccess: (data) => { + setUpdates(data.updates); + setLoading(false); + setError(null); + }, + onError: (error) => { + setError(handleApiError(error).message); + setLoading(false); + }, + onSettled: () => { + setLoading(false); + }, + }); +}; + +export const useUpdate = (id: string, enabled: boolean = true) => { + const { setSelectedUpdate, setLoading, setError } = useUpdateStore(); + + return useQuery({ + queryKey: ['update', id], + queryFn: () => updateApi.getUpdate(id), + enabled: enabled && !!id, + onSuccess: (data) => { + setSelectedUpdate(data); + setLoading(false); + setError(null); + }, + onError: (error) => { + setError(handleApiError(error).message); + setLoading(false); + }, + }); +}; + +export const useApproveUpdate = () => { + const queryClient = useQueryClient(); + const { updateUpdateStatus } = useUpdateStore(); + const { addNotification } = useRealtimeStore(); + + return useMutation({ + mutationFn: ({ id, scheduledAt }: { id: string; scheduledAt?: string }) => + updateApi.approveUpdate(id, scheduledAt), + onSuccess: (_, { id }) => { + // Update local state + updateUpdateStatus(id, 'approved'); + + // Invalidate queries to refresh data + queryClient.invalidateQueries({ queryKey: ['updates'] }); + queryClient.invalidateQueries({ queryKey: ['update', id] }); + + // Show success notification + addNotification({ + type: 'success', + title: 'Update Approved', + message: 'The update has been approved successfully.', + }); + }, + onError: (error) => { + addNotification({ + type: 'error', + title: 'Approval Failed', + message: handleApiError(error).message, + }); + }, + }); +}; + +export const useApproveMultipleUpdates = () => { + const queryClient = useQueryClient(); + const { bulkUpdateStatus } = useUpdateStore(); + const { addNotification } = useRealtimeStore(); + + return useMutation({ + mutationFn: (request: UpdateApprovalRequest) => updateApi.approveUpdates(request), + onSuccess: (_, request) => { + // Update local state + bulkUpdateStatus(request.update_ids, 'approved'); + + // Invalidate queries to refresh data + queryClient.invalidateQueries({ queryKey: ['updates'] }); + + // Show success notification + addNotification({ + type: 'success', + title: 'Updates Approved', + message: `${request.update_ids.length} update(s) have been approved successfully.`, + }); + }, + onError: (error) => { + addNotification({ + type: 'error', + title: 'Bulk Approval Failed', + message: handleApiError(error).message, + }); + }, + }); +}; + +export const useRejectUpdate = () => { + const queryClient = useQueryClient(); + const { updateUpdateStatus } = useUpdateStore(); + const { addNotification } = useRealtimeStore(); + + return useMutation({ + mutationFn: updateApi.rejectUpdate, + onSuccess: (_, id) => { + // Update local state + updateUpdateStatus(id, 'pending'); + + // Invalidate queries to refresh data + queryClient.invalidateQueries({ queryKey: ['updates'] }); + queryClient.invalidateQueries({ queryKey: ['update', id] }); + + // Show success notification + addNotification({ + type: 'success', + title: 'Update Rejected', + message: 'The update has been rejected and moved back to pending status.', + }); + }, + onError: (error) => { + addNotification({ + type: 'error', + title: 'Rejection Failed', + message: handleApiError(error).message, + }); + }, + }); +}; + +export const useInstallUpdate = () => { + const queryClient = useQueryClient(); + const { updateUpdateStatus } = useUpdateStore(); + const { addNotification } = useRealtimeStore(); + + return useMutation({ + mutationFn: updateApi.installUpdate, + onSuccess: (_, id) => { + // Update local state + updateUpdateStatus(id, 'installing'); + + // Invalidate queries to refresh data + queryClient.invalidateQueries({ queryKey: ['updates'] }); + queryClient.invalidateQueries({ queryKey: ['update', id] }); + + // Show success notification + addNotification({ + type: 'info', + title: 'Installation Started', + message: 'The update installation has been started. This may take a few minutes.', + }); + }, + onError: (error) => { + addNotification({ + type: 'error', + title: 'Installation Failed', + message: handleApiError(error).message, + }); + }, + }); +}; \ No newline at end of file diff --git a/aggregator-web/src/index.css b/aggregator-web/src/index.css new file mode 100644 index 0000000..8bfe3a3 --- /dev/null +++ b/aggregator-web/src/index.css @@ -0,0 +1,114 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-gray-50 text-gray-900 font-sans antialiased; + font-family: 'Inter', sans-serif; + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed; + } + + .btn-primary { + @apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500; + } + + .btn-secondary { + @apply btn bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500; + } + + .btn-success { + @apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500; + } + + .btn-warning { + @apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500; + } + + .btn-danger { + @apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500; + } + + .btn-ghost { + @apply btn bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-500; + } + + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200 p-6; + } + + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } + + .badge-success { + @apply badge bg-success-100 text-success-800; + } + + .badge-warning { + @apply badge bg-warning-100 text-warning-800; + } + + .badge-danger { + @apply badge bg-danger-100 text-danger-800; + } + + .badge-info { + @apply badge bg-blue-100 text-blue-800; + } + + .table { + @apply min-w-full divide-y divide-gray-200; + } + + .table-header { + @apply bg-gray-50 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider; + } + + .table-cell { + @apply px-6 py-4 whitespace-nowrap text-sm text-gray-900; + } + + .table-row { + @apply bg-white hover:bg-gray-50; + } + + .terminal { + @apply bg-gray-900 text-green-400 font-mono text-sm p-4 rounded-lg overflow-x-auto; + } + + .terminal-prompt { + @apply text-blue-400; + } + + .terminal-command { + @apply text-white; + } + + .terminal-output { + @apply text-gray-300; + } + + .hierarchy-tree { + @apply border-l-2 border-gray-200 ml-4 pl-4; + } + + .hierarchy-item { + @apply flex items-center space-x-2 py-1; + } + + .hierarchy-toggle { + @apply w-4 h-4 text-gray-500 hover:text-gray-700 cursor-pointer; + } +} \ No newline at end of file diff --git a/aggregator-web/src/lib/api.ts b/aggregator-web/src/lib/api.ts new file mode 100644 index 0000000..afd68f5 --- /dev/null +++ b/aggregator-web/src/lib/api.ts @@ -0,0 +1,201 @@ +import axios, { AxiosResponse } from 'axios'; +import { + Agent, + UpdatePackage, + DashboardStats, + AgentListResponse, + UpdateListResponse, + UpdateApprovalRequest, + ScanRequest, + ListQueryParams, + ApiResponse, + ApiError +} from '@/types'; + +// Create axios instance +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL || '/api/v1', + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor to add auth token +api.interceptors.request.use((config) => { + const token = localStorage.getItem('auth_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Response interceptor to handle errors +api.interceptors.response.use( + (response: AxiosResponse) => response, + (error) => { + if (error.response?.status === 401) { + // Clear token and redirect to login + localStorage.removeItem('auth_token'); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +// API endpoints +export const agentApi = { + // Get all agents + getAgents: async (params?: ListQueryParams): Promise => { + const response = await api.get('/agents', { params }); + return response.data; + }, + + // Get single agent + getAgent: async (id: string): Promise => { + const response = await api.get(`/agents/${id}`); + return response.data; + }, + + // Trigger scan on agents + triggerScan: async (request: ScanRequest): Promise => { + await api.post('/agents/scan', request); + }, + + // Trigger scan on single agent + scanAgent: async (id: string): Promise => { + await api.post(`/agents/${id}/scan`); + }, +}; + +export const updateApi = { + // Get all updates + getUpdates: async (params?: ListQueryParams): Promise => { + const response = await api.get('/updates', { params }); + return response.data; + }, + + // Get single update + getUpdate: async (id: string): Promise => { + const response = await api.get(`/updates/${id}`); + return response.data; + }, + + // Approve updates + approveUpdates: async (request: UpdateApprovalRequest): Promise => { + await api.post('/updates/approve', request); + }, + + // Approve single update + approveUpdate: async (id: string, scheduledAt?: string): Promise => { + await api.post(`/updates/${id}/approve`, { scheduled_at: scheduledAt }); + }, + + // Reject/cancel update + rejectUpdate: async (id: string): Promise => { + await api.post(`/updates/${id}/reject`); + }, + + // Install update immediately + installUpdate: async (id: string): Promise => { + await api.post(`/updates/${id}/install`); + }, +}; + +export const statsApi = { + // Get dashboard statistics + getDashboardStats: async (): Promise => { + const response = await api.get('/stats/summary'); + return response.data; + }, +}; + +export const authApi = { + // Simple login (using API key or token) + login: async (credentials: { token: string }): Promise<{ token: string }> => { + const response = await api.post('/auth/login', credentials); + return response.data; + }, + + // Verify token + verifyToken: async (): Promise<{ valid: boolean }> => { + const response = await api.get('/auth/verify'); + return response.data; + }, + + // Logout + logout: async (): Promise => { + await api.post('/auth/logout'); + }, +}; + +// Utility functions +export const createQueryString = (params: Record): string => { + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + if (Array.isArray(value)) { + value.forEach(v => searchParams.append(key, v)); + } else { + searchParams.append(key, value.toString()); + } + } + }); + return searchParams.toString(); +}; + +// Error handling utility +export const handleApiError = (error: any): ApiError => { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const data = error.response?.data; + + if (status === 401) { + return { + message: 'Authentication required. Please log in.', + code: 'UNAUTHORIZED', + }; + } + + if (status === 403) { + return { + message: 'Access denied. You do not have permission to perform this action.', + code: 'FORBIDDEN', + }; + } + + if (status === 404) { + return { + message: 'The requested resource was not found.', + code: 'NOT_FOUND', + }; + } + + if (status === 429) { + return { + message: 'Too many requests. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + }; + } + + if (status >= 500) { + return { + message: 'Server error. Please try again later.', + code: 'SERVER_ERROR', + }; + } + + return { + message: data?.message || error.message || 'An error occurred', + code: data?.code || 'UNKNOWN_ERROR', + details: data?.details, + }; + } + + return { + message: error.message || 'An unexpected error occurred', + code: 'UNKNOWN_ERROR', + }; +}; + +export default api; \ No newline at end of file diff --git a/aggregator-web/src/lib/store.ts b/aggregator-web/src/lib/store.ts new file mode 100644 index 0000000..f1e0495 --- /dev/null +++ b/aggregator-web/src/lib/store.ts @@ -0,0 +1,241 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { Agent, UpdatePackage, FilterState } from '@/types'; + +// Auth store +interface AuthState { + token: string | null; + isAuthenticated: boolean; + setToken: (token: string) => void; + logout: () => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + token: null, + isAuthenticated: false, + setToken: (token) => set({ token, isAuthenticated: true }), + logout: () => set({ token: null, isAuthenticated: false }), + }), + { + name: 'auth-storage', + partialize: (state) => ({ token: state.token, isAuthenticated: state.isAuthenticated }), + storage: createJSONStorage(() => localStorage), + } + ) +); + +// UI store for global state +interface UIState { + sidebarOpen: boolean; + theme: 'light' | 'dark'; + activeTab: string; + setSidebarOpen: (open: boolean) => void; + setTheme: (theme: 'light' | 'dark') => void; + setActiveTab: (tab: string) => void; +} + +export const useUIStore = create()( + persist( + (set) => ({ + sidebarOpen: true, + theme: 'light', + activeTab: 'dashboard', + setSidebarOpen: (open) => set({ sidebarOpen: open }), + setTheme: (theme) => set({ theme }), + setActiveTab: (tab) => set({ activeTab: tab }), + }), + { + name: 'ui-storage', + storage: createJSONStorage(() => localStorage), + } + ) +); + +// Agent store +interface AgentState { + agents: Agent[]; + selectedAgent: Agent | null; + loading: boolean; + error: string | null; + setAgents: (agents: Agent[]) => void; + setSelectedAgent: (agent: Agent | null) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + updateAgentStatus: (agentId: string, status: Agent['status'], lastCheckin: string) => void; + addAgent: (agent: Agent) => void; + removeAgent: (agentId: string) => void; +} + +export const useAgentStore = create((set, get) => ({ + agents: [], + selectedAgent: null, + loading: false, + error: null, + + setAgents: (agents) => set({ agents }), + setSelectedAgent: (agent) => set({ selectedAgent: agent }), + setLoading: (loading) => set({ loading }), + setError: (error) => set({ error }), + + updateAgentStatus: (agentId, status, lastCheckin) => { + const { agents } = get(); + const updatedAgents = agents.map(agent => + agent.id === agentId + ? { ...agent, status, last_checkin: lastCheckin } + : agent + ); + set({ agents: updatedAgents }); + }, + + addAgent: (agent) => { + const { agents } = get(); + set({ agents: [...agents, agent] }); + }, + + removeAgent: (agentId) => { + const { agents } = get(); + set({ agents: agents.filter(agent => agent.id !== agentId) }); + }, +})); + +// Updates store +interface UpdateState { + updates: UpdatePackage[]; + selectedUpdate: UpdatePackage | null; + filters: FilterState; + loading: boolean; + error: string | null; + setUpdates: (updates: UpdatePackage[]) => void; + setSelectedUpdate: (update: UpdatePackage | null) => void; + setFilters: (filters: Partial) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + updateUpdateStatus: (updateId: string, status: UpdatePackage['status']) => void; + bulkUpdateStatus: (updateIds: string[], status: UpdatePackage['status']) => void; +} + +export const useUpdateStore = create((set, get) => ({ + updates: [], + selectedUpdate: null, + filters: { + status: [], + severity: [], + type: [], + search: '', + }, + loading: false, + error: null, + + setUpdates: (updates) => set({ updates }), + setSelectedUpdate: (update) => set({ selectedUpdate: update }), + setLoading: (loading) => set({ loading }), + setError: (error) => set({ error }), + + setFilters: (newFilters) => { + const { filters } = get(); + set({ filters: { ...filters, ...newFilters } }); + }, + + updateUpdateStatus: (updateId, status) => { + const { updates } = get(); + const updatedUpdates = updates.map(update => + update.id === updateId + ? { ...update, status, updated_at: new Date().toISOString() } + : update + ); + set({ updates: updatedUpdates }); + }, + + bulkUpdateStatus: (updateIds, status) => { + const { updates } = get(); + const updatedUpdates = updates.map(update => + updateIds.includes(update.id) + ? { ...update, status, updated_at: new Date().toISOString() } + : update + ); + set({ updates: updatedUpdates }); + }, +})); + +// Real-time updates store +interface RealtimeState { + isConnected: boolean; + lastUpdate: string | null; + notifications: Array<{ + id: string; + type: 'info' | 'success' | 'warning' | 'error'; + title: string; + message: string; + timestamp: string; + read: boolean; + }>; + setConnected: (connected: boolean) => void; + setLastUpdate: (timestamp: string) => void; + addNotification: (notification: Omit) => void; + markNotificationRead: (id: string) => void; + clearNotifications: () => void; +} + +export const useRealtimeStore = create((set, get) => ({ + isConnected: false, + lastUpdate: null, + notifications: [], + + setConnected: (isConnected) => set({ isConnected }), + setLastUpdate: (lastUpdate) => set({ lastUpdate }), + + addNotification: (notification) => { + const { notifications } = get(); + const newNotification = { + ...notification, + id: Math.random().toString(36).substring(7), + timestamp: new Date().toISOString(), + read: false, + }; + set({ notifications: [newNotification, ...notifications] }); + }, + + markNotificationRead: (id) => { + const { notifications } = get(); + const updatedNotifications = notifications.map(notification => + notification.id === id ? { ...notification, read: true } : notification + ); + set({ notifications: updatedNotifications }); + }, + + clearNotifications: () => set({ notifications: [] }), +})); + +// Settings store +interface SettingsState { + autoRefresh: boolean; + refreshInterval: number; + notificationsEnabled: boolean; + compactView: boolean; + setAutoRefresh: (enabled: boolean) => void; + setRefreshInterval: (interval: number) => void; + setNotificationsEnabled: (enabled: boolean) => void; + setCompactView: (enabled: boolean) => void; +} + +export const useSettingsStore = create()( + persist( + (set) => ({ + autoRefresh: true, + refreshInterval: 30000, // 30 seconds + notificationsEnabled: true, + compactView: false, + + setAutoRefresh: (autoRefresh) => set({ autoRefresh }), + setRefreshInterval: (refreshInterval) => set({ refreshInterval }), + setNotificationsEnabled: (notificationsEnabled) => set({ notificationsEnabled }), + setCompactView: (compactView) => set({ compactView }), + }), + { + name: 'settings-storage', + storage: createJSONStorage(() => localStorage), + } + ) +); \ No newline at end of file diff --git a/aggregator-web/src/lib/utils.ts b/aggregator-web/src/lib/utils.ts new file mode 100644 index 0000000..b74eaf9 --- /dev/null +++ b/aggregator-web/src/lib/utils.ts @@ -0,0 +1,247 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +// Utility function for combining class names +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// Date formatting utilities +export const formatDate = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +export const formatRelativeTime = (dateString: string): string => { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) { + return 'Just now'; + } else if (diffMins < 60) { + return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`; + } else if (diffHours < 24) { + return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; + } else if (diffDays < 7) { + return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`; + } else { + return formatDate(dateString); + } +}; + +export const isOnline = (lastCheckin: string): boolean => { + const lastCheck = new Date(lastCheckin); + const now = new Date(); + const diffMs = now.getTime() - lastCheck.getTime(); + const diffMins = Math.floor(diffMs / 60000); + return diffMins < 10; // Consider online if checked in within 10 minutes +}; + +// Size formatting utilities +export const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +// Version comparison utilities +export const versionCompare = (v1: string, v2: string): number => { + const parts1 = v1.split('.').map(Number); + const parts2 = v2.split('.').map(Number); + + const maxLength = Math.max(parts1.length, parts2.length); + + for (let i = 0; i < maxLength; i++) { + const part1 = parts1[i] || 0; + const part2 = parts2[i] || 0; + + if (part1 > part2) return 1; + if (part1 < part2) return -1; + } + + return 0; +}; + +// Status and severity utilities +export const getStatusColor = (status: string): string => { + switch (status) { + case 'online': + return 'text-success-600 bg-success-100'; + case 'offline': + return 'text-danger-600 bg-danger-100'; + case 'pending': + return 'text-warning-600 bg-warning-100'; + case 'approved': + case 'scheduled': + return 'text-blue-600 bg-blue-100'; + case 'installing': + return 'text-indigo-600 bg-indigo-100'; + case 'installed': + return 'text-success-600 bg-success-100'; + case 'failed': + return 'text-danger-600 bg-danger-100'; + default: + return 'text-gray-600 bg-gray-100'; + } +}; + +export const getSeverityColor = (severity: string): string => { + switch (severity) { + case 'critical': + return 'text-danger-600 bg-danger-100'; + case 'high': + return 'text-warning-600 bg-warning-100'; + case 'medium': + return 'text-blue-600 bg-blue-100'; + case 'low': + return 'text-gray-600 bg-gray-100'; + default: + return 'text-gray-600 bg-gray-100'; + } +}; + +export const getPackageTypeIcon = (type: string): string => { + switch (type) { + case 'apt': + return 'šŸ“¦'; + case 'docker': + return '🐳'; + case 'yum': + case 'dnf': + return '🐧'; + case 'windows': + return '🪟'; + case 'winget': + return 'šŸ“±'; + default: + return 'šŸ“‹'; + } +}; + +// Filter and search utilities +export const filterUpdates = ( + updates: any[], + filters: { + status: string[]; + severity: string[]; + type: string[]; + search: string; + } +): any[] => { + return updates.filter(update => { + // Status filter + if (filters.status.length > 0 && !filters.status.includes(update.status)) { + return false; + } + + // Severity filter + if (filters.severity.length > 0 && !filters.severity.includes(update.severity)) { + return false; + } + + // Type filter + if (filters.type.length > 0 && !filters.type.includes(update.package_type)) { + return false; + } + + // Search filter + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + return ( + update.package_name.toLowerCase().includes(searchLower) || + update.current_version.toLowerCase().includes(searchLower) || + update.available_version.toLowerCase().includes(searchLower) + ); + } + + return true; + }); +}; + +// Error handling utilities +export const getErrorMessage = (error: any): string => { + if (typeof error === 'string') { + return error; + } + + if (error?.message) { + return error.message; + } + + if (error?.response?.data?.message) { + return error.response.data.message; + } + + return 'An unexpected error occurred'; +}; + +// Debounce utility +export const debounce = any>( + func: T, + wait: number +): ((...args: Parameters) => void) => { + let timeout: NodeJS.Timeout; + + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +}; + +// Local storage utilities +export const storage = { + get: (key: string): string | null => { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, + + set: (key: string, value: string): void => { + try { + localStorage.setItem(key, value); + } catch { + // Silent fail for storage issues + } + }, + + remove: (key: string): void => { + try { + localStorage.removeItem(key); + } catch { + // Silent fail for storage issues + } + }, + + getJSON: (key: string): T | null => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : null; + } catch { + return null; + } + }, + + setJSON: (key: string, value: any): void => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Silent fail for storage issues + } + }, +}; \ No newline at end of file diff --git a/aggregator-web/src/main.tsx b/aggregator-web/src/main.tsx new file mode 100644 index 0000000..f1df5cb --- /dev/null +++ b/aggregator-web/src/main.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import App from './App.tsx' +import './index.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + staleTime: 5 * 60 * 1000, // 5 minutes + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + , +) \ No newline at end of file diff --git a/aggregator-web/src/pages/Agents.tsx b/aggregator-web/src/pages/Agents.tsx new file mode 100644 index 0000000..af4eb56 --- /dev/null +++ b/aggregator-web/src/pages/Agents.tsx @@ -0,0 +1,491 @@ +import React, { useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + Computer, + RefreshCw, + Search, + Filter, + ChevronDown, + ChevronRight as ChevronRightIcon, + Activity, + HardDrive, + Cpu, + Globe, + MapPin, + Calendar, + Package, +} from 'lucide-react'; +import { useAgents, useAgent, useScanAgent, useScanMultipleAgents } from '@/hooks/useAgents'; +import { Agent } from '@/types'; +import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils'; +import { cn } from '@/lib/utils'; +import toast from 'react-hot-toast'; + +const Agents: React.FC = () => { + const { id } = useParams<{ id?: string }>(); + const navigate = useNavigate(); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [osFilter, setOsFilter] = useState('all'); + const [showFilters, setShowFilters] = useState(false); + const [selectedAgents, setSelectedAgents] = useState([]); + + // Fetch agents list + const { data: agentsData, isLoading, error } = useAgents({ + search: searchQuery || undefined, + status: statusFilter !== 'all' ? statusFilter : undefined, + }); + + // Fetch single agent if ID is provided + const { data: selectedAgentData } = useAgent(id || '', !!id); + + const scanAgentMutation = useScanAgent(); + const scanMultipleMutation = useScanMultipleAgents(); + + const agents = agentsData?.agents || []; + const selectedAgent = selectedAgentData || agents.find(a => a.id === id); + + // Filter agents based on OS + const filteredAgents = agents.filter(agent => { + if (osFilter === 'all') return true; + return agent.os_type.toLowerCase().includes(osFilter.toLowerCase()); + }); + + // Handle agent selection + const handleSelectAgent = (agentId: string, checked: boolean) => { + if (checked) { + setSelectedAgents([...selectedAgents, agentId]); + } else { + setSelectedAgents(selectedAgents.filter(id => id !== agentId)); + } + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedAgents(filteredAgents.map(agent => agent.id)); + } else { + setSelectedAgents([]); + } + }; + + // Handle scan operations + const handleScanAgent = async (agentId: string) => { + try { + await scanAgentMutation.mutateAsync(agentId); + toast.success('Scan triggered successfully'); + } catch (error) { + // Error handling is done in the hook + } + }; + + const handleScanSelected = async () => { + if (selectedAgents.length === 0) { + toast.error('Please select at least one agent'); + return; + } + + try { + await scanMultipleMutation.mutateAsync({ agent_ids: selectedAgents }); + setSelectedAgents([]); + toast.success(`Scan triggered for ${selectedAgents.length} agents`); + } catch (error) { + // Error handling is done in the hook + } + }; + + // Get unique OS types for filter + const osTypes = [...new Set(agents.map(agent => agent.os_type))]; + + // Agent detail view + if (id && selectedAgent) { + return ( +
+
+ +
+
+

+ {selectedAgent.hostname} +

+

+ Agent details and system information +

+
+ +
+
+ +
+ {/* Agent info */} +
+ {/* Status card */} +
+
+

Status

+ + {selectedAgent.status} + +
+ +
+
+
+ + Last Check-in: +
+

+ {formatRelativeTime(selectedAgent.last_checkin)} +

+
+ +
+
+ + Last Scan: +
+

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

+
+
+
+ + {/* System info */} +
+

System Information

+ +
+
+
+

Operating System

+

+ {selectedAgent.os_type} {selectedAgent.os_version} +

+
+ +
+

Architecture

+

+ {selectedAgent.architecture} +

+
+ +
+

IP Address

+

+ {selectedAgent.ip_address} +

+
+
+ +
+
+

Agent Version

+

+ {selectedAgent.version} +

+
+ +
+

Registered

+

+ {formatRelativeTime(selectedAgent.created_at)} +

+
+
+
+
+
+ + {/* Quick actions */} +
+
+

Quick Actions

+ +
+ + + +
+
+
+
+
+ ); + } + + // Agents list view + return ( +
+ {/* Header */} +
+

Agents

+

+ Monitor and manage your connected agents +

+
+ + {/* Search and filters */} +
+
+ {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search agents by hostname..." + className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ + {/* Filter toggle */} + + + {/* Bulk actions */} + {selectedAgents.length > 0 && ( + + )} +
+ + {/* Filters */} + {showFilters && ( +
+
+
+ + +
+ +
+ + +
+
+
+ )} +
+ + {/* Agents table */} + {isLoading ? ( +
+
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ ) : error ? ( +
+
Failed to load agents
+

Please check your connection and try again.

+
+ ) : filteredAgents.length === 0 ? ( +
+ +

No agents found

+

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

+
+ ) : ( +
+
+ + + + + + + + + + + + + + {filteredAgents.map((agent) => ( + + + + + + + + + + ))} + +
+ handleSelectAll(e.target.checked)} + className="rounded border-gray-300 text-primary-600 focus:ring-primary-500" + /> + AgentStatusOSLast Check-inLast ScanActions
+ handleSelectAgent(agent.id, e.target.checked)} + className="rounded border-gray-300 text-primary-600 focus:ring-primary-500" + /> + +
+
+ +
+
+
+ +
+
+ {agent.ip_address} +
+
+
+
+ + {agent.status} + + +
+ {agent.os_type} +
+
+ {agent.architecture} +
+
+
+ {formatRelativeTime(agent.last_checkin)} +
+
+ {isOnline(agent.last_checkin) ? 'Online' : 'Offline'} +
+
+
+ {agent.last_scan + ? formatRelativeTime(agent.last_scan) + : 'Never'} +
+
+
+ + +
+
+
+
+ )} +
+ ); +}; + +export default Agents; \ No newline at end of file diff --git a/aggregator-web/src/pages/Dashboard.tsx b/aggregator-web/src/pages/Dashboard.tsx new file mode 100644 index 0000000..df55820 --- /dev/null +++ b/aggregator-web/src/pages/Dashboard.tsx @@ -0,0 +1,246 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { + Computer, + Package, + CheckCircle, + AlertTriangle, + XCircle, + RefreshCw, + Activity, + TrendingUp, + Clock, +} from 'lucide-react'; +import { useDashboardStats } from '@/hooks/useStats'; +import { formatRelativeTime } from '@/lib/utils'; + +const Dashboard: React.FC = () => { + const { data: stats, isLoading, error } = useDashboardStats(); + + if (isLoading) { + return ( +
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + if (error || !stats) { + return ( +
+
+ +

Failed to load dashboard

+

Unable to fetch statistics from the server.

+
+
+ ); + } + + const statCards = [ + { + title: 'Total Agents', + value: stats.total_agents, + icon: Computer, + color: 'text-blue-600 bg-blue-100', + link: '/agents', + }, + { + title: 'Online Agents', + value: stats.online_agents, + icon: CheckCircle, + color: 'text-success-600 bg-success-100', + link: '/agents?status=online', + }, + { + title: 'Pending Updates', + value: stats.pending_updates, + icon: Clock, + color: 'text-warning-600 bg-warning-100', + link: '/updates?status=pending', + }, + { + title: 'Failed Updates', + value: stats.failed_updates, + icon: XCircle, + color: 'text-danger-600 bg-danger-100', + link: '/updates?status=failed', + }, + ]; + + const severityBreakdown = [ + { label: 'Critical', value: stats.critical_updates, color: 'bg-danger-600' }, + { label: 'High', value: stats.high_updates, color: 'bg-warning-600' }, + { label: 'Medium', value: stats.medium_updates, color: 'bg-blue-600' }, + { label: 'Low', value: stats.low_updates, color: 'bg-gray-600' }, + ]; + + const updateTypeBreakdown = Object.entries(stats.updates_by_type).map(([type, count]) => ({ + type: type.charAt(0).toUpperCase() + type.slice(1), + value: count, + icon: type === 'apt' ? 'šŸ“¦' : type === 'docker' ? '🐳' : 'šŸ“‹', + })); + + return ( +
+ {/* Page header */} +
+

Dashboard

+

+ Overview of your infrastructure and update status +

+
+ + {/* Stats cards */} +
+ {statCards.map((stat) => { + const Icon = stat.icon; + return ( + +
+
+

+ {stat.title} +

+

+ {stat.value.toLocaleString()} +

+
+
+ +
+
+ + ); + })} +
+ +
+ {/* Severity breakdown */} +
+
+

Update Severity

+ +
+ + {severityBreakdown.some(item => item.value > 0) ? ( +
+ {severityBreakdown.map((severity) => ( +
+
+
+ + {severity.label} + +
+ + {severity.value} + +
+ ))} + + {/* Visual bar chart */} +
+ {severityBreakdown.map((severity) => ( +
+
+ {severity.label} + {severity.value} +
+
+
0 ? (severity.value / stats.pending_updates) * 100 : 0}%` + }} + >
+
+
+ ))} +
+
+ ) : ( +
+ +

No pending updates

+
+ )} +
+ + {/* Update type breakdown */} +
+
+

Updates by Type

+ +
+ + {updateTypeBreakdown.length > 0 ? ( +
+ {updateTypeBreakdown.map((type) => ( +
+
+ {type.icon} + + {type.type} + +
+ + {type.value.toLocaleString()} + +
+ ))} +
+ ) : ( +
+ +

No updates found

+
+ )} +
+
+ + {/* Quick actions */} +
+

Quick Actions

+
+ + + View All Agents + + + + + Manage Updates + + + +
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/aggregator-web/src/pages/Login.tsx b/aggregator-web/src/pages/Login.tsx new file mode 100644 index 0000000..c376de7 --- /dev/null +++ b/aggregator-web/src/pages/Login.tsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Eye, EyeOff, Shield } from 'lucide-react'; +import { useAuthStore } from '@/lib/store'; +import { authApi } from '@/lib/api'; +import { handleApiError } from '@/lib/api'; +import toast from 'react-hot-toast'; + +const Login: React.FC = () => { + const navigate = useNavigate(); + const { setToken } = useAuthStore(); + const [token, setTokenInput] = useState(''); + const [showToken, setShowToken] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!token.trim()) { + toast.error('Please enter your authentication token'); + return; + } + + setIsLoading(true); + try { + const response = await authApi.login({ token: token.trim() }); + setToken(response.token); + localStorage.setItem('auth_token', response.token); + toast.success('Login successful'); + navigate('/'); + } catch (error) { + const apiError = handleApiError(error); + toast.error(apiError.message); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ 🚩 +
+
+

+ Sign in to RedFlag +

+

+ Enter your authentication token to access the dashboard +

+
+ +
+
+
+
+ +
+ setTokenInput(e.target.value)} + className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" + placeholder="Enter your JWT token" + required + /> + +
+
+ +
+ +
+
+ +
+
+
+ +
+

How to get your token:

+
    +
  • Check your RedFlag server configuration
  • +
  • Look for the JWT secret in your server settings
  • +
  • Generate a token using the server CLI
  • +
  • Contact your administrator if you need access
  • +
+
+
+
+
+
+ +
+

+ RedFlag is a self-hosted update management platform +

+
+
+
+ ); +}; + +export default Login; \ No newline at end of file diff --git a/aggregator-web/src/pages/Logs.tsx b/aggregator-web/src/pages/Logs.tsx new file mode 100644 index 0000000..f1f569a --- /dev/null +++ b/aggregator-web/src/pages/Logs.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +const Logs: React.FC = () => { + return ( +
+
+

Logs

+

+ View system logs and update history +

+
+ +
+
šŸ“‹
+

Coming Soon

+

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

+
+
+ ); +}; + +export default Logs; \ No newline at end of file diff --git a/aggregator-web/src/pages/Settings.tsx b/aggregator-web/src/pages/Settings.tsx new file mode 100644 index 0000000..0c69e3f --- /dev/null +++ b/aggregator-web/src/pages/Settings.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { useSettingsStore } from '@/lib/store'; + +const Settings: React.FC = () => { + const { autoRefresh, refreshInterval, setAutoRefresh, setRefreshInterval } = useSettingsStore(); + + return ( +
+
+

Settings

+

+ Configure your dashboard preferences +

+
+ +
+
+

Dashboard Settings

+

+ Configure how the dashboard behaves and displays information +

+
+ +
+ {/* Auto Refresh */} +
+
+

Auto Refresh

+

+ Automatically refresh dashboard data at regular intervals +

+
+ +
+ + {/* Refresh Interval */} +
+

Refresh Interval

+ +

+ How often to refresh dashboard data when auto-refresh is enabled +

+
+
+
+
+ ); +}; + +export default Settings; \ No newline at end of file diff --git a/aggregator-web/src/pages/Updates.tsx b/aggregator-web/src/pages/Updates.tsx new file mode 100644 index 0000000..d171bb5 --- /dev/null +++ b/aggregator-web/src/pages/Updates.tsx @@ -0,0 +1,591 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; +import { + Package, + CheckCircle, + XCircle, + Clock, + AlertTriangle, + Search, + Filter, + ChevronDown as ChevronDownIcon, + RefreshCw, + Calendar, + Computer, + ExternalLink, +} from 'lucide-react'; +import { useUpdates, useUpdate, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates } from '@/hooks/useUpdates'; +import { UpdatePackage } from '@/types'; +import { getSeverityColor, getStatusColor, getPackageTypeIcon, formatBytes, formatRelativeTime } from '@/lib/utils'; +import { useUpdateStore } from '@/lib/store'; +import { cn } from '@/lib/utils'; +import toast from 'react-hot-toast'; + +const Updates: React.FC = () => { + const { id } = useParams<{ id?: string }>(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + // Get filters from URL params + const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || ''); + const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || ''); + const [severityFilter, setSeverityFilter] = useState(searchParams.get('severity') || ''); + const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || ''); + const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || ''); + const [showFilters, setShowFilters] = useState(false); + const [selectedUpdates, setSelectedUpdates] = useState([]); + + // Store filters in URL + useEffect(() => { + const params = new URLSearchParams(); + if (searchQuery) params.set('search', searchQuery); + if (statusFilter) params.set('status', statusFilter); + if (severityFilter) params.set('severity', severityFilter); + if (typeFilter) params.set('type', typeFilter); + if (agentFilter) params.set('agent', agentFilter); + + const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`; + if (newUrl !== window.location.href) { + window.history.replaceState({}, '', newUrl); + } + }, [searchQuery, statusFilter, severityFilter, typeFilter, agentFilter]); + + // Fetch updates list + const { data: updatesData, isLoading, error } = useUpdates({ + search: searchQuery || undefined, + status: statusFilter || undefined, + severity: severityFilter || undefined, + type: typeFilter || undefined, + agent_id: agentFilter || undefined, + }); + + // Fetch single update if ID is provided + const { data: selectedUpdateData } = useUpdate(id || '', !!id); + + const approveMutation = useApproveUpdate(); + const rejectMutation = useRejectUpdate(); + const installMutation = useInstallUpdate(); + const bulkApproveMutation = useApproveMultipleUpdates(); + + const updates = updatesData?.updates || []; + const selectedUpdate = selectedUpdateData || updates.find(u => u.id === id); + + // Handle update selection + const handleSelectUpdate = (updateId: string, checked: boolean) => { + if (checked) { + setSelectedUpdates([...selectedUpdates, updateId]); + } else { + setSelectedUpdates(selectedUpdates.filter(id => id !== updateId)); + } + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedUpdates(updates.map(update => update.id)); + } else { + setSelectedUpdates([]); + } + }; + + // Handle update actions + const handleApproveUpdate = async (updateId: string) => { + try { + await approveMutation.mutateAsync({ id: updateId }); + } catch (error) { + // Error handling is done in the hook + } + }; + + const handleRejectUpdate = async (updateId: string) => { + try { + await rejectMutation.mutateAsync(updateId); + } catch (error) { + // Error handling is done in the hook + } + }; + + const handleInstallUpdate = async (updateId: string) => { + try { + await installMutation.mutateAsync(updateId); + } catch (error) { + // Error handling is done in the hook + } + }; + + const handleBulkApprove = async () => { + if (selectedUpdates.length === 0) { + toast.error('Please select at least one update'); + return; + } + + try { + await bulkApproveMutation.mutateAsync({ update_ids: selectedUpdates }); + setSelectedUpdates([]); + } catch (error) { + // Error handling is done in the hook + } + }; + + // Get unique values for filters + const statuses = [...new Set(updates.map(u => u.status))]; + const severities = [...new Set(updates.map(u => u.severity))]; + const types = [...new Set(updates.map(u => u.package_type))]; + const agents = [...new Set(updates.map(u => u.agent_id))]; + + // Update detail view + if (id && selectedUpdate) { + return ( +
+
+ +
+
+
+ {getPackageTypeIcon(selectedUpdate.package_type)} +

+ {selectedUpdate.package_name} +

+ + {selectedUpdate.severity} + + + {selectedUpdate.status} + +
+

+ Update details and available actions +

+
+
+
+ +
+ {/* Update info */} +
+ {/* Version info */} +
+

Version Information

+ +
+
+

Current Version

+

+ {selectedUpdate.current_version} +

+
+
+

Available Version

+

+ {selectedUpdate.available_version} +

+
+
+
+ + {/* Metadata */} +
+

Additional Information

+ +
+
+
+

Package Type

+

+ {selectedUpdate.package_type.toUpperCase()} +

+
+ +
+

Severity

+ + {selectedUpdate.severity} + +
+
+ +
+
+

Discovered

+

+ {formatRelativeTime(selectedUpdate.created_at)} +

+
+ +
+

Last Updated

+

+ {formatRelativeTime(selectedUpdate.updated_at)} +

+
+
+
+ + {selectedUpdate.metadata && Object.keys(selectedUpdate.metadata).length > 0 && ( +
+

Metadata

+
+                    {JSON.stringify(selectedUpdate.metadata, null, 2)}
+                  
+
+ )} +
+
+ + {/* Actions */} +
+
+

Actions

+ +
+ {selectedUpdate.status === 'pending' && ( + <> + + + + + )} + + {selectedUpdate.status === 'approved' && ( + + )} + + +
+
+
+
+
+ ); + } + + // Updates list view + return ( +
+ {/* Header */} +
+

Updates

+

+ Review and approve available updates for your agents +

+
+ + {/* Search and filters */} +
+
+ {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search updates by package name..." + className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ + {/* Filter toggle */} + + + {/* Bulk actions */} + {selectedUpdates.length > 0 && ( + + )} +
+ + {/* Filters */} + {showFilters && ( +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ )} +
+ + {/* Updates table */} + {isLoading ? ( +
+
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ ) : error ? ( +
+
Failed to load updates
+

Please check your connection and try again.

+
+ ) : updates.length === 0 ? ( +
+ +

No updates found

+

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

+
+ ) : ( +
+
+ + + + + + + + + + + + + + + + {updates.map((update) => ( + + + + + + + + + + + + ))} + +
+ handleSelectAll(e.target.checked)} + className="rounded border-gray-300 text-primary-600 focus:ring-primary-500" + /> + PackageTypeVersionsSeverityStatusAgentDiscoveredActions
+ handleSelectUpdate(update.id, e.target.checked)} + className="rounded border-gray-300 text-primary-600 focus:ring-primary-500" + /> + +
+ {getPackageTypeIcon(update.package_type)} +
+
+ +
+ {update.metadata?.size_bytes && ( +
+ {formatBytes(update.metadata.size_bytes)} +
+ )} +
+
+
+ + {update.package_type.toUpperCase()} + + +
+
{update.current_version}
+
→ {update.available_version}
+
+
+ + {update.severity} + + + + {update.status} + + + + +
+ {formatRelativeTime(update.created_at)} +
+
+
+ {update.status === 'pending' && ( + <> + + + + )} + + {update.status === 'approved' && ( + + )} + + +
+
+
+
+ )} +
+ ); +}; + +export default Updates; \ No newline at end of file diff --git a/aggregator-web/src/types/index.ts b/aggregator-web/src/types/index.ts new file mode 100644 index 0000000..2f4e666 --- /dev/null +++ b/aggregator-web/src/types/index.ts @@ -0,0 +1,175 @@ +// API Response types +export interface ApiResponse { + data?: T; + error?: string; + message?: string; +} + +// Agent types +export interface Agent { + id: string; + hostname: string; + os_type: string; + os_version: string; + architecture: string; + status: 'online' | 'offline'; + last_checkin: string; + last_scan: string | null; + created_at: string; + updated_at: string; + version: string; + ip_address: string; +} + +export interface AgentSpec { + id: string; + agent_id: string; + cpu_cores: number; + memory_mb: number; + disk_gb: number; + docker_version: string | null; + kernel_version: string; + metadata: Record; + created_at: string; +} + +// Update types +export interface UpdatePackage { + id: string; + agent_id: string; + package_type: 'apt' | 'docker' | 'yum' | 'dnf' | 'windows' | 'winget'; + package_name: string; + current_version: string; + available_version: string; + severity: 'low' | 'medium' | 'high' | 'critical'; + status: 'pending' | 'approved' | 'scheduled' | 'installing' | 'installed' | 'failed'; + created_at: string; + updated_at: string; + approved_at: string | null; + scheduled_at: string | null; + installed_at: string | null; + metadata: Record; +} + +// Update specific types +export interface DockerUpdateInfo { + local_digest: string; + remote_digest: string; + image_name: string; + tag: string; + registry: string; + size_bytes: number; +} + +export interface AptUpdateInfo { + package_name: string; + current_version: string; + new_version: string; + section: string; + priority: string; + repository: string; + size_bytes: number; + cves: string[]; +} + +// Command types +export interface Command { + id: string; + agent_id: string; + command_type: 'scan' | 'install' | 'update' | 'reboot'; + payload: Record; + status: 'pending' | 'running' | 'completed' | 'failed'; + created_at: string; + updated_at: string; + executed_at: string | null; + completed_at: string | null; +} + +// Log types +export interface UpdateLog { + id: string; + agent_id: string; + update_package_id: string | null; + command_id: string | null; + level: 'info' | 'warn' | 'error' | 'debug'; + message: string; + metadata: Record; + created_at: string; +} + +// Dashboard stats +export interface DashboardStats { + total_agents: number; + online_agents: number; + offline_agents: number; + pending_updates: number; + approved_updates: number; + installed_updates: number; + failed_updates: number; + critical_updates: number; + high_updates: number; + medium_updates: number; + low_updates: number; + updates_by_type: Record; +} + +// API request/response types +export interface AgentListResponse { + agents: Agent[]; + total: number; +} + +export interface UpdateListResponse { + updates: UpdatePackage[]; + total: number; +} + +export interface UpdateApprovalRequest { + update_ids: string[]; + scheduled_at?: string; +} + +export interface ScanRequest { + agent_ids?: string[]; + force?: boolean; +} + +// Query parameters +export interface ListQueryParams { + page?: number; + limit?: number; + status?: string; + severity?: string; + type?: string; + search?: string; + sort_by?: string; + sort_order?: 'asc' | 'desc'; +} + +// UI State types +export interface FilterState { + status: string[]; + severity: string[]; + type: string[]; + search: string; +} + +export interface PaginationState { + page: number; + limit: number; + total: number; +} + +// WebSocket message types (for future real-time updates) +export interface WebSocketMessage { + type: 'agent_status' | 'update_discovered' | 'update_installed' | 'command_completed'; + data: any; + timestamp: string; +} + +// Error types +export interface ApiError { + message: string; + code?: string; + details?: any; +} \ No newline at end of file diff --git a/aggregator-web/tailwind.config.js b/aggregator-web/tailwind.config.js new file mode 100644 index 0000000..030122e --- /dev/null +++ b/aggregator-web/tailwind.config.js @@ -0,0 +1,80 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: { + 50: '#fef2f2', + 100: '#fee2e2', + 200: '#fecaca', + 300: '#fca5a5', + 400: '#f87171', + 500: '#ef4444', + 600: '#dc2626', + 700: '#b91c1c', + 800: '#991b1b', + 900: '#7f1d1d', + }, + success: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + }, + warning: { + 50: '#fffbeb', + 100: '#fef3c7', + 200: '#fde68a', + 300: '#fcd34d', + 400: '#fbbf24', + 500: '#f59e0b', + 600: '#d97706', + 700: '#b45309', + 800: '#92400e', + 900: '#78350f', + }, + danger: { + 50: '#fef2f2', + 100: '#fee2e2', + 200: '#fecaca', + 300: '#fca5a5', + 400: '#f87171', + 500: '#ef4444', + 600: '#dc2626', + 700: '#b91c1c', + 800: '#991b1b', + 900: '#7f1d1d', + } + }, + fontFamily: { + mono: ['JetBrains Mono', 'Fira Code', 'Monaco', 'monospace'], + }, + animation: { + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + 'fade-in': 'fadeIn 0.5s ease-in-out', + 'slide-up': 'slideUp 0.3s ease-out', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + slideUp: { + '0%': { transform: 'translateY(10px)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + }, + }, + }, + plugins: [], +} \ No newline at end of file diff --git a/aggregator-web/tsconfig.json b/aggregator-web/tsconfig.json new file mode 100644 index 0000000..f9a81ca --- /dev/null +++ b/aggregator-web/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path mapping */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/aggregator-web/tsconfig.node.json b/aggregator-web/tsconfig.node.json new file mode 100644 index 0000000..099658c --- /dev/null +++ b/aggregator-web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/aggregator-web/vite.config.ts b/aggregator-web/vite.config.ts new file mode 100644 index 0000000..791139e --- /dev/null +++ b/aggregator-web/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + } + } + } +}) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7a13734 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + aggregator-db: + image: postgres:16-alpine + container_name: aggregator-db + environment: + POSTGRES_DB: aggregator + POSTGRES_USER: aggregator + POSTGRES_PASSWORD: aggregator + volumes: + - aggregator-db-data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U aggregator"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + aggregator-db-data: