v0.43.0: canonical systemd unit + publish agent binary + golden to Gitea (BUNDLE slice)

- configs/felhom-agent.service: canonical non-root unit (User=felhom-agent, sudo model);
  deliberately NO NoNewPrivileges (breaks sudo) and NO mount-namespacing hardening (breaks
  the intermediary-mount drive propagation into guests) — documented inline.
- scripts/publish-agent.sh: build (optional) + PUT binary to Gitea generic + sha256 +
  GET round-trip. Pinned version, idempotent (delete-then-PUT).
- configs/build-golden.sh: after vzdump, compute sha256 + PUT golden.tar.zst to Gitea
  generic (version = baked controller version). Opt-in; local auto-discovery stays fallback.
- cmd/felhom-agent/main.go: version 0.42.0 -> 0.43.0.
- README: process model now canonical (non-root + publish/install).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 08:38:01 +02:00
parent aaa276a7b9
commit 29aeaa6bb4
6 changed files with 243 additions and 5 deletions
+46
View File
@@ -269,4 +269,50 @@ grep -q "including mount point mp1" /tmp/golden-vzdump.log \
VOLID=$(pvesm list "$ARCHIVE_STORAGE" --content backup 2>/dev/null | awk -v v="$VMID" '$1 ~ ("vzdump-lxc-" v "-") {print $1}' | sort | tail -1)
echo "[golden] DONE. golden archive volid: ${VOLID:-<check ${ARCHIVE_STORAGE} dump dir>} (rootfs ${OS_SIZE_GB}G + Docker-data ${GOLDEN_DOCKER_GB}G + user-data ${GOLDEN_SYSDATA_GB}G, all in the archive)"
#-------------------------------------------------------------------------------
# Publish to Gitea (BUNDLE slice) — make this golden fetchable by the host-bootstrap script.
#-------------------------------------------------------------------------------
# The host-install script fetches the golden from Gitea
# (/api/packages/admin/generic/felhom-golden/<golden-version>/golden.tar.zst) and verifies its sha256
# against the hub-vouched artifact manifest before importing it. <golden-version> = the BAKED
# controller version (so the golden's published version tracks what it ships). Publishing is OPT-IN:
# only runs when GITEA_USER + GITEA_TOKEN (or REGISTRY_USER/REGISTRY_TOKEN) are set. The local-golden
# auto-discovery in the host-install script stays as a fallback either way.
GITEA_BASE="${GITEA_BASE:-https://gitea.dooplex.hu}"
GITEA_OWNER="${GITEA_OWNER:-admin}"
PUB_USER="${GITEA_USER:-${REGISTRY_USER:-}}"
PUB_TOKEN="${GITEA_TOKEN:-${REGISTRY_TOKEN:-}}"
# Golden version = the baked controller tag's version (strip the image path + any leading 'v').
GOLDEN_VERSION="${GOLDEN_VERSION:-${CONTROLLER_IMAGE##*:}}"; GOLDEN_VERSION="${GOLDEN_VERSION#v}"
if [ -z "$VOLID" ]; then
echo "[golden] WARN: could not resolve the archive volid — skipping Gitea publish."
elif [ -z "$PUB_USER" ] || [ -z "$PUB_TOKEN" ]; then
echo "[golden] Gitea publish SKIPPED (set GITEA_USER+GITEA_TOKEN or REGISTRY_USER+REGISTRY_TOKEN to enable)."
echo "[golden] would publish version=$GOLDEN_VERSION from volid $VOLID"
else
# Resolve the archive's on-disk path (pvesm path turns a volid into a filesystem path).
ARCHIVE_PATH="$(pvesm path "$VOLID" 2>/dev/null || true)"
if [ -z "$ARCHIVE_PATH" ] || [ ! -f "$ARCHIVE_PATH" ]; then
echo "[golden] WARN: cannot resolve archive path for $VOLID — skipping publish."
else
GOLDEN_SHA256="$(sha256sum "$ARCHIVE_PATH" | awk '{print $1}')"
PUB_URL="${GITEA_BASE}/api/packages/${GITEA_OWNER}/generic/felhom-golden/${GOLDEN_VERSION}/golden.tar.zst"
echo "[golden] publishing golden ($(wc -c < "$ARCHIVE_PATH") bytes, sha256 ${GOLDEN_SHA256:0:16}…) → $PUB_URL"
# Delete-then-PUT so re-publishing the same version overwrites cleanly (idempotent).
dcode="$(curl -fsS -o /dev/null -w '%{http_code}' -u "${PUB_USER}:${PUB_TOKEN}" -X DELETE "$PUB_URL" 2>/dev/null || true)"
echo "[golden] pre-delete existing: HTTP ${dcode} (404/204 expected)"
ucode="$(curl -sS -o /dev/null -w '%{http_code}' -u "${PUB_USER}:${PUB_TOKEN}" -X PUT --upload-file "$ARCHIVE_PATH" "$PUB_URL")"
if [ "$ucode" = "201" ] || [ "$ucode" = "200" ]; then
echo "[golden] upload OK (HTTP $ucode)"
echo "GOLDEN_VERSION=${GOLDEN_VERSION}"
echo "GOLDEN_SHA256=${GOLDEN_SHA256}"
echo "[golden] Record in the hub operator UI (Configs → Day-0 artifacts): golden ${GOLDEN_VERSION} / ${GOLDEN_SHA256}"
else
echo "[golden] WARN: golden upload failed (HTTP $ucode) — the local archive is still usable via auto-discovery."
fi
fi
fi
echo "[golden] (the build guest $VMID is stopped; destroy it with: pct destroy $VMID --purge)"
+50
View File
@@ -0,0 +1,50 @@
# felhom-agent.service — canonical systemd unit for the Felhom host agent.
#
# Install as /etc/systemd/system/felhom-agent.service (the host-bootstrap script installs this from
# the Gitea-published binary; previously this unit was hand-made on each host). The agent runs as the
# NON-root `felhom-agent` service user (the documented production model — README "Process model";
# `privileged.mode: "sudo"`) and shells the few host-root ops out via `sudo -n` against the fixed-arg
# allowlist in /etc/sudoers.d/felhom-agent (configs/felhom-agent.sudoers). The User= here and the
# sudoers `felhom-agent ALL=(root) NOPASSWD: …` MUST name the SAME user.
#
# Paths MUST match what the sudoers / host-install script expect:
# binary /usr/local/bin/felhom-agent
# config /etc/felhom-agent/agent.json (0600 felhom-agent:felhom-agent — secrets live here)
# state /var/lib/felhom-agent (nonces, local-api cert/key/tokens, staged units, guests)
#
# === DELIBERATELY NO SANDBOXING — read before adding any hardening directive ===
#
# 1. NoNewPrivileges is NOT set. It is INCOMPATIBLE with the agent's privilege model: it blocks the
# setuid `sudo` the agent relies on for EVERY host-root op (mount, format, pct, dnsmasq …), so the
# agent would silently lose all privileged capability. The narrow surface comes from the sudoers
# fixed-arg allowlist + the agent's in-process fine validation (internal/storage/validate.go), NOT
# from NoNewPrivileges. Do not add it.
#
# 2. NO mount-namespacing hardening (ProtectHome, ProtectSystem, PrivateTmp, ReadOnlyPaths,
# ProtectControlGroups, …). Any of these give the unit a PRIVATE mount namespace — and the agent's
# intermediary-mount drive model does `mount --make-shared /mnt/felhom-drives` + `mount --bind` and
# relies on those propagating into the RUNNING customer guest. In a private namespace the binds
# would be invisible to the host/guest and every external-drive enrollment would silently break.
# The agent MUST share the host mount namespace. The security boundary is the sudoers allowlist.
[Unit]
Description=Felhom host agent (Proxmox host tier; hub control loop + PBS verify + storage watchdog)
Documentation=https://gitea.dooplex.hu/admin/felhom-agent
After=network-online.target pve-cluster.service pveproxy.service
Wants=network-online.target
[Service]
Type=simple
User=felhom-agent
Group=felhom-agent
ExecStart=/usr/local/bin/felhom-agent --config /etc/felhom-agent/agent.json
Restart=on-failure
RestartSec=5s
# State dir: created 0750 felhom-agent:felhom-agent on start if absent (local-api cert/key/tokens,
# nonces, staged .mount units, per-guest bootstrap dirs all live here).
StateDirectory=felhom-agent
StateDirectoryMode=0750
[Install]
WantedBy=multi-user.target