diff --git a/aggregator-server/internal/api/handlers/agent_updates.go b/aggregator-server/internal/api/handlers/agent_updates.go index 281d4d0..be2b70c 100644 --- a/aggregator-server/internal/api/handlers/agent_updates.go +++ b/aggregator-server/internal/api/handlers/agent_updates.go @@ -16,6 +16,8 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" ) +// AgentUpdateHandler handles agent binary update operations +// DEPRECATED: This handler is being consolidated - will be replaced by unified update handling type AgentUpdateHandler struct { agentQueries *queries.AgentQueries agentUpdateQueries *queries.AgentUpdateQueries diff --git a/aggregator-server/internal/api/handlers/update_handler.go b/aggregator-server/internal/api/handlers/update_handler.go new file mode 100644 index 0000000..cbcabb0 --- /dev/null +++ b/aggregator-server/internal/api/handlers/update_handler.go @@ -0,0 +1,881 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "strconv" + "time" + + "github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries" + "github.com/Fimeg/RedFlag/aggregator-server/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +// UnifiedUpdateHandler processes all update reports (metrics, package updates, etc.) +type UnifiedUpdateHandler struct { + db *sqlx.DB + agentQueries *queries.AgentQueries + updateQueries *queries.UpdateQueries + subsystemQueries *queries.SubsystemQueries + commandQueries *queries.CommandQueries + agentHandler *AgentHandler + logger *log.Logger +} + +// NewUnifiedUpdateHandler creates a new update handler +func NewUnifiedUpdateHandler(db *sqlx.DB, logger *log.Logger, ah *AgentHandler) *UnifiedUpdateHandler { + return &UnifiedUpdateHandler{ + db: db, + agentQueries: queries.NewAgentQueries(db), + updateQueries: queries.NewUpdateQueries(db), + subsystemQueries: queries.NewSubsystemQueries(db), + commandQueries: queries.NewCommandQueries(db), + agentHandler: ah, + logger: logger, + } +} + +// Report handles POST /api/v1/agents/:id/updates +func (h *UnifiedUpdateHandler) Report(c *gin.Context) { + agentIDStr := c.Param("id") + agentID, err := uuid.Parse(agentIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"}) + return + } + + // Update last_seen timestamp + if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"}) + return + } + + var report models.UpdateReportRequest + if err := c.ShouldBindJSON(&report); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate report + if err := h.validateReport(&report); err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + + // Route to appropriate handler based on report type + if h.isPackageUpdateReport(&report) { + if err := h.handlePackageUpdateReport(agentID, &report); err != nil { + h.logger.Printf("Failed to handle package update report: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process updates"}) + return + } + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "unknown report type"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "received", + "count": len(report.Updates), + "command_id": report.CommandID, + }) +} + +// validateReport validates the update report +func (h *UnifiedUpdateHandler) validateReport(report *models.UpdateReportRequest) error { + if report.Timestamp.IsZero() { + return fmt.Errorf("timestamp is required") + } + + // Validate updates + if len(report.Updates) > 0 { + for i, update := range report.Updates { + if update.PackageName == "" { + return fmt.Errorf("update[%d]: package name is required", i) + } + if update.PackageType == "" { + return fmt.Errorf("update[%d]: package type is required", i) + } + if update.AvailableVersion == "" { + return fmt.Errorf("update[%d]: available version is required", i) + } + } + } + + return nil +} + +// isPackageUpdateReport determines if the report contains package updates +func (h *UnifiedUpdateHandler) isPackageUpdateReport(report *models.UpdateReportRequest) bool { + return len(report.Updates) > 0 +} + +// handlePackageUpdateReport processes package update data +func (h *UnifiedUpdateHandler) handlePackageUpdateReport(agentID uuid.UUID, report *models.UpdateReportRequest) error { + // Convert update report items to events + events := make([]models.UpdateEvent, 0, len(report.Updates)) + for _, item := range report.Updates { + event := models.UpdateEvent{ + ID: uuid.New(), + AgentID: agentID, + PackageType: item.PackageType, + PackageName: item.PackageName, + VersionFrom: item.CurrentVersion, + VersionTo: item.AvailableVersion, + Severity: item.Severity, + RepositorySource: item.RepositorySource, + Metadata: item.Metadata, + EventType: "discovered", + CreatedAt: report.Timestamp, + } + events = append(events, event) + } + + // Store events in batch + if err := h.updateQueries.CreateUpdateEventsBatch(events); err != nil { + return fmt.Errorf("failed to create update events: %w", err) + } + + return nil +} + +// ListUpdates retrieves updates with filtering +func (h *UnifiedUpdateHandler) ListUpdates(c *gin.Context) { + filters := &models.UpdateFilters{ + Status: c.Query("status"), + Severity: c.Query("severity"), + PackageType: c.Query("package_type"), + } + + if agentIDStr := c.Query("agent_id"); agentIDStr != "" { + agentID, err := uuid.Parse(agentIDStr) + if err == nil { + filters.AgentID = agentID + } + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + filters.Page = page + filters.PageSize = pageSize + + updates, total, err := h.updateQueries.ListUpdatesFromState(filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list updates"}) + return + } + + stats, err := h.updateQueries.GetAllUpdateStats() + if err != nil { + stats = &models.UpdateStats{} + } + + c.JSON(http.StatusOK, gin.H{ + "updates": updates, + "total": total, + "page": page, + "page_size": pageSize, + "stats": stats, + }) +} + +// GetUpdate retrieves a single update by ID +func (h *UnifiedUpdateHandler) GetUpdate(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"}) + return + } + + update, err := h.updateQueries.GetUpdateByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "update not found"}) + return + } + + c.JSON(http.StatusOK, update) +} + +// ApproveUpdate marks an update as approved +func (h *UnifiedUpdateHandler) ApproveUpdate(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"}) + return + } + + if err := h.updateQueries.ApproveUpdate(id, "admin"); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to approve update: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "update approved"}) +} + +// isValidResult checks if the result value complies with the database constraint +func isValidUpdateResult(result string) bool { + validResults := map[string]bool{ + "success": true, + "failed": true, + "partial": true, + } + return validResults[result] +} + +// ReportLog handles update execution logs from agents +func (h *UnifiedUpdateHandler) ReportLog(c *gin.Context) { + agentID := c.MustGet("agent_id").(uuid.UUID) + + if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"}) + return + } + + var req models.UpdateLogRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + validResult := req.Result + if !isValidUpdateResult(validResult) { + if validResult == "timed_out" || validResult == "timeout" || validResult == "cancelled" { + validResult = "failed" + } else { + validResult = "failed" + } + } + + logEntry := &models.UpdateLog{ + ID: uuid.New(), + AgentID: agentID, + Action: req.Action, + Result: validResult, + Stdout: req.Stdout, + Stderr: req.Stderr, + ExitCode: req.ExitCode, + DurationSeconds: req.DurationSeconds, + ExecutedAt: time.Now(), + } + + if err := h.updateQueries.CreateUpdateLog(logEntry); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save log"}) + return + } + + // Update command status if command_id is provided + if req.CommandID != "" { + commandID, err := uuid.Parse(req.CommandID) + if err != nil { + log.Printf("Warning: Invalid command ID format in log request: %s\n", req.CommandID) + } else { + result := models.JSONB{ + "stdout": req.Stdout, + "stderr": req.Stderr, + "exit_code": req.ExitCode, + "duration_seconds": req.DurationSeconds, + "logged_at": time.Now(), + } + + if req.Result == "success" || req.Result == "completed" { + if err := h.commandQueries.MarkCommandCompleted(commandID, result); err != nil { + log.Printf("Warning: Failed to mark command %s as completed: %v\n", commandID, err) + } + + command, err := h.commandQueries.GetCommandByID(commandID) + if err == nil && command.CommandType == models.CommandTypeConfirmDependencies { + if packageName, ok := command.Params["package_name"].(string); ok { + if packageType, ok := command.Params["package_type"].(string); ok { + 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 + } + } + + 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 { + log.Printf("Warning: Failed to mark command %s as failed: %v\n", commandID, err) + } + } else { + if err := h.commandQueries.UpdateCommandResult(commandID, result); err != nil { + log.Printf("Warning: Failed to update command %s result: %v\n", commandID, err) + } + } + } + } + + c.JSON(http.StatusOK, gin.H{"message": "log recorded"}) +} + +// GetPackageHistory returns version history for a specific package +func (h *UnifiedUpdateHandler) GetPackageHistory(c *gin.Context) { + agentIDStr := c.Param("agent_id") + agentID, err := uuid.Parse(agentIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"}) + return + } + + packageType := c.Query("package_type") + packageName := c.Query("package_name") + + if packageType == "" || packageName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "package_type and package_name are required"}) + return + } + + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + + history, err := h.updateQueries.GetPackageHistory(agentID, packageType, packageName, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get package history"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "history": history, + "package_type": packageType, + "package_name": packageName, + "count": len(history), + }) +} + +// UpdatePackageStatus updates the status of a package +func (h *UnifiedUpdateHandler) UpdatePackageStatus(c *gin.Context) { + agentIDStr := c.Param("agent_id") + agentID, err := uuid.Parse(agentIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"}) + return + } + + var req struct { + PackageType string `json:"package_type" binding:"required"` + PackageName string `json:"package_name" binding:"required"` + Status string `json:"status" binding:"required"` + Metadata map[string]interface{} `json:"metadata"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + 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 + } + + c.JSON(http.StatusOK, gin.H{"message": "package status updated"}) +} + +// shouldEnableHeartbeat checks if heartbeat is already active for an agent +func (h *UnifiedUpdateHandler) 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 + } + + 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)) { + log.Printf("[Heartbeat] Agent %s already has active heartbeat until %s (skipping)", agentID, untilStr) + return false, nil + } + } + } + + return true, nil +} + +// InstallUpdate marks an update as ready for installation +func (h *UnifiedUpdateHandler) InstallUpdate(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"}) + return + } + + update, err := h.updateQueries.GetUpdateByID(id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get update details"}) + return + } + + command := &models.AgentCommand{ + ID: uuid.New(), + AgentID: update.AgentID, + CommandType: models.CommandTypeDryRunUpdate, + Params: map[string]interface{}{ + "update_id": id.String(), + "package_name": update.PackageName, + "package_type": update.PackageType, + }, + Status: models.CommandStatusPending, + Source: models.CommandSourceManual, + CreatedAt: time.Now(), + } + + 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, + Source: models.CommandSourceSystem, + 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) + } + } + + if err := h.commandQueries.CreateCommand(command); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dry run command"}) + return + } + + if err := h.updateQueries.SetCheckingDependencies(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "dry run command created for agent", + "command_id": command.ID.String(), + }) +} + +// ReportDependencies handles dependency reporting from agents after dry run +func (h *UnifiedUpdateHandler) ReportDependencies(c *gin.Context) { + agentID := c.MustGet("agent_id").(uuid.UUID) + + if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"}) + return + } + + var req models.DependencyReportRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Auto-approve if no dependencies + if len(req.Dependencies) == 0 { + update, err := h.updateQueries.GetUpdateByPackage(agentID, req.PackageType, req.PackageName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get update details"}) + return + } + + command := &models.AgentCommand{ + ID: uuid.New(), + AgentID: agentID, + CommandType: models.CommandTypeConfirmDependencies, + Params: map[string]interface{}{ + "update_id": update.ID.String(), + "package_name": req.PackageName, + "package_type": req.PackageType, + "dependencies": []string{}, + }, + Status: models.CommandStatusPending, + Source: models.CommandSourceManual, + CreatedAt: time.Now(), + } + + 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, + Source: models.CommandSourceSystem, + 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) + } + } + + if err := h.commandQueries.CreateCommand(command); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create installation command"}) + return + } + + if err := h.updateQueries.SetInstallingWithNoDependencies(update.ID, req.Dependencies); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status to installing"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "no dependencies found - installation command created automatically", + "command_id": command.ID.String(), + }) + return + } + + // Require manual approval for dependencies + if err := h.updateQueries.SetPendingDependencies(agentID, req.PackageType, req.PackageName, req.Dependencies); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "dependencies reported and status updated"}) +} + +// ConfirmDependencies handles user confirmation to proceed with dependency installation +func (h *UnifiedUpdateHandler) ConfirmDependencies(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"}) + return + } + + update, err := h.updateQueries.GetUpdateByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "update not found"}) + return + } + + command := &models.AgentCommand{ + ID: uuid.New(), + AgentID: update.AgentID, + CommandType: models.CommandTypeConfirmDependencies, + Params: map[string]interface{}{ + "update_id": id.String(), + "package_name": update.PackageName, + "package_type": update.PackageType, + "dependencies": update.Metadata["dependencies"], + }, + Status: models.CommandStatusPending, + Source: models.CommandSourceManual, + CreatedAt: time.Now(), + } + + 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, + Source: models.CommandSourceSystem, + 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) + } + } + + if err := h.commandQueries.CreateCommand(command); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create confirmation command"}) + return + } + + if err := h.updateQueries.InstallUpdate(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "dependency installation confirmed and command created", + "command_id": command.ID.String(), + }) +} + +// ApproveUpdates handles bulk approval of updates +func (h *UnifiedUpdateHandler) ApproveUpdates(c *gin.Context) { + var req struct { + UpdateIDs []string `json:"update_ids" binding:"required"` + ScheduledAt *string `json:"scheduled_at"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updateIDs := make([]uuid.UUID, 0, len(req.UpdateIDs)) + for _, idStr := range req.UpdateIDs { + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID: " + idStr}) + return + } + updateIDs = append(updateIDs, id) + } + + if err := h.updateQueries.BulkApproveUpdates(updateIDs, "admin"); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to approve updates"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "updates approved", + "count": len(updateIDs), + }) +} + +// RejectUpdate rejects a single update +func (h *UnifiedUpdateHandler) RejectUpdate(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"}) + return + } + + if err := h.updateQueries.RejectUpdate(id, "admin"); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject update"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "update rejected"}) +} + +// GetAllLogs retrieves logs across all agents with filtering +func (h *UnifiedUpdateHandler) GetAllLogs(c *gin.Context) { + filters := &models.LogFilters{ + Action: c.Query("action"), + Result: c.Query("result"), + } + + if agentIDStr := c.Query("agent_id"); agentIDStr != "" { + agentID, err := uuid.Parse(agentIDStr) + if err == nil { + filters.AgentID = agentID + } + } + + if sinceStr := c.Query("since"); sinceStr != "" { + sinceTime, err := time.Parse(time.RFC3339, sinceStr) + if err == nil { + filters.Since = &sinceTime + } + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100")) + filters.Page = page + filters.PageSize = pageSize + + items, total, err := h.updateQueries.GetAllUnifiedHistory(filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve history"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "logs": items, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// GetUpdateLogs retrieves installation logs for a specific update +func (h *UnifiedUpdateHandler) GetUpdateLogs(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"}) + return + } + + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + + logs, err := h.updateQueries.GetUpdateLogs(id, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve update logs"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "logs": logs, + "count": len(logs), + }) +} + +// RetryCommand retries a failed command +func (h *UnifiedUpdateHandler) RetryCommand(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"}) + return + } + + newCommand, err := h.commandQueries.RetryCommand(id) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to retry command: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "command retry created", + "command_id": newCommand.ID.String(), + "new_id": newCommand.ID.String(), + }) +} + +// CancelCommand cancels a pending command +func (h *UnifiedUpdateHandler) CancelCommand(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"}) + return + } + + if err := h.commandQueries.CancelCommand(id); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to cancel command: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "command cancelled"}) +} + +// GetActiveCommands retrieves currently active commands +func (h *UnifiedUpdateHandler) GetActiveCommands(c *gin.Context) { + commands, err := h.commandQueries.GetActiveCommands() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve active commands"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "commands": commands, + "count": len(commands), + }) +} + +// GetRecentCommands retrieves recent commands +func (h *UnifiedUpdateHandler) GetRecentCommands(c *gin.Context) { + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + + commands, err := h.commandQueries.GetRecentCommands(limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve recent commands"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "commands": commands, + "count": len(commands), + "limit": limit, + }) +} + +// ClearFailedCommands removes failed commands +func (h *UnifiedUpdateHandler) ClearFailedCommands(c *gin.Context) { + olderThanDaysStr := c.Query("older_than_days") + onlyRetriedStr := c.Query("only_retried") + allFailedStr := c.Query("all_failed") + + var count int64 + var err error + + olderThanDays := 7 + if olderThanDaysStr != "" { + if days, err := strconv.Atoi(olderThanDaysStr); err == nil && days > 0 { + olderThanDays = days + } + } + + onlyRetried := onlyRetriedStr == "true" + allFailed := allFailedStr == "true" + + if allFailed { + count, err = h.commandQueries.ClearAllFailedCommandsRegardlessOfAge() + } else if onlyRetried { + count, err = h.commandQueries.ClearRetriedFailedCommands(olderThanDays) + } else { + 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 + } + + 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" + message += " (History preserved - commands moved to archived status)" + } + + c.JSON(http.StatusOK, gin.H{ + "message": message, + "count": count, + }) +} + +// GetBatchStatus returns recent batch processing status for an agent +func (h *UnifiedUpdateHandler) GetBatchStatus(c *gin.Context) { + agentIDStr := c.Param("agent_id") + agentID, err := uuid.Parse(agentIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"}) + return + } + + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + + batches, err := h.updateQueries.GetBatchStatus(agentID, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get batch status"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "batches": batches, + "count": len(batches), + }) +} + +// GetActiveOperations retrieves currently running operations +func (h *UnifiedUpdateHandler) GetActiveOperations(c *gin.Context) { + operations, err := h.updateQueries.GetActiveOperations() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve active operations"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "operations": operations, + "count": len(operations), + }) +} diff --git a/aggregator-server/internal/api/handlers/updates.go b/aggregator-server/internal/api/handlers/updates.go index 8fdd321..95d7214 100644 --- a/aggregator-server/internal/api/handlers/updates.go +++ b/aggregator-server/internal/api/handlers/updates.go @@ -23,6 +23,8 @@ func isValidResult(result string) bool { return validResults[result] } +// UpdateHandler handles package update operations +// DEPRECATED: This handler is being consolidated - will be replaced by unified update handling type UpdateHandler struct { updateQueries *queries.UpdateQueries agentQueries *queries.AgentQueries diff --git a/aggregator-web/src/components/AgentScanners.tsx b/aggregator-web/src/components/AgentScanners.tsx index 969d3ff..ac8b535 100644 --- a/aggregator-web/src/components/AgentScanners.tsx +++ b/aggregator-web/src/components/AgentScanners.tsx @@ -13,12 +13,14 @@ import { CheckCircle, AlertCircle, XCircle, + Upload, } from 'lucide-react'; import { formatRelativeTime } from '@/lib/utils'; import { agentApi, securityApi } from '@/lib/api'; import toast from 'react-hot-toast'; import { cn } from '@/lib/utils'; import { AgentSubsystem } from '@/types'; +import { AgentUpdate } from './AgentUpdate'; interface AgentScannersProps { agentId: string; @@ -53,6 +55,7 @@ const subsystemConfig: Record agentApi.getAgent(agentId), + refetchInterval: 30000, + }); + // Fetch security health status const { data: securityOverview, isLoading: securityLoading } = useQuery({ queryKey: ['security-overview'], @@ -79,12 +89,16 @@ export function AgentScanners({ agentId }: AgentScannersProps) { const getSecurityStatusDisplay = (status: string) => { switch (status) { case 'healthy': - case 'enforced': case 'operational': return { color: 'text-green-600 bg-green-100 border-green-200', icon: }; + case 'enforced': + return { + color: 'text-blue-600 bg-blue-100 border-blue-200', + icon: + }; case 'degraded': return { color: 'text-amber-600 bg-amber-100 border-amber-200', @@ -227,11 +241,25 @@ export function AgentScanners({ agentId }: AgentScannersProps) { const autoRunCount = subsystems.filter(s => s.auto_run && s.enabled).length; return ( -
+
{/* Subsystem Configuration Table */}
-

Subsystem Configuration

+
+

Subsystem Configuration

+
+ {enabledCount} enabled + {autoRunCount} auto-running + • {subsystems.length} total +
+
+
{isLoading ? ( @@ -376,37 +404,13 @@ export function AgentScanners({ agentId }: AgentScannersProps) { )}
- {/* Compact note */} -
- Subsystems report specific metrics to the server on scheduled intervals. Enable auto-run to schedule automatic scans, or trigger manual scans as needed. -
- - {/* Compact Summary */} -
-
-
-
- Enabled: - {enabledCount}/{subsystems.length} -
-
- Auto-Run: - {autoRunCount} -
-
- -
+ {/* Note */} +
+ Subsystems report specific metrics on scheduled intervals. Enable auto-run to schedule automatic scans, or use Actions to trigger manual scans.
{/* Security Health */} -
+
@@ -516,6 +520,25 @@ export function AgentScanners({ agentId }: AgentScannersProps) { } }; + const getDetailedSecurityInfo = (subsystemType: string, subsystem: any) => { + if (!securityOverview?.subsystems[subsystemType]) return ''; + + const subsystemData = securityOverview.subsystems[subsystemType]; + const checks = subsystemData.checks || {}; + const metrics = subsystemData.metrics || {}; + + switch (subsystemType) { + case 'nonce_validation': + return `Nonces: ${metrics.total_pending_commands || 0} pending. Max age: ${checks.max_age_minutes || 5}min. Failures: ${checks.validation_failures || 0}. Format: ${checks.nonce_format || 'UUID:Timestamp'}`; + case 'machine_binding': + return `Machine ID: ${checks.machine_id_type || 'Hardware fingerprint'}. Bound agents: ${checks.bound_agents || 'Unknown'}. Violations: ${checks.recent_violations || 0}. Min version: ${checks.min_agent_version || 'v0.1.22'}`; + case 'ed25519_signing': + return `Key: ${checks.public_key_fingerprint?.substring(0, 16) || 'Not available'}... Algorithm: ${checks.algorithm || 'Ed25519'}. Valid since: ${new Date(securityOverview.timestamp).toLocaleDateString()}`; + default: + return `Status: ${subsystem.status}. Last check: ${new Date(securityOverview.timestamp).toLocaleString()}`; + } + }; + return (
-
+

{getSecurityDisplayName(key)} @@ -536,15 +559,24 @@ export function AgentScanners({ agentId }: AgentScannersProps) {

{getEnhancedSubtitle(key, subsystem.status)}

+ {(key === 'nonce_validation' || key === 'machine_binding' || key === 'ed25519_signing') && ( +
+

+ {getDetailedSecurityInfo(key, subsystem)} +

+
+ )}
{subsystem.status === 'healthy' && } + {subsystem.status === 'enforced' && } {subsystem.status === 'degraded' && } {subsystem.status === 'unhealthy' && } {subsystem.status.toUpperCase()} @@ -603,6 +635,35 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
)}
+ + {/* Update Agent Modal */} + {showUpdateModal && agent && ( +
+
setShowUpdateModal(false)} /> +
+
+
+

Update Agent: {agent.hostname}

+ +
+
+ { + setShowUpdateModal(false); + queryClient.invalidateQueries({ queryKey: ['agent', agentId] }); + }} + /> +
+
+
+
+ )}
); } diff --git a/aggregator-web/src/lib/api.ts b/aggregator-web/src/lib/api.ts index cd7e001..c0ab319 100644 --- a/aggregator-web/src/lib/api.ts +++ b/aggregator-web/src/lib/api.ts @@ -190,6 +190,36 @@ export const agentApi = { return response.data; }, + // Get agent metrics + getAgentMetrics: async (agentId: string): Promise => { + const response = await api.get(`/agents/${agentId}/metrics`); + return response.data; + }, + + // Get agent storage metrics + getAgentStorageMetrics: async (agentId: string): Promise => { + const response = await api.get(`/agents/${agentId}/metrics/storage`); + return response.data; + }, + + // Get agent system metrics + getAgentSystemMetrics: async (agentId: string): Promise => { + const response = await api.get(`/agents/${agentId}/metrics/system`); + return response.data; + }, + + // Get agent Docker images + getAgentDockerImages: async (agentId: string, params?: any): Promise => { + const response = await api.get(`/agents/${agentId}/docker-images`, { params }); + return response.data; + }, + + // Get agent Docker info + getAgentDockerInfo: async (agentId: string): Promise => { + const response = await api.get(`/agents/${agentId}/docker-info`); + return response.data; + }, + // Update multiple agents (bulk) updateMultipleAgents: async (updateData: { agent_ids: string[];