diff --git a/.gitignore b/.gitignore
index 8b3153a..f8c1dfe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -404,4 +404,19 @@ secrets/
# =============================================================================
# AI / LLM Development Files
# =============================================================================
-.claude/
\ No newline at end of file
+.claude/
+
+# =============================================================================
+# Development and deployment environments
+# =============================================================================
+website/
+deployment/
+
+# =============================================================================
+# Generated development documentation
+# =============================================================================
+docs/
+*.md
+!README.md
+!LICENSE
+!.env.example
\ No newline at end of file
diff --git a/README.md b/README.md
index 67c614e..7e7a7dd 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,333 @@
# RedFlag (Aggregator)
-⚠️ PRIVATE DEVELOPMENT - NOT FOR PUBLIC USE
-
-This is a private development repository for version retention only.
+**ALPHA RELEASE - v0.1.16**
+Self-hosted update management platform for homelabs and small teams
## Status
-- **Active Development**: In progress
-- **Not Production Ready**: Do not use
-- **Breaking Changes Expected**: APIs will change
-- **No Support Available**: This is not released software
+- **Core Features Working**: Update management, agent registration, web dashboard
+- **Alpha Deployment Ready**: Setup wizard and configuration system implemented
+- **Cross-Platform Support**: Linux and Windows agents
+- **In Development**: Enhanced features and polish
+- **Alpha Software**: Expect some rough edges, backup your data
+
+## What RedFlag Is
+
+A self-hosted, cross-platform update management platform built for homelabs and small teams:
+
+- Go Server Backend with PostgreSQL database
+- React Web Dashboard with real-time updates
+- Cross-Platform Agents (Linux APT/DNF/Docker, Windows Updates/Winget)
+- Secure Authentication with registration tokens and refresh tokens
+- Professional Monitoring with real-time status and audit trails
+- Enterprise-Grade Security with rate limiting and TLS support
+
+## Key Features
+
+### Alpha Features
+- Secure Server Setup: `./redflag-server --setup` with user-provided secrets
+- Registration Token System: One-time tokens for secure agent enrollment
+- Rate Limiting: User-adjustable API security with professional defaults
+- Cross-Platform Agents: Linux and Windows with unified architecture
+- Real-Time Heartbeat: Rapid polling for interactive operations
+- Dependency Management: Safe update installation with dry-run checking
+- Audit Logging: Complete activity tracking and history
+- Proxy Support: Enterprise networking with HTTP/HTTPS/SOCKS5 proxies
+
+### Update Management
+- Package Managers: APT, DNF, Docker images, Windows Updates, Winget
+- Update Discovery: Automatic scanning with severity classification
+- Approval Workflow: Controlled update deployment with confirmation
+- Bulk Operations: Multi-agent management and batch operations
+- Rollback Support: Failed update tracking and retry capabilities
+
+### Deployment
+- Configuration Management: CLI flags → environment → config file → defaults
+- Service Integration: systemd service management on Linux
+- Cross-Platform Installers: One-liner deployment scripts
+- Container Support: Docker and Kubernetes deployment options
+
+## Architecture
+
+```
+┌─────────────────┐
+│ Web Dashboard │ React + TypeScript + TailwindCSS
+│ + Rate Limiting │ + Registration Token Management
+└────────┬────────┘
+ │ HTTPS with TLS + User Authentication
+┌────────▼────────┐
+│ Server (Go) │ Alpha with PostgreSQL
+│ + Rate Limits │ + Registration Tokens + Setup Wizard
+│ + JWT Auth │ + Heartbeat System + Comprehensive API
+└────────┬────────┘
+ │ Pull-based (agents check in every 5 min) + Rapid Polling
+ ┌────┴────┬────────┐
+ │ │ │
+┌───▼──┐ ┌──▼──┐ ┌──▼───┐
+│Linux │ │Windows│ │Linux │
+│Agent │ │Agent │ │Agent │
+│+Proxy│ │+Proxy│ │+Proxy│
+└──────┘ └───────┘ └──────┘
+```
+
+## Quick Start
+
+### 1. Server Setup (Linux)
+```bash
+# Clone and build
+git clone https://github.com/Fimeg/RedFlag.git
+cd RedFlag/aggregator-server
+go build -o redflag-server cmd/server/main.go
+
+# Interactive setup wizard
+sudo ./redflag-server --setup
+# Follow prompts for:
+# - Admin credentials
+# - Database configuration
+# - Server settings
+# - Agent seat limits
+
+# Start database
+docker-compose up -d postgres
+
+# Run migrations
+./redflag-server --migrate
+
+# Start server
+./redflag-server
+# Server: http://redflag.wiuf.net:8080
+# Dashboard: http://redflag.wiuf.net:8080
+```
+
+### 2. Agent Deployment (Linux)
+```bash
+# Option 1: One-liner with registration token
+sudo bash -c 'curl -sfL https://redflag.wiuf.net/install | bash -s -- rf-tok-abc123'
+
+# Option 2: Manual installation
+sudo ./install.sh --server https://redflag.wiuf.net:8080 --token rf-tok-abc123
+
+# Option 3: Advanced configuration with proxy
+sudo ./redflag-agent --server https://redflag.wiuf.net:8080 \
+ --token rf-tok-abc123 \
+ --proxy-http http://proxy.company.com:8080 \
+ --organization "my-homelab" \
+ --tags "production,webserver"
+```
+
+### 3. Windows Agent Deployment
+```powershell
+# PowerShell one-liner
+iwr https://redflag.wiuf.net/install.ps1 | iex -Arguments '--server https://redflag.wiuf.net:8080 --token rf-tok-abc123'
+
+# Or manual download and install
+.\redflag-agent.exe --server https://redflag.wiuf.net:8080 --token rf-tok-abc123
+```
+
+## Agent Configuration Options
+
+### CLI Flags (Highest Priority)
+```bash
+./redflag-agent --server https://redflag.wiuf.net \
+ --token rf-tok-abc123 \
+ --proxy-http http://proxy.company.com:8080 \
+ --proxy-https https://proxy.company.com:8080 \
+ --log-level debug \
+ --organization "my-homelab" \
+ --tags "production,webserver" \
+ --name "redflag-server-01" \
+ --insecure-tls
+```
+
+### Environment Variables
+```bash
+export REDFLAG_SERVER_URL="https://redflag.wiuf.net"
+export REDFLAG_REGISTRATION_TOKEN="rf-tok-abc123"
+export REDFLAG_HTTP_PROXY="http://proxy.company.com:8080"
+export REDFLAG_HTTPS_PROXY="https://proxy.company.com:8080"
+export REDFLAG_NO_PROXY="localhost,127.0.0.1"
+export REDFLAG_LOG_LEVEL="info"
+export REDFLAG_ORGANIZATION="my-homelab"
+```
+
+### Configuration File
+```json
+{
+ "server_url": "https://redflag.wiuf.net",
+ "registration_token": "rf-tok-abc123",
+ "proxy": {
+ "enabled": true,
+ "http": "http://proxy.company.com:8080",
+ "https": "https://proxy.company.com:8080",
+ "no_proxy": "localhost,127.0.0.1"
+ },
+ "network": {
+ "timeout": "30s",
+ "retry_count": 3,
+ "retry_delay": "5s"
+ },
+ "logging": {
+ "level": "info",
+ "max_size": 100,
+ "max_backups": 3
+ },
+ "tags": ["production", "webserver"],
+ "organization": "my-homelab",
+ "display_name": "redflag-server-01"
+}
+```
+
+## Web Dashboard Features
+
+### Agent Management
+- Real-time Status: Online/offline with heartbeat indicators
+- System Information: CPU, memory, disk usage, OS details
+- Version Tracking: Agent versions and update availability
+- Metadata Management: Tags, organizations, display names
+- Bulk Operations: Multi-agent scanning and updates
+
+### Update Management
+- Severity Classification: Critical, high, medium, low priority updates
+- Approval Workflow: Controlled update deployment with dependencies
+- Dependency Resolution: Safe installation with conflict checking
+- Batch Operations: Approve/install multiple updates
+- Audit Trail: Complete history of all operations
+
+### Settings & Administration
+- Registration Tokens: Generate and manage secure enrollment tokens
+- Rate Limiting: User-adjustable API security settings
+- Authentication: Secure login with session management
+- Audit Logging: Comprehensive activity tracking
+- Server Configuration: Admin settings and system controls
+
+## API Reference
+
+### Registration Token Management
+```bash
+# Generate registration token
+curl -X POST https://redflag.wiuf.net/api/v1/admin/registration-tokens \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -d '{"label": "Production Servers", "expires_in": "24h"}'
+
+# List tokens
+curl -X GET https://redflag.wiuf.net/api/v1/admin/registration-tokens \
+ -H "Authorization: Bearer $ADMIN_TOKEN"
+
+# Revoke token
+curl -X DELETE https://redflag.wiuf.net/api/v1/admin/registration-tokens/rf-tok-abc123 \
+ -H "Authorization: Bearer $ADMIN_TOKEN"
+```
+
+### Rate Limit Management
+```bash
+# View current settings
+curl -X GET https://redflag.wiuf.net/api/v1/admin/rate-limits \
+ -H "Authorization: Bearer $ADMIN_TOKEN"
+
+# Update settings
+curl -X PUT https://redflag.wiuf.net/api/v1/admin/rate-limits \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -d '{
+ "agent_registration": {"requests": 10, "window": "1m", "enabled": true},
+ "admin_operations": {"requests": 200, "window": "1m", "enabled": true}
+ }'
+```
+
+## Security
+
+### Authentication & Authorization
+- Registration Tokens: One-time use tokens prevent unauthorized agent enrollment
+- Refresh Token Authentication: 90-day sliding window with 24h access tokens
+- SHA-256 token hashing for secure storage
+- Admin authentication for server access and management
+
+### Network Security
+- Rate Limiting: Configurable API protection with professional defaults
+- TLS Support: Certificate validation and client certificate support
+- Pull-based Model: Agents poll server (firewall-friendly)
+- HTTPS Required: Production deployments must use TLS
+
+### System Hardening
+- Minimal Privilege Execution: Agents run with least required privileges
+- Command Validation: Whitelisted commands only
+- Secure Defaults: Hardened configurations out of the box
+- Security Hardening: Minimal privilege execution and sudoers management
+
+### Audit & Monitoring
+- Audit Trails: Complete logging of all activities
+- Token Renewal: `/renew` endpoint prevents daily re-registration
+- Activity Tracking: Comprehensive monitoring and alerting
+- Access Logs: Full audit trail of user and agent actions
+
+## Docker Deployment
+
+```yaml
+# docker-compose.yml
+version: '3.8'
+services:
+ redflag-server:
+ build: ./aggregator-server
+ ports:
+ - "8080:8080"
+ environment:
+ - REDFLAG_SERVER_HOST=0.0.0.0
+ - REDFLAG_SERVER_PORT=8080
+ - REDFLAG_DB_HOST=postgres
+ - REDFLAG_DB_PORT=5432
+ - REDFLAG_DB_NAME=redflag
+ - REDFLAG_DB_USER=redflag
+ - REDFLAG_DB_PASSWORD=secure-password
+ depends_on:
+ - postgres
+ volumes:
+ - ./redflag-data:/etc/redflag
+ - ./logs:/app/logs
+
+ postgres:
+ image: postgres:15
+ environment:
+ POSTGRES_DB: redflag
+ POSTGRES_USER: redflag
+ POSTGRES_PASSWORD: secure-password
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ ports:
+ - "5432:5432"
+```
+
+## Project Structure
+
+```
+RedFlag/
+├── aggregator-server/ # Go server backend
+│ ├── cmd/server/ # Main server entry point
+│ ├── internal/
+│ │ ├── api/ # REST API handlers and middleware
+│ │ │ └── handlers/ # API endpoint implementations
+│ │ ├── database/ # Database layer with migrations
+│ │ │ ├── migrations/ # Database schema migrations
+│ │ │ └── queries/ # Database query functions
+│ │ ├── models/ # Data models and structs
+│ │ ├── services/ # Business logic services
+│ │ └── config/ # Configuration management
+│ └── redflag-server # Server binary
+
+├── aggregator-agent/ # Cross-platform Go agent
+│ ├── cmd/agent/ # Agent main entry point
+│ ├── internal/
+│ │ ├── client/ # HTTP client with token renewal
+│ │ ├── config/ # Enhanced configuration system
+│ │ ├── scanner/ # Update scanners for each platform
+│ │ ├── installer/ # Package installers
+│ │ └── system/ # System information collection
+│ ├── install.sh # Linux installation script
+│ └── redflag-agent # Agent binary
+
+├── aggregator-web/ # React dashboard
+├── docker-compose.yml # Development environment
+├── Makefile # Common tasks
+└── README.md # This file
+```
## What This Is
@@ -45,9 +363,8 @@ A self-hosted, cross-platform update management platform built with:
- Event-sourced database architecture for scalability
### Known Limitations
-- No rate limiting on API endpoints (security improvement needed)
- No real-time WebSocket updates
-- Proxmox integration is broken (needs complete rewrite)
+- Proxmox integration is not implemented in this version (planned for future release)
- Authentication system works but needs security hardening
## Screenshots
@@ -81,81 +398,6 @@ This repository contains:
- **Web dashboard** (`aggregator-web/`)
- **Database migrations** and configuration
-## Architecture
-
-```
-┌─────────────────┐
-│ Web Dashboard │ React + TypeScript + TailwindCSS
-└────────┬────────┘
- │ HTTPS
-┌────────▼────────┐
-│ Server (Go) │ Production Ready with PostgreSQL
-│ + PostgreSQL │
-└────────┬────────┘
- │ Pull-based (agents check in every 5 min)
- ┌────┴────┬────────┐
- │ │ │
-┌───▼──┐ ┌──▼──┐ ┌──▼───┐
-│Linux │ │Windows│ │Linux │
-│Agent │ │Agent │ │Agent │
-└──────┘ └───────┘ └──────┘
-```
-
-## Project Structure
-
-```
-RedFlag/
-├── aggregator-server/ # Go server (Gin + PostgreSQL)
-│ ├── cmd/server/ # Main entry point
-│ ├── internal/
-│ │ ├── api/ # HTTP handlers & middleware
-│ │ │ └── handlers/ # API endpoint handlers
-│ │ ├── database/ # Database layer & migrations
-│ │ │ ├── migrations/ # Database schema migrations
-│ │ │ └── queries/ # Database query functions
-│ │ ├── models/ # Data models and structs
-│ │ ├── services/ # Business logic services
-│ │ ├── utils/ # Utility functions
-│ │ └── config/ # Configuration management
-│ └── go.mod
-
-├── aggregator-agent/ # Go agent (cross-platform)
-│ ├── cmd/agent/ # Main entry point
-│ ├── internal/
-│ │ ├── cache/ # Local cache system for offline viewing
-│ │ ├── client/ # API client with token renewal
-│ │ ├── config/ # Configuration management
-│ │ ├── display/ # Terminal output formatting
-│ │ ├── installer/ # Update installers
-│ │ │ ├── apt.go # APT package installer
-│ │ │ ├── dnf.go # DNF package installer
-│ │ │ ├── docker.go # Docker image installer
-│ │ │ ├── windows.go # Windows installer base
-│ │ │ ├── winget.go # Winget package installer
-│ │ │ ├── security.go # Security utilities
-│ │ │ └── sudoers.go # Sudo management
-│ │ ├── scanner/ # Update scanners
-│ │ │ ├── apt.go # APT package scanner
-│ │ │ ├── dnf.go # DNF package scanner
-│ │ │ ├── docker.go # Docker image scanner
-│ │ │ ├── registry.go # Docker registry client
-│ │ │ ├── windows.go # Windows Update scanner
-│ │ │ ├── winget.go # Winget package scanner
-│ │ │ └── windows_*.go # Windows Update API components
-│ │ ├── system/ # System information collection
-│ │ │ ├── info.go # System metrics
-│ │ │ └── windows.go # Windows system info
-│ │ └── executor/ # Command execution
-│ ├── install.sh # Linux installation script
-│ ├── uninstall.sh # Linux uninstallation script
-│ └── go.mod
-
-├── aggregator-web/ # React dashboard
-├── docker-compose.yml # PostgreSQL for local dev
-├── Makefile # Common tasks
-└── README.md # This file
-```
-
## Database Schema
Key Tables:
@@ -260,15 +502,6 @@ curl -X POST http://localhost:8080/api/v1/updates/{update-id}/approve
curl -X POST http://localhost:8080/api/v1/updates/{update-id}/confirm-dependencies
```
-## Security
-
-- Agent Authentication: Refresh token system with 90-day sliding window + 24h access tokens
-- SHA-256 token hashing for secure storage
-- Pull-based Model: Agents poll server (firewall-friendly)
-- Command Validation: Whitelisted commands only
-- TLS Required: Production deployments must use HTTPS
-- Token Renewal: `/renew` endpoint prevents daily re-registration
-
## License
MIT License - see LICENSE file for details.
diff --git a/aggregator-agent/aggregator-agent b/aggregator-agent/aggregator-agent
new file mode 100755
index 0000000..ec8dbd6
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
index ae917c0..51d039f 100644
--- a/aggregator-agent/cmd/agent/main.go
+++ b/aggregator-agent/cmd/agent/main.go
@@ -21,7 +21,7 @@ import (
)
const (
- AgentVersion = "0.1.8" // Added dnf makecache to security allowlist, retry tracking
+ AgentVersion = "0.1.16" // Enhanced configuration system with proxy support and registration tokens
)
// getConfigPath returns the platform-specific config path
@@ -32,6 +32,26 @@ func getConfigPath() string {
return "/etc/aggregator/config.json"
}
+// getCurrentPollingInterval returns the appropriate polling interval based on rapid mode
+func getCurrentPollingInterval(cfg *config.Config) int {
+ // Check if rapid polling mode is active and not expired
+ if cfg.RapidPollingEnabled && time.Now().Before(cfg.RapidPollingUntil) {
+ return 5 // Rapid polling: 5 seconds
+ }
+
+ // Check if rapid polling has expired and clean up
+ if cfg.RapidPollingEnabled && time.Now().After(cfg.RapidPollingUntil) {
+ cfg.RapidPollingEnabled = false
+ cfg.RapidPollingUntil = time.Time{}
+ // Save the updated config to clean up expired rapid mode
+ if err := cfg.Save(getConfigPath()); err != nil {
+ log.Printf("Warning: Failed to cleanup expired rapid polling mode: %v", err)
+ }
+ }
+
+ return cfg.CheckInInterval // Normal polling: 5 minutes (300 seconds) by default
+}
+
// getDefaultServerURL returns the default server URL with environment variable support
func getDefaultServerURL() string {
// Check environment variable first
@@ -48,16 +68,65 @@ func getDefaultServerURL() string {
}
func main() {
+ // Define CLI flags
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", getDefaultServerURL(), "Server URL")
+ versionCmd := flag.Bool("version", false, "Show version information")
+ serverURL := flag.String("server", "", "Server URL")
+ registrationToken := flag.String("token", "", "Registration token for secure enrollment")
+ proxyHTTP := flag.String("proxy-http", "", "HTTP proxy URL")
+ proxyHTTPS := flag.String("proxy-https", "", "HTTPS proxy URL")
+ proxyNoProxy := flag.String("proxy-no", "", "Comma-separated hosts to bypass proxy")
+ logLevel := flag.String("log-level", "", "Log level (debug, info, warn, error)")
+ configFile := flag.String("config", "", "Configuration file path")
+ tagsFlag := flag.String("tags", "", "Comma-separated tags for agent")
+ organization := flag.String("organization", "", "Organization/group name")
+ displayName := flag.String("name", "", "Display name for agent")
+ insecureTLS := flag.Bool("insecure-tls", false, "Skip TLS certificate verification")
exportFormat := flag.String("export", "", "Export format: json, csv")
flag.Parse()
- // Load configuration
- cfg, err := config.Load(getConfigPath())
+ // Handle version command
+ if *versionCmd {
+ fmt.Printf("RedFlag Agent v%s\n", AgentVersion)
+ fmt.Printf("Self-hosted update management platform\n")
+ os.Exit(0)
+ }
+
+ // Parse tags from comma-separated string
+ var tags []string
+ if *tagsFlag != "" {
+ tags = strings.Split(*tagsFlag, ",")
+ for i, tag := range tags {
+ tags[i] = strings.TrimSpace(tag)
+ }
+ }
+
+ // Create CLI flags structure
+ cliFlags := &config.CLIFlags{
+ ServerURL: *serverURL,
+ RegistrationToken: *registrationToken,
+ ProxyHTTP: *proxyHTTP,
+ ProxyHTTPS: *proxyHTTPS,
+ ProxyNoProxy: *proxyNoProxy,
+ LogLevel: *logLevel,
+ ConfigFile: *configFile,
+ Tags: tags,
+ Organization: *organization,
+ DisplayName: *displayName,
+ InsecureTLS: *insecureTLS,
+ }
+
+ // Determine config path
+ configPath := getConfigPath()
+ if *configFile != "" {
+ configPath = *configFile
+ }
+
+ // Load configuration with priority: CLI > env > file > defaults
+ cfg, err := config.Load(configPath, cliFlags)
if err != nil {
log.Fatal("Failed to load configuration:", err)
}
@@ -313,6 +382,24 @@ func runAgent(cfg *config.Config) error {
}
}
+ // Add heartbeat status to metrics metadata if available
+ if metrics != nil && cfg.RapidPollingEnabled {
+ // Check if rapid polling is still valid
+ if time.Now().Before(cfg.RapidPollingUntil) {
+ // Include heartbeat metadata in metrics
+ if metrics.Metadata == nil {
+ metrics.Metadata = make(map[string]interface{})
+ }
+ metrics.Metadata["rapid_polling_enabled"] = true
+ metrics.Metadata["rapid_polling_until"] = cfg.RapidPollingUntil.Format(time.RFC3339)
+ metrics.Metadata["rapid_polling_duration_minutes"] = int(time.Until(cfg.RapidPollingUntil).Minutes())
+ } else {
+ // Heartbeat expired, disable it
+ cfg.RapidPollingEnabled = false
+ cfg.RapidPollingUntil = time.Time{}
+ }
+ }
+
// Get commands from server (with optional metrics)
commands, err := apiClient.GetCommands(cfg.AgentID, metrics)
if err != nil {
@@ -320,7 +407,7 @@ func runAgent(cfg *config.Config) error {
newClient, renewErr := renewTokenIfNeeded(apiClient, cfg, err)
if renewErr != nil {
log.Printf("Check-in unsuccessful and token renewal failed: %v\n", renewErr)
- time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second)
+ time.Sleep(time.Duration(getCurrentPollingInterval(cfg)) * time.Second)
continue
}
@@ -331,12 +418,12 @@ func runAgent(cfg *config.Config) error {
commands, err = apiClient.GetCommands(cfg.AgentID, metrics)
if err != nil {
log.Printf("Check-in unsuccessful even after token renewal: %v\n", err)
- time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second)
+ time.Sleep(time.Duration(getCurrentPollingInterval(cfg)) * time.Second)
continue
}
} else {
log.Printf("Check-in unsuccessful: %v\n", err)
- time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second)
+ time.Sleep(time.Duration(getCurrentPollingInterval(cfg)) * time.Second)
continue
}
}
@@ -375,13 +462,23 @@ func runAgent(cfg *config.Config) error {
log.Printf("Error confirming dependencies: %v\n", err)
}
+ case "enable_heartbeat":
+ if err := handleEnableHeartbeat(apiClient, cfg, cmd.ID, cmd.Params); err != nil {
+ log.Printf("[Heartbeat] Error enabling heartbeat: %v\n", err)
+ }
+
+ case "disable_heartbeat":
+ if err := handleDisableHeartbeat(apiClient, cfg, cmd.ID); err != nil {
+ log.Printf("[Heartbeat] Error disabling heartbeat: %v\n", err)
+ }
+
default:
log.Printf("Unknown command type: %s\n", cmd.Type)
}
}
// Wait for next check-in
- time.Sleep(time.Duration(cfg.CheckInInterval) * time.Second)
+ time.Sleep(time.Duration(getCurrentPollingInterval(cfg)) * time.Second)
}
}
@@ -743,9 +840,9 @@ func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandI
// Perform installation based on what's specified
if packageName != "" {
- action = "install"
- log.Printf("Installing package: %s (type: %s)", packageName, packageType)
- result, err = inst.Install(packageName)
+ action = "update"
+ log.Printf("Updating package: %s (type: %s)", packageName, packageType)
+ result, err = inst.UpdatePackage(packageName)
} else if len(params) > 1 {
// Multiple packages might be specified in various ways
var packageNames []string
@@ -774,15 +871,15 @@ func handleInstallUpdates(apiClient *client.Client, cfg *config.Config, commandI
}
if err != nil {
- // Report installation failure
+ // Report installation failure with actual command output
logReport := client.LogReport{
CommandID: commandID,
Action: action,
Result: "failed",
- Stdout: "",
- Stderr: fmt.Sprintf("Installation error: %v", err),
- ExitCode: 1,
- DurationSeconds: 0,
+ Stdout: result.Stdout,
+ Stderr: result.Stderr,
+ ExitCode: result.ExitCode,
+ DurationSeconds: result.DurationSeconds,
}
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
@@ -991,21 +1088,22 @@ func handleConfirmDependencies(apiClient *client.Client, cfg *config.Config, com
allPackages := append([]string{packageName}, dependencies...)
result, err = inst.InstallMultiple(allPackages)
} else {
- action = "install"
+ action = "upgrade"
log.Printf("Installing package: %s (no dependencies)", packageName)
- result, err = inst.Install(packageName)
+ // Use UpdatePackage instead of Install to handle existing packages
+ result, err = inst.UpdatePackage(packageName)
}
if err != nil {
- // Report installation failure
+ // Report installation failure with actual command output
logReport := client.LogReport{
CommandID: commandID,
Action: action,
Result: "failed",
- Stdout: "",
- Stderr: fmt.Sprintf("Installation error: %v", err),
- ExitCode: 1,
- DurationSeconds: 0,
+ Stdout: result.Stdout,
+ Stderr: result.Stderr,
+ ExitCode: result.ExitCode,
+ DurationSeconds: result.DurationSeconds,
}
if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
@@ -1051,6 +1149,145 @@ func handleConfirmDependencies(apiClient *client.Client, cfg *config.Config, com
return nil
}
+// handleEnableHeartbeat handles enable_heartbeat command
+func handleEnableHeartbeat(apiClient *client.Client, cfg *config.Config, commandID string, params map[string]interface{}) error {
+ // Parse duration parameter (default to 10 minutes)
+ durationMinutes := 10
+ if duration, ok := params["duration_minutes"]; ok {
+ if durationFloat, ok := duration.(float64); ok {
+ durationMinutes = int(durationFloat)
+ }
+ }
+
+ // Calculate when heartbeat should expire
+ expiryTime := time.Now().Add(time.Duration(durationMinutes) * time.Minute)
+
+ log.Printf("[Heartbeat] Enabling rapid polling for %d minutes (expires: %s)", durationMinutes, expiryTime.Format(time.RFC3339))
+
+ // Update agent config to enable rapid polling
+ cfg.RapidPollingEnabled = true
+ cfg.RapidPollingUntil = expiryTime
+
+ // Save config to persist heartbeat settings
+ if err := cfg.Save(getConfigPath()); err != nil {
+ log.Printf("[Heartbeat] Warning: Failed to save config: %v", err)
+ }
+
+ // Create log report for heartbeat enable
+ logReport := client.LogReport{
+ CommandID: commandID,
+ Action: "enable_heartbeat",
+ Result: "success",
+ Stdout: fmt.Sprintf("Heartbeat enabled for %d minutes", durationMinutes),
+ Stderr: "",
+ ExitCode: 0,
+ DurationSeconds: 0,
+ }
+
+ if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
+ log.Printf("[Heartbeat] Failed to report heartbeat enable: %v", reportErr)
+ }
+
+ // Send immediate check-in to update heartbeat status in UI
+ log.Printf("[Heartbeat] Sending immediate check-in to update status")
+ sysMetrics, err := system.GetLightweightMetrics()
+ if err == nil {
+ metrics := &client.SystemMetrics{
+ CPUPercent: sysMetrics.CPUPercent,
+ MemoryPercent: sysMetrics.MemoryPercent,
+ MemoryUsedGB: sysMetrics.MemoryUsedGB,
+ MemoryTotalGB: sysMetrics.MemoryTotalGB,
+ DiskUsedGB: sysMetrics.DiskUsedGB,
+ DiskTotalGB: sysMetrics.DiskTotalGB,
+ DiskPercent: sysMetrics.DiskPercent,
+ Uptime: sysMetrics.Uptime,
+ Version: AgentVersion,
+ }
+ // Include heartbeat metadata to show enabled state
+ metrics.Metadata = map[string]interface{}{
+ "rapid_polling_enabled": true,
+ "rapid_polling_until": expiryTime.Format(time.RFC3339),
+ }
+
+ // Send immediate check-in with updated heartbeat status
+ _, checkinErr := apiClient.GetCommands(cfg.AgentID, metrics)
+ if checkinErr != nil {
+ log.Printf("[Heartbeat] Failed to send immediate check-in: %v", checkinErr)
+ } else {
+ log.Printf("[Heartbeat] Immediate check-in sent successfully")
+ }
+ } else {
+ log.Printf("[Heartbeat] Failed to get system metrics for immediate check-in: %v", err)
+ }
+
+ log.Printf("[Heartbeat] Rapid polling enabled successfully")
+ return nil
+}
+
+// handleDisableHeartbeat handles disable_heartbeat command
+func handleDisableHeartbeat(apiClient *client.Client, cfg *config.Config, commandID string) error {
+ log.Printf("[Heartbeat] Disabling rapid polling")
+
+ // Update agent config to disable rapid polling
+ cfg.RapidPollingEnabled = false
+ cfg.RapidPollingUntil = time.Time{} // Zero value
+
+ // Save config to persist heartbeat settings
+ if err := cfg.Save(getConfigPath()); err != nil {
+ log.Printf("[Heartbeat] Warning: Failed to save config: %v", err)
+ }
+
+ // Create log report for heartbeat disable
+ logReport := client.LogReport{
+ CommandID: commandID,
+ Action: "disable_heartbeat",
+ Result: "success",
+ Stdout: "Heartbeat disabled",
+ Stderr: "",
+ ExitCode: 0,
+ DurationSeconds: 0,
+ }
+
+ if reportErr := apiClient.ReportLog(cfg.AgentID, logReport); reportErr != nil {
+ log.Printf("[Heartbeat] Failed to report heartbeat disable: %v", reportErr)
+ }
+
+ // Send immediate check-in to update heartbeat status in UI
+ log.Printf("[Heartbeat] Sending immediate check-in to update status")
+ sysMetrics, err := system.GetLightweightMetrics()
+ if err == nil {
+ metrics := &client.SystemMetrics{
+ CPUPercent: sysMetrics.CPUPercent,
+ MemoryPercent: sysMetrics.MemoryPercent,
+ MemoryUsedGB: sysMetrics.MemoryUsedGB,
+ MemoryTotalGB: sysMetrics.MemoryTotalGB,
+ DiskUsedGB: sysMetrics.DiskUsedGB,
+ DiskTotalGB: sysMetrics.DiskTotalGB,
+ DiskPercent: sysMetrics.DiskPercent,
+ Uptime: sysMetrics.Uptime,
+ Version: AgentVersion,
+ }
+ // Include empty heartbeat metadata to explicitly show disabled state
+ metrics.Metadata = map[string]interface{}{
+ "rapid_polling_enabled": false,
+ "rapid_polling_until": "",
+ }
+
+ // Send immediate check-in with updated heartbeat status
+ _, checkinErr := apiClient.GetCommands(cfg.AgentID, metrics)
+ if checkinErr != nil {
+ log.Printf("[Heartbeat] Failed to send immediate check-in: %v", checkinErr)
+ } else {
+ log.Printf("[Heartbeat] Immediate check-in sent successfully")
+ }
+ } else {
+ log.Printf("[Heartbeat] Failed to get system metrics for immediate check-in: %v", err)
+ }
+
+ log.Printf("[Heartbeat] Rapid polling disabled successfully")
+ return nil
+}
+
// reportSystemInfo collects and reports detailed system information to the server
func reportSystemInfo(apiClient *client.Client, cfg *config.Config) error {
// Collect detailed system information
diff --git a/aggregator-agent/install.sh b/aggregator-agent/install.sh
index 6f7b14f..b900743 100755
--- a/aggregator-agent/install.sh
+++ b/aggregator-agent/install.sh
@@ -29,6 +29,16 @@ create_user() {
useradd -r -s /bin/false -d "$AGENT_HOME" -m "$AGENT_USER"
echo "✓ User $AGENT_USER created"
fi
+
+ # Add user to docker group for Docker update scanning
+ if getent group docker &>/dev/null; then
+ echo "Adding $AGENT_USER to docker group..."
+ usermod -aG docker "$AGENT_USER"
+ echo "✓ User $AGENT_USER added to docker group"
+ else
+ echo "⚠ Docker group not found - Docker updates will not be available"
+ echo " (Install Docker first, then reinstall the agent to enable Docker support)"
+ fi
}
# Function to build agent binary
@@ -58,19 +68,19 @@ install_sudoers() {
# APT package management commands
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get update
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
-redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y
+redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install --dry-run --yes *
# DNF package management commands
-redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf refresh -y
+redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf makecache
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install -y *
-redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf upgrade -y
+redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf upgrade -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install --assumeno --downloadonly *
-# Docker operations (uncomment if needed)
-# redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker pull *
-# redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker image inspect *
-# redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
+# Docker operations
+redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker pull *
+redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker image inspect *
+redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
EOF
chmod 440 "$SUDOERS_FILE"
@@ -103,10 +113,10 @@ Restart=always
RestartSec=30
# Security hardening
-NoNewPrivileges=true
+# NoNewPrivileges=true - DISABLED: Prevents sudo from working
ProtectSystem=strict
ProtectHome=true
-ReadWritePaths=$AGENT_HOME
+ReadWritePaths=$AGENT_HOME /var/log /etc/aggregator
PrivateTmp=true
[Install]
diff --git a/aggregator-agent/internal/client/client.go b/aggregator-agent/internal/client/client.go
index ec0332b..fc886ac 100644
--- a/aggregator-agent/internal/client/client.go
+++ b/aggregator-agent/internal/client/client.go
@@ -16,9 +16,11 @@ import (
// Client handles API communication with the server
type Client struct {
- baseURL string
- token string
- http *http.Client
+ baseURL string
+ token string
+ http *http.Client
+ RapidPollingEnabled bool
+ RapidPollingUntil time.Time
}
// NewClient creates a new API client
@@ -159,20 +161,28 @@ type Command struct {
// CommandsResponse contains pending commands
type CommandsResponse struct {
- Commands []Command `json:"commands"`
+ Commands []Command `json:"commands"`
+ RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
+}
+
+// RapidPollingConfig contains rapid polling configuration from server
+type RapidPollingConfig struct {
+ Enabled bool `json:"enabled"`
+ Until string `json:"until"` // ISO 8601 timestamp
}
// SystemMetrics represents lightweight system metrics sent with check-ins
type SystemMetrics struct {
- CPUPercent float64 `json:"cpu_percent,omitempty"`
- MemoryPercent float64 `json:"memory_percent,omitempty"`
- MemoryUsedGB float64 `json:"memory_used_gb,omitempty"`
- MemoryTotalGB float64 `json:"memory_total_gb,omitempty"`
- DiskUsedGB float64 `json:"disk_used_gb,omitempty"`
- DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
- DiskPercent float64 `json:"disk_percent,omitempty"`
- Uptime string `json:"uptime,omitempty"`
- Version string `json:"version,omitempty"` // Agent version
+ CPUPercent float64 `json:"cpu_percent,omitempty"`
+ MemoryPercent float64 `json:"memory_percent,omitempty"`
+ MemoryUsedGB float64 `json:"memory_used_gb,omitempty"`
+ MemoryTotalGB float64 `json:"memory_total_gb,omitempty"`
+ DiskUsedGB float64 `json:"disk_used_gb,omitempty"`
+ DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
+ DiskPercent float64 `json:"disk_percent,omitempty"`
+ Uptime string `json:"uptime,omitempty"`
+ Version string `json:"version,omitempty"` // Agent version
+ Metadata map[string]interface{} `json:"metadata,omitempty"` // Additional metadata
}
// GetCommands retrieves pending commands from the server
@@ -219,6 +229,16 @@ func (c *Client) GetCommands(agentID uuid.UUID, metrics *SystemMetrics) ([]Comma
return nil, err
}
+ // Handle rapid polling configuration if provided
+ if result.RapidPolling != nil {
+ // Parse the timestamp
+ if until, err := time.Parse(time.RFC3339, result.RapidPolling.Until); err == nil {
+ // Update client's rapid polling configuration
+ c.RapidPollingEnabled = result.RapidPolling.Enabled
+ c.RapidPollingUntil = until
+ }
+ }
+
return result.Commands, nil
}
diff --git a/aggregator-agent/internal/config/config.go b/aggregator-agent/internal/config/config.go
index 9a2c53d..95c2a7a 100644
--- a/aggregator-agent/internal/config/config.go
+++ b/aggregator-agent/internal/config/config.go
@@ -5,21 +5,152 @@ import (
"fmt"
"os"
"path/filepath"
+ "time"
"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"` // Short-lived access token (24h)
- RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
- CheckInInterval int `json:"check_in_interval"`
+// ProxyConfig holds proxy configuration
+type ProxyConfig struct {
+ Enabled bool `json:"enabled"`
+ HTTP string `json:"http,omitempty"` // HTTP proxy URL
+ HTTPS string `json:"https,omitempty"` // HTTPS proxy URL
+ NoProxy string `json:"no_proxy,omitempty"` // Comma-separated hosts to bypass proxy
+ Username string `json:"username,omitempty"` // Proxy username (optional)
+ Password string `json:"password,omitempty"` // Proxy password (optional)
}
-// Load reads configuration from file
-func Load(configPath string) (*Config, error) {
+// TLSConfig holds TLS/security configuration
+type TLSConfig struct {
+ InsecureSkipVerify bool `json:"insecure_skip_verify"` // Skip TLS certificate verification
+ CertFile string `json:"cert_file,omitempty"` // Client certificate file
+ KeyFile string `json:"key_file,omitempty"` // Client key file
+ CAFile string `json:"ca_file,omitempty"` // CA certificate file
+}
+
+// NetworkConfig holds network-related configuration
+type NetworkConfig struct {
+ Timeout time.Duration `json:"timeout"` // Request timeout
+ RetryCount int `json:"retry_count"` // Number of retries
+ RetryDelay time.Duration `json:"retry_delay"` // Delay between retries
+ MaxIdleConn int `json:"max_idle_conn"` // Maximum idle connections
+}
+
+// LoggingConfig holds logging configuration
+type LoggingConfig struct {
+ Level string `json:"level"` // Log level (debug, info, warn, error)
+ File string `json:"file,omitempty"` // Log file path (optional)
+ MaxSize int `json:"max_size"` // Max log file size in MB
+ MaxBackups int `json:"max_backups"` // Max number of log file backups
+ MaxAge int `json:"max_age"` // Max age of log files in days
+}
+
+// Config holds agent configuration
+type Config struct {
+ // Server Configuration
+ ServerURL string `json:"server_url"`
+ RegistrationToken string `json:"registration_token,omitempty"` // One-time registration token
+
+ // Agent Authentication
+ AgentID uuid.UUID `json:"agent_id"`
+ Token string `json:"token"` // Short-lived access token (24h)
+ RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
+
+ // Agent Behavior
+ CheckInInterval int `json:"check_in_interval"`
+
+ // Rapid polling mode for faster response during operations
+ RapidPollingEnabled bool `json:"rapid_polling_enabled"`
+ RapidPollingUntil time.Time `json:"rapid_polling_until"`
+
+ // Network Configuration
+ Network NetworkConfig `json:"network,omitempty"`
+
+ // Proxy Configuration
+ Proxy ProxyConfig `json:"proxy,omitempty"`
+
+ // Security Configuration
+ TLS TLSConfig `json:"tls,omitempty"`
+
+ // Logging Configuration
+ Logging LoggingConfig `json:"logging,omitempty"`
+
+ // Agent Metadata
+ Tags []string `json:"tags,omitempty"` // User-defined tags
+ Metadata map[string]string `json:"metadata,omitempty"` // Custom metadata
+ DisplayName string `json:"display_name,omitempty"` // Human-readable name
+ Organization string `json:"organization,omitempty"` // Organization/group
+}
+
+// Load reads configuration from multiple sources with priority order:
+// 1. CLI flags
+// 2. Environment variables
+// 3. Configuration file
+// 4. Default values
+func Load(configPath string, cliFlags *CLIFlags) (*Config, error) {
+ // Start with defaults
+ config := getDefaultConfig()
+
+ // Load from config file if it exists
+ if fileConfig, err := loadFromFile(configPath); err == nil {
+ mergeConfig(config, fileConfig)
+ }
+
+ // Override with environment variables
+ mergeConfig(config, loadFromEnv())
+
+ // Override with CLI flags (highest priority)
+ if cliFlags != nil {
+ mergeConfig(config, loadFromFlags(cliFlags))
+ }
+
+ // Validate configuration
+ if err := validateConfig(config); err != nil {
+ return nil, fmt.Errorf("invalid configuration: %w", err)
+ }
+
+ return config, nil
+}
+
+// CLIFlags holds command line flag values
+type CLIFlags struct {
+ ServerURL string
+ RegistrationToken string
+ ProxyHTTP string
+ ProxyHTTPS string
+ ProxyNoProxy string
+ LogLevel string
+ ConfigFile string
+ Tags []string
+ Organization string
+ DisplayName string
+ InsecureTLS bool
+}
+
+// getDefaultConfig returns default configuration values
+func getDefaultConfig() *Config {
+ return &Config{
+ ServerURL: "http://localhost:8080",
+ CheckInInterval: 300, // 5 minutes
+ Network: NetworkConfig{
+ Timeout: 30 * time.Second,
+ RetryCount: 3,
+ RetryDelay: 5 * time.Second,
+ MaxIdleConn: 10,
+ },
+ Logging: LoggingConfig{
+ Level: "info",
+ MaxSize: 100, // 100MB
+ MaxBackups: 3,
+ MaxAge: 28, // 28 days
+ },
+ Tags: []string{},
+ Metadata: make(map[string]string),
+ }
+}
+
+// loadFromFile reads configuration from file
+func loadFromFile(configPath string) (*Config, error) {
// Ensure directory exists
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -30,8 +161,7 @@ func Load(configPath string) (*Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
- // Return empty config if file doesn't exist
- return &Config{}, nil
+ return getDefaultConfig(), nil // Return defaults if file doesn't exist
}
return nil, fmt.Errorf("failed to read config: %w", err)
}
@@ -44,6 +174,174 @@ func Load(configPath string) (*Config, error) {
return &config, nil
}
+// loadFromEnv loads configuration from environment variables
+func loadFromEnv() *Config {
+ config := &Config{}
+
+ if serverURL := os.Getenv("REDFLAG_SERVER_URL"); serverURL != "" {
+ config.ServerURL = serverURL
+ }
+ if token := os.Getenv("REDFLAG_REGISTRATION_TOKEN"); token != "" {
+ config.RegistrationToken = token
+ }
+ if proxyHTTP := os.Getenv("REDFLAG_HTTP_PROXY"); proxyHTTP != "" {
+ config.Proxy.Enabled = true
+ config.Proxy.HTTP = proxyHTTP
+ }
+ if proxyHTTPS := os.Getenv("REDFLAG_HTTPS_PROXY"); proxyHTTPS != "" {
+ config.Proxy.Enabled = true
+ config.Proxy.HTTPS = proxyHTTPS
+ }
+ if noProxy := os.Getenv("REDFLAG_NO_PROXY"); noProxy != "" {
+ config.Proxy.NoProxy = noProxy
+ }
+ if logLevel := os.Getenv("REDFLAG_LOG_LEVEL"); logLevel != "" {
+ if config.Logging == (LoggingConfig{}) {
+ config.Logging = LoggingConfig{}
+ }
+ config.Logging.Level = logLevel
+ }
+ if org := os.Getenv("REDFLAG_ORGANIZATION"); org != "" {
+ config.Organization = org
+ }
+ if displayName := os.Getenv("REDFLAG_DISPLAY_NAME"); displayName != "" {
+ config.DisplayName = displayName
+ }
+
+ return config
+}
+
+// loadFromFlags loads configuration from CLI flags
+func loadFromFlags(flags *CLIFlags) *Config {
+ config := &Config{}
+
+ if flags.ServerURL != "" {
+ config.ServerURL = flags.ServerURL
+ }
+ if flags.RegistrationToken != "" {
+ config.RegistrationToken = flags.RegistrationToken
+ }
+ if flags.ProxyHTTP != "" || flags.ProxyHTTPS != "" {
+ config.Proxy = ProxyConfig{
+ Enabled: true,
+ HTTP: flags.ProxyHTTP,
+ HTTPS: flags.ProxyHTTPS,
+ NoProxy: flags.ProxyNoProxy,
+ }
+ }
+ if flags.LogLevel != "" {
+ config.Logging = LoggingConfig{
+ Level: flags.LogLevel,
+ }
+ }
+ if len(flags.Tags) > 0 {
+ config.Tags = flags.Tags
+ }
+ if flags.Organization != "" {
+ config.Organization = flags.Organization
+ }
+ if flags.DisplayName != "" {
+ config.DisplayName = flags.DisplayName
+ }
+ if flags.InsecureTLS {
+ config.TLS = TLSConfig{
+ InsecureSkipVerify: true,
+ }
+ }
+
+ return config
+}
+
+// mergeConfig merges source config into target config (non-zero values only)
+func mergeConfig(target, source *Config) {
+ if source.ServerURL != "" {
+ target.ServerURL = source.ServerURL
+ }
+ if source.RegistrationToken != "" {
+ target.RegistrationToken = source.RegistrationToken
+ }
+ if source.CheckInInterval != 0 {
+ target.CheckInInterval = source.CheckInInterval
+ }
+ if source.AgentID != uuid.Nil {
+ target.AgentID = source.AgentID
+ }
+ if source.Token != "" {
+ target.Token = source.Token
+ }
+ if source.RefreshToken != "" {
+ target.RefreshToken = source.RefreshToken
+ }
+
+ // Merge nested configs
+ if source.Network != (NetworkConfig{}) {
+ target.Network = source.Network
+ }
+ if source.Proxy != (ProxyConfig{}) {
+ target.Proxy = source.Proxy
+ }
+ if source.TLS != (TLSConfig{}) {
+ target.TLS = source.TLS
+ }
+ if source.Logging != (LoggingConfig{}) {
+ target.Logging = source.Logging
+ }
+
+ // Merge metadata
+ if source.Tags != nil {
+ target.Tags = source.Tags
+ }
+ if source.Metadata != nil {
+ if target.Metadata == nil {
+ target.Metadata = make(map[string]string)
+ }
+ for k, v := range source.Metadata {
+ target.Metadata[k] = v
+ }
+ }
+ if source.DisplayName != "" {
+ target.DisplayName = source.DisplayName
+ }
+ if source.Organization != "" {
+ target.Organization = source.Organization
+ }
+
+ // Merge rapid polling settings
+ target.RapidPollingEnabled = source.RapidPollingEnabled
+ if !source.RapidPollingUntil.IsZero() {
+ target.RapidPollingUntil = source.RapidPollingUntil
+ }
+}
+
+// validateConfig validates configuration values
+func validateConfig(config *Config) error {
+ if config.ServerURL == "" {
+ return fmt.Errorf("server_url is required")
+ }
+ if config.CheckInInterval < 30 {
+ return fmt.Errorf("check_in_interval must be at least 30 seconds")
+ }
+ if config.CheckInInterval > 3600 {
+ return fmt.Errorf("check_in_interval cannot exceed 3600 seconds (1 hour)")
+ }
+ if config.Network.Timeout <= 0 {
+ return fmt.Errorf("network timeout must be positive")
+ }
+ if config.Network.RetryCount < 0 || config.Network.RetryCount > 10 {
+ return fmt.Errorf("retry_count must be between 0 and 10")
+ }
+
+ // Validate log level
+ validLogLevels := map[string]bool{
+ "debug": true, "info": true, "warn": true, "error": true,
+ }
+ if config.Logging.Level != "" && !validLogLevels[config.Logging.Level] {
+ return fmt.Errorf("invalid log level: %s", config.Logging.Level)
+ }
+
+ return nil
+}
+
// Save writes configuration to file
func (c *Config) Save(configPath string) error {
data, err := json.MarshalIndent(c, "", " ")
@@ -62,3 +360,13 @@ func (c *Config) Save(configPath string) error {
func (c *Config) IsRegistered() bool {
return c.AgentID != uuid.Nil && c.Token != ""
}
+
+// NeedsRegistration checks if the agent needs to register with a token
+func (c *Config) NeedsRegistration() bool {
+ return c.RegistrationToken != "" && c.AgentID == uuid.Nil
+}
+
+// HasRegistrationToken checks if the agent has a registration token
+func (c *Config) HasRegistrationToken() bool {
+ return c.RegistrationToken != ""
+}
diff --git a/aggregator-agent/internal/installer/apt.go b/aggregator-agent/internal/installer/apt.go
index 7683ac5..eac8196 100644
--- a/aggregator-agent/internal/installer/apt.go
+++ b/aggregator-agent/internal/installer/apt.go
@@ -146,6 +146,36 @@ func (i *APTInstaller) Upgrade() (*InstallResult, error) {
}, nil
}
+// UpdatePackage updates a specific package using APT
+func (i *APTInstaller) UpdatePackage(packageName string) (*InstallResult, error) {
+ startTime := time.Now()
+
+ // Update specific package using secure executor
+ updateResult, err := i.executor.ExecuteCommand("apt-get", []string{"install", "--only-upgrade", "-y", packageName})
+ duration := int(time.Since(startTime).Seconds())
+
+ if err != nil {
+ return &InstallResult{
+ Success: false,
+ ErrorMessage: fmt.Sprintf("APT update failed: %v", err),
+ Stdout: updateResult.Stdout,
+ Stderr: updateResult.Stderr,
+ ExitCode: updateResult.ExitCode,
+ DurationSeconds: duration,
+ }, err
+ }
+
+ return &InstallResult{
+ Success: true,
+ Stdout: updateResult.Stdout,
+ Stderr: updateResult.Stderr,
+ ExitCode: updateResult.ExitCode,
+ DurationSeconds: duration,
+ PackagesInstalled: []string{packageName},
+ Action: "update",
+ }, nil
+}
+
// DryRun performs a dry run installation to check dependencies
func (i *APTInstaller) DryRun(packageName string) (*InstallResult, error) {
startTime := time.Now()
diff --git a/aggregator-agent/internal/installer/dnf.go b/aggregator-agent/internal/installer/dnf.go
index 21615f2..c21e1fa 100644
--- a/aggregator-agent/internal/installer/dnf.go
+++ b/aggregator-agent/internal/installer/dnf.go
@@ -31,15 +31,8 @@ func (i *DNFInstaller) IsAvailable() bool {
func (i *DNFInstaller) Install(packageName string) (*InstallResult, error) {
startTime := time.Now()
- // Refresh package cache first using secure executor
- refreshResult, err := i.executor.ExecuteCommand("dnf", []string{"makecache"})
- if err != nil {
- refreshResult.DurationSeconds = int(time.Since(startTime).Seconds())
- refreshResult.ErrorMessage = fmt.Sprintf("Failed to refresh DNF cache: %v", err)
- return refreshResult, fmt.Errorf("dnf refresh failed: %w", err)
- }
-
- // Install package using secure executor
+ // For single package installs, skip makecache to avoid repository conflicts
+ // Only run makecache when installing multiple packages (InstallMultiple method)
installResult, err := i.executor.ExecuteCommand("dnf", []string{"install", "-y", packageName})
duration := int(time.Since(startTime).Seconds())
@@ -75,17 +68,11 @@ func (i *DNFInstaller) InstallMultiple(packageNames []string) (*InstallResult, e
startTime := time.Now()
- // Refresh package cache first using secure executor
- refreshResult, err := i.executor.ExecuteCommand("dnf", []string{"makecache"})
- if err != nil {
- refreshResult.DurationSeconds = int(time.Since(startTime).Seconds())
- refreshResult.ErrorMessage = fmt.Sprintf("Failed to refresh DNF cache: %v", err)
- return refreshResult, fmt.Errorf("dnf refresh failed: %w", err)
- }
-
// Install all packages in one command using secure executor
args := []string{"install", "-y"}
args = append(args, packageNames...)
+
+ // Install all packages in one command using secure executor
installResult, err := i.executor.ExecuteCommand("dnf", args)
duration := int(time.Since(startTime).Seconds())
@@ -299,6 +286,37 @@ func (i *DNFInstaller) extractPackageNameFromDNFLine(line string) string {
return ""
}
+// UpdatePackage updates a specific package using DNF
+func (i *DNFInstaller) UpdatePackage(packageName string) (*InstallResult, error) {
+ startTime := time.Now()
+
+ // Update specific package using secure executor
+ // Use 'dnf upgrade' instead of 'dnf install' for existing packages
+ updateResult, err := i.executor.ExecuteCommand("dnf", []string{"upgrade", "-y", packageName})
+ duration := int(time.Since(startTime).Seconds())
+
+ if err != nil {
+ return &InstallResult{
+ Success: false,
+ ErrorMessage: fmt.Sprintf("DNF upgrade failed: %v", err),
+ Stdout: updateResult.Stdout,
+ Stderr: updateResult.Stderr,
+ ExitCode: updateResult.ExitCode,
+ DurationSeconds: duration,
+ }, err
+ }
+
+ return &InstallResult{
+ Success: true,
+ Stdout: updateResult.Stdout,
+ Stderr: updateResult.Stderr,
+ ExitCode: updateResult.ExitCode,
+ DurationSeconds: duration,
+ PackagesInstalled: []string{packageName},
+ Action: "upgrade",
+ }, nil
+}
+
// GetPackageType returns type of packages this installer handles
func (i *DNFInstaller) GetPackageType() string {
return "dnf"
diff --git a/aggregator-agent/internal/installer/docker.go b/aggregator-agent/internal/installer/docker.go
index 121c515..386ab5e 100644
--- a/aggregator-agent/internal/installer/docker.go
+++ b/aggregator-agent/internal/installer/docker.go
@@ -60,6 +60,12 @@ func (i *DockerInstaller) Update(imageName, targetVersion string) (*InstallResul
}, nil
}
+// UpdatePackage updates a specific Docker image (alias for Update method)
+func (i *DockerInstaller) UpdatePackage(imageName string) (*InstallResult, error) {
+ // Docker uses same logic for updating as installing
+ return i.Update(imageName, "")
+}
+
// Install installs a Docker image (alias for Update)
func (i *DockerInstaller) Install(imageName string) (*InstallResult, error) {
return i.Update(imageName, "")
diff --git a/aggregator-agent/internal/installer/installer.go b/aggregator-agent/internal/installer/installer.go
index 43d8b19..04537f1 100644
--- a/aggregator-agent/internal/installer/installer.go
+++ b/aggregator-agent/internal/installer/installer.go
@@ -8,6 +8,7 @@ type Installer interface {
Install(packageName string) (*InstallResult, error)
InstallMultiple(packageNames []string) (*InstallResult, error)
Upgrade() (*InstallResult, error)
+ UpdatePackage(packageName string) (*InstallResult, error) // New: Update specific package
GetPackageType() string
DryRun(packageName string) (*InstallResult, error) // New: Perform dry run to check dependencies
}
diff --git a/aggregator-agent/internal/installer/security.go b/aggregator-agent/internal/installer/security.go
index 0f1022d..ba58d5b 100644
--- a/aggregator-agent/internal/installer/security.go
+++ b/aggregator-agent/internal/installer/security.go
@@ -23,6 +23,7 @@ var AllowedCommands = map[string][]string{
},
"dnf": {
"refresh",
+ "makecache",
"install",
"upgrade",
},
@@ -93,6 +94,9 @@ func (e *SecureCommandExecutor) validateDNFCommand(args []string) error {
if !contains(args, "-y") {
return fmt.Errorf("dnf refresh must include -y flag")
}
+ case "makecache":
+ // makecache doesn't require -y flag as it's read-only
+ return nil
case "install":
// Allow dry-run flags for dependency checking
dryRunFlags := []string{"--assumeno", "--downloadonly"}
@@ -165,12 +169,22 @@ func (e *SecureCommandExecutor) ExecuteCommand(baseCmd string, args []string) (*
}, fmt.Errorf("command validation failed: %w", err)
}
- // Log the command for audit purposes (in a real implementation, this would go to a secure log)
- fmt.Printf("[AUDIT] Executing command: %s %s\n", baseCmd, strings.Join(args, " "))
+ // Resolve the full path to the command (required for sudo to match sudoers rules)
+ fullPath, err := exec.LookPath(baseCmd)
+ if err != nil {
+ return &InstallResult{
+ Success: false,
+ ErrorMessage: fmt.Sprintf("Command not found: %s", baseCmd),
+ }, fmt.Errorf("command not found: %w", err)
+ }
- // Execute the command without sudo - it will be handled by sudoers
- fullArgs := append([]string{baseCmd}, args...)
- cmd := exec.Command(fullArgs[0], fullArgs[1:]...)
+ // Log the command for audit purposes (in a real implementation, this would go to a secure log)
+ fmt.Printf("[AUDIT] Executing command: sudo %s %s\n", fullPath, strings.Join(args, " "))
+
+ // Execute the command with sudo - requires sudoers configuration
+ // Use full path to match sudoers rules exactly
+ fullArgs := append([]string{fullPath}, args...)
+ cmd := exec.Command("sudo", fullArgs...)
output, err := cmd.CombinedOutput()
diff --git a/aggregator-agent/internal/installer/windows.go b/aggregator-agent/internal/installer/windows.go
index 9e40e88..5912420 100644
--- a/aggregator-agent/internal/installer/windows.go
+++ b/aggregator-agent/internal/installer/windows.go
@@ -117,6 +117,12 @@ func (i *WindowsUpdateInstaller) installViaPowerShell(packageNames []string) (st
return "Windows Updates installed via PowerShell", nil
}
+// UpdatePackage updates a specific Windows update (alias for Install method)
+func (i *WindowsUpdateInstaller) UpdatePackage(packageName string) (*InstallResult, error) {
+ // Windows uses same logic for updating as installing
+ return i.Install(packageName)
+}
+
// installViaWuauclt uses traditional Windows Update client
func (i *WindowsUpdateInstaller) installViaWuauclt(packageNames []string) (string, error) {
// Force detection of updates
diff --git a/aggregator-agent/internal/installer/winget.go b/aggregator-agent/internal/installer/winget.go
index 2a2011c..a4b7cde 100644
--- a/aggregator-agent/internal/installer/winget.go
+++ b/aggregator-agent/internal/installer/winget.go
@@ -371,4 +371,10 @@ type WingetPackage struct {
Source string `json:"Source"`
IsPinned bool `json:"IsPinned"`
PinReason string `json:"PinReason,omitempty"`
+}
+
+// UpdatePackage updates a specific winget package (alias for Install method)
+func (i *WingetInstaller) UpdatePackage(packageName string) (*InstallResult, error) {
+ // Winget uses same logic for updating as installing
+ return i.Install(packageName)
}
\ No newline at end of file
diff --git a/aggregator-server/cmd/server/main.go b/aggregator-server/cmd/server/main.go
index 48eac30..b3a9980 100644
--- a/aggregator-server/cmd/server/main.go
+++ b/aggregator-server/cmd/server/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "flag"
"fmt"
"log"
"path/filepath"
@@ -16,6 +17,29 @@ import (
)
func main() {
+ // Parse command line flags
+ var setup bool
+ var migrate bool
+ var version bool
+ flag.BoolVar(&setup, "setup", false, "Run setup wizard")
+ flag.BoolVar(&migrate, "migrate", false, "Run database migrations only")
+ flag.BoolVar(&version, "version", false, "Show version information")
+ flag.Parse()
+
+ // Handle special commands
+ if version {
+ fmt.Printf("RedFlag Server v0.1.0-alpha\n")
+ fmt.Printf("Self-hosted update management platform\n")
+ return
+ }
+
+ if setup {
+ if err := config.RunSetupWizard(); err != nil {
+ log.Fatal("Setup failed:", err)
+ }
+ return
+ }
+
// Load configuration
cfg, err := config.Load()
if err != nil {
@@ -23,15 +47,29 @@ func main() {
}
// Set JWT secret
- middleware.JWTSecret = cfg.JWTSecret
+ middleware.JWTSecret = cfg.Admin.JWTSecret
+
+ // Build database URL from new config structure
+ databaseURL := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
+ cfg.Database.Username, cfg.Database.Password, cfg.Database.Host, cfg.Database.Port, cfg.Database.Database)
// Connect to database
- db, err := database.Connect(cfg.DatabaseURL)
+ db, err := database.Connect(databaseURL)
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
defer db.Close()
+ // Handle migrate-only flag
+ if migrate {
+ migrationsPath := filepath.Join("internal", "database", "migrations")
+ if err := db.Migrate(migrationsPath); err != nil {
+ log.Fatal("Migration failed:", err)
+ }
+ fmt.Printf("✅ Database migrations completed\n")
+ return
+ }
+
// Run migrations
migrationsPath := filepath.Join("internal", "database", "migrations")
if err := db.Migrate(migrationsPath); err != nil {
@@ -45,18 +83,24 @@ func main() {
updateQueries := queries.NewUpdateQueries(db.DB)
commandQueries := queries.NewCommandQueries(db.DB)
refreshTokenQueries := queries.NewRefreshTokenQueries(db.DB)
+ registrationTokenQueries := queries.NewRegistrationTokenQueries(db.DB)
// Initialize services
timezoneService := services.NewTimezoneService(cfg)
timeoutService := services.NewTimeoutService(commandQueries, updateQueries)
+ // Initialize rate limiter
+ rateLimiter := middleware.NewRateLimiter()
+
// Initialize handlers
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, cfg.CheckInInterval, cfg.LatestAgentVersion)
- updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries)
- authHandler := handlers.NewAuthHandler(cfg.JWTSecret)
+ updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries, agentHandler)
+ authHandler := handlers.NewAuthHandler(cfg.Admin.JWTSecret)
statsHandler := handlers.NewStatsHandler(agentQueries, updateQueries)
settingsHandler := handlers.NewSettingsHandler(timezoneService)
dockerHandler := handlers.NewDockerHandler(updateQueries, agentQueries, commandQueries)
+ registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
+ rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
// Setup router
router := gin.Default()
@@ -72,24 +116,26 @@ func main() {
// API routes
api := router.Group("/api/v1")
{
- // Authentication routes
- api.POST("/auth/login", authHandler.Login)
+ // Authentication routes (with rate limiting)
+ api.POST("/auth/login", rateLimiter.RateLimit("public_access", middleware.KeyByIP), authHandler.Login)
api.POST("/auth/logout", authHandler.Logout)
api.GET("/auth/verify", authHandler.VerifyToken)
- // Public routes (no authentication required)
- api.POST("/agents/register", agentHandler.RegisterAgent)
- api.POST("/agents/renew", agentHandler.RenewToken)
+ // Public routes (no authentication required, with rate limiting)
+ api.POST("/agents/register", rateLimiter.RateLimit("agent_registration", middleware.KeyByIP), agentHandler.RegisterAgent)
+ api.POST("/agents/renew", rateLimiter.RateLimit("public_access", middleware.KeyByIP), agentHandler.RenewToken)
// 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)
- agents.POST("/:id/dependencies", updateHandler.ReportDependencies)
- agents.POST("/:id/system-info", agentHandler.ReportSystemInfo)
+ agents.POST("/:id/updates", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportUpdates)
+ agents.POST("/:id/logs", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportLog)
+ agents.POST("/:id/dependencies", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportDependencies)
+ agents.POST("/:id/system-info", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), agentHandler.ReportSystemInfo)
+ agents.POST("/:id/rapid-mode", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), agentHandler.SetRapidPollingMode)
+ agents.DELETE("/:id", agentHandler.UnregisterAgent)
}
// Dashboard/Web routes (protected by web auth)
@@ -101,7 +147,8 @@ func main() {
dashboard.GET("/agents/:id", agentHandler.GetAgent)
dashboard.POST("/agents/:id/scan", agentHandler.TriggerScan)
dashboard.POST("/agents/:id/update", agentHandler.TriggerUpdate)
- dashboard.DELETE("/agents/:id", agentHandler.UnregisterAgent)
+ dashboard.POST("/agents/:id/heartbeat", agentHandler.TriggerHeartbeat)
+ dashboard.GET("/agents/:id/heartbeat", agentHandler.GetHeartbeatStatus)
dashboard.GET("/updates", updateHandler.ListUpdates)
dashboard.GET("/updates/:id", updateHandler.GetUpdate)
dashboard.GET("/updates/:id/logs", updateHandler.GetUpdateLogs)
@@ -120,6 +167,7 @@ func main() {
dashboard.GET("/commands/recent", updateHandler.GetRecentCommands)
dashboard.POST("/commands/:id/retry", updateHandler.RetryCommand)
dashboard.POST("/commands/:id/cancel", updateHandler.CancelCommand)
+ dashboard.DELETE("/commands/failed", updateHandler.ClearFailedCommands)
// Settings routes
dashboard.GET("/settings/timezone", settingsHandler.GetTimezone)
@@ -132,6 +180,25 @@ func main() {
dashboard.POST("/docker/containers/:container_id/images/:image_id/approve", dockerHandler.ApproveUpdate)
dashboard.POST("/docker/containers/:container_id/images/:image_id/reject", dockerHandler.RejectUpdate)
dashboard.POST("/docker/containers/:container_id/images/:image_id/install", dockerHandler.InstallUpdate)
+
+ // Admin/Registration Token routes (for agent enrollment management)
+ admin := dashboard.Group("/admin")
+ {
+ admin.POST("/registration-tokens", rateLimiter.RateLimit("admin_token_gen", middleware.KeyByUserID), registrationTokenHandler.GenerateRegistrationToken)
+ admin.GET("/registration-tokens", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ListRegistrationTokens)
+ admin.GET("/registration-tokens/active", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetActiveRegistrationTokens)
+ admin.DELETE("/registration-tokens/:token", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.RevokeRegistrationToken)
+ admin.POST("/registration-tokens/cleanup", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.CleanupExpiredTokens)
+ admin.GET("/registration-tokens/stats", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetTokenStats)
+ admin.GET("/registration-tokens/validate", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ValidateRegistrationToken)
+
+ // Rate Limit Management
+ admin.GET("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.GetRateLimitSettings)
+ admin.PUT("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.UpdateRateLimitSettings)
+ admin.POST("/rate-limits/reset", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.ResetRateLimitSettings)
+ admin.GET("/rate-limits/stats", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.GetRateLimitStats)
+ admin.POST("/rate-limits/cleanup", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.CleanupRateLimitEntries)
+ }
}
}
@@ -166,8 +233,11 @@ func main() {
}()
// Start server
- addr := ":" + cfg.ServerPort
- fmt.Printf("\n🚩 RedFlag Aggregator Server starting on %s\n\n", addr)
+ addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
+ fmt.Printf("\nRedFlag Aggregator Server starting on %s\n", addr)
+ fmt.Printf("Admin interface: http://%s:%d/admin\n", cfg.Server.Host, cfg.Server.Port)
+ fmt.Printf("Dashboard: http://%s:%d\n\n", cfg.Server.Host, cfg.Server.Port)
+
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
index 38e377c..6a81298 100644
--- a/aggregator-server/go.mod
+++ b/aggregator-server/go.mod
@@ -9,6 +9,7 @@ require (
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
+ golang.org/x/term v0.33.0
)
require (
diff --git a/aggregator-server/go.sum b/aggregator-server/go.sum
index 4560e26..bc4792e 100644
--- a/aggregator-server/go.sum
+++ b/aggregator-server/go.sum
@@ -92,6 +92,8 @@ 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/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
+golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
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=
diff --git a/aggregator-server/internal/api/handlers/agents.go b/aggregator-server/internal/api/handlers/agents.go
index 87e1206..2d6dc28 100644
--- a/aggregator-server/internal/api/handlers/agents.go
+++ b/aggregator-server/internal/api/handlers/agents.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "fmt"
"log"
"net/http"
"time"
@@ -107,15 +108,16 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
// Try to parse optional system metrics from request body
var metrics struct {
- CPUPercent float64 `json:"cpu_percent,omitempty"`
- MemoryPercent float64 `json:"memory_percent,omitempty"`
- MemoryUsedGB float64 `json:"memory_used_gb,omitempty"`
- MemoryTotalGB float64 `json:"memory_total_gb,omitempty"`
- DiskUsedGB float64 `json:"disk_used_gb,omitempty"`
- DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
- DiskPercent float64 `json:"disk_percent,omitempty"`
- Uptime string `json:"uptime,omitempty"`
- Version string `json:"version,omitempty"`
+ CPUPercent float64 `json:"cpu_percent,omitempty"`
+ MemoryPercent float64 `json:"memory_percent,omitempty"`
+ MemoryUsedGB float64 `json:"memory_used_gb,omitempty"`
+ MemoryTotalGB float64 `json:"memory_total_gb,omitempty"`
+ DiskUsedGB float64 `json:"disk_used_gb,omitempty"`
+ DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
+ DiskPercent float64 `json:"disk_percent,omitempty"`
+ Uptime string `json:"uptime,omitempty"`
+ Version string `json:"version,omitempty"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// Parse metrics if provided (optional, won't fail if empty)
@@ -130,21 +132,21 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
// Always handle version information if provided
if metrics.Version != "" {
- // Get current agent to preserve existing metadata
- agent, err := h.agentQueries.GetAgentByID(agentID)
- if err == nil && agent.Metadata != nil {
- // Update agent's current version
- if err := h.agentQueries.UpdateAgentVersion(agentID, metrics.Version); err != nil {
- log.Printf("Warning: Failed to update agent version: %v", err)
- } else {
- // Check if update is available
- updateAvailable := utils.IsNewerVersion(h.latestAgentVersion, metrics.Version)
+ // Update agent's current version in database (primary source of truth)
+ if err := h.agentQueries.UpdateAgentVersion(agentID, metrics.Version); err != nil {
+ log.Printf("Warning: Failed to update agent version: %v", err)
+ } else {
+ // Check if update is available
+ updateAvailable := utils.IsNewerVersion(h.latestAgentVersion, metrics.Version)
- // Update agent's update availability status
- if err := h.agentQueries.UpdateAgentUpdateAvailable(agentID, updateAvailable); err != nil {
- log.Printf("Warning: Failed to update agent update availability: %v", err)
- }
+ // Update agent's update availability status
+ if err := h.agentQueries.UpdateAgentUpdateAvailable(agentID, updateAvailable); err != nil {
+ log.Printf("Warning: Failed to update agent update availability: %v", err)
+ }
+ // Get current agent for logging and metadata update
+ agent, err := h.agentQueries.GetAgentByID(agentID)
+ if err == nil {
// Log version check
if updateAvailable {
log.Printf("🔄 Agent %s (%s) version %s has update available: %s",
@@ -154,11 +156,20 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
agent.Hostname, agentID, metrics.Version)
}
- // Store version in metadata as well
+ // Store version in metadata as well (for backwards compatibility)
+ // Initialize metadata if nil
+ if agent.Metadata == nil {
+ agent.Metadata = make(models.JSONB)
+ }
agent.Metadata["reported_version"] = metrics.Version
agent.Metadata["latest_version"] = h.latestAgentVersion
agent.Metadata["update_available"] = updateAvailable
agent.Metadata["version_checked_at"] = time.Now().Format(time.RFC3339)
+
+ // Update agent metadata
+ if err := h.agentQueries.UpdateAgent(agent); err != nil {
+ log.Printf("Warning: Failed to update agent metadata: %v", err)
+ }
}
}
}
@@ -179,6 +190,29 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
agent.Metadata["uptime"] = metrics.Uptime
agent.Metadata["metrics_updated_at"] = time.Now().Format(time.RFC3339)
+ // Process heartbeat metadata from agent check-ins
+ if metrics.Metadata != nil {
+ if rapidPollingEnabled, exists := metrics.Metadata["rapid_polling_enabled"]; exists {
+ if rapidPollingUntil, exists := metrics.Metadata["rapid_polling_until"]; exists {
+ // Parse the until timestamp
+ if untilTime, err := time.Parse(time.RFC3339, rapidPollingUntil.(string)); err == nil {
+ // Validate if rapid polling is still active (not expired)
+ isActive := rapidPollingEnabled.(bool) && time.Now().Before(untilTime)
+
+ // Store heartbeat status in agent metadata
+ agent.Metadata["rapid_polling_enabled"] = rapidPollingEnabled
+ agent.Metadata["rapid_polling_until"] = rapidPollingUntil
+ agent.Metadata["rapid_polling_active"] = isActive
+
+ log.Printf("[Heartbeat] Agent %s heartbeat status: enabled=%v, until=%v, active=%v",
+ agentID, rapidPollingEnabled, rapidPollingUntil, isActive)
+ } else {
+ log.Printf("[Heartbeat] Failed to parse rapid_polling_until timestamp for agent %s: %v", agentID, err)
+ }
+ }
+ }
+ }
+
// Update agent with new metadata
if err := h.agentQueries.UpdateAgent(agent); err != nil {
log.Printf("Warning: Failed to update agent metrics: %v", err)
@@ -192,6 +226,37 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
return
}
+ // Process heartbeat metadata from agent check-ins
+ if metrics.Metadata != nil {
+ agent, err := h.agentQueries.GetAgentByID(agentID)
+ if err == nil && agent.Metadata != nil {
+ if rapidPollingEnabled, exists := metrics.Metadata["rapid_polling_enabled"]; exists {
+ if rapidPollingUntil, exists := metrics.Metadata["rapid_polling_until"]; exists {
+ // Parse the until timestamp
+ if untilTime, err := time.Parse(time.RFC3339, rapidPollingUntil.(string)); err == nil {
+ // Validate if rapid polling is still active (not expired)
+ isActive := rapidPollingEnabled.(bool) && time.Now().Before(untilTime)
+
+ // Store heartbeat status in agent metadata
+ agent.Metadata["rapid_polling_enabled"] = rapidPollingEnabled
+ agent.Metadata["rapid_polling_until"] = rapidPollingUntil
+ agent.Metadata["rapid_polling_active"] = isActive
+
+ log.Printf("[Heartbeat] Agent %s heartbeat status: enabled=%v, until=%v, active=%v",
+ agentID, rapidPollingEnabled, rapidPollingUntil, isActive)
+
+ // Update agent with new metadata
+ if err := h.agentQueries.UpdateAgent(agent); err != nil {
+ log.Printf("[Heartbeat] Warning: Failed to update agent heartbeat metadata: %v", err)
+ }
+ } else {
+ log.Printf("[Heartbeat] Failed to parse rapid_polling_until timestamp for agent %s: %v", agentID, err)
+ }
+ }
+ }
+ }
+ }
+
// Check for version updates for agents that don't send version in metrics
// This ensures agents like Metis that don't report version still get update checks
if metrics.Version == "" {
@@ -239,8 +304,97 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
h.commandQueries.MarkCommandSent(cmd.ID)
}
+ // Check if rapid polling should be enabled
+ var rapidPolling *models.RapidPollingConfig
+
+ // Enable rapid polling if there are commands to process
+ if len(commandItems) > 0 {
+ rapidPolling = &models.RapidPollingConfig{
+ Enabled: true,
+ Until: time.Now().Add(10 * time.Minute).Format(time.RFC3339), // 10 minutes default
+ }
+ } else {
+ // Check if agent has rapid polling already configured in metadata
+ agent, err := h.agentQueries.GetAgentByID(agentID)
+ if err == nil && agent.Metadata != nil {
+ if enabled, ok := agent.Metadata["rapid_polling_enabled"].(bool); ok && enabled {
+ if untilStr, ok := agent.Metadata["rapid_polling_until"].(string); ok {
+ if until, err := time.Parse(time.RFC3339, untilStr); err == nil && time.Now().Before(until) {
+ rapidPolling = &models.RapidPollingConfig{
+ Enabled: true,
+ Until: untilStr,
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Detect stale heartbeat state: Server thinks it's active, but agent didn't report it
+ // This happens when agent restarts without heartbeat mode
+ agent, err := h.agentQueries.GetAgentByID(agentID)
+ if err == nil && agent.Metadata != nil {
+ // Check if server metadata shows heartbeat active
+ if serverEnabled, ok := agent.Metadata["rapid_polling_enabled"].(bool); ok && serverEnabled {
+ if untilStr, ok := agent.Metadata["rapid_polling_until"].(string); ok {
+ if until, err := time.Parse(time.RFC3339, untilStr); err == nil && time.Now().Before(until) {
+ // Server thinks heartbeat is active and not expired
+ // Check if agent is reporting heartbeat in this check-in
+ agentReportingHeartbeat := false
+ if metrics.Metadata != nil {
+ if agentEnabled, exists := metrics.Metadata["rapid_polling_enabled"]; exists {
+ agentReportingHeartbeat = agentEnabled.(bool)
+ }
+ }
+
+ // If agent is NOT reporting heartbeat but server expects it → stale state
+ if !agentReportingHeartbeat {
+ log.Printf("[Heartbeat] Stale heartbeat detected for agent %s - server expected active until %s, but agent not reporting heartbeat (likely restarted)",
+ agentID, until.Format(time.RFC3339))
+
+ // Clear stale heartbeat state
+ agent.Metadata["rapid_polling_enabled"] = false
+ delete(agent.Metadata, "rapid_polling_until")
+
+ if err := h.agentQueries.UpdateAgent(agent); err != nil {
+ log.Printf("[Heartbeat] Warning: Failed to clear stale heartbeat state: %v", err)
+ } else {
+ log.Printf("[Heartbeat] Cleared stale heartbeat state for agent %s", agentID)
+
+ // Create audit command to show in history
+ now := time.Now()
+ auditCmd := &models.AgentCommand{
+ ID: uuid.New(),
+ AgentID: agentID,
+ CommandType: models.CommandTypeDisableHeartbeat,
+ Params: models.JSONB{},
+ Status: models.CommandStatusCompleted,
+ Result: models.JSONB{
+ "message": "Heartbeat cleared - agent restarted without active heartbeat mode",
+ },
+ CreatedAt: now,
+ SentAt: &now,
+ CompletedAt: &now,
+ }
+
+ if err := h.commandQueries.CreateCommand(auditCmd); err != nil {
+ log.Printf("[Heartbeat] Warning: Failed to create audit command for stale heartbeat: %v", err)
+ } else {
+ log.Printf("[Heartbeat] Created audit trail for stale heartbeat cleanup (agent %s)", agentID)
+ }
+ }
+
+ // Clear rapidPolling response since we just disabled it
+ rapidPolling = nil
+ }
+ }
+ }
+ }
+ }
+
response := models.CommandsResponse{
- Commands: commandItems,
+ Commands: commandItems,
+ RapidPolling: rapidPolling,
}
c.JSON(http.StatusOK, response)
@@ -312,6 +466,124 @@ func (h *AgentHandler) TriggerScan(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "scan triggered", "command_id": cmd.ID})
}
+// TriggerHeartbeat creates a heartbeat toggle command for an agent
+func (h *AgentHandler) TriggerHeartbeat(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
+ }
+
+ var request struct {
+ Enabled bool `json:"enabled"`
+ DurationMinutes int `json:"duration_minutes"`
+ }
+
+ if err := c.ShouldBindJSON(&request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Determine command type based on enabled flag
+ commandType := models.CommandTypeDisableHeartbeat
+ if request.Enabled {
+ commandType = models.CommandTypeEnableHeartbeat
+ }
+
+ // Create heartbeat command with duration parameter
+ cmd := &models.AgentCommand{
+ ID: uuid.New(),
+ AgentID: agentID,
+ CommandType: commandType,
+ Params: models.JSONB{
+ "duration_minutes": request.DurationMinutes,
+ },
+ Status: models.CommandStatusPending,
+ }
+
+ if err := h.commandQueries.CreateCommand(cmd); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create heartbeat command"})
+ return
+ }
+
+ // TODO: Clean up previous heartbeat commands for this agent (only for enable commands)
+ // if request.Enabled {
+ // // Mark previous heartbeat commands as 'replaced' to clean up Live Operations view
+ // if err := h.commandQueries.MarkPreviousHeartbeatCommandsReplaced(agentID, cmd.ID); err != nil {
+ // log.Printf("Warning: Failed to mark previous heartbeat commands as replaced: %v", err)
+ // // Don't fail the request, just log the warning
+ // } else {
+ // log.Printf("[Heartbeat] Cleaned up previous heartbeat commands for agent %s", agentID)
+ // }
+ // }
+
+ action := "disabled"
+ if request.Enabled {
+ action = "enabled"
+ }
+
+ log.Printf("💓 Heartbeat %s command created for agent %s (duration: %d minutes)",
+ action, agentID, request.DurationMinutes)
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": fmt.Sprintf("heartbeat %s command sent", action),
+ "command_id": cmd.ID,
+ "enabled": request.Enabled,
+ })
+}
+
+// GetHeartbeatStatus returns the current heartbeat status for an agent
+func (h *AgentHandler) GetHeartbeatStatus(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
+ }
+
+ // Get agent and their heartbeat metadata
+ agent, err := h.agentQueries.GetAgentByID(agentID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
+ return
+ }
+
+ // Extract heartbeat information from metadata
+ response := gin.H{
+ "enabled": false,
+ "until": nil,
+ "active": false,
+ "duration_minutes": 0,
+ }
+
+ if agent.Metadata != nil {
+ // Check if heartbeat is enabled in metadata
+ if enabled, exists := agent.Metadata["rapid_polling_enabled"]; exists {
+ response["enabled"] = enabled.(bool)
+
+ // If enabled, get the until time and check if still active
+ if enabled.(bool) {
+ if untilStr, exists := agent.Metadata["rapid_polling_until"]; exists {
+ response["until"] = untilStr.(string)
+
+ // Parse the until timestamp to check if still active
+ if untilTime, err := time.Parse(time.RFC3339, untilStr.(string)); err == nil {
+ response["active"] = time.Now().Before(untilTime)
+ }
+ }
+
+ // Get duration if available
+ if duration, exists := agent.Metadata["rapid_polling_duration_minutes"]; exists {
+ response["duration_minutes"] = duration.(float64)
+ }
+ }
+ }
+ }
+
+ c.JSON(http.StatusOK, response)
+}
+
// TriggerUpdate creates an update command for an agent
func (h *AgentHandler) TriggerUpdate(c *gin.Context) {
idStr := c.Param("id")
@@ -541,3 +813,114 @@ func (h *AgentHandler) ReportSystemInfo(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "system info updated successfully"})
}
+
+// EnableRapidPollingMode enables rapid polling for an agent by updating metadata
+func (h *AgentHandler) EnableRapidPollingMode(agentID uuid.UUID, durationMinutes int) error {
+ // Get current agent
+ agent, err := h.agentQueries.GetAgentByID(agentID)
+ if err != nil {
+ return fmt.Errorf("failed to get agent: %w", err)
+ }
+
+ // Calculate new rapid polling end time
+ newRapidPollingUntil := time.Now().Add(time.Duration(durationMinutes) * time.Minute)
+
+ // Update agent metadata with rapid polling settings
+ if agent.Metadata == nil {
+ agent.Metadata = models.JSONB{}
+ }
+
+ // Check if rapid polling is already active
+ if enabled, ok := agent.Metadata["rapid_polling_enabled"].(bool); ok && enabled {
+ if untilStr, ok := agent.Metadata["rapid_polling_until"].(string); ok {
+ if currentUntil, err := time.Parse(time.RFC3339, untilStr); err == nil {
+ // If current heartbeat expires later than the new duration, keep the longer duration
+ if currentUntil.After(newRapidPollingUntil) {
+ log.Printf("💓 Heartbeat already active for agent %s (%s), keeping longer duration (expires: %s)",
+ agent.Hostname, agentID, currentUntil.Format(time.RFC3339))
+ return nil
+ }
+ // Otherwise extend the heartbeat
+ log.Printf("💓 Extending heartbeat for agent %s (%s) from %s to %s",
+ agent.Hostname, agentID,
+ currentUntil.Format(time.RFC3339),
+ newRapidPollingUntil.Format(time.RFC3339))
+ }
+ }
+ } else {
+ log.Printf("💓 Enabling heartbeat mode for agent %s (%s) for %d minutes",
+ agent.Hostname, agentID, durationMinutes)
+ }
+
+ // Set/update rapid polling settings
+ agent.Metadata["rapid_polling_enabled"] = true
+ agent.Metadata["rapid_polling_until"] = newRapidPollingUntil.Format(time.RFC3339)
+
+ // Update agent in database
+ if err := h.agentQueries.UpdateAgent(agent); err != nil {
+ return fmt.Errorf("failed to update agent with rapid polling: %w", err)
+ }
+
+ return nil
+}
+
+// SetRapidPollingMode enables rapid polling mode for an agent
+// TODO: Rate limiting should be implemented for rapid polling endpoints to prevent abuse (technical debt)
+func (h *AgentHandler) SetRapidPollingMode(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
+ }
+
+ // Check if agent exists
+ agent, err := h.agentQueries.GetAgentByID(agentID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
+ return
+ }
+
+ var req struct {
+ DurationMinutes int `json:"duration_minutes" binding:"required,min=1,max=60"`
+ Enabled bool `json:"enabled"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Calculate rapid polling end time
+ rapidPollingUntil := time.Now().Add(time.Duration(req.DurationMinutes) * time.Minute)
+
+ // Update agent metadata with rapid polling settings
+ if agent.Metadata == nil {
+ agent.Metadata = models.JSONB{}
+ }
+ agent.Metadata["rapid_polling_enabled"] = req.Enabled
+ agent.Metadata["rapid_polling_until"] = rapidPollingUntil.Format(time.RFC3339)
+
+ // Update agent in database
+ if err := h.agentQueries.UpdateAgent(agent); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update agent"})
+ return
+ }
+
+ status := "disabled"
+ duration := 0
+ if req.Enabled {
+ status = "enabled"
+ duration = req.DurationMinutes
+ }
+
+ log.Printf("🚀 Rapid polling mode %s for agent %s (%s) for %d minutes",
+ status, agent.Hostname, agentID, duration)
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": fmt.Sprintf("Rapid polling mode %s", status),
+ "enabled": req.Enabled,
+ "duration_minutes": req.DurationMinutes,
+ "rapid_polling_until": rapidPollingUntil,
+ })
+}
diff --git a/aggregator-server/internal/api/handlers/docker.go b/aggregator-server/internal/api/handlers/docker.go
index 4c92607..effbff9 100644
--- a/aggregator-server/internal/api/handlers/docker.go
+++ b/aggregator-server/internal/api/handlers/docker.go
@@ -378,7 +378,7 @@ func (h *DockerHandler) RejectUpdate(c *gin.Context) {
}
// For now, we'll mark as rejected (this would need a proper reject method in queries)
- if err := h.updateQueries.UpdatePackageStatus(update.AgentID, "docker", update.PackageName, "rejected", nil); err != nil {
+ if err := h.updateQueries.UpdatePackageStatus(update.AgentID, "docker", update.PackageName, "rejected", nil, nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject Docker update"})
return
}
diff --git a/aggregator-server/internal/api/handlers/rate_limits.go b/aggregator-server/internal/api/handlers/rate_limits.go
new file mode 100644
index 0000000..b1aa920
--- /dev/null
+++ b/aggregator-server/internal/api/handlers/rate_limits.go
@@ -0,0 +1,146 @@
+package handlers
+
+import (
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/aggregator-project/aggregator-server/internal/api/middleware"
+ "github.com/gin-gonic/gin"
+)
+
+type RateLimitHandler struct {
+ rateLimiter *middleware.RateLimiter
+}
+
+func NewRateLimitHandler(rateLimiter *middleware.RateLimiter) *RateLimitHandler {
+ return &RateLimitHandler{
+ rateLimiter: rateLimiter,
+ }
+}
+
+// GetRateLimitSettings returns current rate limit configuration
+func (h *RateLimitHandler) GetRateLimitSettings(c *gin.Context) {
+ settings := h.rateLimiter.GetSettings()
+ c.JSON(http.StatusOK, gin.H{
+ "settings": settings,
+ "updated_at": time.Now(),
+ })
+}
+
+// UpdateRateLimitSettings updates rate limit configuration
+func (h *RateLimitHandler) UpdateRateLimitSettings(c *gin.Context) {
+ var settings middleware.RateLimitSettings
+ if err := c.ShouldBindJSON(&settings); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format: " + err.Error()})
+ return
+ }
+
+ // Validate settings
+ if err := h.validateRateLimitSettings(settings); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Update rate limiter settings
+ h.rateLimiter.UpdateSettings(settings)
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "Rate limit settings updated successfully",
+ "settings": settings,
+ "updated_at": time.Now(),
+ })
+}
+
+// ResetRateLimitSettings resets to default values
+func (h *RateLimitHandler) ResetRateLimitSettings(c *gin.Context) {
+ defaultSettings := middleware.DefaultRateLimitSettings()
+ h.rateLimiter.UpdateSettings(defaultSettings)
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "Rate limit settings reset to defaults",
+ "settings": defaultSettings,
+ "updated_at": time.Now(),
+ })
+}
+
+// GetRateLimitStats returns current rate limit statistics
+func (h *RateLimitHandler) GetRateLimitStats(c *gin.Context) {
+ settings := h.rateLimiter.GetSettings()
+
+ // Calculate total requests and windows
+ stats := gin.H{
+ "total_configured_limits": 6,
+ "enabled_limits": 0,
+ "total_requests_per_minute": 0,
+ "settings": settings,
+ }
+
+ // Count enabled limits and total requests
+ for _, config := range []middleware.RateLimitConfig{
+ settings.AgentRegistration,
+ settings.AgentCheckIn,
+ settings.AgentReports,
+ settings.AdminTokenGen,
+ settings.AdminOperations,
+ settings.PublicAccess,
+ } {
+ if config.Enabled {
+ stats["enabled_limits"] = stats["enabled_limits"].(int) + 1
+ }
+ stats["total_requests_per_minute"] = stats["total_requests_per_minute"].(int) + config.Requests
+ }
+
+ c.JSON(http.StatusOK, stats)
+}
+
+// CleanupRateLimitEntries manually triggers cleanup of expired entries
+func (h *RateLimitHandler) CleanupRateLimitEntries(c *gin.Context) {
+ h.rateLimiter.CleanupExpiredEntries()
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "Rate limit entries cleanup completed",
+ "timestamp": time.Now(),
+ })
+}
+
+// validateRateLimitSettings validates the provided rate limit settings
+func (h *RateLimitHandler) validateRateLimitSettings(settings middleware.RateLimitSettings) error {
+ // Validate each configuration
+ validations := []struct {
+ name string
+ config middleware.RateLimitConfig
+ }{
+ {"agent_registration", settings.AgentRegistration},
+ {"agent_checkin", settings.AgentCheckIn},
+ {"agent_reports", settings.AgentReports},
+ {"admin_token_generation", settings.AdminTokenGen},
+ {"admin_operations", settings.AdminOperations},
+ {"public_access", settings.PublicAccess},
+ }
+
+ for _, validation := range validations {
+ if validation.config.Requests <= 0 {
+ return fmt.Errorf("%s: requests must be greater than 0", validation.name)
+ }
+ if validation.config.Window <= 0 {
+ return fmt.Errorf("%s: window must be greater than 0", validation.name)
+ }
+ if validation.config.Window > 24*time.Hour {
+ return fmt.Errorf("%s: window cannot exceed 24 hours", validation.name)
+ }
+ if validation.config.Requests > 1000 {
+ return fmt.Errorf("%s: requests cannot exceed 1000 per window", validation.name)
+ }
+ }
+
+ // Specific validations for different endpoint types
+ if settings.AgentRegistration.Requests > 10 {
+ return fmt.Errorf("agent_registration: requests should not exceed 10 per minute for security")
+ }
+ if settings.PublicAccess.Requests > 50 {
+ return fmt.Errorf("public_access: requests should not exceed 50 per minute for security")
+ }
+
+ return nil
+}
\ No newline at end of file
diff --git a/aggregator-server/internal/api/handlers/registration_tokens.go b/aggregator-server/internal/api/handlers/registration_tokens.go
new file mode 100644
index 0000000..9c48da7
--- /dev/null
+++ b/aggregator-server/internal/api/handlers/registration_tokens.go
@@ -0,0 +1,284 @@
+package handlers
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/aggregator-project/aggregator-server/internal/config"
+ "github.com/aggregator-project/aggregator-server/internal/database/queries"
+ "github.com/gin-gonic/gin"
+)
+
+type RegistrationTokenHandler struct {
+ tokenQueries *queries.RegistrationTokenQueries
+ agentQueries *queries.AgentQueries
+ config *config.Config
+}
+
+func NewRegistrationTokenHandler(tokenQueries *queries.RegistrationTokenQueries, agentQueries *queries.AgentQueries, config *config.Config) *RegistrationTokenHandler {
+ return &RegistrationTokenHandler{
+ tokenQueries: tokenQueries,
+ agentQueries: agentQueries,
+ config: config,
+ }
+}
+
+// GenerateRegistrationToken creates a new registration token
+func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
+ var request struct {
+ Label string `json:"label" binding:"required"`
+ ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d", "168h"
+ Metadata map[string]interface{} `json:"metadata"`
+ }
+
+ if err := c.ShouldBindJSON(&request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format: " + err.Error()})
+ return
+ }
+
+ // Check agent seat limit (security, not licensing)
+ activeAgents, err := h.agentQueries.GetActiveAgentCount()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check agent count"})
+ return
+ }
+
+ if activeAgents >= h.config.AgentRegistration.MaxSeats {
+ c.JSON(http.StatusForbidden, gin.H{
+ "error": "Maximum agent seats reached",
+ "limit": h.config.AgentRegistration.MaxSeats,
+ "current": activeAgents,
+ })
+ return
+ }
+
+ // Parse expiration duration
+ expiresIn := request.ExpiresIn
+ if expiresIn == "" {
+ expiresIn = h.config.AgentRegistration.TokenExpiry
+ }
+
+ duration, err := time.ParseDuration(expiresIn)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid expiration format. Use formats like '24h', '7d', '168h'"})
+ return
+ }
+
+ expiresAt := time.Now().Add(duration)
+ if duration > 168*time.Hour { // Max 7 days
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Token expiration cannot exceed 7 days"})
+ return
+ }
+
+ // Generate secure token
+ token, err := config.GenerateSecureToken()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
+ return
+ }
+
+ // Create metadata with default values
+ metadata := request.Metadata
+ if metadata == nil {
+ metadata = make(map[string]interface{})
+ }
+ metadata["server_url"] = c.Request.Host
+ metadata["expires_in"] = expiresIn
+
+ // Store token in database
+ err = h.tokenQueries.CreateRegistrationToken(token, request.Label, expiresAt, metadata)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create token"})
+ return
+ }
+
+ // Build install command
+ serverURL := c.Request.Host
+ if serverURL == "" {
+ serverURL = "localhost:8080" // Fallback for development
+ }
+ installCommand := "curl -sfL https://" + serverURL + "/install | bash -s -- " + token
+
+ response := gin.H{
+ "token": token,
+ "label": request.Label,
+ "expires_at": expiresAt,
+ "install_command": installCommand,
+ "metadata": metadata,
+ }
+
+ c.JSON(http.StatusCreated, response)
+}
+
+// ListRegistrationTokens returns all registration tokens with pagination
+func (h *RegistrationTokenHandler) ListRegistrationTokens(c *gin.Context) {
+ // Parse pagination parameters
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
+ status := c.Query("status")
+
+ // Validate pagination
+ if limit > 100 {
+ limit = 100
+ }
+ if page < 1 {
+ page = 1
+ }
+
+ offset := (page - 1) * limit
+
+ var tokens []queries.RegistrationToken
+ var err error
+
+ if status != "" {
+ // TODO: Add filtered queries by status
+ tokens, err = h.tokenQueries.GetAllRegistrationTokens(limit, offset)
+ } else {
+ tokens, err = h.tokenQueries.GetAllRegistrationTokens(limit, offset)
+ }
+
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list tokens"})
+ return
+ }
+
+ // Get token usage stats
+ stats, err := h.tokenQueries.GetTokenUsageStats()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get token stats"})
+ return
+ }
+
+ response := gin.H{
+ "tokens": tokens,
+ "pagination": gin.H{
+ "page": page,
+ "limit": limit,
+ "offset": offset,
+ },
+ "stats": stats,
+ "seat_usage": gin.H{
+ "current": func() int {
+ count, _ := h.agentQueries.GetActiveAgentCount()
+ return count
+ }(),
+ "max": h.config.AgentRegistration.MaxSeats,
+ },
+ }
+
+ c.JSON(http.StatusOK, response)
+}
+
+// GetActiveRegistrationTokens returns only active tokens
+func (h *RegistrationTokenHandler) GetActiveRegistrationTokens(c *gin.Context) {
+ tokens, err := h.tokenQueries.GetActiveRegistrationTokens()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get active tokens"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"tokens": tokens})
+}
+
+// RevokeRegistrationToken revokes a registration token
+func (h *RegistrationTokenHandler) RevokeRegistrationToken(c *gin.Context) {
+ token := c.Param("token")
+ if token == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Token is required"})
+ return
+ }
+
+ var request struct {
+ Reason string `json:"reason"`
+ }
+
+ c.ShouldBindJSON(&request) // Reason is optional
+
+ reason := request.Reason
+ if reason == "" {
+ reason = "Revoked via API"
+ }
+
+ err := h.tokenQueries.RevokeRegistrationToken(token, reason)
+ if err != nil {
+ if err.Error() == "token not found or already used/revoked" {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Token not found or already used/revoked"})
+ } else {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token"})
+ }
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Token revoked successfully"})
+}
+
+// ValidateRegistrationToken checks if a token is valid (for testing/debugging)
+func (h *RegistrationTokenHandler) ValidateRegistrationToken(c *gin.Context) {
+ token := c.Query("token")
+ if token == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Token query parameter is required"})
+ return
+ }
+
+ tokenInfo, err := h.tokenQueries.ValidateRegistrationToken(token)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{
+ "valid": false,
+ "error": err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "valid": true,
+ "token": tokenInfo,
+ })
+}
+
+// CleanupExpiredTokens performs cleanup of expired tokens
+func (h *RegistrationTokenHandler) CleanupExpiredTokens(c *gin.Context) {
+ count, err := h.tokenQueries.CleanupExpiredTokens()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to cleanup expired tokens"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "Cleanup completed",
+ "cleaned": count,
+ })
+}
+
+// GetTokenStats returns comprehensive token usage statistics
+func (h *RegistrationTokenHandler) GetTokenStats(c *gin.Context) {
+ // Get token stats
+ tokenStats, err := h.tokenQueries.GetTokenUsageStats()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get token stats"})
+ return
+ }
+
+ // Get agent count
+ activeAgentCount, err := h.agentQueries.GetActiveAgentCount()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agent count"})
+ return
+ }
+
+ response := gin.H{
+ "token_stats": tokenStats,
+ "agent_usage": gin.H{
+ "active_agents": activeAgentCount,
+ "max_seats": h.config.AgentRegistration.MaxSeats,
+ "available": h.config.AgentRegistration.MaxSeats - activeAgentCount,
+ },
+ "security_limits": gin.H{
+ "max_tokens_per_request": h.config.AgentRegistration.MaxTokens,
+ "max_token_duration": "7 days",
+ "token_expiry_default": h.config.AgentRegistration.TokenExpiry,
+ },
+ }
+
+ c.JSON(http.StatusOK, response)
+}
\ No newline at end of file
diff --git a/aggregator-server/internal/api/handlers/updates.go b/aggregator-server/internal/api/handlers/updates.go
index 5128705..2037e71 100644
--- a/aggregator-server/internal/api/handlers/updates.go
+++ b/aggregator-server/internal/api/handlers/updates.go
@@ -2,6 +2,7 @@ package handlers
import (
"fmt"
+ "log"
"net/http"
"strconv"
"time"
@@ -16,16 +17,42 @@ type UpdateHandler struct {
updateQueries *queries.UpdateQueries
agentQueries *queries.AgentQueries
commandQueries *queries.CommandQueries
+ agentHandler *AgentHandler
}
-func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries) *UpdateHandler {
+func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries, ah *AgentHandler) *UpdateHandler {
return &UpdateHandler{
updateQueries: uq,
agentQueries: aq,
commandQueries: cq,
+ agentHandler: ah,
}
}
+// shouldEnableHeartbeat checks if heartbeat is already active for an agent
+// Returns true if heartbeat should be enabled (i.e., not already active or expired)
+func (h *UpdateHandler) shouldEnableHeartbeat(agentID uuid.UUID, durationMinutes int) (bool, error) {
+ agent, err := h.agentQueries.GetAgentByID(agentID)
+ if err != nil {
+ log.Printf("Warning: Failed to get agent %s for heartbeat check: %v", agentID, err)
+ return true, nil // Enable heartbeat by default if we can't check
+ }
+
+ // Check if rapid polling is already enabled and not expired
+ if enabled, ok := agent.Metadata["rapid_polling_enabled"].(bool); ok && enabled {
+ if untilStr, ok := agent.Metadata["rapid_polling_until"].(string); ok {
+ until, err := time.Parse(time.RFC3339, untilStr)
+ if err == nil && until.After(time.Now().Add(5*time.Minute)) {
+ // Heartbeat is already active for sufficient time
+ log.Printf("[Heartbeat] Agent %s already has active heartbeat until %s (skipping)", agentID, untilStr)
+ return false, nil
+ }
+ }
+ }
+
+ return true, nil
+}
+
// ReportUpdates handles update reports from agents using event sourcing
func (h *UpdateHandler) ReportUpdates(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
@@ -172,7 +199,7 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
return
}
- log := &models.UpdateLog{
+ logEntry := &models.UpdateLog{
ID: uuid.New(),
AgentID: agentID,
Action: req.Action,
@@ -185,7 +212,7 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
}
// Store the log entry
- if err := h.updateQueries.CreateUpdateLog(log); err != nil {
+ if err := h.updateQueries.CreateUpdateLog(logEntry); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save log"})
return
}
@@ -207,10 +234,34 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
}
// Update command status based on log result
- if req.Result == "success" {
+ if req.Result == "success" || req.Result == "completed" {
if err := h.commandQueries.MarkCommandCompleted(commandID, result); err != nil {
fmt.Printf("Warning: Failed to mark command %s as completed: %v\n", commandID, err)
}
+
+ // NEW: If this was a successful confirm_dependencies command, mark the package as updated
+ command, err := h.commandQueries.GetCommandByID(commandID)
+ if err == nil && command.CommandType == models.CommandTypeConfirmDependencies {
+ // Extract package info from command params
+ if packageName, ok := command.Params["package_name"].(string); ok {
+ if packageType, ok := command.Params["package_type"].(string); ok {
+ // Extract actual completion timestamp from command result for accurate audit trail
+ var completionTime *time.Time
+ if loggedAtStr, ok := command.Result["logged_at"].(string); ok {
+ if parsed, err := time.Parse(time.RFC3339Nano, loggedAtStr); err == nil {
+ completionTime = &parsed
+ }
+ }
+
+ // Update package status to 'updated' with actual completion timestamp
+ if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, "updated", nil, completionTime); err != nil {
+ log.Printf("Warning: Failed to update package status for %s/%s: %v", packageType, packageName, err)
+ } else {
+ log.Printf("✅ Package %s (%s) marked as updated after successful installation", packageName, packageType)
+ }
+ }
+ }
+ }
} else if req.Result == "failed" || req.Result == "dry_run_failed" {
if err := h.commandQueries.MarkCommandFailed(commandID, result); err != nil {
fmt.Printf("Warning: Failed to mark command %s as failed: %v\n", commandID, err)
@@ -304,7 +355,7 @@ func (h *UpdateHandler) UpdatePackageStatus(c *gin.Context) {
return
}
- if err := h.updateQueries.UpdatePackageStatus(agentID, req.PackageType, req.PackageName, req.Status, req.Metadata); err != nil {
+ if err := h.updateQueries.UpdatePackageStatus(agentID, req.PackageType, req.PackageName, req.Status, req.Metadata, nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
return
}
@@ -395,7 +446,29 @@ func (h *UpdateHandler) InstallUpdate(c *gin.Context) {
CreatedAt: time.Now(),
}
- // Store the command in database
+ // Check if heartbeat should be enabled (avoid duplicates)
+ if shouldEnable, err := h.shouldEnableHeartbeat(update.AgentID, 10); err == nil && shouldEnable {
+ heartbeatCmd := &models.AgentCommand{
+ ID: uuid.New(),
+ AgentID: update.AgentID,
+ CommandType: models.CommandTypeEnableHeartbeat,
+ Params: models.JSONB{
+ "duration_minutes": 10,
+ },
+ Status: models.CommandStatusPending,
+ CreatedAt: time.Now(),
+ }
+
+ if err := h.commandQueries.CreateCommand(heartbeatCmd); err != nil {
+ log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", update.AgentID, err)
+ } else {
+ log.Printf("[Heartbeat] Command created for agent %s before dry run", update.AgentID)
+ }
+ } else {
+ log.Printf("[Heartbeat] Skipping heartbeat command for agent %s (already active)", update.AgentID)
+ }
+
+ // Store the dry run command in database
if err := h.commandQueries.CreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dry run command"})
return
@@ -478,6 +551,28 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
CreatedAt: time.Now(),
}
+ // Check if heartbeat should be enabled (avoid duplicates)
+ if shouldEnable, err := h.shouldEnableHeartbeat(agentID, 10); err == nil && shouldEnable {
+ heartbeatCmd := &models.AgentCommand{
+ ID: uuid.New(),
+ AgentID: agentID,
+ CommandType: models.CommandTypeEnableHeartbeat,
+ Params: models.JSONB{
+ "duration_minutes": 10,
+ },
+ Status: models.CommandStatusPending,
+ CreatedAt: time.Now(),
+ }
+
+ if err := h.commandQueries.CreateCommand(heartbeatCmd); err != nil {
+ log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", agentID, err)
+ } else {
+ log.Printf("[Heartbeat] Command created for agent %s before installation", agentID)
+ }
+ } else {
+ log.Printf("[Heartbeat] Skipping heartbeat command for agent %s (already active)", agentID)
+ }
+
if err := h.commandQueries.CreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create installation command"})
return
@@ -536,6 +631,28 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
CreatedAt: time.Now(),
}
+ // Check if heartbeat should be enabled (avoid duplicates)
+ if shouldEnable, err := h.shouldEnableHeartbeat(update.AgentID, 10); err == nil && shouldEnable {
+ heartbeatCmd := &models.AgentCommand{
+ ID: uuid.New(),
+ AgentID: update.AgentID,
+ CommandType: models.CommandTypeEnableHeartbeat,
+ Params: models.JSONB{
+ "duration_minutes": 10,
+ },
+ Status: models.CommandStatusPending,
+ CreatedAt: time.Now(),
+ }
+
+ if err := h.commandQueries.CreateCommand(heartbeatCmd); err != nil {
+ log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", update.AgentID, err)
+ } else {
+ log.Printf("[Heartbeat] Command created for agent %s before confirm dependencies", update.AgentID)
+ }
+ } else {
+ log.Printf("[Heartbeat] Skipping heartbeat command for agent %s (already active)", update.AgentID)
+ }
+
// Store the command in database
if err := h.commandQueries.CreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create confirmation command"})
@@ -684,3 +801,60 @@ func (h *UpdateHandler) GetRecentCommands(c *gin.Context) {
"limit": limit,
})
}
+
+// ClearFailedCommands manually removes failed/timed_out commands with cheeky warning
+func (h *UpdateHandler) ClearFailedCommands(c *gin.Context) {
+ // Get query parameters for filtering
+ olderThanDaysStr := c.Query("older_than_days")
+ onlyRetriedStr := c.Query("only_retried")
+ allFailedStr := c.Query("all_failed")
+
+ var count int64
+ var err error
+
+ // Parse parameters
+ olderThanDays := 7 // default
+ if olderThanDaysStr != "" {
+ if days, err := strconv.Atoi(olderThanDaysStr); err == nil && days > 0 {
+ olderThanDays = days
+ }
+ }
+
+ onlyRetried := onlyRetriedStr == "true"
+ allFailed := allFailedStr == "true"
+
+ // Build the appropriate cleanup query based on parameters
+ if allFailed {
+ // Clear ALL failed commands (most aggressive)
+ count, err = h.commandQueries.ClearAllFailedCommands(olderThanDays)
+ } else if onlyRetried {
+ // Clear only failed commands that have been retried
+ count, err = h.commandQueries.ClearRetriedFailedCommands(olderThanDays)
+ } else {
+ // Clear failed commands older than specified days (default behavior)
+ count, err = h.commandQueries.ClearOldFailedCommands(olderThanDays)
+ }
+
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "failed to clear failed commands",
+ "details": err.Error(),
+ })
+ return
+ }
+
+ // Return success with cheeky message
+ message := fmt.Sprintf("Archived %d failed commands", count)
+ if count > 0 {
+ message += ". WARNING: This shouldn't be necessary if the retry logic is working properly - you might want to check what's causing commands to fail in the first place!"
+ message += " (History preserved - commands moved to archived status)"
+ } else {
+ message += ". No failed commands found matching your criteria. SUCCESS!"
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": message,
+ "count": count,
+ "cheeky_warning": "Consider this a developer experience enhancement - the system should clean up after itself automatically!",
+ })
+}
diff --git a/aggregator-server/internal/api/middleware/rate_limiter.go b/aggregator-server/internal/api/middleware/rate_limiter.go
new file mode 100644
index 0000000..9b93176
--- /dev/null
+++ b/aggregator-server/internal/api/middleware/rate_limiter.go
@@ -0,0 +1,279 @@
+package middleware
+
+import (
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+// RateLimitConfig holds configuration for rate limiting
+type RateLimitConfig struct {
+ Requests int `json:"requests"`
+ Window time.Duration `json:"window"`
+ Enabled bool `json:"enabled"`
+}
+
+// RateLimitEntry tracks requests for a specific key
+type RateLimitEntry struct {
+ Requests []time.Time
+ mutex sync.RWMutex
+}
+
+// RateLimiter implements in-memory rate limiting with user-configurable settings
+type RateLimiter struct {
+ entries sync.Map // map[string]*RateLimitEntry
+ configs map[string]RateLimitConfig
+ mutex sync.RWMutex
+}
+
+// RateLimitSettings holds all user-configurable rate limit settings
+type RateLimitSettings struct {
+ AgentRegistration RateLimitConfig `json:"agent_registration"`
+ AgentCheckIn RateLimitConfig `json:"agent_checkin"`
+ AgentReports RateLimitConfig `json:"agent_reports"`
+ AdminTokenGen RateLimitConfig `json:"admin_token_generation"`
+ AdminOperations RateLimitConfig `json:"admin_operations"`
+ PublicAccess RateLimitConfig `json:"public_access"`
+}
+
+// DefaultRateLimitSettings provides sensible defaults
+func DefaultRateLimitSettings() RateLimitSettings {
+ return RateLimitSettings{
+ AgentRegistration: RateLimitConfig{
+ Requests: 5,
+ Window: time.Minute,
+ Enabled: true,
+ },
+ AgentCheckIn: RateLimitConfig{
+ Requests: 60,
+ Window: time.Minute,
+ Enabled: true,
+ },
+ AgentReports: RateLimitConfig{
+ Requests: 30,
+ Window: time.Minute,
+ Enabled: true,
+ },
+ AdminTokenGen: RateLimitConfig{
+ Requests: 10,
+ Window: time.Minute,
+ Enabled: true,
+ },
+ AdminOperations: RateLimitConfig{
+ Requests: 100,
+ Window: time.Minute,
+ Enabled: true,
+ },
+ PublicAccess: RateLimitConfig{
+ Requests: 20,
+ Window: time.Minute,
+ Enabled: true,
+ },
+ }
+}
+
+// NewRateLimiter creates a new rate limiter with default settings
+func NewRateLimiter() *RateLimiter {
+ rl := &RateLimiter{
+ entries: sync.Map{},
+ }
+
+ // Load default settings
+ defaults := DefaultRateLimitSettings()
+ rl.UpdateSettings(defaults)
+
+ return rl
+}
+
+// UpdateSettings updates rate limit configurations
+func (rl *RateLimiter) UpdateSettings(settings RateLimitSettings) {
+ rl.mutex.Lock()
+ defer rl.mutex.Unlock()
+
+ rl.configs = map[string]RateLimitConfig{
+ "agent_registration": settings.AgentRegistration,
+ "agent_checkin": settings.AgentCheckIn,
+ "agent_reports": settings.AgentReports,
+ "admin_token_gen": settings.AdminTokenGen,
+ "admin_operations": settings.AdminOperations,
+ "public_access": settings.PublicAccess,
+ }
+}
+
+// GetSettings returns current rate limit settings
+func (rl *RateLimiter) GetSettings() RateLimitSettings {
+ rl.mutex.RLock()
+ defer rl.mutex.RUnlock()
+
+ return RateLimitSettings{
+ AgentRegistration: rl.configs["agent_registration"],
+ AgentCheckIn: rl.configs["agent_checkin"],
+ AgentReports: rl.configs["agent_reports"],
+ AdminTokenGen: rl.configs["admin_token_gen"],
+ AdminOperations: rl.configs["admin_operations"],
+ PublicAccess: rl.configs["public_access"],
+ }
+}
+
+// RateLimit creates middleware for a specific rate limit type
+func (rl *RateLimiter) RateLimit(limitType string, keyFunc func(*gin.Context) string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ rl.mutex.RLock()
+ config, exists := rl.configs[limitType]
+ rl.mutex.RUnlock()
+
+ if !exists || !config.Enabled {
+ c.Next()
+ return
+ }
+
+ key := keyFunc(c)
+ if key == "" {
+ c.Next()
+ return
+ }
+
+ // Check rate limit
+ allowed, resetTime := rl.checkRateLimit(key, config)
+ if !allowed {
+ c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", config.Requests))
+ c.Header("X-RateLimit-Remaining", "0")
+ c.Header("X-RateLimit-Reset", fmt.Sprintf("%d", resetTime.Unix()))
+ c.Header("Retry-After", fmt.Sprintf("%d", int(resetTime.Sub(time.Now()).Seconds())))
+
+ c.JSON(http.StatusTooManyRequests, gin.H{
+ "error": "Rate limit exceeded",
+ "limit": config.Requests,
+ "window": config.Window.String(),
+ "reset_time": resetTime,
+ })
+ c.Abort()
+ return
+ }
+
+ // Add rate limit headers
+ remaining := rl.getRemainingRequests(key, config)
+ c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", config.Requests))
+ c.Header("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining))
+ c.Header("X-RateLimit-Reset", fmt.Sprintf("%d", time.Now().Add(config.Window).Unix()))
+
+ c.Next()
+ }
+}
+
+// checkRateLimit checks if the request is allowed
+func (rl *RateLimiter) checkRateLimit(key string, config RateLimitConfig) (bool, time.Time) {
+ now := time.Now()
+
+ // Get or create entry
+ entryInterface, _ := rl.entries.LoadOrStore(key, &RateLimitEntry{
+ Requests: []time.Time{},
+ })
+ entry := entryInterface.(*RateLimitEntry)
+
+ entry.mutex.Lock()
+ defer entry.mutex.Unlock()
+
+ // Clean old requests outside the window
+ cutoff := now.Add(-config.Window)
+ validRequests := make([]time.Time, 0)
+ for _, reqTime := range entry.Requests {
+ if reqTime.After(cutoff) {
+ validRequests = append(validRequests, reqTime)
+ }
+ }
+
+ // Check if under limit
+ if len(validRequests) >= config.Requests {
+ // Find when the oldest request expires
+ oldestRequest := validRequests[0]
+ resetTime := oldestRequest.Add(config.Window)
+ return false, resetTime
+ }
+
+ // Add current request
+ entry.Requests = append(validRequests, now)
+
+ // Clean up expired entries periodically
+ if len(entry.Requests) == 0 {
+ rl.entries.Delete(key)
+ }
+
+ return true, time.Time{}
+}
+
+// getRemainingRequests calculates remaining requests for the key
+func (rl *RateLimiter) getRemainingRequests(key string, config RateLimitConfig) int {
+ entryInterface, ok := rl.entries.Load(key)
+ if !ok {
+ return config.Requests
+ }
+
+ entry := entryInterface.(*RateLimitEntry)
+ entry.mutex.RLock()
+ defer entry.mutex.RUnlock()
+
+ now := time.Now()
+ cutoff := now.Add(-config.Window)
+ count := 0
+
+ for _, reqTime := range entry.Requests {
+ if reqTime.After(cutoff) {
+ count++
+ }
+ }
+
+ remaining := config.Requests - count
+ if remaining < 0 {
+ remaining = 0
+ }
+
+ return remaining
+}
+
+// CleanupExpiredEntries removes expired entries to prevent memory leaks
+func (rl *RateLimiter) CleanupExpiredEntries() {
+ rl.entries.Range(func(key, value interface{}) bool {
+ entry := value.(*RateLimitEntry)
+ entry.mutex.Lock()
+
+ now := time.Now()
+ validRequests := make([]time.Time, 0)
+ for _, reqTime := range entry.Requests {
+ if reqTime.After(now.Add(-time.Hour)) { // Keep requests from last hour
+ validRequests = append(validRequests, reqTime)
+ }
+ }
+
+ if len(validRequests) == 0 {
+ rl.entries.Delete(key)
+ } else {
+ entry.Requests = validRequests
+ }
+
+ entry.mutex.Unlock()
+ return true
+ })
+}
+
+// Key generation functions
+func KeyByIP(c *gin.Context) string {
+ return c.ClientIP()
+}
+
+func KeyByAgentID(c *gin.Context) string {
+ return c.Param("id")
+}
+
+func KeyByUserID(c *gin.Context) string {
+ // This would extract user ID from JWT or session
+ // For now, use IP as fallback
+ return c.ClientIP()
+}
+
+func KeyByIPAndPath(c *gin.Context) string {
+ return c.ClientIP() + ":" + c.Request.URL.Path
+}
\ No newline at end of file
diff --git a/aggregator-server/internal/config/config.go b/aggregator-server/internal/config/config.go
index 9f2c5d3..730c630 100644
--- a/aggregator-server/internal/config/config.go
+++ b/aggregator-server/internal/config/config.go
@@ -1,18 +1,47 @@
package config
import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
"fmt"
"os"
"strconv"
+ "strings"
+ "time"
"github.com/joho/godotenv"
+ "golang.org/x/term"
)
// Config holds the application configuration
type Config struct {
- ServerPort string
- DatabaseURL string
- JWTSecret string
+ Server struct {
+ Host string `env:"REDFLAG_SERVER_HOST" default:"0.0.0.0"`
+ Port int `env:"REDFLAG_SERVER_PORT" default:"8080"`
+ TLS struct {
+ Enabled bool `env:"REDFLAG_TLS_ENABLED" default:"false"`
+ CertFile string `env:"REDFLAG_TLS_CERT_FILE"`
+ KeyFile string `env:"REDFLAG_TLS_KEY_FILE"`
+ }
+ }
+ Database struct {
+ Host string `env:"REDFLAG_DB_HOST" default:"localhost"`
+ Port int `env:"REDFLAG_DB_PORT" default:"5432"`
+ Database string `env:"REDFLAG_DB_NAME" default:"redflag"`
+ Username string `env:"REDFLAG_DB_USER" default:"redflag"`
+ Password string `env:"REDFLAG_DB_PASSWORD"`
+ }
+ Admin struct {
+ Username string `env:"REDFLAG_ADMIN_USER" default:"admin"`
+ Password string `env:"REDFLAG_ADMIN_PASSWORD"`
+ JWTSecret string `env:"REDFLAG_JWT_SECRET"`
+ }
+ AgentRegistration struct {
+ TokenExpiry string `env:"REDFLAG_TOKEN_EXPIRY" default:"24h"`
+ MaxTokens int `env:"REDFLAG_MAX_TOKENS" default:"100"`
+ MaxSeats int `env:"REDFLAG_MAX_SEATS" default:"50"`
+ }
CheckInInterval int
OfflineThreshold int
Timezone string
@@ -24,30 +53,195 @@ func Load() (*Config, error) {
// Load .env file if it exists (for development)
_ = godotenv.Load()
+ cfg := &Config{}
+
+ // Parse server configuration
+ cfg.Server.Host = getEnv("REDFLAG_SERVER_HOST", "0.0.0.0")
+ serverPort, _ := strconv.Atoi(getEnv("REDFLAG_SERVER_PORT", "8080"))
+ cfg.Server.Port = serverPort
+ cfg.Server.TLS.Enabled = getEnv("REDFLAG_TLS_ENABLED", "false") == "true"
+ cfg.Server.TLS.CertFile = getEnv("REDFLAG_TLS_CERT_FILE", "")
+ cfg.Server.TLS.KeyFile = getEnv("REDFLAG_TLS_KEY_FILE", "")
+
+ // Parse database configuration
+ cfg.Database.Host = getEnv("REDFLAG_DB_HOST", "localhost")
+ dbPort, _ := strconv.Atoi(getEnv("REDFLAG_DB_PORT", "5432"))
+ cfg.Database.Port = dbPort
+ cfg.Database.Database = getEnv("REDFLAG_DB_NAME", "redflag")
+ cfg.Database.Username = getEnv("REDFLAG_DB_USER", "redflag")
+ cfg.Database.Password = getEnv("REDFLAG_DB_PASSWORD", "")
+
+ // Parse admin configuration
+ cfg.Admin.Username = getEnv("REDFLAG_ADMIN_USER", "admin")
+ cfg.Admin.Password = getEnv("REDFLAG_ADMIN_PASSWORD", "")
+ cfg.Admin.JWTSecret = getEnv("REDFLAG_JWT_SECRET", "")
+
+ // Parse agent registration configuration
+ cfg.AgentRegistration.TokenExpiry = getEnv("REDFLAG_TOKEN_EXPIRY", "24h")
+ maxTokens, _ := strconv.Atoi(getEnv("REDFLAG_MAX_TOKENS", "100"))
+ cfg.AgentRegistration.MaxTokens = maxTokens
+ maxSeats, _ := strconv.Atoi(getEnv("REDFLAG_MAX_SEATS", "50"))
+ cfg.AgentRegistration.MaxSeats = maxSeats
+
+ // Parse legacy configuration for backwards compatibility
checkInInterval, _ := strconv.Atoi(getEnv("CHECK_IN_INTERVAL", "300"))
offlineThreshold, _ := strconv.Atoi(getEnv("OFFLINE_THRESHOLD", "600"))
+ cfg.CheckInInterval = checkInInterval
+ cfg.OfflineThreshold = offlineThreshold
+ cfg.Timezone = getEnv("TIMEZONE", "UTC")
+ cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.16")
- cfg := &Config{
- ServerPort: getEnv("SERVER_PORT", "8080"),
- DatabaseURL: getEnv("DATABASE_URL", "postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable"),
- JWTSecret: getEnv("JWT_SECRET", "test-secret-for-development-only"),
- CheckInInterval: checkInInterval,
- OfflineThreshold: offlineThreshold,
- Timezone: getEnv("TIMEZONE", "UTC"),
- LatestAgentVersion: getEnv("LATEST_AGENT_VERSION", "0.1.4"),
+ // Handle missing secrets
+ if cfg.Admin.Password == "" || cfg.Admin.JWTSecret == "" || cfg.Database.Password == "" {
+ fmt.Printf("[WARNING] Missing required configuration (admin password, JWT secret, or database password)\n")
+ fmt.Printf("[INFO] Run: ./redflag-server --setup to configure\n")
+ return nil, fmt.Errorf("missing required configuration")
}
- // Debug: Log what JWT secret we're using (remove in production)
- if cfg.JWTSecret == "test-secret-for-development-only" {
- fmt.Printf("🔓 Using development JWT secret\n")
+ // Validate JWT secret is not the development default
+ if cfg.Admin.JWTSecret == "test-secret-for-development-only" {
+ fmt.Printf("[SECURITY WARNING] Using development JWT secret\n")
+ fmt.Printf("[INFO] Run: ./redflag-server --setup to configure production secrets\n")
}
return cfg, nil
}
+// RunSetupWizard guides user through initial configuration
+func RunSetupWizard() error {
+ fmt.Printf("RedFlag Server Setup Wizard\n")
+ fmt.Printf("===========================\n\n")
+
+ // Admin credentials
+ fmt.Printf("Admin Account Setup\n")
+ fmt.Printf("--------------------\n")
+ username := promptForInput("Admin username", "admin")
+ password := promptForPassword("Admin password")
+
+ // Database configuration
+ fmt.Printf("\nDatabase Configuration\n")
+ fmt.Printf("----------------------\n")
+ dbHost := promptForInput("Database host", "localhost")
+ dbPort, _ := strconv.Atoi(promptForInput("Database port", "5432"))
+ dbName := promptForInput("Database name", "redflag")
+ dbUser := promptForInput("Database user", "redflag")
+ dbPassword := promptForPassword("Database password")
+
+ // Server configuration
+ fmt.Printf("\nServer Configuration\n")
+ fmt.Printf("--------------------\n")
+ serverHost := promptForInput("Server bind address", "0.0.0.0")
+ serverPort, _ := strconv.Atoi(promptForInput("Server port", "8080"))
+
+ // Agent limits
+ fmt.Printf("\nAgent Registration\n")
+ fmt.Printf("------------------\n")
+ maxSeats, _ := strconv.Atoi(promptForInput("Maximum agent seats (security limit)", "50"))
+
+ // Generate JWT secret from admin password
+ jwtSecret := deriveJWTSecret(username, password)
+
+ // Create .env file
+ envContent := fmt.Sprintf(`# RedFlag Server Configuration
+# Generated on %s
+
+# Server Configuration
+REDFLAG_SERVER_HOST=%s
+REDFLAG_SERVER_PORT=%d
+REDFLAG_TLS_ENABLED=false
+# REDFLAG_TLS_CERT_FILE=
+# REDFLAG_TLS_KEY_FILE=
+
+# Database Configuration
+REDFLAG_DB_HOST=%s
+REDFLAG_DB_PORT=%d
+REDFLAG_DB_NAME=%s
+REDFLAG_DB_USER=%s
+REDFLAG_DB_PASSWORD=%s
+
+# Admin Configuration
+REDFLAG_ADMIN_USER=%s
+REDFLAG_ADMIN_PASSWORD=%s
+REDFLAG_JWT_SECRET=%s
+
+# Agent Registration
+REDFLAG_TOKEN_EXPIRY=24h
+REDFLAG_MAX_TOKENS=100
+REDFLAG_MAX_SEATS=%d
+
+# Legacy Configuration (for backwards compatibility)
+SERVER_PORT=%d
+DATABASE_URL=postgres://%s:%s@%s:%d/%s?sslmode=disable
+JWT_SECRET=%s
+CHECK_IN_INTERVAL=300
+OFFLINE_THRESHOLD=600
+TIMEZONE=UTC
+LATEST_AGENT_VERSION=0.1.8
+`, time.Now().Format("2006-01-02 15:04:05"), serverHost, serverPort,
+ dbHost, dbPort, dbName, dbUser, dbPassword,
+ username, password, jwtSecret, maxSeats,
+ serverPort, dbUser, dbPassword, dbHost, dbPort, dbName, jwtSecret)
+
+ // Write .env file
+ if err := os.WriteFile(".env", []byte(envContent), 0600); err != nil {
+ return fmt.Errorf("failed to write .env file: %w", err)
+ }
+
+ fmt.Printf("\n[OK] Configuration saved to .env file\n")
+ fmt.Printf("[SECURITY] File permissions set to 0600 (owner read/write only)\n")
+ fmt.Printf("\nNext steps:\n")
+ fmt.Printf(" 1. Start database: %s:%d\n", dbHost, dbPort)
+ fmt.Printf(" 2. Create database: CREATE DATABASE %s;\n", dbName)
+ fmt.Printf(" 3. Run migrations: ./redflag-server --migrate\n")
+ fmt.Printf(" 4. Start server: ./redflag-server\n")
+ fmt.Printf("\nServer will be available at: http://%s:%d\n", serverHost, serverPort)
+ fmt.Printf("Admin interface: http://%s:%d/admin\n", serverHost, serverPort)
+
+ return nil
+}
+
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
+
+func promptForInput(prompt, defaultValue string) string {
+ fmt.Printf("%s [%s]: ", prompt, defaultValue)
+ var input string
+ fmt.Scanln(&input)
+ if strings.TrimSpace(input) == "" {
+ return defaultValue
+ }
+ return strings.TrimSpace(input)
+}
+
+func promptForPassword(prompt string) string {
+ fmt.Printf("%s: ", prompt)
+ password, err := term.ReadPassword(int(os.Stdin.Fd()))
+ if err != nil {
+ // Fallback to non-hidden input
+ var input string
+ fmt.Scanln(&input)
+ return strings.TrimSpace(input)
+ }
+ fmt.Printf("\n")
+ return strings.TrimSpace(string(password))
+}
+
+func deriveJWTSecret(username, password string) string {
+ // Derive JWT secret from admin credentials
+ // This ensures JWT secret changes if admin password changes
+ hash := sha256.Sum256([]byte(username + password + "redflag-jwt-2024"))
+ return hex.EncodeToString(hash[:])
+}
+
+// GenerateSecureToken generates a cryptographically secure random token
+func GenerateSecureToken() (string, error) {
+ bytes := make([]byte, 32)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", fmt.Errorf("failed to generate secure token: %w", err)
+ }
+ return hex.EncodeToString(bytes), nil
+}
diff --git a/aggregator-server/internal/database/migrations/009_add_retry_tracking.sql b/aggregator-server/internal/database/migrations/009_add_retry_tracking.sql
new file mode 100644
index 0000000..a5d36aa
--- /dev/null
+++ b/aggregator-server/internal/database/migrations/009_add_retry_tracking.sql
@@ -0,0 +1,9 @@
+-- Add retry tracking to agent_commands table
+-- This allows us to track command retry chains and display retry indicators in the UI
+
+-- Add retried_from_id column to link retries to their original commands
+ALTER TABLE agent_commands
+ADD COLUMN retried_from_id UUID REFERENCES agent_commands(id) ON DELETE SET NULL;
+
+-- Add index for efficient retry chain lookups
+CREATE INDEX idx_commands_retried_from ON agent_commands(retried_from_id) WHERE retried_from_id IS NOT NULL;
diff --git a/aggregator-server/internal/database/migrations/010_add_archived_failed_status.sql b/aggregator-server/internal/database/migrations/010_add_archived_failed_status.sql
new file mode 100644
index 0000000..5eb7c5b
--- /dev/null
+++ b/aggregator-server/internal/database/migrations/010_add_archived_failed_status.sql
@@ -0,0 +1,9 @@
+-- Add 'archived_failed' status to agent_commands status constraint
+-- This allows archiving failed/timed_out commands to clean up the active list
+
+-- Drop the existing constraint
+ALTER TABLE agent_commands DROP CONSTRAINT IF EXISTS agent_commands_status_check;
+
+-- Add the new constraint with 'archived_failed' included
+ALTER TABLE agent_commands ADD CONSTRAINT agent_commands_status_check
+ CHECK (status IN ('pending', 'sent', 'running', 'completed', 'failed', 'timed_out', 'cancelled', 'archived_failed'));
diff --git a/aggregator-server/internal/database/migrations/011_create_registration_tokens_table.sql b/aggregator-server/internal/database/migrations/011_create_registration_tokens_table.sql
new file mode 100644
index 0000000..ef15246
--- /dev/null
+++ b/aggregator-server/internal/database/migrations/011_create_registration_tokens_table.sql
@@ -0,0 +1,85 @@
+-- Registration tokens for secure agent enrollment
+-- Tokens are one-time use and have configurable expiration
+
+CREATE TABLE registration_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ token VARCHAR(64) UNIQUE NOT NULL, -- One-time use token
+ label VARCHAR(255), -- Optional label for token identification
+ expires_at TIMESTAMP NOT NULL, -- Token expiration time
+ created_at TIMESTAMP DEFAULT NOW(), -- When token was created
+ used_at TIMESTAMP NULL, -- When token was used (NULL if unused)
+ used_by_agent_id UUID NULL, -- Which agent used this token (foreign key)
+ revoked BOOLEAN DEFAULT FALSE, -- Manual revocation
+ revoked_at TIMESTAMP NULL, -- When token was revoked
+ revoked_reason VARCHAR(255) NULL, -- Reason for revocation
+
+ -- Token status tracking
+ status VARCHAR(20) DEFAULT 'active'
+ CHECK (status IN ('active', 'used', 'expired', 'revoked')),
+
+ -- Additional metadata
+ created_by VARCHAR(100) DEFAULT 'setup_wizard', -- Who created the token
+ metadata JSONB DEFAULT '{}'::jsonb -- Additional token metadata
+);
+
+-- Indexes for performance
+CREATE INDEX idx_registration_tokens_token ON registration_tokens(token);
+CREATE INDEX idx_registration_tokens_expires_at ON registration_tokens(expires_at);
+CREATE INDEX idx_registration_tokens_status ON registration_tokens(status);
+CREATE INDEX idx_registration_tokens_used_by_agent ON registration_tokens(used_by_agent_id) WHERE used_by_agent_id IS NOT NULL;
+
+-- Foreign key constraint for used_by_agent_id
+ALTER TABLE registration_tokens
+ ADD CONSTRAINT fk_registration_tokens_agent
+ FOREIGN KEY (used_by_agent_id) REFERENCES agents(id) ON DELETE SET NULL;
+
+-- Function to clean up expired tokens (called by periodic cleanup job)
+CREATE OR REPLACE FUNCTION cleanup_expired_registration_tokens()
+RETURNS INTEGER AS $$
+DECLARE
+ deleted_count INTEGER;
+BEGIN
+ UPDATE registration_tokens
+ SET status = 'expired',
+ used_at = NOW()
+ WHERE status = 'active'
+ AND expires_at < NOW()
+ AND used_at IS NULL;
+
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
+ RETURN deleted_count;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Function to check if a token is valid
+CREATE OR REPLACE FUNCTION is_registration_token_valid(token_input VARCHAR)
+RETURNS BOOLEAN AS $$
+DECLARE
+ token_valid BOOLEAN;
+BEGIN
+ SELECT (status = 'active' AND expires_at > NOW()) INTO token_valid
+ FROM registration_tokens
+ WHERE token = token_input;
+
+ RETURN COALESCE(token_valid, FALSE);
+END;
+$$ LANGUAGE plpgsql;
+
+-- Function to mark token as used
+CREATE OR REPLACE function mark_registration_token_used(token_input VARCHAR, agent_id UUID)
+RETURNS BOOLEAN AS $$
+DECLARE
+ updated BOOLEAN;
+BEGIN
+ UPDATE registration_tokens
+ SET status = 'used',
+ used_at = NOW(),
+ used_by_agent_id = agent_id
+ WHERE token = token_input
+ AND status = 'active'
+ AND expires_at > NOW();
+
+ GET DIAGNOSTICS updated = ROW_COUNT;
+ RETURN updated > 0;
+END;
+$$ LANGUAGE plpgsql;
\ No newline at end of file
diff --git a/aggregator-server/internal/database/queries/agents.go b/aggregator-server/internal/database/queries/agents.go
index 4c21b28..1626869 100644
--- a/aggregator-server/internal/database/queries/agents.go
+++ b/aggregator-server/internal/database/queries/agents.go
@@ -196,3 +196,11 @@ func (q *AgentQueries) DeleteAgent(id uuid.UUID) error {
// Commit the transaction
return tx.Commit()
}
+
+// GetActiveAgentCount returns the count of active (online) agents
+func (q *AgentQueries) GetActiveAgentCount() (int, error) {
+ var count int
+ query := `SELECT COUNT(*) FROM agents WHERE status = 'online'`
+ err := q.db.Get(&count, query)
+ return count, err
+}
diff --git a/aggregator-server/internal/database/queries/commands.go b/aggregator-server/internal/database/queries/commands.go
index fe14d72..a8d1dcc 100644
--- a/aggregator-server/internal/database/queries/commands.go
+++ b/aggregator-server/internal/database/queries/commands.go
@@ -21,9 +21,9 @@ func NewCommandQueries(db *sqlx.DB) *CommandQueries {
func (q *CommandQueries) CreateCommand(cmd *models.AgentCommand) error {
query := `
INSERT INTO agent_commands (
- id, agent_id, command_type, params, status
+ id, agent_id, command_type, params, status, retried_from_id
) VALUES (
- :id, :agent_id, :command_type, :params, :status
+ :id, :agent_id, :command_type, :params, :status, :retried_from_id
)
`
_, err := q.db.NamedExec(query, cmd)
@@ -152,14 +152,15 @@ func (q *CommandQueries) RetryCommand(originalID uuid.UUID) (*models.AgentComman
return nil, fmt.Errorf("command must be failed, timed_out, or cancelled to retry")
}
- // Create new command with same parameters
+ // Create new command with same parameters, linking it to the original
newCommand := &models.AgentCommand{
- ID: uuid.New(),
- AgentID: original.AgentID,
- CommandType: original.CommandType,
- Params: original.Params,
- Status: models.CommandStatusPending,
- CreatedAt: time.Now(),
+ ID: uuid.New(),
+ AgentID: original.AgentID,
+ CommandType: original.CommandType,
+ Params: original.Params,
+ Status: models.CommandStatusPending,
+ CreatedAt: time.Now(),
+ RetriedFromID: &originalID,
}
// Store the new command
@@ -180,20 +181,44 @@ func (q *CommandQueries) GetActiveCommands() ([]models.ActiveCommandInfo, error)
c.id,
c.agent_id,
c.command_type,
+ c.params,
c.status,
c.created_at,
c.sent_at,
c.result,
+ c.retried_from_id,
a.hostname as agent_hostname,
COALESCE(ups.package_name, 'N/A') as package_name,
- COALESCE(ups.package_type, 'N/A') as package_type
+ COALESCE(ups.package_type, 'N/A') as package_type,
+ (c.retried_from_id IS NOT NULL) as is_retry,
+ EXISTS(SELECT 1 FROM agent_commands WHERE retried_from_id = c.id) as has_been_retried,
+ COALESCE((
+ WITH RECURSIVE retry_chain AS (
+ SELECT id, retried_from_id, 1 as depth
+ FROM agent_commands
+ WHERE id = c.id
+ UNION ALL
+ SELECT ac.id, ac.retried_from_id, rc.depth + 1
+ FROM agent_commands ac
+ JOIN retry_chain rc ON ac.id = rc.retried_from_id
+ )
+ SELECT MAX(depth) FROM retry_chain
+ ), 1) - 1 as retry_count
FROM agent_commands c
LEFT JOIN agents a ON c.agent_id = a.id
LEFT JOIN current_package_state ups ON (
c.params->>'update_id' = ups.id::text OR
(c.params->>'package_name' = ups.package_name AND c.params->>'package_type' = ups.package_type)
)
- WHERE c.status NOT IN ('completed', 'cancelled')
+ WHERE c.status NOT IN ('completed', 'cancelled', 'archived_failed')
+ AND NOT (
+ c.status IN ('failed', 'timed_out')
+ AND EXISTS (
+ SELECT 1 FROM agent_commands retry
+ WHERE retry.retried_from_id = c.id
+ AND retry.status = 'completed'
+ )
+ )
ORDER BY c.created_at DESC
`
@@ -223,9 +248,24 @@ func (q *CommandQueries) GetRecentCommands(limit int) ([]models.ActiveCommandInf
c.sent_at,
c.completed_at,
c.result,
+ c.retried_from_id,
a.hostname as agent_hostname,
COALESCE(ups.package_name, 'N/A') as package_name,
- COALESCE(ups.package_type, 'N/A') as package_type
+ COALESCE(ups.package_type, 'N/A') as package_type,
+ (c.retried_from_id IS NOT NULL) as is_retry,
+ EXISTS(SELECT 1 FROM agent_commands WHERE retried_from_id = c.id) as has_been_retried,
+ COALESCE((
+ WITH RECURSIVE retry_chain AS (
+ SELECT id, retried_from_id, 1 as depth
+ FROM agent_commands
+ WHERE id = c.id
+ UNION ALL
+ SELECT ac.id, ac.retried_from_id, rc.depth + 1
+ FROM agent_commands ac
+ JOIN retry_chain rc ON ac.id = rc.retried_from_id
+ )
+ SELECT MAX(depth) FROM retry_chain
+ ), 1) - 1 as retry_count
FROM agent_commands c
LEFT JOIN agents a ON c.agent_id = a.id
LEFT JOIN current_package_state ups ON (
@@ -243,3 +283,55 @@ func (q *CommandQueries) GetRecentCommands(limit int) ([]models.ActiveCommandInf
return commands, nil
}
+
+// ClearOldFailedCommands archives failed commands older than specified days by changing status to 'archived_failed'
+func (q *CommandQueries) ClearOldFailedCommands(days int) (int64, error) {
+ query := fmt.Sprintf(`
+ UPDATE agent_commands
+ SET status = 'archived_failed'
+ WHERE status IN ('failed', 'timed_out')
+ AND created_at < NOW() - INTERVAL '%d days'
+ `, days)
+
+ result, err := q.db.Exec(query)
+ if err != nil {
+ return 0, fmt.Errorf("failed to archive old failed commands: %w", err)
+ }
+
+ return result.RowsAffected()
+}
+
+// ClearRetriedFailedCommands archives failed commands that have been retried and are older than specified days
+func (q *CommandQueries) ClearRetriedFailedCommands(days int) (int64, error) {
+ query := fmt.Sprintf(`
+ UPDATE agent_commands
+ SET status = 'archived_failed'
+ WHERE status IN ('failed', 'timed_out')
+ AND EXISTS (SELECT 1 FROM agent_commands WHERE retried_from_id = agent_commands.id)
+ AND created_at < NOW() - INTERVAL '%d days'
+ `, days)
+
+ result, err := q.db.Exec(query)
+ if err != nil {
+ return 0, fmt.Errorf("failed to archive retried failed commands: %w", err)
+ }
+
+ return result.RowsAffected()
+}
+
+// ClearAllFailedCommands archives all failed commands older than specified days (most aggressive)
+func (q *CommandQueries) ClearAllFailedCommands(days int) (int64, error) {
+ query := fmt.Sprintf(`
+ UPDATE agent_commands
+ SET status = 'archived_failed'
+ WHERE status IN ('failed', 'timed_out')
+ AND created_at < NOW() - INTERVAL '%d days'
+ `, days)
+
+ result, err := q.db.Exec(query)
+ if err != nil {
+ return 0, fmt.Errorf("failed to archive all failed commands: %w", err)
+ }
+
+ return result.RowsAffected()
+}
diff --git a/aggregator-server/internal/database/queries/registration_tokens.go b/aggregator-server/internal/database/queries/registration_tokens.go
new file mode 100644
index 0000000..9dc072b
--- /dev/null
+++ b/aggregator-server/internal/database/queries/registration_tokens.go
@@ -0,0 +1,232 @@
+package queries
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jmoiron/sqlx"
+)
+
+type RegistrationTokenQueries struct {
+ db *sqlx.DB
+}
+
+type RegistrationToken struct {
+ ID uuid.UUID `json:"id" db:"id"`
+ Token string `json:"token" db:"token"`
+ Label *string `json:"label" db:"label"`
+ ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
+ CreatedAt time.Time `json:"created_at" db:"created_at"`
+ UsedAt *time.Time `json:"used_at" db:"used_at"`
+ UsedByAgentID *uuid.UUID `json:"used_by_agent_id" db:"used_by_agent_id"`
+ Revoked bool `json:"revoked" db:"revoked"`
+ RevokedAt *time.Time `json:"revoked_at" db:"revoked_at"`
+ RevokedReason *string `json:"revoked_reason" db:"revoked_reason"`
+ Status string `json:"status" db:"status"`
+ CreatedBy string `json:"created_by" db:"created_by"`
+ Metadata map[string]interface{} `json:"metadata" db:"metadata"`
+}
+
+type TokenRequest struct {
+ Label string `json:"label"`
+ ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d"
+ Metadata map[string]interface{} `json:"metadata"`
+}
+
+type TokenResponse struct {
+ Token string `json:"token"`
+ Label string `json:"label"`
+ ExpiresAt time.Time `json:"expires_at"`
+ InstallCommand string `json:"install_command"`
+}
+
+func NewRegistrationTokenQueries(db *sqlx.DB) *RegistrationTokenQueries {
+ return &RegistrationTokenQueries{db: db}
+}
+
+// CreateRegistrationToken creates a new one-time use registration token
+func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, metadata map[string]interface{}) error {
+ metadataJSON, err := json.Marshal(metadata)
+ if err != nil {
+ return fmt.Errorf("failed to marshal metadata: %w", err)
+ }
+
+ query := `
+ INSERT INTO registration_tokens (token, label, expires_at, metadata)
+ VALUES ($1, $2, $3, $4)
+ `
+
+ _, err = q.db.Exec(query, token, label, expiresAt, metadataJSON)
+ if err != nil {
+ return fmt.Errorf("failed to create registration token: %w", err)
+ }
+
+ return nil
+}
+
+// ValidateRegistrationToken checks if a token is valid and unused
+func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*RegistrationToken, error) {
+ var regToken RegistrationToken
+ query := `
+ SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
+ revoked, revoked_at, revoked_reason, status, created_by, metadata
+ FROM registration_tokens
+ WHERE token = $1 AND status = 'active' AND expires_at > NOW()
+ `
+
+ err := q.db.Get(®Token, query, token)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("invalid or expired token")
+ }
+ return nil, fmt.Errorf("failed to validate token: %w", err)
+ }
+
+ return ®Token, nil
+}
+
+// MarkTokenUsed marks a token as used by an agent
+func (q *RegistrationTokenQueries) MarkTokenUsed(token string, agentID uuid.UUID) error {
+ query := `
+ UPDATE registration_tokens
+ SET status = 'used',
+ used_at = NOW(),
+ used_by_agent_id = $1
+ WHERE token = $2 AND status = 'active' AND expires_at > NOW()
+ `
+
+ result, err := q.db.Exec(query, agentID, token)
+ if err != nil {
+ return fmt.Errorf("failed to mark token as used: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %w", err)
+ }
+
+ if rowsAffected == 0 {
+ return fmt.Errorf("token not found or already used")
+ }
+
+ return nil
+}
+
+// GetActiveRegistrationTokens returns all active tokens
+func (q *RegistrationTokenQueries) GetActiveRegistrationTokens() ([]RegistrationToken, error) {
+ var tokens []RegistrationToken
+ query := `
+ SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
+ revoked, revoked_at, revoked_reason, status, created_by, metadata
+ FROM registration_tokens
+ WHERE status = 'active'
+ ORDER BY created_at DESC
+ `
+
+ err := q.db.Select(&tokens, query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get active tokens: %w", err)
+ }
+
+ return tokens, nil
+}
+
+// GetAllRegistrationTokens returns all tokens with pagination
+func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) ([]RegistrationToken, error) {
+ var tokens []RegistrationToken
+ query := `
+ SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
+ revoked, revoked_at, revoked_reason, status, created_by, metadata
+ FROM registration_tokens
+ ORDER BY created_at DESC
+ LIMIT $1 OFFSET $2
+ `
+
+ err := q.db.Select(&tokens, query, limit, offset)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get all tokens: %w", err)
+ }
+
+ return tokens, nil
+}
+
+// RevokeRegistrationToken revokes a token
+func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string) error {
+ query := `
+ UPDATE registration_tokens
+ SET status = 'revoked',
+ revoked = true,
+ revoked_at = NOW(),
+ revoked_reason = $1
+ WHERE token = $2 AND status = 'active'
+ `
+
+ result, err := q.db.Exec(query, reason, token)
+ if err != nil {
+ return fmt.Errorf("failed to revoke token: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %w", err)
+ }
+
+ if rowsAffected == 0 {
+ return fmt.Errorf("token not found or already used/revoked")
+ }
+
+ return nil
+}
+
+// CleanupExpiredTokens marks expired tokens as expired
+func (q *RegistrationTokenQueries) CleanupExpiredTokens() (int, error) {
+ query := `
+ UPDATE registration_tokens
+ SET status = 'expired',
+ used_at = NOW()
+ WHERE status = 'active' AND expires_at < NOW() AND used_at IS NULL
+ `
+
+ result, err := q.db.Exec(query)
+ if err != nil {
+ return 0, fmt.Errorf("failed to cleanup expired tokens: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return 0, fmt.Errorf("failed to get rows affected: %w", err)
+ }
+
+ return int(rowsAffected), nil
+}
+
+// GetTokenUsageStats returns statistics about token usage
+func (q *RegistrationTokenQueries) GetTokenUsageStats() (map[string]int, error) {
+ stats := make(map[string]int)
+
+ query := `
+ SELECT status, COUNT(*) as count
+ FROM registration_tokens
+ GROUP BY status
+ `
+
+ rows, err := q.db.Query(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get token stats: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var status string
+ var count int
+ if err := rows.Scan(&status, &count); err != nil {
+ return nil, fmt.Errorf("failed to scan token stats row: %w", err)
+ }
+ stats[status] = count
+ }
+
+ return stats, nil
+}
\ No newline at end of file
diff --git a/aggregator-server/internal/database/queries/updates.go b/aggregator-server/internal/database/queries/updates.go
index 69c8275..a2955ba 100644
--- a/aggregator-server/internal/database/queries/updates.go
+++ b/aggregator-server/internal/database/queries/updates.go
@@ -527,7 +527,8 @@ func (q *UpdateQueries) GetPackageHistory(agentID uuid.UUID, packageType, packag
}
// UpdatePackageStatus updates the status of a package and records history
-func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, packageName, status string, metadata models.JSONB) error {
+// completedAt is optional - if nil, uses time.Now(). Pass actual completion time for accurate audit trails.
+func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, packageName, status string, metadata models.JSONB, completedAt *time.Time) error {
tx, err := q.db.Beginx()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
@@ -542,13 +543,19 @@ func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, pack
return fmt.Errorf("failed to get current state: %w", err)
}
+ // Use provided timestamp or fall back to server time
+ timestamp := time.Now()
+ if completedAt != nil {
+ timestamp = *completedAt
+ }
+
// Update status
updateQuery := `
UPDATE current_package_state
SET status = $1, last_updated_at = $2
WHERE agent_id = $3 AND package_type = $4 AND package_name = $5
`
- _, err = tx.Exec(updateQuery, status, time.Now(), agentID, packageType, packageName)
+ _, err = tx.Exec(updateQuery, status, timestamp, agentID, packageType, packageName)
if err != nil {
return fmt.Errorf("failed to update package status: %w", err)
}
@@ -564,7 +571,7 @@ func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, pack
_, err = tx.Exec(historyQuery,
agentID, packageType, packageName, currentState.CurrentVersion,
currentState.AvailableVersion, currentState.Severity,
- currentState.RepositorySource, metadata, time.Now(), status)
+ currentState.RepositorySource, metadata, timestamp, status)
if err != nil {
return fmt.Errorf("failed to record version history: %w", err)
}
diff --git a/aggregator-server/internal/models/command.go b/aggregator-server/internal/models/command.go
index 3eb8e1f..ab64826 100644
--- a/aggregator-server/internal/models/command.go
+++ b/aggregator-server/internal/models/command.go
@@ -8,20 +8,28 @@ import (
// 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"`
+ 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"`
+ RetriedFromID *uuid.UUID `json:"retried_from_id,omitempty" db:"retried_from_id"`
}
// CommandsResponse is returned when an agent checks in for commands
type CommandsResponse struct {
- Commands []CommandItem `json:"commands"`
+ Commands []CommandItem `json:"commands"`
+ RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
+}
+
+// RapidPollingConfig contains rapid polling configuration for the agent
+type RapidPollingConfig struct {
+ Enabled bool `json:"enabled"`
+ Until string `json:"until"` // ISO 8601 timestamp
}
// CommandItem represents a command in the response
@@ -40,6 +48,8 @@ const (
CommandTypeConfirmDependencies = "confirm_dependencies"
CommandTypeRollback = "rollback_update"
CommandTypeUpdateAgent = "update_agent"
+ CommandTypeEnableHeartbeat = "enable_heartbeat"
+ CommandTypeDisableHeartbeat = "disable_heartbeat"
)
// Command statuses
@@ -55,15 +65,20 @@ const (
// ActiveCommandInfo represents information about an active command for UI display
type ActiveCommandInfo struct {
- ID uuid.UUID `json:"id" db:"id"`
- AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
- CommandType string `json:"command_type" db:"command_type"`
- Status string `json:"status" db:"status"`
- CreatedAt time.Time `json:"created_at" db:"created_at"`
- SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
- CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
- Result JSONB `json:"result,omitempty" db:"result"`
- AgentHostname string `json:"agent_hostname" db:"agent_hostname"`
- PackageName string `json:"package_name" db:"package_name"`
- PackageType string `json:"package_type" db:"package_type"`
+ 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"`
+ AgentHostname string `json:"agent_hostname" db:"agent_hostname"`
+ PackageName string `json:"package_name" db:"package_name"`
+ PackageType string `json:"package_type" db:"package_type"`
+ RetriedFromID *uuid.UUID `json:"retried_from_id,omitempty" db:"retried_from_id"`
+ IsRetry bool `json:"is_retry" db:"is_retry"`
+ HasBeenRetried bool `json:"has_been_retried" db:"has_been_retried"`
+ RetryCount int `json:"retry_count" db:"retry_count"`
}
diff --git a/aggregator-server/internal/services/timeout.go b/aggregator-server/internal/services/timeout.go
index 35ed545..a28727f 100644
--- a/aggregator-server/internal/services/timeout.go
+++ b/aggregator-server/internal/services/timeout.go
@@ -162,7 +162,8 @@ func (ts *TimeoutService) updateRelatedPackageStatus(command *models.AgentComman
command.Params["package_type"].(string),
command.Params["package_name"].(string),
"failed",
- metadata)
+ metadata,
+ nil) // nil = use time.Now() for timeout operations
}
// extractUpdatePackageID extracts the update package ID from command params
diff --git a/aggregator-web/src/App.tsx b/aggregator-web/src/App.tsx
index 1c8576a..83364f3 100644
--- a/aggregator-web/src/App.tsx
+++ b/aggregator-web/src/App.tsx
@@ -10,6 +10,8 @@ import Docker from '@/pages/Docker';
import LiveOperations from '@/pages/LiveOperations';
import History from '@/pages/History';
import Settings from '@/pages/Settings';
+import TokenManagement from '@/pages/TokenManagement';
+import RateLimiting from '@/pages/RateLimiting';
import Login from '@/pages/Login';
// Protected route component
@@ -98,6 +100,8 @@ const App: React.FC = () => {
Waiting
+Pending
- {activeOperations.filter(op => op.status === 'waiting').length} + {activeOperations.filter(op => op.status === 'pending' || op.status === 'sent').length}
+ INFO: This will remove failed commands from the active operations view, but all history will be preserved in the database for audit trails and continuity. +
++ WARNING: This shouldn't be necessary if the retry logic is working properly - you might want to check what's causing commands to fail in the first place! +
+Configure API rate limits and monitor system usage
+Active Endpoints
+{summary.active_endpoints}
+of {summary.total_endpoints} total
+Total Requests/Min
+{summary.total_requests_per_minute}
+Avg Utilization
++ {Math.round(summary.average_utilization)}% +
+Most Active
++ {formatEndpointName(summary.most_active_endpoint)} +
+Status
+Enabled
++ You have unsaved changes. Click "Save All Changes" to apply them. +
+| + Endpoint + | ++ Current Usage + | ++ Requests/Min + | ++ Window (min) + | ++ Max Requests + | + {showAdvanced && ( ++ Burst Allowance + | + )} +
|---|---|---|---|---|---|
|
+
+ {formatEndpointName(config.endpoint)}
+
+
+ {config.endpoint}
+
+ |
+
+
+ {endpointUsage && (
+
+
+ )}
+
+
+ = 90 ? 'bg-red-500' :
+ usagePercentage >= 70 ? 'bg-yellow-500' : 'bg-green-500'
+ }`}>
+ {endpointUsage.current} / {endpointUsage.limit}
+ ({Math.round(usagePercentage)}%)
+
+
+ {endpointUsage && (
+ = 90 ? 'bg-red-500' :
+ usagePercentage >= 70 ? 'bg-yellow-500' : 'bg-green-500'
+ }`}
+ style={{ width: `${Math.min(usagePercentage, 100)}%` }}
+ >
+
+
+ )}
+ |
+
+ + {editingMode ? ( + handleConfigChange(originalIndex, 'requests_per_minute', parseInt(e.target.value))} + className="w-24 px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( + {config.requests_per_minute} + )} + | + ++ {editingMode ? ( + handleConfigChange(originalIndex, 'window_minutes', parseInt(e.target.value))} + className="w-20 px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( + {config.window_minutes} + )} + | + ++ {editingMode ? ( + handleConfigChange(originalIndex, 'max_requests', parseInt(e.target.value))} + className="w-24 px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( + {config.max_requests} + )} + | + + {showAdvanced && ( ++ {editingMode ? ( + handleConfigChange(originalIndex, 'burst_allowance', parseInt(e.target.value))} + className="w-24 px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( + {config.burst_allowance} + )} + | + )} +
+ {searchTerm || statusFilter !== 'all' + ? 'Try adjusting your search or filter criteria' + : 'No rate limit configurations available'} +
+Loading rate limit configurations...
+Top Clients:
+Configure your RedFlag dashboard preferences
+Configure your RedFlag deployment and system preferences
Configure the timezone used for displaying timestamps
+Manage agent registration tokens
+ + + +Configure API rate limits
+ + +Coming soon
Coming soon
+{tokenStats.total_tokens}
+Total Tokens
+{tokenStats.active_tokens}
+Active
+{tokenStats.used_tokens}
+Used
+{tokenStats.expired_tokens}
+Expired
+Loading token statistics...
+{rateLimitSummary.active_endpoints}
+Active Endpoints
++ {rateLimitSummary.total_requests_per_minute} +
+Requests/Min
++ {Math.round(rateLimitSummary.average_utilization)}% +
+Avg Utilization
+Enabled
+System Protected
+Loading rate limit status...
+Updating timezone...
+ )} + {updateTimezone.isSuccess && ( +Timezone updated successfully
+ )} + {updateTimezone.isError && ( +Failed to update timezone
+ )} +Updating timezone...
- )} - - {updateTimezone.isSuccess && ( -Timezone updated successfully!
- )} - - {updateTimezone.isError && ( -- Failed to update timezone. Please try again. -
- )} -- This setting affects how timestamps are displayed throughout the dashboard, including agent - last check-in times, scan times, and update timestamps. -
Configure how the dashboard behaves and displays information
+- Automatically refresh dashboard data at regular intervals -
-- How often to refresh dashboard data when auto-refresh is enabled -
-More configuration options coming soon
-+ This settings page reflects the current state of the RedFlag backend API. +
Manage agent registration tokens and monitor their usage
+Total Tokens
+{stats.total_tokens}
+Active
+{stats.active_tokens}
+Used
+{stats.used_tokens}
+Expired
+{stats.expired_tokens}
+Seats Used
++ {stats.total_seats_used}/{stats.total_seats_available || '∞'} +
+Loading tokens...
++ {searchTerm || statusFilter !== 'all' + ? 'Try adjusting your search or filter criteria' + : 'Create your first token to begin registering agents'} +
+| + Token + | ++ Label + | ++ Status + | ++ Seats + | ++ Created + | ++ Expires + | ++ Last Used + | ++ Actions + | +
|---|---|---|---|---|---|---|---|
|
+
+
+
+ {showToken[token.id] ? token.token : '•••••••••••••••••'}
+
+
+ |
+
+ {token.label}
+ |
+
+
+ {getStatusText(token)}
+
+ |
+
+
+ {token.current_seats}
+ {token.max_seats && ` / ${token.max_seats}`}
+
+ {token.max_seats && (
+
+
+
+ )}
+ |
+ + {formatDateTime(token.created_at)} + | ++ {formatDateTime(token.expires_at) || 'Never'} + | ++ {formatDateTime(token.last_used_at) || 'Never'} + | +
+
+
+
+ {token.is_active && (
+
+ )}
+
+ |
+
Discovered
- {formatRelativeTime(selectedUpdate.created_at)} + {formatRelativeTime(selectedUpdate.last_discovered_at)}
Last Updated
- {formatRelativeTime(selectedUpdate.updated_at)} + {formatRelativeTime(selectedUpdate.last_updated_at)}