Watch
1
0
Fork
You've already forked RedFlag
0

command-lifecycle v2: add received state, disk-persisted dedup, authenticated download, timeout reconciler

Migration 033 adds the 'received' status to agent_commands so the server can
distinguish "agent confirmed receipt" from "sent but may be lost in flight."
Stuck-command re-issuance now excludes received commands — the TimeoutService
handles the longer timeout for those (default 30m) vs the per-poll re-issuer
(sent/pending at 5m).

The agent side: disk-persists executed command IDs to survive restart (closes
the in-memory-only dedup gap), reports received_command_ids on each check-in so
the server transitions sent→received before issuing new work, and authenticates
binary downloads with JWT+X-Machine-ID (was unauthenticated http.Get — would
401 in production).

TimeoutService extended with reconcileAgentUpdates: clears is_updating when
current_version matches updating_to_version (success), or after a 15m threshold
(timeout, with system_event) so the dashboard never shows "updating" forever.
isVersionUpgrade replaced with utils.IsNewerVersion (no panic on 2-part
versions, no false-reject on 4-part).

MarkCommand* failures elevated from [WARNING] to [ERROR] + should_retry
response hint so agents know to re-deliver results (silent drops were ETHOS #1
violations).

Fixes: build broken on public since eac8a012 (command.go accidentally emptied).
This commit is contained in:
Fimeg 2026-05-22 08:45:16 -04:00
commit 7abe331bf8
19 changed files with 920 additions and 122 deletions

View file

@ -455,6 +455,11 @@ If you're looking for an enterprise-grade solution with SLAs and support contrac
- ✅ Test suite: 170+ tests across 18 packages
- ✅ Install URL auto-detection: backend uses REDFLAG_PUBLIC_URL, frontend :8080 → :31337
- ✅ Setup wizard includes agent-facing URL field with auto-detect
- ✅ Command lifecycle v2 (Migration 033): `received` state distinguishes "agent has it" from "lost in flight"; stuck-command re-issuance no longer fires blindly
- ✅ Disk-persisted command deduplication: a restarted agent cannot re-execute a command issued within the 4h max-age window
- ✅ Agent self-upgrade completion loop: TimeoutService reconciles `is_updating` on version attestation (success) or after threshold (timeout, with system_event)
- ✅ Authenticated binary download: agent self-upgrade now reuses JWT + X-Machine-ID for the download endpoint (was unauthenticated, would 401 in production)
- ✅ Surfaced MarkCommand* failures: silent warnings → ERROR logs + `should_retry` hint on the response (ETHOS #1)
**v0.1.27 (Dec 2025, Christmas Release) 🎄**:
- ✅ Hardware binding with machine fingerprinting (security differentiator)

View file

@ -16,6 +16,7 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/logging"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
"github.com/Fimeg/RedFlag/agent/internal/receipt"
"github.com/Fimeg/RedFlag/agent/internal/recovery"
"github.com/Fimeg/RedFlag/agent/internal/scanner"
"github.com/Fimeg/RedFlag/agent/internal/startup"
@ -86,15 +87,22 @@ func RunAgentLoop(cfg *config.Config) error {
scanOrchestrator.RegisterScanner("system", orchestrator.NewSystemScannerWrapper(systemScanner), systemCB, cfg.Subsystems.System.Timeout, cfg.Subsystems.System.Enabled)
scanOrchestrator.RegisterScanner("docker", orchestrator.NewDockerScannerWrapper(dockerScanner), dockerCB, cfg.Subsystems.Docker.Timeout, cfg.Subsystems.Docker.Enabled)
// Initialize acknowledgment tracker
// Initialize acknowledgment tracker (result acks — pending_acks.json)
ackTracker := acknowledgment.NewTracker(constants.GetAgentStateDir())
if err := ackTracker.Load(); err != nil {
log.Printf("Warning: Failed to load pending acknowledgments: %v", err)
log.Printf("[WARNING] [agent] [acknowledgment] load_pending_acks_failed error=%v", err)
}
// Initialize receipt tracker (command-receipt confirmation — pending_receipts.json)
// Migration 033 §2: doctrine TODO-full-command-lifecycle.md
receiptTracker := receipt.NewTracker(constants.GetAgentStateDir())
if err := receiptTracker.Load(); err != nil {
log.Printf("[WARNING] [agent] [receipt] load_pending_receipts_failed error=%v", err)
}
// Initialize command handler
securityLogger, _ := logging.NewSecurityLogger(cfg, constants.GetAgentStateDir())
commandHandler, err := orchestrator.NewCommandHandler(cfg, securityLogger, log.New(os.Stdout, "", log.LstdFlags))
commandHandler, err := orchestrator.NewCommandHandler(cfg, constants.GetAgentStateDir(), securityLogger, log.New(os.Stdout, "", log.LstdFlags))
if err != nil {
return fmt.Errorf("failed to initialize command handler: %w", err)
}
@ -104,6 +112,7 @@ func RunAgentLoop(cfg *config.Config) error {
cfg: cfg,
apiClient: apiClient,
ackTracker: ackTracker,
receiptTracker: receiptTracker,
commandHandler: commandHandler,
scanOrchestrator: scanOrchestrator,
circuitBreakers: map[string]*circuitbreaker.CircuitBreaker{
@ -123,6 +132,7 @@ type loopContext struct {
cfg *config.Config
apiClient *client.Client
ackTracker *acknowledgment.Tracker
receiptTracker *receipt.Tracker
commandHandler *orchestrator.CommandHandler
scanOrchestrator *orchestrator.Orchestrator
circuitBreakers map[string]*circuitbreaker.CircuitBreaker
@ -170,8 +180,15 @@ func runPollingLoop(ctx *loopContext) error {
log.Printf("Checking in with server... (Agent v%s)", version.Version)
// Collect system metrics
// Collect system metrics, then bolt on the tracked IDs so the server can both
// (a) acknowledge command results we're still buffering (pending_acks.json) and
// (b) confirm receipt of commands we received last round but haven't completed
// (pending_receipts.json, Migration 033 §2).
metrics := collectMetrics(ctx.cfg)
if metrics != nil {
metrics.PendingAcknowledgments = ctx.ackTracker.GetPending()
metrics.ReceivedCommandIDs = ctx.receiptTracker.GetPending()
}
// Get commands from server
response, err := ctx.apiClient.GetCommands(ctx.cfg.AgentID, metrics)
@ -192,11 +209,22 @@ func runPollingLoop(ctx *loopContext) error {
"rapid_polling": ctx.cfg.RapidPollingEnabled && time.Now().Before(ctx.cfg.RapidPollingUntil),
})
// Process acknowledgments
// Drop result-acks the server confirmed.
if response != nil && len(response.AcknowledgedIDs) > 0 {
ctx.ackTracker.Acknowledge(response.AcknowledgedIDs)
log.Printf("Server acknowledged %d command result(s)", len(response.AcknowledgedIDs))
ctx.ackTracker.Save()
log.Printf("[INFO] [agent] [acknowledgment] results_acknowledged count=%d", len(response.AcknowledgedIDs))
if err := ctx.ackTracker.Save(); err != nil {
log.Printf("[ERROR] [agent] [acknowledgment] save_failed error=%v", err)
}
}
// Drop receipts the server confirmed (sent→received transition successful).
if response != nil && len(response.ReceiptConfirmedIDs) > 0 {
ctx.receiptTracker.Confirm(response.ReceiptConfirmedIDs)
log.Printf("[INFO] [agent] [receipt] receipts_confirmed count=%d", len(response.ReceiptConfirmedIDs))
if err := ctx.receiptTracker.Save(); err != nil {
log.Printf("[ERROR] [agent] [receipt] save_failed error=%v", err)
}
}
// Report circuit breaker health
@ -205,9 +233,16 @@ func runPollingLoop(ctx *loopContext) error {
// Report buffered events [TD-003]
go reportBufferedEvents(ctx)
// Process commands
// Process commands — record receipt BEFORE verification/dispatch so the server
// stops re-issuing immediately, even for commands that turn out malformed.
if len(response.Commands) > 0 {
log.Printf("Received %d command(s)", len(response.Commands))
for _, cmd := range response.Commands {
ctx.receiptTracker.Add(cmd.ID)
}
if err := ctx.receiptTracker.Save(); err != nil {
log.Printf("[ERROR] [agent] [receipt] save_after_receive_failed error=%v", err)
}
processCommands(ctx, response.Commands)
}

View file

@ -7,6 +7,7 @@ import (
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
@ -173,6 +174,68 @@ func (c *Client) SetToken(token string) {
c.token = token
}
// DownloadAuthenticatedToFile fetches a relative or absolute URL using the agent's
// JWT + machine binding and streams the body into dstPath. maxBytes caps the size to
// guard against a misbehaving server filling the disk. Returns the number of bytes
// written, or an error.
//
// Relative URLs (those beginning with "/") are resolved against the configured server.
// Used by the agent self-update handler — the /api/v1/downloads/updates/:package_id
// route is auth-protected, so an unauthenticated http.Get would 401.
func (c *Client) DownloadAuthenticatedToFile(rawURL, dstPath string, maxBytes int64) (int64, error) {
target := rawURL
if strings.HasPrefix(rawURL, "/") {
base, err := url.Parse(c.baseURL)
if err != nil {
return 0, fmt.Errorf("parse base url: %w", err)
}
rel, err := url.Parse(rawURL)
if err != nil {
return 0, fmt.Errorf("parse download url: %w", err)
}
target = base.ResolveReference(rel).String()
}
req, err := http.NewRequest("GET", target, nil)
if err != nil {
return 0, fmt.Errorf("build download request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
c.addMachineIDHeader(req)
resp, err := c.http.Do(req)
if err != nil {
return 0, fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return 0, fmt.Errorf("download failed: status=%d body=%q", resp.StatusCode, string(bodyBytes))
}
dst, err := os.Create(dstPath)
if err != nil {
return 0, fmt.Errorf("create destination: %w", err)
}
defer dst.Close()
limit := io.LimitReader(resp.Body, maxBytes)
written, err := io.Copy(dst, limit)
if err != nil {
return written, fmt.Errorf("write download: %w", err)
}
// Detect truncation: if we hit exactly maxBytes, the source may have been longer.
// Probe by reading one more byte.
probe := make([]byte, 1)
if n, _ := resp.Body.Read(probe); n > 0 {
return written, fmt.Errorf("download exceeded max bytes %d", maxBytes)
}
return written, nil
}
// RegisterRequest is the payload for agent registration
type RegisterRequest struct {
Hostname string `json:"hostname"`
@ -397,9 +460,10 @@ type CommandItem = Command
// CommandsResponse contains pending commands
type CommandsResponse struct {
Commands []Command `json:"commands"`
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // IDs server has received
Commands []Command `json:"commands"`
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // Result IDs server has recorded (drop from pending_acks)
ReceiptConfirmedIDs []string `json:"receipt_confirmed_ids,omitempty"` // Command IDs server flipped sent→received (drop from outbound receipts)
}
// RapidPollingConfig contains rapid polling configuration from server
@ -422,7 +486,12 @@ type SystemMetrics struct {
Metadata map[string]interface{} `json:"metadata,omitempty"` // Additional metadata
// Command acknowledgment tracking
PendingAcknowledgments []string `json:"pending_acknowledgments,omitempty"` // Command IDs awaiting ACK
PendingAcknowledgments []string `json:"pending_acknowledgments,omitempty"` // Command result IDs awaiting server ACK
// Receipt confirmation (Migration 033 §2): command IDs the agent has received but
// not yet completed. Server flips these sent→received and returns them in
// ReceiptConfirmedIDs, at which point the agent drops them from its outbound buffer.
ReceivedCommandIDs []string `json:"received_command_ids,omitempty"`
// Capability advertisement (ARC-001): scanners present on this host right now.
// Server diffs this against agent_subsystems each poll so newly-installed

View file

@ -9,12 +9,9 @@ import (
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
@ -97,21 +94,11 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
log.Printf("[INFO] [agent] [update] download_start url=%s", downloadURL)
// Resolve relative download URLs against the server address
if strings.HasPrefix(downloadURL, "/") {
base, err := url.Parse(cfg.ServerURL)
if err != nil {
return fmt.Errorf("failed to parse server URL: %w", err)
}
rel, err := url.Parse(downloadURL)
if err != nil {
return fmt.Errorf("failed to parse download URL: %w", err)
}
downloadURL = base.ResolveReference(rel).String()
log.Printf("[INFO] [agent] [update] resolved_url=%s", downloadURL)
}
tempBinaryPath, err := downloadUpdatePackage(downloadURL)
// The /api/v1/downloads/updates/:package_id route is protected by AuthMiddleware
// — the prior implementation used a raw http.Client.Get and would 401 in
// production. Use the authenticated client so JWT + X-Machine-ID accompany the
// request. Relative-URL resolution happens inside DownloadAuthenticatedToFile.
tempBinaryPath, err := downloadUpdatePackage(apiClient, downloadURL)
if err != nil {
return fmt.Errorf("failed to download update package: %w", err)
}
@ -205,31 +192,24 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
// --- Helper functions ---
func downloadUpdatePackage(downloadURL string) (string, error) {
// downloadUpdatePackage streams the new agent binary into a temp file using the
// agent's authenticated client. Caller owns the returned path (use defer os.Remove).
// The 500MB cap mirrors the prior implementation; an oversize response returns an
// error rather than a silently-truncated binary that would fail signature verify.
func downloadUpdatePackage(apiClient *client.Client, downloadURL string) (string, error) {
tempFile, err := os.CreateTemp("", "redflag-update-*.bin")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tempFile.Close()
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Get(downloadURL)
if err != nil {
return "", fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed with status: %d", resp.StatusCode)
}
tempPath := tempFile.Name()
tempFile.Close() // DownloadAuthenticatedToFile re-opens via os.Create
const maxBinarySize = 500 * 1024 * 1024
limitedReader := io.LimitReader(resp.Body, maxBinarySize)
if _, err := io.Copy(tempFile, limitedReader); err != nil {
return "", fmt.Errorf("failed to write download: %w", err)
if _, err := apiClient.DownloadAuthenticatedToFile(downloadURL, tempPath, maxBinarySize); err != nil {
os.Remove(tempPath)
return "", fmt.Errorf("failed to download: %w", err)
}
return tempFile.Name(), nil
return tempPath, nil
}
func computeSHA256(filePath string) (string, error) {

View file

@ -2,8 +2,11 @@ package orchestrator
import (
"crypto/ed25519"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
@ -31,6 +34,7 @@ type CommandHandler struct {
keyCacheMu sync.RWMutex
executedIDs map[string]time.Time // cmd UUID -> execution time (F-2 fix: dedup)
executedIDsMu sync.Mutex
executedIDsPath string // Migration 033 §5: disk-persisted dedup
lastKeyRefresh time.Time
logger *log.Logger
}
@ -41,8 +45,9 @@ type CommandSigningConfig struct {
EnforcementMode string `json:"enforcement_mode" env:"REDFLAG_AGENT_COMMAND_ENFORCEMENT_MODE" default:"strict"`
}
// NewCommandHandler creates a new command handler
func NewCommandHandler(cfg *config.Config, securityLogger *logging.SecurityLogger, logger *log.Logger) (*CommandHandler, error) {
// NewCommandHandler creates a new command handler. stateDir is the agent's persistent
// state directory; passing an empty string disables disk-persisted dedup (test path).
func NewCommandHandler(cfg *config.Config, stateDir string, securityLogger *logging.SecurityLogger, logger *log.Logger) (*CommandHandler, error) {
handler := &CommandHandler{
securityLogger: securityLogger,
logger: logger,
@ -50,6 +55,21 @@ func NewCommandHandler(cfg *config.Config, securityLogger *logging.SecurityLogge
keyCache: make(map[string]ed25519.PublicKey),
executedIDs: make(map[string]time.Time),
}
if stateDir != "" {
handler.executedIDsPath = filepath.Join(stateDir, "executed_commands.json")
}
// Migration 033 §5: rebuild dedup set from disk so a restart can't re-execute a
// command issued within commandMaxAge. Missing file is a fresh start, not an error.
if handler.executedIDsPath != "" {
if err := handler.loadExecutedIDs(); err != nil {
logger.Printf("[WARNING] [agent] [cmd_handler] load_executed_ids_failed path=%q error=%v", handler.executedIDsPath, err)
} else {
logger.Printf("[INFO] [agent] [cmd_handler] executed_ids_loaded count=%d path=%q", len(handler.executedIDs), handler.executedIDsPath)
// Trim anything that's already aged out — keeps the file from growing forever.
handler.CleanupExecutedIDs()
}
}
// Pre-load cached public key if command signing is enabled
if cfg.CommandSigning.Enabled {
@ -67,6 +87,53 @@ func NewCommandHandler(cfg *config.Config, securityLogger *logging.SecurityLogge
return handler, nil
}
// loadExecutedIDs restores the dedup set from disk. Caller holds no lock.
func (h *CommandHandler) loadExecutedIDs() error {
if _, err := os.Stat(h.executedIDsPath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(h.executedIDsPath)
if err != nil {
return fmt.Errorf("read: %w", err)
}
if len(data) == 0 {
return nil
}
var loaded map[string]time.Time
if err := json.Unmarshal(data, &loaded); err != nil {
return fmt.Errorf("parse: %w", err)
}
h.executedIDsMu.Lock()
h.executedIDs = loaded
h.executedIDsMu.Unlock()
return nil
}
// saveExecutedIDs persists the current dedup set. Caller MUST hold executedIDsMu.
// Errors are logged by the caller — best-effort persistence; the in-memory set is
// the authoritative source within a single process lifetime.
func (h *CommandHandler) saveExecutedIDsLocked() error {
if h.executedIDsPath == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(h.executedIDsPath), 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
data, err := json.MarshalIndent(h.executedIDs, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
tmp := h.executedIDsPath + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write: %w", err)
}
if err := os.Rename(tmp, h.executedIDsPath); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
return nil
}
// getKeyForCommand returns the appropriate public key for verifying a command.
// Uses key_id-aware lookup with lazy fetch for unknown keys.
func (h *CommandHandler) getKeyForCommand(cmd client.Command, serverURL string) (ed25519.PublicKey, error) {
@ -196,15 +263,21 @@ func (h *CommandHandler) ProcessCommand(cmd client.Command, cfg *config.Config,
return nil
}
// markExecuted records a command ID in the deduplication set (F-2 fix)
// markExecuted records a command ID in the deduplication set (F-2 fix).
// Persists the updated set to disk so a restart-within-commandMaxAge cannot re-execute
// the same command (Migration 033 §5).
func (h *CommandHandler) markExecuted(cmdID string) {
h.executedIDsMu.Lock()
h.executedIDs[cmdID] = time.Now()
if err := h.saveExecutedIDsLocked(); err != nil {
h.logger.Printf("[ERROR] [agent] [cmd_handler] persist_executed_ids_failed cmd=%s error=%v", cmdID, err)
}
h.executedIDsMu.Unlock()
}
// CleanupExecutedIDs evicts entries older than commandMaxAge from the dedup set.
// Should be called when ShouldRefreshKey() fires (every 6h).
// Should be called when ShouldRefreshKey() fires (every 6h). Persists the trimmed
// set if anything was evicted.
func (h *CommandHandler) CleanupExecutedIDs() {
h.executedIDsMu.Lock()
defer h.executedIDsMu.Unlock()
@ -219,6 +292,9 @@ func (h *CommandHandler) CleanupExecutedIDs() {
}
if evicted > 0 {
h.logger.Printf("[INFO] [agent] [cmd_handler] cleanup_executed_ids evicted=%d remaining=%d", evicted, len(h.executedIDs))
if err := h.saveExecutedIDsLocked(); err != nil {
h.logger.Printf("[ERROR] [agent] [cmd_handler] persist_after_cleanup_failed error=%v", err)
}
}
}

View file

@ -0,0 +1,152 @@
// Package receipt persists the set of command IDs the agent has received from the
// server but not yet seen the server confirm as transitioned to status='received'.
//
// Doctrine: TODO-full-command-lifecycle.md §2. The agent reports these in
// SystemMetrics.ReceivedCommandIDs each check-in. The server flips matching rows from
// 'sent' to 'received' and echoes the confirmed subset back in
// CommandsResponse.ReceiptConfirmedIDs; the agent then drops only the confirmed IDs.
// The unsent ones stay buffered so a dropped check-in doesn't lose receipt confirmation.
//
// This is distinct from pending_acks.json, which tracks command RESULTS awaiting
// server confirmation. Receipt happens BEFORE dispatch; result happens AFTER.
package receipt
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// pendingReceipt captures when the agent first received a command — used to age out
// entries the server has somehow forgotten so they don't accumulate forever.
type pendingReceipt struct {
CommandID string `json:"command_id"`
ReceivedAt time.Time `json:"received_at"`
}
// Tracker is a disk-persisted set of command IDs awaiting server-side receipt confirmation.
type Tracker struct {
pending map[string]*pendingReceipt
mu sync.RWMutex
filePath string
maxAge time.Duration // discard buffered receipts older than this so a permanent server-side loss doesn't leak forever
}
// NewTracker creates a tracker that persists state under statePath/pending_receipts.json.
func NewTracker(statePath string) *Tracker {
return &Tracker{
pending: make(map[string]*pendingReceipt),
filePath: filepath.Join(statePath, "pending_receipts.json"),
maxAge: 24 * time.Hour,
}
}
// Load restores pending receipts from disk. Missing file is not an error (fresh start).
func (t *Tracker) Load() error {
t.mu.Lock()
defer t.mu.Unlock()
if _, err := os.Stat(t.filePath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(t.filePath)
if err != nil {
return fmt.Errorf("read pending_receipts: %w", err)
}
if len(data) == 0 {
return nil
}
var pending map[string]*pendingReceipt
if err := json.Unmarshal(data, &pending); err != nil {
return fmt.Errorf("parse pending_receipts: %w", err)
}
t.pending = pending
return nil
}
// Save persists the pending set to disk.
func (t *Tracker) Save() error {
t.mu.RLock()
defer t.mu.RUnlock()
if err := os.MkdirAll(filepath.Dir(t.filePath), 0o755); err != nil {
return fmt.Errorf("create receipt dir: %w", err)
}
data, err := json.MarshalIndent(t.pending, "", " ")
if err != nil {
return fmt.Errorf("marshal pending_receipts: %w", err)
}
if err := os.WriteFile(t.filePath, data, 0o600); err != nil {
return fmt.Errorf("write pending_receipts: %w", err)
}
return nil
}
// Add records that the agent has received a command from the server. Safe to call
// multiple times with the same ID — duplicates are a no-op (idempotent, ETHOS #4).
func (t *Tracker) Add(commandID string) {
t.mu.Lock()
defer t.mu.Unlock()
if _, exists := t.pending[commandID]; exists {
return
}
t.pending[commandID] = &pendingReceipt{
CommandID: commandID,
ReceivedAt: time.Now(),
}
}
// GetPending returns the current pending set as a slice (snapshot — safe to mutate).
// Order is not stable.
func (t *Tracker) GetPending() []string {
t.mu.RLock()
defer t.mu.RUnlock()
ids := make([]string, 0, len(t.pending))
for id := range t.pending {
ids = append(ids, id)
}
return ids
}
// Confirm removes the IDs the server confirmed receipt of. IDs not in the pending
// set are silently ignored — server might confirm an ID we already dropped after
// a successful prior round-trip, or an old buffer survived a crash.
func (t *Tracker) Confirm(commandIDs []string) {
t.mu.Lock()
defer t.mu.Unlock()
for _, id := range commandIDs {
delete(t.pending, id)
}
}
// Cleanup discards receipts older than maxAge. Returns the count removed. Should be
// run periodically to bound disk and memory growth in the pathological case where
// the server forgets a command id permanently.
func (t *Tracker) Cleanup() int {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now()
removed := 0
for id, p := range t.pending {
if now.Sub(p.ReceivedAt) > t.maxAge {
delete(t.pending, id)
removed++
}
}
return removed
}
// Len returns the current size of the pending set.
func (t *Tracker) Len() int {
t.mu.RLock()
defer t.mu.RUnlock()
return len(t.pending)
}

View file

@ -124,7 +124,7 @@ func (s *redflagService) initialize() error {
// CRITICAL: Initialize command handler with signature verification
// If this fails, we MUST NOT allow the service to run without verification (ETHOS #2)
commandHandler, err := orchestrator.NewCommandHandler(s.agent, securityLogger, log.New(os.Stdout, "", log.LstdFlags))
commandHandler, err := orchestrator.NewCommandHandler(s.agent, constants.GetAgentStateDir(), securityLogger, log.New(os.Stdout, "", log.LstdFlags))
if err != nil {
log.Printf("[ERROR] [agent] [cmd_handler] init_failed error=\"%v\"", err)
elog.Error(1, fmt.Sprintf("Command handler init failed: %v", err))

View file

@ -291,12 +291,14 @@ func main() {
tokenCleanupInterval := time.Duration(getOperationalSetting(securitySettingsService, "token_cleanup_interval_hours", 24)) * time.Hour
sentTimeout := time.Duration(getOperationalSetting(securitySettingsService, "sent_command_timeout_hours", 2)) * time.Hour
pendingTimeout := time.Duration(getOperationalSetting(securitySettingsService, "pending_command_timeout_minutes", 30)) * time.Minute
receivedTimeout := time.Duration(getOperationalSetting(securitySettingsService, "received_command_timeout_minutes", 30)) * time.Minute
updateTimeout := time.Duration(getOperationalSetting(securitySettingsService, "agent_update_timeout_minutes", 15)) * time.Minute
checkInterval := time.Duration(getOperationalSetting(securitySettingsService, "timeout_check_interval_minutes", 5)) * time.Minute
log.Printf("[INFO] [server] [config] operational_timeouts_loaded offline_check=%s offline_threshold=%s token_cleanup=%s sent_cmd_timeout=%s pending_cmd_timeout=%s timeout_check=%s",
offlineCheckInterval, offlineThreshold, tokenCleanupInterval, sentTimeout, pendingTimeout, checkInterval)
log.Printf("[INFO] [server] [config] operational_timeouts_loaded offline_check=%s offline_threshold=%s token_cleanup=%s sent_cmd_timeout=%s pending_cmd_timeout=%s received_cmd_timeout=%s update_timeout=%s timeout_check=%s",
offlineCheckInterval, offlineThreshold, tokenCleanupInterval, sentTimeout, pendingTimeout, receivedTimeout, updateTimeout, checkInterval)
timeoutService := services.NewTimeoutService(commandQueries, updateQueries, sentTimeout, pendingTimeout, checkInterval)
timeoutService := services.NewTimeoutService(commandQueries, updateQueries, agentQueries, sentTimeout, pendingTimeout, receivedTimeout, updateTimeout, checkInterval)
// Check if setup is complete
if !isSetupComplete(cfg, signingService, db) {

View file

@ -377,10 +377,11 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
DiskPercent float64 `json:"disk_percent,omitempty"`
Uptime string `json:"uptime,omitempty"`
Version string `json:"version,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
PendingAcknowledgments []string `json:"pending_acknowledgments,omitempty"`
AvailableScanners []string `json:"available_scanners,omitempty"` // ARC-001: per-poll capability advertisement
ReceivedCommandIDs []string `json:"received_command_ids,omitempty"` // Migration 033: command IDs the agent has received but not yet completed
Version string `json:"version,omitempty"` // Agent's currently-running version; drives post-update IsUpdating clearance via TimeoutService
AvailableScanners []string `json:"available_scanners,omitempty"` // ARC-001: per-poll capability advertisement
}
// Parse metrics if provided (optional, won't fail if empty)
@ -606,6 +607,35 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
}
defer cmdTx.Rollback()
// Post-update version attestation: if the agent reports a version, record it. The
// IsUpdating flag clearance is handled by TimeoutService once it observes
// current_version == updating_to_version (avoids per-request side effects).
if metrics.Version != "" {
if err := h.agentQueries.UpdateAgentVersion(agentID, metrics.Version); err != nil {
log.Printf("[ERROR] [server] [command] update_version_failed agent_id=%s version=%s error=%v",
agentID, metrics.Version, err)
}
}
// Migration 033 §2: transition agent-reported sent→received BEFORE looking for stuck
// commands. Excludes those IDs from the GetStuckCommandsTx re-issuance candidate set,
// which now only sees 'pending' and 'sent'. Receipt confirmation goes back in the
// response so the agent can drop them from its outbox.
var receiptConfirmedIDs []string
if len(metrics.ReceivedCommandIDs) > 0 {
confirmed, markErr := h.commandQueries.MarkCommandsReceivedTx(cmdTx, agentID, metrics.ReceivedCommandIDs)
if markErr != nil {
log.Printf("[ERROR] [server] [command] mark_received_failed agent_id=%s reported=%d error=%v",
agentID, len(metrics.ReceivedCommandIDs), markErr)
} else {
receiptConfirmedIDs = confirmed
if len(confirmed) > 0 {
log.Printf("[INFO] [server] [command] commands_received agent_id=%s confirmed=%d reported=%d",
agentID, len(confirmed), len(metrics.ReceivedCommandIDs))
}
}
}
// Get pending commands with row-level lock
pendingCommands, err := h.commandQueries.GetPendingCommandsTx(cmdTx, agentID)
if err != nil {
@ -614,7 +644,7 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
return
}
// Recover stuck commands with row-level lock
// Recover stuck commands with row-level lock (excludes 'received' — migration 033)
stuckCommands, err := h.commandQueries.GetStuckCommandsTx(cmdTx, agentID, 5*time.Minute)
if err != nil {
log.Printf("[WARNING] [server] [command] get_stuck_failed agent_id=%s error=%v", agentID, err)
@ -806,9 +836,10 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
}
response := models.CommandsResponse{
Commands: commandItems,
RapidPolling: rapidPolling,
AcknowledgedIDs: acknowledgedIDs,
Commands: commandItems,
RapidPolling: rapidPolling,
AcknowledgedIDs: acknowledgedIDs,
ReceiptConfirmedIDs: receiptConfirmedIDs,
}
c.JSON(http.StatusOK, response)

View file

@ -284,9 +284,14 @@ func (h *UnifiedUpdateHandler) ReportLog(c *gin.Context) {
log.Printf("DEBUG: ReportLog - Marking command %s as completed for agent %s", commandID, agentID)
// Surface MarkCommand* errors via structured ERROR log + should_retry hint
// (ETHOS #1: errors are history). Without this, agent silently believes the
// result was recorded while the server state stays inconsistent.
var markErr error
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)
if markErr = h.commandQueries.MarkCommandCompleted(commandID, result); markErr != nil {
log.Printf("[ERROR] [server] [update_handler] mark_completed_failed agent_id=%s command_id=%s error=%q",
agentID, commandID, markErr)
}
command, err := h.commandQueries.GetCommandByID(commandID)
@ -301,7 +306,7 @@ func (h *UnifiedUpdateHandler) ReportLog(c *gin.Context) {
}
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)
log.Printf("[WARNING] [server] [update_handler] package_status_update_failed package=%s type=%s error=%v", packageName, packageType, err)
} else {
log.Printf("[INFO] [server] [updates] package_updated package=%s type=%s", packageName, packageType)
}
@ -309,14 +314,25 @@ func (h *UnifiedUpdateHandler) ReportLog(c *gin.Context) {
}
}
} else if req.Result == "failed" || req.Result == "failure" || req.Result == "dry_run_failed" || req.Result == "partial_failure" {
if err := h.commandQueries.MarkCommandFailed(commandID, result); err != nil {
log.Printf("Warning: Failed to mark command %s as failed: %v\n", commandID, err)
if markErr = h.commandQueries.MarkCommandFailed(commandID, result); markErr != nil {
log.Printf("[ERROR] [server] [update_handler] mark_failed_failed agent_id=%s command_id=%s error=%q",
agentID, commandID, markErr)
}
} else {
if err := h.commandQueries.UpdateCommandResult(commandID, result); err != nil {
log.Printf("Warning: Failed to update command %s result: %v\n", commandID, err)
if markErr = h.commandQueries.UpdateCommandResult(commandID, result); markErr != nil {
log.Printf("[ERROR] [server] [update_handler] update_result_failed agent_id=%s command_id=%s error=%q",
agentID, commandID, markErr)
}
}
if markErr != nil {
c.JSON(http.StatusOK, gin.H{
"message": "log saved but command state update failed",
"should_retry": true,
"reason": markErr.Error(),
})
return
}
}
}

View file

@ -318,10 +318,15 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
"logged_at": time.Now(),
}
// Update command status based on log result
// Update command status based on log result.
// MarkCommand* errors are ETHOS #1 violations if dropped silently — the
// agent thinks the result was recorded and won't resend. Surface via
// structured ERROR log + a should_retry hint in the response.
var markErr error
if req.Result == "success" || req.Result == "completed" {
if err := h.commandQueries.MarkCommandCompleted(commandID, result); err != nil {
log.Printf("[WARNING] [server] [updates] mark_completed_failed command_id=%s error=%v", commandID, err)
if markErr = h.commandQueries.MarkCommandCompleted(commandID, result); markErr != nil {
log.Printf("[ERROR] [server] [updates] mark_completed_failed agent_id=%s command_id=%s error=%q",
agentID, commandID, markErr)
}
// NEW: If this was a successful confirm_dependencies command, mark the package as updated
@ -348,15 +353,26 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
}
}
} else if req.Result == "failed" || req.Result == "failure" || req.Result == "dry_run_failed" || req.Result == "partial_failure" {
if err := h.commandQueries.MarkCommandFailed(commandID, result); err != nil {
log.Printf("[WARNING] [server] [updates] mark_failed_failed command_id=%s error=%v", commandID, err)
if markErr = h.commandQueries.MarkCommandFailed(commandID, result); markErr != nil {
log.Printf("[ERROR] [server] [updates] mark_failed_failed agent_id=%s command_id=%s error=%q",
agentID, commandID, markErr)
}
} else {
// For other results, just update the result field
if err := h.commandQueries.UpdateCommandResult(commandID, result); err != nil {
log.Printf("[WARNING] [server] [updates] update_result_failed command_id=%s error=%v", commandID, err)
if markErr = h.commandQueries.UpdateCommandResult(commandID, result); markErr != nil {
log.Printf("[ERROR] [server] [updates] update_result_failed agent_id=%s command_id=%s error=%q",
agentID, commandID, markErr)
}
}
if markErr != nil {
c.JSON(http.StatusOK, gin.H{
"message": "log saved but command state update failed",
"should_retry": true,
"reason": markErr.Error(),
})
return
}
}
}

View file

@ -8,7 +8,6 @@ import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
@ -217,29 +216,17 @@ func validateUpdateNonceMiddleware(nonceB64, serverPublicKey string) error {
return nil
}
// isVersionUpgrade returns true when `new` is strictly newer than `current`.
// Uses utils.IsNewerVersion, which is the project's semver-aware comparator (handles
// arbitrary part counts including 4-part versions like 0.2.0.1). The prior
// hand-rolled implementation panicked on <3 parts and silently dropped parts beyond
// index 2, false-rejecting legitimate upgrades.
func isVersionUpgrade(new, current string) bool {
// Parse semantic versions
newParts := strings.Split(new, ".")
curParts := strings.Split(current, ".")
// 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
// Reject empty inputs explicitly — nothing is an upgrade from missing data.
if new == "" || current == "" {
return false
}
if newMajor == curMajor && newMinor > curMinor {
return true
}
if newMajor == curMajor && newMinor == curMinor && newPatch > curPatch {
return true
}
return false // Equal or downgrade
// Also tolerate a leading "v" by stripping it before comparison; the rest of
// the codebase sometimes carries it on tag-derived strings.
return utils.IsNewerVersion(strings.TrimPrefix(new, "v"), strings.TrimPrefix(current, "v"))
}

View file

@ -0,0 +1,13 @@
-- Down for migration 033
DROP INDEX IF EXISTS idx_agent_commands_status_received_at;
-- Any rows currently in 'received' must be folded back to 'sent' before the
-- CHECK constraint can be tightened, otherwise the ALTER will fail.
UPDATE agent_commands SET status = 'sent' WHERE status = 'received';
ALTER TABLE agent_commands DROP CONSTRAINT IF EXISTS agent_commands_status_check;
ALTER TABLE agent_commands ADD CONSTRAINT agent_commands_status_check
CHECK (status IN ('pending', 'sent', 'running', 'completed', 'failed', 'timed_out', 'cancelled', 'archived_failed'));
ALTER TABLE agent_commands DROP COLUMN IF EXISTS received_at;

View file

@ -0,0 +1,19 @@
-- Migration 033: Add 'received' state to agent command lifecycle
-- Distinguishes "server sent" (status=sent) from "agent confirmed receipt" (status=received).
-- Closes the gap where stuck-command re-issuance fires blindly because the server has
-- no way to tell a lost-in-flight command from a slow-to-execute one.
-- Doctrine: TODO-full-command-lifecycle.md §1.
-- Drop existing CHECK constraint and re-add with 'received' included
ALTER TABLE agent_commands DROP CONSTRAINT IF EXISTS agent_commands_status_check;
ALTER TABLE agent_commands ADD CONSTRAINT agent_commands_status_check
CHECK (status IN ('pending', 'sent', 'received', 'running', 'completed', 'failed', 'timed_out', 'cancelled', 'archived_failed'));
-- Timestamp the sent->received transition
ALTER TABLE agent_commands
ADD COLUMN IF NOT EXISTS received_at TIMESTAMPTZ;
-- Index for the TimeoutService 'received' reconciler (longer threshold than 'sent')
CREATE INDEX IF NOT EXISTS idx_agent_commands_status_received_at
ON agent_commands(status, received_at)
WHERE status = 'received';

View file

@ -338,6 +338,45 @@ func (q *AgentQueries) UpdateAgentUpdatingStatus(id uuid.UUID, isUpdating bool,
return err
}
// GetAgentsStuckUpdating returns agents where is_updating has been true longer than the
// threshold. Used by TimeoutService to reconcile updates whose completion attestation
// never arrived. Returns the candidates; the caller decides success vs timeout based on
// whether current_version matches updating_to_version.
//
// Doctrine: TODO-full-command-lifecycle.md §4 (Timeout State Machine for update lifecycle).
func (q *AgentQueries) GetAgentsStuckUpdating(threshold time.Time) ([]models.Agent, error) {
var agents []models.Agent
query := `
SELECT id, hostname, os_type, os_version, os_architecture, agent_version,
current_version, update_available, last_version_check, machine_id,
public_key_fingerprint, is_updating, updating_to_version,
update_initiated_at, last_seen, status, metadata, reboot_required,
last_reboot_at, reboot_reason, created_at, updated_at
FROM agents
WHERE is_updating = true
AND update_initiated_at IS NOT NULL
AND update_initiated_at < $1
`
err := q.db.Select(&agents, query, threshold)
return agents, err
}
// ClearAgentUpdating flips is_updating=false WITHOUT changing current_version. Used
// by TimeoutService when an update times out without a version-attestation match;
// the operator can re-trigger from the dashboard.
func (q *AgentQueries) ClearAgentUpdating(agentID uuid.UUID) error {
query := `
UPDATE agents
SET is_updating = false,
updating_to_version = NULL,
update_initiated_at = NULL,
updated_at = NOW()
WHERE id = $1
`
_, err := q.db.Exec(query, agentID)
return err
}
// CompleteAgentUpdate marks an agent update as successful and updates version
func (q *AgentQueries) CompleteAgentUpdate(agentID string, newVersion string) error {
query := `

View file

@ -158,8 +158,11 @@ func (q *CommandQueries) RedeliverStuckCommandTx(tx *sqlx.Tx, id uuid.UUID) erro
return err
}
// GetStuckCommandsTx retrieves stuck commands with FOR UPDATE SKIP LOCKED (F-B2-2 fix)
// Excludes commands that have exceeded max retries (F-B2-10 fix)
// GetStuckCommandsTx retrieves stuck commands with FOR UPDATE SKIP LOCKED (F-B2-2 fix).
// Excludes commands that have exceeded max retries (F-B2-10 fix).
// Migration 033: 'received' commands are NOT re-issued here — the agent has them and
// is working, just not yet done. Use GetStuckReceivedCommandsTx with a longer threshold
// for that case (handled by TimeoutService, not the per-poll re-issuer).
func (q *CommandQueries) GetStuckCommandsTx(tx *sqlx.Tx, agentID uuid.UUID, olderThan time.Duration) ([]models.AgentCommand, error) {
var commands []models.AgentCommand
query := `
@ -179,6 +182,72 @@ func (q *CommandQueries) GetStuckCommandsTx(tx *sqlx.Tx, agentID uuid.UUID, olde
return commands, err
}
// MarkCommandsReceivedTx batch-transitions a set of command IDs from 'sent' to 'received'.
// Only commands belonging to the named agent and currently in status 'sent' are flipped —
// this prevents an agent from claiming receipt of another agent's commands, and prevents
// re-transitioning out of terminal states. Returns the IDs that were actually flipped.
//
// Doctrine: TODO-full-command-lifecycle.md §2. Receipt confirmation closes the gap where
// GetStuckCommandsTx would re-issue blindly.
func (q *CommandQueries) MarkCommandsReceivedTx(tx *sqlx.Tx, agentID uuid.UUID, commandIDs []string) ([]string, error) {
if len(commandIDs) == 0 {
return []string{}, nil
}
parsed := make([]uuid.UUID, 0, len(commandIDs))
for _, idStr := range commandIDs {
id, err := uuid.Parse(idStr)
if err != nil {
continue
}
parsed = append(parsed, id)
}
if len(parsed) == 0 {
return []string{}, nil
}
placeholders := make([]string, len(parsed))
args := make([]interface{}, 0, len(parsed)+1)
args = append(args, agentID)
for i, id := range parsed {
placeholders[i] = fmt.Sprintf("$%d", i+2)
args = append(args, id)
}
query := fmt.Sprintf(`
UPDATE agent_commands
SET status = 'received', received_at = NOW()
WHERE agent_id = $1
AND status = 'sent'
AND id IN (%s)
RETURNING id::text
`, strings.Join(placeholders, ","))
var confirmed []string
if err := tx.Select(&confirmed, query, args...); err != nil {
return nil, fmt.Errorf("mark_received batch failed: %w", err)
}
return confirmed, nil
}
// GetStuckReceivedCommandsTx returns commands that have been 'received' by an agent
// but haven't transitioned to completed/failed within the longer threshold. These are
// timed out by the TimeoutService rather than re-issued — the agent had it, the agent
// is presumed broken or the command is presumed lost on the agent side.
func (q *CommandQueries) GetStuckReceivedCommandsTx(tx *sqlx.Tx, olderThan time.Duration) ([]models.AgentCommand, error) {
var commands []models.AgentCommand
query := `
SELECT * FROM agent_commands
WHERE status = 'received'
AND received_at IS NOT NULL
AND received_at < $1
ORDER BY received_at ASC
FOR UPDATE SKIP LOCKED
`
err := tx.Select(&commands, query, time.Now().Add(-olderThan))
return commands, err
}
// MarkCommandCompleted updates a command's status to completed
func (q *CommandQueries) MarkCommandCompleted(id uuid.UUID, result models.JSONB) error {
now := time.Now()
@ -514,8 +583,9 @@ func (q *CommandQueries) GetCommandsInTimeRange(hours int) (int, error) {
return count, err
}
// GetStuckCommands retrieves commands that are stuck in 'pending' or 'sent' status
// Excludes expired commands and commands that have exceeded max retries (F-B2-10 fix)
// GetStuckCommands retrieves commands stuck in 'pending' or 'sent' status.
// Excludes expired commands and commands that have exceeded max retries (F-B2-10 fix).
// 'received' is excluded — see GetStuckCommandsTx for rationale.
func (q *CommandQueries) GetStuckCommands(agentID uuid.UUID, olderThan time.Duration) ([]models.AgentCommand, error) {
var commands []models.AgentCommand
query := `

View file

@ -0,0 +1,150 @@
package models
import (
"errors"
"time"
"github.com/google/uuid"
)
// AgentCommand represents a command to be executed by an agent
type AgentCommand struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
CommandType string `json:"command_type" db:"command_type"`
Params JSONB `json:"params" db:"params"`
Status string `json:"status" db:"status"`
Source string `json:"source" db:"source"`
Signature string `json:"signature,omitempty" db:"signature"`
KeyID string `json:"key_id,omitempty" db:"key_id"`
SignedAt *time.Time `json:"signed_at,omitempty" db:"signed_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
IdempotencyKey *string `json:"idempotency_key,omitempty" db:"idempotency_key"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
ReceivedAt *time.Time `json:"received_at,omitempty" db:"received_at"`
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
Result JSONB `json:"result,omitempty" db:"result"`
RetriedFromID *uuid.UUID `json:"retried_from_id,omitempty" db:"retried_from_id"`
RetryCount int `json:"retry_count" db:"retry_count"`
}
// Validate checks if the command has all required fields
func (c *AgentCommand) Validate() error {
if c.ID == uuid.Nil {
return ErrCommandIDRequired
}
if c.AgentID == uuid.Nil {
return ErrAgentIDRequired
}
if c.CommandType == "" {
return ErrCommandTypeRequired
}
if c.Status == "" {
return ErrStatusRequired
}
if c.Source != "manual" && c.Source != "system" {
return ErrInvalidSource
}
return nil
}
// IsTerminal returns true if the command is in a terminal state
func (c *AgentCommand) IsTerminal() bool {
return c.Status == "completed" || c.Status == "failed" || c.Status == "cancelled"
}
// CanRetry returns true if the command can be retried
func (c *AgentCommand) CanRetry() bool {
return c.Status == "failed" && c.RetriedFromID == nil
}
// Predefined errors for validation
var (
ErrCommandIDRequired = errors.New("command ID cannot be zero UUID")
ErrAgentIDRequired = errors.New("agent ID is required")
ErrCommandTypeRequired = errors.New("command type is required")
ErrStatusRequired = errors.New("status is required")
ErrInvalidSource = errors.New("source must be 'manual' or 'system'")
)
// CommandsResponse is returned when an agent checks in for commands
type CommandsResponse struct {
Commands []CommandItem `json:"commands"`
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // Result IDs server has recorded
ReceiptConfirmedIDs []string `json:"receipt_confirmed_ids,omitempty"` // Command IDs server transitioned sent->received this turn
}
// RapidPollingConfig contains rapid polling configuration for the agent
type RapidPollingConfig struct {
Enabled bool `json:"enabled"`
Until string `json:"until"` // ISO 8601 timestamp
}
// CommandItem represents a command in the response
type CommandItem struct {
ID string `json:"id"`
Type string `json:"type"`
Params JSONB `json:"params"`
Signature string `json:"signature,omitempty"`
KeyID string `json:"key_id,omitempty"`
SignedAt *time.Time `json:"signed_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
AgentID string `json:"agent_id,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}
// Command types
const (
CommandTypeCollectSpecs = "collect_specs"
CommandTypeInstallUpdate = "install_updates"
CommandTypeDryRunUpdate = "dry_run_update"
CommandTypeConfirmDependencies = "confirm_dependencies"
CommandTypeRollback = "rollback_update"
CommandTypeUpdateAgent = "update_agent"
CommandTypeEnableHeartbeat = "enable_heartbeat"
CommandTypeDisableHeartbeat = "disable_heartbeat"
CommandTypeReboot = "reboot"
)
// Command statuses
const (
CommandStatusPending = "pending"
CommandStatusSent = "sent"
CommandStatusReceived = "received" // Agent confirmed receipt; not yet completed
CommandStatusCompleted = "completed"
CommandStatusFailed = "failed"
CommandStatusTimedOut = "timed_out"
CommandStatusCancelled = "cancelled"
CommandStatusRunning = "running"
)
// Command sources
const (
CommandSourceManual = "manual" // User-initiated via UI
CommandSourceSystem = "system" // Auto-triggered by system operations
)
// ActiveCommandInfo represents information about an active command for UI display
type ActiveCommandInfo struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
CommandType string `json:"command_type" db:"command_type"`
Params JSONB `json:"params" db:"params"`
Status string `json:"status" db:"status"`
Source string `json:"source" db:"source"`
Signature string `json:"signature,omitempty" db:"signature"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
Result JSONB `json:"result,omitempty" db:"result"`
AgentHostname string `json:"agent_hostname" db:"agent_hostname"`
PackageName string `json:"package_name" db:"package_name"`
PackageType string `json:"package_type" db:"package_type"`
RetriedFromID *uuid.UUID `json:"retried_from_id,omitempty" db:"retried_from_id"`
IsRetry bool `json:"is_retry" db:"is_retry"`
HasBeenRetried bool `json:"has_been_retried" db:"has_been_retried"`
RetryCount int `json:"retry_count" db:"retry_count"`
}

View file

@ -12,40 +12,54 @@ import (
// TimeoutService handles timeout management for long-running operations
type TimeoutService struct {
commandQueries *queries.CommandQueries
updateQueries *queries.UpdateQueries
ticker *time.Ticker
stopChan chan bool
sentTimeout time.Duration // For commands already sent to agents
pendingTimeout time.Duration // For commands stuck in queue
checkInterval time.Duration // How often to check for timeouts
commandQueries *queries.CommandQueries
updateQueries *queries.UpdateQueries
agentQueries *queries.AgentQueries
ticker *time.Ticker
stopChan chan bool
sentTimeout time.Duration // For commands already sent to agents
pendingTimeout time.Duration // For commands stuck in queue
receivedTimeout time.Duration // For commands received by agent but not completed (Migration 033 §4)
updateTimeout time.Duration // For agents stuck in is_updating=true
checkInterval time.Duration // How often to check for timeouts
}
// NewTimeoutService creates a new timeout service with configurable durations.
// Pass zero values to use defaults (2h sent, 30m pending, 5m check interval).
func NewTimeoutService(cq *queries.CommandQueries, uq *queries.UpdateQueries, sentTimeout, pendingTimeout, checkInterval time.Duration) *TimeoutService {
// Pass zero values to use defaults (2h sent, 30m pending, 30m received, 15m update,
// 5m check interval).
func NewTimeoutService(cq *queries.CommandQueries, uq *queries.UpdateQueries, aq *queries.AgentQueries, sentTimeout, pendingTimeout, receivedTimeout, updateTimeout, checkInterval time.Duration) *TimeoutService {
if sentTimeout <= 0 {
sentTimeout = 2 * time.Hour
}
if pendingTimeout <= 0 {
pendingTimeout = 30 * time.Minute
}
if receivedTimeout <= 0 {
receivedTimeout = 30 * time.Minute
}
if updateTimeout <= 0 {
updateTimeout = 15 * time.Minute
}
if checkInterval <= 0 {
checkInterval = 5 * time.Minute
}
return &TimeoutService{
commandQueries: cq,
updateQueries: uq,
sentTimeout: sentTimeout,
pendingTimeout: pendingTimeout,
checkInterval: checkInterval,
stopChan: make(chan bool),
commandQueries: cq,
updateQueries: uq,
agentQueries: aq,
sentTimeout: sentTimeout,
pendingTimeout: pendingTimeout,
receivedTimeout: receivedTimeout,
updateTimeout: updateTimeout,
checkInterval: checkInterval,
stopChan: make(chan bool),
}
}
// Start begins the timeout monitoring service
func (ts *TimeoutService) Start() {
log.Printf("[INFO] [server] [timeout] service_started sent_timeout=%v pending_timeout=%v check_interval=%v", ts.sentTimeout, ts.pendingTimeout, ts.checkInterval)
log.Printf("[INFO] [server] [timeout] service_started sent_timeout=%v pending_timeout=%v received_timeout=%v update_timeout=%v check_interval=%v",
ts.sentTimeout, ts.pendingTimeout, ts.receivedTimeout, ts.updateTimeout, ts.checkInterval)
ts.ticker = time.NewTicker(ts.checkInterval)
@ -54,6 +68,8 @@ func (ts *TimeoutService) Start() {
select {
case <-ts.ticker.C:
ts.checkForTimeouts()
ts.checkForReceivedTimeouts()
ts.reconcileAgentUpdates()
case <-ts.stopChan:
ts.ticker.Stop()
log.Println("Timeout service stopped")
@ -268,4 +284,126 @@ func (ts *TimeoutService) SetSentTimeout(duration time.Duration) {
func (ts *TimeoutService) SetPendingTimeout(duration time.Duration) {
ts.pendingTimeout = duration
log.Printf("Pending timeout duration updated to %v", duration)
}
// checkForReceivedTimeouts handles commands the agent received but never completed.
// Distinct from sent-timeouts because we know the agent had it — re-issuance won't
// help; the right action is to mark timed_out so the operator (or scheduler) can
// decide whether to retry or escalate.
//
// Doctrine: TODO-full-command-lifecycle.md §4. Migration 033 added the 'received' state.
func (ts *TimeoutService) checkForReceivedTimeouts() {
tx, err := ts.commandQueries.DB().Beginx()
if err != nil {
log.Printf("[ERROR] [server] [timeout] received_tx_begin_failed error=%v", err)
return
}
defer tx.Rollback()
stuck, err := ts.commandQueries.GetStuckReceivedCommandsTx(tx, ts.receivedTimeout)
if err != nil {
log.Printf("[ERROR] [server] [timeout] get_stuck_received_failed error=%v", err)
return
}
if len(stuck) == 0 {
return
}
for _, command := range stuck {
cmd := command
if err := ts.timeoutCommand(&cmd); err != nil {
log.Printf("[ERROR] [server] [timeout] timeout_received_failed command_id=%s error=%v", cmd.ID, err)
}
}
if err := tx.Commit(); err != nil {
log.Printf("[ERROR] [server] [timeout] received_tx_commit_failed error=%v", err)
return
}
log.Printf("[INFO] [server] [timeout] received_commands_timed_out count=%d threshold=%v", len(stuck), ts.receivedTimeout)
}
// reconcileAgentUpdates handles agents whose is_updating flag was set but never cleared.
// Decision per agent:
// - if current_version == updating_to_version: the agent completed the update and reported
// its new version, but the per-request side-effect to clear is_updating was deliberately
// omitted. Clear it now (success path).
// - otherwise: the update never reported in (binary failed to start, network dead, etc).
// Clear is_updating and log a timeout system_event so the operator sees what happened.
//
// Doctrine: TODO-full-command-lifecycle.md §4 + audit finding "completion loop has no firing path".
func (ts *TimeoutService) reconcileAgentUpdates() {
if ts.agentQueries == nil {
return // not wired (test path)
}
threshold := time.Now().Add(-ts.updateTimeout)
stuck, err := ts.agentQueries.GetAgentsStuckUpdating(threshold)
if err != nil {
log.Printf("[ERROR] [server] [timeout] get_stuck_updating_agents_failed error=%v", err)
return
}
if len(stuck) == 0 {
return
}
successes, timeouts := 0, 0
for _, agent := range stuck {
target := ""
if agent.UpdatingToVersion != nil {
target = *agent.UpdatingToVersion
}
if target != "" && agent.CurrentVersion == target {
// Success — agent reported the new version; just close the flag.
if err := ts.agentQueries.CompleteAgentUpdate(agent.ID.String(), agent.CurrentVersion); err != nil {
log.Printf("[ERROR] [server] [timeout] complete_update_failed agent_id=%s error=%v", agent.ID, err)
continue
}
ts.recordUpdateEvent(agent.ID, "succeeded", "info",
fmt.Sprintf("Agent update succeeded: %s reported", agent.CurrentVersion),
map[string]interface{}{"new_version": agent.CurrentVersion, "reconciled_by": "timeout_service"})
successes++
continue
}
// Timeout — version never matched. Clear the flag so the operator can retry.
if err := ts.agentQueries.ClearAgentUpdating(agent.ID); err != nil {
log.Printf("[ERROR] [server] [timeout] clear_updating_failed agent_id=%s error=%v", agent.ID, err)
continue
}
ageMessage := fmt.Sprintf("Agent update timed out after %v without version attestation (current=%s, target=%s)",
ts.updateTimeout, agent.CurrentVersion, target)
ts.recordUpdateEvent(agent.ID, "timed_out", "warning", ageMessage,
map[string]interface{}{
"current_version": agent.CurrentVersion,
"target_version": target,
"threshold": ts.updateTimeout.String(),
"reconciled_by": "timeout_service",
})
timeouts++
}
log.Printf("[INFO] [server] [timeout] agent_updates_reconciled stuck=%d succeeded=%d timed_out=%d threshold=%v",
len(stuck), successes, timeouts, ts.updateTimeout)
}
// recordUpdateEvent writes a system_event row for an update lifecycle transition. Best
// effort — a failure to log shouldn't block the reconciler from continuing on the next agent.
func (ts *TimeoutService) recordUpdateEvent(agentID uuid.UUID, subtype, severity, message string, metadata map[string]interface{}) {
event := &models.SystemEvent{
ID: uuid.New(),
AgentID: &agentID,
EventType: "agent_update",
EventSubtype: subtype,
Severity: severity,
Component: "agent",
Message: message,
Metadata: metadata,
CreatedAt: time.Now(),
}
if err := ts.agentQueries.CreateSystemEvent(event); err != nil {
log.Printf("[WARNING] [server] [timeout] system_event_write_failed agent_id=%s subtype=%s error=%v",
agentID, subtype, err)
}
}

View file

@ -14,7 +14,7 @@ func TestTimeoutServiceUsesConfiguredValues(t *testing.T) {
customPending := 45 * time.Minute
customInterval := 10 * time.Minute
ts := services.NewTimeoutService(nil, nil, customSent, customPending, customInterval)
ts := services.NewTimeoutService(nil, nil, nil, customSent, customPending, 20*time.Minute, 10*time.Minute, customInterval)
if ts == nil {
t.Fatal("NewTimeoutService returned nil")
}
@ -29,7 +29,7 @@ func TestTimeoutServiceUsesConfiguredValues(t *testing.T) {
// are replaced with sensible defaults, not left at zero.
func TestTimeoutServiceFallsBackToDefaults(t *testing.T) {
// Passing zero values should result in defaults being used (not panic/zero tickers)
ts := services.NewTimeoutService(nil, nil, 0, 0, 0)
ts := services.NewTimeoutService(nil, nil, nil, 0, 0, 0, 0, 0)
if ts == nil {
t.Fatal("NewTimeoutService returned nil with zero-value durations")
}