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:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
13
agent/internal/handlers/agent_restart_other.go
Normal file
13
agent/internal/handlers/agent_restart_other.go
Normal 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")
|
||||
}
|
||||
35
agent/internal/handlers/agent_restart_windows.go
Normal file
35
agent/internal/handlers/agent_restart_windows.go
Normal 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
|
||||
}
|
||||
557
agent/internal/handlers/agent_update.go
Normal file
557
agent/internal/handlers/agent_update.go
Normal 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
|
||||
}
|
||||
241
agent/internal/handlers/commands.go
Normal file
241
agent/internal/handlers/commands.go
Normal 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] + "..."
|
||||
}
|
||||
90
agent/internal/handlers/dispatch.go
Normal file
90
agent/internal/handlers/dispatch.go
Normal 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
|
||||
}
|
||||
289
agent/internal/handlers/dry_run.go
Normal file
289
agent/internal/handlers/dry_run.go
Normal 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
|
||||
}
|
||||
72
agent/internal/handlers/heartbeat.go
Normal file
72
agent/internal/handlers/heartbeat.go
Normal 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
|
||||
}
|
||||
180
agent/internal/handlers/install.go
Normal file
180
agent/internal/handlers/install.go
Normal 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
|
||||
}
|
||||
272
agent/internal/handlers/local_approve.go
Normal file
272
agent/internal/handlers/local_approve.go
Normal 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
|
||||
}
|
||||
49
agent/internal/handlers/local_approve_test.go
Normal file
49
agent/internal/handlers/local_approve_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
60
agent/internal/handlers/local_state.go
Normal file
60
agent/internal/handlers/local_state.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
84
agent/internal/handlers/local_trigger.go
Normal file
84
agent/internal/handlers/local_trigger.go
Normal 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
|
||||
}
|
||||
62
agent/internal/handlers/processes.go
Normal file
62
agent/internal/handlers/processes.go
Normal 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
|
||||
}
|
||||
81
agent/internal/handlers/reboot.go
Normal file
81
agent/internal/handlers/reboot.go
Normal 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
|
||||
}
|
||||
742
agent/internal/handlers/scan.go
Normal file
742
agent/internal/handlers/scan.go
Normal 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
|
||||
}
|
||||
381
agent/internal/handlers/screenshot.go
Normal file
381
agent/internal/handlers/screenshot.go
Normal 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
|
||||
}
|
||||
171
agent/internal/handlers/upgrade_attestation.go
Normal file
171
agent/internal/handlers/upgrade_attestation.go
Normal 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
|
||||
}
|
||||
26
agent/internal/handlers/upgrade_attestation_test.go
Normal file
26
agent/internal/handlers/upgrade_attestation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
119
agent/internal/handlers/upgrade_healthcheck.go
Normal file
119
agent/internal/handlers/upgrade_healthcheck.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue