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 = () => { } /> } /> } /> + } /> + } /> } /> diff --git a/aggregator-web/src/components/ChatTimeline.tsx b/aggregator-web/src/components/ChatTimeline.tsx index 6db0dca..7a829c6 100644 --- a/aggregator-web/src/components/ChatTimeline.tsx +++ b/aggregator-web/src/components/ChatTimeline.tsx @@ -18,6 +18,7 @@ import { } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { logApi } from '@/lib/api'; +import { useRetryCommand } from '@/hooks/useCommands'; import { cn } from '@/lib/utils'; import toast from 'react-hot-toast'; import { Highlight, themes } from 'prism-react-renderer'; @@ -47,11 +48,80 @@ interface ChatTimelineProps { externalSearch?: string; // external search query from parent } +// Helper function to create smart summaries for package operations +const createPackageOperationSummary = (entry: HistoryEntry): string => { + const action = entry.action.replace(/_/g, ' '); + const result = entry.result || 'unknown'; + + // Extract package name from stdout or params + let packageName = 'unknown package'; + if (entry.params?.package_name) { + packageName = entry.params.package_name as string; + } else if (entry.stdout) { + // Look for package patterns in stdout + const packageMatch = entry.stdout.match(/(?:Upgrading|Installing|Package):\s+(\S+)/i); + if (packageMatch) { + packageName = packageMatch[1]; + } else { + // Look for "Packages installed: [pkg]" pattern + const installedMatch = entry.stdout.match(/Packages installed:\s*\[([^\]]+)\]/i); + if (installedMatch) { + packageName = installedMatch[1]; + } + } + } + + // Extract duration if available + let durationInfo = ''; + if (entry.logged_at) { + try { + const loggedTime = new Date(entry.logged_at).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + durationInfo = ` at ${loggedTime}`; + + if (entry.duration_seconds) { + durationInfo += ` (${entry.duration_seconds}s)`; + } + } catch (e) { + // Ignore date parsing errors + } + } + + // Create action-specific summaries + switch (entry.action) { + case 'upgrade': + case 'install': + case 'confirm_dependencies': + if (result === 'success' || result === 'completed') { + return `Successfully ${action}d ${packageName}${durationInfo}`; + } else if (result === 'failed' || result === 'error') { + return `Failed to ${action} ${packageName}${durationInfo}`; + } else { + return `${action.charAt(0).toUpperCase() + action.slice(1)} ${packageName}${durationInfo}`; + } + + case 'dry_run_update': + if (result === 'success' || result === 'completed') { + return `Dry run completed for ${packageName}${durationInfo}`; + } else { + return `Dry run for ${packageName}${durationInfo}`; + } + + default: + return `${action} ${packageName}${durationInfo}`; + } +}; + const ChatTimeline: React.FC = ({ agentId, className, isScopedView = false, externalSearch }) => { const [statusFilter, setStatusFilter] = useState('all'); // 'all', 'success', 'failed', 'pending', 'completed', 'running', 'timed_out' const [expandedEntries, setExpandedEntries] = useState>(new Set()); const [selectedAgents, setSelectedAgents] = useState([]); + // Retry command hook + const retryCommandMutation = useRetryCommand(); + // Query parameters for API const [queryParams, setQueryParams] = useState({ page: 1, @@ -440,18 +510,23 @@ const ChatTimeline: React.FC = ({ agentId, className, isScope sentence = `System log: ${entry.action}`; } } catch { - const lines = entry.stdout.split('\n'); - const firstLine = lines[0]?.trim(); - // Clean up common prefixes for more elegant system thoughts - if (firstLine) { - sentence = firstLine - .replace(/^(INFO|WARN|ERROR|DEBUG):\s*/i, '') - .replace(/^Step \d+:\s*/i, '') - .replace(/^Command:\s*/i, '') - .replace(/^Output:\s*/i, '') - .trim() || `System log: ${entry.action}`; + // Create smart summary for package management operations + if (['upgrade', 'install', 'confirm_dependencies', 'dry_run_update'].includes(entry.action)) { + sentence = createPackageOperationSummary(entry); } else { - sentence = `System log: ${entry.action}`; + const lines = entry.stdout.split('\n'); + const firstLine = lines[0]?.trim(); + // Clean up common prefixes for more elegant system thoughts + if (firstLine) { + sentence = firstLine + .replace(/^(INFO|WARN|ERROR|DEBUG):\s*/i, '') + .replace(/^Step \d+:\s*/i, '') + .replace(/^Command:\s*/i, '') + .replace(/^Output:\s*/i, '') + .trim() || `System log: ${entry.action}`; + } else { + sentence = `System log: ${entry.action}`; + } } } } else { @@ -564,8 +639,8 @@ const ChatTimeline: React.FC = ({ agentId, className, isScope )} {narrative.statusType === 'pending' && ( <> - - + + PENDING @@ -862,15 +937,24 @@ const ChatTimeline: React.FC = ({ agentId, className, isScope {(entry.result === 'failed' || entry.result === 'timed_out') && ( )} @@ -988,10 +1072,11 @@ const ChatTimeline: React.FC = ({ agentId, className, isScope

) : ( -
-
- {createTimelineWithDividers(filteredEntries)} -
+
+ {createTimelineWithDividers(filteredEntries)}
)} diff --git a/aggregator-web/src/hooks/useAgents.ts b/aggregator-web/src/hooks/useAgents.ts index 07e25a5..f87c6f8 100644 --- a/aggregator-web/src/hooks/useAgents.ts +++ b/aggregator-web/src/hooks/useAgents.ts @@ -7,8 +7,10 @@ export const useAgents = (params?: ListQueryParams): UseQueryResult agentApi.getAgents(params), - staleTime: 30 * 1000, // Consider data stale after 30 seconds - refetchInterval: 60 * 1000, // Auto-refetch every minute + staleTime: 30 * 1000, // Consider data fresh for 30 seconds + refetchInterval: 60 * 1000, // Poll every 60 seconds + refetchIntervalInBackground: false, // Don't poll when tab is inactive + refetchOnWindowFocus: true, // Refresh when window gains focus }); }; @@ -17,6 +19,10 @@ export const useAgent = (id: string, enabled: boolean = true): UseQueryResult agentApi.getAgent(id), enabled: enabled && !!id, + staleTime: 30 * 1000, // Consider data fresh for 30 seconds + refetchInterval: 30 * 1000, // Poll every 30 seconds for selected agent + refetchIntervalInBackground: false, // Don't poll when tab is inactive + refetchOnWindowFocus: true, // Refresh when window gains focus }); }; diff --git a/aggregator-web/src/hooks/useCommands.ts b/aggregator-web/src/hooks/useCommands.ts index 88dd8bc..f8b49c7 100644 --- a/aggregator-web/src/hooks/useCommands.ts +++ b/aggregator-web/src/hooks/useCommands.ts @@ -15,11 +15,12 @@ interface ActiveCommand { package_type: string; } -export const useActiveCommands = (): UseQueryResult<{ commands: ActiveCommand[]; count: number }, Error> => { +export const useActiveCommands = (autoRefresh: boolean = true): UseQueryResult<{ commands: ActiveCommand[]; count: number }, Error> => { return useQuery({ queryKey: ['activeCommands'], queryFn: () => updateApi.getActiveCommands(), - refetchInterval: 5000, // Auto-refresh every 5 seconds + refetchInterval: autoRefresh ? 5000 : false, // Auto-refresh every 5 seconds when enabled + staleTime: 0, // Override global staleTime to allow refetchInterval to work }); }; @@ -54,4 +55,21 @@ export const useCancelCommand = (): UseMutationResult => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateApi.clearFailedCommands, + onSuccess: () => { + // Invalidate active and recent commands queries to refresh the UI + queryClient.invalidateQueries({ queryKey: ['activeCommands'] }); + queryClient.invalidateQueries({ queryKey: ['recentCommands'] }); + }, + }); }; \ No newline at end of file diff --git a/aggregator-web/src/hooks/useHeartbeat.ts b/aggregator-web/src/hooks/useHeartbeat.ts new file mode 100644 index 0000000..fa730ee --- /dev/null +++ b/aggregator-web/src/hooks/useHeartbeat.ts @@ -0,0 +1,63 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { agentApi } from '@/lib/api'; +import type { UseQueryResult } from '@tanstack/react-query'; + +export interface HeartbeatStatus { + enabled: boolean; + until: string | null; + active: boolean; + duration_minutes: number; +} + +export const useHeartbeatStatus = (agentId: string, enabled: boolean = true): UseQueryResult => { + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: ['heartbeat', agentId], + queryFn: () => agentApi.getHeartbeatStatus(agentId), + enabled: enabled && !!agentId, + staleTime: 5000, // Consider data stale after 5 seconds + refetchInterval: (query) => { + // Smart polling: only poll when heartbeat is active + const data = query.state.data as HeartbeatStatus | undefined; + + // If heartbeat is enabled and still active, poll every 5 seconds + if (data?.enabled && data?.active) { + return 5000; // 5 seconds + } + + // If heartbeat is not active, don't poll + return false; + }, + refetchOnWindowFocus: false, // Don't refresh when window gains focus + refetchOnMount: true, // Always refetch when component mounts + }); +}; + +// Hook to manually invalidate heartbeat cache (used after commands) +export const useInvalidateHeartbeat = () => { + const queryClient = useQueryClient(); + + return (agentId: string) => { + // Invalidate heartbeat cache + queryClient.invalidateQueries({ queryKey: ['heartbeat', agentId] }); + + // Also invalidate agent cache to synchronize data + queryClient.invalidateQueries({ queryKey: ['agent', agentId] }); + queryClient.invalidateQueries({ queryKey: ['agents'] }); + }; +}; + +// Hook to synchronize agent data when heartbeat status changes +export const useHeartbeatAgentSync = (agentId: string, heartbeatStatus?: HeartbeatStatus) => { + const queryClient = useQueryClient(); + + // Sync agent data when heartbeat status changes + return () => { + if (agentId && heartbeatStatus) { + // Invalidate agent cache to get updated last_seen and status + queryClient.invalidateQueries({ queryKey: ['agent', agentId] }); + queryClient.invalidateQueries({ queryKey: ['agents'] }); + } + }; +}; \ No newline at end of file diff --git a/aggregator-web/src/hooks/useRateLimits.ts b/aggregator-web/src/hooks/useRateLimits.ts new file mode 100644 index 0000000..bb0e976 --- /dev/null +++ b/aggregator-web/src/hooks/useRateLimits.ts @@ -0,0 +1,131 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-hot-toast'; +import { adminApi } from '@/lib/api'; +import { + RateLimitConfig, + RateLimitStats, + RateLimitUsage, + RateLimitSummary +} from '@/types'; + +// Query keys +export const rateLimitKeys = { + all: ['rate-limits'] as const, + configs: () => [...rateLimitKeys.all, 'configs'] as const, + stats: () => [...rateLimitKeys.all, 'stats'] as const, + usage: () => [...rateLimitKeys.all, 'usage'] as const, + summary: () => [...rateLimitKeys.all, 'summary'] as const, +}; + +// Hooks +export const useRateLimitConfigs = () => { + return useQuery({ + queryKey: rateLimitKeys.configs(), + queryFn: () => adminApi.rateLimits.getConfigs(), + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; + +export const useRateLimitStats = () => { + return useQuery({ + queryKey: rateLimitKeys.stats(), + queryFn: () => adminApi.rateLimits.getStats(), + staleTime: 1000 * 30, // 30 seconds + refetchInterval: 1000 * 30, // Refresh every 30 seconds for real-time monitoring + }); +}; + +export const useRateLimitUsage = () => { + return useQuery({ + queryKey: rateLimitKeys.usage(), + queryFn: () => adminApi.rateLimits.getUsage(), + staleTime: 1000 * 15, // 15 seconds + refetchInterval: 1000 * 15, // Refresh every 15 seconds for live usage + }); +}; + +export const useRateLimitSummary = () => { + return useQuery({ + queryKey: rateLimitKeys.summary(), + queryFn: () => adminApi.rateLimits.getSummary(), + staleTime: 1000 * 60, // 1 minute + refetchInterval: 1000 * 60, // Refresh every minute + }); +}; + +export const useUpdateRateLimitConfig = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ endpoint, config }: { endpoint: string; config: Partial }) => + adminApi.rateLimits.updateConfig(endpoint, config), + onSuccess: (_, { endpoint }) => { + toast.success(`Rate limit configuration for ${endpoint} updated successfully`); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.configs() }); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.stats() }); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.usage() }); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.summary() }); + }, + onError: (error: any, { endpoint }) => { + console.error(`Failed to update rate limit config for ${endpoint}:`, error); + toast.error(error.response?.data?.message || `Failed to update rate limit configuration for ${endpoint}`); + }, + }); +}; + +export const useUpdateAllRateLimitConfigs = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (configs: RateLimitConfig[]) => + adminApi.rateLimits.updateAllConfigs(configs), + onSuccess: () => { + toast.success('All rate limit configurations updated successfully'); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.configs() }); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.stats() }); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.usage() }); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.summary() }); + }, + onError: (error: any) => { + console.error('Failed to update rate limit configurations:', error); + toast.error(error.response?.data?.message || 'Failed to update rate limit configurations'); + }, + }); +}; + +export const useResetRateLimitConfigs = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => adminApi.rateLimits.resetConfigs(), + onSuccess: () => { + toast.success('Rate limit configurations reset to defaults successfully'); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.configs() }); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.stats() }); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.usage() }); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.summary() }); + }, + onError: (error: any) => { + console.error('Failed to reset rate limit configurations:', error); + toast.error(error.response?.data?.message || 'Failed to reset rate limit configurations'); + }, + }); +}; + +export const useCleanupRateLimits = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => adminApi.rateLimits.cleanup(), + onSuccess: (result) => { + toast.success(`Cleaned up ${result.cleaned} expired rate limit entries`); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.stats() }); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.usage() }); + queryClient.invalidateQueries({ queryKey: rateLimitKeys.summary() }); + }, + onError: (error: any) => { + console.error('Failed to cleanup rate limits:', error); + toast.error(error.response?.data?.message || 'Failed to cleanup rate limits'); + }, + }); +}; \ No newline at end of file diff --git a/aggregator-web/src/hooks/useRegistrationTokens.ts b/aggregator-web/src/hooks/useRegistrationTokens.ts new file mode 100644 index 0000000..31014b0 --- /dev/null +++ b/aggregator-web/src/hooks/useRegistrationTokens.ts @@ -0,0 +1,103 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-hot-toast'; +import { adminApi } from '@/lib/api'; +import { + RegistrationToken, + CreateRegistrationTokenRequest, + RegistrationTokenStats +} from '@/types'; + +// Query keys +export const registrationTokenKeys = { + all: ['registration-tokens'] as const, + lists: () => [...registrationTokenKeys.all, 'list'] as const, + list: (params: any) => [...registrationTokenKeys.lists(), params] as const, + details: () => [...registrationTokenKeys.all, 'detail'] as const, + detail: (id: string) => [...registrationTokenKeys.details(), id] as const, + stats: () => [...registrationTokenKeys.all, 'stats'] as const, +}; + +// Hooks +export const useRegistrationTokens = (params?: { + page?: number; + page_size?: number; + is_active?: boolean; + label?: string; +}) => { + return useQuery({ + queryKey: registrationTokenKeys.list(params), + queryFn: () => adminApi.tokens.getTokens(params), + staleTime: 1000 * 60, // 1 minute + }); +}; + +export const useRegistrationToken = (id: string) => { + return useQuery({ + queryKey: registrationTokenKeys.detail(id), + queryFn: () => adminApi.tokens.getToken(id), + enabled: !!id, + staleTime: 1000 * 60, // 1 minute + }); +}; + +export const useRegistrationTokenStats = () => { + return useQuery({ + queryKey: registrationTokenKeys.stats(), + queryFn: () => adminApi.tokens.getStats(), + staleTime: 1000 * 60, // 1 minute + refetchInterval: 1000 * 60 * 5, // Refresh every 5 minutes + }); +}; + +export const useCreateRegistrationToken = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateRegistrationTokenRequest) => + adminApi.tokens.createToken(data), + onSuccess: (newToken) => { + toast.success(`Registration token "${newToken.label}" created successfully`); + queryClient.invalidateQueries({ queryKey: registrationTokenKeys.lists() }); + queryClient.invalidateQueries({ queryKey: registrationTokenKeys.stats() }); + }, + onError: (error: any) => { + console.error('Failed to create registration token:', error); + toast.error(error.response?.data?.message || 'Failed to create registration token'); + }, + }); +}; + +export const useRevokeRegistrationToken = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => adminApi.tokens.revokeToken(id), + onSuccess: (_, tokenId) => { + toast.success('Registration token revoked successfully'); + queryClient.invalidateQueries({ queryKey: registrationTokenKeys.lists() }); + queryClient.invalidateQueries({ queryKey: registrationTokenKeys.detail(tokenId) }); + queryClient.invalidateQueries({ queryKey: registrationTokenKeys.stats() }); + }, + onError: (error: any) => { + console.error('Failed to revoke registration token:', error); + toast.error(error.response?.data?.message || 'Failed to revoke registration token'); + }, + }); +}; + +export const useCleanupRegistrationTokens = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => adminApi.tokens.cleanup(), + onSuccess: (result) => { + toast.success(`Cleaned up ${result.cleaned} expired tokens`); + queryClient.invalidateQueries({ queryKey: registrationTokenKeys.lists() }); + queryClient.invalidateQueries({ queryKey: registrationTokenKeys.stats() }); + }, + onError: (error: any) => { + console.error('Failed to cleanup registration tokens:', error); + toast.error(error.response?.data?.message || 'Failed to cleanup registration tokens'); + }, + }); +}; \ No newline at end of file diff --git a/aggregator-web/src/lib/api.ts b/aggregator-web/src/lib/api.ts index 7705489..3f96e79 100644 --- a/aggregator-web/src/lib/api.ts +++ b/aggregator-web/src/lib/api.ts @@ -14,7 +14,14 @@ import { DockerContainerListResponse, DockerStats, DockerUpdateRequest, - BulkDockerUpdateRequest + BulkDockerUpdateRequest, + RegistrationToken, + CreateRegistrationTokenRequest, + RegistrationTokenStats, + RateLimitConfig, + RateLimitStats, + RateLimitUsage, + RateLimitSummary } from '@/types'; // Base URL for API @@ -75,6 +82,21 @@ export const agentApi = { await api.post(`/agents/${id}/scan`); }, + // Trigger heartbeat toggle on single agent + toggleHeartbeat: async (id: string, enabled: boolean, durationMinutes: number = 10): Promise<{ message: string; command_id: string; enabled: boolean }> => { + const response = await api.post(`/agents/${id}/heartbeat`, { + enabled: enabled, + duration_minutes: durationMinutes, + }); + return response.data; + }, + + // Get heartbeat status for single agent + getHeartbeatStatus: async (id: string): Promise<{ enabled: boolean; until: string | null; active: boolean; duration_minutes: number }> => { + const response = await api.get(`/agents/${id}/heartbeat`); + return response.data; + }, + // Unregister/remove agent unregisterAgent: async (id: string): Promise => { await api.delete(`/agents/${id}`); @@ -147,6 +169,28 @@ export const updateApi = { }); return response.data; }, + + // Clear failed commands with filtering options + clearFailedCommands: async (options?: { + olderThanDays?: number; + onlyRetried?: boolean; + allFailed?: boolean; + }): Promise<{ message: string; count: number; cheeky_warning?: string }> => { + const params = new URLSearchParams(); + + if (options?.olderThanDays !== undefined) { + params.append('older_than_days', options.olderThanDays.toString()); + } + if (options?.onlyRetried) { + params.append('only_retried', 'true'); + } + if (options?.allFailed) { + params.append('all_failed', 'true'); + } + + const response = await api.delete(`/commands/failed${params.toString() ? '?' + params.toString() : ''}`); + return response.data; + }, }; export const statsApi = { @@ -351,4 +395,144 @@ export const dockerApi = { }, }; +// Admin API endpoints +export const adminApi = { + // Registration Token Management + tokens: { + // Get all registration tokens + getTokens: async (params?: { + page?: number; + page_size?: number; + is_active?: boolean; + label?: string; + }): Promise<{ tokens: RegistrationToken[]; total: number; page: number; page_size: number }> => { + const response = await api.get('/admin/registration-tokens', { params }); + return response.data; + }, + + // Get single registration token + getToken: async (id: string): Promise => { + const response = await api.get(`/admin/registration-tokens/${id}`); + return response.data; + }, + + // Create new registration token + createToken: async (request: CreateRegistrationTokenRequest): Promise => { + const response = await api.post('/admin/registration-tokens', request); + return response.data; + }, + + // Revoke registration token + revokeToken: async (id: string): Promise => { + await api.delete(`/admin/registration-tokens/${id}`); + }, + + // Get registration token statistics + getStats: async (): Promise => { + const response = await api.get('/admin/registration-tokens/stats'); + return response.data; + }, + + // Cleanup expired tokens + cleanup: async (): Promise<{ cleaned: number }> => { + const response = await api.post('/admin/registration-tokens/cleanup'); + return response.data; + }, + }, + + // Rate Limiting Management + rateLimits: { + // Get all rate limit configurations + getConfigs: async (): Promise => { + const response = await api.get('/admin/rate-limits'); + return response.data; + }, + + // Update rate limit configuration + updateConfig: async (endpoint: string, config: Partial): Promise => { + const response = await api.put(`/admin/rate-limits/${endpoint}`, config); + return response.data; + }, + + // Update all rate limit configurations + updateAllConfigs: async (configs: RateLimitConfig[]): Promise => { + const response = await api.put('/admin/rate-limits', { configs }); + return response.data; + }, + + // Reset rate limit configurations to defaults + resetConfigs: async (): Promise => { + const response = await api.post('/admin/rate-limits/reset'); + return response.data; + }, + + // Get rate limit statistics + getStats: async (): Promise => { + const response = await api.get('/admin/rate-limits/stats'); + return response.data; + }, + + // Get rate limit usage + getUsage: async (): Promise => { + const response = await api.get('/admin/rate-limits/usage'); + return response.data; + }, + + // Get rate limit summary + getSummary: async (): Promise => { + const response = await api.get('/admin/rate-limits/summary'); + return response.data; + }, + + // Cleanup expired rate limit data + cleanup: async (): Promise<{ cleaned: number }> => { + const response = await api.post('/admin/rate-limits/cleanup'); + return response.data; + }, + }, + + // System Administration + system: { + // Get system health and status + getHealth: async (): Promise<{ + status: 'healthy' | 'degraded' | 'unhealthy'; + uptime: number; + version: string; + database_status: 'connected' | 'disconnected'; + active_agents: number; + active_tokens: number; + rate_limits_enabled: boolean; + }> => { + const response = await api.get('/admin/system/health'); + return response.data; + }, + + // Get active agents + getActiveAgents: async (): Promise<{ + agents: Array<{ + id: string; + hostname: string; + last_seen: string; + status: string; + }>; + count: number; + }> => { + const response = await api.get('/admin/system/active-agents'); + return response.data; + }, + + // Get system configuration + getConfig: async (): Promise> => { + const response = await api.get('/admin/system/config'); + return response.data; + }, + + // Update system configuration + updateConfig: async (config: Record): Promise> => { + const response = await api.put('/admin/system/config', config); + return response.data; + }, + }, +}; + export default api; \ No newline at end of file diff --git a/aggregator-web/src/lib/utils.ts b/aggregator-web/src/lib/utils.ts index 17d9e12..4693615 100644 --- a/aggregator-web/src/lib/utils.ts +++ b/aggregator-web/src/lib/utils.ts @@ -18,6 +18,19 @@ export const formatDate = (dateString: string): string => { }); }; +export const formatDateTime = (dateString: string | null): string => { + if (!dateString) return 'Never'; + + const date = new Date(dateString); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + export const formatRelativeTime = (dateString: string): string => { if (!dateString) return 'Never'; diff --git a/aggregator-web/src/main.tsx b/aggregator-web/src/main.tsx index aaaf14c..936d0d3 100644 --- a/aggregator-web/src/main.tsx +++ b/aggregator-web/src/main.tsx @@ -10,7 +10,8 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 2, - staleTime: 10 * 1000, // 10 seconds + staleTime: 0, // Data is always stale to allow real-time updates + refetchOnWindowFocus: false, // Don't refetch on window focus to avoid unnecessary requests }, }, }) diff --git a/aggregator-web/src/pages/Agents.tsx b/aggregator-web/src/pages/Agents.tsx index 3bc739d..48a5225 100644 --- a/aggregator-web/src/pages/Agents.tsx +++ b/aggregator-web/src/pages/Agents.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Computer, @@ -6,6 +6,7 @@ import { Search, Filter, ChevronRight as ChevronRightIcon, + ChevronDown, Activity, Calendar, Package, @@ -23,6 +24,9 @@ import { } from 'lucide-react'; import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents'; import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands'; +import { useHeartbeatStatus, useInvalidateHeartbeat, useHeartbeatAgentSync } from '@/hooks/useHeartbeat'; +import { agentApi } from '@/lib/api'; +import { useQueryClient } from '@tanstack/react-query'; import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils'; import { cn } from '@/lib/utils'; import toast from 'react-hot-toast'; @@ -32,6 +36,7 @@ import ChatTimeline from '@/components/ChatTimeline'; const Agents: React.FC = () => { const { id } = useParams<{ id?: string }>(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const [searchQuery, setSearchQuery] = useState(''); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); @@ -39,6 +44,40 @@ const Agents: React.FC = () => { const [showFilters, setShowFilters] = useState(false); const [selectedAgents, setSelectedAgents] = useState([]); const [activeTab, setActiveTab] = useState<'overview' | 'history'>('overview'); + const [currentTime, setCurrentTime] = useState(new Date()); + const [heartbeatDuration, setHeartbeatDuration] = useState(10); // Default 10 minutes + const [showDurationDropdown, setShowDurationDropdown] = useState(false); + const [heartbeatLoading, setHeartbeatLoading] = useState(false); // Loading state for heartbeat toggle + const [heartbeatCommandId, setHeartbeatCommandId] = useState(null); // Track specific heartbeat command + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setShowDurationDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + // Duration options for heartbeat + const durationOptions = [ + { label: '10 minutes', value: 10 }, + { label: '30 minutes', value: 30 }, + { label: '1 hour', value: 60 }, + { label: 'Permanent', value: -1 }, + ]; + + // Get duration label for display + const getDurationLabel = (duration: number) => { + const option = durationOptions.find(opt => opt.value === duration); + return option?.label || '10 minutes'; + }; // Debounce search query to avoid API calls on every keystroke useEffect(() => { @@ -51,6 +90,18 @@ const Agents: React.FC = () => { }; }, [searchQuery]); + + // Update current time every second for countdown timers + useEffect(() => { + const timer = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + + return () => { + clearInterval(timer); + }; + }, []); + // Helper function to get system metadata from agent const getSystemMetadata = (agent: any) => { const metadata = agent.metadata || {}; @@ -124,8 +175,41 @@ const Agents: React.FC = () => { return { platform, distribution, version: version.trim() }; }; + // Helper function to format heartbeat expiration time + const formatHeartExpiration = (untilString: string) => { + const until = new Date(untilString); + const now = new Date(); + const diffMs = until.getTime() - now.getTime(); + + if (diffMs <= 0) { + return 'expired'; + } + + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + + if (diffMinutes < 60) { + return `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`; + } + + const diffHours = Math.floor(diffMinutes / 60); + const remainingMinutes = diffMinutes % 60; + + if (diffHours < 24) { + return remainingMinutes > 0 + ? `${diffHours} hour${diffHours !== 1 ? 's' : ''} ${remainingMinutes} min` + : `${diffHours} hour${diffHours !== 1 ? 's' : ''}`; + } + + const diffDays = Math.floor(diffHours / 24); + const remainingHours = diffHours % 24; + + return remainingHours > 0 + ? `${diffDays} day${diffDays !== 1 ? 's' : ''} ${remainingHours} hour${remainingHours !== 1 ? 's' : ''}` + : `${diffDays} day${diffDays !== 1 ? 's' : ''}`; + }; + // Fetch agents list - const { data: agentsData, isPending, error } = useAgents({ + const { data: agentsData, isPending, error, refetch } = useAgents({ search: debouncedSearchQuery || undefined, status: statusFilter !== 'all' ? statusFilter : undefined, }); @@ -141,9 +225,31 @@ const Agents: React.FC = () => { const { data: activeCommandsData, refetch: refetchActiveCommands } = useActiveCommands(); const cancelCommandMutation = useCancelCommand(); + const agents = agentsData?.agents || []; const selectedAgent = selectedAgentData || agents.find(a => a.id === id); + // Get heartbeat status for selected agent (smart polling - only when active) + const { data: heartbeatStatus } = useHeartbeatStatus(selectedAgent?.id || '', !!selectedAgent); + const invalidateHeartbeat = useInvalidateHeartbeat(); + const syncAgentData = useHeartbeatAgentSync(selectedAgent?.id || '', heartbeatStatus); + + + // Simple completion handling - clear loading state quickly + useEffect(() => { + if (!heartbeatCommandId) return; + + // Clear loading state quickly since smart polling will handle UI updates + const timeout = setTimeout(() => { + setHeartbeatCommandId(null); + setHeartbeatLoading(false); + }, 2000); // 2 seconds - enough time for command to process + + return () => { + clearTimeout(timeout); + }; + }, [heartbeatCommandId]); + // Filter agents based on OS const filteredAgents = agents.filter(agent => { if (osFilter === 'all') return true; @@ -224,6 +330,40 @@ const Agents: React.FC = () => { } }; + // Handle rapid polling toggle + const handleRapidPollingToggle = async (agentId: string, enabled: boolean, durationMinutes?: number) => { + // Prevent multiple clicks + if (heartbeatLoading) return; + + setHeartbeatLoading(true); + try { + const duration = durationMinutes || heartbeatDuration; + const result = await agentApi.toggleHeartbeat(agentId, enabled, duration); + + // Immediately invalidate cache to force fresh data + invalidateHeartbeat(agentId); + + // Store the command ID for minimal tracking + if (result.command_id) { + setHeartbeatCommandId(result.command_id); + } + + if (enabled) { + if (duration === -1) { + toast.success('Heartbeat enabled permanently'); + } else { + toast.success(`Heartbeat enabled for ${duration} minutes`); + } + } else { + toast.success('Heartbeat disabled'); + } + } catch (error: any) { + toast.error(`Failed to send heartbeat command: ${error.message || 'Unknown error'}`); + setHeartbeatLoading(false); + setHeartbeatCommandId(null); + } + }; + // Get agent-specific active commands const getAgentActiveCommands = () => { if (!selectedAgent || !activeCommandsData?.commands) return []; @@ -232,11 +372,19 @@ const Agents: React.FC = () => { // Helper function to get command display info const getCommandDisplayInfo = (command: any) => { + // Helper to get package name from command params + const getPackageName = (cmd: any) => { + if (cmd.package_name) return cmd.package_name; + if (cmd.params?.package_name) return cmd.params.package_name; + if (cmd.params?.update_id && cmd.update_name) return cmd.update_name; + return 'unknown package'; + }; + const actionMap: { [key: string]: { icon: React.ReactNode; label: string } } = { 'scan': { icon: , label: 'System scan' }, - 'install_updates': { icon: , label: `Installing ${command.package_name || 'packages'}` }, - 'dry_run_update': { icon: , label: `Checking dependencies for ${command.package_name || 'packages'}` }, - 'confirm_dependencies': { icon: , label: `Installing confirmed dependencies` }, + 'install_updates': { icon: , label: `Installing ${getPackageName(command)}` }, + 'dry_run_update': { icon: , label: `Checking dependencies for ${getPackageName(command)}` }, + 'confirm_dependencies': { icon: , label: `Installing ${getPackageName(command)}` }, }; return actionMap[command.command_type] || { @@ -386,22 +534,88 @@ const Agents: React.FC = () => { {isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'}
+ + {/* Heartbeat Status Indicator */} +
+ {(() => { + // Use dedicated heartbeat status instead of general agent metadata + const isRapidPolling = heartbeatStatus?.enabled && heartbeatStatus?.active; + + return ( + + ); + })()} +
{/* Compact Timeline Display */}
{(() => { const agentCommands = getAgentActiveCommands(); - const activeCommands = agentCommands.filter(cmd => + + // Separate heartbeat commands from other commands + const heartbeatCommands = agentCommands.filter(cmd => + cmd.command_type === 'enable_heartbeat' || cmd.command_type === 'disable_heartbeat' + ); + const otherCommands = agentCommands.filter(cmd => + cmd.command_type !== 'enable_heartbeat' && cmd.command_type !== 'disable_heartbeat' + ); + + // For heartbeat commands: only show the MOST RECENT one, but exclude old completed ones + const recentHeartbeatCommands = heartbeatCommands.filter(cmd => { + const createdTime = new Date(cmd.created_at); + const now = new Date(); + const hoursOld = (now.getTime() - createdTime.getTime()) / (1000 * 60 * 60); + + // Exclude completed/failed heartbeat commands older than 30 minutes + if ((cmd.status === 'completed' || cmd.status === 'failed' || cmd.status === 'timed_out') && hoursOld > 0.5) { + return false; + } + return true; + }); + + const latestHeartbeatCommand = recentHeartbeatCommands.length > 0 + ? [recentHeartbeatCommands.reduce((latest, cmd) => + new Date(cmd.created_at) > new Date(latest.created_at) ? cmd : latest + )] + : []; + + // For other commands: show active ones normally + const activeOtherCommands = otherCommands.filter(cmd => cmd.status === 'running' || cmd.status === 'sent' || cmd.status === 'pending' ); - const completedCommands = agentCommands.filter(cmd => + const completedOtherCommands = otherCommands.filter(cmd => cmd.status === 'completed' || cmd.status === 'failed' || cmd.status === 'timed_out' ).slice(0, 1); // Only show last completed const displayCommands = [ - ...activeCommands.slice(0, 2), // Max 2 active - ...completedCommands.slice(0, 1) // Max 1 completed + ...latestHeartbeatCommand.slice(0, 1), // Max 1 heartbeat (latest only) + ...activeOtherCommands.slice(0, 2), // Max 2 active other commands + ...completedOtherCommands.slice(0, 1) // Max 1 completed other command ].slice(0, 3); // Total max 3 entries if (displayCommands.length === 0) { @@ -454,7 +668,18 @@ const Agents: React.FC = () => {
- {formatRelativeTime(command.created_at)} + {(() => { + const createdTime = new Date(command.created_at); + const now = new Date(); + const hoursOld = (now.getTime() - createdTime.getTime()) / (1000 * 60 * 60); + + // Show exact time for commands older than 1 hour, relative time for recent ones + if (hoursOld > 1) { + return createdTime.toLocaleString(); + } else { + return formatRelativeTime(command.created_at); + } + })()} {isActive && (command.status === 'pending' || command.status === 'sent') && (
+ {/* Heartbeat Status Info */} + {heartbeatStatus?.enabled && heartbeatStatus?.active && ( +
+ Heartbeat active for {formatHeartExpiration(heartbeatStatus.until)} +
+ )} + {/* Action Button */}
+ {/* Split button for heartbeat with duration */} +
+ + + {/* Duration dropdown */} +
+ + + {showDurationDropdown && ( +
+
+ {durationOptions.map((option) => ( + + ))} +
+
+ )} +
+
+ + {failedCount > 0 && ( + + )}
@@ -265,9 +321,9 @@ const LiveOperations: React.FC = () => {
-

Waiting

+

Pending

- {activeOperations.filter(op => op.status === 'waiting').length} + {activeOperations.filter(op => op.status === 'pending' || op.status === 'sent').length}

@@ -331,7 +387,8 @@ const LiveOperations: React.FC = () => { > - + + @@ -371,6 +428,17 @@ const LiveOperations: React.FC = () => { {getStatusIcon(operation.status)} {operation.status} + {operation.isRetry && operation.retryCount && operation.retryCount > 0 && ( + + + Retry #{operation.retryCount} + + )} + {operation.hasBeenRetried && ( + + Retried + + )}
@@ -382,7 +450,15 @@ const LiveOperations: React.FC = () => {
@@ -403,7 +479,7 @@ const LiveOperations: React.FC = () => {
{/* Expanded details */} - {expandedOperation === operation.id && ( + {expandedOperations.has(operation.id) && (
@@ -460,14 +536,21 @@ const LiveOperations: React.FC = () => { {/* Retry button for failed/timed_out commands */} {operation.commandStatus === 'failed' || operation.commandStatus === 'timed_out' ? ( - + operation.hasBeenRetried ? ( +
+ + Already Retried +
+ ) : ( + + ) ) : null}
@@ -501,6 +584,114 @@ const LiveOperations: React.FC = () => {
)}
+ + {/* Cleanup Confirmation Dialog */} + {showCleanupDialog && ( +
+
+

Archive Failed Operations

+ +
+

+ 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! +

+
+ +
+
+ +
+ setCleanupOptions(prev => ({ + ...prev, + olderThanDays: parseInt(e.target.value) || 0 + }))} + className="w-20 px-3 py-2 border border-gray-300 rounded-md text-sm" + /> + days +
+
+ +
+ +
+ + + +
+
+
+ +
+ + +
+
+
+ )}
); }; diff --git a/aggregator-web/src/pages/RateLimiting.tsx b/aggregator-web/src/pages/RateLimiting.tsx new file mode 100644 index 0000000..041ef8c --- /dev/null +++ b/aggregator-web/src/pages/RateLimiting.tsx @@ -0,0 +1,601 @@ +import React, { useState, useMemo } from 'react'; +import { + Shield, + RefreshCw, + Save, + RotateCcw, + Activity, + AlertTriangle, + TrendingUp, + BarChart3, + Settings as SettingsIcon, + Eye, + Users, + Search, + Filter +} from 'lucide-react'; +import { + useRateLimitConfigs, + useRateLimitStats, + useRateLimitUsage, + useRateLimitSummary, + useUpdateAllRateLimitConfigs, + useResetRateLimitConfigs, + useCleanupRateLimits +} from '../hooks/useRateLimits'; +import { RateLimitConfig, RateLimitStats, RateLimitUsage } from '@/types'; + +const RateLimiting: React.FC = () => { + const [editingMode, setEditingMode] = useState(false); + const [editingConfigs, setEditingConfigs] = useState([]); + const [showAdvanced, setShowAdvanced] = useState(false); + + // Search and filter state + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled'>('all'); + + // Queries + const { data: configs, isLoading: isLoadingConfigs, refetch: refetchConfigs } = useRateLimitConfigs(); + const { data: stats, isLoading: isLoadingStats } = useRateLimitStats(); + const { data: usage, isLoading: isLoadingUsage } = useRateLimitUsage(); + const { data: summary, isLoading: isLoadingSummary } = useRateLimitSummary(); + + // Mutations + const updateAllConfigs = useUpdateAllRateLimitConfigs(); + const resetConfigs = useResetRateLimitConfigs(); + const cleanupLimits = useCleanupRateLimits(); + + React.useEffect(() => { + if (configs) { + setEditingConfigs([...configs]); + } + }, [configs]); + + // Filtered configurations for display + const filteredConfigs = useMemo(() => { + if (!configs) return []; + + return configs.filter((config) => { + const matchesSearch = searchTerm === '' || + config.endpoint.toLowerCase().includes(searchTerm.toLowerCase()) || + config.method.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesStatus = statusFilter === 'all' || + (statusFilter === 'enabled' && config.enabled) || + (statusFilter === 'disabled' && !config.enabled); + + return matchesSearch && matchesStatus; + }); + }, [configs, searchTerm, statusFilter]); + + const handleConfigChange = (index: number, field: keyof RateLimitConfig, value: any) => { + const updatedConfigs = [...editingConfigs]; + updatedConfigs[index] = { ...updatedConfigs[index], [field]: value }; + setEditingConfigs(updatedConfigs); + }; + + const handleSaveAllConfigs = () => { + updateAllConfigs.mutate(editingConfigs, { + onSuccess: () => { + setEditingMode(false); + refetchConfigs(); + } + }); + }; + + const handleResetConfigs = () => { + if (confirm('Reset all rate limit configurations to defaults? This will overwrite your custom settings.')) { + resetConfigs.mutate(undefined, { + onSuccess: () => { + setEditingMode(false); + refetchConfigs(); + } + }); + } + }; + + const handleCleanup = () => { + if (confirm('Clean up expired rate limit data?')) { + cleanupLimits.mutate(undefined, { + onSuccess: () => { + // Refetch stats and usage after cleanup + } + }); + } + }; + + const getUsagePercentage = (endpoint: string) => { + const endpointUsage = usage?.find(u => u.endpoint === endpoint); + if (!endpointUsage) return 0; + return (endpointUsage.current / endpointUsage.limit) * 100; + }; + + const getUsageColor = (percentage: number) => { + if (percentage >= 90) return 'text-red-600 bg-red-100'; + if (percentage >= 70) return 'text-yellow-600 bg-yellow-100'; + return 'text-green-600 bg-green-100'; + }; + + const formatEndpointName = (endpoint: string) => { + return endpoint.split('/').pop()?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) || endpoint; + }; + + return ( +
+ {/* Header */} +
+
+
+

Rate Limiting

+

Configure API rate limits and monitor system usage

+
+
+ + + +
+
+
+ + {/* Summary Cards */} + {summary && ( +
+
+
+
+

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

+
+ +
+
+
+ )} + + {/* Controls */} + {(editingMode || editingConfigs.length > 0) && ( +
+
+

+ You have unsaved changes. Click "Save All Changes" to apply them. +

+
+ + +
+
+
+ )} + + {/* Rate Limit Configurations */} +
+
+
+

Rate Limit Configurations

+
+ {!editingMode && ( + + )} + +
+
+
+ + {/* Search and Filter Controls */} +
+
+
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ +
+ + + +
+
+ + {/* Filter results summary */} + {configs && ( +
+ Showing {filteredConfigs.length} of {configs.length} configurations +
+ )} +
+ + {filteredConfigs.length > 0 ? ( +
+ + + + + + + + + {showAdvanced && ( + + )} + + + + {filteredConfigs.map((config) => { + const originalIndex = editingConfigs.findIndex(c => c.endpoint === config.endpoint); + const usagePercentage = getUsagePercentage(config.endpoint); + const endpointUsage = usage?.find(u => u.endpoint === config.endpoint); + + return ( + + + + + + + + + + + + {showAdvanced && ( + + )} + + ); + })} + +
+ Endpoint + + Current Usage + + Requests/Min + + Window (min) + + Max Requests + + 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)}%) +
+
+
= 90 ? 'bg-red-500' : + usagePercentage >= 70 ? 'bg-yellow-500' : 'bg-green-500' + }`} + style={{ width: `${Math.min(usagePercentage, 100)}%` }} + >
+
+ {endpointUsage && ( +
+ + + Window: {formatDateTime(endpointUsage.window_start)} - {formatDateTime(endpointUsage.window_end)} + +
+ )} +
+ )} +
+ {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} + )} + + {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} + )} +
+
+ ) : configs && configs.length > 0 ? ( +
+ +

No configurations found

+

+ {searchTerm || statusFilter !== 'all' + ? 'Try adjusting your search or filter criteria' + : 'No rate limit configurations available'} +

+
+ ) : ( +
+
+

Loading rate limit configurations...

+
+ )} +
+ + {/* Rate Limit Statistics */} + {stats && stats.length > 0 && ( +
+
+

Rate Limit Statistics

+
+
+
+ {stats.map((stat) => ( +
+
+

+ {formatEndpointName(stat.endpoint)} +

+ +
+ +
+
+ Current Requests: + {stat.current_requests} +
+
+ Limit: + {stat.limit} +
+
+ Blocked: + {stat.blocked_requests} +
+
+ Window: + + {new Date(stat.window_start).toLocaleTimeString()} - {new Date(stat.window_end).toLocaleTimeString()} + +
+
+ + {stat.top_clients && stat.top_clients.length > 0 && ( +
+

Top Clients:

+
+ {stat.top_clients.slice(0, 3).map((client, index) => ( +
+ {client.identifier} + {client.request_count} +
+ ))} +
+
+ )} +
+ ))} +
+
+
+ )} + + {/* Usage Monitoring */} + {usage && usage.length > 0 && ( +
+
+

Usage Monitoring

+
+
+
+ {usage.map((endpointUsage) => ( +
+
+

+ {formatEndpointName(endpointUsage.endpoint)} +

+ +
+ +
+
+
+ Usage + + {endpointUsage.current} / {endpointUsage.limit} + +
+
+
= 90 ? 'bg-red-500' : + (endpointUsage.current / endpointUsage.limit) * 100 >= 70 ? 'bg-yellow-500' : 'bg-green-500' + }`} + style={{ width: `${Math.min((endpointUsage.current / endpointUsage.limit) * 100, 100)}%` }} + >
+
+
+ +
+
Remaining: {endpointUsage.remaining} requests
+
Reset: {formatDateTime(endpointUsage.reset_time)}
+
Window: {endpointUsage.window_minutes} minutes
+
+
+
+ ))} +
+
+
+ )} +
+ ); +}; + +export default RateLimiting; \ No newline at end of file diff --git a/aggregator-web/src/pages/Settings.tsx b/aggregator-web/src/pages/Settings.tsx index 039de09..298b042 100644 --- a/aggregator-web/src/pages/Settings.tsx +++ b/aggregator-web/src/pages/Settings.tsx @@ -1,17 +1,35 @@ import React from 'react'; -import { Clock } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { + Clock, + User, + Shield, + Server, + Settings as SettingsIcon, + ArrowRight, + AlertTriangle, + CheckCircle, + Activity +} from 'lucide-react'; import { useSettingsStore } from '@/lib/store'; import { useTimezones, useTimezone, useUpdateTimezone } from '../hooks/useSettings'; +import { useRegistrationTokenStats } from '../hooks/useRegistrationTokens'; +import { useRateLimitSummary } from '../hooks/useRateLimits'; +import { formatDateTime } from '@/lib/utils'; const Settings: React.FC = () => { const { autoRefresh, refreshInterval, setAutoRefresh, setRefreshInterval } = useSettingsStore(); + // Timezone settings const { data: timezones, isLoading: isLoadingTimezones } = useTimezones(); const { data: currentTimezone, isLoading: isLoadingCurrentTimezone } = useTimezone(); const updateTimezone = useUpdateTimezone(); - const [selectedTimezone, setSelectedTimezone] = React.useState(''); + // Statistics for overview + const { data: tokenStats } = useRegistrationTokenStats(); + const { data: rateLimitSummary } = useRateLimitSummary(); + React.useEffect(() => { if (currentTimezone?.timezone) { setSelectedTimezone(currentTimezone.timezone); @@ -21,168 +39,302 @@ const Settings: React.FC = () => { const handleTimezoneChange = async (e: React.ChangeEvent) => { const newTimezone = e.target.value; setSelectedTimezone(newTimezone); - try { await updateTimezone.mutateAsync(newTimezone); } catch (error) { console.error('Failed to update timezone:', error); - // Revert on error - if (currentTimezone?.timezone) { - setSelectedTimezone(currentTimezone.timezone); - } } }; + const overviewCards = [ + { + title: 'Registration Tokens', + description: 'Create and manage agent registration tokens', + icon: Shield, + href: '/settings/tokens', + stats: tokenStats ? { + total: tokenStats.total_tokens, + active: tokenStats.active_tokens, + used: tokenStats.used_tokens, + color: 'blue' + } : null, + status: 'implemented' + }, + { + title: 'Rate Limiting', + description: 'Configure API rate limits and monitor usage', + icon: Activity, + href: '/settings/rate-limiting', + stats: rateLimitSummary ? { + active: rateLimitSummary.active_endpoints, + total: rateLimitSummary.total_endpoints, + utilization: Math.round(rateLimitSummary.average_utilization), + color: 'green' + } : null, + status: 'implemented' + }, + { + title: 'System Configuration', + description: 'Server settings and performance tuning', + icon: Server, + href: '/settings/system', + stats: null, + status: 'not-implemented' + }, + { + title: 'Agent Management', + description: 'Agent defaults and cleanup policies', + icon: SettingsIcon, + href: '/settings/agents', + stats: null, + status: 'not-implemented' + } + ]; + return ( -
-
-

Settings

-

Configure your RedFlag dashboard preferences

+
+ {/* Header */} +
+

Settings

+

Configure your RedFlag deployment and system preferences

- {/* Timezone Settings */} -
-
-
- + {/* Quick Actions */} +
+ +
+ +
-
-

Timezone Settings

-

Configure the timezone used for displaying timestamps

+

Registration Tokens

+

Manage agent registration tokens

+ + + +
+ +
+

Rate Limiting

+

Configure API rate limits

+ + +
+
+ + +
+

System Configuration

+

Coming soon

-
+
+
+ + +
+

Agent Management

+

Coming soon

+
+
+ + {/* Overview Statistics */} +
+ {/* Token Overview */} +
+
+

Token Overview

+ + Manage all → + +
+ {tokenStats ? ( +
+
+

{tokenStats.total_tokens}

+

Total Tokens

+
+
+

{tokenStats.active_tokens}

+

Active

+
+
+

{tokenStats.used_tokens}

+

Used

+
+
+

{tokenStats.expired_tokens}

+

Expired

+
+
+ ) : ( +
+
+

Loading token statistics...

+
+ )} +
+ + {/* Rate Limiting Overview */} +
+
+

Rate Limiting Status

+ + Configure → + +
+ {rateLimitSummary ? ( +
+
+

{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...

+
+ )} +
+
+ + {/* Account Settings */} +
+

Account Settings

+ +
+ {/* Display Preferences */}
- -
+

Display Preferences

+ +
+ + {updateTimezone.isPending && ( +

Updating timezone...

+ )} + {updateTimezone.isSuccess && ( +

Timezone updated successfully

+ )} + {updateTimezone.isError && ( +

Failed to update timezone

+ )} +
+
- {/* Custom dropdown arrow */} -
- - - + {/* Dashboard Behavior */} +
+

Dashboard Behavior

+
+
+
+
Auto-refresh
+
Automatically refresh dashboard data
+
+ +
+ +
+ +
- - {updateTimezone.isPending && ( -

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

- {/* Dashboard Settings */} -
-
-
- + {/* Implementation Status */} +
+

Implementation Status

+
+
+

✅ Implemented Features

+
    +
  • • Registration token management (full CRUD)
  • +
  • • API rate limiting configuration
  • +
  • • Real-time usage monitoring
  • +
  • • User preferences (timezone, dashboard)
  • +
-

Dashboard Settings

-

Configure how the dashboard behaves and displays information

+

🚧 Planned Features

+
    +
  • • System configuration management
  • +
  • • Agent management settings
  • +
  • • Integration with third-party services
  • +
  • • Persistent settings storage
  • +
- -
- {/* Auto Refresh */} -
-
-

Auto Refresh

-

- Automatically refresh dashboard data at regular intervals -

-
- -
- - {/* Refresh Interval */} -
-

Refresh Interval

- -

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

-
-
-
- - {/* Future Settings Sections */} -
-
-
- -
-
-

Additional Settings

-

More configuration options coming soon

-
-
- -
-
• Notification preferences
-
• Agent monitoring settings
-
• Data retention policies
-
• API access tokens
-
+

+ This settings page reflects the current state of the RedFlag backend API. +

); diff --git a/aggregator-web/src/pages/TokenManagement.tsx b/aggregator-web/src/pages/TokenManagement.tsx new file mode 100644 index 0000000..1362d7a --- /dev/null +++ b/aggregator-web/src/pages/TokenManagement.tsx @@ -0,0 +1,524 @@ +import React, { useState } from 'react'; +import { + Shield, + Plus, + Search, + Filter, + RefreshCw, + Download, + Trash2, + Copy, + Eye, + EyeOff, + AlertTriangle, + CheckCircle, + Clock, + Users +} from 'lucide-react'; +import { + useRegistrationTokens, + useCreateRegistrationToken, + useRevokeRegistrationToken, + useRegistrationTokenStats, + useCleanupRegistrationTokens +} from '../hooks/useRegistrationTokens'; +import { RegistrationToken, CreateRegistrationTokenRequest } from '@/types'; +import { formatDateTime } from '@/lib/utils'; + +const TokenManagement: React.FC = () => { + // Filters and search + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'used' | 'expired' | 'revoked'>('all'); + const [showCreateForm, setShowCreateForm] = useState(false); + const [showToken, setShowToken] = useState>({}); + + // Pagination + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 50; + + // Token management + const { data: tokensData, isLoading, refetch } = useRegistrationTokens({ + page: currentPage, + page_size: pageSize, + is_active: statusFilter === 'all' ? undefined : statusFilter === 'active', + label: searchTerm || undefined, + }); + + const { data: stats, isLoading: isLoadingStats } = useRegistrationTokenStats(); + const createToken = useCreateRegistrationToken(); + const revokeToken = useRevokeRegistrationToken(); + const cleanupTokens = useCleanupRegistrationTokens(); + + // Reset page when filters change + React.useEffect(() => { + setCurrentPage(1); + }, [searchTerm, statusFilter]); + + // Form state + const [formData, setFormData] = useState({ + label: '', + max_seats: 10, + expires_at: '', + }); + + const handleCreateToken = (e: React.FormEvent) => { + e.preventDefault(); + createToken.mutate(formData, { + onSuccess: () => { + setFormData({ label: '', max_seats: 10, expires_at: '' }); + setShowCreateForm(false); + refetch(); + }, + }); + }; + + const handleRevokeToken = (tokenId: string, tokenLabel: string) => { + if (confirm(`Revoke token "${tokenLabel}"? Agents using it will need to re-register.`)) { + revokeToken.mutate(tokenId, { onSuccess: () => refetch() }); + } + }; + + const handleCleanup = () => { + if (confirm('Clean up all expired tokens? This cannot be undone.')) { + cleanupTokens.mutate(undefined, { onSuccess: () => refetch() }); + } + }; + + const copyToClipboard = async (text: string) => { + await navigator.clipboard.writeText(text); + // Show success feedback + }; + + const copyInstallCommand = async (token: string) => { + const command = `curl -sSL https://get.redflag.dev | bash -s -- ${token}`; + await navigator.clipboard.writeText(command); + }; + + const generateInstallCommand = (token: string) => { + return `curl -sSL https://get.redflag.dev | bash -s -- ${token}`; + }; + + const getStatusColor = (token: RegistrationToken) => { + if (!token.is_active) return 'text-gray-500'; + if (token.expires_at && new Date(token.expires_at) < new Date()) return 'text-red-600'; + if (token.max_seats && token.current_seats >= token.max_seats) return 'text-yellow-600'; + return 'text-green-600'; + }; + + const getStatusText = (token: RegistrationToken) => { + if (!token.is_active) return 'Revoked'; + if (token.expires_at && new Date(token.expires_at) < new Date()) return 'Expired'; + if (token.max_seats && token.current_seats >= token.max_seats) return 'Full'; + return 'Active'; + }; + + const filteredTokens = tokensData?.tokens || []; + + return ( +
+ {/* Header */} +
+
+
+

Registration Tokens

+

Manage agent registration tokens and monitor their usage

+
+
+ + + +
+
+
+ + {/* Statistics Cards */} + {stats && ( +
+
+
+
+

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 || '∞'} +

+
+ +
+
+
+ )} + + {/* Create Token Form */} + {showCreateForm && ( +
+

Create New Registration Token

+
+
+
+ + setFormData({ ...formData, label: e.target.value })} + placeholder="e.g., Production Team" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ + setFormData({ ...formData, max_seats: e.target.value ? parseInt(e.target.value) : undefined })} + placeholder="Leave empty for unlimited" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ + setFormData({ ...formData, expires_at: e.target.value })} + min={new Date().toISOString().slice(0, 16)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ +
+ + +
+
+
+ )} + + {/* Filters and Search */} +
+
+
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ +
+ + + + +
+
+
+ + {/* Tokens List */} +
+ {isLoading ? ( +
+
+

Loading tokens...

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

No tokens found

+

+ {searchTerm || statusFilter !== 'all' + ? 'Try adjusting your search or filter criteria' + : 'Create your first token to begin registering agents'} +

+
+ ) : ( +
+ + + + + + + + + + + + + + + {filteredTokens.map((token) => ( + + + + + + + + + + + ))} + +
+ 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 && ( + + )} +
+
+
+ )} +
+ + {/* Pagination */} + {tokensData && tokensData.total > pageSize && ( +
+
+ Showing {((currentPage - 1) * pageSize) + 1}-{Math.min(currentPage * pageSize, tokensData.total)} of {tokensData.total} tokens +
+
+ + +
+ {Array.from({ length: Math.min(5, Math.ceil(tokensData.total / pageSize)) }, (_, i) => { + const totalPages = Math.ceil(tokensData.total / pageSize); + let pageNum; + + if (totalPages <= 5) { + pageNum = i + 1; + } else if (currentPage <= 3) { + pageNum = i + 1; + } else if (currentPage >= totalPages - 2) { + pageNum = totalPages - 4 + i; + } else { + pageNum = currentPage - 2 + i; + } + + return ( + + ); + })} +
+ + +
+
+ )} +
+ ); +}; + +export default TokenManagement; \ No newline at end of file diff --git a/aggregator-web/src/pages/Updates.tsx b/aggregator-web/src/pages/Updates.tsx index 6b137c7..4d99d73 100644 --- a/aggregator-web/src/pages/Updates.tsx +++ b/aggregator-web/src/pages/Updates.tsx @@ -419,14 +419,14 @@ const Updates: React.FC = () => {

Discovered

- {formatRelativeTime(selectedUpdate.created_at)} + {formatRelativeTime(selectedUpdate.last_discovered_at)}

Last Updated

- {formatRelativeTime(selectedUpdate.updated_at)} + {formatRelativeTime(selectedUpdate.last_updated_at)}

@@ -521,7 +521,7 @@ const Updates: React.FC = () => { onClick={() => { // This would need a way to find the associated command ID // For now, we'll show a message indicating this needs to be implemented - toast.info('Retry functionality will be available in the command history view'); + toast('Retry functionality will be available in the command history view', { icon: 'ℹ️' }); }} className="w-full btn btn-warning" > @@ -1295,11 +1295,11 @@ const Updates: React.FC = () => { Actions @@ -1376,7 +1376,7 @@ const Updates: React.FC = () => {
- {formatRelativeTime(update.created_at)} + {formatRelativeTime(update.last_discovered_at)}
diff --git a/aggregator-web/src/types/index.ts b/aggregator-web/src/types/index.ts index f058868..6da2c31 100644 --- a/aggregator-web/src/types/index.ts +++ b/aggregator-web/src/types/index.ts @@ -47,8 +47,9 @@ export interface UpdatePackage { available_version: string; severity: 'low' | 'medium' | 'high' | 'critical'; status: 'pending' | 'approved' | 'scheduled' | 'installing' | 'installed' | 'failed' | 'checking_dependencies' | 'pending_dependencies'; - created_at: string; - updated_at: string; + // Timestamp fields - matching backend API response + last_discovered_at: string; // When package was first discovered + last_updated_at: string; // When package status was last updated approved_at: string | null; scheduled_at: string | null; installed_at: string | null; @@ -285,4 +286,76 @@ export interface ApiError { message: string; code?: string; details?: any; +} + +// Registration Token types +export interface RegistrationToken { + id: string; + token: string; + label: string; + expires_at: string | null; + max_seats: number | null; + current_seats: number; + is_active: boolean; + created_at: string; + updated_at: string; + last_used_at: string | null; + metadata: Record; +} + +export interface CreateRegistrationTokenRequest { + label: string; + expires_at?: string; + max_seats?: number; + metadata?: Record; +} + +export interface RegistrationTokenStats { + total_tokens: number; + active_tokens: number; + used_tokens: number; + expired_tokens: number; + revoked_tokens: number; + total_seats_used: number; + total_seats_available: number; +} + +// Rate Limiting types +export interface RateLimitConfig { + endpoint: string; + requests_per_minute: number; + window_minutes: number; + max_requests: number; + burst_allowance: number; + metadata: Record; +} + +export interface RateLimitStats { + endpoint: string; + current_requests: number; + limit: number; + window_start: string; + window_end: string; + blocked_requests: number; + top_clients: Array<{ + identifier: string; + request_count: number; + }>; +} + +export interface RateLimitUsage { + endpoint: string; + limit: number; + current: number; + remaining: number; + reset_time: string; + window_minutes: number; +} + +export interface RateLimitSummary { + total_endpoints: number; + active_endpoints: number; + total_requests_per_minute: number; + most_active_endpoint: string; + average_utilization: number; } \ No newline at end of file