slice 8A (agent half): local-API server + provisioning back-half (v0.10.0)

internal/localapi: per-guest local-API server (doc 03 §6) — 7 self-scoped
endpoints, hashed per-guest token store, persisted self-signed leaf with stable
SHA-256 pin, optional 6th daemon goroutine. internal/provision: back-half —
mint token, render bootstrap.json (no registry cred), write 0600, chown
100000:100000, attach pct-set bind mount (host-side, F3, no pct exec).
--selftest=provision. build-golden.sh bakes the controller image + bootstrap
unit. sudoers FELHOM_PROVISION; firewall narrowing artifact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 09:47:42 +02:00
parent fae11020a5
commit 3fecf4c713
18 changed files with 2203 additions and 11 deletions
+7
View File
@@ -45,5 +45,12 @@
"pbs_verify_cadence_seconds": 0,
"pbs_secret_dir": "/etc/pve/priv/storage"
},
"local_api": {
"enable": true,
"listen_addr": "192.168.0.162:8443",
"cert_file": "/var/lib/felhom-agent/local-api.crt",
"key_file": "/var/lib/felhom-agent/local-api.key",
"token_store": "/var/lib/felhom-agent/local-tokens.log"
},
"log_level": "info"
}
+61 -1
View File
@@ -14,7 +14,15 @@
# symmetric with machine-id.
# - machine-id: truncated; systemd regenerates it on first boot for free (no unit needed).
#
# Usage: build-golden.sh [VMID] [TEMPLATE_VOLID] [ROOTFS_STORAGE] [ARCHIVE_STORAGE] [BRIDGE]
# Slice 8A — the golden now also BAKES the in-guest controller (decision: image baked at golden
# build on the trusted host, so NO registry credential ever enters a customer guest at deploy) and
# a controller-bootstrap unit that, on boot, deploys the baked image from the agent-populated
# config mount (/etc/felhom-bootstrap/bootstrap.json) — no docker login/pull at deploy. Refreshing
# the golden bumps the controller baseline; controller self-update covers in-between drift.
#
# Usage: build-golden.sh [VMID] [TEMPLATE_VOLID] [ROOTFS_STORAGE] [ARCHIVE_STORAGE] [BRIDGE] [CONTROLLER_IMAGE]
# Build-time registry login for the controller pull (used ONCE inside the build guest, then logged
# out — never baked): set REGISTRY_USER + REGISTRY_TOKEN in the environment.
set -euo pipefail
VMID="${1:-9100}"
@@ -22,6 +30,8 @@ TEMPLATE="${2:-local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst}"
ROOTFS_STORAGE="${3:-local-lvm}"
ARCHIVE_STORAGE="${4:-local}"
BRIDGE="${5:-vmbr0}"
CONTROLLER_IMAGE="${6:-gitea.dooplex.hu/admin/felhom-controller:v0.35.0}"
REGISTRY_HOST="${CONTROLLER_IMAGE%%/*}"
echo "[golden] creating build LXC $VMID (nesting=1,keyctl=1, unprivileged) …"
pct create "$VMID" "$TEMPLATE" \
@@ -52,6 +62,56 @@ pct exec "$VMID" -- bash -c '
echo "[golden] verifying Docker works in the build guest …"
pct exec "$VMID" -- bash -c 'systemctl start docker; sleep 2; docker run --rm hello-world >/dev/null && echo " docker OK ($(docker info 2>/dev/null | sed -n "s/.*Storage Driver: //p"))"'
echo "[golden] baking the in-guest controller image $CONTROLLER_IMAGE (no registry cred at deploy) …"
# docker login is used ONCE here on the trusted build host, then logged out before archiving so
# the credential is NEVER baked into the golden. The IMAGE is what gets baked (in Docker storage).
if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_TOKEN:-}" ]; then
pct exec "$VMID" -- bash -c "systemctl start docker; sleep 1; echo '$REGISTRY_TOKEN' | docker login '$REGISTRY_HOST' -u '$REGISTRY_USER' --password-stdin >/dev/null"
fi
pct exec "$VMID" -- bash -c "docker pull '$CONTROLLER_IMAGE'"
pct exec "$VMID" -- bash -c "docker logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; rm -f /root/.docker/config.json"
# Record the baked image ref for the bootstrap unit (so the unit needs no login/pull).
pct exec "$VMID" -- bash -c "printf '%s\n' '$CONTROLLER_IMAGE' > /etc/felhom-controller-image"
echo "[golden] baking the controller-bootstrap unit (deploys the BAKED controller from the config mount) …"
pct push "$VMID" /dev/stdin /usr/local/sbin/felhom-controller-bootstrap.sh --perms 700 <<'BOOTSH'
#!/bin/bash
# felhom controller-bootstrap (slice 8A): the host agent's back-half populated the read-only
# config mount /etc/felhom-bootstrap; this golden-baked oneshot deploys the BAKED controller image
# with that config. NO docker login / NO docker pull — the image is already in this golden's Docker
# storage (and self-update handles version drift). Host-side only; the agent never enters the guest.
set -euo pipefail
CFG=/etc/felhom-bootstrap/bootstrap.json
[ -r "$CFG" ] || { echo "[ctrl-bootstrap] no $CFG — not provisioned, nothing to do"; exit 0; }
IMAGE=$(cat /etc/felhom-controller-image 2>/dev/null || true)
[ -n "$IMAGE" ] || { echo "[ctrl-bootstrap] FATAL: /etc/felhom-controller-image missing"; exit 1; }
echo "[ctrl-bootstrap] deploying $IMAGE from $CFG"
docker rm -f felhom-controller >/dev/null 2>&1 || true
docker run -d --name felhom-controller --restart unless-stopped \
-e FELHOM_BOOTSTRAP_PATH=/etc/felhom-bootstrap/bootstrap.json \
-v /etc/felhom-bootstrap:/etc/felhom-bootstrap:ro \
-v felhom-controller-data:/opt/docker/felhom-controller \
-v /var/run/docker.sock:/var/run/docker.sock \
"$IMAGE"
echo "[ctrl-bootstrap] controller started"
BOOTSH
pct exec "$VMID" -- bash -c 'cat > /etc/systemd/system/felhom-controller-bootstrap.service <<UNIT
[Unit]
Description=Felhom controller bootstrap (deploy the baked controller from the agent-populated config mount)
After=docker.service network-online.target
Wants=docker.service network-online.target
ConditionPathExists=/etc/felhom-bootstrap/bootstrap.json
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/felhom-controller-bootstrap.sh
[Install]
WantedBy=multi-user.target
UNIT
systemctl enable felhom-controller-bootstrap.service'
echo "[golden] baking the first-boot SSH host-key regeneration unit (F3) …"
pct exec "$VMID" -- bash -c 'cat > /etc/systemd/system/felhom-regen-hostkeys.service <<UNIT
[Unit]
+11 -1
View File
@@ -26,4 +26,14 @@ Cmnd_Alias FELHOM_DISK = \
/usr/sbin/smartctl -a -j /dev/hd[a-z]*, \
/usr/sbin/lvs --reportformat json --units b -o lv_name,data_percent,metadata_percent -- *
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK
# Provisioning back-half (slice 8A, doc 03 §6): populate a guest's bootstrap config mount
# host-side (internal/provision). These are host-root ops the API token cannot do — a bind mount
# is root@pam-only, and the chown maps the 0600 bootstrap.json to the unprivileged-LXC guest-root
# (uid/gid 100000, spike gotcha 1). The host dir is AGENT-OWNED state under /var/lib/felhom-agent/
# (the wildcard only ever names a path the agent itself created), and the bootstrap file the agent
# writes there is the only thing these touch. ':' is escaped per sudoers grammar.
Cmnd_Alias FELHOM_PROVISION = \
/usr/bin/chown -R 100000\:100000 /var/lib/felhom-agent/guests/*, \
/usr/sbin/pct set [0-9]* -mp[0-9]* /var/lib/felhom-agent/guests/*
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION
+40
View File
@@ -0,0 +1,40 @@
# felhom-agent local API — host firewall narrowing (doc 03 §6, slice 8A)
#
# Defense-in-depth for the per-guest local API (the controller→agent channel on the host
# bridge). The PER-GUEST BEARER TOKEN is the authorization gate; this firewall rule is an
# ADDITIONAL layer that limits who can even reach the port. The slice-8A spike found no rule
# was needed for reachability on the demo (PVE firewall off) — this narrows exposure so that
# only guests on the bridge subnet (not arbitrary LAN hosts) can open a connection.
#
# The agent already binds the listener to the host BRIDGE IP (local_api.listen_addr), not
# 0.0.0.0. This file adds the subnet restriction. Apply it at HOST SETUP (it is a host-level
# packet-filter change, intentionally OUTSIDE the agent's 3-exception privileged fence — the
# agent never mutates the host firewall at runtime).
#
# Replace the bridge IP (192.168.0.162), port (8443), and the guest bridge subnet
# (192.168.0.0/24) with this host's values.
#
# ---------------------------------------------------------------------------------------------
# Option A — nftables (recommended on PVE 8/9; inet filter table). Insert ABOVE any accept:
#
# nft add rule inet filter input ip daddr 192.168.0.162 tcp dport 8443 \
# ip saddr != 192.168.0.0/24 drop
# nft add rule inet filter input ip daddr 192.168.0.162 tcp dport 8443 \
# ip saddr 192.168.0.0/24 accept
#
# ---------------------------------------------------------------------------------------------
# Option B — iptables:
#
# iptables -A INPUT -d 192.168.0.162 -p tcp --dport 8443 -s 192.168.0.0/24 -j ACCEPT
# iptables -A INPUT -d 192.168.0.162 -p tcp --dport 8443 -j DROP
#
# ---------------------------------------------------------------------------------------------
# Option C — PVE host firewall (/etc/pve/nodes/<node>/host.fw), if the PVE firewall is enabled.
# Add under [RULES] (and ensure the firewall is enabled in cluster.fw / host.fw):
#
# [RULES]
# IN ACCEPT -source 192.168.0.0/24 -dport 8443 -proto tcp -log nolog
# IN DROP -dport 8443 -proto tcp -log nolog
#
# Verify after applying: from a guest ON the bridge, a TLS connect to <bridge-ip>:8443 succeeds;
# from an OFF-bridge host it is refused/dropped. (The token + leaf-pin still gate the request.)