controllerswap: stdin tee write + narrow FELHOM_CONTROLLERSWAP grants (non-root, v0.45.0)
writeImage drops bash -c/printf for GuestExecStdin(img+\n -> tee /etc/felhom-controller-image); new Runner.RunStdin/GuestExecStdin route stdin through the fenced sudo -n runner. 5 narrow, auditable sudoers grants (no general pct exec, no bash -c) + capability manifest entries (Critical) so the self-probe watches them and the build-test asserts coverage (companion red-proof). No controller change; swap orchestration/rollback/state unchanged. Spike GO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPZ4GJ8L5Jqf8UiPwbn1kt
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -39,6 +40,9 @@ 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)
|
||||
}
|
||||
|
||||
// ControllerSwapState is the durable record of a swap (crash-safety + status). Written before the swap
|
||||
@@ -133,10 +137,13 @@ func (c *ControllerSwapper) imagePresent(ctx context.Context, vmid int, image st
|
||||
}
|
||||
|
||||
func (c *ControllerSwapper) writeImage(ctx context.Context, vmid int, image string) error {
|
||||
// image is strict-validated (controllerImageRe) before we ever get here, so this single-quoted
|
||||
// interpolation cannot smuggle shell metacharacters.
|
||||
cmd := fmt.Sprintf("printf '%%s\\n' '%s' > %s", image, controllerImageFile)
|
||||
_, err := c.exec.GuestExec(ctx, vmid, "bash", "-c", cmd)
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -24,12 +23,11 @@ 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)
|
||||
failRestart bool
|
||||
noHealthBlock bool // if set, .State.Health is absent ("none")
|
||||
}
|
||||
|
||||
var writeImgRe = regexp.MustCompile(`'([^']+)' >`)
|
||||
|
||||
func (f *fakeGuestExec) GuestExec(_ context.Context, _ int, args ...string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -42,13 +40,6 @@ func (f *fakeGuestExec) GuestExec(_ context.Context, _ int, args ...string) (str
|
||||
return args[3], nil
|
||||
}
|
||||
return "", fmt.Errorf("no such image: %s", args[3])
|
||||
case len(args) >= 3 && args[0] == "bash" && args[1] == "-c":
|
||||
m := writeImgRe.FindStringSubmatch(args[2])
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("fake: unparseable write cmd %q", args[2])
|
||||
}
|
||||
f.imageFile = m[1]
|
||||
return "", nil
|
||||
case len(args) >= 3 && args[0] == "systemctl" && args[1] == "restart":
|
||||
if f.failRestart {
|
||||
return "", fmt.Errorf("fake: systemctl restart failed")
|
||||
@@ -70,17 +61,50 @@ 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) {
|
||||
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))
|
||||
}
|
||||
|
||||
// wrote reports whether the image was written via the stdin `tee` vector with the exact `image\n`
|
||||
// bytes (byte-identical to the golden's printf '%s\n').
|
||||
func (f *fakeGuestExec) wrote(image string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.calls {
|
||||
if len(c) >= 3 && c[0] == "bash" && strings.Contains(c[2], "'"+image+"'") {
|
||||
for _, w := range f.teeStdin {
|
||||
if w == image+"\n" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// usedShell reports whether ANY exec used a shell vector (bash/-c/printf/sh) — the thing the swap
|
||||
// rewrite removes. A regression to `bash -c "printf … >"` would make this true.
|
||||
func (f *fakeGuestExec) usedShell() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.calls {
|
||||
for _, a := range c {
|
||||
if a == "bash" || a == "sh" || a == "-c" || strings.HasPrefix(a, "printf") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newTestSwapper(t *testing.T, fe *fakeGuestExec) *ControllerSwapper {
|
||||
t.Helper()
|
||||
s := NewControllerSwapper(fe, t.TempDir(), nil)
|
||||
@@ -122,6 +146,38 @@ 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) {
|
||||
fe := &fakeGuestExec{
|
||||
imageFile: prevImg,
|
||||
present: map[string]bool{newImg: true},
|
||||
good: map[string]bool{newImg: true, prevImg: true},
|
||||
}
|
||||
s := newTestSwapper(t, fe)
|
||||
if st := s.Swap(context.Background(), 9201, newImg); st.State != "done" {
|
||||
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)
|
||||
}
|
||||
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 !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)
|
||||
}
|
||||
}
|
||||
|
||||
// The load-bearing failure path: an unhealthy target must roll back to the previous image.
|
||||
func TestControllerSwap_RollbackOnUnhealthy(t *testing.T) {
|
||||
fe := &fakeGuestExec{
|
||||
|
||||
@@ -3,6 +3,7 @@ package localapi
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -124,3 +125,15 @@ 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...)
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("pct exec %d %v: %w: %s", vmid, args, err, strings.TrimSpace(string(stderr)))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user