Watch
1
0
Fork
You've already forked RedFlag
0

v0.2.3.5: unlock self-update + gated installs on fresh hosts

We kept claiming self-update worked. On a clean box it didn't.

- linux.sh.tmpl: install a polkit rule so the service user can invoke the
  helper via systemd-run. Without it every gated install and self-update
  hit auth_admin and died on a TTY-less service.
- self-update: drop the post-update .bak sweep. It ran unprivileged against
  a root-owned backup and could only ever log permission-denied. The helper
  already keeps .bak as the single rollback slot.
- metrics/docker reports: stop finalizing the command at ingest. It raced
  ReportLog and 409'd the history-bearing log, silently dropping system and
  docker scans from History. ReportLog is the sole finalize point now, same
  as dnf/storage.
This commit is contained in:
Fimeg 2026-06-03 16:08:30 -04:00
commit cff31d6106
7 changed files with 53 additions and 77 deletions

View file

@ -184,7 +184,6 @@ func RunPollingLoop(loopCtx *LoopContext) error {
consecutiveFailures := 0
lastSystemInfoUpdate := time.Time{}
lastConfigRefresh := time.Time{} // zero → refresh on first successful check-in
postUpdateCleanupDone := false
for {
// Stop-channel check before each iteration
@ -309,14 +308,6 @@ func RunPollingLoop(loopCtx *LoopContext) error {
consecutiveFailures = 0
// First successful check-in after boot — if a post-update .bak is
// sitting on disk, the new binary has now proven viability and the
// backup is safe to drop. See agent_update.go cleanup contract.
if !postUpdateCleanupDone {
handlers.CleanupPostUpdateBackup()
postUpdateCleanupDone = true
}
// Refresh fleet-wide operational config (polling resilience tuning) on
// first check-in and periodically thereafter. Server is the source of
// the fleet default; non-zero values are merged into the local config so

View file

@ -297,33 +297,15 @@ func getCurrentBinaryPath() (string, error) {
return execPath, nil
}
// CleanupPostUpdateBackup removes a leftover .bak sibling of the running
// binary if one exists. The update handler intentionally leaves .bak on disk
// across systemd restart because the deferred cleanup cannot survive SIGTERM;
// callers should invoke this once after the first successful server check-in,
// when the new binary has proven it can boot and reach the control plane.
// No-op when no backup is present.
func CleanupPostUpdateBackup() {
execPath, err := os.Executable()
if err != nil {
log.Printf("[WARNING] [agent] [upgrade] cleanup_executable_path_failed error=%v", err)
return
}
backupPath := execPath + ".bak"
info, err := os.Stat(backupPath)
if os.IsNotExist(err) {
return
}
if err != nil {
log.Printf("[WARNING] [agent] [upgrade] cleanup_stat_failed path=%s error=%v", backupPath, err)
return
}
if err := os.Remove(backupPath); err != nil {
log.Printf("[WARNING] [agent] [upgrade] cleanup_remove_failed path=%s error=%v", backupPath, err)
return
}
log.Printf("[INFO] [agent] [upgrade] post_update_backup_removed path=%s size_bytes=%d", backupPath, info.Size())
}
// 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.
func createBackup(src, dst string) error {
cmd := exec.Command("sudo", "cp", src, dst)

View file

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

View file

@ -1,7 +1,6 @@
package handlers
import (
"log"
"net/http"
"strconv"
"strings"
@ -115,24 +114,11 @@ func (h *DockerReportsHandler) ReportDockerImages(c *gin.Context) {
return
}
// Update command status to completed
result := models.JSONB{
"docker_images_count": len(req.Images),
"logged_at": time.Now().UTC(),
}
if err := h.commandQueries.MarkCommandCompleted(commandID, result); err != nil {
log.Printf("[ERROR] [server] [docker] mark_command_completed_failed command_id=%s error=%q", commandID, err)
c.JSON(http.StatusOK, gin.H{
"message": "docker image events recorded but command state update failed",
"count": len(events),
"command_id": req.CommandID,
"should_retry": true,
"reason": err.Error(),
})
return
}
// Command finalization is owned by ReportLog — the single point that closes
// the command AND writes the [HISTORY] row. Completing it here too would mark
// the command terminal, then 409 the agent's history-bearing ReportLog and drop
// docker-scan events from the History page. Storage and dnf already leave the
// command open for ReportLog; this keeps docker consistent with them.
c.JSON(http.StatusOK, gin.H{
"message": "docker image events recorded",
"count": len(events),

View file

@ -1,7 +1,6 @@
package handlers
import (
"log"
"net/http"
"strconv"
"time"
@ -86,24 +85,11 @@ func (h *MetricsHandler) ReportMetrics(c *gin.Context) {
return
}
// Update command status to completed
result := models.JSONB{
"metrics_count": len(req.Metrics),
"logged_at": time.Now().UTC(),
}
if err := h.commandQueries.MarkCommandCompleted(commandID, result); err != nil {
log.Printf("[ERROR] [server] [metrics] mark_command_completed_failed command_id=%s error=%q", commandID, err)
c.JSON(http.StatusOK, gin.H{
"message": "metrics events recorded but command state update failed",
"count": len(events),
"command_id": req.CommandID,
"should_retry": true,
"reason": err.Error(),
})
return
}
// Command finalization is owned by ReportLog — the single point that closes
// the command AND writes the [HISTORY] row. Completing it here too would mark
// the command terminal, then 409 the agent's history-bearing ReportLog and drop
// system-scan events from the History page. Storage and dnf already leave the
// command open for ReportLog; this keeps system consistent with them.
c.JSON(http.StatusOK, gin.H{
"message": "metrics events recorded",
"count": len(events),

View file

@ -232,6 +232,37 @@ else
echo "⚠ Sudoers configuration validation failed - using generic version"
fi
# Step 4b: Install polkit rule for transient unit management
# The agent's only sudo is `systemd-run --pipe ... redflag-helper`. systemd-run
# spawns a transient unit over the system D-Bus, which polkit gates behind
# org.freedesktop.systemd1.manage-units (auth_admin). polkit evaluates the
# original caller ({{.AgentUser}}), so without this rule the helper invocation —
# and therefore every gated install and the agent self-update — is denied on a
# TTY-less service. The sudoers line above already pins the exact command; this
# grants the matching D-Bus permission to the same user, nothing wider.
POLKIT_RULES_DIR="/etc/polkit-1/rules.d"
POLKIT_RULE_FILE="${POLKIT_RULES_DIR}/50-redflag-agent.rules"
if [ -d "$POLKIT_RULES_DIR" ]; then
cat <<'EOF' | sudo tee "$POLKIT_RULE_FILE" > /dev/null
// RedFlag Agent — allow the service user to manage transient systemd units.
// Scope: manage-units only. The matching sudoers grant pins the command line to
// `systemd-run --pipe ... /usr/local/bin/redflag-helper`, so this user can spawn
// the helper as a transient unit and nothing else.
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
subject.user == "{{.AgentUser}}") {
return polkit.Result.YES;
}
});
EOF
sudo chmod 644 "$POLKIT_RULE_FILE"
echo "✓ Polkit rule installed (transient unit management for {{.AgentUser}})"
else
echo "⚠ ${POLKIT_RULES_DIR} not found — polkit JS rules unsupported on this host."
echo " Gated installs and agent self-update will be denied until a polkit rule"
echo " granting org.freedesktop.systemd1.manage-units to {{.AgentUser}} is added."
fi
# Step 5: Stop existing service
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
echo "Stopping existing RedFlag agent service..."

View file

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