fix: repair version detection platform query format

- Fix GetLatestVersionByTypeAndArch to separate platform/architecture
- Query now correctly uses platform='linux' and architecture='amd64'
- Resolves UI showing 'no packages available' despite updates existing
This commit is contained in:
Fimeg
2025-11-10 20:11:32 -05:00
parent e6ac0b1ec4
commit 1f2b1b7179
7 changed files with 412 additions and 34 deletions

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
@@ -14,37 +15,72 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// AgentUpdateHandler handles agent update operations
type AgentUpdateHandler struct {
agentQueries *queries.AgentQueries
agentUpdateQueries *queries.AgentUpdateQueries
commandQueries *queries.CommandQueries
signingService *services.SigningService
nonceService *services.UpdateNonceService
agentHandler *AgentHandler
}
// NewAgentUpdateHandler creates a new agent update handler
func NewAgentUpdateHandler(aq *queries.AgentQueries, auq *queries.AgentUpdateQueries, cq *queries.CommandQueries, ss *services.SigningService, ah *AgentHandler) *AgentUpdateHandler {
func NewAgentUpdateHandler(aq *queries.AgentQueries, auq *queries.AgentUpdateQueries, cq *queries.CommandQueries, ss *services.SigningService, ns *services.UpdateNonceService, ah *AgentHandler) *AgentUpdateHandler {
return &AgentUpdateHandler{
agentQueries: aq,
agentUpdateQueries: auq,
commandQueries: cq,
signingService: ss,
nonceService: ns,
agentHandler: ah,
}
}
// UpdateAgent handles POST /api/v1/agents/:id/update (manual agent update)
func (h *AgentUpdateHandler) UpdateAgent(c *gin.Context) {
// Extract agent ID from URL path
agentID := c.Param("id")
if agentID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "agent ID is required"})
return
}
// Debug logging for development (controlled via REDFLAG_DEBUG env var or query param)
debugMode := os.Getenv("REDFLAG_DEBUG") == "true" || c.Query("debug") == "true"
if debugMode {
log.Printf("[DEBUG] [UpdateAgent] Starting update request for agent %s from %s", agentID, c.ClientIP())
log.Printf("[DEBUG] [UpdateAgent] Content-Type: %s, Content-Length: %d", c.ContentType(), c.Request.ContentLength)
}
var req models.AgentUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
if debugMode {
log.Printf("[DEBUG] [UpdateAgent] JSON binding error for agent %s: %v", agentID, err)
}
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
"_error_context": "json_binding_failed", // Helps identify binding vs validation errors
})
return
}
// Always log critical update operations for audit trail
log.Printf("[UPDATE] Agent %s received update request - Version: %s, Platform: %s", agentID, req.Version, req.Platform)
// Debug: Log the parsed request
if debugMode {
log.Printf("[DEBUG] [UpdateAgent] Parsed update request - Version: %s, Platform: %s, Nonce: %s", req.Version, req.Platform, req.Nonce)
}
agentIDUUID, err := uuid.Parse(agentID)
if err != nil {
log.Printf("[UPDATE] Agent ID format error for %s: %v", agentID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID format"})
return
}
// Verify the agent exists
agent, err := h.agentQueries.GetAgentByID(req.AgentID)
agent, err := h.agentQueries.GetAgentByID(agentIDUUID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
@@ -77,12 +113,70 @@ func (h *AgentUpdateHandler) UpdateAgent(c *gin.Context) {
}
// Update agent status to "updating"
if err := h.agentQueries.UpdateAgentUpdatingStatus(req.AgentID, true, &req.Version); err != nil {
log.Printf("Failed to update agent %s status to updating: %v", req.AgentID, err)
if err := h.agentQueries.UpdateAgentUpdatingStatus(agentIDUUID, true, &req.Version); err != nil {
log.Printf("Failed to update agent %s status to updating: %v", agentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initiate update"})
return
}
// Validate the provided nonce
if h.nonceService != nil {
if debugMode {
log.Printf("[DEBUG] [UpdateAgent] Validating nonce for agent %s: %s", agentID, req.Nonce)
}
verifiedNonce, err := h.nonceService.Validate(req.Nonce)
if err != nil {
h.agentQueries.UpdateAgentUpdatingStatus(agentIDUUID, false, nil) // Rollback
log.Printf("[UPDATE] Nonce validation failed for agent %s: %v", agentID, err)
// Include specific error context for debugging
errorType := "signature_verification_failed"
if err.Error() == "nonce expired" {
errorType = "nonce_expired"
} else if err.Error() == "invalid base64" {
errorType = "invalid_nonce_format"
}
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid update nonce: " + err.Error(),
"_error_context": errorType,
"_error_detail": err.Error(),
})
return
}
if debugMode {
log.Printf("[DEBUG] [UpdateAgent] Nonce verified - AgentID: %s, TargetVersion: %s", verifiedNonce.AgentID, verifiedNonce.TargetVersion)
}
// Verify the nonce matches the requested agent and version
if verifiedNonce.AgentID != agentID {
if debugMode {
log.Printf("[DEBUG] [UpdateAgent] Agent ID mismatch - nonce: %s, URL: %s", verifiedNonce.AgentID, agentID)
}
log.Printf("[UPDATE] Agent ID mismatch in nonce: expected %s, got %s", agentID, verifiedNonce.AgentID)
h.agentQueries.UpdateAgentUpdatingStatus(agentIDUUID, false, nil) // Rollback
c.JSON(http.StatusBadRequest, gin.H{
"error": "nonce agent ID mismatch",
"_agent_id": agentID,
"_nonce_agent_id": verifiedNonce.AgentID,
})
return
}
if verifiedNonce.TargetVersion != req.Version {
if debugMode {
log.Printf("[DEBUG] [UpdateAgent] Version mismatch - nonce: %s, request: %s", verifiedNonce.TargetVersion, req.Version)
}
log.Printf("[UPDATE] Version mismatch in nonce: expected %s, got %s", req.Version, verifiedNonce.TargetVersion)
h.agentQueries.UpdateAgentUpdatingStatus(agentIDUUID, false, nil) // Rollback
c.JSON(http.StatusBadRequest, gin.H{
"error": "nonce version mismatch",
"_requested_version": req.Version,
"_nonce_version": verifiedNonce.TargetVersion,
})
return
}
log.Printf("[UPDATE] Nonce successfully validated for agent %s to version %s", agentID, req.Version)
}
// Generate nonce for replay protection
nonceUUID := uuid.New()
nonceTimestamp := time.Now()
@@ -399,3 +493,188 @@ func (h *AgentUpdateHandler) estimateUpdateTime(fileSize int64) int {
return seconds
}
// GenerateUpdateNonce handles POST /api/v1/agents/:id/update-nonce
func (h *AgentUpdateHandler) GenerateUpdateNonce(c *gin.Context) {
agentID := c.Param("id")
targetVersion := c.Query("target_version")
if targetVersion == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "target_version query parameter required"})
return
}
if h.nonceService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "nonce service not available"})
return
}
// Parse agent ID as UUID
agentIDUUID, err := uuid.Parse(agentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID format"})
return
}
// Verify agent exists
agent, err := h.agentQueries.GetAgentByID(agentIDUUID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
// Generate nonce
nonce, err := h.nonceService.Generate(agentID, targetVersion)
if err != nil {
log.Printf("[ERROR] Failed to generate update nonce for agent %s: %v", agentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate nonce"})
return
}
log.Printf("[system] Generated update nonce for agent %s (%s) -> %s", agentID, agent.Hostname, targetVersion)
c.JSON(http.StatusOK, gin.H{
"agent_id": agentID,
"hostname": agent.Hostname,
"current_version": agent.CurrentVersion,
"target_version": targetVersion,
"update_nonce": nonce,
"expires_at": time.Now().Add(10 * time.Minute).Unix(),
"expires_in_seconds": 600,
})
}
// CheckForUpdateAvailable handles GET /api/v1/agents/:id/updates/available
func (h *AgentUpdateHandler) CheckForUpdateAvailable(c *gin.Context) {
agentID := c.Param("id")
// Parse agent ID
agentIDUUID, err := uuid.Parse(agentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID format"})
return
}
// Query database for agent's current version
agent, err := h.agentQueries.GetAgentByID(agentIDUUID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
// Platform format: separate os_type and os_architecture from agent data
osType := strings.ToLower(agent.OSType)
osArch := agent.OSArchitecture
// Check if newer version available from agent_update_packages table
latestVersion, err := h.agentUpdateQueries.GetLatestVersionByTypeAndArch(osType, osArch)
if err != nil {
log.Printf("[DEBUG] GetLatestVersionByTypeAndArch error for %s/%s: %v", osType, osArch, err)
c.JSON(http.StatusOK, gin.H{
"hasUpdate": false,
"reason": "no packages available",
"currentVersion": agent.CurrentVersion,
})
return
}
// Check if this is actually newer than current version
hasUpdate := isVersionUpgrade(latestVersion, agent.CurrentVersion)
log.Printf("[DEBUG] Version comparison - latest: %s, current: %s, hasUpdate: %v for platform: %s/%s", latestVersion, agent.CurrentVersion, hasUpdate, osType, osArch)
// Special handling for sub-versions (0.1.23.5 vs 0.1.23)
if !hasUpdate && strings.HasPrefix(latestVersion, agent.CurrentVersion + ".") {
hasUpdate = true
log.Printf("[DEBUG] Detected sub-version upgrade: %s -> %s", agent.CurrentVersion, latestVersion)
}
c.JSON(http.StatusOK, gin.H{
"hasUpdate": hasUpdate,
"currentVersion": agent.CurrentVersion,
"latestVersion": latestVersion,
"platform": osType + "-" + osArch,
})
}
// GetUpdateStatus handles GET /api/v1/agents/:id/updates/status
func (h *AgentUpdateHandler) GetUpdateStatus(c *gin.Context) {
agentID := c.Param("id")
// Parse agent ID
agentIDUUID, err := uuid.Parse(agentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID format"})
return
}
// Fetch agent with update state
agent, err := h.agentQueries.GetAgentByID(agentIDUUID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
// Determine status from agent state + recent commands
var status string
var progress *int
var errorMsg *string
if agent.IsUpdating {
// Check if agent has pending update command
cmd, err := h.agentUpdateQueries.GetPendingUpdateCommand(agentID)
if err == nil && cmd != nil {
status = "downloading"
// Progress could be based on last acknowledgment time
if time.Since(cmd.CreatedAt) > 2*time.Minute {
status = "installing"
}
} else {
status = "pending"
}
} else {
status = "idle"
}
c.JSON(http.StatusOK, gin.H{
"status": status,
"progress": progress,
"error": errorMsg,
})
}
// isVersionUpgrade returns true if new version is greater than current version
func isVersionUpgrade(newVersion string, currentVersion string) bool {
// Parse semantic versions
newParts := strings.Split(newVersion, ".")
curParts := strings.Split(currentVersion, ".")
// Pad arrays to 3 parts
for len(newParts) < 3 {
newParts = append(newParts, "0")
}
for len(curParts) < 3 {
curParts = append(curParts, "0")
}
// Convert to integers for comparison
newMajor, _ := strconv.Atoi(newParts[0])
newMinor, _ := strconv.Atoi(newParts[1])
newPatch, _ := strconv.Atoi(newParts[2])
curMajor, _ := strconv.Atoi(curParts[0])
curMinor, _ := strconv.Atoi(curParts[1])
curPatch, _ := strconv.Atoi(curParts[2])
// Check if new > current (not equal, not less)
if newMajor > curMajor {
return true
}
if newMajor == curMajor && newMinor > curMinor {
return true
}
if newMajor == curMajor && newMinor == curMinor && newPatch > curPatch {
return true
}
return false
}

View File

@@ -31,24 +31,28 @@ func (h *DownloadHandler) getServerURL(c *gin.Context) string {
return h.config.Server.PublicURL
}
// Priority 2: Construct API server URL from configuration
// Priority 2: Detect from request with TLS/proxy awareness
scheme := "http"
host := h.config.Server.Host
port := h.config.Server.Port
// Use HTTPS if TLS is enabled in config
// Check if TLS is enabled in config
if h.config.Server.TLS.Enabled {
scheme = "https"
}
// For default host (0.0.0.0), use localhost for client connections
if host == "0.0.0.0" {
host = "localhost"
// Check if request came through HTTPS (direct or via proxy)
if c.Request.TLS != nil {
scheme = "https"
}
// Only include port if it's not the default for the protocol
if (scheme == "http" && port != 80) || (scheme == "https" && port != 443) {
return fmt.Sprintf("%s://%s:%d", scheme, host, port)
// Check X-Forwarded-Proto for reverse proxy setups
if forwardedProto := c.GetHeader("X-Forwarded-Proto"); forwardedProto == "https" {
scheme = "https"
}
// Use the Host header exactly as received (includes port if present)
host := c.GetHeader("X-Forwarded-Host")
if host == "" {
host = c.Request.Host
}
return fmt.Sprintf("%s://%s", scheme, host)
@@ -151,7 +155,6 @@ AGENT_BINARY="/usr/local/bin/redflag-agent"
SUDOERS_FILE="/etc/sudoers.d/redflag-agent"
SERVICE_FILE="/etc/systemd/system/redflag-agent.service"
CONFIG_DIR="/etc/aggregator"
STATE_DIR="/var/lib/aggregator"
echo "=== RedFlag Agent Installation ==="
echo ""
@@ -298,24 +301,19 @@ else
exit 1
fi
# Step 4: Create configuration and state directories
# Step 4: Create configuration directory
echo ""
echo "Step 4: Creating configuration and state directories..."
echo "Step 4: Creating configuration directory..."
mkdir -p "$CONFIG_DIR"
chown "$AGENT_USER:$AGENT_USER" "$CONFIG_DIR"
chmod 755 "$CONFIG_DIR"
echo "✓ Configuration directory created"
# Create state directory for acknowledgment tracking (v0.1.19+)
mkdir -p "$STATE_DIR"
chown "$AGENT_USER:$AGENT_USER" "$STATE_DIR"
chmod 755 "$STATE_DIR"
echo "✓ Configuration and state directories created"
# Set SELinux context for directories if SELinux is enabled
# Set SELinux context for config directory if SELinux is enabled
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "Setting SELinux context for directories..."
restorecon -Rv "$CONFIG_DIR" "$STATE_DIR" 2>/dev/null || true
echo "✓ SELinux context set for directories"
echo "Setting SELinux context for config directory..."
restorecon -Rv "$CONFIG_DIR" 2>/dev/null || true
echo "✓ SELinux context set for config directory"
fi
# Step 5: Install systemd service
@@ -340,7 +338,7 @@ RestartSec=30
# NoNewPrivileges=true - DISABLED: Prevents sudo from working, which agent needs for package management
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=$AGENT_HOME /var/log $CONFIG_DIR $STATE_DIR
ReadWritePaths=$AGENT_HOME /var/log $CONFIG_DIR
PrivateTmp=true
# Logging

View File

@@ -43,6 +43,7 @@ type Config struct {
LatestAgentVersion string
MinAgentVersion string `env:"MIN_AGENT_VERSION" default:"0.1.22"`
SigningPrivateKey string `env:"REDFLAG_SIGNING_PRIVATE_KEY"`
DebugEnabled bool `env:"REDFLAG_DEBUG" default:"false"` // Enable debug logging
}
// Load reads configuration from environment variables only (immutable configuration)
@@ -86,7 +87,7 @@ func Load() (*Config, error) {
cfg.CheckInInterval = checkInInterval
cfg.OfflineThreshold = offlineThreshold
cfg.Timezone = getEnv("TIMEZONE", "UTC")
cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.23")
cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.23.5")
cfg.MinAgentVersion = getEnv("MIN_AGENT_VERSION", "0.1.22")
cfg.SigningPrivateKey = getEnv("REDFLAG_SIGNING_PRIVATE_KEY", "")

View File

@@ -217,3 +217,65 @@ func (q *AgentUpdateQueries) GetAgentByMachineID(machineID string) (*models.Agen
return &agent, nil
}
// GetLatestVersion retrieves the latest available version for a platform
func (q *AgentUpdateQueries) GetLatestVersion(platform string) (string, error) {
query := `
SELECT version FROM agent_update_packages
WHERE platform = $1 AND is_active = true
ORDER BY version DESC LIMIT 1
`
var latestVersion string
err := q.db.Get(&latestVersion, query, platform)
if err != nil {
if err == sql.ErrNoRows {
return "", fmt.Errorf("no update packages available for platform %s", platform)
}
return "", fmt.Errorf("failed to get latest version: %w", err)
}
return latestVersion, nil
}
// GetLatestVersionByTypeAndArch retrieves the latest available version for a specific os_type and architecture
func (q *AgentUpdateQueries) GetLatestVersionByTypeAndArch(osType, osArch string) (string, error) {
query := `
SELECT version FROM agent_update_packages
WHERE platform = $1 AND architecture = $2 AND is_active = true
ORDER BY version DESC LIMIT 1
`
var latestVersion string
err := q.db.Get(&latestVersion, query, osType, osArch)
if err != nil {
if err == sql.ErrNoRows {
return "", fmt.Errorf("no update packages available for platform %s/%s", osType, osArch)
}
return "", fmt.Errorf("failed to get latest version: %w", err)
}
return latestVersion, nil
}
// GetPendingUpdateCommand retrieves the most recent pending update command for an agent
func (q *AgentUpdateQueries) GetPendingUpdateCommand(agentID string) (*models.AgentCommand, error) {
query := `
SELECT id, agent_id, command_type, params, status, source, created_at, sent_at, completed_at, result, retried_from_id
FROM agent_commands
WHERE agent_id = $1 AND command_type = 'install_update' AND status = 'pending'
ORDER BY created_at DESC
LIMIT 1
`
var command models.AgentCommand
err := q.db.Get(&command, query, agentID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil // No pending update command found
}
return nil, fmt.Errorf("failed to get pending update command: %w", err)
}
return &command, nil
}

View File

@@ -23,10 +23,11 @@ type AgentUpdatePackage struct {
// AgentUpdateRequest represents a request to update an agent
type AgentUpdateRequest struct {
AgentID uuid.UUID `json:"agent_id" binding:"required"`
AgentID uuid.UUID `json:"agent_id,omitempty"` // Optional when agent ID is in URL path
Version string `json:"version" binding:"required"`
Platform string `json:"platform" binding:"required"`
Scheduled *string `json:"scheduled_at,omitempty"`
Nonce string `json:"nonce" binding:"required"` // Required security nonce to prevent replay attacks
}
// BulkAgentUpdateRequest represents a bulk update request

View File

@@ -73,6 +73,23 @@ export function AgentUpdatesModal({
const pkg = packages.find(p => p.id === packageId);
if (!pkg) throw new Error('Package not found');
// For single agent updates, use individual update with nonce for security
if (selectedAgentIds.length === 1) {
const agentId = selectedAgentIds[0];
// Generate nonce for security
const nonceData = await agentApi.generateUpdateNonce(agentId, pkg.version);
console.log('[UI] Update nonce generated for single agent:', nonceData);
// Use individual update endpoint with nonce
return agentApi.updateAgent(agentId, {
version: pkg.version,
platform: pkg.platform,
nonce: nonceData.update_nonce
});
}
// For multiple agents, use bulk update
const updateData = {
agent_ids: selectedAgentIds,
version: pkg.version,
@@ -82,7 +99,9 @@ export function AgentUpdatesModal({
return agentApi.updateMultipleAgents(updateData);
},
onSuccess: (data) => {
toast.success(`Update initiated for ${data.updated?.length || 0} agent(s)`);
const count = selectedAgentIds.length;
const message = count === 1 ? 'Update initiated for agent' : `Update initiated for ${count} agents`;
toast.success(message);
setIsUpdating(false);
onAgentsUpdated();
onClose();

View File

@@ -172,6 +172,24 @@ export const agentApi = {
return response.data;
},
// Check if update available for agent
checkForUpdateAvailable: async (agentId: string): Promise<{ hasUpdate: boolean; currentVersion: string; latestVersion?: string; reason?: string; platform?: string }> => {
const response = await api.get(`/agents/${agentId}/updates/available`);
return response.data;
},
// Get update status for agent
getUpdateStatus: async (agentId: string): Promise<{ status: string; progress?: number; error?: string }> => {
const response = await api.get(`/agents/${agentId}/updates/status`);
return response.data;
},
// Generate update nonce for agent (new security feature)
generateUpdateNonce: async (agentId: string, targetVersion: string): Promise<{ agent_id: string; target_version: string; update_nonce: string; expires_at: number; expires_in_seconds: number }> => {
const response = await api.post(`/agents/${agentId}/update-nonce?target_version=${targetVersion}`);
return response.data;
},
// Update multiple agents (bulk)
updateMultipleAgents: async (updateData: {
agent_ids: string[];