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:
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
Reference in New Issue
Block a user