From b7cbded429927e02e1cba67f7df3f48895b1f724 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 5 Jul 2026 15:20:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(configs):=20D1=20Part=201=20=E2=80=94=20se?= =?UTF-8?q?lf-update=20host=20artifacts=20(guarded=20wrapper,=20rollback?= =?UTF-8?q?=20unit,=20limits=20drop-in,=20sudoers=20alias)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design provenance SPIKE-agent-selfupdate-2026-07-05 (SF findings binding): - felhom-selfupdate-guarded: apply / commit / rollback. Ordering [SF-7]: temp-sweep → path confinement (staging dir only, no '..') → 64-hex + sha256 RE-verify as root (BEFORE .prev) → same-fs assert → .prev snapshot → root-owned staging copy → atomic mv → pending marker → reset-failed [SF-4/5] → detached systemd-run restart, verbatim [SF-6]. rollback is pending-guarded (no pending → exit 0 no-op, [SF-1]) and clears pending BEFORE its restart so per-crash OnFailure re-fires no-op. commit idempotent, .prev retained (S3d). No env-overridable paths (path-fixedness is the security property). shellcheck clean. - felhom-agent-rollback.service: Type=oneshot OnFailure target; comment block documents the systemd-257 per-crash firing reality [SF-1]. - felhom-agent-limits.conf: [Unit]-ONLY drop-in [SF-3] with the spike's tuned values verbatim [SF-2]: StartLimitIntervalSec=120, StartLimitBurst=4, OnFailure=felhom-agent-rollback.service. - sudoers: FELHOM_SELFUPDATE alias (coarse apply glob per S4b — the wrapper re-verify is the real gate) appended to the grant line. visudo -cf OK. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- configs/felhom-agent-limits.conf | 19 ++++ configs/felhom-agent-rollback.service | 21 ++++ configs/felhom-agent.sudoers | 16 ++- configs/felhom-selfupdate-guarded | 139 ++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 configs/felhom-agent-limits.conf create mode 100644 configs/felhom-agent-rollback.service create mode 100644 configs/felhom-selfupdate-guarded diff --git a/configs/felhom-agent-limits.conf b/configs/felhom-agent-limits.conf new file mode 100644 index 0000000..585f9ec --- /dev/null +++ b/configs/felhom-agent-limits.conf @@ -0,0 +1,19 @@ +# felhom-agent-limits.conf — start-limit + rollback-trigger drop-in for felhom-agent.service +# (TASK D1). Install as /etc/systemd/system/felhom-agent.service.d/felhom-agent-limits.conf +# and `systemctl daemon-reload`. +# +# Values are the SPIKE-agent-selfupdate-2026-07-05 tuned set, verbatim [SF-2]: with the unit's +# Restart=on-failure + RestartSec=5s and systemd 257's compiled defaults (10s/5), a crash-looping +# binary NEVER trips the start limit and loops forever (35 starts/180s measured). 120s/4 gives a +# terminal `failed` ≈20s after the first crash. +# +# PLACEMENT TRAP [SF-3]: these keys MUST be in [Unit]. On systemd 257 a [Service] placement is +# HALF-APPLIED — StartLimitBurst is accepted while StartLimitIntervalSec is silently ignored +# (journal warning only). Never split them; never put them in [Service]. +# +# OnFailure fires on EVERY crash on systemd 257 [SF-1] — see the comment block in +# felhom-agent-rollback.service for why that is safe (pending-marker guard). +[Unit] +StartLimitIntervalSec=120 +StartLimitBurst=4 +OnFailure=felhom-agent-rollback.service diff --git a/configs/felhom-agent-rollback.service b/configs/felhom-agent-rollback.service new file mode 100644 index 0000000..4ea4428 --- /dev/null +++ b/configs/felhom-agent-rollback.service @@ -0,0 +1,21 @@ +# felhom-agent-rollback.service — the OnFailure= target that auto-reverts a crash-looping agent +# self-update (TASK D1; SPIKE-agent-selfupdate-2026-07-05). +# +# Install as /etc/systemd/system/felhom-agent-rollback.service. It is referenced by the +# felhom-agent-limits.conf drop-in's OnFailure= line. +# +# THE PER-CRASH-FIRING REALITY [SF-1]: on systemd 257, OnFailure= fires on EVERY crash of the main +# unit — even while it is merely `activating (auto-restart)`, long before (and regardless of) the +# terminal start-limit `failed` state. During one crash incident this unit therefore runs MANY +# times. That is safe BY DESIGN: the wrapper's rollback verb is pending-marker-guarded — the first +# fire after a bad update reverts the binary and clears the marker; every later fire (and any fire +# with no update in flight at all) is a logged no-op that touches nothing. Consequence: a bad +# update is rolled back at the FIRST crash (~seconds), not after the start-limit burst — the tuned +# start-limit in the drop-in is the terminal BACKSTOP (e.g. an environmental crash loop of the +# known-good binary), not the rollback trigger. +[Unit] +Description=Felhom agent self-update auto-rollback (pending-guarded; fires per crash, no-ops without a pending update) + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/felhom-selfupdate-guarded rollback diff --git a/configs/felhom-agent.sudoers b/configs/felhom-agent.sudoers index 21b3fec..9119640 100644 --- a/configs/felhom-agent.sudoers +++ b/configs/felhom-agent.sudoers @@ -164,4 +164,18 @@ Cmnd_Alias FELHOM_WG = \ /usr/bin/systemctl disable --now wg-quick@wg-felhom, \ /usr/bin/wg show wg-felhom latest-handshakes -felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_NETMOUNT, FELHOM_WG +# Agent self-update (TASK D1, SPIKE-agent-selfupdate-2026-07-05). The agent downloads the +# operator-SIGNED binary (sha256 pinned in the signed op — neither hub nor Gitea compromise can +# substitute it), verifies the sha in-process, then hands off to the guarded wrapper, which +# RE-verifies the sha as root, confines the staged path to /var/lib/felhom-agent/selfupdate/, +# performs the A/B flip (atomic same-fs rename, .prev retained) and schedules a detached restart. +# The apply args are a COARSE glob (spike S4b: sudoers fnmatch makes a [a-f0-9]* sha pattern +# first-char-only anyway) — the wrapper's own sha re-verify + path confinement is the real gate. +# `rollback` is normally run by felhom-agent-rollback.service (root, OnFailure=), not via sudo; +# granting it here keeps the verb probe-able (capability self-check) and operator-invokable. +Cmnd_Alias FELHOM_SELFUPDATE = \ + /usr/local/sbin/felhom-selfupdate-guarded apply /var/lib/felhom-agent/selfupdate/* *, \ + /usr/local/sbin/felhom-selfupdate-guarded commit, \ + /usr/local/sbin/felhom-selfupdate-guarded rollback + +felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_NETMOUNT, FELHOM_WG, FELHOM_SELFUPDATE diff --git a/configs/felhom-selfupdate-guarded b/configs/felhom-selfupdate-guarded new file mode 100644 index 0000000..c41583d --- /dev/null +++ b/configs/felhom-selfupdate-guarded @@ -0,0 +1,139 @@ +#!/bin/sh +# felhom-selfupdate-guarded — the ROOT half of the agent's A/B self-update (TASK D1). +# +# Install as /usr/local/sbin/felhom-selfupdate-guarded (0755 root:root). The non-root agent invokes +# `apply`/`commit` via `sudo -n` (FELHOM_SELFUPDATE alias); `rollback` is ALSO the ExecStart of +# felhom-agent-rollback.service — the OnFailure= target that auto-reverts a crash-looping update. +# +# Design provenance: SPIKE-agent-selfupdate-2026-07-05 (its SF-findings are cited inline). The core +# principle: the thing that performs rollback is never the thing being updated — this wrapper + +# systemd change almost never; the agent binary is what flips. +# +# Trust model: the agent verifies the download against the OPERATOR-SIGNED sha before staging; this +# wrapper RE-verifies the same sha as root (defense in depth — the sudoers arg glob is coarse, the +# sha check here is the real gate). Path confinement: apply only ever reads from the agent's own +# staging dir and only ever writes the fixed live path + its siblings. NO env-overridable paths — +# path-fixedness IS the security property (a test-mode override would be a root escalation hole). +# +# Verbs: +# apply stage-verify → .prev → atomic flip → pending marker → detached restart +# commit clear the pending marker (idempotent; .prev retained as a manual net) +# rollback pending-guarded revert to .prev + restart (no pending → exit 0 no-op) +set -u + +BIN=/usr/local/bin/felhom-agent +PREV=$BIN.prev +STAGING=/var/lib/felhom-agent/selfupdate +PENDING=$STAGING/pending.json +UNIT=felhom-agent.service + +# Every refusal/decision goes to stderr AND the journal (strict rule 10). +log() { echo "felhom-selfupdate-guarded: $*" >&2; logger -t felhom-selfupdate-guarded -- "$*" 2>/dev/null || true; } + +case "${1:-}" in + +apply) + staged=${2:-}; want=${3:-} + # [SF-7] entry sweep: a kill between staging-copy and mv leaves an orphaned temp — harmless, + # but sweep it so temps can never accumulate. + rm -f "$BIN".new.* + + if [ -z "$staged" ] || [ -z "$want" ]; then + log "refusing apply: usage: apply " + exit 2 + fi + # Root-side path confinement: the staged binary MUST live in the agent's staging dir. + case "$staged" in + "$STAGING"/*) ;; + *) log "refusing apply: staged path outside $STAGING: $staged"; exit 1 ;; + esac + case "$staged" in + *..*) log "refusing apply: staged path contains '..'"; exit 1 ;; + esac + [ -f "$staged" ] || { log "refusing apply: staged file missing: $staged"; exit 1; } + # The sha must be 64 lowercase hex chars — anything else is refused before any hashing. + case "$want" in + *[!0-9a-f]*) log "refusing apply: sha256 is not lowercase hex"; exit 1 ;; + esac + [ "${#want}" -eq 64 ] || { log "refusing apply: sha256 must be 64 hex chars (got ${#want})"; exit 1; } + + # [SF-7] sha-verify FIRST — before .prev, before any mutation (spike S3a companion ordering). + got=$(sha256sum "$staged" | awk '{print $1}') + if [ "$got" != "$want" ]; then + log "refusing apply: sha mismatch (got=$got want=$want)" + exit 1 + fi + + # Same-fs assert (§8): the atomic-rename guarantee only holds within one filesystem. + if [ "$(stat -c %d "$staged")" != "$(stat -c %d /usr/local/bin)" ]; then + log "refusing apply: staging and /usr/local/bin are on different filesystems — atomic rename impossible" + exit 1 + fi + + old_ver=$("$BIN" --version 2>/dev/null | awk '{print $2}') + [ -n "$old_ver" ] || old_ver=unknown + # The staged filename is felhom-agent- (executor contract) — version without executing. + new_ver=$(basename "$staged"); new_ver=${new_ver#felhom-agent-} + + cp -p "$BIN" "$PREV" || { log "apply failed: cannot snapshot current binary to .prev"; exit 1; } + # Stage a root-owned 0755 copy next to the live path, then ATOMIC same-fs rename. + if ! cp "$staged" "$BIN.new.$$" || ! chmod 0755 "$BIN.new.$$" || ! chown root:root "$BIN.new.$$"; then + rm -f "$BIN.new.$$"; log "apply failed: staging copy"; exit 1 + fi + mv "$BIN.new.$$" "$BIN" || { rm -f "$BIN.new.$$"; log "apply failed: atomic rename"; exit 1; } + + # Pending marker: written AFTER the flip — its existence means "an update is awaiting commit", + # which is exactly the rollback unit's trigger condition. + printf '{"old_version":"%s","new_version":"%s","sha256":"%s","applied_at":"%s"}\n' \ + "$old_ver" "$new_ver" "$want" "$(date -Is)" > "$PENDING" \ + || { log "apply failed: cannot write pending marker"; exit 1; } + + # [SF-4/5] deliberate restarts consume start-limit budget — clear the counter first. + systemctl reset-failed "$UNIT" 2>/dev/null || true + # [SF-6] the spike's S2b winner, verbatim: detached transient timer OUTSIDE the agent's cgroup, + # so the sudo/agent caller survives to log the handoff and the restart cannot be torn down + # by its own requester dying. + systemd-run --on-active=2s --timer-property=AccuracySec=100ms systemctl restart "$UNIT" \ + || { log "apply: flip done but detached restart scheduling FAILED — restart $UNIT manually"; exit 1; } + log "applied $new_ver (prev $old_ver, sha $want); detached restart scheduled" + ;; + +commit) + if [ ! -f "$PENDING" ]; then + log "commit: no pending — no-op" + exit 0 + fi + # .prev is deliberately RETAINED (spike S3d) — a manual safety net until the next apply. + rm -f "$PENDING" || { log "commit failed: cannot remove pending marker"; exit 1; } + log "committed (pending cleared, .prev retained)" + ;; + +rollback) + # [SF-1] On systemd 257 OnFailure= fires on EVERY crash, so this verb runs MANY times per + # incident — the pending-guard makes every fire after the first a harmless no-op, and makes a + # crash with NO update in flight touch nothing at all (spike S1d/S3e). + if [ ! -f "$PENDING" ]; then + log "rollback: no pending update — no-op" + exit 0 + fi + [ -f "$PREV" ] || { log "rollback FAILED: pending exists but no .prev binary"; exit 1; } + rm -f "$BIN".new.* + if ! cp "$PREV" "$BIN.new.$$" || ! chmod 0755 "$BIN.new.$$" || ! chown root:root "$BIN.new.$$"; then + rm -f "$BIN.new.$$"; log "rollback FAILED: staging copy"; exit 1 + fi + mv "$BIN.new.$$" "$BIN" || { rm -f "$BIN.new.$$"; log "rollback FAILED: atomic rename"; exit 1; } + # Clear pending BEFORE the restart: once the binary is reverted, later OnFailure fires must + # no-op (the guard above) instead of re-copying .prev forever. + rm -f "$PENDING" + # [SF-4/5] the crash burst has been eating the start-limit budget — reset before starting. + systemctl reset-failed "$UNIT" 2>/dev/null || true + # Direct restart is correct HERE: this caller is the rollback oneshot, OUTSIDE the agent cgroup. + systemctl restart "$UNIT" || { log "rollback: binary reverted but restart FAILED"; exit 1; } + log "rolled back to previous binary and restarted $UNIT" + ;; + +*) + log "usage: felhom-selfupdate-guarded apply | commit | rollback" + exit 2 + ;; +esac