Files
felhom.eu/documentation/audits/SPIKE-controllerswap-narrow-grants-2026-06-29.md
T

6.9 KiB

SPIKE — Narrow-grant controller-swap mechanics (stdin tee write + sudoers wildcard matching)

  • Date: 2026-06-29
  • Class: Spike / empirical, read-only / non-destructive (scratch sudoers drop-in + /tmp scratch file only; both removed in teardown). No production code, no change to the real /etc/sudoers.d/felhom-agent, /etc/felhom-controller-image, controllerswap.go, or the bootstrap unit.
  • Host: felhom-pve (demo-felhom, 192.168.0.162), guest LXC 9201; agent runs non-root as felhom-agent. Live controller image at spike time: …/felhom-controller:0.89.0.
  • Verdict: GO — Option A (narrow-grant controller-swap) is mechanically sound as specced. All three questions PASS; no vector needs reshaping.

Q1 — stdin round-trip through sudo -n → pct exec → teePASS

Run as felhom-agent:

printf '%s\n' 'gitea.dooplex.hu/admin/felhom-controller:0.89.0' \
  | sudo -u felhom-agent sudo -n pct exec 9201 -- tee /tmp/felhom-swap-spike
→ tee-exit=0

The bytes written into the guest, od -c tail:

…  c   o   n   t   r   o   l   l   e   r   :   0   .   8   9   .   0  \n

Exact image ref plus the trailing \n — byte-identical to the golden's printf '%s\n' write. The $(cat) parse the bootstrap uses:

IMG=$(cat /tmp/felhom-swap-spike) → parsed=[gitea.dooplex.hu/admin/felhom-controller:0.89.0]

stdin forwards cleanly through sudo -n and pct exec into the in-guest tee; the result is what the reader expects. The bash -c "printf … >" vector can be replaced by stdin-fed tee.


Q2 — sudoers wildcard matching — PASS (all 5 grants)

Scratch grants installed (validated visudo -cf):

/usr/sbin/pct exec [0-9]* -- cat /etc/felhom-controller-image
/usr/sbin/pct exec [0-9]* -- docker image inspect *
/usr/sbin/pct exec [0-9]* -- docker inspect -f *
/usr/sbin/pct exec [0-9]* -- systemctl restart felhom-controller-bootstrap.service
/usr/sbin/pct exec [0-9]* -- tee /tmp/felhom-swap-spike   (scratch path; real grant → /etc/felhom-controller-image)

sudo -n -l -- <real invocation> exit codes (0 = matched/permitted):

Real invocation exit
pct exec 9201 -- docker inspect -f '{{json .State}}' felhom-controller 0
`pct exec 9201 -- docker inspect -f '{{.State.Running}} {{if .State.Health}}…{{end}}
pct exec 9201 -- docker image inspect gitea.dooplex.hu/admin/felhom-controller:0.89.0 0
pct exec 9201 -- cat /etc/felhom-controller-image 0
pct exec 9201 -- systemctl restart felhom-controller-bootstrap.service 0
pct exec 9201 -- tee /tmp/felhom-swap-spike 0

The critical question — does * span spaces? YES. docker inspect -f * matches the full -f '<template-with-spaces-and-braces>' <container> line, the * spanning the format string AND the container across the space. Same for docker image inspect *. (sudo matches the trailing argument string with fnmatch, and an unanchored * covers whitespace.) No grant needs reshaping — the -f template can stay the rich multi-field string; it does not need to be flattened to a fixed template.

Negative controls — the grants stay SCOPED (not a blanket pct exec) — PASS

Non-granted invocation exit (want 1)
pct exec 9201 -- docker rm -f felhom-controller 1
pct exec 9201 -- rm -rf /etc 1
pct exec 9201 -- bash -c 'echo pwned' 1
pct exec 9201 -- tee /etc/felhom-controller-image (real file; scratch only granted /tmp) 1

Arbitrary in-guest execution (bash -c, rm, docker rm) and writing the real image file are all denied. The five narrow grants do NOT widen back into the general pct exec * surface that this work exists to avoid. (In the real grant, tee /etc/felhom-controller-image is a FIXED path — no wildcard — so it is even tighter than the scratch /tmp form tested here.)


Q3 — read compatibility on the live guest — PASS

Deployed unit felhom-controller-bootstrap.serviceExecStart=/usr/local/sbin/felhom-controller-bootstrap.sh. Its read line (verbatim):

9:  IMAGE=$(cat /etc/felhom-controller-image 2>/dev/null || true)
10: [ -n "$IMAGE" ] || { echo "[ctrl-bootstrap] FATAL: …image missing"; exit 1; }

$(cat) strips trailing newlines, so the read is newline-agnostic. The currently-deployed file is 48 bytes ending …0.89.0\n (od-confirmed) — i.e. the live golden already stores image+\n, exactly what a tee-written image\n produces. A tee-written file is consumed identically to today's printf write — no bootstrap change needed.


Go / No-go for Option A — GO

All three unproven mechanisms hold on the live host:

  1. stdin → sudo -npct exectee writes exact bytes incl. the trailing newline (Q1).
  2. The 5 narrow sudoers grants match every real invocation, * spans spaces, and they remain scoped — arbitrary exec stays denied (Q2 + negatives).
  3. The deployed bootstrap's $(cat) read consumes a tee-written file identically (Q3).

No vector needs reshaping before implementation. Implementation notes for the impl task (not this spike):

  • GuestExec (guestbind.go:119) has no stdin path today — the impl needs a stdin-carrying variant (e.g. GuestExecStdin(ctx, vmid, stdin io.Reader, args…)) that sets cmd.Stdin; writeImage switches from bash -c "printf…>" to that variant piping the image into tee <file>.
  • The 5 grants go into the real /etc/sudoers.d/felhom-agent (a new FELHOM_CONTROLLERSWAP alias) — with tee /etc/felhom-controller-image as a FIXED path (no wildcard). Add them to the v0.44.0 capability manifest so the build-test covers them.
  • The image ref is already strict-validated (controllerImageRe) before write — the stdin/tee path removes the shell entirely, so even that is now defence-in-depth rather than the only guard.

Teardown — confirmed clean

/etc/sudoers.d/felhom-agent-spike removed; /tmp/felhom-swap-spike removed; visudo -cf /etc/sudoers parses OK; remaining drop-ins felhom-agent, README, zfs; the real felhom-agent grant is untouched (lxc-info grant still present). No secrets recorded (the image ref is not a secret; no tokens were handled).


IMPLEMENTED 2026-06-29 — felhom-agent v0.45.0

Option A shipped per this spike's GO. writeImage now pipes the image on stdin into an in-guest tee (no bash -c); the 5 narrow grants live in the FELHOM_CONTROLLERSWAP sudoers alias + the capability manifest (Critical). Live: agent reports capabilities self-check ok=45/45; all 5 grants match (sudo -n -l exit 0) and the negative controls (bash -c, tee /etc/passwd, docker rm -f, rm -rf) are denied on the live host. Live swap (Scenario D) awaiting supervised run (restarts the controller). See felhom-agent/REPORT.md + CHANGELOG v0.45.0.