R-861 (a) A1 + (b) B2: the image ref goes to a root verb that checks it; the agent's in-guest tee grant is gone; felhom-op's pct lines are exact (09 §3 decision 165)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -145,7 +145,8 @@ var manifest = []Capability{
|
||||
{"controllerswap-image-inspect", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "image", "inspect", "gitea.dooplex.hu/admin/felhom-controller:0.0.0"}, true, ""},
|
||||
{"controllerswap-inspect", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "inspect", "-f", "{{.State.Running}}", "felhom-controller"}, true, ""},
|
||||
{"controllerswap-restart", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "systemctl", "restart", "felhom-controller-bootstrap.service"}, true, ""},
|
||||
{"controllerswap-write", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "tee", "/etc/felhom-controller-image"}, true, ""},
|
||||
// R-861 (a) A1 (decision 165): the write goes through the ROOT verb that checks the ref; the agent has no `tee` grant.
|
||||
{"controllerswap-write", "controller-swap / managed auto-update", "/usr/local/sbin/felhom-priv-apply", []string{"controller-image", "9201"}, true, ""},
|
||||
|
||||
// ---- Stale-lock recovery (FELHOM_STALELOCK, v0.49.0; Critical: a guest stuck behind a stale
|
||||
// reboot-during-backup lock can't start → the customer box stays DOWN until this clears it) ----
|
||||
|
||||
@@ -200,7 +200,8 @@ func TestRedProof_DroppedGrantFailsCheck(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRedProof_DroppedControllerSwapTeeFailsCheck is the companion red-proof for the v0.45.0
|
||||
// FELHOM_CONTROLLERSWAP grants: with the `tee /etc/felhom-controller-image` line removed, the
|
||||
// FELHOM_CONTROLLERSWAP grants: with the write grant removed (since R-861 (a) A1 the `felhom-priv-apply controller-image`
|
||||
// line; before it, an agent `tee /etc/felhom-controller-image`), the
|
||||
// controllerswap-write capability MUST be reported uncovered. Proves the build gate watches the new
|
||||
// swap write grant (so dropping it can't ship a non-root agent that silently can't auto-update).
|
||||
func TestRedProof_DroppedControllerSwapTeeFailsCheck(t *testing.T) {
|
||||
@@ -210,7 +211,7 @@ func TestRedProof_DroppedControllerSwapTeeFailsCheck(t *testing.T) {
|
||||
}
|
||||
var kept []string
|
||||
for _, ln := range strings.Split(string(data), "\n") {
|
||||
if strings.Contains(ln, "tee /etc/felhom-controller-image") {
|
||||
if strings.Contains(ln, "felhom-priv-apply ^controller-image") { // R-861 (a) A1: the write's grant
|
||||
continue
|
||||
}
|
||||
kept = append(kept, ln)
|
||||
@@ -229,7 +230,7 @@ func TestRedProof_DroppedControllerSwapTeeFailsCheck(t *testing.T) {
|
||||
}
|
||||
cmdline := write.Binary + " " + strings.Join(write.ReprArgs, " ")
|
||||
if matchesAny(cmdline, entries) {
|
||||
t.Errorf("red-proof FAILED: controllerswap-write still matches after dropping the tee grant")
|
||||
t.Errorf("red-proof FAILED: controllerswap-write still matches after dropping its grant")
|
||||
}
|
||||
if full := parseSudoersEntries(t, string(data)); !matchesAny(cmdline, full) {
|
||||
t.Errorf("controllerswap-write should be covered by the real sudoers")
|
||||
|
||||
@@ -49,6 +49,10 @@ var r861Injections = []string{
|
||||
"/usr/local/sbin/felhom-priv-apply unit ../../etc/x.mount",
|
||||
"/usr/local/sbin/felhom-priv-apply dnsmasq /etc/shadow felhom-x.conf",
|
||||
"/usr/local/sbin/felhom-priv-apply wg /etc/shadow",
|
||||
// R-861 (a) A1 (decision 165): the agent wrote ANY image ref into the guest by `tee` — now only the root verb may
|
||||
"/usr/sbin/pct exec 9201 -- tee /etc/felhom-controller-image",
|
||||
"/usr/local/sbin/felhom-priv-apply controller-image 9201 9202",
|
||||
"/usr/local/sbin/felhom-priv-apply controller-image 9201;id",
|
||||
}
|
||||
|
||||
func TestSudoersRefusesTheR861Injections(t *testing.T) {
|
||||
@@ -93,3 +97,42 @@ func TestSudoersFstrimRuleIsExact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R-861 (a) A1: the managed controller update still has its route — the root verb, one numeric vmid.
|
||||
func TestSudoersAllowsTheControllerImageVerb(t *testing.T) {
|
||||
data, err := os.ReadFile(sudoersPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !matchesAny("/usr/local/sbin/felhom-priv-apply controller-image 9201", parseSudoersEntries(t, string(data))) {
|
||||
t.Fatal("the sudoers does not allow `felhom-priv-apply controller-image 9201` — a managed controller update cannot write its image")
|
||||
}
|
||||
}
|
||||
|
||||
// R-861 (b) B2 (decision 165, hygiene): felhom-op's `pct start|stop|unlock` grants are ONE numeric vmid each. The old
|
||||
// glob `[0-9]*` eats spaces, so `pct stop 9201 --skiplock 1` and two vmids matched.
|
||||
// RED-PROOF: on the pre-B2 felhom-op.sudoers (`/usr/sbin/pct stop [0-9]*`) the decoys match.
|
||||
func TestFelhomOpSudoersPctIsExact(t *testing.T) {
|
||||
data, err := os.ReadFile("../../configs/felhom-op.sudoers")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := parseSudoersEntries(t, string(data))
|
||||
for _, ok := range []string{"/usr/sbin/pct start 9201", "/usr/sbin/pct stop 9201", "/usr/sbin/pct unlock 9201", "/usr/sbin/pct list"} {
|
||||
if !matchesAny(ok, entries) {
|
||||
t.Errorf("felhom-op lost a repair verb: %s", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{
|
||||
"/usr/sbin/pct stop 9201 --skiplock 1",
|
||||
"/usr/sbin/pct start 9201 9202",
|
||||
"/usr/sbin/pct unlock 9201 --whatever",
|
||||
"/usr/sbin/pct start 92a1",
|
||||
"/usr/sbin/pct stop ",
|
||||
"/usr/sbin/pct destroy 9201",
|
||||
} {
|
||||
if matchesAny(bad, entries) {
|
||||
t.Errorf("felhom-op's sudoers allows %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -54,8 +53,8 @@ func (f *supExec) GuestExec(_ context.Context, vmid int, args ...string) (string
|
||||
}
|
||||
return "", errors.New("supExec: unexpected args")
|
||||
}
|
||||
func (f *supExec) GuestExecStdin(context.Context, int, io.Reader, ...string) (string, error) {
|
||||
return "", errors.New("supExec: no stdin exec expected")
|
||||
func (f *supExec) WriteControllerImage(context.Context, int, string) error {
|
||||
return errors.New("supExec: no image write expected")
|
||||
}
|
||||
func (f *supExec) count(vmid int) int {
|
||||
f.mu.Lock()
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -41,9 +40,10 @@ func ValidControllerImage(ref string) bool { return controllerImageRe.MatchStrin
|
||||
// faked in tests. The single seam the swap composes over (no hand-rolled pct).
|
||||
type GuestExecutor interface {
|
||||
GuestExec(ctx context.Context, vmid int, args ...string) (string, error)
|
||||
// GuestExecStdin is GuestExec with the command's stdin fed from stdin — the swap write pipes the
|
||||
// image ref into an in-guest `tee` (no shell vector).
|
||||
GuestExecStdin(ctx context.Context, vmid int, stdin io.Reader, args ...string) (string, error)
|
||||
// WriteControllerImage writes the image ref into the guest's /etc/felhom-controller-image through the ROOT
|
||||
// verb `felhom-priv-apply controller-image <vmid>` (R-861 (a) A1, `09` §3 decision 165), which re-checks the
|
||||
// ref against our registry + repository + x.y.z. The agent no longer holds a `tee` grant into the guest.
|
||||
WriteControllerImage(ctx context.Context, vmid int, image string) error
|
||||
}
|
||||
|
||||
// ControllerSwapState is the durable record of a swap (crash-safety + status). Written before the swap
|
||||
@@ -141,14 +141,11 @@ func (c *ControllerSwapper) imagePresent(ctx context.Context, vmid int, image st
|
||||
}
|
||||
|
||||
func (c *ControllerSwapper) writeImage(ctx context.Context, vmid int, image string) error {
|
||||
// Non-root path: pipe the image ref into an in-guest `tee` over stdin — no shell, no
|
||||
// interpolation, no `bash -c` (the only swap vector that would have needed an arbitrary-exec
|
||||
// grant). The trailing "\n" makes the on-disk bytes byte-identical to the golden's
|
||||
// `printf '%s\n'`; the bootstrap reads `IMAGE=$(cat …)` so the newline is stripped on read
|
||||
// (spike SPIKE-controllerswap-narrow-grants-2026-06-29). image is strict-validated
|
||||
// (controllerImageRe) upstream in Swap; defence-in-depth, the stdin path can't smuggle anyway.
|
||||
_, err := c.exec.GuestExecStdin(ctx, vmid, strings.NewReader(image+"\n"), "tee", controllerImageFile)
|
||||
return err
|
||||
// R-861 (a) A1: the ROOT verb writes the file (it re-checks the ref — a compromised agent cannot hand the guest's
|
||||
// bootstrap another image). The bytes are `image\n`, byte-identical to the golden's `printf '%s\n'`; the bootstrap
|
||||
// reads `IMAGE=$(cat …)` so the newline is stripped on read. image is also strict-validated (controllerImageRe)
|
||||
// upstream in Swap.
|
||||
return c.exec.WriteControllerImage(ctx, vmid, image)
|
||||
}
|
||||
|
||||
func (c *ControllerSwapper) restartBootstrap(ctx context.Context, vmid int) error {
|
||||
|
||||
@@ -23,7 +23,7 @@ type fakeGuestExec struct {
|
||||
present map[string]bool // images pulled into the guest
|
||||
good map[string]bool // images that report healthy when running
|
||||
containerImg string // image the running container currently has
|
||||
teeStdin []string // raw bytes piped into each `tee` write (the swap's write vector)
|
||||
teeStdin []string // image refs handed to WriteControllerImage (the root verb, R-861 (a) A1)
|
||||
failRestart bool
|
||||
noHealthBlock bool // if set, .State.Health is absent ("none")
|
||||
restartCount int // F1: .RestartCount reported by docker inspect (a crash-looper has >0)
|
||||
@@ -75,20 +75,15 @@ func (f *fakeGuestExec) GuestExec(_ context.Context, _ int, args ...string) (str
|
||||
return "", fmt.Errorf("fake: unexpected exec %v", args)
|
||||
}
|
||||
|
||||
// GuestExecStdin models the swap's write vector: `tee /etc/felhom-controller-image` with the image
|
||||
// piped on stdin. It records the raw stdin bytes and sets the modeled file content (newline-stripped,
|
||||
// as the bootstrap's `IMAGE=$(cat …)` read would see it).
|
||||
func (f *fakeGuestExec) GuestExecStdin(_ context.Context, _ int, stdin io.Reader, args ...string) (string, error) {
|
||||
// WriteControllerImage models the swap's write: the root verb `felhom-priv-apply controller-image <vmid>` (R-861
|
||||
// (a) A1). It records the ref and sets the modeled file content, as the bootstrap's `IMAGE=$(cat …)` would read it.
|
||||
func (f *fakeGuestExec) WriteControllerImage(_ context.Context, _ int, image string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, args)
|
||||
b, _ := io.ReadAll(stdin)
|
||||
if len(args) >= 2 && args[0] == "tee" && args[1] == controllerImageFile {
|
||||
f.teeStdin = append(f.teeStdin, string(b))
|
||||
f.imageFile = strings.TrimSpace(string(b))
|
||||
return string(b), nil // tee echoes stdin to stdout
|
||||
}
|
||||
return "", fmt.Errorf("fake: unexpected exec-stdin args=%v stdin=%q", args, string(b))
|
||||
f.calls = append(f.calls, []string{"felhom-priv-apply", "controller-image", image})
|
||||
f.teeStdin = append(f.teeStdin, image+"\n")
|
||||
f.imageFile = image
|
||||
return nil
|
||||
}
|
||||
|
||||
// wrote reports whether the image was written via the stdin `tee` vector with the exact `image\n`
|
||||
@@ -162,7 +157,7 @@ func TestControllerSwap_Happy(t *testing.T) {
|
||||
|
||||
// The write vector must be the stdin `tee` with byte-identical `image\n` and NO shell — the
|
||||
// controllerswap.go writeImage rewrite. This would FAIL on the pre-change `bash -c "printf … >"` impl.
|
||||
func TestControllerSwap_WriteViaStdinTee_NoShell(t *testing.T) {
|
||||
func TestControllerSwap_WriteViaRootVerb_NoShell(t *testing.T) {
|
||||
fe := &fakeGuestExec{
|
||||
imageFile: prevImg,
|
||||
present: map[string]bool{newImg: true},
|
||||
@@ -173,22 +168,15 @@ func TestControllerSwap_WriteViaStdinTee_NoShell(t *testing.T) {
|
||||
t.Fatalf("state = %q, want done", st.State)
|
||||
}
|
||||
if !fe.wrote(newImg) {
|
||||
t.Errorf("expected a tee write of %q+\\n; teeStdin=%q", newImg, fe.teeStdin)
|
||||
t.Errorf("expected the root verb to write %q; writes=%q", newImg, fe.teeStdin)
|
||||
}
|
||||
sawTee := false
|
||||
for _, c := range fe.calls {
|
||||
if len(c) >= 2 && c[0] == "tee" {
|
||||
sawTee = true
|
||||
if c[1] != controllerImageFile {
|
||||
t.Errorf("tee target = %q, want fixed %q", c[1], controllerImageFile)
|
||||
}
|
||||
if len(c) >= 1 && c[0] == "tee" {
|
||||
t.Errorf("the swap still uses an in-guest tee (R-861 (a) A1 removed that grant): %v", c)
|
||||
}
|
||||
}
|
||||
if !sawTee {
|
||||
t.Error("no tee call recorded — writeImage did not use the stdin tee vector")
|
||||
}
|
||||
if fe.usedShell() {
|
||||
t.Errorf("swap used a shell vector (bash/-c/printf) — must be stdin tee only; calls=%v", fe.calls)
|
||||
t.Errorf("swap used a shell vector (bash/-c/printf); calls=%v", fe.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,9 +288,7 @@ func (s *inspectScript) GuestExec(_ context.Context, _ int, args ...string) (str
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
func (s *inspectScript) GuestExecStdin(_ context.Context, _ int, _ io.Reader, _ ...string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (s *inspectScript) WriteControllerImage(context.Context, int, string) error { return nil }
|
||||
|
||||
func fastSwapper(exec GuestExecutor) *ControllerSwapper {
|
||||
s := NewControllerSwapper(exec, "", discardLogger())
|
||||
|
||||
@@ -3,7 +3,6 @@ package localapi
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -126,14 +125,16 @@ func (b *GuestBinder) GuestExec(ctx context.Context, vmid int, args ...string) (
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// GuestExecStdin is GuestExec with the in-guest command's stdin fed from stdin. The controller-swap
|
||||
// write uses it to pipe the image ref into an in-guest `tee` (no shell vector, no interpolation),
|
||||
// through the same fenced runner so the `sudo -n` prefix stays in one place.
|
||||
func (b *GuestBinder) GuestExecStdin(ctx context.Context, vmid int, stdin io.Reader, args ...string) (string, error) {
|
||||
pctArgs := append([]string{"exec", strconv.Itoa(vmid), "--"}, args...)
|
||||
out, stderr, err := b.runner.RunStdin(ctx, stdin, "pct", pctArgs...)
|
||||
// privApplyBin is the root content checker (R-861); its `controller-image` verb writes the guest's image file.
|
||||
const privApplyBin = "/usr/local/sbin/felhom-priv-apply"
|
||||
|
||||
// WriteControllerImage pipes `image\n` to `felhom-priv-apply controller-image <vmid>` through the same fenced runner
|
||||
// (the `sudo -n` prefix stays in one place). The verb checks the ref as root and writes the guest file itself
|
||||
// (R-861 (a) A1, `09` §3 decision 165). Pinned by TestR861_WriteControllerImageUsesTheRootVerb.
|
||||
func (b *GuestBinder) WriteControllerImage(ctx context.Context, vmid int, image string) error {
|
||||
_, stderr, err := b.runner.RunStdin(ctx, strings.NewReader(image+"\n"), privApplyBin, "controller-image", strconv.Itoa(vmid))
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("pct exec %d %v: %w: %s", vmid, args, err, strings.TrimSpace(string(stderr)))
|
||||
return fmt.Errorf("felhom-priv-apply controller-image %d: %w: %s", vmid, err, strings.TrimSpace(string(stderr)))
|
||||
}
|
||||
return string(out), nil
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stdinRecorder is a proxmox.Runner that records each call and the stdin it was handed.
|
||||
type stdinRecorder struct {
|
||||
name string
|
||||
args []string
|
||||
stdin string
|
||||
}
|
||||
|
||||
func (r *stdinRecorder) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
r.name, r.args = name, args
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r *stdinRecorder) RunStdin(_ context.Context, stdin io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||
b, _ := io.ReadAll(stdin)
|
||||
r.name, r.args, r.stdin = name, args, string(b)
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// R-861 (a) A1 (`09` §3 decision 165): the managed controller update writes the guest's image file through the ROOT
|
||||
// verb, never through an in-guest `tee` the agent could feed any image.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed): restore the pre-A1 body (`b.runner.RunStdin(ctx, …, "pct", "exec", vmid, "--",
|
||||
// "tee", controllerImageFile)`) → this fails with "the image write ran pct …, want felhom-priv-apply". Restored.
|
||||
func TestR861_WriteControllerImageUsesTheRootVerb(t *testing.T) {
|
||||
rec := &stdinRecorder{}
|
||||
b := NewGuestBinder(rec, discardLogger())
|
||||
const img = "gitea.dooplex.hu/admin/felhom-controller:0.302.0"
|
||||
if err := b.WriteControllerImage(context.Background(), 9201, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.name != privApplyBin {
|
||||
t.Fatalf("the image write ran %s %v, want felhom-priv-apply", rec.name, rec.args)
|
||||
}
|
||||
if len(rec.args) != 2 || rec.args[0] != "controller-image" || rec.args[1] != "9201" {
|
||||
t.Fatalf("argv = %v, want [controller-image 9201] (the sudoers line `^controller-image [0-9]+$`)", rec.args)
|
||||
}
|
||||
if rec.stdin != img+"\n" {
|
||||
t.Fatalf("stdin = %q, want the ref plus one newline", rec.stdin)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user