Watch
1
0
Fork
You've already forked RedFlag
0

feat: post-upgrade attestation — new binary proves the swap took

Old binary drops a marker (command_id, from/to) once the swap is
committed, on both the helper path and the legacy path. New binary
checks it at startup: running >= target just clears the marker
(check-in confirm still owns success); short of target means the swap
failed or rolled back, so it files a failed update_agent report under
the original command_id and the server clears is_updating right away
instead of sitting out the stuck-update timeout. Marker survives
failed reports for retry, drops on 409 or after 24h.
This commit is contained in:
Fimeg 2026-06-10 15:13:47 -04:00
commit 2f3363cbce
11 changed files with 304 additions and 102 deletions

View file

@ -128,6 +128,11 @@ func RunAgentLoop(cfg *config.Config) error {
return fmt.Errorf("failed to initialize command handler: %w", err)
}
// Post-upgrade healthcheck: if the previous process restarted us as part of a
// self-upgrade, verify this binary is the version the swap installed and
// report a failed update_agent under the original command_id if not.
handlers.RunUpgradeAttestation(apiClient, cfg, ackTracker)
// Initialize desktop manager (spawns Tauri system tray + local UI)
desktopMgr := desktop.NewManager(
"", // auto-detect binary alongside agent

View file

@ -154,7 +154,7 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
}
}
}
return installAgentViaHelper(tempBinaryPath, helperBinaryPath, params, updateStartTime)
return installAgentViaHelper(tempBinaryPath, helperBinaryPath, params, commandID, version, updateStartTime)
}
currentBinaryPath, err := getCurrentBinaryPath()
@ -200,6 +200,12 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
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
@ -217,7 +223,7 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
// 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{}, startTime time.Time) error {
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.
const upgradeStagingPath = "/var/lib/redflag/agent/pending-upgrade.bin"
if err := copyFile(tempBinaryPath, upgradeStagingPath); err != nil {
@ -238,7 +244,9 @@ func installAgentViaHelper(tempBinaryPath, helperBinaryPath string, params map[s
// 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.
// 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 {
@ -247,6 +255,7 @@ func installAgentViaHelper(tempBinaryPath, helperBinaryPath string, params map[s
log.Printf("[WARNING] [agent] [upgrade] staging_cleanup_failed path=%s error=%v", p, err)
}
}
ClearUpgradeAttestation()
}
}()
@ -259,6 +268,13 @@ func installAgentViaHelper(tempBinaryPath, helperBinaryPath string, params map[s
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()

View file

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

View file

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

View file

@ -153,21 +153,14 @@ if [ ! -d "$AGENT_HOME" ]; then
sudo mkdir -p "$AGENT_HOME"
sudo mkdir -p "$AGENT_HOME/cache"
sudo mkdir -p "$AGENT_HOME/state"
sudo mkdir -p "$AGENT_HOME/localapi"
sudo mkdir -p "$AGENT_CONFIG_DIR"
sudo mkdir -p "$SERVER_KEY_DIR"
sudo mkdir -p "$AGENT_LOG_DIR"
# Set ownership and permissions
sudo chown -R "$AGENT_USER:$AGENT_USER" "$BASE_DIR"
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$BASE_DIR"
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$AGENT_HOME"
sudo chmod 710 "$BASE_DIR"
sudo chmod 710 "$AGENT_HOME"
sudo chmod 750 "$AGENT_HOME/cache"
sudo chmod 750 "$AGENT_HOME/state"
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$AGENT_HOME/localapi"
sudo chmod 750 "$AGENT_HOME/localapi"
sudo chmod 755 "$AGENT_CONFIG_DIR"
sudo chown "$AGENT_USER:$AGENT_USER" "$SERVER_KEY_DIR"
sudo chmod 755 "$SERVER_KEY_DIR"
@ -180,6 +173,19 @@ if [ ! -d "$AGENT_HOME" ]; then
echo " - Logs: $AGENT_LOG_DIR"
fi
# Local-API / tray access chain — enforced on EVERY run, not just first
# install. Upgrades from pre-tray versions carry agent:agent 700 dirs that
# block the ${LOCAL_API_GROUP} group from traversing to the socket, which
# leaves the tray installed but unable to connect. Idempotent by design.
# 710/750: group gets traverse only on the path, read on the socket dir;
# the agent re-asserts socket dir/file ownership itself on startup.
sudo mkdir -p "$AGENT_HOME/localapi"
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$BASE_DIR" "$AGENT_HOME" "$AGENT_HOME/localapi"
sudo chmod 710 "$BASE_DIR"
sudo chmod 710 "$AGENT_HOME"
sudo chmod 750 "$AGENT_HOME/localapi"
echo "✓ Local API access chain enforced ($BASE_DIR -> $AGENT_HOME/localapi, group $LOCAL_API_GROUP)"
# Step 4: Install sudoers configuration with OS-specific commands
PM=$(detect_package_manager)
echo "Detected package manager: $PM"
@ -717,6 +723,13 @@ EOF
echo "✓ Tray autostart entry installed (${XDG_AUTOSTART_DIR}/redflag-desktop.desktop)"
fi
# Stock GNOME ships no tray host — without the AppIndicator extension
# the icon never appears and the tray looks broken. Other desktops
# (KDE, Hyprland/waybar, XFCE) support StatusNotifier out of the box.
if command -v gnome-shell >/dev/null && ! compgen -G "/usr/share/gnome-shell/extensions/*appindicator*" >/dev/null; then
echo "[INFO] [installer] [desktop] GNOME detected without the AppIndicator extension — install gnome-shell-extension-appindicator or the tray icon will not be visible"
fi
# The local API socket lives under /var/lib/redflag (0710, group
# ${LOCAL_API_GROUP}) — the tray cannot reach it unless its desktop
# user is in the group. Add the invoking user; takes effect at next

View file

@ -5,7 +5,7 @@ import { cn } from '@/lib/utils';
* MetricItem a single system-info metric cell.
*
* Renders a label, value, optional icon, optional sub-text, and optional
* progress bar. Designed to be an independent grid item inside SystemInfoGrid.
* progress bar. Designed to be an independent grid or flex item.
*
* Usage:
* <MetricItem label="CPU" value="Intel i7" icon={Cpu} sub="4 cores" />

View file

@ -8,7 +8,7 @@ import { cn } from '@/lib/utils';
* Renders a compact table (Name, PID, CPU%, Mem%) when process data is
* available, or a fallback message with the process count.
*
* Designed as an independent grid item inside SystemInfoGrid.
* Designed as an independent grid or flex item.
*/
interface ProcessInfo {
name: string;

View file

@ -6,8 +6,9 @@ import { cn } from '@/lib/utils';
* ScreenshotCard screenshot thumbnail with sunshine overlay and capture state.
*
* Renders the agent's screenshot (or capture prompt), live/stream badges,
* and timestamp. Designed as a grid item inside SystemInfoGrid with
* col-start-1 row-span-2 to anchor top-left.
* and timestamp. The image box keeps a 16:9 ratio at its natural size but
* grows past it to absorb extra height pass `className="grow"` from a
* flex-col parent to make the card fill leftover vertical space.
*
* Handlers are passed in as props this component doesn't own mutations.
*/
@ -49,10 +50,10 @@ const ScreenshotCard: React.FC<ScreenshotCardProps> = ({
const canClick = !isCapturing && !isPolling;
return (
<div className={cn('min-w-0', className)}>
<div className={cn('flex min-w-0 flex-col', className)}>
<div
className={cn(
'relative aspect-video w-full overflow-hidden rounded border',
'relative aspect-video w-full grow overflow-hidden rounded border',
'bg-gradient-to-br from-slate-800 to-slate-900 border-slate-700',
'flex flex-col items-center justify-center gap-1 text-slate-400',
canClick && 'cursor-pointer hover:from-slate-700 hover:to-slate-800 transition-colors'

View file

@ -1,36 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
/**
* SystemInfoGrid CSS Grid container for system information primitives.
*
* Items auto-place into a 2-column grid that collapses to 1 on narrow
* viewports. The screenshot (or any anchor item) gets col-start-1
* row-span-2 via its own className prop.
*
* Usage:
* <SystemInfoGrid>
* <ScreenshotCard className="col-start-1 row-span-2" ... />
* <MetricItem label="CPU" ... />
* <MetricItem label="Memory" ... />
* <ProcessTable ... />
* </SystemInfoGrid>
*/
interface SystemInfoGridProps {
children: React.ReactNode;
className?: string;
}
const SystemInfoGrid: React.FC<SystemInfoGridProps> = ({ children, className }) => (
<div
className={cn('grid gap-6', className)}
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gridAutoRows: 'min-content',
}}
>
{children}
</div>
);
export default SystemInfoGrid;

View file

@ -6,3 +6,6 @@ export { default as PageState, PageSkeleton } from './PageState';
export { default as Modal } from './Modal';
export { default as Pagination } from './Pagination';
export { default as StatCard, StatCardGroup } from './StatCard';
export { default as ScreenshotCard } from './ScreenshotCard';
export { default as MetricItem } from './MetricItem';
export { default as ProcessTable } from './ProcessTable';

View file

@ -23,11 +23,14 @@ import {
MonitorPlay,
Upload,
} from 'lucide-react';
import { SearchInput, FilterDropdown, PageState } from '@/components/primitives';
import SystemInfoGrid from '@/components/primitives/SystemInfoGrid';
import ScreenshotCard from '@/components/primitives/ScreenshotCard';
import MetricItem from '@/components/primitives/MetricItem';
import ProcessTable from '@/components/primitives/ProcessTable';
import {
SearchInput,
FilterDropdown,
PageState,
ScreenshotCard,
MetricItem,
ProcessTable,
} from '@/components/primitives';
import { useDebounce } from '@/hooks/useDebounce';
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand } from '@/hooks/useCommands';
@ -825,7 +828,7 @@ const Agents: React.FC = () => {
)}
</div>
{/* System info — independent primitives in a CSS Grid */}
{/* System info — screenshot pane + metrics grid */}
<div className="card">
<h2 className="text-lg font-medium text-gray-900 mb-4">System Information</h2>
@ -837,51 +840,51 @@ const Agents: React.FC = () => {
const topProcesses = selectedAgent.metadata?.top_processes;
return (
<SystemInfoGrid>
<ScreenshotCard
className="col-start-1 row-span-2"
image={screenshotImage}
isCapturing={captureScreenshotMutation.isPending}
isPolling={screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status)}
sunshine={sunshine ? { ...sunshine, state: resolveState(sunshine) } : undefined}
screenshotStatus={screenshotCommand}
onCapture={() => handleCaptureScreenshot(selectedAgent.id)}
formatRelativeTime={formatRelativeTime}
/>
<MetricItem label="Platform" value={osInfo.platform} />
<MetricItem
label="Distribution"
value={osInfo.distribution}
sub={osInfo.version ? `(${osInfo.version})` : undefined}
/>
<MetricItem
label="Architecture"
value={selectedAgent.os_architecture || selectedAgent.architecture}
/>
<MetricItem label="CPU" value={meta.cpuModel} icon={Cpu} sub={`${meta.cpuCores} cores`} />
{meta.memoryTotal > 0 && (
<MetricItem label="Memory" value={formatBytes(meta.memoryTotal)} icon={MemoryStick} />
)}
{meta.diskTotal > 0 && (
<MetricItem
label={`Disk (${meta.diskMount})`}
value={`${formatBytes(meta.diskUsed)} / ${formatBytes(meta.diskTotal)}`}
icon={HardDrive}
progress={{ used: meta.diskUsed, total: meta.diskTotal }}
<div className="flex flex-col gap-6 md:flex-row">
{/* Left: screenshot grows to keep the panes level, platform identity below */}
<div className="flex flex-col gap-3 md:w-[45%] md:shrink-0">
<ScreenshotCard
className="grow"
image={screenshotImage}
isCapturing={captureScreenshotMutation.isPending}
isPolling={screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status)}
sunshine={sunshine ? { ...sunshine, state: resolveState(sunshine) } : undefined}
screenshotStatus={screenshotCommand}
onCapture={() => handleCaptureScreenshot(selectedAgent.id)}
formatRelativeTime={formatRelativeTime}
/>
)}
{meta.processes !== 'Unknown' && (
<MetricItem label="Running Processes" value={meta.processes} icon={GitBranch} />
)}
{meta.uptime !== 'Unknown' && (
<MetricItem label="Uptime" value={meta.uptime} icon={Clock} />
)}
<ProcessTable
processes={topProcesses}
processCount={meta.processes}
onSeeMore={() => navigate(`/updates?agent=${selectedAgent.id}`)}
/>
</SystemInfoGrid>
<MetricItem label="Platform" value={osInfo.platform} />
<MetricItem label="Distribution" value={osInfo.distribution} sub={osInfo.version ? `(${osInfo.version})` : undefined} />
<MetricItem label="Architecture" value={selectedAgent.os_architecture || selectedAgent.architecture} />
</div>
{/* Right: hardware metrics, ProcessTable spanning full width below */}
<div className="grid flex-1 grid-cols-1 content-start gap-3 min-w-0 sm:grid-cols-2">
<MetricItem label="CPU" value={meta.cpuModel} icon={Cpu} sub={`${meta.cpuCores} cores`} />
{meta.memoryTotal > 0 && (
<MetricItem label="Memory" value={formatBytes(meta.memoryTotal)} icon={MemoryStick} />
)}
{meta.diskTotal > 0 && (
<MetricItem
label={`Disk (${meta.diskMount})`}
value={`${formatBytes(meta.diskUsed)} / ${formatBytes(meta.diskTotal)}`}
icon={HardDrive}
progress={{ used: meta.diskUsed, total: meta.diskTotal }}
/>
)}
{meta.processes !== 'Unknown' && (
<MetricItem label="Running Processes" value={meta.processes} icon={GitBranch} />
)}
{meta.uptime !== 'Unknown' && (
<MetricItem label="Uptime" value={meta.uptime} icon={Clock} />
)}
<ProcessTable
className="col-span-full"
processes={topProcesses}
processCount={meta.processes}
onSeeMore={() => navigate(`/updates?agent=${selectedAgent.id}`)}
/>
</div>
</div>
);
})()}
</div>