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:
@@ -3,6 +3,41 @@
|
||||
All notable changes to **felhom-agent** are recorded here. Update on every code
|
||||
change that gets pushed.
|
||||
|
||||
## v0.43.0 — canonical systemd unit + binary published to Gitea (BUNDLE slice) (2026-06-28)
|
||||
|
||||
Day-0 no longer needs a hand-installed agent. The agent binary is now PUBLISHED to Gitea as a generic
|
||||
package and the host-bootstrap script fetches → verifies (sha256 vs the hub-vouched manifest) →
|
||||
installs it. This commit adds the **canonical systemd unit** (was hand-made per host) and the publish
|
||||
tooling; the binary itself is a version-only rebuild (no behavioural change).
|
||||
|
||||
- **`configs/felhom-agent.service` (NEW, canonical):** `User=felhom-agent`/`Group=felhom-agent` (the
|
||||
documented non-root production model — README "Process model"; `privileged.mode: "sudo"` + the
|
||||
narrow sudoers allowlist), `ExecStart=/usr/local/bin/felhom-agent --config
|
||||
/etc/felhom-agent/agent.json`, `After=network-online.target pve-cluster.service pveproxy.service`,
|
||||
`Restart=on-failure`, `StateDirectory=felhom-agent`. **Deliberately NO sandboxing**, with the reasons
|
||||
documented inline:
|
||||
- `NoNewPrivileges` is NOT set — it would block the setuid `sudo` the agent needs for every host-root
|
||||
op (mount/format/pct/dnsmasq), silently killing all privileged capability.
|
||||
- NO mount-namespacing hardening (`ProtectHome`/`ProtectSystem`/`PrivateTmp`/…) — any of those give the
|
||||
unit a PRIVATE mount namespace, and the intermediary-mount drive model relies on `mount
|
||||
--make-shared`/`--bind` propagating into the running guest; in a private namespace every drive
|
||||
enrollment would silently break. The agent shares the host mount namespace; the sudoers allowlist is
|
||||
the security boundary.
|
||||
- **`scripts/publish-agent.sh` (NEW):** builds (optional) + PUTs the binary to
|
||||
`/api/packages/admin/generic/felhom-agent/<ver>/felhom-agent` (Gitea generic), prints
|
||||
`AGENT_VERSION` + `AGENT_SHA256`, and does a GET round-trip (re-fetch + sha256 re-check) to prove the
|
||||
artifact is fetchable + intact. Pinned to a version (never `:latest`); idempotent (delete-then-PUT);
|
||||
asserts the binary's `--version` matches the publish version. Creds via `GITEA_USER/GITEA_TOKEN`
|
||||
(falls back to `REGISTRY_USER/REGISTRY_TOKEN`).
|
||||
- **`configs/build-golden.sh`:** after the vzdump archive is produced, computes its sha256 and PUTs it
|
||||
to `/api/packages/admin/generic/felhom-golden/<golden-version>/golden.tar.zst` (`<golden-version>` =
|
||||
the baked controller version), printing `GOLDEN_VERSION` + `GOLDEN_SHA256`. Opt-in (only when the
|
||||
Gitea creds are set); the local-golden auto-discovery stays as a fallback.
|
||||
- **`cmd/felhom-agent/main.go`:** `version` 0.42.0 → 0.43.0.
|
||||
- The operator records the printed agent + golden version+sha256 in the hub (Configs → "Day-0
|
||||
artifacts"); the host-bootstrap script verifies fetched artifacts against those before installing.
|
||||
- `go build/vet/test ./...` green.
|
||||
|
||||
## build-golden.sh — default controller image bumped to current; golden rebuilt at 0.85.1 (2026-06-27)
|
||||
|
||||
**Operational + a default fix (no agent binary change — version stays v0.42.0).**
|
||||
|
||||
@@ -205,11 +205,27 @@ so cleanly if the token/endpoint isn't configured.
|
||||
`--selftest=task --vmid N` (explicitly gated) exercises `WaitTask` on a **reversible** op
|
||||
(snapshot → rollback → delete-snapshot) against guest `N`. Default `--selftest` never mutates.
|
||||
|
||||
## Process model (proposed, not finalized — see 03 §3/§12)
|
||||
## Process model
|
||||
|
||||
Native Go binary, systemd service, **non-root** service user holding the scoped token, with a
|
||||
**narrow sudoers allowlist** for the three fenced ops. `privileged.mode: "sudo"` matches this;
|
||||
`"direct"` is for dev/CI where the agent is already root.
|
||||
Native Go binary, systemd service, **non-root** `felhom-agent` service user holding the scoped token,
|
||||
with a **narrow sudoers allowlist** for the fenced host-root ops. `privileged.mode: "sudo"` matches
|
||||
this; `"direct"` is for dev/CI where the agent is already root.
|
||||
|
||||
The canonical artifacts (BUNDLE slice):
|
||||
- **`configs/felhom-agent.service`** — the canonical unit (`User=felhom-agent`,
|
||||
`ExecStart=/usr/local/bin/felhom-agent --config /etc/felhom-agent/agent.json`). It deliberately sets
|
||||
**no** `NoNewPrivileges` and **no** mount-namespacing hardening (`ProtectHome`/`PrivateTmp`/…): the
|
||||
first would block the `sudo` the agent needs, the second would put the agent in a private mount
|
||||
namespace so its `mount --bind` drive enrollments wouldn't propagate into the running guest. The
|
||||
security boundary is the sudoers allowlist, not systemd sandboxing.
|
||||
- **`configs/felhom-agent.sudoers`** → `/etc/sudoers.d/felhom-agent` (0440, `visudo -cf`-validated).
|
||||
- **`scripts/publish-agent.sh`** publishes the binary to Gitea as a generic package
|
||||
(`/api/packages/admin/generic/felhom-agent/<ver>/felhom-agent`), printing the sha256 the operator
|
||||
records in the hub artifact manifest.
|
||||
|
||||
**Install is automated.** The host-bootstrap script (`felhom.eu/scripts/felhom-host-install.sh`) fetches
|
||||
the binary from Gitea, verifies its sha256 against the hub-vouched manifest, then installs the user +
|
||||
binary + sudoers + unit + config — no manual agent install step.
|
||||
|
||||
## Test
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import (
|
||||
|
||||
// version is the agent version. Overridable at build time with
|
||||
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
|
||||
var version = "0.42.0"
|
||||
var version = "0.43.0"
|
||||
|
||||
// runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook <vmid> <phase>`). On the
|
||||
// pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# publish-agent.sh — build (optional) + publish the felhom-agent binary to Gitea as a generic package.
|
||||
#
|
||||
# Part of the BUNDLE slice: the host-bootstrap script fetches the agent binary from Gitea
|
||||
# (/api/packages/admin/generic/felhom-agent/<ver>/felhom-agent) and verifies its sha256 against the
|
||||
# hub-vouched artifact manifest before installing it. This script PUTs that binary and prints the
|
||||
# version + sha256 the operator records in the hub (Configs → "Day-0 artifacts").
|
||||
#
|
||||
# The binary is PINNED to a version (never :latest). The published path encodes the version.
|
||||
#
|
||||
# Usage:
|
||||
# GITEA_USER=admin GITEA_TOKEN=<token> ./publish-agent.sh <version> [binary-path]
|
||||
#
|
||||
# <version> bare semver, e.g. 0.43.0 (MUST match the binary's `--version`)
|
||||
# [binary-path] path to a prebuilt felhom-agent binary. If omitted, the script builds one with
|
||||
# `go build -ldflags "-X main.version=<version>"` from the repo (needs a Go toolchain).
|
||||
#
|
||||
# Env:
|
||||
# GITEA_USER / GITEA_TOKEN Gitea credentials with package write (the build-server's admin creds).
|
||||
# Falls back to REGISTRY_USER / REGISTRY_TOKEN if those are unset.
|
||||
# GITEA_BASE Gitea base URL (default https://gitea.dooplex.hu)
|
||||
# GITEA_OWNER package owner (default admin)
|
||||
#
|
||||
# Output (stdout, machine-greppable):
|
||||
# AGENT_VERSION=<version>
|
||||
# AGENT_SHA256=<sha256>
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_BASE="${GITEA_BASE:-https://gitea.dooplex.hu}"
|
||||
GITEA_OWNER="${GITEA_OWNER:-admin}"
|
||||
GITEA_USER="${GITEA_USER:-${REGISTRY_USER:-}}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-${REGISTRY_TOKEN:-}}"
|
||||
|
||||
die() { echo "[publish-agent] ERROR: $*" >&2; exit 1; }
|
||||
log() { echo "[publish-agent] $*" >&2; }
|
||||
|
||||
VERSION="${1:-}"
|
||||
BIN="${2:-}"
|
||||
[[ -n "$VERSION" ]] || die "version required (usage: GITEA_USER=.. GITEA_TOKEN=.. $0 <version> [binary-path])"
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "version must be bare semver X.Y.Z (got '$VERSION')"
|
||||
[[ -n "$GITEA_USER" && -n "$GITEA_TOKEN" ]] || die "GITEA_USER + GITEA_TOKEN (or REGISTRY_USER/REGISTRY_TOKEN) required"
|
||||
|
||||
# Resolve repo root from this script's location (scripts/ lives at the repo root).
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Build the binary if no prebuilt path was given.
|
||||
CLEANUP_BIN=""
|
||||
if [[ -z "$BIN" ]]; then
|
||||
command -v go >/dev/null || die "no binary-path given and no 'go' toolchain to build one"
|
||||
BIN="$(mktemp -t felhom-agent.XXXXXX)"
|
||||
CLEANUP_BIN="$BIN"
|
||||
log "building felhom-agent $VERSION from $REPO_ROOT …"
|
||||
( cd "$REPO_ROOT" && CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o "$BIN" ./cmd/felhom-agent )
|
||||
fi
|
||||
[[ -f "$BIN" ]] || die "binary not found: $BIN"
|
||||
trap '[[ -n "$CLEANUP_BIN" ]] && rm -f "$CLEANUP_BIN"' EXIT
|
||||
|
||||
# Sanity: the binary's self-reported version MUST match the publish version (catch a stale rebuild).
|
||||
BIN_VER="$("$BIN" --version 2>/dev/null | awk '{print $2}' || true)"
|
||||
if [[ -n "$BIN_VER" && "$BIN_VER" != "$VERSION" ]]; then
|
||||
die "binary --version is '$BIN_VER' but publishing as '$VERSION' — rebuild with the right ldflags"
|
||||
fi
|
||||
|
||||
SHA256="$(sha256sum "$BIN" | awk '{print $1}')"
|
||||
[[ -n "$SHA256" ]] || die "failed to compute sha256"
|
||||
|
||||
URL="${GITEA_BASE}/api/packages/${GITEA_OWNER}/generic/felhom-agent/${VERSION}/felhom-agent"
|
||||
log "publishing $BIN ($(wc -c < "$BIN") bytes, sha256 ${SHA256:0:16}…) → $URL"
|
||||
|
||||
# Generic packages reject re-upload of an existing version+file with 409/400. Delete-then-put makes
|
||||
# re-publishing a version idempotent (so a rebuild of the same version overwrites cleanly).
|
||||
code="$(curl -fsS -o /dev/null -w '%{http_code}' -u "${GITEA_USER}:${GITEA_TOKEN}" -X DELETE "$URL" 2>/dev/null || true)"
|
||||
log "pre-delete existing artifact: HTTP ${code} (404/204 expected)"
|
||||
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' -u "${GITEA_USER}:${GITEA_TOKEN}" -X PUT --upload-file "$BIN" "$URL")"
|
||||
[[ "$code" == "201" || "$code" == "200" ]] || die "upload failed: HTTP $code"
|
||||
log "upload OK (HTTP $code)"
|
||||
|
||||
# GET round-trip: re-fetch and confirm the stored bytes hash to the same sha256 (proves fetchable +
|
||||
# intact end-to-end, the same path the host-install script will take).
|
||||
TMP_GET="$(mktemp -t felhom-agent-get.XXXXXX)"
|
||||
trap '[[ -n "$CLEANUP_BIN" ]] && rm -f "$CLEANUP_BIN"; rm -f "$TMP_GET"' EXIT
|
||||
curl -fsS -u "${GITEA_USER}:${GITEA_TOKEN}" -o "$TMP_GET" "$URL" || die "round-trip GET failed"
|
||||
GOT_SHA="$(sha256sum "$TMP_GET" | awk '{print $1}')"
|
||||
[[ "$GOT_SHA" == "$SHA256" ]] || die "round-trip sha256 mismatch (put $SHA256, got $GOT_SHA)"
|
||||
log "round-trip GET verified (sha256 matches)"
|
||||
|
||||
echo "AGENT_VERSION=${VERSION}"
|
||||
echo "AGENT_SHA256=${SHA256}"
|
||||
log "DONE. Record in the hub operator UI (Configs → Day-0 artifacts): agent ${VERSION} / ${SHA256}"
|
||||
Reference in New Issue
Block a user