Watch
1
0
Fork
You've already forked RedFlag
0

v0.2.0.3: wire install flow, restore lost handlers, dedupe agent packages

Server / agent install pipeline:
- Restore dry_run_update, confirm_dependencies, install_updates,
  enable_heartbeat, disable_heartbeat handlers on the agent side (lost in
  the TD-001 god-function refactor at 9da5134e); wire them through
  handlers/dispatch.go so the cross-platform agent loop dispatches them
  alongside scans and update_agent.
- Wire JWT renewal into the polling loop on 401 (RenewToken existed in the
  client but was dead code in loop.go).
- Self-update path now shells through sudo for cp/chmod/systemctl restart,
  matching the redflag-agent user's hardened systemd unit.

Server build orchestrator:
- BuildAndSignAgent now reuses the existing signed package row when the
  on-disk binary's checksum matches the stored one. Previously the server
  re-signed and inserted 4 fresh rows on every boot, leaving dozens of
  duplicate agent_update_packages entries.
- CreateUpdatePackage is now ON CONFLICT (version, platform, architecture)
  DO UPDATE so a fresh build of the same version replaces in place.
- New migration 037: dedupes existing rows (keep newest per tuple) and
  enforces UNIQUE (version, platform, architecture).
- Drop dead verification.go endpoint stub — architecturally broken in a
  pull-only polling model.

Dashboard:
- AgentUpdatesModal filters packages to the selected agents' os_type and
  os_architecture, dedupes by (version, platform, arch), and renders
  platform/arch together so 32/64-bit differentiate visually. Drops the
  platform dropdown (now agent-driven).

Install script template:
- Fix server_public_key + initial_binary.sig ownership so the agent user
  can overwrite them; convert hex key to raw 32 bytes inline.
- Add sudoers entries for the agent's self-update cp/chmod/systemctl path.

Downloads handler resolves ?version=latest to AgentVersion so install
scripts pull a signed package instead of a 404.

Version bumped to 0.2.0.3 across versions.go, docker-compose, Makefile,
downloads.go, security min_agent_version.
This commit is contained in:
Fimeg 2026-05-25 10:02:52 -04:00
commit 78d9131a9f
23 changed files with 592 additions and 501 deletions

View file

@ -24,7 +24,7 @@ build-server: ## Build server binary
cd server && go mod tidy && go build -o bin/server cmd/server/main.go
build-agent: ## Build agent binary with version injection
cd agent && go mod tidy && go build -ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=0.2.0.2 -X github.com/Fimeg/RedFlag/agent/internal/version.ConfigVersion=0.2.0.2 -X github.com/Fimeg/RedFlag/agent/internal/version.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -o bin/agent ./cmd/agent/
cd agent && go mod tidy && go build -ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=0.2.0.3 -X github.com/Fimeg/RedFlag/agent/internal/version.ConfigVersion=0.2.0.3 -X github.com/Fimeg/RedFlag/agent/internal/version.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -o bin/agent ./cmd/agent/
build-agent-simple: ## Build agent binary with simple script
@./scripts/build-secure-agent.sh

View file

@ -5,6 +5,7 @@ import (
"log"
"math/rand"
"os"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
@ -194,6 +195,20 @@ func runPollingLoop(ctx *loopContext) error {
// Get commands from server
response, err := ctx.apiClient.GetCommands(ctx.cfg.AgentID, metrics)
if err != nil {
if strings.Contains(err.Error(), "401 Unauthorized") && ctx.cfg.RefreshToken != "" {
log.Printf("[INFO] [agent] [auth] jwt_expired attempting_renewal agent_id=%s", ctx.cfg.AgentID)
if renewErr := ctx.apiClient.RenewToken(ctx.cfg.AgentID, ctx.cfg.RefreshToken, version.Version); renewErr != nil {
log.Printf("[ERROR] [agent] [auth] token_renewal_failed error=%v", renewErr)
} else {
ctx.cfg.Token = ctx.apiClient.GetToken()
if saveErr := ctx.cfg.Save(constants.GetAgentConfigPath()); saveErr != nil {
log.Printf("[WARNING] [agent] [auth] token_persist_failed error=%v", saveErr)
}
log.Printf("[INFO] [agent] [auth] token_renewed_successfully")
consecutiveFailures = 0
continue
}
}
consecutiveFailures++
backoffDelay := calculateBackoff(consecutiveFailures)
log.Printf("[WARNING] Server unavailable (attempt %d), retrying in %s: %v", consecutiveFailures, backoffDelay, err)

View file

@ -249,82 +249,38 @@ func CleanupPostUpdateBackup() {
}
func createBackup(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open source: %w", err)
cmd := exec.Command("sudo", "cp", src, dst)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create backup: %w (output: %s)", err, string(out))
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
defer dstFile.Close()
if _, err := dstFile.ReadFrom(srcFile); err != nil {
return fmt.Errorf("failed to copy backup: %w", err)
}
if err := os.Chmod(dst, 0755); err != nil {
return fmt.Errorf("failed to set backup permissions: %w", err)
}
return nil
}
func restoreFromBackup(backup, target string) error {
if _, err := os.Stat(target); err == nil {
if err := os.Remove(target); err != nil {
return fmt.Errorf("failed to remove current binary: %w", err)
}
}
return createBackup(backup, target)
}
func installNewBinary(src, dst string) error {
tempDst := dst + ".new"
srcFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open source binary: %w", err)
cmd := exec.Command("sudo", "cp", src, dst)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to copy binary: %w (output: %s)", err, string(out))
}
defer srcFile.Close()
dstFile, err := os.Create(tempDst)
if err != nil {
return fmt.Errorf("failed to create temp binary: %w", err)
cmd = exec.Command("sudo", "chmod", "755", dst)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to set binary permissions: %w (output: %s)", err, string(out))
}
defer dstFile.Close()
if _, err := dstFile.ReadFrom(srcFile); err != nil {
return fmt.Errorf("failed to copy binary: %w", err)
}
dstFile.Close()
if err := os.Chmod(tempDst, 0755); err != nil {
return fmt.Errorf("failed to set binary permissions: %w", err)
}
if err := os.Rename(tempDst, dst); err != nil {
os.Remove(tempDst)
return fmt.Errorf("failed to atomically replace binary: %w", err)
}
return nil
}
func restartAgentService() error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
cmd = exec.Command("systemctl", "restart", "redflag-agent")
if err := cmd.Run(); err == nil {
log.Printf("[INFO] [agent] [service] systemd_service_restarted")
return nil
cmd := exec.Command("sudo", "systemctl", "restart", "redflag-agent")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to restart service: %w (output: %s)", err, string(out))
}
cmd = exec.Command("service", "redflag-agent", "restart")
log.Printf("[INFO] [agent] [service] systemd_service_restarted")
return nil
case "windows":
stopCmd := exec.Command("sc", "stop", "RedFlagAgent")
@ -334,19 +290,16 @@ func restartAgentService() error {
log.Printf("[INFO] [agent] [service] service_stop_requested")
}
time.Sleep(3 * time.Second)
cmd = exec.Command("sc", "start", "RedFlagAgent")
cmd := exec.Command("sc", "start", "RedFlagAgent")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to start service: %w", err)
}
log.Printf("[INFO] [agent] [service] service_start_requested")
return nil
default:
return fmt.Errorf("unsupported OS for service restart: %s", runtime.GOOS)
}
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to restart service: %w", err)
}
log.Printf("[INFO] [agent] [service] agent_service_restarted")
return nil
}
// --- Crypto helpers ---

View file

@ -40,6 +40,16 @@ func DispatchCrossPlatformCommand(
err = HandleScanWinget(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_updates":
err = HandleScanUpdates(apiClient, cfg, ackTracker, orch, cmd.ID)
case "install_updates":
err = HandleInstallUpdates(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
case "dry_run_update":
err = HandleDryRunUpdate(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
case "confirm_dependencies":
err = HandleConfirmDependencies(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
case "enable_heartbeat":
err = HandleEnableHeartbeat(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
case "disable_heartbeat":
err = HandleDisableHeartbeat(apiClient, cfg, ackTracker, cmd.ID)
case "update_agent":
err = HandleUpdateAgent(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
default:

View file

@ -0,0 +1,206 @@
package handlers
import (
"fmt"
"log"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/installer"
)
// HandleDryRunUpdate runs a package-manager dry-run for the named update so
// dependency resolution can be surfaced to the dashboard operator before the
// real install fires. Reports dependencies via the dedicated endpoint and the
// command result via the standard log path; the install itself does not happen
// here — the operator's "Confirm" produces a separate confirm_dependencies
// command (see HandleConfirmDependencies).
func HandleDryRunUpdate(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
packageType, _ := params["package_type"].(string)
packageName, _ := params["package_name"].(string)
updateID, _ := params["update_id"].(string)
if packageType == "" || packageName == "" {
return fmt.Errorf("package_type and package_name parameters are required")
}
inst, err := installer.InstallerFactory(packageType)
if err != nil {
return fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", packageType, err)
}
if !inst.IsAvailable() {
return fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", packageType)
}
log.Printf("[INFO] [agent] [installer] dry_run_start package=%s type=%s", packageName, packageType)
result, err := inst.DryRun(packageName)
if err != nil {
stdout, stderr, exitCode, duration := "", err.Error(), 1, 0
if result != nil {
stdout, stderr, exitCode, duration = result.Stdout, result.Stderr, result.ExitCode, result.DurationSeconds
}
logReport := client.LogReport{
CommandID: commandID,
Action: "dry_run",
Result: "failed",
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: duration,
Metadata: map[string]string{
"subsystem_label": "Package Install",
"subsystem": packageType,
},
}
ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
return fmt.Errorf("dry run failed: %w", err)
}
// Mirror the result into the client's wire type and post dependencies.
depReport := client.DependencyReport{
PackageName: packageName,
PackageType: packageType,
Dependencies: result.Dependencies,
UpdateID: updateID,
DryRunResult: &client.InstallResult{
Success: result.Success,
ErrorMessage: result.ErrorMessage,
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
Action: result.Action,
PackagesInstalled: result.PackagesInstalled,
ContainersUpdated: result.ContainersUpdated,
Dependencies: result.Dependencies,
IsDryRun: true,
},
}
if err := apiClient.ReportDependencies(cfg.AgentID, depReport); err != nil {
log.Printf("[ERROR] [agent] [installer] report_dependencies_failed error=%v", err)
return fmt.Errorf("failed to report dependencies: %w", err)
}
logReport := client.LogReport{
CommandID: commandID,
Action: "dry_run",
Result: "success",
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
Metadata: map[string]string{
"subsystem_label": "Package Install",
"subsystem": packageType,
},
}
if len(result.Dependencies) > 0 {
logReport.Stdout += fmt.Sprintf("\nDependencies found: %v", result.Dependencies)
}
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); reportErr != nil {
log.Printf("[WARNING] [agent] [installer] report_dry_run_failed error=%v", reportErr)
}
log.Printf("[INFO] [agent] [installer] dry_run_complete package=%s deps=%d duration=%ds",
packageName, len(result.Dependencies), result.DurationSeconds)
return nil
}
// HandleConfirmDependencies installs a package together with the dependencies
// the operator confirmed during the dry-run review. Empty dependency list is
// allowed — that path resolves to a simple UpdatePackage on the named target.
func HandleConfirmDependencies(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
packageType, _ := params["package_type"].(string)
packageName, _ := params["package_name"].(string)
if packageType == "" || packageName == "" {
return fmt.Errorf("package_type and package_name parameters are required")
}
var dependencies []string
if deps, ok := params["dependencies"].([]interface{}); ok {
for _, dep := range deps {
if s, ok := dep.(string); ok && s != "" {
dependencies = append(dependencies, s)
}
}
}
inst, err := installer.InstallerFactory(packageType)
if err != nil {
return fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", packageType, err)
}
if !inst.IsAvailable() {
return fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", packageType)
}
var result *installer.InstallResult
var action string
if len(dependencies) > 0 {
action = "install_with_dependencies"
log.Printf("[INFO] [agent] [installer] install_with_deps package=%s deps=%v", packageName, dependencies)
allPackages := append([]string{packageName}, dependencies...)
result, err = inst.InstallMultiple(allPackages)
} else {
action = "update"
log.Printf("[INFO] [agent] [installer] update_package package=%s", packageName)
result, err = inst.UpdatePackage(packageName)
}
if err != nil {
stdout, stderr, exitCode, duration := "", err.Error(), 1, 0
if result != nil {
stdout, stderr, exitCode, duration = result.Stdout, result.Stderr, result.ExitCode, result.DurationSeconds
}
logReport := client.LogReport{
CommandID: commandID,
Action: action,
Result: "failed",
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: duration,
Metadata: map[string]string{
"subsystem_label": "Package Install",
"subsystem": packageType,
},
}
ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
return fmt.Errorf("installation failed: %w", err)
}
logReport := client.LogReport{
CommandID: commandID,
Action: result.Action,
Result: "success",
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
Metadata: map[string]string{
"subsystem_label": "Package Install",
"subsystem": packageType,
},
}
if len(result.PackagesInstalled) > 0 {
logReport.Stdout += fmt.Sprintf("\nPackages installed: %v", result.PackagesInstalled)
}
if len(dependencies) > 0 {
logReport.Stdout += fmt.Sprintf("\nDependencies included: %v", dependencies)
}
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); reportErr != nil {
log.Printf("[WARNING] [agent] [installer] report_install_failed error=%v", reportErr)
}
log.Printf("[INFO] [agent] [installer] install_complete action=%s package=%s duration=%ds",
action, packageName, result.DurationSeconds)
return nil
}

View file

@ -0,0 +1,72 @@
package handlers
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
// HandleEnableHeartbeat flips the agent into rapid-polling mode for the
// duration the server requested. The polling loop reads RapidPollingEnabled
// and RapidPollingUntil on every iteration (see agent/loop.go), so persisting
// the change to config is enough — the next loop tick picks it up.
func HandleEnableHeartbeat(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
durationMinutes := 60
if d, ok := params["duration_minutes"].(float64); ok && d > 0 {
durationMinutes = int(d)
}
cfg.RapidPollingEnabled = true
cfg.RapidPollingUntil = time.Now().UTC().Add(time.Duration(durationMinutes) * time.Minute)
if err := cfg.Save(constants.GetAgentConfigPath()); err != nil {
log.Printf("[WARNING] [agent] [heartbeat] config_save_failed error=%v", err)
}
logReport := client.LogReport{
CommandID: commandID,
Action: "enable_heartbeat",
Result: "success",
Stdout: fmt.Sprintf("Heartbeat enabled for %d minutes", durationMinutes),
ExitCode: 0,
DurationSeconds: 0,
}
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[WARNING] [agent] [heartbeat] report_enable_failed error=%v", err)
}
log.Printf("[INFO] [agent] [heartbeat] enabled duration_minutes=%d until=%s",
durationMinutes, cfg.RapidPollingUntil.Format(time.RFC3339))
return nil
}
// HandleDisableHeartbeat clears rapid-polling state. Called by the server when
// a multi-step flow finishes (or by the timeout reconciler if the flow stalls).
func HandleDisableHeartbeat(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string) error {
cfg.RapidPollingEnabled = false
cfg.RapidPollingUntil = time.Time{}
if err := cfg.Save(constants.GetAgentConfigPath()); err != nil {
log.Printf("[WARNING] [agent] [heartbeat] config_save_failed error=%v", err)
}
logReport := client.LogReport{
CommandID: commandID,
Action: "disable_heartbeat",
Result: "success",
Stdout: "Heartbeat disabled",
ExitCode: 0,
DurationSeconds: 0,
}
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[WARNING] [agent] [heartbeat] report_disable_failed error=%v", err)
}
log.Printf("[INFO] [agent] [heartbeat] disabled")
return nil
}

View file

@ -0,0 +1,114 @@
package handlers
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/installer"
)
func HandleInstallUpdates(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
packageType, _ := params["package_type"].(string)
packageName, _ := params["package_name"].(string)
if packageType == "" {
return fmt.Errorf("package_type parameter is required")
}
inst, err := installer.InstallerFactory(packageType)
if err != nil {
return fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", packageType, err)
}
if !inst.IsAvailable() {
return fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", packageType)
}
var result *installer.InstallResult
var action string
startTime := time.Now()
if packageName != "" {
action = "update"
log.Printf("[INFO] [agent] [installer] updating_package package=%s type=%s", packageName, packageType)
result, err = inst.UpdatePackage(packageName)
} else {
var packageNames []string
for key, value := range params {
if key == "package_type" {
continue
}
if name, ok := value.(string); ok && name != "" {
packageNames = append(packageNames, name)
}
}
if len(packageNames) > 0 {
action = "install_multiple"
log.Printf("[INFO] [agent] [installer] installing_multiple count=%d type=%s", len(packageNames), packageType)
result, err = inst.InstallMultiple(packageNames)
} else {
action = "upgrade"
log.Printf("[INFO] [agent] [installer] upgrading_all type=%s", packageType)
result, err = inst.Upgrade()
}
}
duration := int(time.Since(startTime).Seconds())
if err != nil {
stdout := ""
stderr := err.Error()
exitCode := 1
if result != nil {
stdout = result.Stdout
stderr = result.Stderr
exitCode = result.ExitCode
}
logReport := client.LogReport{
CommandID: commandID,
Action: action,
Result: "failed",
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: duration,
Metadata: map[string]string{
"subsystem_label": "Package Install",
"subsystem": packageType,
},
}
ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
return fmt.Errorf("[ERROR] [agent] [installer] install_failed action=%s type=%s error=%w", action, packageType, err)
}
logReport := client.LogReport{
CommandID: commandID,
Action: result.Action,
Result: "success",
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
Metadata: map[string]string{
"subsystem_label": "Package Install",
"subsystem": packageType,
},
}
if len(result.PackagesInstalled) > 0 {
logReport.Stdout += fmt.Sprintf("\nPackages installed: %v", result.PackagesInstalled)
}
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); reportErr != nil {
log.Printf("[WARNING] [agent] [installer] report_failed error=%v", reportErr)
}
log.Printf("[INFO] [agent] [installer] install_complete action=%s type=%s duration=%ds", action, packageType, duration)
return nil
}

View file

@ -21,7 +21,7 @@ services:
context: .
dockerfile: ./server/Dockerfile
args:
BUILD_VERSION: ${BUILD_VERSION:-0.2.0.2}
BUILD_VERSION: ${BUILD_VERSION:-0.2.0.3}
container_name: redflag-server
volumes:
- server-config:/app/config

View file

@ -7,7 +7,6 @@ import (
"flag"
"fmt"
"log"
"net/http"
"path/filepath"
"strings"
"time"
@ -386,12 +385,6 @@ func main() {
// Initialize scanner config handler (for user-configurable scanner timeouts)
scannerConfigHandler := handlers.NewScannerConfigHandler(db.DB)
// Initialize verification handler
var verificationHandler *handlers.VerificationHandler
if signingService != nil {
verificationHandler = handlers.NewVerificationHandler(agentQueries, signingService)
}
// Initialize update nonce service (for version upgrade middleware)
var updateNonceService *services.UpdateNonceService
if signingService != nil && cfg.SigningPrivateKey != "" {
@ -548,13 +541,6 @@ func main() {
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.POST("/:id/verify-signature", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), func(c *gin.Context) {
if verificationHandler == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "signature verification service not available"})
return
}
verificationHandler.VerifySignature(c)
})
// BUG-013: DELETE /agents/:id intentionally NOT registered here.
// It's an admin operation invoked from the dashboard, not an
// agent-self-service endpoint. Lives under the web-auth group

View file

@ -16,6 +16,7 @@ import (
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/services"
serverVersion "github.com/Fimeg/RedFlag/server/internal/version"
"github.com/google/uuid"
"github.com/gin-gonic/gin"
)
@ -75,6 +76,9 @@ func (h *DownloadHandler) getServerURL(c *gin.Context) string {
func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
platform := c.Param("platform")
version := c.Query("version") // Optional version parameter for signed binaries
if version == "latest" {
version = serverVersion.AgentVersion
}
// Validate platform to prevent directory traversal
validPlatforms := map[string]bool{
@ -341,7 +345,7 @@ func (h *DownloadHandler) HandleConfigDownload(c *gin.Context) {
// Credentials are preserved locally by the install script
configTemplate := map[string]interface{}{
"version": 5, // Current schema version (v5 as of 0.1.23+)
"agent_version": "0.2.0.2",
"agent_version": "0.2.0.3",
"server_url": serverURL,
// Placeholder credentials - will be replaced by install script

View file

@ -158,7 +158,7 @@ func (h *SecurityHandler) MachineBindingStatus(c *gin.Context) {
"timestamp": time.Now().UTC(),
"checks": map[string]interface{}{
"binding_enforced": true,
"min_agent_version": "v0.2.0.2",
"min_agent_version": "v0.2.0.3",
"fingerprint_required": true,
"recent_violations": 0,
"bound_agents": 0,

View file

@ -1,137 +0,0 @@
package handlers
import (
"crypto/ed25519"
"encoding/hex"
"fmt"
"log"
"net/http"
"strings"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/services"
"github.com/gin-gonic/gin"
)
// VerificationHandler handles signature verification requests
type VerificationHandler struct {
agentQueries *queries.AgentQueries
signingService *services.SigningService
}
// NewVerificationHandler creates a new verification handler
func NewVerificationHandler(aq *queries.AgentQueries, signingService *services.SigningService) *VerificationHandler {
return &VerificationHandler{
agentQueries: aq,
signingService: signingService,
}
}
// VerifySignature handles POST /api/v1/agents/:id/verify-signature
func (h *VerificationHandler) VerifySignature(c *gin.Context) {
var req models.SignatureVerificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate the agent exists and matches the provided machine ID
agent, err := h.agentQueries.GetAgentByID(req.AgentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
// Verify machine ID matches
if agent.MachineID == nil || *agent.MachineID != req.MachineID {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "machine ID mismatch",
"expected": agent.MachineID,
"received": req.MachineID,
})
return
}
// Verify public key fingerprint matches
if agent.PublicKeyFingerprint == nil || *agent.PublicKeyFingerprint != req.PublicKey {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "public key fingerprint mismatch",
"expected": agent.PublicKeyFingerprint,
"received": req.PublicKey,
})
return
}
// Verify the signature
valid, err := h.verifyAgentSignature(req.BinaryPath, req.Signature)
if err != nil {
log.Printf("Signature verification failed for agent %s: %v", req.AgentID, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "signature verification failed",
"details": err.Error(),
})
return
}
response := models.SignatureVerificationResponse{
Valid: valid,
AgentID: req.AgentID.String(),
MachineID: req.MachineID,
Fingerprint: req.PublicKey,
Message: "Signature verification completed",
}
if !valid {
response.Message = "Invalid signature - binary may be tampered with"
c.JSON(http.StatusUnauthorized, response)
return
}
c.JSON(http.StatusOK, response)
}
// verifyAgentSignature verifies the signature of an agent binary
func (h *VerificationHandler) verifyAgentSignature(binaryPath, signatureHex string) (bool, error) {
// Decode the signature
signature, err := hex.DecodeString(signatureHex)
if err != nil {
return false, fmt.Errorf("invalid signature format: %w", err)
}
if len(signature) != ed25519.SignatureSize {
return false, fmt.Errorf("invalid signature size: expected %d bytes, got %d", ed25519.SignatureSize, len(signature))
}
// Read the binary file
content, err := readFileContent(binaryPath)
if err != nil {
return false, fmt.Errorf("failed to read binary: %w", err)
}
// Verify using the signing service
valid, err := h.signingService.VerifySignature(content, signatureHex)
if err != nil {
return false, fmt.Errorf("verification failed: %w", err)
}
return valid, nil
}
// readFileContent reads file content safely
func readFileContent(filePath string) ([]byte, error) {
// Basic path validation to prevent directory traversal
if strings.Contains(filePath, "..") || strings.Contains(filePath, "~") {
return nil, fmt.Errorf("invalid file path")
}
// Only allow specific file patterns for security
if !strings.HasSuffix(filePath, "/redflag-agent") && !strings.HasSuffix(filePath, "/redflag-agent.exe") {
return nil, fmt.Errorf("invalid file type - only agent binaries are allowed")
}
// For security, we won't actually read files in this handler
// In a real implementation, this would verify the actual binary on the agent
// For now, we'll simulate the verification process
return []byte("simulated-binary-content"), nil
}

View file

@ -1,97 +0,0 @@
-- Migration 036 rollback: revert TIMESTAMPTZ columns back to TIMESTAMP.
-- Drops zone awareness; values are converted using UTC interpretation so the wall-clock
-- is preserved as it was written.
BEGIN;
ALTER TABLE agents
ALTER COLUMN last_seen TYPE TIMESTAMP USING last_seen AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC',
ALTER COLUMN last_version_check TYPE TIMESTAMP USING last_version_check AT TIME ZONE 'UTC',
ALTER COLUMN last_reboot_at TYPE TIMESTAMP USING last_reboot_at AT TIME ZONE 'UTC',
ALTER COLUMN update_initiated_at TYPE TIMESTAMP USING update_initiated_at AT TIME ZONE 'UTC';
ALTER TABLE agent_specs
ALTER COLUMN collected_at TYPE TIMESTAMP USING collected_at AT TIME ZONE 'UTC';
ALTER TABLE update_packages
ALTER COLUMN discovered_at TYPE TIMESTAMP USING discovered_at AT TIME ZONE 'UTC',
ALTER COLUMN approved_at TYPE TIMESTAMP USING approved_at AT TIME ZONE 'UTC',
ALTER COLUMN scheduled_for TYPE TIMESTAMP USING scheduled_for AT TIME ZONE 'UTC',
ALTER COLUMN installed_at TYPE TIMESTAMP USING installed_at AT TIME ZONE 'UTC';
ALTER TABLE update_logs
ALTER COLUMN executed_at TYPE TIMESTAMP USING executed_at AT TIME ZONE 'UTC';
ALTER TABLE users
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN last_login TYPE TIMESTAMP USING last_login AT TIME ZONE 'UTC';
ALTER TABLE agent_commands
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN sent_at TYPE TIMESTAMP USING sent_at AT TIME ZONE 'UTC',
ALTER COLUMN completed_at TYPE TIMESTAMP USING completed_at AT TIME ZONE 'UTC',
ALTER COLUMN signed_at TYPE TIMESTAMP USING signed_at AT TIME ZONE 'UTC',
ALTER COLUMN expires_at TYPE TIMESTAMP USING expires_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE refresh_tokens
ALTER COLUMN expires_at TYPE TIMESTAMP USING expires_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN last_used_at TYPE TIMESTAMP USING last_used_at AT TIME ZONE 'UTC';
ALTER TABLE registration_tokens
ALTER COLUMN expires_at TYPE TIMESTAMP USING expires_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN used_at TYPE TIMESTAMP USING used_at AT TIME ZONE 'UTC',
ALTER COLUMN revoked_at TYPE TIMESTAMP USING revoked_at AT TIME ZONE 'UTC';
ALTER TABLE token_seats
ALTER COLUMN used_at TYPE TIMESTAMP USING used_at AT TIME ZONE 'UTC';
ALTER TABLE agent_subsystems
ALTER COLUMN last_run_at TYPE TIMESTAMP USING last_run_at AT TIME ZONE 'UTC',
ALTER COLUMN next_run_at TYPE TIMESTAMP USING next_run_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE agent_update_packages
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC';
ALTER TABLE security_settings
ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC';
ALTER TABLE security_settings_audit
ALTER COLUMN changed_at TYPE TIMESTAMP USING changed_at AT TIME ZONE 'UTC';
ALTER TABLE security_incidents
ALTER COLUMN resolved_at TYPE TIMESTAMP USING resolved_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC';
ALTER TABLE signing_keys
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN deprecated_at TYPE TIMESTAMP USING deprecated_at AT TIME ZONE 'UTC';
ALTER TABLE storage_metrics
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC';
ALTER TABLE client_errors
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC';
ALTER TABLE scanner_config
ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE tracked_software
ALTER COLUMN latest_at TYPE TIMESTAMP USING latest_at AT TIME ZONE 'UTC',
ALTER COLUMN eol_at TYPE TIMESTAMP USING eol_at AT TIME ZONE 'UTC',
ALTER COLUMN last_checked_at TYPE TIMESTAMP USING last_checked_at AT TIME ZONE 'UTC',
ALTER COLUMN last_synced_at TYPE TIMESTAMP USING last_synced_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE upstream_drift_events
ALTER COLUMN observed_at TYPE TIMESTAMP USING observed_at AT TIME ZONE 'UTC';
COMMIT;

View file

@ -1,130 +0,0 @@
-- Migration 036: Convert remaining TIMESTAMP columns to TIMESTAMPTZ.
-- Doctrine: tasks/CRITICAL-003-Time-Handling-Standardization.md.
--
-- Rationale: the codebase has fragmented between time.Now() (server-local Go time),
-- time.Now().UTC(), SQL NOW(), and CURRENT_TIMESTAMP. Storing into plain TIMESTAMP
-- columns loses zone awareness and creates drift any time the Postgres container or
-- the Go process is not at UTC. This migration converts every persistence-side
-- timestamp to TIMESTAMPTZ so the wire-format is unambiguous and Go callers can stop
-- caring about zone at write time.
--
-- Existing values are interpreted as UTC (the conventional state in Docker stacks);
-- if any are not actually UTC, the deployment has had a latent bug all along — this
-- migration converts honestly rather than papering over it.
--
-- Already-TIMESTAMPTZ columns from prior migrations (003, 018, 019, 031, 033) are
-- skipped — listed inline in comments for clarity.
BEGIN;
-- agents (initial schema + later additions)
ALTER TABLE agents
ALTER COLUMN last_seen TYPE TIMESTAMPTZ USING last_seen AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC',
ALTER COLUMN last_version_check TYPE TIMESTAMPTZ USING last_version_check AT TIME ZONE 'UTC',
ALTER COLUMN last_reboot_at TYPE TIMESTAMPTZ USING last_reboot_at AT TIME ZONE 'UTC',
ALTER COLUMN update_initiated_at TYPE TIMESTAMPTZ USING update_initiated_at AT TIME ZONE 'UTC';
-- agent_specs
ALTER TABLE agent_specs
ALTER COLUMN collected_at TYPE TIMESTAMPTZ USING collected_at AT TIME ZONE 'UTC';
-- update_packages
ALTER TABLE update_packages
ALTER COLUMN discovered_at TYPE TIMESTAMPTZ USING discovered_at AT TIME ZONE 'UTC',
ALTER COLUMN approved_at TYPE TIMESTAMPTZ USING approved_at AT TIME ZONE 'UTC',
ALTER COLUMN scheduled_for TYPE TIMESTAMPTZ USING scheduled_for AT TIME ZONE 'UTC',
ALTER COLUMN installed_at TYPE TIMESTAMPTZ USING installed_at AT TIME ZONE 'UTC';
-- update_logs
ALTER TABLE update_logs
ALTER COLUMN executed_at TYPE TIMESTAMPTZ USING executed_at AT TIME ZONE 'UTC';
-- users
ALTER TABLE users
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN last_login TYPE TIMESTAMPTZ USING last_login AT TIME ZONE 'UTC';
-- agent_commands (received_at is already TIMESTAMPTZ from migration 033)
ALTER TABLE agent_commands
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN sent_at TYPE TIMESTAMPTZ USING sent_at AT TIME ZONE 'UTC',
ALTER COLUMN completed_at TYPE TIMESTAMPTZ USING completed_at AT TIME ZONE 'UTC',
ALTER COLUMN signed_at TYPE TIMESTAMPTZ USING signed_at AT TIME ZONE 'UTC',
ALTER COLUMN expires_at TYPE TIMESTAMPTZ USING expires_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
-- refresh_tokens
ALTER TABLE refresh_tokens
ALTER COLUMN expires_at TYPE TIMESTAMPTZ USING expires_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN last_used_at TYPE TIMESTAMPTZ USING last_used_at AT TIME ZONE 'UTC';
-- registration_tokens
ALTER TABLE registration_tokens
ALTER COLUMN expires_at TYPE TIMESTAMPTZ USING expires_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN used_at TYPE TIMESTAMPTZ USING used_at AT TIME ZONE 'UTC',
ALTER COLUMN revoked_at TYPE TIMESTAMPTZ USING revoked_at AT TIME ZONE 'UTC';
-- token_seats
ALTER TABLE token_seats
ALTER COLUMN used_at TYPE TIMESTAMPTZ USING used_at AT TIME ZONE 'UTC';
-- agent_subsystems
ALTER TABLE agent_subsystems
ALTER COLUMN last_run_at TYPE TIMESTAMPTZ USING last_run_at AT TIME ZONE 'UTC',
ALTER COLUMN next_run_at TYPE TIMESTAMPTZ USING next_run_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
-- agent_update_packages
ALTER TABLE agent_update_packages
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
-- security_settings
ALTER TABLE security_settings
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
-- security_settings_audit
ALTER TABLE security_settings_audit
ALTER COLUMN changed_at TYPE TIMESTAMPTZ USING changed_at AT TIME ZONE 'UTC';
-- security_incidents
ALTER TABLE security_incidents
ALTER COLUMN resolved_at TYPE TIMESTAMPTZ USING resolved_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
-- signing_keys
ALTER TABLE signing_keys
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN deprecated_at TYPE TIMESTAMPTZ USING deprecated_at AT TIME ZONE 'UTC';
-- storage_metrics
ALTER TABLE storage_metrics
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
-- client_errors (from migration 023)
ALTER TABLE client_errors
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
-- scanner_config
ALTER TABLE scanner_config
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
-- tracked_software (migration 035)
ALTER TABLE tracked_software
ALTER COLUMN latest_at TYPE TIMESTAMPTZ USING latest_at AT TIME ZONE 'UTC',
ALTER COLUMN eol_at TYPE TIMESTAMPTZ USING eol_at AT TIME ZONE 'UTC',
ALTER COLUMN last_checked_at TYPE TIMESTAMPTZ USING last_checked_at AT TIME ZONE 'UTC',
ALTER COLUMN last_synced_at TYPE TIMESTAMPTZ USING last_synced_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
-- upstream_drift_events (migration 035)
ALTER TABLE upstream_drift_events
ALTER COLUMN observed_at TYPE TIMESTAMPTZ USING observed_at AT TIME ZONE 'UTC';
COMMIT;

View file

@ -0,0 +1,5 @@
-- Reversal of 037: drop the uniqueness constraint. Deleted duplicate rows
-- are not recreated — they were redundant signed artifacts of identical
-- binaries and re-creating them would only re-introduce the original bug.
DROP INDEX IF EXISTS uq_agent_update_packages_version_platform_arch;

View file

@ -0,0 +1,27 @@
-- Migration 037: deduplicate agent_update_packages and enforce uniqueness.
--
-- The BuildOrchestratorService used to re-sign and re-insert every platform
-- binary at every server boot, accumulating 4 fresh rows per restart for the
-- same artifact. The dashboard's "available packages" list ballooned to dozens
-- of identical entries and forced operators to scroll past them when picking a
-- target version.
--
-- The service-side fix lives in build_orchestrator.go (reuse the row if the
-- on-disk checksum matches an existing signed package); this migration
-- cleans up historical duplicates and adds the unique constraint that makes
-- the duplication impossible at the schema level going forward.
--
-- Strategy: keep the newest row per (version, platform, architecture).
-- created_at is the tiebreaker, id breaks ties when two inserts share a
-- timestamp.
DELETE FROM agent_update_packages a
USING agent_update_packages b
WHERE a.version = b.version
AND a.platform = b.platform
AND a.architecture = b.architecture
AND (a.created_at < b.created_at
OR (a.created_at = b.created_at AND a.id < b.id));
CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_update_packages_version_platform_arch
ON agent_update_packages (version, platform, architecture);

View file

@ -20,7 +20,11 @@ func NewAgentUpdateQueries(db *sqlx.DB) *AgentUpdateQueries {
return &AgentUpdateQueries{db: db}
}
// CreateUpdatePackage stores a new signed update package
// CreateUpdatePackage stores a signed update package. Upserts on
// (version, platform, architecture): a fresh build of the same version
// (different checksum / signature) replaces the row in place. Combined with
// the unique constraint added in migration 037, this guarantees one row per
// real artifact even across repeated dev builds.
func (q *AgentUpdateQueries) CreateUpdatePackage(pkg *models.AgentUpdatePackage) error {
query := `
INSERT INTO agent_update_packages (
@ -29,7 +33,16 @@ func (q *AgentUpdateQueries) CreateUpdatePackage(pkg *models.AgentUpdatePackage)
) VALUES (
:id, :version, :platform, :architecture, :binary_path, :signature,
:checksum, :file_size, :created_by, :is_active
) RETURNING id, created_at
)
ON CONFLICT (version, platform, architecture) DO UPDATE SET
binary_path = EXCLUDED.binary_path,
signature = EXCLUDED.signature,
checksum = EXCLUDED.checksum,
file_size = EXCLUDED.file_size,
created_by = EXCLUDED.created_by,
is_active = EXCLUDED.is_active,
created_at = NOW()
RETURNING id, created_at
`
rows, err := q.db.NamedQuery(query, pkg)

View file

@ -28,41 +28,51 @@ func NewBuildOrchestratorService(signingService *SigningService, packageQueries
}
}
// BuildAndSignAgent builds (or retrieves) and signs an agent binary
// BuildAndSignAgent builds (or retrieves) and signs an agent binary.
//
// Idempotent across server restarts: if a signed package already exists for
// (version, platform, architecture) and the on-disk binary's checksum matches,
// the existing row is returned unchanged. Previously the server would re-sign
// and insert a fresh row every boot, accumulating duplicate packages and
// confusing the dashboard's "update available" list.
func (s *BuildOrchestratorService) BuildAndSignAgent(version, platform, architecture string) (*models.AgentUpdatePackage, error) {
// Determine binary name
binaryName := "redflag-agent"
if strings.HasPrefix(platform, "windows") {
binaryName += ".exe"
}
// Path to pre-built binary (directory format: binaries/{platform}-{arch}/)
binaryPath := filepath.Join(s.agentDir, "binaries", platform+"-"+architecture, binaryName)
// Check if binary exists
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
return nil, fmt.Errorf("binary not found for platform %s: %w", platform, err)
}
// Sign the binary if signing is enabled
if s.signingService.IsEnabled() {
// Compute the on-disk checksum once so we can compare against any
// existing row before paying the cost of a fresh sign.
diskChecksum, checksumErr := s.signingService.ComputeFileChecksum(binaryPath)
if checksumErr != nil {
log.Printf("[WARNING] [server] [build_orchestrator] checksum_failed path=%s error=%v — falling through to fresh sign", binaryPath, checksumErr)
} else if existing, getErr := s.packageQueries.GetSignedPackage(version, platform, architecture); getErr == nil && existing != nil && existing.Checksum == diskChecksum && existing.Signature != "" {
log.Printf("[INFO] [server] [build_orchestrator] package_reused version=%s platform=%s arch=%s id=%s", version, platform, architecture, existing.ID)
return existing, nil
}
signedPackage, err := s.signingService.SignFile(binaryPath)
if err != nil {
return nil, fmt.Errorf("failed to sign agent binary: %w", err)
}
// Set additional fields
signedPackage.Version = version
signedPackage.Platform = platform
signedPackage.Architecture = architecture
// Store signed package in database
err = s.packageQueries.StoreSignedPackage(signedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store signed package: %w", err)
}
log.Printf("Successfully signed and stored agent binary: %s (%s/%s)", signedPackage.ID, platform, architecture)
log.Printf("[INFO] [server] [build_orchestrator] package_signed id=%s version=%s platform=%s arch=%s", signedPackage.ID, version, platform, architecture)
return signedPackage, nil
} else {
log.Printf("Signing disabled, creating unsigned package entry")

View file

@ -165,6 +165,26 @@ func (s *SigningService) GetAllActivePublicKeys(ctx context.Context) ([]models.S
}, nil
}
// ComputeFileChecksum returns the SHA-256 hex digest of the file at the given
// path without signing it. Used by the build orchestrator to decide whether
// an already-stored signed package is still valid for the on-disk binary
// (so we can skip a redundant re-sign on every server boot).
func (s *SigningService) ComputeFileChecksum(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("failed to read file: %w", err)
}
hash := sha256.Sum256(content)
return hex.EncodeToString(hash[:]), nil
}
// SignFile signs a file and returns the signature and checksum
func (s *SigningService) SignFile(filePath string) (*models.AgentUpdatePackage, error) {
// Check if signing is enabled

View file

@ -210,6 +210,16 @@ cat <<'DOCKER_EOF' | sudo tee -a "$SUDOERS_FILE" > /dev/null
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
DOCKER_EOF
# Add self-update commands
cat <<'UPDATE_EOF' | sudo tee -a "$SUDOERS_FILE" > /dev/null
# RedFlag Agent self-update permissions
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/cp /tmp/redflag-update-*.bin /usr/local/bin/redflag-agent
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/cp /usr/local/bin/redflag-agent /usr/local/bin/redflag-agent.bak
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/cp /usr/local/bin/redflag-agent.bak /usr/local/bin/redflag-agent
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/chmod 755 /usr/local/bin/redflag-agent
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemctl restart redflag-agent
UPDATE_EOF
sudo chmod 440 "$SUDOERS_FILE"
if visudo -c -f "$SUDOERS_FILE" &>/dev/null; then
echo "✓ Sudoers configuration installed and validated"
@ -258,13 +268,15 @@ EXPECTED_SIGNATURE=$(grep -i "x-content-signature" "$TMP_HEADERS" | awk '{print
if [ -n "$EXPECTED_SIGNATURE" ]; then
echo "Signature received, saving for agent verification"
echo "$EXPECTED_SIGNATURE" | sudo tee "${SERVER_KEY_DIR}/initial_binary.sig" > /dev/null
sudo chown {{.AgentUser}}:{{.AgentUser}} "${SERVER_KEY_DIR}/initial_binary.sig"
else
echo "WARNING: Server did not provide signature header"
fi
# Save server public key for TOFU model
if [ -n "{{.ServerPublicKey}}" ]; then
echo "{{.ServerPublicKey}}" | sudo tee "${SERVER_KEY_DIR}/server_public_key" > /dev/null
echo -n "{{.ServerPublicKey}}" | python3 -c "import sys,binascii;sys.stdout.buffer.write(binascii.unhexlify(sys.stdin.read().strip()))" | sudo tee "${SERVER_KEY_DIR}/server_public_key" > /dev/null
sudo chown {{.AgentUser}}:{{.AgentUser}} "${SERVER_KEY_DIR}/server_public_key"
echo "Server public key saved for TOFU verification"
fi

View file

@ -12,8 +12,8 @@ import (
// Build-time injected version information (SERVER AUTHORITY)
var (
AgentVersion = "0.2.0.2"
ConfigVersion = "0.2.0.2"
AgentVersion = "0.2.0.3"
ConfigVersion = "0.2.0.3"
MinAgentVersion = "0.1.22"
)

View file

@ -51,22 +51,44 @@ export function AgentUpdatesModal({
enabled: isOpen,
});
const packages = packagesResponse?.packages || [];
const allPackages = packagesResponse?.packages || [];
// Group packages by version
const versions = [...new Set(packages.map(pkg => pkg.version))].sort((a, b) => b.localeCompare(a));
const platforms = [...new Set(packages.map(pkg => pkg.platform))].sort();
// Filter packages based on selection
const availablePackages = packages.filter(
pkg => (!selectedVersion || pkg.version === selectedVersion) &&
(!selectedPlatform || pkg.platform === selectedPlatform)
);
// Get unique platform for selected agents (simplified - assumes all agents same platform)
// Get unique platform/arch for selected agents (simplified - assumes all agents same platform)
const agentPlatform = agents[0]?.os_type || 'linux';
const agentArchitecture = agents[0]?.os_architecture || 'amd64';
// Restrict the candidate set to packages that actually match the selected
// agents' platform+architecture. The server enforces this on POST too (it
// returns 400 for an incompatible combo) — surface it here so the operator
// never sees a row they can't actually install.
const compatiblePackages = allPackages.filter(
pkg => pkg.platform === agentPlatform && pkg.architecture === agentArchitecture
);
// Dedupe by (version, platform, architecture) — the server signs binaries
// at every boot, which historically left multiple rows per tuple. Keep the
// most recent row so the operator picks from one entry per real artifact.
const dedupedPackages = Object.values(
compatiblePackages.reduce<Record<string, typeof compatiblePackages[number]>>((acc, pkg) => {
const key = `${pkg.version}|${pkg.platform}|${pkg.architecture}`;
const existing = acc[key];
if (!existing || new Date(pkg.created_at) > new Date(existing.created_at)) {
acc[key] = pkg;
}
return acc;
}, {})
).sort((a, b) => b.version.localeCompare(a.version));
const versions = [...new Set(dedupedPackages.map(pkg => pkg.version))].sort((a, b) => b.localeCompare(a));
const availablePackages = dedupedPackages.filter(
pkg => !selectedVersion || pkg.version === selectedVersion
);
// selectedPlatform is now driven entirely by agent platform; kept in state
// for future multi-arch agent selection but unused in current filtering.
void selectedPlatform;
// Update agents mutation
const updateAgentsMutation = useMutation({
mutationFn: async (packageId: string) => {
@ -207,34 +229,19 @@ export function AgentUpdatesModal({
Update Package Selection
</h4>
{/* Filters */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Version</label>
<select
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
className="w-full rounded-md border-gray-300 shadow-sm text-sm"
>
<option value="">All Versions</option>
{versions.map(version => (
<option key={version} value={version}>{version}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Platform</label>
<select
value={selectedPlatform}
onChange={(e) => setSelectedPlatform(e.target.value)}
className="w-full rounded-md border-gray-300 shadow-sm text-sm"
>
<option value="">All Platforms</option>
{platforms.map(platform => (
<option key={platform} value={platform}>{platform}</option>
))}
</select>
</div>
{/* Filters — platform/arch fixed by selected agents; only version varies */}
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 mb-1">Version</label>
<select
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
className="w-full rounded-md border-gray-300 shadow-sm text-sm"
>
<option value="">All Versions</option>
{versions.map(version => (
<option key={version} value={version}>{version}</option>
))}
</select>
</div>
{/* Available Packages */}
@ -264,7 +271,7 @@ export function AgentUpdatesModal({
Version {pkg.version}
</div>
<div className="text-xs text-gray-500">
{pkg.platform} {(pkg.file_size / 1024 / 1024).toFixed(1)} MB
{pkg.platform}/{pkg.architecture} {(pkg.file_size / 1024 / 1024).toFixed(1)} MB
</div>
</div>
</div>

View file

@ -71,6 +71,7 @@ export interface AgentUpdatePackage {
id: string;
version: string;
platform: string;
architecture: string;
file_size: number;
checksum: string;
created_at: string;