projection: begin the exact-path public epoch

The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally.

Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
Fimeg 2026-09-08 21:59:33 -04:00
commit 67e26be2d9
838 changed files with 157302 additions and 0 deletions

View file

@ -0,0 +1,13 @@
//go:build !windows
package handlers
import "fmt"
// dispatchWindowsRestart is a non-Windows stub so the package compiles on every
// platform. restartAgentService only invokes it under runtime.GOOS == "windows";
// on Linux the privileged helper owns the restart, and macOS has no self-restart
// path yet (its binaries aren't signed).
func dispatchWindowsRestart(service string) error {
return fmt.Errorf("windows restart path invoked on non-windows platform")
}

View file

@ -0,0 +1,35 @@
//go:build windows
package handlers
import (
"fmt"
"log"
"os/exec"
"syscall"
)
// dispatchWindowsRestart cycles the agent's own Windows service after the binary
// has already been swapped on disk.
//
// A service cannot stop itself and then start again from the same process: the
// stop terminates this process before the start runs, so nothing issues the start.
// The work is handed to a detached child that outlives us — it waits a few seconds
// for this process to exit, then stops and starts the service. This is the standard
// self-update restart pattern for a Windows service, the same shape any tray/agent
// app (Avira, AGV, etc.) uses to relaunch itself after replacing its own binary.
func dispatchWindowsRestart(service string) error {
// `ping -n 4 localhost` is a dependency-free ~3s sleep; then stop and start.
script := fmt.Sprintf("ping -n 4 127.0.0.1 >nul & sc stop %s & sc start %s", service, service)
cmd := exec.Command("cmd", "/C", script)
cmd.SysProcAttr = &syscall.SysProcAttr{
// DETACHED_PROCESS (0x8) | CREATE_NEW_PROCESS_GROUP (0x200): fully decouple
// the child so it survives this process being killed by the service stop.
CreationFlags: 0x00000008 | 0x00000200,
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to launch detached restart helper: %w", err)
}
log.Printf("[INFO] [agent] [service] windows_restart_dispatched service=%s", service)
return nil
}

View file

@ -0,0 +1,557 @@
package handlers
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"os"
"runtime"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
)
// HandleUpdateAgent handles agent update commands with signature verification
func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
version, ok := params["version"].(string)
if !ok {
return fmt.Errorf("missing version parameter")
}
platform, ok := params["platform"].(string)
if !ok {
return fmt.Errorf("missing platform parameter")
}
downloadURL, ok := params["download_url"].(string)
if !ok {
return fmt.Errorf("missing download_url parameter")
}
signature, ok := params["signature"].(string)
if !ok {
return fmt.Errorf("missing signature parameter")
}
checksum, ok := params["checksum"].(string)
if !ok {
return fmt.Errorf("missing checksum parameter")
}
nonceUUIDStr, ok := params["nonce_uuid"].(string)
if !ok {
return fmt.Errorf("missing nonce_uuid parameter")
}
nonceTimestampStr, ok := params["nonce_timestamp"].(string)
if !ok {
return fmt.Errorf("missing nonce_timestamp parameter")
}
nonceSignature, ok := params["nonce_signature"].(string)
if !ok {
return fmt.Errorf("missing nonce_signature parameter")
}
log.Printf("[INFO] [agent] [update] starting version=%s platform=%s", version, platform)
// Nonce max-age = 2 × check-in interval so the nonce survives the gap between
// being queued server-side and being fetched at the agent's next poll cycle.
nonceMaxAge := time.Duration(cfg.CheckInInterval*2) * time.Second
log.Printf("[tunturi_ed25519] Validating nonce...")
if err := validateNonce(nonceUUIDStr, nonceTimestampStr, nonceSignature, nonceMaxAge); err != nil {
log.Printf("[ERROR] [agent] [security] nonce_validation_failed error=%v", err)
return fmt.Errorf("[tunturi_ed25519] nonce validation failed: %w", err)
}
log.Printf("[INFO] [agent] [security] nonce_validated")
updateStartTime := time.Now()
logReport := client.LogReport{
CommandID: commandID,
Action: "update_agent",
Result: "started",
Stdout: fmt.Sprintf("Starting agent update to version %s\n", version),
Stderr: "",
ExitCode: 0,
DurationSeconds: 0,
Metadata: map[string]string{
"subsystem_label": "Agent Update",
"subsystem": "agent",
"target_version": version,
},
}
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [update] report_start_failed error=%v", err)
}
log.Printf("[INFO] [agent] [update] download_start url=%s", 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)
}
defer os.Remove(tempBinaryPath)
log.Printf("[INFO] [agent] [update] download_complete")
actualChecksum, err := computeSHA256(tempBinaryPath)
if err != nil {
return fmt.Errorf("failed to compute checksum: %w", err)
}
if actualChecksum != checksum {
return fmt.Errorf("checksum mismatch: expected %s, got %s", checksum, actualChecksum)
}
log.Printf("[INFO] [agent] [upgrade] checksum_verified checksum=%s", actualChecksum)
log.Printf("[tunturi_ed25519] Step 3: Verifying Ed25519 signature...")
if err := verifyBinarySignature(tempBinaryPath, signature); err != nil {
return fmt.Errorf("[tunturi_ed25519] signature verification failed: %w", err)
}
log.Printf("[INFO] [agent] [upgrade] ed25519_signature_verified")
// Linux: the privileged binary swap is performed by the root helper, not the
// agent's own sudo. The server signed an agent-self capability token over this
// binary's hash; the helper re-verifies and installs. Other platforms keep the
// legacy direct path below until a platform executor exists.
if runtime.GOOS == "linux" {
// Download the helper binary if the server provided a URL.
var helperBinaryPath string
if helperURL, ok := params["helper_download_url"].(string); ok && helperURL != "" {
var dlErr error
helperBinaryPath, dlErr = downloadUpdatePackage(apiClient, helperURL)
if dlErr != nil {
log.Printf("[WARNING] [agent] [upgrade] helper_download_failed error=%v — agent will update, helper stays as-is", dlErr)
} else {
defer os.Remove(helperBinaryPath)
// Verify helper checksum if provided
if helperChecksum, ok := params["helper_checksum"].(string); ok && helperChecksum != "" {
actual, err := computeSHA256(helperBinaryPath)
if err != nil {
log.Printf("[WARNING] [agent] [upgrade] helper_checksum_compute_failed error=%v", err)
helperBinaryPath = ""
} else if actual != helperChecksum {
log.Printf("[WARNING] [agent] [upgrade] helper_checksum_mismatch expected=%s got=%s", helperChecksum, actual)
helperBinaryPath = ""
}
}
}
}
return installAgentViaHelper(tempBinaryPath, helperBinaryPath, params, commandID, version, updateStartTime)
}
currentBinaryPath, err := getCurrentBinaryPath()
if err != nil {
return fmt.Errorf("failed to determine current binary path: %w", err)
}
backupPath := currentBinaryPath + ".bak"
var updateSuccess bool = false
if err := createBackup(currentBinaryPath, backupPath); err != nil {
log.Printf("[ERROR] [agent] [upgrade] backup_failed error=%v", err)
} else {
defer func() {
if updateSuccess {
// Binary swap and restart dispatch succeeded. systemd's SIGTERM
// is imminent and may abort this defer before completion. .bak
// stays on disk: the new binary cleans it up after first
// successful check-in attestation (cleanupPostUpdateBackup),
// or the operator restores from it if the new binary fails to
// start. Completion is owned by server-side
// timeout.reconcileAgentUpdates.
return
}
log.Printf("[INFO] [agent] [upgrade] rollback_start reason=pre_restart_error")
if restoreErr := restoreFromBackup(backupPath, currentBinaryPath); restoreErr != nil {
log.Printf("[ERROR] [agent] [upgrade] rollback_failed error=%v", restoreErr)
} else {
log.Printf("[INFO] [agent] [upgrade] rollback_success")
}
}()
}
log.Printf("[INFO] [agent] [upgrade] install_start")
if err := installNewBinary(tempBinaryPath, currentBinaryPath); err != nil {
return fmt.Errorf("failed to install new binary: %w", err)
}
// Past the point of no return on disk. The watchdog-and-final-log-report
// pattern used to live here; it could not survive systemd's SIGTERM and
// has been removed. Server-side reconcileAgentUpdates closes the command
// when the new binary reports its version on next check-in.
updateSuccess = true
log.Printf("[INFO] [agent] [upgrade] install_complete duration_seconds=%d", int(time.Since(updateStartTime).Seconds()))
// Binary is committed on disk — even if the restart dispatch below fails, the
// next boot runs the new binary, so the marker stays valid either way.
if err := WriteUpgradeAttestation(commandID, version); err != nil {
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_write_failed error=%v — upgrade proceeds unattested", err)
}
log.Printf("[INFO] [agent] [upgrade] restart_dispatch")
if err := restartAgentService(); err != nil {
// Binary is swapped on disk, but restart failed. The current process
// keeps running on the OLD code; .bak remains for manual restore on
// next manual boot.
return fmt.Errorf("failed to restart agent: %w", err)
}
return nil
}
// installAgentViaHelper performs the Linux agent self-upgrade through the
// privileged helper. The agent stages the verified binary on the real filesystem
// (the helper's transient-unit mount namespace cannot see the agent's PrivateTmp)
// and hands the helper the server-signed agent-self token. The helper verifies the
// token signature and the staged binary's hash, backs up, installs, chmods, and
// restarts the agent — the agent holds no sudo for cp/chmod/systemctl.
func installAgentViaHelper(tempBinaryPath, helperBinaryPath string, params map[string]interface{}, commandID, targetVersion string, startTime time.Time) error {
// Must match the helper's DEFAULT_AGENT_UPGRADE_SOURCE.
upgradeStagingPath := constants.GetAgentStagingPath("pending-upgrade.bin")
if err := copyFile(tempBinaryPath, upgradeStagingPath); err != nil {
return fmt.Errorf("failed to stage new binary for helper: %w", err)
}
// Stage the helper binary if we downloaded one. The helper will
// self-update from this staged copy before installing the new agent.
helperStagingPath := constants.GetAgentStagingPath("pending-helper.bin")
hasHelper := false
if helperBinaryPath != "" {
if err := copyFile(helperBinaryPath, helperStagingPath); err != nil {
log.Printf("[WARNING] [agent] [upgrade] helper_stage_failed error=%v — agent will update, helper stays as-is", err)
} else {
hasHelper = true
}
}
// Clean up staged binaries on failure. On success the helper restarts
// us (SIGTERM) before we reach this defer, so the files are already
// replaced and this is a harmless no-op. The attestation marker is also
// dropped on failure — no restart happened, so the next boot must not
// attest against this command.
success := false
defer func() {
if !success {
for _, p := range []string{upgradeStagingPath, helperStagingPath} {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
log.Printf("[WARNING] [agent] [upgrade] staging_cleanup_failed path=%s error=%v", p, err)
}
}
ClearUpgradeAttestation()
}
}()
tokenJSON, ok := params["capability_token"].(string)
if !ok || tokenJSON == "" {
return fmt.Errorf("missing capability_token — server did not authorize the helper swap")
}
var token capability.Token
if err := json.Unmarshal([]byte(tokenJSON), &token); err != nil {
return fmt.Errorf("failed to parse capability_token: %w", err)
}
// The marker must be on disk before the helper runs: on success the helper
// restarts this process mid-Execute, and the post-upgrade healthcheck in the
// new binary (RunUpgradeAttestation) is what verifies the swap actually took.
if err := WriteUpgradeAttestation(commandID, targetVersion); err != nil {
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_write_failed error=%v — upgrade proceeds unattested", err)
}
log.Printf("[INFO] [agent] [upgrade] invoking_helper token_id=%s has_helper=%v", token.TokenID, hasHelper)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Build args for the helper. --helper-file tells the helper where the
// staged helper binary lives so it can self-update before installing
// the new agent.
var helperArgs []string
if hasHelper {
helperArgs = []string{"--helper-file", helperStagingPath}
}
result, err := supplychain.NewExecutor("").Execute(ctx, &token, helperArgs...)
if err != nil {
// On success the helper restarts this agent, which can SIGTERM us before
// Execute returns. The new binary reporting its version on next check-in is
// the authoritative success signal (server reconcileAgentUpdates closes the
// command), so a transport error here is not necessarily a failed upgrade.
return fmt.Errorf("helper invocation failed (or agent restarted mid-swap): %w", err)
}
if result.Decision != "executed" {
return fmt.Errorf("helper refused agent upgrade: decision=%s reason=%s exit=%d",
result.Decision, result.Reason, result.ExitCode)
}
success = true
log.Printf("[INFO] [agent] [upgrade] helper_swap_complete token_id=%s duration_seconds=%d",
token.TokenID, int(time.Since(startTime).Seconds()))
return nil
}
// copyFile copies src to dst (truncating dst). No privilege required — used to
// place the verified binary on the real filesystem where the root helper can read
// it (the agent's own /var/lib/redflag/agent is writable and outside PrivateTmp).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// --- Helper functions ---
// 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)
}
tempPath := tempFile.Name()
tempFile.Close() // DownloadAuthenticatedToFile re-opens via os.Create
const maxBinarySize = 500 * 1024 * 1024
if _, err := apiClient.DownloadAuthenticatedToFile(downloadURL, tempPath, maxBinarySize); err != nil {
os.Remove(tempPath)
return "", fmt.Errorf("failed to download: %w", err)
}
return tempPath, nil
}
func computeSHA256(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", fmt.Errorf("failed to compute hash: %w", err)
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func getCurrentBinaryPath() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("failed to get executable path: %w", err)
}
return execPath, nil
}
// No post-update backup sweep exists by design. On Linux the helper owns the
// binary swap and writes <binary>.bak as root into the root-owned install dir
// (helper/src/main.rs install_staged_agent_binary). The agent runs unprivileged
// and its only sudo grant is the systemd-run helper invocation — it cannot, and
// must not, remove that file directly. Each upgrade's fs::copy truncates .bak in
// place, so it is always a single slot holding exactly the version before the one
// now running: the correct rollback target, maintained without the agent touching
// it. Discarding it after first check-in (the prior behavior) only threw away
// rollback while the new binary was still unproven.
// createBackup copies the current binary to dst as the rollback slot. Pure file
// I/O, no privilege escalation: this runs only on non-Linux platforms (Windows,
// macOS), where the agent process owns its own install directory. Linux never
// reaches here — the privileged helper owns the swap and writes .bak itself.
func createBackup(src, dst string) error {
if err := copyFile(src, dst); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
if err := os.Chmod(dst, 0o755); err != nil {
return fmt.Errorf("failed to set backup permissions: %w", err)
}
return nil
}
func restoreFromBackup(backup, target string) error {
// The live target may be a running, locked binary. On Windows it can't be
// overwritten in place but can be renamed out of the way first; on POSIX it
// can be unlinked while the old image keeps running off its open inode.
if runtime.GOOS == "windows" {
aside := target + ".old"
_ = os.Remove(aside)
_ = os.Rename(target, aside)
} else 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)
}
// installNewBinary stages the verified binary beside the current one and swaps it
// into place with pure file I/O — no sudo, no helper. Reached only on non-Linux
// platforms (Linux swaps through the helper). The agent service account has write
// access to its own install directory on both Windows and macOS.
func installNewBinary(src, dst string) error {
staged := dst + ".new"
if err := copyFile(src, staged); err != nil {
return fmt.Errorf("failed to stage new binary: %w", err)
}
if err := os.Chmod(staged, 0o755); err != nil {
os.Remove(staged)
return fmt.Errorf("failed to set binary permissions: %w", err)
}
if runtime.GOOS == "windows" {
// A running .exe can't be overwritten, but Windows permits renaming it.
// Move the live binary aside, then move the staged binary into the real
// path. The aside copy is locked until this process exits; it self-clears
// on the next upgrade (the os.Remove above) — a single stale slot at rest.
aside := dst + ".old"
_ = os.Remove(aside) // rename won't clobber an existing target on Windows
if err := os.Rename(dst, aside); err != nil {
os.Remove(staged)
return fmt.Errorf("failed to move running binary aside: %w", err)
}
if err := os.Rename(staged, dst); err != nil {
os.Rename(aside, dst) // undo: restore the original
return fmt.Errorf("failed to install new binary: %w", err)
}
return nil
}
// POSIX: atomically replace the directory entry. The running process keeps its
// already-open image until it restarts.
if err := os.Rename(staged, dst); err != nil {
os.Remove(staged)
return fmt.Errorf("failed to install new binary: %w", err)
}
return nil
}
// restartAgentService cycles the agent's own service after the on-disk binary swap.
// Only reached on non-Linux platforms: Linux self-upgrades run end to end through
// the privileged helper (installAgentViaHelper), which restarts the unit itself.
func restartAgentService() error {
switch runtime.GOOS {
case "windows":
return dispatchWindowsRestart("RedFlagAgent")
default:
return fmt.Errorf("self-restart not supported on %s; restart the service manually", runtime.GOOS)
}
}
// --- Signature verification ---
func verifyBinarySignature(binaryPath, signatureHex string) error {
publicKey, err := getServerPublicKey()
if err != nil {
return fmt.Errorf("failed to get server public key: %w", err)
}
content, err := os.ReadFile(binaryPath)
if err != nil {
return fmt.Errorf("failed to read binary: %w", err)
}
signatureBytes, err := hex.DecodeString(signatureHex)
if err != nil {
return fmt.Errorf("failed to decode signature: %w", err)
}
if len(signatureBytes) != ed25519.SignatureSize {
return fmt.Errorf("invalid signature length: expected %d bytes, got %d", ed25519.SignatureSize, len(signatureBytes))
}
valid := ed25519.Verify(ed25519.PublicKey(publicKey), content, signatureBytes)
if !valid {
return fmt.Errorf("signature verification failed: invalid signature")
}
return nil
}
func getServerPublicKey() ([]byte, error) {
publicKey, err := loadCachedPublicKeyDirect()
if err != nil {
return nil, fmt.Errorf("failed to load server public key: %w (hint: key is fetched at agent startup)", err)
}
return publicKey, nil
}
func loadCachedPublicKeyDirect() ([]byte, error) {
keyPath := constants.GetServerPublicKeyPath()
data, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("public key not found at %s: %w", keyPath, err)
}
if len(data) != 32 {
return nil, fmt.Errorf("invalid public key size: expected 32 bytes, got %d", len(data))
}
return data, nil
}
func validateNonce(nonceUUIDStr, nonceTimestampStr, nonceSignature string, maxAge time.Duration) error {
nonceTimestamp, err := time.Parse(time.RFC3339, nonceTimestampStr)
if err != nil {
return fmt.Errorf("invalid nonce timestamp format: %w", err)
}
age := time.Since(nonceTimestamp)
if age > maxAge {
return fmt.Errorf("nonce expired: age %v > %v", age, maxAge)
}
if age < 0 {
return fmt.Errorf("nonce timestamp in the future: %v", nonceTimestamp)
}
publicKey, err := getServerPublicKey()
if err != nil {
return fmt.Errorf("failed to get server public key: %w", err)
}
nonceData := fmt.Sprintf("%s:%d", nonceUUIDStr, nonceTimestamp.Unix())
signatureBytes, err := hex.DecodeString(nonceSignature)
if err != nil {
return fmt.Errorf("invalid nonce signature format: %w", err)
}
if len(signatureBytes) != ed25519.SignatureSize {
return fmt.Errorf("invalid nonce signature length: expected %d bytes, got %d",
ed25519.SignatureSize, len(signatureBytes))
}
valid := ed25519.Verify(ed25519.PublicKey(publicKey), []byte(nonceData), signatureBytes)
if !valid {
return fmt.Errorf("invalid nonce signature")
}
return nil
}

View file

@ -0,0 +1,241 @@
package handlers
import (
"encoding/csv"
"encoding/json"
"fmt"
"log"
"os"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/cache"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/display"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
"github.com/Fimeg/RedFlag/agent/internal/scanner"
"github.com/Fimeg/RedFlag/agent/internal/system"
"github.com/Fimeg/RedFlag/agent/internal/version"
)
// ReportLogWithAck reports a command log to the server and tracks it for acknowledgment
func ReportLogWithAck(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, logReport client.LogReport) error {
// Track this command result as pending acknowledgment
ackTracker.Add(logReport.CommandID)
// Save acknowledgment state immediately
if err := ackTracker.Save(); err != nil {
log.Printf("Warning: Failed to save acknowledgment for command %s: %v", logReport.CommandID, err)
}
// Report the log to the server
if err := apiClient.ReportLog(cfg.AgentID, logReport); err != nil {
// If reporting failed, increment retry count but don't remove from pending
ackTracker.IncrementRetry(logReport.CommandID)
return err
}
return nil
}
// ScanCommand performs a local scan and displays results
func ScanCommand(cfg *config.Config, exportFormat string) error {
// Initialize scanners
aptScanner := scanner.NewAPTScanner()
dnfScanner := scanner.NewDNFScanner()
dockerScanner, _ := orchestrator.NewDockerScanner()
windowsUpdateScanner := scanner.NewWindowsUpdateScanner()
wingetScanner := scanner.NewWingetScanner()
fmt.Println("Scanning for updates...")
var allUpdates []client.UpdateReportItem
var scanResults []orchestrator.ScanResult
// Scan APT updates
if aptScanner.IsAvailable() {
fmt.Println(" - Scanning APT packages...")
result := runLocalScanner("apt", aptScanner.Scan)
scanResults = append(scanResults, result)
if result.Error != nil {
fmt.Printf(" APT scan failed: %v\n", result.Error)
} else {
fmt.Printf(" ✓ Found %d APT updates\n", len(result.Updates))
allUpdates = append(allUpdates, result.Updates...)
}
}
// Scan DNF updates
if dnfScanner.IsAvailable() {
fmt.Println(" - Scanning DNF packages...")
result := runLocalScanner("dnf", dnfScanner.Scan)
scanResults = append(scanResults, result)
if result.Error != nil {
fmt.Printf(" DNF scan failed: %v\n", result.Error)
} else {
fmt.Printf(" ✓ Found %d DNF updates\n", len(result.Updates))
allUpdates = append(allUpdates, result.Updates...)
}
}
// Scan Docker updates
if dockerScanner != nil && dockerScanner.IsAvailable() {
fmt.Println(" - Scanning Docker images...")
result := runLocalScanner("docker", dockerScanner.Scan)
scanResults = append(scanResults, result)
if result.Error != nil {
fmt.Printf(" Docker scan failed: %v\n", result.Error)
} else {
fmt.Printf(" ✓ Found %d Docker image updates\n", len(result.Updates))
allUpdates = append(allUpdates, result.Updates...)
}
}
// Scan Windows updates
if windowsUpdateScanner.IsAvailable() {
fmt.Println(" - Scanning Windows updates...")
result := runLocalScanner("windows", windowsUpdateScanner.Scan)
scanResults = append(scanResults, result)
if result.Error != nil {
fmt.Printf(" Windows Update scan failed: %v\n", result.Error)
} else {
fmt.Printf(" ✓ Found %d Windows updates\n", len(result.Updates))
allUpdates = append(allUpdates, result.Updates...)
}
}
// Scan Winget packages
if wingetScanner.IsAvailable() {
fmt.Println(" - Scanning Winget packages...")
result := runLocalScanner("winget", wingetScanner.Scan)
scanResults = append(scanResults, result)
if result.Error != nil {
fmt.Printf(" Winget scan failed: %v\n", result.Error)
} else {
fmt.Printf(" ✓ Found %d Winget package updates\n", len(result.Updates))
allUpdates = append(allUpdates, result.Updates...)
}
}
recordLocalFullScan(cfg, allUpdates, scanResults)
// Display results
fmt.Println()
return display.PrintScanResults(allUpdates, exportFormat)
}
func runLocalScanner(scannerName string, scan func() ([]client.UpdateReportItem, error)) orchestrator.ScanResult {
startTime := time.Now()
updates, err := scan()
result := orchestrator.ScanResult{
ScannerName: scannerName,
Updates: updates,
Error: err,
Duration: time.Since(startTime),
Status: "success",
}
if err != nil {
result.Status = "failed"
}
return result
}
// StatusCommand displays agent status information
func StatusCommand(cfg *config.Config) error {
fmt.Println("==================================================================")
fmt.Println("🚩 RedFlag Agent Status")
fmt.Println("==================================================================")
// Agent information
fmt.Printf("Agent ID: %s\n", cfg.AgentID)
fmt.Printf("Server: %s\n", cfg.ServerURL)
fmt.Printf("Check-in Interval: %ds\n", cfg.CheckInInterval)
fmt.Printf("Version: %s\n", version.Version)
// Registration status
if cfg.IsRegistered() {
fmt.Println("Registration: Registered")
} else {
fmt.Println("Registration: Not registered")
}
// System information
sysInfo, err := system.GetSystemInfo(version.Version)
if err == nil {
fmt.Printf("Hostname: %s\n", sysInfo.Hostname)
fmt.Printf("OS: %s %s (%s)\n", sysInfo.OSType, sysInfo.OSVersion, sysInfo.OSArchitecture)
fmt.Printf("Uptime: %s\n", sysInfo.Uptime)
}
// Cache status
localCache, err := cache.Load()
if err == nil && !localCache.LastScanTime.IsZero() {
fmt.Printf("Last Scan: %s\n", localCache.LastScanTime.Format(time.RFC3339))
fmt.Printf("Updates Available: %d\n", len(localCache.Updates))
}
fmt.Println("==================================================================")
return nil
}
// ListUpdatesCommand lists detailed update information
func ListUpdatesCommand(cfg *config.Config, exportFormat string) error {
// Load cache to get last scan results
localCache, err := cache.Load()
if err != nil {
return fmt.Errorf("failed to load cache: %w", err)
}
if localCache.LastScanTime.IsZero() {
fmt.Println("No scan results available. Run -scan first.")
return nil
}
updates := localCache.Updates
if len(updates) == 0 {
fmt.Println("No updates available. System is up to date!")
return nil
}
// Handle export formats
switch exportFormat {
case "json":
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(updates)
case "csv":
writer := csv.NewWriter(os.Stdout)
// Write header
writer.Write([]string{"Name", "Current Version", "Available Version", "Source", "Severity"})
// Write data
for _, u := range updates {
writer.Write([]string{u.PackageName, u.CurrentVersion, u.AvailableVersion, u.RepositorySource, u.Severity})
}
writer.Flush()
return writer.Error()
default:
// Table format
fmt.Printf("Found %d updates:\n\n", len(updates))
fmt.Printf("%-30s %-20s %-20s %-10s\n", "NAME", "CURRENT", "AVAILABLE", "SOURCE")
fmt.Println(string(make([]byte, 80)))
for _, u := range updates {
fmt.Printf("%-30s %-20s %-20s %-10s\n",
truncate(u.PackageName, 30),
truncate(u.CurrentVersion, 20),
truncate(u.AvailableVersion, 20),
u.RepositorySource)
}
}
return nil
}
// truncate truncates a string to max length with ellipsis
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}

View file

@ -0,0 +1,90 @@
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/event"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
)
// reportFailure sends a command failure back to the server so it doesn't
// get stuck in "received" indefinitely. Called when a handler that already
// reported "started" hits an error before completing.
func reportFailure(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, cmdType, cmdID, errMsg string) {
logReport := client.LogReport{
CommandID: cmdID,
Action: cmdType,
Result: "failed",
Stderr: errMsg,
ExitCode: 1,
DurationSeconds: 0,
}
// Best-effort: don't block the caller if the server is unreachable.
if rErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); rErr != nil {
log.Printf("[ERROR] [agent] [dispatch] failure_report_failed cmd_type=%s cmd_id=%s error=%q", cmdType, cmdID, rErr)
}
}
// DispatchCrossPlatformCommand handles command types that work on every
// platform (scans, agent self-update). Returns true if cmd.Type matched a
// known cross-platform command; the caller is then responsible only for
// platform-specific command types. Errors from the underlying handler are
// logged here so both the regular agent loop and the Windows service path
// produce a uniform error trail.
func DispatchCrossPlatformCommand(
apiClient *client.Client,
cfg *config.Config,
ackTracker *acknowledgment.Tracker,
orch *orchestrator.Orchestrator,
eventBuffer *event.Buffer,
cmd client.Command,
) bool {
var err error
switch cmd.Type {
case "scan_storage":
err = HandleScanStorage(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_system":
err = HandleScanSystem(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_docker":
err = HandleScanDocker(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_apt":
err = HandleScanAPT(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_dnf":
err = HandleScanDNF(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_windows":
err = HandleScanWindows(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_winget":
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, eventBuffer, 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, eventBuffer, 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)
case "reboot":
err = HandleReboot(apiClient, cfg, ackTracker, cmd.ID, cmd.Params)
case "capture_screenshot":
err = HandleCaptureScreenshot(apiClient, cfg, ackTracker, cmd.ID)
case "scan_processes":
err = HandleScanProcesses(apiClient, cfg, ackTracker, cmd.ID)
default:
return false
}
if err != nil {
log.Printf("[ERROR] [agent] [%s] command_failed error=%q", cmd.Type, err)
reportFailure(apiClient, cfg, ackTracker, cmd.Type, cmd.ID, fmt.Sprintf("command failed: %s", err))
}
return true
}

View file

@ -0,0 +1,289 @@
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/event"
"github.com/Fimeg/RedFlag/agent/internal/installer"
"github.com/Fimeg/RedFlag/agent/internal/models"
)
// 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)
availableVersion, _ := params["available_version"].(string)
targetVersion, _ := params["target_version"].(string)
if targetVersion == "" {
targetVersion = availableVersion
}
if packageType == "" || packageName == "" {
return fmt.Errorf("package_type and package_name parameters are required")
}
inst, err := installer.InstallerFactory(packageType, cfg.ServerURL)
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 version=%s type=%s", packageName, targetVersion, packageType)
result, err := inst.DryRun(packageName, targetVersion)
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)
}
// Resolve the canonical artifact hashes for the closure (top-level package +
// dependencies) from the agent's signed repo metadata. This is the registry's
// hash source for OS package managers — the server cannot reach the agent's
// repos. Best-effort per package: a resolution failure is logged and that
// entry is omitted, never guessed. The server mints only over what resolved.
closure := resolveClosureHashes(packageType, packageName, targetVersion, result.Dependencies)
// Mirror the result into the client's wire type and post dependencies.
depReport := client.DependencyReport{
PackageName: packageName,
PackageType: packageType,
TargetVersion: targetVersion,
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,
},
Closure: closure,
}
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
}
// resolveClosureHashes resolves the canonical SHA256 for the top-level package
// and each dependency via the agent's signed repo metadata. The top-level entry
// is required: if it cannot be resolved the closure is dropped entirely (no
// partial pin). Dependencies are best-effort and logged on failure. Returns nil
// for package types whose hash is sourced server-side (npm/PyPI) or unsupported.
func resolveClosureHashes(packageType, packageName, targetVersion string, dependencies []string) []client.ClosureItem {
top, err := installer.ResolveArtifactSHA256(packageType, packageName, targetVersion)
if err != nil {
log.Printf("[WARNING] [agent] [installer] closure_toplevel_unresolved pkg=%s type=%s error=%v",
packageName, packageType, err)
return nil
}
closure := []client.ClosureItem{{
Name: top.Name,
Version: top.Version,
SHA256: top.SHA256,
Source: "registry",
}}
for _, dep := range dependencies {
if dep == "" || dep == packageName {
continue
}
resolved, err := installer.ResolveArtifactSHA256(packageType, dep, "")
if err != nil {
log.Printf("[WARNING] [agent] [installer] closure_dependency_unresolved dep=%s type=%s error=%v",
dep, packageType, err)
continue
}
closure = append(closure, client.ClosureItem{
Name: resolved.Name,
Version: resolved.Version,
SHA256: resolved.SHA256,
Source: "registry",
})
}
log.Printf("[INFO] [agent] [installer] closure_resolved pkg=%s type=%s entries=%d",
packageName, packageType, len(closure))
return closure
}
// 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, eventBuffer *event.Buffer, params map[string]interface{}, commandID string) (err error) {
packageType, _ := params["package_type"].(string)
packageName, _ := params["package_name"].(string)
// Every error return below surfaces on the History channel as a failed
// install event (ETHOS #1), inward via the operational event buffer.
defer func() {
if err != nil {
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeFailed, models.SeverityError, packageType, packageName, commandID, err.Error())
}
}()
if packageType == "" || packageName == "" {
return fmt.Errorf("package_type and package_name parameters are required")
}
// Gated ecosystems — confirmation goes through capability-token path.
if packageType == "dnf" || packageType == "apt" {
return fmt.Errorf("[SECURITY] [agent] [installer] direct_confirm_refused type=%s — confirmation must go through capability-token path", packageType)
}
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, cfg.ServerURL)
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
// Non-gated ecosystems mutate directly. dnf/apt do not implement
// NonGatedInstaller, so the assertion fails for them — the gate boundary
// is structural here, not a packageType guard.
mut, ok := inst.(installer.NonGatedInstaller)
if !ok {
return fmt.Errorf("[ERROR] [agent] [installer] direct_confirm_not_supported type=%s", packageType)
}
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 = mut.InstallMultiple(allPackages)
} else {
action = "update"
log.Printf("[INFO] [agent] [installer] update_package package=%s", packageName)
result, err = mut.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)
}
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeSuccess, models.SeverityInfo, packageType, packageName, commandID,
fmt.Sprintf("%s of %s completed in %ds", action, packageName, result.DurationSeconds))
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,180 @@
package handlers
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/cache"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/event"
"github.com/Fimeg/RedFlag/agent/internal/installer"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/gofrs/uuid/v5"
)
// emitInstallEvent records a package-install outcome on the system-event
// channel so it reaches the History page (ETHOS #1: errors are history).
// It routes inward through the operational event buffer, which persists to disk
// and flushes to the server independently of the command-result path. A
// failed install is auditable even if the result report is lost.
func emitInstallEvent(buffer *event.Buffer, agentID uuid.UUID, subtype, severity, packageType, packageName, commandID, message string) {
event.BufferSystemEvent(buffer, agentID,
models.EventTypeAgentInstall, subtype, severity,
packageType+"_installer", message,
map[string]interface{}{
"package_type": packageType,
"package_name": packageName,
"command_id": commandID,
},
)
}
// ExpectedHashes maps package names to their expected SHA256 hashes
func ExpectedHashes(cfg *config.Config) map[string]string {
hashes := make(map[string]string)
if cfg.PackageHashes != nil {
for name, hash := range cfg.PackageHashes {
hashes[name] = hash
}
}
return hashes
}
// FetchExpectedHash retrieves the expected SHA256 hash for a package from the server
func FetchExpectedHash(apiClient *client.Client, cfg *config.Config, packageName, version, packageType string) (string, error) {
return apiClient.GetExpectedHash(packageType, packageName, cfg.AgentID)
}
func HandleInstallUpdates(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, eventBuffer *event.Buffer, params map[string]interface{}, commandID string) (err error) {
packageType, _ := params["package_type"].(string)
packageName, _ := params["package_name"].(string)
// Any error return below lands on the History channel as a failed install
// event — one inward funnel covering every failure path (factory, hash
// verification, the install itself).
defer func() {
if err != nil {
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeFailed, models.SeverityError, packageType, packageName, commandID, err.Error())
}
}()
if packageType == "" {
return fmt.Errorf("package_type parameter is required")
}
inst, err := installer.InstallerFactory(packageType, cfg.ServerURL)
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)
}
// Layer 1: Hash Registry — verify package hash before installation
// Fetch the expected hash from the server
expectedHash, err := FetchExpectedHash(apiClient, cfg, packageName, "", packageType)
if err != nil {
return fmt.Errorf("[ERROR] [agent] [installer] hash_fetch_failed package=%s error=%w", packageName, err)
}
if expectedHash != "" {
log.Printf("[INFO] [agent] [installer] verifying_hash package=%s expected_sha256=%s", packageName, expectedHash[:16]+"...")
if err := inst.VerifyHash(packageName, "", expectedHash); err != nil {
return fmt.Errorf("[ERROR] [agent] [installer] hash_verification_failed package=%s error=%w", packageName, err)
}
log.Printf("[INFO] [agent] [installer] hash_verified package=%s", packageName)
}
var result *installer.InstallResult
var action string
startTime := time.Now()
// NEW: Check cache first for expected hash
hashCache := cache.NewHashCache(100) // 100 entries max
if cachedHash, cached := hashCache.Get(packageType, packageName, ""); cached {
log.Printf("[INFO] [agent] [installer] hash_cached package=%s hash=%s", packageName, cachedHash[:16]+"...")
}
// Gated ecosystems (dnf, apt) — mutation is through capability-token path only.
if packageType == "dnf" || packageType == "apt" {
return fmt.Errorf("[SECURITY] [agent] [installer] direct_mutation_refused type=%s — mutation must go through capability-token path", packageType)
}
// Non-gated ecosystems mutate directly. dnf/apt are excluded above and
// do not implement NonGatedInstaller, so the assertion fails for them.
mut, ok := inst.(installer.NonGatedInstaller)
if !ok {
return fmt.Errorf("[ERROR] [agent] [installer] direct_mutation_not_supported type=%s", packageType)
}
if packageName != "" {
action = "update"
log.Printf("[INFO] [agent] [installer] updating_package package=%s type=%s", packageName, packageType)
result, err = mut.UpdatePackage(packageName)
} else {
action = "upgrade"
log.Printf("[INFO] [agent] [installer] upgrading_all type=%s", packageType)
result, err = mut.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)
}
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeSuccess, models.SeverityInfo, packageType, packageName, commandID,
fmt.Sprintf("%s of %s completed in %ds", result.Action, packageName, duration))
log.Printf("[INFO] [agent] [installer] install_complete action=%s type=%s duration=%ds", action, packageType, duration)
return nil
}

View file

@ -0,0 +1,272 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/installer"
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
"github.com/gofrs/uuid/v5"
)
// Standalone local approval (FEAT-003). The full gate pipeline runs locally:
// dry-run closure resolve → hash pin → OSV best-effort → mint request → the
// privileged helper signs with the root-owned authority → the normal execute
// path verifies and installs. Fleet mode refuses: the server is the sole
// authority there, and this path never weakens that.
// Typed failures the local API maps to honest HTTP codes.
var (
// ErrApprovalFleetMode: this host is registered to a fleet server.
ErrApprovalFleetMode = errors.New("local approval refused: fleet mode — approve via the server")
// ErrApprovalBlocked: a gate refused and no override reason was supplied.
ErrApprovalBlocked = errors.New("approval blocked by supply-chain gate")
// ErrApprovalNoStandaloneIdentity: local authority was not provisioned.
ErrApprovalNoStandaloneIdentity = errors.New("local approval refused: standalone identity is not provisioned")
)
// LocalApproveRequest is RedFlag Desktop's approval submission.
type LocalApproveRequest struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
AvailableVersion string `json:"available_version"`
Operator string `json:"operator"`
OverrideReason string `json:"override_reason"`
}
// LocalApproveResult carries the gate verdicts plus the execution outcome so
// Desktop can render exactly what was checked and what happened.
type LocalApproveResult struct {
RequestID string `json:"request_id"`
OSVStatus string `json:"osv_status"`
OSVVulnCount int `json:"osv_vuln_count"`
ClosureSize int `json:"closure_size"`
Policy *supplychain.PolicyResult `json:"policy,omitempty"`
Receipt *capability.MutationReceipt `json:"receipt,omitempty"`
}
// gatedLocalApproval limits local approval to the ecosystems whose closures
// the agent can resolve and pin from signed repo metadata. Mirrors the
// capability-gate set, not the full installer set.
func gatedLocalApproval(packageType string) bool {
return packageType == "dnf" || packageType == "apt" || packageType == "pacman"
}
type pacmanExecutionLocation struct {
Kind string `json:"kind"`
Value string `json:"value"`
}
type pacmanActionPayload struct {
ArtifactSHA256 string `json:"artifact_sha256"`
ExecutionLocation pacmanExecutionLocation `json:"execution_location"`
Repository string `json:"repository"`
Requested bool `json:"requested"`
SignatureSHA256 string `json:"signature_sha256"`
SignatureLocation pacmanExecutionLocation `json:"signature_location"`
}
func handleLocalPacmanApproval(
ctx context.Context,
cfg *config.Config,
req LocalApproveRequest,
) (*LocalApproveResult, error) {
// OSV has no Arch ecosystem mapping in RedFlag. Preserve that as an
// unsupported gate, which requires the same recorded operator override as
// an outage. Refuse before network and artifact work when intent is absent.
osvStatus := supplychain.OSVStatusUnsupported
if req.OverrideReason == "" {
return nil, fmt.Errorf("%w: osv_status=%s — override requires an explicit reason",
ErrApprovalBlocked, osvStatus)
}
resolution, err := installer.ResolvePacmanClosure(req.PackageName, req.AvailableVersion)
if err != nil {
return nil, fmt.Errorf("resolve pacman closure: %w", err)
}
defer resolution.Cleanup()
resolvedAt := time.Now().UTC()
actions := make([]capability.ResolvedAction, 0, len(resolution.Artifacts))
evidence := make([]capability.Evidence, 0, len(resolution.Artifacts)*2)
for _, artifact := range resolution.Artifacts {
payload, err := json.Marshal(pacmanActionPayload{
ArtifactSHA256: artifact.ArchiveSHA256,
ExecutionLocation: pacmanExecutionLocation{Kind: "cache", Value: artifact.ArchivePath},
Repository: artifact.Repository,
Requested: artifact.Name == req.PackageName && (req.AvailableVersion == "" || artifact.Version == req.AvailableVersion),
SignatureSHA256: artifact.SignatureSHA256,
SignatureLocation: pacmanExecutionLocation{Kind: "cache", Value: artifact.SignaturePath},
})
if err != nil {
return nil, fmt.Errorf("encode pacman action %s: %w", artifact.Name, err)
}
actions = append(actions, capability.ResolvedAction{
Kind: "package", Identity: artifact.Name + "@" + artifact.Version, Payload: string(payload),
})
evidence = append(evidence,
capability.Evidence{Kind: "pacman-package-archive", Digest: artifact.ArchiveSHA256},
capability.Evidence{Kind: "pacman-package-signature", Digest: artifact.SignatureSHA256},
)
}
operationID, err := uuid.NewV4()
if err != nil {
return nil, fmt.Errorf("generate pacman operation id: %w", err)
}
manifest := capability.MutationManifest{
ProtocolVersion: capability.MutationProtocolVersion,
OperationID: operationID.String(),
TargetID: cfg.AgentID.String(),
Backend: "pacman",
Operation: "upgrade",
ResolvedActions: actions,
Evidence: evidence,
}
gateEvidence := supplychain.GateEvidence{
ResolvedAt: resolvedAt.Unix(),
OSVCheckedAt: 0,
OSVStatus: osvStatus,
OSVVulnCount: 0,
AgeGate: "not_applicable",
SoakGate: "not_applicable",
Operator: req.Operator,
OverrideReason: req.OverrideReason,
}
executor := supplychain.NewExecutor("")
envelope, requestID, err := executor.MintEnvelope(ctx, manifest, gateEvidence)
if err != nil {
return nil, err
}
receipt, err := executor.ExecuteEnvelope(ctx, envelope)
if err != nil {
return nil, fmt.Errorf("execute pacman envelope authorization_id=%s: %w",
envelope.Authorization.AuthorizationID, err)
}
log.Printf("[INFO] [agent] [localapprove] approval_completed pkg=%s decision=%s exit=%d authorization_id=%s",
req.PackageName, receipt.Decision, receipt.ExitCode, receipt.AuthorizationID)
return &LocalApproveResult{
RequestID: requestID,
OSVStatus: osvStatus,
OSVVulnCount: 0,
ClosureSize: len(resolution.Artifacts),
Receipt: receipt,
}, nil
}
// HandleLocalApprove runs the standalone approval flow end to end. Synchronous:
// the caller holds the local socket connection until the helper's verdict (and,
// on success, the install) completes.
func HandleLocalApprove(ctx context.Context, cfg *config.Config, req LocalApproveRequest) (*LocalApproveResult, error) {
if cfg.IsRegistered() {
return nil, ErrApprovalFleetMode
}
if !cfg.IsStandalone() {
return nil, ErrApprovalNoStandaloneIdentity
}
if req.PackageType == "" || req.PackageName == "" {
return nil, fmt.Errorf("package_type and package_name are required")
}
if !gatedLocalApproval(req.PackageType) {
return nil, fmt.Errorf("local approval not supported for package_type=%s (dnf|apt|pacman only)", req.PackageType)
}
if req.Operator == "" {
return nil, fmt.Errorf("operator is required")
}
log.Printf("[INFO] [agent] [localapprove] approval_started pkg=%s type=%s version=%s operator=%s",
req.PackageName, req.PackageType, req.AvailableVersion, req.Operator)
if req.PackageType == "pacman" {
return handleLocalPacmanApproval(ctx, cfg, req)
}
// Resolve the closure exactly as the fleet dry-run path does.
inst, err := installer.InstallerFactory(req.PackageType, cfg.ServerURL)
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", req.PackageType, err)
}
if !inst.IsAvailable() {
return nil, fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", req.PackageType)
}
dryRun, err := inst.DryRun(req.PackageName, req.AvailableVersion)
if err != nil {
return nil, fmt.Errorf("dry run failed: %w", err)
}
resolvedAt := time.Now().UTC()
closureItems := resolveClosureHashes(req.PackageType, req.PackageName, req.AvailableVersion, dryRun.Dependencies)
if len(closureItems) == 0 {
// No pin, no mint. The execute path could not verify anything.
return nil, fmt.Errorf("closure hash resolution failed for %s — refusing unpinned approval", req.PackageName)
}
closure := make([]capability.ClosureEntry, len(closureItems))
pkgs := make([]supplychain.PkgVersion, len(closureItems))
for i, c := range closureItems {
closure[i] = capability.ClosureEntry{
Name: c.Name,
Version: c.Version,
SHA256: c.SHA256,
Source: c.Source,
}
pkgs[i] = supplychain.PkgVersion{Name: c.Name, Version: c.Version}
}
// OSV best-effort with honest verdict: vulnerable or unreachable proceeds
// only over an explicit operator reason, refused here before the helper is
// ever invoked (the helper re-enforces — defense in depth).
osvStatus, vulnCount := supplychain.CheckClosureOSV(ctx, req.PackageType, pkgs)
osvCheckedAt := time.Now().UTC()
if osvStatus != supplychain.OSVStatusClear && req.OverrideReason == "" {
log.Printf("[SECURITY] [agent] [localapprove] approval_blocked pkg=%s osv_status=%s vulns=%d",
req.PackageName, osvStatus, vulnCount)
return nil, fmt.Errorf("%w: osv_status=%s vulns=%d — override requires an explicit reason",
ErrApprovalBlocked, osvStatus, vulnCount)
}
mintReq := &supplychain.MintRequest{
AgentID: cfg.AgentID.String(),
PackageType: req.PackageType,
Operation: "install",
Closure: closure,
GateEvidence: supplychain.GateEvidence{
ResolvedAt: resolvedAt.Unix(),
OSVCheckedAt: osvCheckedAt.Unix(),
OSVStatus: osvStatus,
OSVVulnCount: vulnCount,
// Standalone has no registry age data for dnf/apt (matches the
// fleet age gate's ecosystem scope) and no local version
// first-seen tracking yet — journaled honestly as not_applicable.
AgeGate: "not_applicable",
SoakGate: "not_applicable",
Operator: req.Operator,
OverrideReason: req.OverrideReason,
},
}
executor := supplychain.NewExecutor("")
token, err := executor.Mint(ctx, mintReq)
if err != nil {
return nil, err
}
policy, err := executor.Execute(ctx, token)
if err != nil {
return nil, fmt.Errorf("execute after mint failed (token_id=%s): %w", token.TokenID, err)
}
log.Printf("[INFO] [agent] [localapprove] approval_completed pkg=%s decision=%s exit=%d",
req.PackageName, policy.Decision, policy.ExitCode)
return &LocalApproveResult{
RequestID: mintReq.RequestID,
OSVStatus: osvStatus,
OSVVulnCount: vulnCount,
ClosureSize: len(closure),
Policy: policy,
}, nil
}

View file

@ -0,0 +1,49 @@
package handlers
import (
"context"
"errors"
"testing"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/gofrs/uuid/v5"
)
func TestGatedLocalApprovalEcosystems(t *testing.T) {
for _, packageType := range []string{"apt", "dnf", "pacman"} {
if !gatedLocalApproval(packageType) {
t.Errorf("%s is not routed through local authority", packageType)
}
}
for _, packageType := range []string{"docker", "winget", "windows_update", "cargo"} {
if gatedLocalApproval(packageType) {
t.Errorf("%s entered local authority without a migrated backend", packageType)
}
}
}
func TestLocalApprovalRefusesFleetModeBeforeResolution(t *testing.T) {
id, err := uuid.NewV4()
if err != nil {
t.Fatal(err)
}
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id, Token: "fleet"}, LocalApproveRequest{
PackageType: "pacman", PackageName: "linux", Operator: "operator", OverrideReason: "accepted",
})
if !errors.Is(err, ErrApprovalFleetMode) {
t.Fatalf("fleet approval error = %v", err)
}
}
func TestPacmanUnsupportedOSVRequiresReasonBeforeResolution(t *testing.T) {
id, err := uuid.NewV4()
if err != nil {
t.Fatal(err)
}
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id}, LocalApproveRequest{
PackageType: "pacman", PackageName: "not-a-real-package", Operator: "operator",
})
if !errors.Is(err, ErrApprovalBlocked) {
t.Fatalf("pacman approval error = %v", err)
}
}

View file

@ -0,0 +1,60 @@
package handlers
import (
"log"
"github.com/Fimeg/RedFlag/agent/internal/cache"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
)
func persistLocalState(cfg *config.Config, update func(*cache.LocalCache)) {
localCache, err := cache.Load()
if err != nil {
log.Printf("[WARNING] [agent] [local_state] load_failed error=%v", err)
localCache = &cache.LocalCache{}
}
applyLocalIdentity(localCache, cfg)
if update != nil {
update(localCache)
}
if err := localCache.Save(); err != nil {
log.Printf("[WARNING] [agent] [local_state] save_failed error=%v", err)
}
}
func applyLocalIdentity(localCache *cache.LocalCache, cfg *config.Config) {
if localCache == nil || cfg == nil || !cfg.IsRegistered() {
return
}
localCache.SetAgentInfo(cfg.AgentID, cfg.ServerURL)
}
func recordLocalFullScan(cfg *config.Config, updates []client.UpdateReportItem, results []orchestrator.ScanResult) {
persistLocalState(cfg, func(localCache *cache.LocalCache) {
localCache.SetAgentStatus("online")
localCache.UpdateScanResults(updates)
for _, result := range results {
localCache.RecordScannerResult(result.ScannerName, result.Status, result.Updates, result.Error, result.Duration, false)
}
})
}
func recordLocalScanResult(cfg *config.Config, result orchestrator.ScanResult, affectsUpdateList bool) {
persistLocalState(cfg, func(localCache *cache.LocalCache) {
localCache.SetAgentStatus("online")
localCache.RecordScannerResult(result.ScannerName, result.Status, result.Updates, result.Error, result.Duration, affectsUpdateList)
})
}
func recordLocalScanResults(cfg *config.Config, results []orchestrator.ScanResult, affectsUpdateList bool) {
persistLocalState(cfg, func(localCache *cache.LocalCache) {
localCache.SetAgentStatus("online")
for _, result := range results {
localCache.RecordScannerResult(result.ScannerName, result.Status, result.Updates, result.Error, result.Duration, affectsUpdateList)
}
})
}

View file

@ -0,0 +1,84 @@
package handlers
import (
"context"
"fmt"
"log"
"runtime"
"strings"
"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/orchestrator"
"github.com/Fimeg/RedFlag/agent/internal/scanner"
)
// HandleLocalTriggeredScan runs the full package-update scan on behalf of the
// local API write path (FEAT-002). There is no signed server command behind
// it, so no command ID exists and nothing is ack-tracked.
//
// Registered agents run the same HandleScanUpdates path a server scan command
// uses — results are reported so the server ingests and reconciles them
// (RECONCILE-001 close-by-absence included). Unregistered (standalone) agents
// run the scanners through the orchestrator and record the local read model
// only; no doomed HTTP calls are attempted.
func HandleLocalTriggeredScan(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, source string) error {
log.Printf("[INFO] [agent] [localapi] local_scan_started source=%s registered=%v", source, cfg.IsRegistered())
if cfg.IsRegistered() {
return HandleScanUpdates(apiClient, cfg, ackTracker, orch, "")
}
return runStandaloneUpdateScan(cfg, orch)
}
// runStandaloneUpdateScan mirrors HandleScanUpdates's scanner set (the virtual
// "updates" subsystem) without any server reporting: orchestrator-managed
// scans (circuit breakers, timeouts) feeding the local read model.
func runStandaloneUpdateScan(cfg *config.Config, orch *orchestrator.Orchestrator) error {
ctx := context.Background()
var results []orchestrator.ScanResult
var errs []string
type updateScanner struct {
name string
available func() bool
}
var candidates []updateScanner
switch runtime.GOOS {
case "linux":
candidates = []updateScanner{
{"apt", scanner.NewAPTScanner().IsAvailable},
{"dnf", scanner.NewDNFScanner().IsAvailable},
{"pacman", scanner.NewPacmanScanner().IsAvailable},
}
case "windows":
candidates = []updateScanner{
{"windows", scanner.NewWindowsUpdateScanner().IsAvailable},
{"winget", scanner.NewWingetScanner().IsAvailable},
}
}
ran := 0
for _, cand := range candidates {
if !cand.available() {
continue
}
ran++
result, err := orch.ScanSingle(ctx, cand.name)
results = append(results, result)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", cand.name, err))
}
}
recordLocalScanResults(cfg, results, true)
if len(errs) > 0 {
err := fmt.Errorf("standalone scan errors: %s", strings.Join(errs, "; "))
log.Printf("[ERROR] [agent] [localapi] standalone_scan_failed scanners_run=%d error=%v", ran, err)
return err
}
log.Printf("[INFO] [agent] [localapi] standalone_scan_completed scanners_run=%d", ran)
return nil
}

View file

@ -0,0 +1,62 @@
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/system"
)
// HandleScanProcesses performs a full on-demand process scan and reports
// the results to the server. Triggered by the "scan_processes" command
// (issued when a user opens the Processes tab in the dashboard).
func HandleScanProcesses(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string) error {
log.Printf("[INFO] [agent] [processes] scan_started command_id=%s", commandID)
startTime := time.Now()
snapshot, err := system.GetFullProcessSnapshot()
duration := time.Since(startTime)
if err != nil {
log.Printf("[ERROR] [agent] [processes] scan_failed command_id=%s duration=%s error=%v", commandID, duration, err)
return fmt.Errorf("process scan failed: %w", err)
}
log.Printf("[INFO] [agent] [processes] scan_complete command_id=%s process_count=%d duration=%s", commandID, snapshot.ProcessCount, duration)
// Report to server
report := client.ProcessScanReport{
AgentID: cfg.AgentID,
CommandID: commandID,
Timestamp: time.Now().UTC(),
Snapshot: *snapshot,
}
if err := apiClient.ReportProcessScan(cfg.AgentID, report); err != nil {
log.Printf("[ERROR] [agent] [processes] report_failed command_id=%s error=%v", commandID, err)
return fmt.Errorf("failed to report process scan: %w", err)
}
// Audit log
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_processes",
Result: "success",
ExitCode: 0,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "Processes",
"subsystem": "processes",
"process_count": fmt.Sprintf("%d", snapshot.ProcessCount),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [processes] report_log_failed: %v", err)
}
return nil
}

View file

@ -0,0 +1,81 @@
package handlers
import (
"fmt"
"log"
"os/exec"
"runtime"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
)
// HandleReboot schedules a system reboot in response to a server-issued
// `reboot` command. Ported from the pre-TD-001 main.go (commit 9da5134e^)
// where it was lost in the god-function split. The server-side endpoint
// (POST /api/v1/agents/:id/reboot) creates the command; without this
// handler the agent silently drops it in dispatch's default case.
func HandleReboot(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string, params map[string]interface{}) error {
delayMinutes := 1
message := "System reboot requested by RedFlag"
if delay, ok := params["delay_minutes"]; ok {
if delayFloat, ok := delay.(float64); ok {
delayMinutes = int(delayFloat)
}
}
if msg, ok := params["message"].(string); ok && msg != "" {
message = msg
}
log.Printf("[INFO] [agent] [reboot] scheduled delay_minutes=%d message=%q", delayMinutes, message)
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
cmd = exec.Command("shutdown", "-r", fmt.Sprintf("+%d", delayMinutes), message)
case "windows":
delaySeconds := delayMinutes * 60
cmd = exec.Command("shutdown", "/r", "/t", fmt.Sprintf("%d", delaySeconds), "/c", message)
default:
err := fmt.Errorf("reboot not supported on platform: %s", runtime.GOOS)
log.Printf("[ERROR] [agent] [reboot] unsupported_platform os=%s", runtime.GOOS)
ReportLogWithAck(apiClient, cfg, ackTracker, client.LogReport{
CommandID: commandID,
Action: "reboot",
Result: "failed",
Stderr: err.Error(),
ExitCode: 1,
})
return err
}
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("[ERROR] [agent] [reboot] schedule_failed error=%v output=%q", err, string(output))
ReportLogWithAck(apiClient, cfg, ackTracker, client.LogReport{
CommandID: commandID,
Action: "reboot",
Result: "failed",
Stdout: string(output),
Stderr: err.Error(),
ExitCode: 1,
})
return err
}
log.Printf("[INFO] [agent] [reboot] scheduled_ok delay_minutes=%d", delayMinutes)
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, client.LogReport{
CommandID: commandID,
Action: "reboot",
Result: "success",
Stdout: fmt.Sprintf("System reboot scheduled for %d minute(s) from now. Message: %s", delayMinutes, message),
ExitCode: 0,
}); reportErr != nil {
log.Printf("[ERROR] [agent] [reboot] report_failed error=%v", reportErr)
}
return nil
}

View file

@ -0,0 +1,742 @@
package handlers
import (
"context"
"fmt"
"log"
"runtime"
"strings"
"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/models"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
"github.com/Fimeg/RedFlag/agent/internal/scanner"
)
// mapAny extracts a typed value from map[string]interface{} with zero fallback.
func mapAny[T any](v interface{}) T {
r, _ := v.(T)
return r
}
// reportLogWithAck reports a command log to the server and tracks it for acknowledgment.
// A log with no command ID (locally triggered scan — no signed server command behind it)
// is reported without ack tracking: there is no command completion to guarantee.
func reportLogWithAck(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, logReport client.LogReport) error {
if logReport.CommandID == "" {
return apiClient.ReportLog(cfg.AgentID, logReport)
}
// Track this command result as pending acknowledgment
ackTracker.Add(logReport.CommandID)
// Save acknowledgment state immediately
if err := ackTracker.Save(); err != nil {
log.Printf("Warning: Failed to save acknowledgment for command %s: %v", logReport.CommandID, err)
}
// Report the log to the server
if err := apiClient.ReportLog(cfg.AgentID, logReport); err != nil {
// If reporting failed, increment retry count but don't remove from pending
ackTracker.IncrementRetry(logReport.CommandID)
return err
}
return nil
}
// HandleScanUpdates scans for ALL package updates across all available package managers
// This is the virtual "updates" subsystem that triggers apt, dnf, winget, and windows scans
func HandleScanUpdates(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning for all package updates...")
ctx := context.Background()
startTime := time.Now()
var totalUpdates int
var scanResults []string
var localResults []orchestrator.ScanResult
var errors []string
// Detect OS and run appropriate scanners
osType := runtime.GOOS
// Linux: Try APT first, then DNF
if osType == "linux" {
// Try APT
aptScanner := scanner.NewAPTScanner()
if aptScanner.IsAvailable() {
log.Println("[updates] Running APT scan...")
result, err := orch.ScanSingle(ctx, "apt")
localResults = append(localResults, result)
if err != nil {
errors = append(errors, fmt.Sprintf("APT: %v", err))
} else {
scanResults = append(scanResults, fmt.Sprintf("APT: %d updates", len(result.Updates)))
totalUpdates += len(result.Updates)
// RECONCILE-001: always report on a successful scan, even 0 updates,
// so the server can close rows absent from this scan.
if result.Status == "success" {
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: result.Updates,
Ecosystem: "apt",
ScanSucceeded: true,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [agent] [updates] apt_report_failed error=%v", err)
}
}
}
}
// Try DNF
dnfScanner := scanner.NewDNFScanner()
if dnfScanner.IsAvailable() {
log.Println("[updates] Running DNF scan...")
result, err := orch.ScanSingle(ctx, "dnf")
localResults = append(localResults, result)
if err != nil {
errors = append(errors, fmt.Sprintf("DNF: %v", err))
} else {
scanResults = append(scanResults, fmt.Sprintf("DNF: %d updates", len(result.Updates)))
totalUpdates += len(result.Updates)
// RECONCILE-001: always report on a successful scan, even 0 updates,
// so the server can close rows absent from this scan.
if result.Status == "success" {
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: result.Updates,
Ecosystem: "dnf",
ScanSucceeded: true,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [agent] [updates] dnf_report_failed error=%v", err)
}
}
}
}
}
// Windows: Try Windows Update and Winget
if osType == "windows" {
// Try Windows Update
windowsScanner := scanner.NewWindowsUpdateScanner()
if windowsScanner.IsAvailable() {
log.Println("[updates] Running Windows Update scan...")
result, err := orch.ScanSingle(ctx, "windows")
localResults = append(localResults, result)
if err != nil {
errors = append(errors, fmt.Sprintf("Windows: %v", err))
} else {
scanResults = append(scanResults, fmt.Sprintf("Windows: %d updates", len(result.Updates)))
totalUpdates += len(result.Updates)
// Report Windows updates
if len(result.Updates) > 0 {
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: result.Updates,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [updates] Failed to report Windows updates: %v", err)
}
}
}
}
// Try Winget
wingetScanner := scanner.NewWingetScanner()
if wingetScanner.IsAvailable() {
log.Println("[updates] Running Winget scan...")
result, err := orch.ScanSingle(ctx, "winget")
localResults = append(localResults, result)
if err != nil {
errors = append(errors, fmt.Sprintf("Winget: %v", err))
} else {
scanResults = append(scanResults, fmt.Sprintf("Winget: %d updates", len(result.Updates)))
totalUpdates += len(result.Updates)
// Report Winget updates
if len(result.Updates) > 0 {
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: result.Updates,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [updates] Failed to report Winget updates: %v", err)
}
}
}
}
}
recordLocalScanResults(cfg, localResults, true)
duration := time.Since(startTime)
stdout := fmt.Sprintf("Package update scan completed in %.2f seconds\n\nResults:\n%s\n\nTotal updates found: %d",
duration.Seconds(),
strings.Join(scanResults, "\n"),
totalUpdates)
stderr := ""
exitCode := 0
if len(errors) > 0 {
stderr = fmt.Sprintf("Errors encountered:\n%s", strings.Join(errors, "\n"))
exitCode = 1
}
// Create history entry
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_updates",
Result: map[bool]string{true: "success", false: "partial_failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "Package Updates",
"subsystem": "updates",
"total_updates": fmt.Sprintf("%d", totalUpdates),
"scanners_run": fmt.Sprintf("%d", len(scanResults)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [updates] report_log_failed: %v", err)
} else {
log.Printf("[INFO] [agent] [updates] scan completed: %d updates found", totalUpdates)
}
return nil
}
// HandleScanStorage scans disk usage metrics only
func HandleScanStorage(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning storage...")
ctx := context.Background()
startTime := time.Now()
result, err := orch.ScanSingle(ctx, "storage")
if err != nil {
return fmt.Errorf("failed to scan storage: %w", err)
}
recordLocalScanResult(cfg, result, false)
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
duration := time.Since(startTime)
stdout += fmt.Sprintf("\nStorage scan completed in %.2f seconds\n", duration.Seconds())
// Report storage metrics to server using dedicated endpoint
if len(result.Updates) > 0 {
metricItems := make([]models.StorageMetric, 0, len(result.Updates))
for _, u := range result.Updates {
m := u.Metadata
metricItems = append(metricItems, models.StorageMetric{
Mountpoint: u.PackageName,
Device: u.RepositorySource,
DiskType: mapAny[string](m["disk_type"]),
Filesystem: mapAny[string](m["filesystem"]),
TotalBytes: mapAny[int64](m["total_bytes"]),
UsedBytes: mapAny[int64](m["used_bytes"]),
AvailableBytes: mapAny[int64](m["available_bytes"]),
UsedPercent: mapAny[float64](m["used_percent"]),
IsRoot: mapAny[bool](m["is_root"]),
IsLargest: mapAny[bool](m["is_largest"]),
Severity: u.Severity,
})
}
report := models.StorageMetricReport{
AgentID: cfg.AgentID,
CommandID: commandID,
Timestamp: time.Now().UTC(),
Metrics: metricItems,
}
if err := apiClient.ReportStorageMetrics(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report storage metrics: %w", err)
}
log.Printf("[INFO] [storage] Successfully reported %d storage metrics to server\n", len(result.Updates))
}
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_storage",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "Disk Usage",
"subsystem": "storage",
"metrics_count": fmt.Sprintf("%d", len(result.Updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [storage] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [storage] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [storage] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_storage] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}
// HandleScanSystem scans system metrics (CPU, memory, processes, uptime)
func HandleScanSystem(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning system metrics...")
ctx := context.Background()
startTime := time.Now()
result, err := orch.ScanSingle(ctx, "system")
if err != nil {
return fmt.Errorf("failed to scan system: %w", err)
}
recordLocalScanResult(cfg, result, false)
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
duration := time.Since(startTime)
stdout += fmt.Sprintf("\nSystem scan completed in %.2f seconds\n", duration.Seconds())
// Report system metrics to server using dedicated endpoint
if len(result.Updates) > 0 {
metricItems := make([]client.MetricsReportItem, 0, len(result.Updates))
for _, u := range result.Updates {
metricItems = append(metricItems, client.MetricsReportItem{
PackageType: u.PackageType,
PackageName: u.PackageName,
CurrentVersion: u.CurrentVersion,
AvailableVersion: u.AvailableVersion,
Severity: u.Severity,
RepositorySource: u.RepositorySource,
Metadata: u.Metadata,
})
}
report := client.MetricsReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Metrics: metricItems,
}
if err := apiClient.ReportMetrics(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report system metrics: %w", err)
}
log.Printf("[INFO] [agent] [system] Reported %d system metrics to server\n", len(result.Updates))
}
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_system",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "System Metrics",
"subsystem": "system",
"metrics_count": fmt.Sprintf("%d", len(result.Updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [system] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [system] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [system] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_system] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}
// HandleScanDocker scans Docker image inventory and container enrichment data.
// Uses the InventoryScanner path for image inventory, and the existing
// DockerReport path for container/stack/engine enrichment.
func HandleScanDocker(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning Docker images...")
ctx := context.Background()
startTime := time.Now()
// Use inventory scanner path for image data
invResult, err := orch.ScanInventorySingle(ctx, "docker")
if err != nil {
return fmt.Errorf("failed to scan Docker inventory: %w", err)
}
duration := time.Since(startTime)
// Report inventory items to the new inventory endpoint
if len(invResult.Items) > 0 {
invReport := client.InventoryReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Ecosystem: "docker",
Items: invResult.Items,
ScanSucceeded: invResult.Status == "success",
}
if err := apiClient.ReportInventory(cfg.AgentID, invReport); err != nil {
log.Printf("[WARNING] [agent] [docker] report_inventory_failed error=%v", err)
} else {
log.Printf("[INFO] [agent] [docker] Reported %d Docker images to inventory", len(invResult.Items))
}
}
// Collect container/stack enrichment data (existing DockerReport path)
updateCount := 0
for _, item := range invResult.Items {
if hasUpdate, ok := item.Metadata["has_update"].(bool); ok && hasUpdate {
updateCount++
}
}
dockerScanner, err := orchestrator.NewDockerScanner()
if err != nil {
log.Printf("[WARN] [agent] [docker] could not create scanner for enrichment: %v", err)
} else {
defer dockerScanner.Close()
containers, err := dockerScanner.ScanContainers()
if err != nil {
log.Printf("[WARN] [agent] [docker] container scan failed: %v", err)
} else {
report := client.DockerReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Containers: containers,
Stacks: dockerScanner.ScanStacks(containers),
EngineVersion: dockerScanner.GetEngineVersion(),
}
if err := apiClient.ReportDockerImages(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [agent] [docker] report_enrichment_failed error=%v", err)
} else {
log.Printf("[INFO] [agent] [docker] Reported %d containers, %d stacks for enrichment",
len(report.Containers), len(report.Stacks))
}
}
}
// Build log report
stdout := fmt.Sprintf("Docker inventory scan completed in %.2f seconds\n\nItems found: %d\nUpdates available: %d",
duration.Seconds(), len(invResult.Items), updateCount)
stderr := ""
exitCode := 0
if invResult.Status == "failed" {
stderr = fmt.Sprintf("Error: %v", invResult.Error)
exitCode = 1
}
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_docker",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "Docker Images",
"subsystem": "docker",
"images_count": fmt.Sprintf("%d", len(invResult.Items)),
"updates_found": fmt.Sprintf("%d", updateCount),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [docker] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [docker] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [docker] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_docker] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}
// HandleScanAPT scans APT package updates only
func HandleScanAPT(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning APT packages...")
ctx := context.Background()
startTime := time.Now()
// Execute APT scanner
result, err := orch.ScanSingle(ctx, "apt")
if err != nil {
return fmt.Errorf("failed to scan APT: %w", err)
}
recordLocalScanResult(cfg, result, true)
// Format results
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
duration := time.Since(startTime)
stdout += fmt.Sprintf("\nAPT scan completed in %.2f seconds\n", duration.Seconds())
// Report APT updates to server.
// RECONCILE-001: always report on a successful scan, even when 0 updates are
// found. An empty reported set + ScanSucceeded=true tells the server this
// ecosystem is fully patched — it must close all tracked non-resting rows.
// Declare updates at function scope for ReportLog access.
var updates []client.UpdateReportItem
if result.Status == "success" {
updates = result.Updates
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: updates,
Ecosystem: "apt",
ScanSucceeded: true,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [agent] [apt] report_updates_failed error=%v", err)
// Do not return error here — the scan succeeded locally; the report failure
// is a transport problem. ReportLog below will still record the scan.
} else {
log.Printf("[INFO] [agent] [apt] reported %d APT updates to server", len(updates))
}
}
// Create history entry for unified view with proper formatting
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_apt",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "APT Packages",
"subsystem": "apt",
"updates_found": fmt.Sprintf("%d", len(updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [apt] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [apt] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [apt] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_apt] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}
// HandleScanDNF scans DNF package updates only
func HandleScanDNF(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning DNF packages...")
ctx := context.Background()
startTime := time.Now()
// Execute DNF scanner
result, err := orch.ScanSingle(ctx, "dnf")
if err != nil {
return fmt.Errorf("failed to scan DNF: %w", err)
}
recordLocalScanResult(cfg, result, true)
// Format results
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
duration := time.Since(startTime)
stdout += fmt.Sprintf("\nDNF scan completed in %.2f seconds\n", duration.Seconds())
// Report DNF updates to server.
// RECONCILE-001: always report on a successful scan, even when 0 updates are
// found. An empty reported set + ScanSucceeded=true tells the server this
// ecosystem is fully patched — it must close all tracked non-resting rows.
// Declare updates at function scope for ReportLog access.
var updates []client.UpdateReportItem
if result.Status == "success" {
updates = result.Updates
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: updates,
Ecosystem: "dnf",
ScanSucceeded: true,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [agent] [dnf] report_updates_failed error=%v", err)
// Do not return error here — the scan succeeded locally; the report failure
// is a transport problem. ReportLog below will still record the scan.
} else {
log.Printf("[INFO] [agent] [dnf] reported %d DNF updates to server", len(updates))
}
}
// Create history entry for unified view with proper formatting
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_dnf",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "DNF Packages",
"subsystem": "dnf",
"updates_found": fmt.Sprintf("%d", len(updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [dnf] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [dnf] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [dnf] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_dnf] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}
// HandleScanWindows scans Windows Updates only
func HandleScanWindows(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning Windows Updates...")
ctx := context.Background()
startTime := time.Now()
// Execute Windows Update scanner
result, err := orch.ScanSingle(ctx, "windows")
if err != nil {
return fmt.Errorf("failed to scan Windows Updates: %w", err)
}
recordLocalScanResult(cfg, result, true)
// Format results
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
duration := time.Since(startTime)
stdout += fmt.Sprintf("\nWindows Update scan completed in %.2f seconds\n", duration.Seconds())
// Report Windows updates to server if any were found
// Declare updates at function scope for ReportLog access
var updates []client.UpdateReportItem
if result.Status == "success" && len(result.Updates) > 0 {
updates = result.Updates
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: updates,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report Windows updates: %w", err)
}
log.Printf("[INFO] [agent] [windows] Successfully reported %d Windows updates to server\n", len(updates))
}
// Create history entry for unified view with proper formatting
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_windows",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "Windows Updates",
"subsystem": "windows",
"updates_found": fmt.Sprintf("%d", len(updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [windows] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [windows] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [windows] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_windows] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}
// HandleScanWinget scans Winget package updates only
func HandleScanWinget(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning Winget packages...")
ctx := context.Background()
startTime := time.Now()
// Execute Winget scanner
result, err := orch.ScanSingle(ctx, "winget")
if err != nil {
return fmt.Errorf("failed to scan Winget: %w", err)
}
recordLocalScanResult(cfg, result, true)
// Format results
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
duration := time.Since(startTime)
stdout += fmt.Sprintf("\nWinget scan completed in %.2f seconds\n", duration.Seconds())
// Report Winget updates to server if any were found
// Declare updates at function scope for ReportLog access
var updates []client.UpdateReportItem
if result.Status == "success" && len(result.Updates) > 0 {
updates = result.Updates
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: updates,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report Winget updates: %w", err)
}
log.Printf("[INFO] [agent] [winget] Successfully reported %d Winget updates to server\n", len(updates))
}
// Create history entry for unified view with proper formatting
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_winget",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "Winget Packages",
"subsystem": "winget",
"updates_found": fmt.Sprintf("%d", len(updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [winget] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [winget] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [winget] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_winget] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}

View file

@ -0,0 +1,381 @@
package handlers
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
)
// HandleCaptureScreenshot captures the current display output and reports it
// back as a base64-encoded PNG in the command result's stdout field.
// Observe-only: reads the screen, does not interact with it.
//
// Linux: uses scrot (X11) or import (ImageMagick) as fallback.
// Windows: uses PowerShell with .NET System.Drawing.
// The temp file is cleaned up after encoding.
func HandleCaptureScreenshot(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string) error {
start := time.Now()
tmpPath := filepath.Join(os.TempDir(), fmt.Sprintf("redflag_screenshot_%d.png", time.Now().UnixNano()))
defer os.Remove(tmpPath)
diag, err := captureScreen(tmpPath)
if err != nil {
// Report detailed diagnostics to the server instead of a generic message.
diagJSON, _ := json.Marshal(diag)
logReport := client.LogReport{
CommandID: commandID,
Action: "capture_screenshot",
Result: "failed",
Stderr: fmt.Sprintf("screen capture failed: %v", err),
ExitCode: 1,
Metadata: map[string]string{
"diagnostics": string(diagJSON),
},
DurationSeconds: int(time.Since(start).Seconds()),
}
_ = ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
return fmt.Errorf("screen capture failed: %w", err)
}
data, err := os.ReadFile(tmpPath)
if err != nil {
return fmt.Errorf("failed to read screenshot: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(data)
logReport := client.LogReport{
CommandID: commandID,
Action: "capture_screenshot",
Result: "success",
Stdout: encoded,
ExitCode: 0,
DurationSeconds: int(time.Since(start).Seconds()),
}
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
return fmt.Errorf("failed to report screenshot: %w", err)
}
log.Printf("[INFO] [agent] [screenshot] captured size_bytes=%d duration=%s",
len(data), time.Since(start).Round(time.Millisecond))
return nil
}
// captureScreen writes a PNG screenshot to outputPath. Platform-specific.
// Returns diagnostics for health reporting on failure.
func captureScreen(outputPath string) (*screenshotDiagnostics, error) {
switch runtime.GOOS {
case "linux":
return captureScreenLinux(outputPath)
case "windows":
if err := captureScreenWindows(outputPath); err != nil {
return &screenshotDiagnostics{SessionType: "windows"}, err
}
return &screenshotDiagnostics{SessionType: "windows"}, nil
default:
return &screenshotDiagnostics{}, fmt.Errorf("screenshot not supported on %s", runtime.GOOS)
}
}
// sessionDisplayInfo holds discovered display environment variables and the
// detected session type (x11, wayland, or unknown).
type sessionDisplayInfo struct {
env []string
sessionType string // "x11", "wayland", or ""
sourcePID int // pid whose environ provided the vars (0 = none)
}
// toolAttempt records one screenshot tool invocation for diagnostic reporting.
type toolAttempt struct {
Name string `json:"name"`
Found bool `json:"found"` // in PATH
Tried bool `json:"tried"` // actually executed
ExitCode int `json:"exit_code,omitempty"` // 0 = success, -1 = not tried
Stderr string `json:"stderr,omitempty"` // first 200 chars of output
}
// screenshotDiagnostics captures the full picture for the server when a
// screenshot fails. The generic "no tool found" message hid the real cause;
// this exposes it as structured health data.
type screenshotDiagnostics struct {
SessionType string `json:"session_type"`
SessionFound bool `json:"session_found"`
DisplayVars int `json:"display_vars"`
SessionPID int `json:"session_pid,omitempty"`
Tools []toolAttempt `json:"tools"`
CapSysPtrace bool `json:"cap_sys_ptrace"`
ProcReadable bool `json:"proc_readable"`
}
// captureScreenLinux captures the display. The agent service does not inherit
// DISPLAY/WAYLAND_DISPLAY from systemd, so we discover them from the running
// user session before invoking any capture tool.
//
// Tool priority depends on session type:
//
// X11: scrot → magick import → import (ImageMagick v6)
// Wayland: grim (wlroots) → gnome-screenshot (GNOME) → spectacle (KDE)
// → magick import (fallback, needs root on some compositors)
// Unknown: try all tools in order
func captureScreenLinux(outputPath string) (*screenshotDiagnostics, error) {
info := discoverSessionDisplay()
env := append(os.Environ(), info.env...)
diag := &screenshotDiagnostics{
SessionType: info.sessionType,
SessionFound: info.sessionType != "",
DisplayVars: len(info.env),
SessionPID: info.sourcePID,
CapSysPtrace: hasCapSysPtrace(),
ProcReadable: info.sourcePID != 0,
}
log.Printf("[INFO] [agent] [screenshot] session_type=%s display_vars=%d session_pid=%d cap_sys_ptrace=%v",
orDefault(info.sessionType, "unknown"), len(info.env), info.sourcePID, diag.CapSysPtrace)
type cmdFunc func(string, ...string) bool
var tryCmd cmdFunc
tryCmd = func(name string, args ...string) bool {
_, lookErr := exec.LookPath(name)
attempt := toolAttempt{Name: name, Found: lookErr == nil, ExitCode: -1}
if lookErr != nil {
diag.Tools = append(diag.Tools, attempt)
return false
}
attempt.Tried = true
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = env
if out, err := cmd.CombinedOutput(); err != nil {
attempt.Stderr = truncate(string(out), 200)
if exitErr, ok := err.(*exec.ExitError); ok {
attempt.ExitCode = exitErr.ExitCode()
}
log.Printf("[WARN] [agent] [screenshot] %s_failed exit=%d output=%q error=%v", name, attempt.ExitCode, string(out), err)
diag.Tools = append(diag.Tools, attempt)
return false
}
attempt.ExitCode = 0
diag.Tools = append(diag.Tools, attempt)
return true
}
var err error
switch info.sessionType {
case "wayland":
err = captureWayland(outputPath, env, tryCmd)
case "x11":
err = captureX11(outputPath, tryCmd)
default:
err = captureFallback(outputPath, tryCmd)
}
if err != nil {
return diag, err
}
return diag, nil
}
// captureX11 tries X11 screenshot tools in priority order.
func captureX11(outputPath string, tryCmd func(string, ...string) bool) error {
if tryCmd("scrot", "-o", outputPath) {
return nil
}
// ImageMagick v7: standalone `import` is gone, use `magick import`.
if tryCmd("magick", "import", "-window", "root", outputPath) {
return nil
}
// ImageMagick v6 compat.
if tryCmd("import", "-window", "root", outputPath) {
return nil
}
return fmt.Errorf("no X11 screenshot tool found (tried scrot, magick import)")
}
// captureWayland tries Wayland screenshot tools. Tool availability depends on
// the compositor:
// - wlroots (Sway, Hyprland, River): grim
// - GNOME (Mutter): gnome-screenshot or D-Bus org.gnome.Shell.Screenshot
// - KDE (KWin): spectacle --background --output
// - Fallback: magick import (may work under XWayland or as root)
func captureWayland(outputPath string, env []string, tryCmd func(string, ...string) bool) error {
// wlroots compositors — grim speaks wlr-screencopy / ext-image-capture-source.
if tryCmd("grim", outputPath) {
return nil
}
// GNOME Wayland — gnome-screenshot uses the GNOME Shell D-Bus API.
if tryCmd("gnome-screenshot", "-f", outputPath) {
return nil
}
// KDE Wayland — spectacle's --background flag captures without opening the GUI.
if tryCmd("spectacle", "--background", "--output", outputPath) {
return nil
}
// ImageMagick — may work via XWayland or with compositor-specific backends.
if tryCmd("magick", "import", "-window", "root", outputPath) {
return nil
}
if tryCmd("import", "-window", "root", outputPath) {
return nil
}
return fmt.Errorf("no Wayland screenshot tool found (tried grim, gnome-screenshot, spectacle, magick import)")
}
// captureFallback tries all tools regardless of session type.
func captureFallback(outputPath string, tryCmd func(string, ...string) bool) error {
if tryCmd("scrot", "-o", outputPath) {
return nil
}
if tryCmd("grim", outputPath) {
return nil
}
if tryCmd("gnome-screenshot", "-f", outputPath) {
return nil
}
if tryCmd("spectacle", "--background", "--output", outputPath) {
return nil
}
if tryCmd("magick", "import", "-window", "root", outputPath) {
return nil
}
if tryCmd("import", "-window", "root", outputPath) {
return nil
}
return fmt.Errorf("no screenshot tool found (tried scrot, grim, gnome-screenshot, spectacle, magick import)")
}
// discoverSessionDisplay reads /proc environ entries to find DISPLAY,
// WAYLAND_DISPLAY, XDG_RUNTIME_DIR, and XDG_SESSION_TYPE from an active
// user session. The agent service runs without these inherited from systemd.
//
// XDG_SESSION_TYPE is used to select the right screenshot tool chain:
// "x11" → scrot/magick, "wayland" → grim/gnome-screenshot/spectacle.
func discoverSessionDisplay() sessionDisplayInfo {
entries, err := os.ReadDir("/proc")
if err != nil {
return sessionDisplayInfo{}
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if _, err := strconv.Atoi(entry.Name()); err != nil {
continue
}
data, err := os.ReadFile("/proc/" + entry.Name() + "/environ")
if err != nil {
continue
}
var display, wayland, xdgRuntime, sessionType string
for _, v := range bytes.Split(data, []byte{0}) {
s := string(v)
switch {
case strings.HasPrefix(s, "DISPLAY=") && display == "":
display = s
case strings.HasPrefix(s, "WAYLAND_DISPLAY=") && wayland == "":
wayland = s
case strings.HasPrefix(s, "XDG_RUNTIME_DIR=") && xdgRuntime == "":
xdgRuntime = s
case strings.HasPrefix(s, "XDG_SESSION_TYPE=") && sessionType == "":
sessionType = strings.TrimPrefix(s, "XDG_SESSION_TYPE=")
}
}
if display != "" || wayland != "" {
var env []string
if display != "" {
env = append(env, display)
}
if wayland != "" {
env = append(env, wayland)
}
if xdgRuntime != "" {
env = append(env, xdgRuntime)
}
// Infer session type from env if XDG_SESSION_TYPE was not set.
if sessionType == "" {
if wayland != "" {
sessionType = "wayland"
} else if display != "" {
sessionType = "x11"
}
}
pid, _ := strconv.Atoi(entry.Name())
return sessionDisplayInfo{env: env, sessionType: sessionType, sourcePID: pid}
}
}
return sessionDisplayInfo{}
}
// orDefault returns s if non-empty, otherwise fallback.
func orDefault(s, fallback string) string {
if s != "" {
return s
}
return fallback
}
// hasCapSysPtrace checks if the current process has CAP_SYS_PTRACE in its
// effective set. Reads /proc/self/status to avoid a cgo dependency on libcap.
func hasCapSysPtrace() bool {
data, err := os.ReadFile("/proc/self/status")
if err != nil {
return false
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "CapEff:") {
// CAP_SYS_PTRACE is bit 19 = 0x80000.
fields := strings.Fields(line)
if len(fields) >= 2 {
var cap uint64
fmt.Sscanf(fields[1], "%x", &cap)
return cap&0x80000 != 0
}
}
}
return false
}
// captureScreenWindows captures the display using PowerShell + .NET
// System.Drawing. No extra tools required — always available on Windows.
func captureScreenWindows(outputPath string) error {
psScript := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bmp = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height)
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
$gfx.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$bmp.Save('%s', [System.Drawing.Imaging.ImageFormat]::Png)
$gfx.Dispose()
$bmp.Dispose()
`, outputPath)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("powershell screenshot failed: %s: %w", string(out), err)
}
return nil
}

View file

@ -0,0 +1,171 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"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"
"github.com/Fimeg/RedFlag/agent/internal/version"
)
// UpgradeAttestation is the marker the old binary writes immediately before the
// self-upgrade restart. The new binary reads it at startup and attests whether
// the process now running is actually the version the swap installed. Without
// this, a swap that silently failed (or was rolled back from .bak) leaves the
// old binary checking in normally and the server waiting out the full
// stuck-update timeout before anyone notices.
type UpgradeAttestation struct {
CommandID string `json:"command_id"`
FromVersion string `json:"from_version"`
ToVersion string `json:"to_version"`
InitiatedAt time.Time `json:"initiated_at"`
}
// attestationMaxAge bounds marker retries. Past this the server's
// reconcileAgentUpdates sweep has long since timed the command out, so a
// report would only 409; drop the marker instead of retrying forever.
const attestationMaxAge = 24 * time.Hour
func upgradeAttestationPath() string {
return filepath.Join(constants.GetAgentStateDir(), "upgrade-attestation.json")
}
// WriteUpgradeAttestation persists the pre-restart marker. Called by the
// upgrade handler after the binary swap is committed (or handed to the helper)
// and before the service restart.
func WriteUpgradeAttestation(commandID, toVersion string) error {
att := UpgradeAttestation{
CommandID: commandID,
FromVersion: version.Version,
ToVersion: toVersion,
InitiatedAt: time.Now().UTC(),
}
data, err := json.Marshal(att)
if err != nil {
return fmt.Errorf("marshal upgrade attestation: %w", err)
}
if err := os.WriteFile(upgradeAttestationPath(), data, 0o600); err != nil {
return fmt.Errorf("write upgrade attestation: %w", err)
}
return nil
}
// ClearUpgradeAttestation removes the marker. Used on upgrade paths that fail
// before the restart is dispatched — the next boot is not a post-upgrade boot.
func ClearUpgradeAttestation() {
if err := os.Remove(upgradeAttestationPath()); err != nil && !os.IsNotExist(err) {
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_remove_failed error=%v", err)
}
}
// RunUpgradeAttestation is the post-upgrade healthcheck, called once at agent
// startup before the polling loop. If a marker exists, the running version is
// checked against the upgrade target:
//
// - running >= target: the swap worked. Local log only — success closure is
// deliberately owned by the server (version check-in confirm in
// ReportMetrics + the reconcileAgentUpdates sweep), not this report.
// - running < target: the swap failed or was rolled back. Report a failed
// update_agent log under the original command_id — the server marks the
// command failed, clears is_updating immediately, and journals a system
// event, instead of the operator waiting out the stuck-update timeout.
func RunUpgradeAttestation(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker) {
data, err := os.ReadFile(upgradeAttestationPath())
if err != nil {
if !os.IsNotExist(err) {
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_read_failed error=%v", err)
}
return
}
var att UpgradeAttestation
if err := json.Unmarshal(data, &att); err != nil {
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_corrupt error=%v", err)
ClearUpgradeAttestation()
return
}
if versionAtLeast(version.Version, att.ToVersion) {
log.Printf("[INFO] [agent] [upgrade] post_upgrade_attestation_ok running=%s target=%s command_id=%s",
version.Version, att.ToVersion, att.CommandID)
ClearUpgradeAttestation()
return
}
log.Printf("[CRITICAL] [agent] [upgrade] post_upgrade_attestation_failed running=%s target=%s from=%s command_id=%s",
version.Version, att.ToVersion, att.FromVersion, att.CommandID)
expired := time.Since(att.InitiatedAt) > attestationMaxAge
report := client.LogReport{
CommandID: att.CommandID,
Action: "update_agent",
Result: "failed",
Stderr: fmt.Sprintf(
"post-upgrade attestation failed: running version %s, expected %s (was %s before the swap) — binary swap failed or was rolled back",
version.Version, att.ToVersion, att.FromVersion),
ExitCode: 1,
Metadata: map[string]string{
"subsystem_label": "Agent Update",
"subsystem": "agent",
"target_version": att.ToVersion,
"running_version": version.Version,
"attested_at": time.Now().UTC().Format(time.RFC3339),
},
}
if err := ReportLogWithAck(apiClient, cfg, ackTracker, report); err != nil {
// 409 = the command is already finalized server-side (timeout sweep or an
// earlier report won the race) — the marker has nothing left to say.
if strings.Contains(err.Error(), "409") {
log.Printf("[INFO] [agent] [upgrade] attestation_already_finalized command_id=%s", att.CommandID)
ClearUpgradeAttestation()
return
}
if expired {
log.Printf("[ERROR] [agent] [upgrade] attestation_report_failed error=%v — marker expired (>%v), dropping", err, attestationMaxAge)
ClearUpgradeAttestation()
return
}
log.Printf("[ERROR] [agent] [upgrade] attestation_report_failed error=%v — marker retained for retry on next start", err)
return
}
ClearUpgradeAttestation()
}
// versionAtLeast reports whether running >= target, comparing dotted numeric
// versions (leading "v" tolerated). Mirrors the server's IsNewerOrEqualVersion
// check-in confirm: a running version past the target still proves the swap
// took. Non-numeric segments fall back to string comparison.
func versionAtLeast(running, target string) bool {
r := strings.Split(strings.TrimPrefix(running, "v"), ".")
t := strings.Split(strings.TrimPrefix(target, "v"), ".")
for i := 0; i < len(r) || i < len(t); i++ {
var rs, ts string
if i < len(r) {
rs = r[i]
}
if i < len(t) {
ts = t[i]
}
rn, rErr := strconv.Atoi(rs)
tn, tErr := strconv.Atoi(ts)
if rErr != nil || tErr != nil {
if rs == ts {
continue
}
return rs > ts
}
if rn != tn {
return rn > tn
}
}
return true
}

View file

@ -0,0 +1,26 @@
package handlers
import "testing"
func TestVersionAtLeast(t *testing.T) {
cases := []struct {
running, target string
want bool
}{
{"0.2.7.0", "0.2.7.0", true},
{"v0.2.7.0", "0.2.7.0", true},
{"0.2.7.0", "v0.2.7.0", true},
{"0.2.7.1", "0.2.7.0", true}, // past the target still proves the swap
{"0.2.8.0", "0.2.7.9", true},
{"0.2.7.0", "0.2.7.1", false}, // rolled back / swap failed
{"0.2.6.9", "0.2.7.0", false},
{"0.2.10.0", "0.2.9.0", true}, // numeric, not lexicographic
{"0.2.7", "0.2.7.0", false}, // shorter = missing segment treated as lower
{"0.2.7.0", "0.2.7", true},
}
for _, c := range cases {
if got := versionAtLeast(c.running, c.target); got != c.want {
t.Errorf("versionAtLeast(%q, %q) = %v, want %v", c.running, c.target, got, c.want)
}
}
}

View file

@ -0,0 +1,119 @@
package handlers
import (
"log"
"os"
"path/filepath"
"runtime"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
// RunPostUpgradeHealthcheck checks the agent's environment against what this
// version expects. Called once at startup after RunUpgradeAttestation, before
// the polling loop. All findings are warnings — the agent continues regardless.
// Findings are logged and a non-zero return indicates gaps found.
func RunPostUpgradeHealthcheck(cfg *config.Config) int {
gaps := 0
// --- Config key completeness ---
// The install template's default JSON is the source of truth for freshly
// installed agents. Upgraded agents carry over their old config verbatim,
// so new keys added this release may be absent. The agent's merge logic
// (loadFromFile → getDefaultConfig chain) fills zero values on access, but
// visible gaps help the operator understand why defaults are shipping.
check := func(present bool, format string, args ...interface{}) {
if !present {
log.Printf("[WARN] [agent] [healthcheck] "+format, args...)
gaps++
}
}
// Top-level keys the template should have set. The agent.Config struct
// with json tags is the canonical list — these are the non-omitempty keys
// the template writes for fresh installs. We check the config struct
// was populated (not zero-valued per the template JSON) and not missing.
check(cfg.ServerURL != "", "config key 'server_url' is empty — should be set by installer")
check(cfg.CheckInInterval > 0, "config key 'check_in_interval' is zero — should be 300 from template")
// PollingConfig: this version added these keys; warn if empty.
check(cfg.Polling.JitterMaxSeconds > 0 || cfg.Polling.BackoffBaseSeconds > 0,
"config key 'polling' missing — polling resilience tuning will use defaults")
// CommandSigning: recommended on.
check(cfg.CommandSigning.Enabled,
"config key 'command_signing.enabled' is false — recommend true")
// DesktopConfig: warn about missing keys if enabled.
if cfg.Desktop.Enabled {
check(cfg.Desktop.MaxRestarts > 0,
"config key 'desktop.max_restarts' is zero — agent will not auto-restart the desktop app on crash")
}
// --- Binary presence ---
// The helper binary (redflag-helper) should exist when command signing is on.
if cfg.CommandSigning.Enabled {
helperPath := filepath.Join(filepath.Dir(os.Args[0]), "redflag-helper")
if runtime.GOOS == "windows" {
helperPath += ".exe"
}
check(fileExists(helperPath),
"helper binary not found at %s — capability-gated installs will fail", helperPath)
}
// Desktop binary should exist when desktop is enabled.
if cfg.Desktop.Enabled {
desktopPath := filepath.Join(filepath.Dir(os.Args[0]), "redflag-desktop")
if runtime.GOOS == "windows" {
desktopPath += ".exe"
}
check(fileExists(desktopPath),
"desktop binary not found at %s — local operations console will not appear", desktopPath)
}
// --- Autostart entry (Linux only) ---
if cfg.Desktop.Enabled && runtime.GOOS == "linux" {
autostartPath := "/etc/xdg/autostart/redflag-desktop.desktop"
check(fileExists(autostartPath),
"desktop autostart entry not found at %s — RedFlag Desktop will not start on next login", autostartPath)
}
// --- Socket directory permissions ---
// The local API socket lives under GetAgentStateDir()'s parent with 0710.
socketDir := filepath.Join(constants.GetAgentStateDir(), "..", "localapi")
resolved, _ := filepath.EvalSymlinks(socketDir)
if resolved == "" {
resolved = socketDir
}
if info, err := os.Stat(resolved); err == nil {
perm := info.Mode().Perm()
check(perm&0o010 != 0,
"localapi socket dir %s has permissions %#o — the Desktop user cannot traverse to the socket (needs 0o10 group execute)", resolved, perm)
} else {
log.Printf("[WARN] [agent] [healthcheck] localapi socket dir %s not found — Desktop may not work: %v", resolved, err)
gaps++
}
if gaps > 0 {
log.Printf("[WARN] [agent] [healthcheck] complete gaps=%d — re-run the install script or correct manually", gaps)
} else {
log.Printf("[INFO] [agent] [healthcheck] all_ok")
}
return gaps
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// ReportHealthcheckGapsAsEvents sends system event entries for each gap found.
// Called when gaps > 0 after RunPostUpgradeHealthcheck.
// TODO: wire into system event logger once that's available from the handler context.
func ReportHealthcheckGapsAsEvents(gaps int, cfg *config.Config) {
// Placeholder — gaps are logged above.
// A future iteration will emit proper system events via the agent's
// security logger or the server's event logging endpoint.
_ = gaps
_ = cfg
}